@morlay/ui-conversation-manager 0.1.4 → 0.1.6

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.
@@ -1,17 +1,23 @@
1
1
  // 「对话管理」页面:已归档会话的搜索、取消归档、导出、删除,以及导入为新会话与孤儿数据 GC。
2
2
  // 数据面:会话行读**我们自己的**列表路由(注入面 listRows,含归档;搜索 / 子代理过滤 / 分页都在后端),
3
3
  // 动作面只读注入面。
4
+ // 形态:页面框架与上游一级页面同款(960px 居中列 + 标题行 + 过滤 chip 行 + 搜索行,见
5
+ // `ConversationManagerPage.module.css`);切换与过滤都用那套 chip(`role="group"` + `aria-pressed`),
6
+ // 读取 / 空语料 / 读列表失败三种状态整屏居中。
4
7
  import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
5
8
  import {
6
9
  Button,
7
- Checkbox,
10
+ IconCloseOutlineRegular,
8
11
  IconSearchOutlineRegular,
9
12
  Input,
10
13
  Modal,
14
+ Row,
15
+ Spinner,
16
+ Stack,
11
17
  Tag,
18
+ Text,
12
19
  relativeTime,
13
- } from "@deepseek-ai/dsh-client-ui-primitives";
14
- import { styling } from "@morlay/dsh-client-ui-primitives/client";
20
+ } from "@morlay/dsh-client-ui-primitives";
15
21
  import type { InjectFace, PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots";
16
22
  import type { SessionId } from "@deepseek-ai/dsh-session";
17
23
  import {
@@ -23,8 +29,8 @@ import {
23
29
  type UsageRangeKey,
24
30
  type UsageTotals,
25
31
  } from "./controller.ts";
32
+ import frameCss from "./ConversationManagerPage.module.css";
26
33
  import { formatCount, formatPercent, formatTokens } from "./format.ts";
27
- import { styles } from "./ConversationManagerPage.styles.ts";
28
34
 
29
35
  // 一页的行数(会话列表)。
30
36
  const PAGE_SIZE = 20;
@@ -69,6 +75,69 @@ function failureText(error: unknown, t: Translate): string {
69
75
  return t("failure.other", { reason: error instanceof Error ? error.message : String(error) });
70
76
  }
71
77
 
78
+ // 过滤 chip:上游一级页面那套(28px 胶囊,选中那档带填充 + 主标签色)。语义是**筛选**:`role="group"` 的组里
79
+ // 每个 chip 用 `aria-pressed` 说选中,不是 tab 条。
80
+ function FilterChip({
81
+ active,
82
+ label,
83
+ tab,
84
+ onSelect,
85
+ }: {
86
+ active: boolean;
87
+ label: string;
88
+ // `data-tab` 标注(沟通与 e2e 按它定位那一个 chip)。
89
+ tab?: string;
90
+ onSelect: () => void;
91
+ }): ReactNode {
92
+ return (
93
+ <button
94
+ type="button"
95
+ className={active ? `${frameCss.filterTab} ${frameCss.filterTabActive}` : frameCss.filterTab}
96
+ aria-pressed={active}
97
+ {...(tab === undefined ? {} : { "data-tab": tab })}
98
+ onClick={onSelect}
99
+ >
100
+ {label}
101
+ </button>
102
+ );
103
+ }
104
+
105
+ // 整屏居中的状态态:列表还没就绪 / 一份会话都没有 / 读列表失败。这三种时候页面没有别的内容可看,状态占满
106
+ // 整个面板(`.status` 住 `ConversationManagerPage.module.css`),因此不画标题行、过滤行与搜索行。
107
+ function PageStatus({
108
+ t,
109
+ status,
110
+ text,
111
+ spinner = false,
112
+ failure = false,
113
+ }: {
114
+ t: Translate;
115
+ status: "loading" | "empty" | "failure";
116
+ text: string;
117
+ spinner?: boolean;
118
+ failure?: boolean;
119
+ }): ReactNode {
120
+ return (
121
+ <section className={frameCss.page} aria-label={t("title")}>
122
+ <div className={frameCss.listPane}>
123
+ <div className={frameCss.status} data-status={status}>
124
+ <div className={frameCss.statusBody}>
125
+ {spinner ? <Spinner label={t("loading")} /> : null}
126
+ <p
127
+ className={
128
+ failure ? `${frameCss.statusText} ${frameCss.statusFailure}` : frameCss.statusText
129
+ }
130
+ {...(failure ? { role: "alert" } : {})}
131
+ >
132
+ {text}
133
+ </p>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ </section>
138
+ );
139
+ }
140
+
72
141
  export function ConversationManagerPage({
73
142
  t,
74
143
  listRows,
@@ -91,6 +160,7 @@ export function ConversationManagerPage({
91
160
  const [view, setView] = useState<PageView>("sessions");
92
161
  const [usageTab, setUsageTab] = useState<UsageTab>("overview");
93
162
  const [usageRange, setUsageRange] = useState<UsageRange>("day");
163
+ const [usageOnlySubagents, setUsageOnlySubagents] = useState(false);
94
164
  const [usage, setUsage] = useState<SessionUsageReport | null>(null);
95
165
  const [usageLoading, setUsageLoading] = useState(false);
96
166
  const [usageError, setUsageError] = useState<string | null>(null);
@@ -208,7 +278,11 @@ export function ConversationManagerPage({
208
278
  };
209
279
 
210
280
  if (rowsPage === null) {
211
- return <p {...styling.props(styles.status)}>{rowsFailure ?? t("loading")}</p>;
281
+ return rowsFailure === null ? (
282
+ <PageStatus t={t} status="loading" text={t("loading")} spinner />
283
+ ) : (
284
+ <PageStatus t={t} status="failure" text={rowsFailure} failure />
285
+ );
212
286
  }
213
287
 
214
288
  const now = Date.now();
@@ -220,257 +294,298 @@ export function ConversationManagerPage({
220
294
  const matched = rows;
221
295
  const confirmed = confirming;
222
296
 
297
+ // 一份会话都没有(也没在搜索):标题行与搜索行都没有可做的事,整个面板就让给这个状态。
298
+ if (total === 0 && settledQuery === "") {
299
+ return <PageStatus t={t} status="empty" text={t("empty")} />;
300
+ }
301
+
223
302
  return (
224
- <div {...styling.props(styles.page)} data-view={view}>
225
- <div {...styling.props(styles.header)}>
226
- <h1 {...styling.props(styles.title)}>{t("title")}</h1>
227
- <div {...styling.props(styles.tabs)} role="tablist">
228
- <button
229
- type="button"
230
- role="tab"
231
- data-tab="sessions"
232
- aria-selected={view === "sessions"}
233
- className={styling.className(styles.tab, view === "sessions" && styles.tabActive)}
234
- onClick={() => {
235
- setView("sessions");
236
- }}
237
- >
238
- {t("view.sessions")}
239
- </button>
240
- <button
241
- type="button"
242
- role="tab"
243
- data-tab="usage"
244
- aria-selected={view === "usage"}
245
- className={styling.className(styles.tab, view === "usage" && styles.tabActive)}
246
- onClick={openUsage}
247
- >
248
- {t("view.usage")}
249
- </button>
250
- </div>
251
- <div {...styling.props(styles.headerActions)}>
252
- <Button
253
- variant="outline"
254
- size="sm"
255
- data-action="import"
256
- disabled={importing}
257
- aria-busy={importing}
258
- aria-label={importing ? t("importing") : t("import")}
259
- onClick={() => {
260
- fileRef.current?.click();
261
- }}
262
- >
263
- {importing ? t("importing") : t("import")}
264
- </Button>
265
- <Button
266
- variant="outline"
267
- size="sm"
268
- data-action="gc"
269
- disabled={gcPhase !== "idle"}
270
- aria-label={t("gc.button")}
271
- onClick={() => {
272
- setFailure(null);
273
- setNotice(null);
274
- setGcPhase("confirm");
275
- }}
276
- >
277
- {t("gc.button")}
278
- </Button>
279
- </div>
280
- <input
281
- ref={fileRef}
282
- type="file"
283
- accept=".zip,application/zip"
284
- hidden
285
- onChange={(event) => {
286
- const file = event.currentTarget.files?.[0];
287
- event.currentTarget.value = "";
288
- if (file === undefined) return;
289
- setImporting(true);
290
- setFailure(null);
291
- setNotice(null);
292
- void importZip(file)
293
- .then(
294
- () => {
295
- setNotice(t("imported"));
296
- },
297
- (error: unknown) => {
298
- setFailure(failureText(error, t));
299
- },
300
- )
301
- .finally(() => {
302
- setImporting(false);
303
- });
304
- }}
305
- />
306
- </div>
307
- {view === "usage" ? (
308
- <div {...styling.props(styles.scroll)} data-scroll="page">
309
- <UsageView
310
- report={usage}
311
- loading={usageLoading}
312
- error={usageError}
313
- tab={usageTab}
314
- onTab={setUsageTab}
315
- range={usageRange}
316
- onRange={requestUsage}
317
- t={t}
318
- />
319
- </div>
320
- ) : (
321
- <>
322
- <div {...styling.props(styles.filters)}>
323
- <Input
324
- className={styling.className(styles.search)}
325
- data-filter="search"
326
- type="search"
327
- icon={<IconSearchOutlineRegular />}
328
- value={query}
329
- placeholder={t("search")}
330
- aria-label={t("search")}
331
- onChange={(event) => {
332
- setQuery(event.currentTarget.value);
333
- setPage(1);
334
- }}
335
- />
336
- <span data-filter="subagents">
337
- <Checkbox
338
- checked={showSubagents}
339
- label={t("showSubagents")}
340
- onChange={(next) => {
341
- setShowSubagents(next);
342
- setPage(1);
343
- }}
344
- />
345
- </span>
346
- </div>
347
- <div {...styling.props(styles.scroll)} data-scroll="page">
348
- {notice === null ? null : (
349
- <p {...styling.props(styles.status)} data-notice="result">
350
- {notice}
351
- </p>
352
- )}
353
- {failure === null ? null : (
354
- <p {...styling.props(styles.failure)} data-failure="result" role="alert">
355
- {failure}
356
- </p>
357
- )}
358
- {total === 0 ? (
359
- <p {...styling.props(styles.status)} data-status="empty">
360
- {t(settledQuery === "" ? "empty" : "emptySearch")}
361
- </p>
362
- ) : null}
363
- {visible.length > 0 ? (
364
- <ul {...styling.props(styles.list)}>
365
- {visible.map((row) => (
366
- <li
367
- key={row.id}
368
- {...styling.props(styles.row)}
369
- data-session-id={String(row.id)}
370
- data-archived={row.archived ? "true" : "false"}
371
- data-subagent={row.subagent ? "true" : "false"}
372
- >
373
- <span {...styling.props(styles.identity)}>
374
- <span {...styling.props(styles.titleLine)}>
375
- <span {...styling.props(styles.rowTitle)}>{row.title}</span>
376
- {row.archived ? <Tag tone="neutral">{t("archived")}</Tag> : null}
377
- {row.subagent ? <Tag tone="quiet">{t("subagent")}</Tag> : null}
378
- </span>
379
- <span {...styling.props(styles.meta)}>
380
- {[row.workspace, timeLabel(row.updatedAt, now, t)].join(" · ")}
381
- </span>
382
- </span>
383
- <span {...styling.props(styles.actions)}>
384
- {row.archived ? (
385
- <Button
386
- variant="outline"
387
- size="sm"
388
- data-action="unarchive"
389
- aria-label={t("unarchiveNamed", { title: row.title })}
390
- onClick={() => {
391
- run(unarchive(row.id));
392
- }}
393
- >
394
- {t("unarchive")}
395
- </Button>
396
- ) : (
397
- <Button
398
- variant="outline"
399
- size="sm"
400
- data-action="archive"
401
- aria-label={t("archiveNamed", { title: row.title })}
402
- onClick={() => {
403
- run(archive(row.id));
404
- }}
405
- >
406
- {t("archive")}
407
- </Button>
408
- )}
409
- <Button
410
- variant="outline"
411
- size="sm"
412
- data-action="export"
413
- aria-label={t("exportNamed", { title: row.title })}
414
- onClick={() => {
415
- run(exportZip(row.id));
416
- }}
417
- >
418
- {t("export")}
419
- </Button>
420
- <Button
421
- variant="outline"
422
- size="sm"
423
- disabled={!row.archived}
424
- data-action="remove"
425
- aria-label={t("removeNamed", { title: row.title })}
426
- onClick={() => {
427
- setFailure(null);
428
- setNotice(null);
429
- setConfirming(row);
430
- }}
431
- >
432
- {t("remove")}
433
- </Button>
434
- </span>
435
- </li>
436
- ))}
437
- </ul>
438
- ) : null}
439
- {matched.length > 0 ? (
440
- <div
441
- {...styling.props(styles.pagination)}
442
- data-pagination=""
443
- data-page-current={currentPage}
444
- data-page-total={pageCount}
445
- >
303
+ <section className={frameCss.page} data-view={view} aria-label={t("title")}>
304
+ <div className={frameCss.listPane}>
305
+ <div className={frameCss.pageScroll} data-scroll="page">
306
+ <div className={frameCss.pageContent}>
307
+ <div className={frameCss.pageHeading}>
308
+ <h1>{t("title")}</h1>
309
+ <div className={frameCss.headingActions}>
446
310
  <Button
447
- variant="ghost"
311
+ variant="outline"
448
312
  size="sm"
449
- disabled={currentPage <= 1}
313
+ data-action="import"
314
+ disabled={importing}
315
+ aria-busy={importing}
316
+ aria-label={importing ? t("importing") : t("import")}
450
317
  onClick={() => {
451
- setPage(currentPage - 1);
318
+ fileRef.current?.click();
452
319
  }}
453
320
  >
454
- {t("page.previous")}
321
+ {importing ? t("importing") : t("import")}
455
322
  </Button>
456
- <span {...styling.props(styles.paginationLabel)}>
457
- {t("page.label", { page: currentPage, total: pageCount })}
458
- </span>
459
323
  <Button
460
- variant="ghost"
324
+ variant="outline"
461
325
  size="sm"
462
- disabled={currentPage >= pageCount}
326
+ data-action="gc"
327
+ disabled={gcPhase !== "idle"}
328
+ aria-label={t("gc.button")}
463
329
  onClick={() => {
464
- setPage(currentPage + 1);
330
+ setFailure(null);
331
+ setNotice(null);
332
+ setGcPhase("confirm");
465
333
  }}
466
334
  >
467
- {t("page.next")}
335
+ {t("gc.button")}
468
336
  </Button>
469
337
  </div>
470
- ) : null}
338
+ </div>
339
+ <input
340
+ ref={fileRef}
341
+ type="file"
342
+ accept=".zip,application/zip"
343
+ hidden
344
+ onChange={(event) => {
345
+ const file = event.currentTarget.files?.[0];
346
+ event.currentTarget.value = "";
347
+ if (file === undefined) return;
348
+ setImporting(true);
349
+ setFailure(null);
350
+ setNotice(null);
351
+ void importZip(file)
352
+ .then(
353
+ () => {
354
+ setNotice(t("imported"));
355
+ },
356
+ (error: unknown) => {
357
+ setFailure(failureText(error, t));
358
+ },
359
+ )
360
+ .finally(() => {
361
+ setImporting(false);
362
+ });
363
+ }}
364
+ />
365
+ <div className={frameCss.filters}>
366
+ <div
367
+ className={frameCss.filterTabs}
368
+ role="group"
369
+ aria-label={t("filter.view")}
370
+ data-filter="view"
371
+ >
372
+ <FilterChip
373
+ active={view === "sessions"}
374
+ label={t("view.sessions")}
375
+ tab="sessions"
376
+ onSelect={() => {
377
+ setView("sessions");
378
+ }}
379
+ />
380
+ <FilterChip
381
+ active={view === "usage"}
382
+ label={t("view.usage")}
383
+ tab="usage"
384
+ onSelect={openUsage}
385
+ />
386
+ </div>
387
+ </div>
388
+ {view === "usage" ? null : (
389
+ <div className={frameCss.filters}>
390
+ <div
391
+ className={frameCss.filterTabs}
392
+ role="group"
393
+ aria-label={t("filter.subagents")}
394
+ data-filter="subagents"
395
+ >
396
+ <FilterChip
397
+ active={showSubagents}
398
+ label={t("showSubagents")}
399
+ onSelect={() => {
400
+ setShowSubagents(!showSubagents);
401
+ setPage(1);
402
+ }}
403
+ />
404
+ </div>
405
+ </div>
406
+ )}
407
+ {view === "usage" ? null : (
408
+ <div className={frameCss.searchField}>
409
+ <Input
410
+ data-filter="search"
411
+ type="search"
412
+ icon={<IconSearchOutlineRegular />}
413
+ value={query}
414
+ placeholder={t("search")}
415
+ aria-label={t("search")}
416
+ onChange={(event) => {
417
+ setQuery(event.currentTarget.value);
418
+ setPage(1);
419
+ }}
420
+ />
421
+ {query === "" ? null : (
422
+ <Button
423
+ variant="ghost"
424
+ size="sm"
425
+ className={frameCss.searchClear}
426
+ aria-label={t("clearSearch")}
427
+ onClick={() => {
428
+ setQuery("");
429
+ setPage(1);
430
+ }}
431
+ >
432
+ <IconCloseOutlineRegular />
433
+ </Button>
434
+ )}
435
+ </div>
436
+ )}
437
+ {view === "usage" ? (
438
+ <UsageView
439
+ report={usage}
440
+ loading={usageLoading}
441
+ error={usageError}
442
+ tab={usageTab}
443
+ onTab={setUsageTab}
444
+ range={usageRange}
445
+ onRange={requestUsage}
446
+ onlySubagents={usageOnlySubagents}
447
+ onOnlySubagents={setUsageOnlySubagents}
448
+ t={t}
449
+ />
450
+ ) : (
451
+ <div className={frameCss.list}>
452
+ {notice === null ? null : (
453
+ <Text data-notice="result" as="p" size="md" tone="tertiary">
454
+ {notice}
455
+ </Text>
456
+ )}
457
+ {failure === null ? null : (
458
+ <Text data-failure="result" role="alert" as="p" size="md" tone="danger">
459
+ {failure}
460
+ </Text>
461
+ )}
462
+ {total === 0 ? (
463
+ <div className={frameCss.listStatus} data-status="empty-search">
464
+ <p className={frameCss.statusText}>{t("emptySearch")}</p>
465
+ </div>
466
+ ) : null}
467
+ {visible.length > 0 ? (
468
+ <Stack as="ul" gap={4} plain>
469
+ {visible.map((row) => (
470
+ <Row
471
+ as="li"
472
+ key={row.id}
473
+ gap={16}
474
+ justify="between"
475
+ boxed
476
+ pad="row"
477
+ fixed
478
+ data-session-id={String(row.id)}
479
+ data-archived={row.archived ? "true" : "false"}
480
+ data-subagent={row.subagent ? "true" : "false"}
481
+ >
482
+ <Stack gap={2} grow>
483
+ <Row gap={8} grow>
484
+ <Text size="md" truncate>
485
+ {row.title}
486
+ </Text>
487
+ {row.archived ? <Tag tone="neutral">{t("archived")}</Tag> : null}
488
+ {row.subagent ? <Tag tone="quiet">{t("subagent")}</Tag> : null}
489
+ </Row>
490
+ <Text size="sm" tone="tertiary">
491
+ {[row.workspace, timeLabel(row.updatedAt, now, t)].join(" · ")}
492
+ </Text>
493
+ </Stack>
494
+ <Row gap={8} fixed>
495
+ {row.archived ? (
496
+ <Button
497
+ variant="outline"
498
+ size="sm"
499
+ data-action="unarchive"
500
+ aria-label={t("unarchiveNamed", { title: row.title })}
501
+ onClick={() => {
502
+ run(unarchive(row.id));
503
+ }}
504
+ >
505
+ {t("unarchive")}
506
+ </Button>
507
+ ) : (
508
+ <Button
509
+ variant="outline"
510
+ size="sm"
511
+ data-action="archive"
512
+ aria-label={t("archiveNamed", { title: row.title })}
513
+ onClick={() => {
514
+ run(archive(row.id));
515
+ }}
516
+ >
517
+ {t("archive")}
518
+ </Button>
519
+ )}
520
+ <Button
521
+ variant="outline"
522
+ size="sm"
523
+ data-action="export"
524
+ aria-label={t("exportNamed", { title: row.title })}
525
+ onClick={() => {
526
+ run(exportZip(row.id));
527
+ }}
528
+ >
529
+ {t("export")}
530
+ </Button>
531
+ <Button
532
+ variant="outline"
533
+ size="sm"
534
+ disabled={!row.archived}
535
+ data-action="remove"
536
+ aria-label={t("removeNamed", { title: row.title })}
537
+ onClick={() => {
538
+ setFailure(null);
539
+ setNotice(null);
540
+ setConfirming(row);
541
+ }}
542
+ >
543
+ {t("remove")}
544
+ </Button>
545
+ </Row>
546
+ </Row>
547
+ ))}
548
+ </Stack>
549
+ ) : null}
550
+ {matched.length > 0 ? (
551
+ <Row
552
+ data-pagination=""
553
+ data-page-current={currentPage}
554
+ data-page-total={pageCount}
555
+ gap={8}
556
+ justify="end"
557
+ fixed
558
+ >
559
+ <Button
560
+ variant="ghost"
561
+ size="sm"
562
+ disabled={currentPage <= 1}
563
+ onClick={() => {
564
+ setPage(currentPage - 1);
565
+ }}
566
+ >
567
+ {t("page.previous")}
568
+ </Button>
569
+ <Text size="sm" tone="tertiary" tabular>
570
+ {t("page.label", { page: currentPage, total: pageCount })}
571
+ </Text>
572
+ <Button
573
+ variant="ghost"
574
+ size="sm"
575
+ disabled={currentPage >= pageCount}
576
+ onClick={() => {
577
+ setPage(currentPage + 1);
578
+ }}
579
+ >
580
+ {t("page.next")}
581
+ </Button>
582
+ </Row>
583
+ ) : null}
584
+ </div>
585
+ )}
471
586
  </div>
472
- </>
473
- )}
587
+ </div>
588
+ </div>
474
589
  <Modal
475
590
  open={confirmed !== null}
476
591
  onClose={() => {
@@ -529,12 +644,14 @@ export function ConversationManagerPage({
529
644
  />
530
645
  {/* 运行期不给关闭手段:没有关闭按钮,mask 与 Escape 都只走空 onClose。 */}
531
646
  <Modal open={gcPhase === "running"} onClose={() => {}} headless title={t("gc.title")}>
532
- <div {...styling.props(styles.blocking)}>
533
- <span {...styling.props(styles.spinner)} aria-hidden="true" />
534
- <p {...styling.props(styles.blockingText)}>{t("gc.running")}</p>
535
- </div>
647
+ <Stack align="center" gap={14} pad="blocking">
648
+ <Spinner label={t("gc.running")} />
649
+ <Text as="p" size="md">
650
+ {t("gc.running")}
651
+ </Text>
652
+ </Stack>
536
653
  </Modal>
537
- </div>
654
+ </section>
538
655
  );
539
656
  }
540
657
 
@@ -647,6 +764,8 @@ interface UsageListRow {
647
764
  key: string;
648
765
  label: string;
649
766
  totals: UsageTotals;
767
+ // 子代理派生会话的行(按会话维度才有此维度;其余维度不带标记)。
768
+ subagent?: boolean;
650
769
  }
651
770
 
652
771
  function emptyTotals(): UsageTotals {
@@ -663,7 +782,7 @@ function emptyTotals(): UsageTotals {
663
782
  };
664
783
  }
665
784
 
666
- // 把桶按一个键折叠成行并按用量降序(按天 / 按模型都用它)。
785
+ // 把桶按一个键折叠成行并按用量降序(按模型维度用它:人类的桶与子代理的桶折进同一行)。
667
786
  function foldBuckets(
668
787
  buckets: readonly UsageBucket[],
669
788
  keyOf: (bucket: UsageBucket) => string,
@@ -684,43 +803,60 @@ function foldBuckets(
684
803
  );
685
804
  }
686
805
 
687
- // 一行用量:label 在上,下面是横向排布的单项。
806
+ // 一行用量:label 在上(子代理行另带标记),下面是横向排布的单项。
688
807
  function UsageRow({
689
808
  rowKey,
690
809
  label,
691
810
  totals,
692
811
  t,
812
+ subagent,
693
813
  withActivity = true,
694
814
  }: {
695
815
  rowKey: string;
696
816
  label: string;
697
817
  totals: UsageTotals;
698
818
  t: Translate;
819
+ subagent?: boolean;
699
820
  withActivity?: boolean;
700
821
  }): ReactNode {
701
822
  return (
702
- <li {...styling.props(styles.usageRow)} data-usage-key={rowKey}>
703
- <span {...styling.props(styles.usageRowLabel)}>{label}</span>
704
- <span {...styling.props(styles.usageMetrics)}>
823
+ <Stack
824
+ as="li"
825
+ gap={6}
826
+ boxed
827
+ pad="row"
828
+ fixed
829
+ data-usage-key={rowKey}
830
+ {...(subagent === undefined ? {} : { "data-subagent": subagent ? "true" : "false" })}
831
+ >
832
+ <Row gap={8} grow>
833
+ <Text size="sm" tone="tertiary" truncate>
834
+ {label}
835
+ </Text>
836
+ {subagent === true ? <Tag tone="quiet">{t("subagent")}</Tag> : null}
837
+ </Row>
838
+ <Row gap="wide" wrap>
705
839
  {usageMetrics(totals, t, { withActivity }).map((metric) => (
706
- <span
840
+ <Stack
707
841
  key={metric.key}
708
- {...styling.props(styles.usageMetric)}
709
842
  data-usage-cell={metric.key}
710
843
  data-usage-value={metric.value}
844
+ gap={2}
711
845
  >
712
- <span {...styling.props(styles.usageMetricLabel)}>{metric.label}</span>
713
- <span {...styling.props(styles.usageMetricValue)}>
846
+ <Text size="sm" tone="tertiary">
847
+ {metric.label}
848
+ </Text>
849
+ <Text size="lg" weight="medium" tabular>
714
850
  {metric.kind === "percent"
715
851
  ? formatPercent(metric.value)
716
852
  : metric.kind === "count"
717
853
  ? formatCount(metric.value)
718
854
  : formatTokens(metric.value)}
719
- </span>
720
- </span>
855
+ </Text>
856
+ </Stack>
721
857
  ))}
722
- </span>
723
- </li>
858
+ </Row>
859
+ </Stack>
724
860
  );
725
861
  }
726
862
 
@@ -735,13 +871,13 @@ function UsageList({
735
871
  }): ReactNode {
736
872
  if (rows.length === 0) {
737
873
  return (
738
- <p {...styling.props(styles.status)} data-usage-status="empty">
874
+ <Text data-usage-status="empty" as="p" size="md" tone="tertiary">
739
875
  {t("usage.empty")}
740
- </p>
876
+ </Text>
741
877
  );
742
878
  }
743
879
  return (
744
- <ul {...styling.props(styles.usageList)}>
880
+ <Stack as="ul" gap={4} plain>
745
881
  {rows.map((row) => (
746
882
  <UsageRow
747
883
  key={row.key}
@@ -749,20 +885,21 @@ function UsageList({
749
885
  label={row.label}
750
886
  totals={row.totals}
751
887
  t={t}
888
+ {...(row.subagent === undefined ? {} : { subagent: row.subagent })}
752
889
  withActivity={withActivity}
753
890
  />
754
891
  ))}
755
- </ul>
892
+ </Stack>
756
893
  );
757
894
  }
758
895
 
759
896
  // 总览:全部与「其中子代理」两行,与列表行同形。
760
897
  function UsageOverview({ report, t }: { report: SessionUsageReport; t: Translate }): ReactNode {
761
898
  return (
762
- <ul {...styling.props(styles.usageList)}>
899
+ <Stack as="ul" gap={4} plain>
763
900
  <UsageRow rowKey="all" label={t("usage.all")} totals={report.totals} t={t} />
764
901
  <UsageRow rowKey="subagent" label={t("usage.subagentOnly")} totals={report.subagent} t={t} />
765
- </ul>
902
+ </Stack>
766
903
  );
767
904
  }
768
905
 
@@ -775,6 +912,8 @@ function UsageView({
775
912
  onTab,
776
913
  range,
777
914
  onRange,
915
+ onlySubagents,
916
+ onOnlySubagents,
778
917
  t,
779
918
  }: {
780
919
  report: SessionUsageReport | null;
@@ -784,6 +923,8 @@ function UsageView({
784
923
  onTab: (tab: UsageTab) => void;
785
924
  range: UsageRange;
786
925
  onRange: (range: UsageRange) => void;
926
+ onlySubagents: boolean;
927
+ onOnlySubagents: (next: boolean) => void;
787
928
  t: Translate;
788
929
  }): ReactNode {
789
930
  const items: UsageTab[] = ["overview", "models", "sessions"];
@@ -802,64 +943,85 @@ function UsageView({
802
943
  `${bucket.provider ?? t("usage.unknownModel")} / ${bucket.model ?? t("usage.unknownModel")}`,
803
944
  )
804
945
  : report.sessions
946
+ // 子代理会话单个用量小,混排时会被行数上限挤掉:给一个只看它们的开关(过滤在本页,数据已全量在手)。
947
+ .filter((row) => !onlySubagents || row.subagent)
805
948
  .map((row) => ({
806
949
  key: row.sessionId,
807
950
  label: row.title ?? row.sessionId,
808
951
  totals: row,
952
+ subagent: row.subagent,
809
953
  }))
810
954
  .sort((left, right) => sortWeight(right.totals) - sortWeight(left.totals))
811
955
  .slice(0, USAGE_SESSION_ROWS);
812
956
  return (
813
- <div {...styling.props(styles.usage)} data-usage-view={tab} data-usage-range={range}>
814
- <div {...styling.props(styles.tabs)} role="radiogroup" aria-label={t("usage.range")}>
957
+ <Stack data-usage-view={tab} data-usage-range={range} gap={12} fixed>
958
+ <div
959
+ className={frameCss.filterTabs}
960
+ role="group"
961
+ aria-label={t("usage.range")}
962
+ data-filter="usage-range"
963
+ >
815
964
  {USAGE_RANGES.map((option) => (
816
- <button
965
+ <FilterChip
817
966
  key={option}
818
- type="button"
819
- role="radio"
820
- aria-checked={range === option}
821
- data-range={option}
822
- className={styling.className(styles.tab, range === option && styles.tabActive)}
823
- onClick={() => {
967
+ active={option === range}
968
+ label={rangeLabel(option, t)}
969
+ tab={option}
970
+ onSelect={() => {
824
971
  onRange(option);
825
972
  }}
826
- >
827
- {rangeLabel(option, t)}
828
- </button>
973
+ />
829
974
  ))}
830
975
  </div>
831
- <div {...styling.props(styles.tabs)} role="tablist">
976
+ <div
977
+ className={frameCss.filterTabs}
978
+ role="group"
979
+ aria-label={t("usage.dimension")}
980
+ data-filter="usage-tabs"
981
+ >
832
982
  {items.map((item) => (
833
- <button
983
+ <FilterChip
834
984
  key={item}
835
- type="button"
836
- role="tab"
837
- data-usage-tab={item}
838
- aria-selected={tab === item}
839
- className={styling.className(styles.tab, tab === item && styles.tabActive)}
840
- onClick={() => {
985
+ active={item === tab}
986
+ label={labels[item]}
987
+ tab={item}
988
+ onSelect={() => {
841
989
  onTab(item);
842
990
  }}
843
- >
844
- {labels[item]}
845
- </button>
991
+ />
846
992
  ))}
847
993
  </div>
994
+ {tab === "sessions" ? (
995
+ <div
996
+ className={frameCss.filterTabs}
997
+ role="group"
998
+ aria-label={t("filter.subagents")}
999
+ data-filter="usage-subagents"
1000
+ >
1001
+ <FilterChip
1002
+ active={onlySubagents}
1003
+ label={t("usage.onlySubagents")}
1004
+ onSelect={() => {
1005
+ onOnlySubagents(!onlySubagents);
1006
+ }}
1007
+ />
1008
+ </div>
1009
+ ) : null}
848
1010
  {loading ? (
849
- <p {...styling.props(styles.status)} data-usage-status="loading">
1011
+ <Text data-usage-status="loading" as="p" size="md" tone="tertiary">
850
1012
  {t("usage.loading")}
851
- </p>
1013
+ </Text>
852
1014
  ) : null}
853
1015
  {error === null ? null : (
854
- <p {...styling.props(styles.failure)} data-usage-status="error" role="alert">
1016
+ <Text data-usage-status="error" role="alert" as="p" size="md" tone="danger">
855
1017
  {error}
856
- </p>
1018
+ </Text>
857
1019
  )}
858
1020
  {report === null ? null : tab === "overview" ? (
859
1021
  <UsageOverview report={report} t={t} />
860
1022
  ) : (
861
1023
  <UsageList rows={rows} t={t} withActivity={tab === "sessions"} />
862
1024
  )}
863
- </div>
1025
+ </Stack>
864
1026
  );
865
1027
  }