Add account filtering and range-aware tray activity widgets

This commit is contained in:
musistudio
2026-08-25 19:06:24 +08:00
parent f2360ac046
commit a8d7038778
15 changed files with 414 additions and 51 deletions
+11
View File
@@ -1288,13 +1288,24 @@ function parseTrayWidget(value: unknown): TrayWidgetConfig | undefined {
return undefined;
}
const variant = parseTrayWidgetVariant(type, value.variant);
const accountProviders = type === "account" ? parseTrayWidgetAccountProviders(value) : [];
return {
...(accountProviders.length === 1 ? { accountProvider: accountProviders[0] } : {}),
...(accountProviders.length > 0 ? { accountProviders } : {}),
id: readString(value.id) || trayWidgetId(type),
type,
...(variant ? { variant } : {})
};
}
function parseTrayWidgetAccountProviders(value: Record<string, unknown>): string[] {
const accountProvider = readString(value.accountProvider);
return uniqueStrings([
...parseStringList(value.accountProviders),
...(accountProvider ? [accountProvider] : [])
]);
}
function parseTrayWidgetType(value: unknown): TrayWidgetType | undefined {
return parseEnumValue(value, ["account", "activity", "header", "model-share", "rings", "source-tabs", "stats", "token-flow", "token-mix"], undefined);
}
+2
View File
@@ -1429,6 +1429,8 @@ export const TRAY_SINGLETON_WIDGET_TYPES = ["source-tabs", "header"] as const sa
export const TRAY_TOP_WIDGET_TYPES = ["source-tabs", "header"] as const satisfies readonly TrayWidgetType[];
export type TrayWidgetConfig = {
accountProvider?: string;
accountProviders?: string[];
id: string;
type: TrayWidgetType;
variant?: TrayWidgetVariant;
+48 -4
View File
@@ -1,4 +1,4 @@
import type { UsageSeriesPoint } from "@ccr/core/contracts/app";
import type { UsageSeriesPoint, UsageStatsRange } from "@ccr/core/contracts/app";
export type TokenActivityCell = {
date: Date;
@@ -32,6 +32,8 @@ export type TokenActivitySummary = {
type TokenActivityOptions = {
maxWeeks?: number;
minWeeks?: number;
now?: Date | string;
range?: UsageStatsRange;
};
const dayMs = 24 * 60 * 60 * 1000;
@@ -53,8 +55,9 @@ export function buildTokenActivity(series: UsageSeriesPoint[], options: TokenAct
}
const today = startOfLocalDay(new Date());
observedStart = observedStart ?? today;
observedEnd = observedEnd ?? today;
const rangeWindow = activityRangeWindow(options.range, options.now);
observedStart = rangeWindow?.start ?? observedStart ?? today;
observedEnd = rangeWindow?.end ?? observedEnd ?? today;
let gridStart = startOfActivityWeek(observedStart);
const gridEnd = endOfActivityWeek(observedEnd);
@@ -74,7 +77,7 @@ export function buildTokenActivity(series: UsageSeriesPoint[], options: TokenAct
const dayCount = Math.max(1, daysBetween(observedStart, observedEnd) + 1);
const totalTokens = sumObservedTokens(totalsByDay, observedStart, observedEnd);
const maxTokens = Math.max(...Array.from(totalsByDay.values()), 0);
const maxTokens = maxObservedTokens(totalsByDay, observedStart, observedEnd);
const cells: TokenActivityCell[] = [];
for (let weekIndex = 0; weekIndex < weekCount; weekIndex += 1) {
@@ -127,6 +130,39 @@ function parseActivityDate(bucket: string): Date {
return date;
}
function activityRangeWindow(range: UsageStatsRange | undefined, nowInput: Date | string | undefined): { end: Date; start: Date } | undefined {
if (!range) {
return undefined;
}
const now = normalizeActivityNow(nowInput);
if (!isFiniteDate(now)) {
return undefined;
}
const end = startOfLocalDay(now);
if (range === "today") {
return { end, start: end };
}
if (range === "24h") {
const start = new Date(now);
start.setHours(start.getHours() - 24);
return { end, start: startOfLocalDay(start) };
}
return {
end,
start: addDays(end, range === "7d" ? -6 : -29)
};
}
function normalizeActivityNow(value: Date | string | undefined): Date {
if (value instanceof Date) {
return value;
}
if (typeof value === "string" && value.trim()) {
return new Date(value);
}
return new Date();
}
export function activityDateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
@@ -174,6 +210,14 @@ function sumObservedTokens(totalsByDay: Map<string, number>, start: Date, end: D
return total;
}
function maxObservedTokens(totalsByDay: Map<string, number>, start: Date, end: Date): number {
let max = 0;
walkDays(start, end, (date) => {
max = Math.max(max, totalsByDay.get(activityDateKey(date)) ?? 0);
});
return max;
}
function countObservedDays(totalsByDay: Map<string, number>, start: Date, end: Date, predicate: (value: number) => boolean): number {
let count = 0;
walkDays(start, end, (date) => {
@@ -2,7 +2,7 @@ import {
Activity, AppConfig, AppCopy, AppInfo, AppLanguagePreference, Boxes, BotGatewayConfigDraft, botGatewayAuthSpecsForPlatform,
botGatewayDefaultAuthType, botGatewayFieldsForAuth, botGatewayPickAuthFields, botGatewayPlatformLabel, botGatewayPlatformOptions,
botGatewaySavedConfigFromDraft, BotGatewayQrLoginStartResult, BotGatewayQrLoginWaitResult, BotGatewayQrWindowOpenResult, BotGatewaySavedConfig, Button,
CircleAlert, closestCenter, cn, CSS, Database, Dialog, DialogBody, DialogContent,
Checkbox, ChevronDown, CircleAlert, closestCenter, cn, compareProviderAccountSnapshots, CSS, Database, Dialog, DialogBody, DialogContent,
DialogFooter, DialogHeader, DialogTitle, endpointFromHostPort, Field, formatAppError, formatProviderAccountMeterValue, formatSystemOption, Gauge,
Globe,
createBotGatewayConfigDraft, createMcpServerDraft, createMcpServerDraftFromConfig, createMcpServerDraftFromUnknown, DndContext, DragEndEvent, GatewayMcpServerConfig, GatewayProviderConfig, Input, isBotGatewayConfigDraftSubmittable, KeyboardSensor, KeyRound, KeyValueRowsControl, languageDisplayName, Layers3, LoaderCircle,
@@ -11,7 +11,7 @@ import {
PointerSensor, rectSortingStrategy, Settings, SettingsPageId, SortableContext, sortableKeyboardCoordinates, themeDisplayName,
translateOptions, TrayBalanceProgressConfig, TrayComponentVariants, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant,
appLogoUrl, trayMascotIconUrls, arrayMove, defaultTrayWidgetVariant, isTraySingletonWidgetType, normalizeTrayWidget, normalizeTrayWidgets, Switch, Textarea, Trash2, trayWidgetVariantOptions, useAppText, useEffect, useMemo, useRef, useSensor, useSensors, useSortable, useState, validateMcpServerDraft,
X
providerAccountSnapshotKey, providerAccountSnapshotLabel, uniqueStrings, X
} from "../shared/index";
import { ModelSelector } from "./model-selector";
@@ -1921,6 +1921,11 @@ function TraySettingsPage({
const selectedCategory = selectedWidget ? trayComponentCategoryForType(selectedWidget.type) : "provider-tabs";
const selectedCategoryOption = paletteItems.find((item) => item.value === selectedCategory) ?? paletteItems[0];
const selectedStyleOptions = selectedWidget ? trayWidgetVariantOptions(selectedWidget.type) : [];
const selectedAccountProviderValues = selectedWidget ? trayWidgetAccountProviderValues(selectedWidget) : [];
const accountDataOptions = useMemo(
() => trayAccountDataOptions(providerAccountSnapshots, selectedAccountProviderValues),
[providerAccountSnapshots, selectedAccountProviderValues]
);
const SelectedTrayCategoryIcon = selectedCategoryOption.icon;
const trayPreviewSensors = useSensors(
useSensor(PointerSensor, {
@@ -2015,6 +2020,16 @@ function TraySettingsPage({
updateTrayWidget(selectedWidget.id, { variant });
}
function changeTrayWidgetAccountProviders(accountProviders: string[]) {
if (!selectedWidget || selectedWidget.type !== "account") {
return;
}
updateTrayWidget(selectedWidget.id, {
accountProvider: accountProviders.length === 1 ? accountProviders[0] : undefined,
accountProviders: accountProviders.length > 0 ? accountProviders : undefined
});
}
function removeSelectedTrayWidget() {
if (!selectedWidget || selectedWidgetIndex < 0) {
return;
@@ -2220,6 +2235,16 @@ function TraySettingsPage({
</Field>
) : null}
{selectedWidget.type === "account" ? (
<Field label={trayT("Accounts")}>
<TrayAccountDataSelector
options={accountDataOptions}
value={selectedAccountProviderValues}
onChange={changeTrayWidgetAccountProviders}
/>
</Field>
) : null}
<Button className="w-full justify-center" onClick={removeSelectedTrayWidget} size="sm" type="button" variant="outline">
{trayT("Remove widget")}
</Button>
@@ -2356,6 +2381,129 @@ function uniqueTrayWidgetId(widgets: TrayWidgetConfig[], baseId: string): string
return `${baseId}-${index}`;
}
function trayWidgetAccountProviderValues(widget: TrayWidgetConfig): string[] {
return uniqueStrings([
...(widget.accountProviders ?? []),
...(widget.accountProvider ? [widget.accountProvider] : [])
]);
}
function trayAccountDataOptions(
providerAccountSnapshots: ProviderAccountSnapshot[],
selectedValues: string[]
): Array<{ label: string; value: string }> {
const options = providerAccountSnapshots
.filter((account) => account.provider)
.sort(compareProviderAccountSnapshots)
.map((account) => ({ label: providerAccountSnapshotLabel(account), value: providerAccountSnapshotKey(account) }));
for (const selectedValue of selectedValues) {
if (!options.some((option) => option.value === selectedValue)) {
options.push({ label: selectedValue, value: selectedValue });
}
}
return [{ label: "All accounts", value: "" }, ...options];
}
function TrayAccountDataSelector({
onChange,
options,
value
}: {
onChange: (value: string[]) => void;
options: Array<{ label: string; value: string }>;
value: string[];
}) {
const t = useAppText();
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const selected = new Set(value);
const accountOptions = options.filter((option) => option.value);
const allSelected = selected.size === 0;
const selectedLabels = accountOptions
.filter((option) => selected.has(option.value))
.map((option) => option.label);
const summary = allSelected
? t("All accounts")
: selected.size === 1
? selectedLabels[0] ?? value[0] ?? t("Select account")
: `${selected.size} ${t("accounts selected")}`;
useEffect(() => {
if (!open) {
return;
}
const closeOnOutsidePointer = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && rootRef.current?.contains(target)) {
return;
}
setOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("pointerdown", closeOnOutsidePointer);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("pointerdown", closeOnOutsidePointer);
document.removeEventListener("keydown", closeOnEscape);
};
}, [open]);
function toggleAccount(account: string, checked: boolean) {
const next = new Set(selected);
if (checked) {
next.add(account);
} else {
next.delete(account);
}
onChange([...next]);
}
return (
<div className="relative min-w-0" ref={rootRef}>
<button
aria-expanded={open}
aria-haspopup="listbox"
className="flex h-8 w-full min-w-0 items-center gap-2 rounded-md border border-input bg-background px-3 text-left text-[12px] text-foreground shadow-[inset_0_1px_1px_rgba(0,0,0,0.03)] outline-none transition-[background-color,border-color,box-shadow,color] hover:border-muted-foreground/45 focus:border-primary/60 focus:ring-2 focus:ring-ring/25"
onClick={() => setOpen((current) => !current)}
type="button"
>
<span className="min-w-0 flex-1 truncate">{summary}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
{open ? (
<div
aria-multiselectable="true"
className="absolute left-0 top-[calc(100%+4px)] z-50 max-h-56 w-full min-w-[220px] overflow-y-auto rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-card-elevated"
role="listbox"
>
<label className="flex min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-[12px] font-medium transition-colors hover:bg-muted/50">
<Checkbox checked={allSelected} onCheckedChange={(checked) => checked ? onChange([]) : undefined} />
<span className="min-w-0 flex-1 truncate">{t("All accounts")}</span>
</label>
<div className="my-1 h-px bg-border/70" />
{accountOptions.length > 0 ? (
accountOptions.map((option) => (
<label className="flex min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-[12px] transition-colors hover:bg-muted/50" key={option.value} role="option" aria-selected={selected.has(option.value)}>
<Checkbox
checked={selected.has(option.value)}
onCheckedChange={(checked) => toggleAccount(option.value, checked)}
/>
<span className="min-w-0 flex-1 truncate">{option.label}</span>
</label>
))
) : (
<div className="px-2 py-1.5 text-[12px] text-muted-foreground">{t("No account data configured")}</div>
)}
</div>
) : null}
</div>
);
}
function TrayIconSelect({
onChange,
options,
@@ -181,13 +181,24 @@ export function normalizeTrayWidget(value: unknown): TrayWidgetConfig | undefine
return undefined;
}
const variant = normalizeTrayWidgetVariant(type, value.variant);
const accountProviders = type === "account" ? normalizeTrayWidgetAccountProviders(value) : [];
return {
...(accountProviders.length === 1 ? { accountProvider: accountProviders[0] } : {}),
...(accountProviders.length > 0 ? { accountProviders } : {}),
id: stringValue(value.id) || trayWidgetId(type),
type,
...(variant ? { variant } : {})
};
}
export function normalizeTrayWidgetAccountProviders(value: Record<string, unknown>): string[] {
const accountProvider = stringValue(value.accountProvider);
return uniqueStrings([
...overviewAccountProviderListValue(value.accountProviders),
...(accountProvider ? [accountProvider] : [])
]);
}
export function normalizeTrayWidgetType(value: unknown): TrayWidgetType | undefined {
return typeof value === "string" && ["account", "activity", "header", "model-share", "rings", "source-tabs", "stats", "token-flow", "token-mix"].includes(value)
? value as TrayWidgetType
@@ -484,6 +484,8 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Account Balance": "Account Balance",
"Account Usage": "Account Usage",
"Account component": "Account component",
"Accounts": "Accounts",
"accounts selected": "accounts selected",
"All accounts": "All accounts",
"All credentials": "All credentials",
"Last updated": "Last updated",
@@ -530,6 +532,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Move down": "Move down",
"Move up": "Move up",
"Nested rings": "Nested rings",
"No account data configured": "No account data configured",
"No model activity": "No model activity",
"No widget selected": "No widget selected",
"No widgets configured": "No widgets configured",
@@ -1520,6 +1523,8 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Startup timeout ms": "启动超时 ms",
"State directory": "状态目录",
"Account component": "账户组件",
"Accounts": "账户",
"accounts selected": "个账户已选择",
"All accounts": "所有账户",
"All credentials": "全部凭据",
"Last updated": "上次更新",
@@ -1567,6 +1572,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Model Distribution": "模型分布",
"Model distribution": "模型分布",
"Nested rings": "内外圆",
"No account data configured": "未配置账户数据",
"No widget selected": "未选择组件",
"No widgets configured": "未配置组件",
"Overview layout": "概览布局",
+10 -2
View File
@@ -218,7 +218,7 @@ function TrayRuntimeWidget({
}
if (widget.type === "account") {
return <AccountSummaryPanel refreshing={accountRefreshing} snapshots={accountSnapshots} variant={(widget.variant ?? defaultTrayWidgetVariant("account")) as TrayComponentVariants["account"]} onRefresh={onRefreshAccount} />;
return <AccountSummaryPanel accountProviders={trayWidgetAccountProviderValues(widget)} refreshing={accountRefreshing} snapshots={accountSnapshots} variant={(widget.variant ?? defaultTrayWidgetVariant("account")) as TrayComponentVariants["account"]} onRefresh={onRefreshAccount} />;
}
if (widget.type === "token-flow") {
@@ -230,7 +230,7 @@ function TrayRuntimeWidget({
}
if (widget.type === "activity") {
return <TokenActivityPanel series={activeStats.series} />;
return <TokenActivityPanel generatedAt={activeStats.generatedAt} range={activeStats.range} series={activeStats.series} />;
}
if (widget.type === "stats") {
@@ -258,6 +258,14 @@ function TrayRuntimeWidget({
return <ModelShareChart rows={activeStats.models} variant={(widget.variant ?? defaultTrayWidgetVariant("model-share")) as TrayComponentVariants["modelShare"]} />;
}
function trayWidgetAccountProviderValues(widget: TrayWidgetConfig): string[] | undefined {
const values = [
...(widget.accountProviders ?? []),
...(widget.accountProvider ? [widget.accountProvider] : [])
].map((value) => value.trim()).filter(Boolean);
return values.length > 0 ? Array.from(new Set(values)) : undefined;
}
function TrayHeaderRangeSwitch({
range,
onChange
@@ -1,28 +1,27 @@
import {
accountMetersForDisplay, accountProgressClass, accountProgressColor, accountSnapshotLabel, compareAccountSnapshots, formatAccountMeterTitle, formatAccountMeterValue,
accountMetersForDisplay, accountProgressClass, accountProgressColor, accountSnapshotKey, accountSnapshotLabel, compareAccountSnapshots, formatAccountMeterTitle, formatAccountMeterValue,
LoaderCircle, meterProgress, meterRemainingRatio, meterValidityProgress, ProviderAccountMeter, ProviderAccountSnapshot, RefreshCw, TrayComponentVariants,
useTrayText
} from "../shared";
import { RadialMetric } from "./widgets";
export function AccountSummaryPanel({
accountProviders,
onRefresh,
refreshing = false,
snapshots,
variant
}: {
accountProviders?: string[];
onRefresh?: () => void | Promise<void>;
refreshing?: boolean;
snapshots: ProviderAccountSnapshot[];
variant: TrayComponentVariants["account"];
}) {
const t = useTrayText();
const snapshot = snapshots
.filter((snapshot) => snapshot.meters.length > 0 || snapshot.status === "error")
.sort(compareAccountSnapshots)
[0];
const selectedSnapshots = accountSnapshotsForDisplay(snapshots, accountProviders);
if (!snapshot) {
if (selectedSnapshots.length === 0) {
return (
<div className="tray-panel-subtle px-3 py-2 text-[11px] font-medium text-slate-400">
{t("No account data configured")}
@@ -30,15 +29,22 @@ export function AccountSummaryPanel({
);
}
const meters = accountMetersForDisplay(snapshot, variant === "stacked" ? 3 : 2);
const title = selectedSnapshots.length === 1
? accountSnapshotLabel(selectedSnapshots[0])
: t("Account");
return (
<div className="tray-panel p-2.5">
<div className="mb-2 flex min-w-0 items-center justify-between gap-2">
<h3 className="truncate text-[11px] font-bold text-slate-100">{accountSnapshotLabel(snapshot)}</h3>
<div className="min-w-0">
<h3 className="truncate text-[11px] font-bold text-slate-100">{title}</h3>
{selectedSnapshots.length > 1 ? (
<div className="mt-0.5 truncate text-[9px] font-medium text-slate-400">{selectedSnapshots.length} {t("accounts selected")}</div>
) : null}
</div>
<button
aria-label={t("Refresh")}
className={`inline-flex h-5 w-5 shrink-0 appearance-none items-center justify-center rounded-md border-0 bg-transparent p-0 shadow-none transition-colors hover:bg-white/[.07] disabled:cursor-not-allowed disabled:opacity-50 ${accountStatusButtonClass(snapshot.status)}`}
className={`inline-flex h-5 w-5 shrink-0 appearance-none items-center justify-center rounded-md border-0 bg-transparent p-0 shadow-none transition-colors hover:bg-white/[.07] disabled:cursor-not-allowed disabled:opacity-50 ${accountStatusButtonClass(highestAccountStatus(selectedSnapshots))}`}
disabled={refreshing || !onRefresh}
onClick={() => {
void onRefresh?.();
@@ -49,6 +55,50 @@ export function AccountSummaryPanel({
{refreshing ? <LoaderCircle className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
</button>
</div>
<div className={selectedSnapshots.length > 1 ? "space-y-2" : ""}>
{selectedSnapshots.map((snapshot) => (
<AccountSnapshotBlock
key={accountSnapshotKey(snapshot)}
showLabel={selectedSnapshots.length > 1}
snapshot={snapshot}
variant={variant}
/>
))}
</div>
</div>
);
}
function accountSnapshotsForDisplay(
snapshots: ProviderAccountSnapshot[],
accountProviders: string[] | undefined
): ProviderAccountSnapshot[] {
const selected = new Set((accountProviders ?? []).map((provider) => provider.trim()).filter(Boolean));
return snapshots
.filter((snapshot) => snapshot.meters.length > 0 || snapshot.status === "error")
.filter((snapshot) => selected.size === 0 || selected.has(accountSnapshotKey(snapshot)) || selected.has(snapshot.provider))
.sort(compareAccountSnapshots);
}
function highestAccountStatus(snapshots: ProviderAccountSnapshot[]): ProviderAccountSnapshot["status"] {
return [...snapshots].sort(compareAccountSnapshots)[0]?.status ?? "unsupported";
}
function AccountSnapshotBlock({
showLabel,
snapshot,
variant
}: {
showLabel: boolean;
snapshot: ProviderAccountSnapshot;
variant: TrayComponentVariants["account"];
}) {
const t = useTrayText();
const meters = accountMetersForDisplay(snapshot, variant === "stacked" ? 3 : 2);
return (
<div className={showLabel ? "border-t border-white/10 pt-2 first:border-t-0 first:pt-0" : undefined}>
{showLabel ? <div className="mb-1.5 truncate text-[10px] font-semibold text-slate-100">{accountSnapshotLabel(snapshot)}</div> : null}
{meters.length > 0 ? (
<AccountMeters meters={meters} status={snapshot.status} variant={variant} />
) : (
@@ -47,7 +47,7 @@ export function UsageOverviewPanel({
</ChartShell>
) : null}
{modules.has("activity") ? <TokenActivityPanel series={activeStats.series} /> : null}
{modules.has("activity") ? <TokenActivityPanel generatedAt={activeStats.generatedAt} range={activeStats.range} series={activeStats.series} /> : null}
{modules.has("stats") ? (
<StatsGrid
@@ -61,7 +61,7 @@ export function UsageDetailPanel({
);
}
if (widget.type === "account") {
return <AccountSummaryPanel key={`${widget.id}-${index}`} refreshing={accountRefreshing} snapshots={accountSnapshots} variant={(widget.variant ?? defaultTrayWidgetVariant("account")) as TrayComponentVariants["account"]} onRefresh={onRefreshAccount} />;
return <AccountSummaryPanel accountProviders={trayWidgetAccountProviderValues(widget)} key={`${widget.id}-${index}`} refreshing={accountRefreshing} snapshots={accountSnapshots} variant={(widget.variant ?? defaultTrayWidgetVariant("account")) as TrayComponentVariants["account"]} onRefresh={onRefreshAccount} />;
}
if (widget.type === "token-flow") {
return (
@@ -71,7 +71,7 @@ export function UsageDetailPanel({
);
}
if (widget.type === "activity") {
return <TokenActivityPanel key={`${widget.id}-${index}`} series={activeStats.series} />;
return <TokenActivityPanel generatedAt={activeStats.generatedAt} key={`${widget.id}-${index}`} range={activeStats.range} series={activeStats.series} />;
}
if (widget.type === "token-mix") {
return <TokenMixPanel key={`${widget.id}-${index}`} totals={totals} variant={(widget.variant ?? defaultTrayWidgetVariant("token-mix")) as TrayComponentVariants["tokenMix"]} />;
@@ -90,3 +90,11 @@ export function UsageDetailPanel({
</>
);
}
function trayWidgetAccountProviderValues(widget: TrayWidgetConfig): string[] | undefined {
const values = [
...(widget.accountProviders ?? []),
...(widget.accountProvider ? [widget.accountProvider] : [])
].map((value) => value.trim()).filter(Boolean);
return values.length > 0 ? Array.from(new Set(values)) : undefined;
}
@@ -144,12 +144,16 @@ export function AnimatedUsageChart({
}
export function TokenActivityPanel({
generatedAt,
range,
series
}: {
generatedAt?: string;
range?: UsageStatsRange;
series: UsageStatsSnapshot["series"];
}) {
const t = useTrayText();
const activity = buildTokenActivity(series, { maxWeeks: 14, minWeeks: 10 });
const activity = buildTokenActivity(series, { maxWeeks: 30, minWeeks: 30, now: generatedAt, range });
return (
<div className="tray-panel min-w-0 p-2.5">
@@ -211,25 +215,25 @@ function TokenActivityGrid({
const t = useTrayText();
const dayLabels = [t("M"), "", t("W"), "", t("F"), "", ""];
const cellGap = 3;
const cellSize = 9;
const labelColumnWidth = 14;
const gridTemplateColumns = `${labelColumnWidth}px repeat(${activity.weekCount}, minmax(0, 1fr))`;
return (
<div className="min-w-0 overflow-visible">
<div className="w-max">
<div className="w-full">
<div
className="mb-1 grid text-[8px] font-medium text-slate-500"
style={{
columnGap: `${cellGap}px`,
gridTemplateColumns: `repeat(${activity.weekCount}, ${cellSize}px)`,
marginLeft: `${labelColumnWidth + cellGap}px`
gridTemplateColumns
}}
>
<span aria-hidden="true" />
{activity.months.map((month) => (
<span
className="truncate"
key={`${month.label}-${month.weekIndex}`}
style={{ gridColumn: `${month.weekIndex + 1} / span ${Math.min(3, activity.weekCount - month.weekIndex)}` }}
style={{ gridColumn: `${month.weekIndex + 2} / span ${Math.min(3, activity.weekCount - month.weekIndex)}` }}
>
{month.label}
</span>
@@ -241,8 +245,8 @@ function TokenActivityGrid({
aria-label={`${t("Activity")} ${t("Tokens")}`}
style={{
gap: `${cellGap}px`,
gridTemplateColumns: `${labelColumnWidth}px repeat(${activity.weekCount}, ${cellSize}px)`,
gridTemplateRows: `repeat(7, ${cellSize}px)`
gridTemplateColumns,
gridTemplateRows: "repeat(7, auto)"
}}
>
{dayLabels.map((label, index) => (
@@ -254,27 +258,27 @@ function TokenActivityGrid({
{label}
</span>
))}
{activity.cells.map((cell) => (
<Tooltip
aria-label={`${cell.dateLabel}: ${formatActivityTokenCount(cell.totalTokens)} ${t("tokens")}`}
align={cell.weekIndex <= 1 ? "start" : cell.weekIndex >= activity.weekCount - 2 ? "end" : "center"}
className="rounded-[3px]"
content={(
<>
<span className="block font-bold">{cell.dateLabel}</span>
<span className="tray-activity-tooltip-detail mt-0.5 block font-medium">{formatActivityTokenCount(cell.totalTokens)} {t("tokens")}</span>
</>
)}
contentClassName="tray-activity-tooltip min-w-[96px] px-2 py-1.5 text-left text-[10px] font-normal"
key={cell.dateKey}
side={cell.dayIndex <= 1 ? "bottom" : "top"}
style={{
backgroundColor: trayActivityColor(cell.intensity, cell.inObservedRange),
gridColumn: cell.weekIndex + 2,
gridRow: cell.dayIndex + 1
}}
/>
))}
{activity.cells.map((cell) => (
<Tooltip
aria-label={`${cell.dateLabel}: ${formatActivityTokenCount(cell.totalTokens)} ${t("tokens")}`}
align={cell.weekIndex <= 1 ? "start" : cell.weekIndex >= activity.weekCount - 2 ? "end" : "center"}
className="aspect-square w-full rounded-[3px]"
content={(
<>
<span className="block font-bold">{cell.dateLabel}</span>
<span className="tray-activity-tooltip-detail mt-0.5 block font-medium">{formatActivityTokenCount(cell.totalTokens)} {t("tokens")}</span>
</>
)}
contentClassName="tray-activity-tooltip min-w-[96px] px-2 py-1.5 text-left text-[10px] font-normal"
key={cell.dateKey}
side={cell.dayIndex <= 1 ? "bottom" : "top"}
style={{
backgroundColor: trayActivityColor(cell.intensity, cell.inObservedRange),
gridColumn: cell.weekIndex + 2,
gridRow: cell.dayIndex + 1
}}
/>
))}
</div>
</div>
</div>
+45
View File
@@ -58,7 +58,9 @@ export const trayText: Record<ResolvedLanguage, Record<string, string>> = {
"7d": "7 天",
"30d": "30 天",
"All": "全部",
"accounts selected": "个账户已选择",
"Account": "账户",
"Accounts": "账户",
"All providers": "全部供应商",
"Activity": "活跃度",
"Avg / day": "日均",
@@ -390,13 +392,24 @@ export function normalizeTrayWidget(value: unknown): TrayWidgetConfig | undefine
return undefined;
}
const variant = normalizeTrayWidgetVariant(type, value.variant);
const accountProviders = type === "account" ? normalizeTrayWidgetAccountProviders(value) : [];
return {
...(accountProviders.length === 1 ? { accountProvider: accountProviders[0] } : {}),
...(accountProviders.length > 0 ? { accountProviders } : {}),
id: typeof value.id === "string" && value.id.trim() ? value.id.trim() : trayWidgetId(type),
type,
...(variant ? { variant } : {})
};
}
export function normalizeTrayWidgetAccountProviders(value: Record<string, unknown>): string[] {
const accountProvider = stringValue(value.accountProvider);
return uniqueTrayStrings([
...trayStringListValue(value.accountProviders),
...(accountProvider ? [accountProvider] : [])
]);
}
export function normalizeTrayWidgetType(value: unknown): TrayWidgetType | undefined {
return typeof value === "string" && ["account", "activity", "header", "model-share", "rings", "source-tabs", "stats", "token-flow", "token-mix"].includes(value)
? value as TrayWidgetType
@@ -527,6 +540,34 @@ export function isObjectRecord(value: unknown): value is Record<string, unknown>
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function trayStringListValue(value: unknown): string[] {
if (Array.isArray(value)) {
return uniqueTrayStrings(value.map((item) => stringValue(item)).filter((item): item is string => Boolean(item)));
}
if (typeof value === "string") {
return uniqueTrayStrings(value.split(/\r?\n|,/g).map((item) => item.trim()).filter(Boolean));
}
return [];
}
function uniqueTrayStrings(values: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const item = value.trim();
if (!item || seen.has(item)) {
continue;
}
seen.add(item);
result.push(item);
}
return result;
}
export function normalizeTrayIconPreference(value: AppConfig["trayIcon"] | undefined): AppConfig["trayIcon"] {
return value === "violet" || value === "orange" || value === "cyan" || value === "progress" || value === "random"
? value
@@ -741,6 +782,10 @@ export function accountSnapshotLabel(snapshot: ProviderAccountSnapshot): string
return credential ? `${snapshot.provider} / ${credential}` : snapshot.provider;
}
export function accountSnapshotKey(snapshot: ProviderAccountSnapshot): string {
return snapshot.credentialId ? `${snapshot.provider}::${snapshot.credentialId}` : snapshot.provider;
}
export function accountSnapshotCredentialLabel(snapshot: ProviderAccountSnapshot): string {
return snapshot.credentialLabel?.trim() || snapshot.credentialId?.trim() || "";
}
@@ -167,12 +167,18 @@ test("AccountSummaryPanel covers empty and metered account states", () => {
const meteredHtml = renderToStaticMarkup(
<AccountSummaryPanel snapshots={accountSnapshots()} variant="stacked" onRefresh={() => undefined} />
);
const selectedHtml = renderToStaticMarkup(
<AccountSummaryPanel accountProviders={["anthropic::secondary"]} snapshots={accountSnapshots()} variant="compact" />
);
assert.match(emptyHtml, /No account data configured/);
assert.match(meteredHtml, /openai \/ Primary Key/);
assert.match(meteredHtml, /anthropic \/ Secondary Key/);
assert.match(meteredHtml, /5h quota/);
assert.match(meteredHtml, /42 requests/);
assert.match(meteredHtml, /style="\s*width:42%"/);
assert.match(selectedHtml, /anthropic \/ Secondary Key/);
assert.doesNotMatch(selectedHtml, /openai \/ Primary Key/);
});
test("AccountSummaryPanel prioritizes Codex manual reset meter with expiration", () => {
@@ -297,6 +303,7 @@ test("TokenActivityPanel renders summary, grid, and legend", () => {
assert.match(html, /Longest streak/);
assert.match(html, /aria-label="Activity Tokens"/);
assert.match(html, /data-ui-tooltip-trigger/);
assert.equal(html.match(/data-ui-tooltip-trigger/g)?.length, 210);
assert.doesNotMatch(html, /tray-activity-tooltip/);
assert.doesNotMatch(html, /bg-slate-950/);
assert.match(html, /Less/);
+2 -2
View File
@@ -39,7 +39,7 @@ test("tray component variants retain valid values and fall back independently",
test("tray widgets normalize ids and variants, pin top widgets, and dedupe singletons", () => {
const widgets = normalizeTrayWidgets([
{ id: " custom-account ", type: "account", variant: "arc" },
{ accountProvider: "openai::primary", accountProviders: ["anthropic::secondary", "openai::primary", " "], id: " custom-account ", type: "account", variant: "arc" },
{ id: "", type: "header", variant: "ignored" },
{ id: "duplicate-header", type: "header" },
{ type: "source-tabs" },
@@ -51,7 +51,7 @@ test("tray widgets normalize ids and variants, pin top widgets, and dedupe singl
assert.deepEqual(widgets, [
{ id: "header", type: "header" },
{ id: "source-tabs", type: "source-tabs" },
{ id: "custom-account", type: "account", variant: "arc" },
{ accountProviders: ["anthropic::secondary", "openai::primary"], id: "custom-account", type: "account", variant: "arc" },
{ id: "invalid-variant", type: "stats", variant: DEFAULT_TRAY_COMPONENT_VARIANTS.stats }
]);
});
@@ -35,6 +35,25 @@ test("activityDateKey formats local calendar dates", () => {
assert.equal(activityDateKey(new Date(2026, 0, 5, 14, 30)), "2026-01-05");
});
test("buildTokenActivity averages sparse data over the selected range window", () => {
withTimezone("America/New_York", () => {
const summary = buildTokenActivity(
[
{ bucket: "2026-06-29", totalTokens: 10 },
{ bucket: "2026-06-30", totalTokens: 30 }
],
{ now: "2026-06-30T12:00:00.000Z", range: "30d" }
);
assert.equal(summary.totalTokens, 40);
assert.equal(summary.activeDays, 2);
assert.equal(summary.dayCount, 30);
assert.equal(summary.longestStreak, 2);
assert.equal(Math.round(summary.avgPerDay * 100) / 100, 1.33);
assert.equal(Math.round(summary.avgPerWeek * 100) / 100, 9.33);
});
});
function withTimezone(timezone, run) {
const previousTimezone = process.env.TZ;
process.env.TZ = timezone;