@morlay/ui-conversation-manager 0.1.5 → 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,21 +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,
12
+ Input,
9
13
  Modal,
10
14
  Row,
11
- SearchInput,
12
15
  Spinner,
13
16
  Stack,
14
- Tabs,
15
17
  Tag,
16
18
  Text,
17
19
  relativeTime,
18
- } from "@morlay/dsh-client-ui-primitives/client";
20
+ } from "@morlay/dsh-client-ui-primitives";
19
21
  import type { InjectFace, PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots";
20
22
  import type { SessionId } from "@deepseek-ai/dsh-session";
21
23
  import {
@@ -27,6 +29,7 @@ import {
27
29
  type UsageRangeKey,
28
30
  type UsageTotals,
29
31
  } from "./controller.ts";
32
+ import frameCss from "./ConversationManagerPage.module.css";
30
33
  import { formatCount, formatPercent, formatTokens } from "./format.ts";
31
34
 
32
35
  // 一页的行数(会话列表)。
@@ -72,6 +75,69 @@ function failureText(error: unknown, t: Translate): string {
72
75
  return t("failure.other", { reason: error instanceof Error ? error.message : String(error) });
73
76
  }
74
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
+
75
141
  export function ConversationManagerPage({
76
142
  t,
77
143
  listRows,
@@ -212,7 +278,11 @@ export function ConversationManagerPage({
212
278
  };
213
279
 
214
280
  if (rowsPage === null) {
215
- return <Text as="p" size="md" tone="tertiary">{rowsFailure ?? t("loading")}</Text>;
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
+ );
216
286
  }
217
287
 
218
288
  const now = Date.now();
@@ -224,250 +294,298 @@ export function ConversationManagerPage({
224
294
  const matched = rows;
225
295
  const confirmed = confirming;
226
296
 
297
+ // 一份会话都没有(也没在搜索):标题行与搜索行都没有可做的事,整个面板就让给这个状态。
298
+ if (total === 0 && settledQuery === "") {
299
+ return <PageStatus t={t} status="empty" text={t("empty")} />;
300
+ }
301
+
227
302
  return (
228
- <Stack data-view={view} fill pad="page" gap={12}>
229
- <Row gap={12} justify="between" fixed>
230
- <Text as="h1" size="lg" weight="medium">{t("title")}</Text>
231
- <Tabs
232
- label={t("title")}
233
- items={[
234
- { value: "sessions", label: t("view.sessions") },
235
- { value: "usage", label: t("view.usage") },
236
- ]}
237
- value={view}
238
- onChange={(next) => {
239
- if (next === "usage") openUsage();
240
- else setView("sessions");
241
- }}
242
- />
243
- <Row gap={8} fixed>
244
- <Button
245
- variant="outline"
246
- size="sm"
247
- data-action="import"
248
- disabled={importing}
249
- aria-busy={importing}
250
- aria-label={importing ? t("importing") : t("import")}
251
- onClick={() => {
252
- fileRef.current?.click();
253
- }}
254
- >
255
- {importing ? t("importing") : t("import")}
256
- </Button>
257
- <Button
258
- variant="outline"
259
- size="sm"
260
- data-action="gc"
261
- disabled={gcPhase !== "idle"}
262
- aria-label={t("gc.button")}
263
- onClick={() => {
264
- setFailure(null);
265
- setNotice(null);
266
- setGcPhase("confirm");
267
- }}
268
- >
269
- {t("gc.button")}
270
- </Button>
271
- </Row>
272
- <input
273
- ref={fileRef}
274
- type="file"
275
- accept=".zip,application/zip"
276
- hidden
277
- onChange={(event) => {
278
- const file = event.currentTarget.files?.[0];
279
- event.currentTarget.value = "";
280
- if (file === undefined) return;
281
- setImporting(true);
282
- setFailure(null);
283
- setNotice(null);
284
- void importZip(file)
285
- .then(
286
- () => {
287
- setNotice(t("imported"));
288
- },
289
- (error: unknown) => {
290
- setFailure(failureText(error, t));
291
- },
292
- )
293
- .finally(() => {
294
- setImporting(false);
295
- });
296
- }}
297
- />
298
- </Row>
299
- {view === "usage" ? (
300
- <Stack data-scroll="page" gap={12} scroll>
301
- <UsageView
302
- report={usage}
303
- loading={usageLoading}
304
- error={usageError}
305
- tab={usageTab}
306
- onTab={setUsageTab}
307
- range={usageRange}
308
- onRange={requestUsage}
309
- onlySubagents={usageOnlySubagents}
310
- onOnlySubagents={setUsageOnlySubagents}
311
- t={t}
312
- />
313
- </Stack>
314
- ) : (
315
- <>
316
- <Row gap={16} fixed>
317
- <SearchInput
318
- width="search"
319
- data-filter="search"
320
- type="search"
321
- icon={<IconSearchOutlineRegular />}
322
- value={query}
323
- placeholder={t("search")}
324
- aria-label={t("search")}
325
- onChange={(event) => {
326
- setQuery(event.currentTarget.value);
327
- setPage(1);
328
- }}
329
- />
330
- <span data-filter="subagents">
331
- <Checkbox
332
- checked={showSubagents}
333
- label={t("showSubagents")}
334
- onChange={(next) => {
335
- setShowSubagents(next);
336
- setPage(1);
337
- }}
338
- />
339
- </span>
340
- </Row>
341
- <Stack data-scroll="page" gap={12} scroll>
342
- {notice === null ? null : (
343
- <Text data-notice="result" as="p" size="md" tone="tertiary">
344
- {notice}
345
- </Text>
346
- )}
347
- {failure === null ? null : (
348
- <Text data-failure="result" role="alert" as="p" size="md" tone="danger">
349
- {failure}
350
- </Text>
351
- )}
352
- {total === 0 ? (
353
- <Text data-status="empty" as="p" size="md" tone="tertiary">
354
- {t(settledQuery === "" ? "empty" : "emptySearch")}
355
- </Text>
356
- ) : null}
357
- {visible.length > 0 ? (
358
- <Stack as="ul" gap={4} plain>
359
- {visible.map((row) => (
360
- <Row
361
- as="li"
362
- key={row.id}
363
- gap={16}
364
- justify="between"
365
- boxed
366
- pad="row"
367
- fixed
368
- data-session-id={String(row.id)}
369
- data-archived={row.archived ? "true" : "false"}
370
- data-subagent={row.subagent ? "true" : "false"}
371
- >
372
- <Stack gap={2} grow>
373
- <Row gap={8} grow>
374
- <Text size="md" truncate>{row.title}</Text>
375
- {row.archived ? <Tag tone="neutral">{t("archived")}</Tag> : null}
376
- {row.subagent ? <Tag tone="quiet">{t("subagent")}</Tag> : null}
377
- </Row>
378
- <Text size="sm" tone="tertiary">
379
- {[row.workspace, timeLabel(row.updatedAt, now, t)].join(" · ")}
380
- </Text>
381
- </Stack>
382
- <Row gap={8} fixed>
383
- {row.archived ? (
384
- <Button
385
- variant="outline"
386
- size="sm"
387
- data-action="unarchive"
388
- aria-label={t("unarchiveNamed", { title: row.title })}
389
- onClick={() => {
390
- run(unarchive(row.id));
391
- }}
392
- >
393
- {t("unarchive")}
394
- </Button>
395
- ) : (
396
- <Button
397
- variant="outline"
398
- size="sm"
399
- data-action="archive"
400
- aria-label={t("archiveNamed", { title: row.title })}
401
- onClick={() => {
402
- run(archive(row.id));
403
- }}
404
- >
405
- {t("archive")}
406
- </Button>
407
- )}
408
- <Button
409
- variant="outline"
410
- size="sm"
411
- data-action="export"
412
- aria-label={t("exportNamed", { title: row.title })}
413
- onClick={() => {
414
- run(exportZip(row.id));
415
- }}
416
- >
417
- {t("export")}
418
- </Button>
419
- <Button
420
- variant="outline"
421
- size="sm"
422
- disabled={!row.archived}
423
- data-action="remove"
424
- aria-label={t("removeNamed", { title: row.title })}
425
- onClick={() => {
426
- setFailure(null);
427
- setNotice(null);
428
- setConfirming(row);
429
- }}
430
- >
431
- {t("remove")}
432
- </Button>
433
- </Row>
434
- </Row>
435
- ))}
436
- </Stack>
437
- ) : null}
438
- {matched.length > 0 ? (
439
- <Row
440
- data-pagination=""
441
- data-page-current={currentPage}
442
- data-page-total={pageCount} gap={8} justify="end" fixed>
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}>
443
310
  <Button
444
- variant="ghost"
311
+ variant="outline"
445
312
  size="sm"
446
- disabled={currentPage <= 1}
313
+ data-action="import"
314
+ disabled={importing}
315
+ aria-busy={importing}
316
+ aria-label={importing ? t("importing") : t("import")}
447
317
  onClick={() => {
448
- setPage(currentPage - 1);
318
+ fileRef.current?.click();
449
319
  }}
450
320
  >
451
- {t("page.previous")}
321
+ {importing ? t("importing") : t("import")}
452
322
  </Button>
453
- <Text size="sm" tone="tertiary" tabular>
454
- {t("page.label", { page: currentPage, total: pageCount })}
455
- </Text>
456
323
  <Button
457
- variant="ghost"
324
+ variant="outline"
458
325
  size="sm"
459
- disabled={currentPage >= pageCount}
326
+ data-action="gc"
327
+ disabled={gcPhase !== "idle"}
328
+ aria-label={t("gc.button")}
460
329
  onClick={() => {
461
- setPage(currentPage + 1);
330
+ setFailure(null);
331
+ setNotice(null);
332
+ setGcPhase("confirm");
462
333
  }}
463
334
  >
464
- {t("page.next")}
335
+ {t("gc.button")}
465
336
  </Button>
466
- </Row>
467
- ) : null}
468
- </Stack>
469
- </>
470
- )}
337
+ </div>
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
+ )}
586
+ </div>
587
+ </div>
588
+ </div>
471
589
  <Modal
472
590
  open={confirmed !== null}
473
591
  onClose={() => {
@@ -528,10 +646,12 @@ export function ConversationManagerPage({
528
646
  <Modal open={gcPhase === "running"} onClose={() => {}} headless title={t("gc.title")}>
529
647
  <Stack align="center" gap={14} pad="blocking">
530
648
  <Spinner label={t("gc.running")} />
531
- <Text as="p" size="md">{t("gc.running")}</Text>
649
+ <Text as="p" size="md">
650
+ {t("gc.running")}
651
+ </Text>
532
652
  </Stack>
533
653
  </Modal>
534
- </Stack>
654
+ </section>
535
655
  );
536
656
  }
537
657
 
@@ -710,7 +830,9 @@ function UsageRow({
710
830
  {...(subagent === undefined ? {} : { "data-subagent": subagent ? "true" : "false" })}
711
831
  >
712
832
  <Row gap={8} grow>
713
- <Text size="sm" tone="tertiary" truncate>{label}</Text>
833
+ <Text size="sm" tone="tertiary" truncate>
834
+ {label}
835
+ </Text>
714
836
  {subagent === true ? <Tag tone="quiet">{t("subagent")}</Tag> : null}
715
837
  </Row>
716
838
  <Row gap="wide" wrap>
@@ -718,8 +840,12 @@ function UsageRow({
718
840
  <Stack
719
841
  key={metric.key}
720
842
  data-usage-cell={metric.key}
721
- data-usage-value={metric.value} gap={2}>
722
- <Text size="sm" tone="tertiary">{metric.label}</Text>
843
+ data-usage-value={metric.value}
844
+ gap={2}
845
+ >
846
+ <Text size="sm" tone="tertiary">
847
+ {metric.label}
848
+ </Text>
723
849
  <Text size="lg" weight="medium" tabular>
724
850
  {metric.kind === "percent"
725
851
  ? formatPercent(metric.value)
@@ -829,31 +955,57 @@ function UsageView({
829
955
  .slice(0, USAGE_SESSION_ROWS);
830
956
  return (
831
957
  <Stack data-usage-view={tab} data-usage-range={range} gap={12} fixed>
832
- <Tabs
833
- role="radiogroup"
834
- label={t("usage.range")}
835
- items={USAGE_RANGES.map((option) => ({ value: option, label: rangeLabel(option, t) }))}
836
- value={range}
837
- onChange={(next) => {
838
- onRange(next as UsageRange);
839
- }}
840
- />
841
- <Tabs
842
- label={t("usage.range")}
843
- items={items.map((item) => ({ value: item, label: labels[item] }))}
844
- value={tab}
845
- onChange={(next) => {
846
- onTab(next as UsageTab);
847
- }}
848
- />
958
+ <div
959
+ className={frameCss.filterTabs}
960
+ role="group"
961
+ aria-label={t("usage.range")}
962
+ data-filter="usage-range"
963
+ >
964
+ {USAGE_RANGES.map((option) => (
965
+ <FilterChip
966
+ key={option}
967
+ active={option === range}
968
+ label={rangeLabel(option, t)}
969
+ tab={option}
970
+ onSelect={() => {
971
+ onRange(option);
972
+ }}
973
+ />
974
+ ))}
975
+ </div>
976
+ <div
977
+ className={frameCss.filterTabs}
978
+ role="group"
979
+ aria-label={t("usage.dimension")}
980
+ data-filter="usage-tabs"
981
+ >
982
+ {items.map((item) => (
983
+ <FilterChip
984
+ key={item}
985
+ active={item === tab}
986
+ label={labels[item]}
987
+ tab={item}
988
+ onSelect={() => {
989
+ onTab(item);
990
+ }}
991
+ />
992
+ ))}
993
+ </div>
849
994
  {tab === "sessions" ? (
850
- <Row data-filter="usage-subagents" gap={16} fixed>
851
- <Checkbox
852
- checked={onlySubagents}
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}
853
1003
  label={t("usage.onlySubagents")}
854
- onChange={onOnlySubagents}
1004
+ onSelect={() => {
1005
+ onOnlySubagents(!onlySubagents);
1006
+ }}
855
1007
  />
856
- </Row>
1008
+ </div>
857
1009
  ) : null}
858
1010
  {loading ? (
859
1011
  <Text data-usage-status="loading" as="p" size="md" tone="tertiary">