@morlay/ui-conversation-manager 0.0.2-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,801 @@
1
+ // 「对话管理」页面:已归档会话的搜索、取消归档、导出、删除,以及导入为新会话与孤儿数据 GC。
2
+ // 数据面只读框架标准座位(useSessions / useWorkspaces),动作面只读注入面。
3
+ import { useMemo, useRef, useState, type ReactNode } from "react";
4
+ import {
5
+ Button,
6
+ Checkbox,
7
+ IconSearchOutline16,
8
+ Input,
9
+ Modal,
10
+ Tag,
11
+ relativeTime,
12
+ } from "@deepseek-ai/dsh-client-ui-primitives";
13
+ import { styling } from "@morlay/dsh-client-ui-primitives/client";
14
+ import type { InjectFace, PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots";
15
+ import type { SessionId } from "@deepseek-ai/dsh-session";
16
+ import {
17
+ ConversationManagerRequestError,
18
+ type ConversationManagerFace,
19
+ type SessionUsageReport,
20
+ type UsageBucket,
21
+ type UsageRangeKey,
22
+ type UsageTotals,
23
+ } from "./controller.ts";
24
+ import { formatPercent, formatTokens } from "./format.ts";
25
+ import { styles } from "./ConversationManagerPage.styles.ts";
26
+
27
+ /** 一页的行数(会话列表)。 */
28
+ const PAGE_SIZE = 20;
29
+
30
+ /** 统计视图按会话列出时的行数上限。 */
31
+ const USAGE_SESSION_ROWS = 20;
32
+
33
+ /** 页面 props:main 座位的运行时份额 + 本包字典 + 注入的动作。 */
34
+ export type ConversationManagerPageProps = PropsRuntime<"main"> &
35
+ PropsLocale<"conversationManager"> &
36
+ InjectFace<ConversationManagerFace>;
37
+
38
+ type Translate = ConversationManagerPageProps["t"];
39
+
40
+ /** GC 的三段状态:确认 → 运行(阻塞界面)→ 收尾。 */
41
+ type GcPhase = "idle" | "confirm" | "running";
42
+
43
+ interface ConversationRow {
44
+ id: SessionId;
45
+ title: string;
46
+ /** 所属工作区标题;不在任何工作区里的会话用未分组文案。 */
47
+ workspace: string;
48
+ /** 只有已归档的行允许取消归档与删除(host 侧同样守卫)。 */
49
+ archived: boolean;
50
+ /** 子代理派生会话:默认不显示(既不可删也不可取消归档)。 */
51
+ subagent: boolean;
52
+ updatedAt: number;
53
+ }
54
+
55
+ /** 行上显示的紧凑相对时间。 */
56
+ function timeLabel(updatedAt: number, now: number, t: Translate): string {
57
+ const { unit, n } = relativeTime(updatedAt, now);
58
+ return unit === "now" ? t("time.now") : t(`time.${unit}`, { n });
59
+ }
60
+
61
+ /** 标题或工作区名命中归一化后的查询。 */
62
+ function matches(row: ConversationRow, normalizedQuery: string): boolean {
63
+ return (
64
+ normalizedQuery.length === 0 ||
65
+ row.title.toLowerCase().includes(normalizedQuery) ||
66
+ row.workspace.toLowerCase().includes(normalizedQuery)
67
+ );
68
+ }
69
+
70
+ /** host 错误码 → 可读文案;没有码时保留原文。 */
71
+ function failureText(error: unknown, t: Translate): string {
72
+ const code = error instanceof ConversationManagerRequestError ? error.code : undefined;
73
+ if (code === "SESSION_NOT_ARCHIVED") return t("failure.notArchived");
74
+ if (code === "SESSION_LIVE") return t("failure.live");
75
+ if (code === "SESSION_NOT_FOUND") return t("failure.missing");
76
+ return t("failure.other", { reason: error instanceof Error ? error.message : String(error) });
77
+ }
78
+
79
+ export function ConversationManagerPage({
80
+ t,
81
+ useSessions,
82
+ useWorkspaces,
83
+ archive,
84
+ unarchive,
85
+ remove,
86
+ exportZip,
87
+ importZip,
88
+ collectGarbage,
89
+ loadUsage,
90
+ }: ConversationManagerPageProps): ReactNode {
91
+ const sessions = useSessions((state) => state);
92
+ const workspaces = useWorkspaces((state) => state);
93
+ const [query, setQuery] = useState("");
94
+ const [page, setPage] = useState(1);
95
+ const [showSubagents, setShowSubagents] = useState(false);
96
+ const [view, setView] = useState<PageView>("sessions");
97
+ const [usageTab, setUsageTab] = useState<UsageTab>("overview");
98
+ const [usageRange, setUsageRange] = useState<UsageRange>("day");
99
+ const [usage, setUsage] = useState<SessionUsageReport | null>(null);
100
+ const [usageLoading, setUsageLoading] = useState(false);
101
+ const [usageError, setUsageError] = useState<string | null>(null);
102
+ const [confirming, setConfirming] = useState<ConversationRow | null>(null);
103
+ const [gcPhase, setGcPhase] = useState<GcPhase>("idle");
104
+ const [importing, setImporting] = useState(false);
105
+ const [notice, setNotice] = useState<string | null>(null);
106
+ const [failure, setFailure] = useState<string | null>(null);
107
+ const fileRef = useRef<HTMLInputElement>(null);
108
+ const ungrouped = t("ungrouped");
109
+
110
+ // 全量会话:会话目录(ids)∪ 归档集,按最近活动在前;没有加载到 summary 的成员不产生行。
111
+ const rows = useMemo<ConversationRow[]>(() => {
112
+ const owners = new Map<string, string>();
113
+ for (const workspace of workspaces.items) {
114
+ for (const id of workspace.sessionIds) owners.set(id, workspace.title);
115
+ }
116
+ const archivedIds = new Set<SessionId>(workspaces.archivedSessionIds);
117
+ const ids = new Set<SessionId>([...sessions.ids, ...workspaces.archivedSessionIds]);
118
+ return [...ids]
119
+ .flatMap((id) => {
120
+ const summary = sessions.byId[id];
121
+ if (summary === undefined) return [];
122
+ return [
123
+ {
124
+ id,
125
+ title: summary.displayTitle,
126
+ workspace: owners.get(id) ?? ungrouped,
127
+ archived: archivedIds.has(id),
128
+ subagent: summary.origin === "subagent",
129
+ updatedAt: summary.updatedAt,
130
+ },
131
+ ];
132
+ })
133
+ .sort((left, right) => right.updatedAt - left.updatedAt);
134
+ }, [workspaces, sessions.ids, sessions.byId, ungrouped]);
135
+
136
+ // 一个动作的收尾:成败都收掉弹窗,失败把原因落到页面上的提示行。
137
+ const run = (action: Promise<unknown>, settle?: () => void): void => {
138
+ setFailure(null);
139
+ setNotice(null);
140
+ void action.then(
141
+ () => {
142
+ settle?.();
143
+ },
144
+ (error: unknown) => {
145
+ settle?.();
146
+ setFailure(failureText(error, t));
147
+ },
148
+ );
149
+ };
150
+
151
+ const startGc = (): void => {
152
+ setGcPhase("running");
153
+ setFailure(null);
154
+ setNotice(null);
155
+ void collectGarbage().then(
156
+ (result) => {
157
+ setGcPhase("idle");
158
+ setNotice(t("gc.done", { sessions: result.orphanSessions, events: result.orphanEvents }));
159
+ },
160
+ (error: unknown) => {
161
+ setGcPhase("idle");
162
+ setFailure(failureText(error, t));
163
+ },
164
+ );
165
+ };
166
+
167
+ // 统计是按时间范围在 host 侧聚合的:进入统计视图拉一次,换范围再拉一次;维度切换本地折叠。
168
+ const requestUsage = (range: UsageRange): void => {
169
+ setUsageRange(range);
170
+ setUsageLoading(true);
171
+ setUsageError(null);
172
+ void loadUsage(range)
173
+ .then(
174
+ (report) => {
175
+ setUsage(report);
176
+ },
177
+ (error: unknown) => {
178
+ setUsageError(failureText(error, t));
179
+ },
180
+ )
181
+ .finally(() => {
182
+ setUsageLoading(false);
183
+ });
184
+ };
185
+
186
+ const openUsage = (): void => {
187
+ setView("usage");
188
+ if (usage === null && !usageLoading) requestUsage(usageRange);
189
+ };
190
+
191
+ if (sessions.phase !== "ready") {
192
+ return <p {...styling.props(styles.status)}>{t("loading")}</p>;
193
+ }
194
+
195
+ const now = Date.now();
196
+ // 子代理派生会话默认不列:它们不可删也不可取消归档,只会淹没真实对话。
197
+ const listed = showSubagents ? rows : rows.filter((row) => !row.subagent);
198
+ const matched = listed.filter((row) => matches(row, query.trim().toLowerCase()));
199
+ const pageCount = Math.max(1, Math.ceil(matched.length / PAGE_SIZE));
200
+ const currentPage = Math.min(page, pageCount);
201
+ const visible = matched.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
202
+ const confirmed = confirming;
203
+
204
+ return (
205
+ <div {...styling.props(styles.page)} data-view={view}>
206
+ <div {...styling.props(styles.header)}>
207
+ <h1 {...styling.props(styles.title)}>{t("title")}</h1>
208
+ <div {...styling.props(styles.tabs)} role="tablist">
209
+ <button
210
+ type="button"
211
+ role="tab"
212
+ data-tab="sessions"
213
+ aria-selected={view === "sessions"}
214
+ className={styling.className(styles.tab, view === "sessions" && styles.tabActive)}
215
+ onClick={() => {
216
+ setView("sessions");
217
+ }}
218
+ >
219
+ {t("view.sessions")}
220
+ </button>
221
+ <button
222
+ type="button"
223
+ role="tab"
224
+ data-tab="usage"
225
+ aria-selected={view === "usage"}
226
+ className={styling.className(styles.tab, view === "usage" && styles.tabActive)}
227
+ onClick={openUsage}
228
+ >
229
+ {t("view.usage")}
230
+ </button>
231
+ </div>
232
+ <div {...styling.props(styles.headerActions)}>
233
+ <Button
234
+ variant="outline"
235
+ size="sm"
236
+ data-action="import"
237
+ disabled={importing}
238
+ aria-busy={importing}
239
+ aria-label={importing ? t("importing") : t("import")}
240
+ onClick={() => {
241
+ fileRef.current?.click();
242
+ }}
243
+ >
244
+ {importing ? t("importing") : t("import")}
245
+ </Button>
246
+ <Button
247
+ variant="outline"
248
+ size="sm"
249
+ data-action="gc"
250
+ disabled={gcPhase !== "idle"}
251
+ aria-label={t("gc.button")}
252
+ onClick={() => {
253
+ setFailure(null);
254
+ setNotice(null);
255
+ setGcPhase("confirm");
256
+ }}
257
+ >
258
+ {t("gc.button")}
259
+ </Button>
260
+ </div>
261
+ <input
262
+ ref={fileRef}
263
+ type="file"
264
+ accept=".zip,application/zip"
265
+ hidden
266
+ onChange={(event) => {
267
+ const file = event.currentTarget.files?.[0];
268
+ event.currentTarget.value = "";
269
+ if (file === undefined) return;
270
+ setImporting(true);
271
+ setFailure(null);
272
+ setNotice(null);
273
+ void importZip(file)
274
+ .then(
275
+ () => {
276
+ setNotice(t("imported"));
277
+ },
278
+ (error: unknown) => {
279
+ setFailure(failureText(error, t));
280
+ },
281
+ )
282
+ .finally(() => {
283
+ setImporting(false);
284
+ });
285
+ }}
286
+ />
287
+ </div>
288
+ {view === "usage" ? (
289
+ <UsageView
290
+ report={usage}
291
+ loading={usageLoading}
292
+ error={usageError}
293
+ tab={usageTab}
294
+ onTab={setUsageTab}
295
+ range={usageRange}
296
+ onRange={requestUsage}
297
+ t={t}
298
+ />
299
+ ) : (
300
+ <>
301
+ <div {...styling.props(styles.filters)}>
302
+ <Input
303
+ className={styling.className(styles.search)}
304
+ data-filter="search"
305
+ type="search"
306
+ icon={<IconSearchOutline16 />}
307
+ value={query}
308
+ placeholder={t("search")}
309
+ aria-label={t("search")}
310
+ onChange={(event) => {
311
+ setQuery(event.currentTarget.value);
312
+ setPage(1);
313
+ }}
314
+ />
315
+ <span data-filter="subagents">
316
+ <Checkbox
317
+ checked={showSubagents}
318
+ label={t("showSubagents")}
319
+ onChange={(next) => {
320
+ setShowSubagents(next);
321
+ setPage(1);
322
+ }}
323
+ />
324
+ </span>
325
+ </div>
326
+ {notice === null ? null : (
327
+ <p {...styling.props(styles.status)} data-notice="result">
328
+ {notice}
329
+ </p>
330
+ )}
331
+ {failure === null ? null : (
332
+ <p {...styling.props(styles.failure)} data-failure="result" role="alert">
333
+ {failure}
334
+ </p>
335
+ )}
336
+ {listed.length === 0 ? (
337
+ <p {...styling.props(styles.status)} data-status="empty">
338
+ {t("empty")}
339
+ </p>
340
+ ) : null}
341
+ {listed.length > 0 && matched.length === 0 ? (
342
+ <p {...styling.props(styles.status)} data-status="empty-search">
343
+ {t("emptySearch")}
344
+ </p>
345
+ ) : null}
346
+ {visible.length > 0 ? (
347
+ <ul {...styling.props(styles.list)}>
348
+ {visible.map((row) => (
349
+ <li
350
+ key={row.id}
351
+ {...styling.props(styles.row)}
352
+ data-session-id={String(row.id)}
353
+ data-archived={row.archived ? "true" : "false"}
354
+ data-subagent={row.subagent ? "true" : "false"}
355
+ >
356
+ <span {...styling.props(styles.identity)}>
357
+ <span {...styling.props(styles.titleLine)}>
358
+ <span {...styling.props(styles.rowTitle)}>{row.title}</span>
359
+ {row.archived ? <Tag tone="neutral">{t("archived")}</Tag> : null}
360
+ {row.subagent ? <Tag tone="quiet">{t("subagent")}</Tag> : null}
361
+ </span>
362
+ <span {...styling.props(styles.meta)}>
363
+ {[row.workspace, timeLabel(row.updatedAt, now, t)].join(" · ")}
364
+ </span>
365
+ </span>
366
+ <span {...styling.props(styles.actions)}>
367
+ {row.archived ? (
368
+ <Button
369
+ variant="outline"
370
+ size="sm"
371
+ data-action="unarchive"
372
+ aria-label={t("unarchiveNamed", { title: row.title })}
373
+ onClick={() => {
374
+ run(unarchive(row.id));
375
+ }}
376
+ >
377
+ {t("unarchive")}
378
+ </Button>
379
+ ) : (
380
+ <Button
381
+ variant="outline"
382
+ size="sm"
383
+ data-action="archive"
384
+ aria-label={t("archiveNamed", { title: row.title })}
385
+ onClick={() => {
386
+ run(archive(row.id));
387
+ }}
388
+ >
389
+ {t("archive")}
390
+ </Button>
391
+ )}
392
+ <Button
393
+ variant="outline"
394
+ size="sm"
395
+ data-action="export"
396
+ aria-label={t("exportNamed", { title: row.title })}
397
+ onClick={() => {
398
+ run(exportZip(row.id));
399
+ }}
400
+ >
401
+ {t("export")}
402
+ </Button>
403
+ <Button
404
+ variant="outline"
405
+ size="sm"
406
+ disabled={!row.archived}
407
+ data-action="remove"
408
+ aria-label={t("removeNamed", { title: row.title })}
409
+ onClick={() => {
410
+ setFailure(null);
411
+ setNotice(null);
412
+ setConfirming(row);
413
+ }}
414
+ >
415
+ {t("remove")}
416
+ </Button>
417
+ </span>
418
+ </li>
419
+ ))}
420
+ </ul>
421
+ ) : null}
422
+ {matched.length > 0 ? (
423
+ <div
424
+ {...styling.props(styles.pagination)}
425
+ data-pagination=""
426
+ data-page-current={currentPage}
427
+ data-page-total={pageCount}
428
+ >
429
+ <Button
430
+ variant="ghost"
431
+ size="sm"
432
+ disabled={currentPage <= 1}
433
+ onClick={() => {
434
+ setPage(currentPage - 1);
435
+ }}
436
+ >
437
+ {t("page.previous")}
438
+ </Button>
439
+ <span {...styling.props(styles.paginationLabel)}>
440
+ {t("page.label", { page: currentPage, total: pageCount })}
441
+ </span>
442
+ <Button
443
+ variant="ghost"
444
+ size="sm"
445
+ disabled={currentPage >= pageCount}
446
+ onClick={() => {
447
+ setPage(currentPage + 1);
448
+ }}
449
+ >
450
+ {t("page.next")}
451
+ </Button>
452
+ </div>
453
+ ) : null}
454
+ </>
455
+ )}
456
+ <Modal
457
+ open={confirmed !== null}
458
+ onClose={() => {
459
+ setConfirming(null);
460
+ }}
461
+ title={t("confirmTitle")}
462
+ closeLabel={t("close")}
463
+ description={t("confirmDescription")}
464
+ footer={
465
+ <>
466
+ <Button
467
+ variant="ghost"
468
+ onClick={() => {
469
+ setConfirming(null);
470
+ }}
471
+ >
472
+ {t("confirmCancel")}
473
+ </Button>
474
+ <Button
475
+ variant="primary"
476
+ onClick={() => {
477
+ if (confirmed === null) return;
478
+ run(remove(confirmed.id), () => {
479
+ setConfirming(null);
480
+ });
481
+ }}
482
+ >
483
+ {t("confirmAccept")}
484
+ </Button>
485
+ </>
486
+ }
487
+ />
488
+ <Modal
489
+ open={gcPhase === "confirm"}
490
+ onClose={() => {
491
+ setGcPhase("idle");
492
+ }}
493
+ title={t("gc.title")}
494
+ closeLabel={t("close")}
495
+ description={t("gc.description")}
496
+ footer={
497
+ <>
498
+ <Button
499
+ variant="ghost"
500
+ onClick={() => {
501
+ setGcPhase("idle");
502
+ }}
503
+ >
504
+ {t("gc.cancel")}
505
+ </Button>
506
+ <Button variant="primary" onClick={startGc}>
507
+ {t("gc.confirm")}
508
+ </Button>
509
+ </>
510
+ }
511
+ />
512
+ {/* 运行期不给关闭手段:没有关闭按钮,mask 与 Escape 都只走空 onClose。 */}
513
+ <Modal open={gcPhase === "running"} onClose={() => {}} headless title={t("gc.title")}>
514
+ <div {...styling.props(styles.blocking)}>
515
+ <span {...styling.props(styles.spinner)} aria-hidden="true" />
516
+ <p {...styling.props(styles.blockingText)}>{t("gc.running")}</p>
517
+ </div>
518
+ </Modal>
519
+ </div>
520
+ );
521
+ }
522
+
523
+ /** 一层视图:会话列表 / 用量统计。 */
524
+ type PageView = "sessions" | "usage";
525
+
526
+ /** 统计视图的二层维度(时间范围取代了原来的「按天」)。 */
527
+ type UsageTab = "overview" | "models" | "sessions";
528
+
529
+ /** 时间范围选项:默认本日,其后是本周(周一起算)与最近 N 天,「全部」放在最后。 */
530
+ const USAGE_RANGES: readonly UsageRange[] = ["day", "week", "7d", "30d", "90d", "all"];
531
+
532
+ type UsageRange = UsageRangeKey;
533
+
534
+ /** 范围按钮的文案。 */
535
+ function rangeLabel(range: UsageRange, t: Translate): string {
536
+ switch (range) {
537
+ case "all":
538
+ return t("usage.range.all");
539
+ case "day":
540
+ return t("usage.range.day");
541
+ case "week":
542
+ return t("usage.range.week");
543
+ case "7d":
544
+ return t("usage.range.days", { n: 7 });
545
+ case "30d":
546
+ return t("usage.range.days", { n: 30 });
547
+ case "90d":
548
+ return t("usage.range.days", { n: 90 });
549
+ }
550
+ }
551
+
552
+ /** 一个用量单项:标签在上、值在下;单项之间横向排布。 */
553
+ interface UsageMetric {
554
+ key: string;
555
+ label: string;
556
+ /** 原始值:token 数,或百分点(`percent` 项)。 */
557
+ value: number;
558
+ kind: "tokens" | "percent";
559
+ }
560
+
561
+ /** 缓存命中率(百分点):缓存输入占总输入(含缓存)的比例。 */
562
+ function cacheHitPercent(totals: UsageTotals): number {
563
+ const total = totals.inputTokens + totals.cacheReadTokens;
564
+ if (total <= 0) return 0;
565
+ return (totals.cacheReadTokens / total) * 100;
566
+ }
567
+
568
+ /** 显示口径的单项:输入(含缓存输入)、缓存输入、缓存命中率、输出、推理、事件——没有合计项。 */
569
+ function usageMetrics(totals: UsageTotals, t: Translate): UsageMetric[] {
570
+ return [
571
+ {
572
+ key: "input",
573
+ label: t("usage.inputWithCache"),
574
+ value: totals.inputTokens + totals.cacheReadTokens,
575
+ kind: "tokens",
576
+ },
577
+ {
578
+ key: "cacheInput",
579
+ label: t("usage.cacheInput"),
580
+ value: totals.cacheReadTokens,
581
+ kind: "tokens",
582
+ },
583
+ {
584
+ key: "cacheRate",
585
+ label: t("usage.cacheRate"),
586
+ value: cacheHitPercent(totals),
587
+ kind: "percent",
588
+ },
589
+ { key: "output", label: t("usage.output"), value: totals.outputTokens, kind: "tokens" },
590
+ {
591
+ key: "reasoning",
592
+ label: t("usage.reasoning"),
593
+ value: totals.reasoningTokens,
594
+ kind: "tokens",
595
+ },
596
+ { key: "events", label: t("usage.events"), value: totals.events, kind: "tokens" },
597
+ ];
598
+ }
599
+
600
+ /** 折叠行的排序口径(不显示):输入(含缓存)+ 输出。 */
601
+ function sortWeight(totals: UsageTotals): number {
602
+ return totals.inputTokens + totals.cacheReadTokens + totals.outputTokens;
603
+ }
604
+
605
+ interface UsageListRow {
606
+ key: string;
607
+ label: string;
608
+ totals: UsageTotals;
609
+ }
610
+
611
+ function emptyTotals(): UsageTotals {
612
+ return {
613
+ events: 0,
614
+ inputTokens: 0,
615
+ outputTokens: 0,
616
+ cacheReadTokens: 0,
617
+ reasoningTokens: 0,
618
+ totalTokens: 0,
619
+ };
620
+ }
621
+
622
+ /** 把桶按一个键折叠成行并按用量降序(按天 / 按模型都用它)。 */
623
+ function foldBuckets(
624
+ buckets: readonly UsageBucket[],
625
+ keyOf: (bucket: UsageBucket) => string,
626
+ ): UsageListRow[] {
627
+ const folded = new Map<string, UsageListRow>();
628
+ for (const bucket of buckets) {
629
+ const key = keyOf(bucket);
630
+ const row = folded.get(key) ?? { key, label: key, totals: emptyTotals() };
631
+ row.totals.events += bucket.events;
632
+ row.totals.inputTokens += bucket.inputTokens;
633
+ row.totals.outputTokens += bucket.outputTokens;
634
+ row.totals.cacheReadTokens += bucket.cacheReadTokens;
635
+ row.totals.reasoningTokens += bucket.reasoningTokens;
636
+ row.totals.totalTokens += bucket.totalTokens;
637
+ folded.set(key, row);
638
+ }
639
+ return [...folded.values()].sort(
640
+ (left, right) => sortWeight(right.totals) - sortWeight(left.totals),
641
+ );
642
+ }
643
+
644
+ /** 一行用量:label 在上,下面是横向排布的单项。 */
645
+ function UsageRow({
646
+ rowKey,
647
+ label,
648
+ totals,
649
+ t,
650
+ }: {
651
+ rowKey: string;
652
+ label: string;
653
+ totals: UsageTotals;
654
+ t: Translate;
655
+ }): ReactNode {
656
+ return (
657
+ <li {...styling.props(styles.usageRow)} data-usage-key={rowKey}>
658
+ <span {...styling.props(styles.usageRowLabel)}>{label}</span>
659
+ <span {...styling.props(styles.usageMetrics)}>
660
+ {usageMetrics(totals, t).map((metric) => (
661
+ <span
662
+ key={metric.key}
663
+ {...styling.props(styles.usageMetric)}
664
+ data-usage-cell={metric.key}
665
+ data-usage-value={metric.value}
666
+ >
667
+ <span {...styling.props(styles.usageMetricLabel)}>{metric.label}</span>
668
+ <span {...styling.props(styles.usageMetricValue)}>
669
+ {metric.kind === "percent" ? formatPercent(metric.value) : formatTokens(metric.value)}
670
+ </span>
671
+ </span>
672
+ ))}
673
+ </span>
674
+ </li>
675
+ );
676
+ }
677
+
678
+ function UsageList({ rows, t }: { rows: readonly UsageListRow[]; t: Translate }): ReactNode {
679
+ if (rows.length === 0) {
680
+ return (
681
+ <p {...styling.props(styles.status)} data-usage-status="empty">
682
+ {t("usage.empty")}
683
+ </p>
684
+ );
685
+ }
686
+ return (
687
+ <ul {...styling.props(styles.usageList)}>
688
+ {rows.map((row) => (
689
+ <UsageRow key={row.key} rowKey={row.key} label={row.label} totals={row.totals} t={t} />
690
+ ))}
691
+ </ul>
692
+ );
693
+ }
694
+
695
+ /** 总览:全部与「其中子代理」两行,与列表行同形。 */
696
+ function UsageOverview({ report, t }: { report: SessionUsageReport; t: Translate }): ReactNode {
697
+ return (
698
+ <ul {...styling.props(styles.usageList)}>
699
+ <UsageRow rowKey="all" label={t("usage.all")} totals={report.totals} t={t} />
700
+ <UsageRow rowKey="subagent" label={t("usage.subagentOnly")} totals={report.subagent} t={t} />
701
+ </ul>
702
+ );
703
+ }
704
+
705
+ /** 统计视图:时间范围过滤 + 二层维度切换(总览 / 按模型 / 按会话)。 */
706
+ function UsageView({
707
+ report,
708
+ loading,
709
+ error,
710
+ tab,
711
+ onTab,
712
+ range,
713
+ onRange,
714
+ t,
715
+ }: {
716
+ report: SessionUsageReport | null;
717
+ loading: boolean;
718
+ error: string | null;
719
+ tab: UsageTab;
720
+ onTab: (tab: UsageTab) => void;
721
+ range: UsageRange;
722
+ onRange: (range: UsageRange) => void;
723
+ t: Translate;
724
+ }): ReactNode {
725
+ const items: UsageTab[] = ["overview", "models", "sessions"];
726
+ const labels: Record<UsageTab, string> = {
727
+ overview: t("usage.overview"),
728
+ models: t("usage.models"),
729
+ sessions: t("usage.sessions"),
730
+ };
731
+ const rows: readonly UsageListRow[] =
732
+ report === null || tab === "overview"
733
+ ? []
734
+ : tab === "models"
735
+ ? foldBuckets(
736
+ report.buckets,
737
+ (bucket) =>
738
+ `${bucket.provider ?? t("usage.unknownModel")} / ${bucket.model ?? t("usage.unknownModel")}`,
739
+ )
740
+ : report.sessions
741
+ .map((row) => ({
742
+ key: row.sessionId,
743
+ label: row.title ?? row.sessionId,
744
+ totals: row,
745
+ }))
746
+ .sort((left, right) => sortWeight(right.totals) - sortWeight(left.totals))
747
+ .slice(0, USAGE_SESSION_ROWS);
748
+ return (
749
+ <div {...styling.props(styles.usage)} data-usage-view={tab} data-usage-range={range}>
750
+ <div {...styling.props(styles.tabs)} role="radiogroup" aria-label={t("usage.range")}>
751
+ {USAGE_RANGES.map((option) => (
752
+ <button
753
+ key={option}
754
+ type="button"
755
+ role="radio"
756
+ aria-checked={range === option}
757
+ data-range={option}
758
+ className={styling.className(styles.tab, range === option && styles.tabActive)}
759
+ onClick={() => {
760
+ onRange(option);
761
+ }}
762
+ >
763
+ {rangeLabel(option, t)}
764
+ </button>
765
+ ))}
766
+ </div>
767
+ <div {...styling.props(styles.tabs)} role="tablist">
768
+ {items.map((item) => (
769
+ <button
770
+ key={item}
771
+ type="button"
772
+ role="tab"
773
+ data-usage-tab={item}
774
+ aria-selected={tab === item}
775
+ className={styling.className(styles.tab, tab === item && styles.tabActive)}
776
+ onClick={() => {
777
+ onTab(item);
778
+ }}
779
+ >
780
+ {labels[item]}
781
+ </button>
782
+ ))}
783
+ </div>
784
+ {loading ? (
785
+ <p {...styling.props(styles.status)} data-usage-status="loading">
786
+ {t("usage.loading")}
787
+ </p>
788
+ ) : null}
789
+ {error === null ? null : (
790
+ <p {...styling.props(styles.failure)} data-usage-status="error" role="alert">
791
+ {error}
792
+ </p>
793
+ )}
794
+ {report === null ? null : tab === "overview" ? (
795
+ <UsageOverview report={report} t={t} />
796
+ ) : (
797
+ <UsageList rows={rows} t={t} />
798
+ )}
799
+ </div>
800
+ );
801
+ }