@circuitwall/jarela 2.4.0 → 2.5.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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-path-routes-manifest.json +1 -1
- package/.next/standalone/.next/build-manifest.json +2 -2
- package/.next/standalone/.next/prerender-manifest.json +4 -4
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +14 -2
- package/.next/standalone/.next/server/app/_not-found.rsc +16 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +16 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +16 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/dashboard/pricing-task/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/documents/sources/[id]/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/documents/sources/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/events/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extensions/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/config/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/secrets/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/packages/defaults/[id]/route.js +28 -4
- package/.next/standalone/.next/server/app/api/v1/packages/reload/route.js +28 -4
- package/.next/standalone/.next/server/app/api/v1/packages/route.js +28 -4
- package/.next/standalone/.next/server/app/api/v1/page-capture/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +578 -58
- package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/run/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/route.js +118 -26
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/tools/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/watchers/[id]/run/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/watchers/route.js +1 -1
- package/.next/standalone/.next/server/app/page.js +31202 -30960
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app-paths-manifest.json +1 -1
- package/.next/standalone/.next/server/chunks/2471.js +17 -17
- package/.next/standalone/.next/server/chunks/2653.js +80 -1
- package/.next/standalone/.next/server/chunks/3399.js +10695 -10695
- package/.next/standalone/.next/server/chunks/3566.js +1 -1
- package/.next/standalone/.next/server/chunks/4189.js +1 -1
- package/.next/standalone/.next/server/chunks/4508.js +2 -2
- package/.next/standalone/.next/server/chunks/4596.js +28 -4
- package/.next/standalone/.next/server/chunks/46.js +18 -3
- package/.next/standalone/.next/server/chunks/466.js +53 -7
- package/.next/standalone/.next/server/chunks/4955.js +30 -3
- package/.next/standalone/.next/server/chunks/5585.js +28 -4
- package/.next/standalone/.next/server/chunks/6649.js +1 -1
- package/.next/standalone/.next/server/chunks/9164.js +65 -14
- package/.next/standalone/.next/server/chunks/9590.js +18 -3
- package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
- package/.next/standalone/.next/server/middleware.js +18 -3
- package/.next/standalone/.next/server/pages/404.html +14 -2
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/preview-props.json +3 -3
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/{1395-e4a99912b8f83044.js → 1395-bfe1f3ef760c0f50.js} +54 -8
- package/.next/standalone/.next/static/chunks/app/{layout-9bc0b10127cb7d42.js → layout-7b24ad13faa3944b.js} +69 -2
- package/.next/standalone/.next/static/chunks/app/{page-04748847c6ff866f.js → page-2ef8d75d40eb4311.js} +10661 -10352
- package/.next/standalone/.next/static/css/c160ea862133acd2.css +5 -0
- package/.next/standalone/docs/adoption/changesets.json +2756 -0
- package/.next/standalone/package.json +1 -1
- package/CHANGELOG.md +33 -0
- package/api/client.models.test.ts +10 -0
- package/api/client.startup.test.ts +129 -0
- package/api/client.ts +51 -9
- package/api/thread-metadata.test.ts +72 -0
- package/api/types.ts +6 -3
- package/app/api/v1/providers/[provider]/models/route.test.ts +216 -6
- package/app/api/v1/providers/[provider]/models/route.ts +259 -40
- package/app/api/v1/threads/[thread_id]/route.ts +26 -23
- package/app/globals.css +119 -29
- package/app/layout.tsx +12 -0
- package/components/agents/AgentsPanel.tsx +5 -13
- package/components/chat/ChatView.tsx +9 -18
- package/components/chat/InputBar.tsx +1 -1
- package/components/chat/MessageBubble.tsx +1 -1
- package/components/chat/MessageList.test.tsx +45 -4
- package/components/chat/MessageList.tsx +123 -89
- package/components/chat/ToolList.tsx +88 -84
- package/components/chat/useThreadActivity.test.tsx +2 -2
- package/components/chat/useThreadActivity.ts +2 -2
- package/components/chat/useThreadData.ts +1 -30
- package/components/chat/useThreadMetadataPolling.test.tsx +128 -0
- package/components/chat/useThreadMetadataPolling.ts +50 -0
- package/components/chat/useUserProfileAndAgent.test.tsx +31 -0
- package/components/chat/useUserProfileAndAgent.ts +13 -2
- package/components/dashboard/DashboardPanel.tsx +1 -1
- package/components/dashboard/Series.tsx +34 -21
- package/components/dashboard/SummaryCards.tsx +1 -1
- package/components/layout/AppShell.tsx +22 -7
- package/components/layout/MenuPanel.tsx +14 -24
- package/components/profile/ProfilePanel.tsx +2 -2
- package/components/settings/AppearancePanel.tsx +70 -3
- package/components/settings/SettingsPanel.tsx +2 -2
- package/components/tools/ToolsPanel.tsx +2 -7
- package/components/ui/AppearancePreferences.test.tsx +68 -0
- package/components/ui/BootScreen.test.tsx +44 -0
- package/components/ui/BootScreen.tsx +24 -11
- package/components/ui/NotificationStatus.test.tsx +2 -2
- package/components/ui/NotificationStatus.tsx +5 -5
- package/components/ui/SubTabBar.tsx +2 -3
- package/contexts/ThemeContext.tsx +84 -1
- package/hooks/useAgentSession.test.ts +12 -1
- package/hooks/useAgentSession.ts +6 -4
- package/hooks/useUrlSync.test.ts +13 -0
- package/hooks/useUrlSync.ts +0 -4
- package/lib/db/migrations.test.ts +61 -0
- package/lib/db/migrations.ts +13 -0
- package/lib/embeddings/performance.test.ts +114 -0
- package/lib/stores/dashboard-metrics-cache.test.ts +99 -0
- package/lib/stores/dashboard-metrics.ts +30 -4
- package/lib/stores/message-embedding-jobs.test.ts +27 -0
- package/lib/stores/message-embedding-jobs.ts +5 -3
- package/lib/stores/message-usage.test.ts +21 -1
- package/lib/stores/pricing-cache.test.ts +31 -0
- package/lib/stores/pricing.ts +3 -0
- package/lib/tools/packages/default-packages.test.ts +21 -0
- package/lib/tools/packages/optional-package.test.ts +72 -0
- package/lib/tools/packages/optional-package.ts +33 -4
- package/lib/ui/navigate.test.ts +13 -0
- package/lib/ui/navigate.ts +10 -2
- package/package.json +1 -1
- package/.next/standalone/.next/static/css/2669e8744cc8c212.css +0 -5
- /package/.next/standalone/.next/static/{hwujW4hR1b-r3k5WdMxZO → 544OFJV-q0RC6Gjgje4LT}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{hwujW4hR1b-r3k5WdMxZO → 544OFJV-q0RC6Gjgje4LT}/_ssgManifest.js +0 -0
|
@@ -434,83 +434,87 @@ function ToolCallCard({ group, startedAt }: { group: ToolCallGroup; startedAt: n
|
|
|
434
434
|
const transcriptSteps = group.steps.length > 0 ? group.steps : delegateTranscript?.steps ?? stepsFromResult(displayedResult);
|
|
435
435
|
return (
|
|
436
436
|
<div className="min-w-0 max-w-full">
|
|
437
|
-
<MetaRow fullWidth onClick={() => setOpen((v) => !v)} expanded={open}>
|
|
438
|
-
<
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
{summary}
|
|
455
|
-
</span>
|
|
456
|
-
{group.status === "async" && (
|
|
457
|
-
<Badge
|
|
458
|
-
tone="info"
|
|
459
|
-
className="uppercase tracking-wide"
|
|
460
|
-
title="running in the background"
|
|
461
|
-
>
|
|
462
|
-
bg
|
|
463
|
-
</Badge>
|
|
464
|
-
)}
|
|
465
|
-
{codexJob?.status === "running" && (
|
|
466
|
-
<Badge tone="info" className="uppercase tracking-wide" title="Codex job is updating live">
|
|
467
|
-
live
|
|
468
|
-
</Badge>
|
|
469
|
-
)}
|
|
470
|
-
{codexJob?.status === "running" && (
|
|
471
|
-
<button
|
|
472
|
-
type="button"
|
|
473
|
-
onClick={(event) => { event.stopPropagation(); void cancelCodexJob(); }}
|
|
474
|
-
className="control-tap shrink-0 text-fg-faint hover:text-rose-600 dark:hover:text-rose-300"
|
|
475
|
-
title="Cancel Codex task"
|
|
476
|
-
aria-label="Cancel Codex task"
|
|
477
|
-
>
|
|
478
|
-
<X size={12} />
|
|
479
|
-
</button>
|
|
480
|
-
)}
|
|
481
|
-
{group.readByAgent && (
|
|
482
|
-
<Badge
|
|
483
|
-
tone="neutral"
|
|
484
|
-
className="uppercase tracking-wide"
|
|
485
|
-
title="agent read the background result"
|
|
486
|
-
>
|
|
487
|
-
read
|
|
488
|
-
</Badge>
|
|
489
|
-
)}
|
|
490
|
-
{isToolResultRefEnvelope(group.result) && (
|
|
491
|
-
<Badge
|
|
492
|
-
tone="info"
|
|
493
|
-
className="uppercase tracking-wide"
|
|
494
|
-
title="large result is shown as a preview with a file reference"
|
|
495
|
-
>
|
|
496
|
-
preview
|
|
497
|
-
</Badge>
|
|
498
|
-
)}
|
|
499
|
-
{delegateTranscript?.awaitingUserAnswers && (
|
|
500
|
-
<Badge
|
|
501
|
-
tone="warning"
|
|
502
|
-
className="uppercase tracking-wide"
|
|
503
|
-
title="Claude asked design questions and is waiting for the user"
|
|
437
|
+
<MetaRow fullWidth onClick={() => setOpen((v) => !v)} expanded={open} className="flex-wrap">
|
|
438
|
+
<span className="flex min-w-0 flex-1 basis-40 items-center gap-1.5">
|
|
439
|
+
<CollapseChevron open={open} size={9} className="text-fg-faint/70" />
|
|
440
|
+
<ToolIcon toolName={group.name} />
|
|
441
|
+
<span className="min-w-0 max-w-[50%] truncate font-medium text-fg-muted" title={group.name}>{group.name}</span>
|
|
442
|
+
<span
|
|
443
|
+
className="relative truncate text-fg-faint font-normal flex-1 min-w-0"
|
|
444
|
+
title={summaryTitle || undefined}
|
|
445
|
+
style={{
|
|
446
|
+
// Fade the tail of the args summary into the surface so the
|
|
447
|
+
// status pills + progress bar on the right stay legible even
|
|
448
|
+
// when the args fill the available space.
|
|
449
|
+
WebkitMaskImage:
|
|
450
|
+
"linear-gradient(to right, black 0, black calc(100% - 28px), transparent 100%)",
|
|
451
|
+
maskImage:
|
|
452
|
+
"linear-gradient(to right, black 0, black calc(100% - 28px), transparent 100%)",
|
|
453
|
+
}}
|
|
504
454
|
>
|
|
505
|
-
|
|
506
|
-
</
|
|
507
|
-
|
|
508
|
-
<
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
455
|
+
{summary}
|
|
456
|
+
</span>
|
|
457
|
+
</span>
|
|
458
|
+
<span className="ml-auto flex max-w-full flex-wrap items-center gap-1.5">
|
|
459
|
+
{group.status === "async" && (
|
|
460
|
+
<Badge
|
|
461
|
+
tone="info"
|
|
462
|
+
className="uppercase tracking-wide"
|
|
463
|
+
title="running in the background"
|
|
464
|
+
>
|
|
465
|
+
bg
|
|
466
|
+
</Badge>
|
|
467
|
+
)}
|
|
468
|
+
{codexJob?.status === "running" && (
|
|
469
|
+
<Badge tone="info" className="uppercase tracking-wide" title="Codex job is updating live">
|
|
470
|
+
live
|
|
471
|
+
</Badge>
|
|
472
|
+
)}
|
|
473
|
+
{codexJob?.status === "running" && (
|
|
474
|
+
<button
|
|
475
|
+
type="button"
|
|
476
|
+
onClick={(event) => { event.stopPropagation(); void cancelCodexJob(); }}
|
|
477
|
+
className="control-tap shrink-0 text-fg-faint hover:text-rose-600 dark:hover:text-rose-300"
|
|
478
|
+
title="Cancel Codex task"
|
|
479
|
+
aria-label="Cancel Codex task"
|
|
480
|
+
>
|
|
481
|
+
<X size={12} />
|
|
482
|
+
</button>
|
|
483
|
+
)}
|
|
484
|
+
{group.readByAgent && (
|
|
485
|
+
<Badge
|
|
486
|
+
tone="neutral"
|
|
487
|
+
className="uppercase tracking-wide"
|
|
488
|
+
title="agent read the background result"
|
|
489
|
+
>
|
|
490
|
+
read
|
|
491
|
+
</Badge>
|
|
492
|
+
)}
|
|
493
|
+
{isToolResultRefEnvelope(group.result) && (
|
|
494
|
+
<Badge
|
|
495
|
+
tone="info"
|
|
496
|
+
className="uppercase tracking-wide"
|
|
497
|
+
title="large result is shown as a preview with a file reference"
|
|
498
|
+
>
|
|
499
|
+
preview
|
|
500
|
+
</Badge>
|
|
501
|
+
)}
|
|
502
|
+
{delegateTranscript?.awaitingUserAnswers && (
|
|
503
|
+
<Badge
|
|
504
|
+
tone="warning"
|
|
505
|
+
className="uppercase tracking-wide"
|
|
506
|
+
title="Claude asked design questions and is waiting for the user"
|
|
507
|
+
>
|
|
508
|
+
asks
|
|
509
|
+
</Badge>
|
|
510
|
+
)}
|
|
511
|
+
<StatusIndicator
|
|
512
|
+
status={group.status}
|
|
513
|
+
deadlineMs={group.deadlineMs}
|
|
514
|
+
startedAt={startedAt}
|
|
515
|
+
stepCount={transcriptSteps.length}
|
|
516
|
+
/>
|
|
517
|
+
</span>
|
|
514
518
|
</MetaRow>
|
|
515
519
|
{delegateTranscript ? (
|
|
516
520
|
<DelegateTurnCards
|
|
@@ -526,7 +530,7 @@ function ToolCallCard({ group, startedAt }: { group: ToolCallGroup; startedAt: n
|
|
|
526
530
|
<LiveTranscript steps={transcriptSteps} />
|
|
527
531
|
)}
|
|
528
532
|
{open && (
|
|
529
|
-
<div className="mt-0.5 rounded border border-border/40 bg-surface-2/30 px-2 py-1.5 space-y-1.5 text-[10px]">
|
|
533
|
+
<div className="mt-0.5 min-w-0 rounded border border-border/40 bg-surface-2/30 px-2 py-1.5 space-y-1.5 text-[10px] [overflow-wrap:anywhere]">
|
|
530
534
|
<WorkflowProgressDetails group={group} />
|
|
531
535
|
{!hasWorkflowView(group) && (<>
|
|
532
536
|
{hasVisibleArgs(group.args) ? (
|
|
@@ -1073,16 +1077,16 @@ function DetailSection({
|
|
|
1073
1077
|
<div className="min-w-0">
|
|
1074
1078
|
<div className="text-[10px] uppercase tracking-wide text-fg-faint mb-0.5">{label}</div>
|
|
1075
1079
|
{kvEntries ? (
|
|
1076
|
-
<dl className={`grid grid-cols-[
|
|
1080
|
+
<dl className={`grid grid-cols-[fit-content(35%)_minmax(0,1fr)] gap-x-2 gap-y-0.5 text-[11px] font-mono ${toneClass}`}>
|
|
1077
1081
|
{kvEntries.map(([k, v]) => (
|
|
1078
1082
|
<div key={k} className="contents">
|
|
1079
|
-
<dt className="text-fg-faint">{k}</dt>
|
|
1080
|
-
<dd className="min-w-0
|
|
1083
|
+
<dt className="min-w-0 text-fg-faint">{k}</dt>
|
|
1084
|
+
<dd className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]">{renderKvValue(v)}</dd>
|
|
1081
1085
|
</div>
|
|
1082
1086
|
))}
|
|
1083
1087
|
</dl>
|
|
1084
1088
|
) : (
|
|
1085
|
-
<pre className={`text-[11px] font-mono whitespace-pre-wrap
|
|
1089
|
+
<pre className={`text-[11px] font-mono whitespace-pre-wrap [overflow-wrap:anywhere] max-w-full ${toneClass}`}>
|
|
1086
1090
|
{formatPayload(value)}
|
|
1087
1091
|
</pre>
|
|
1088
1092
|
)}
|
|
@@ -1198,11 +1202,11 @@ function renderKvValue(v: unknown): ReactNode {
|
|
|
1198
1202
|
const entries = Object.entries(obj).filter(([k]) => !HIDDEN_ARG_KEYS.has(k));
|
|
1199
1203
|
if (entries.length === 0) return <span className="text-fg-faint">{"{}"}</span>;
|
|
1200
1204
|
return (
|
|
1201
|
-
<dl className="grid grid-cols-[
|
|
1205
|
+
<dl className="grid grid-cols-[fit-content(35%)_minmax(0,1fr)] gap-x-2 gap-y-0.5">
|
|
1202
1206
|
{entries.map(([k, vv]) => (
|
|
1203
1207
|
<div key={k} className="contents">
|
|
1204
|
-
<dt className="text-fg-faint">{k}</dt>
|
|
1205
|
-
<dd className="min-w-0
|
|
1208
|
+
<dt className="min-w-0 text-fg-faint">{k}</dt>
|
|
1209
|
+
<dd className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]">{renderKvValue(vv)}</dd>
|
|
1206
1210
|
</div>
|
|
1207
1211
|
))}
|
|
1208
1212
|
</dl>
|
|
@@ -9,7 +9,7 @@ vi.mock("@/api/client", () => ({
|
|
|
9
9
|
api: {
|
|
10
10
|
threads: {
|
|
11
11
|
activity: (...args: unknown[]) => mocks.activity(...args),
|
|
12
|
-
|
|
12
|
+
metadata: (...args: unknown[]) => mocks.getThread(...args),
|
|
13
13
|
},
|
|
14
14
|
},
|
|
15
15
|
}));
|
|
@@ -87,7 +87,7 @@ describe("useThreadActivity", () => {
|
|
|
87
87
|
expect(meta.setCompactionPending).toHaveBeenCalledWith(true);
|
|
88
88
|
expect(meta.setManualCompactionPending).toHaveBeenCalledWith(true);
|
|
89
89
|
await tick(1_500);
|
|
90
|
-
expect(mocks.getThread).toHaveBeenCalledWith("t1"
|
|
90
|
+
expect(mocks.getThread).toHaveBeenCalledWith("t1");
|
|
91
91
|
expect(meta.setManualCompactionPending).toHaveBeenLastCalledWith(false);
|
|
92
92
|
});
|
|
93
93
|
|
|
@@ -73,9 +73,9 @@ export function useThreadActivity({ threadId, attach, isLive, lastSettledAt, met
|
|
|
73
73
|
// pull the new boundary and recap.
|
|
74
74
|
d.metaApplier.setCompactionPending?.(false);
|
|
75
75
|
d.metaApplier.setManualCompactionPending?.(false);
|
|
76
|
-
api.threads.
|
|
76
|
+
api.threads.metadata(threadId)
|
|
77
77
|
.then((meta) => { if (!cancelled) applyThreadMeta(deps.current.metaApplier, meta); })
|
|
78
|
-
.catch(() => {
|
|
78
|
+
.catch((err) => { if (!cancelled) console.error("activity metadata refresh failed", err); });
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
schedule(next.busy ? BUSY_POLL_MS : IDLE_POLL_MS);
|
|
@@ -110,7 +110,7 @@ export function useThreadData({ threadId, attach }: Params): ThreadDataApi {
|
|
|
110
110
|
setCompactionPending(false);
|
|
111
111
|
setManualCompactionPending(false);
|
|
112
112
|
setContextWindowTokens(null);
|
|
113
|
-
api.threads.get(threadId).then((d) => {
|
|
113
|
+
api.threads.get(threadId, { startup: true }).then((d) => {
|
|
114
114
|
if (cancelled) return;
|
|
115
115
|
setMessages(d.messages);
|
|
116
116
|
setHasMore(d.has_more);
|
|
@@ -163,35 +163,6 @@ export function useThreadData({ threadId, attach }: Params): ThreadDataApi {
|
|
|
163
163
|
}
|
|
164
164
|
}, [threadId]);
|
|
165
165
|
|
|
166
|
-
useEffect(() => {
|
|
167
|
-
if (!threadId) return;
|
|
168
|
-
const summaryFresh = hotSinceSeq !== null && warmSummaryBeforeSeq === hotSinceSeq;
|
|
169
|
-
const summaryStale = warmSummaryPending && hotSinceSeq !== null && !summaryFresh;
|
|
170
|
-
if (!summaryStale && !compactionPending && !manualCompactionPending) {
|
|
171
|
-
if (warmSummaryPending && hotSinceSeq !== null && summaryFresh) setWarmSummaryPending(false);
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
let cancelled = false;
|
|
175
|
-
const timer = window.setInterval(() => {
|
|
176
|
-
void api.threads.get(threadId).then((d) => {
|
|
177
|
-
if (cancelled) return;
|
|
178
|
-
applyThreadMeta(metaApplier, d);
|
|
179
|
-
const refreshedSummaryIsFresh = d.hot_since_seq != null
|
|
180
|
-
&& d.warm_summary_before_seq === d.hot_since_seq;
|
|
181
|
-
if (!d.hot_since_seq || refreshedSummaryIsFresh) {
|
|
182
|
-
setWarmSummaryPending(false);
|
|
183
|
-
}
|
|
184
|
-
}).catch((err) => {
|
|
185
|
-
if (!cancelled) console.error("warm summary refresh poll failed", err);
|
|
186
|
-
});
|
|
187
|
-
}, 1600);
|
|
188
|
-
return () => {
|
|
189
|
-
cancelled = true;
|
|
190
|
-
window.clearInterval(timer);
|
|
191
|
-
};
|
|
192
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
193
|
-
}, [threadId, warmSummaryPending, compactionPending, manualCompactionPending, hotSince, hotSinceSeq, warmSummaryBefore, warmSummaryBeforeSeq]);
|
|
194
|
-
|
|
195
166
|
const loadOlder = useCallback(async () => {
|
|
196
167
|
if (!threadId || loadingMore || !hasMore || messages.length === 0) return;
|
|
197
168
|
setLoadingMore(true);
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, renderHook } from "@testing-library/react";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({ metadata: vi.fn() }));
|
|
6
|
+
vi.mock("@/api/client", () => ({ api: { threads: { metadata: mocks.metadata } } }));
|
|
7
|
+
const { useThreadMetadataPolling } = await import("./useThreadMetadataPolling");
|
|
8
|
+
|
|
9
|
+
function applier() {
|
|
10
|
+
return {
|
|
11
|
+
setHotSince: vi.fn(), setHotSinceSeq: vi.fn(), setWarmSummary: vi.fn(),
|
|
12
|
+
setWarmSummaryBefore: vi.fn(), setWarmSummaryBeforeSeq: vi.fn(),
|
|
13
|
+
setWarmSummaryComputedAt: vi.fn(), setWarmSummarySourceMessages: vi.fn(),
|
|
14
|
+
setWarmSummarySourceChars: vi.fn(), setContextWindowTokens: vi.fn(),
|
|
15
|
+
setWarmSummaryPending: vi.fn(), setCompactionPending: vi.fn(),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function advance(ms: number) {
|
|
20
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(ms); });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("thread metadata polling", () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.useFakeTimers();
|
|
26
|
+
mocks.metadata.mockReset().mockResolvedValue({ hot_since_seq: 4, warm_summary_before_seq: 4 });
|
|
27
|
+
Object.defineProperty(document, "hidden", { configurable: true, value: false });
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.useRealTimers();
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
Reflect.deleteProperty(document, "hidden");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("uses metadata reads and stops when disabled", async () => {
|
|
36
|
+
const meta = applier();
|
|
37
|
+
const { rerender } = renderHook(({ enabled }) => useThreadMetadataPolling("t1", enabled, meta),
|
|
38
|
+
{ initialProps: { enabled: true } });
|
|
39
|
+
await advance(1600);
|
|
40
|
+
expect(mocks.metadata).toHaveBeenCalledWith("t1", expect.any(AbortSignal));
|
|
41
|
+
expect(meta.setHotSinceSeq).toHaveBeenCalledWith(4);
|
|
42
|
+
expect(meta.setWarmSummaryPending).toHaveBeenCalledWith(false);
|
|
43
|
+
rerender({ enabled: false });
|
|
44
|
+
await advance(5000);
|
|
45
|
+
expect(mocks.metadata).toHaveBeenCalledTimes(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("waits for each response before scheduling another read", async () => {
|
|
49
|
+
let resolve!: (value: object) => void;
|
|
50
|
+
mocks.metadata.mockReturnValueOnce(new Promise((r) => { resolve = r; }));
|
|
51
|
+
renderHook(() => useThreadMetadataPolling("t1", true, applier()));
|
|
52
|
+
await advance(8000);
|
|
53
|
+
expect(mocks.metadata).toHaveBeenCalledTimes(1);
|
|
54
|
+
await act(async () => resolve({}));
|
|
55
|
+
await advance(1599);
|
|
56
|
+
expect(mocks.metadata).toHaveBeenCalledTimes(1);
|
|
57
|
+
await advance(1);
|
|
58
|
+
expect(mocks.metadata).toHaveBeenCalledTimes(2);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("pauses while hidden and refreshes immediately on visibility", async () => {
|
|
62
|
+
Object.defineProperty(document, "hidden", { configurable: true, value: true });
|
|
63
|
+
renderHook(() => useThreadMetadataPolling("t1", true, applier()));
|
|
64
|
+
await advance(5000);
|
|
65
|
+
expect(mocks.metadata).not.toHaveBeenCalled();
|
|
66
|
+
Object.defineProperty(document, "hidden", { configurable: true, value: false });
|
|
67
|
+
await act(async () => document.dispatchEvent(new Event("visibilitychange")));
|
|
68
|
+
expect(mocks.metadata).toHaveBeenCalledTimes(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("does not overlap an in-flight read when the page becomes visible again", async () => {
|
|
72
|
+
let resolve!: (value: object) => void;
|
|
73
|
+
mocks.metadata.mockReturnValueOnce(new Promise((r) => { resolve = r; }));
|
|
74
|
+
const { unmount } = renderHook(() => useThreadMetadataPolling("t1", true, applier()));
|
|
75
|
+
await advance(1600);
|
|
76
|
+
Object.defineProperty(document, "hidden", { configurable: true, value: true });
|
|
77
|
+
await act(async () => document.dispatchEvent(new Event("visibilitychange")));
|
|
78
|
+
Object.defineProperty(document, "hidden", { configurable: true, value: false });
|
|
79
|
+
await act(async () => document.dispatchEvent(new Event("visibilitychange")));
|
|
80
|
+
expect(mocks.metadata).toHaveBeenCalledTimes(1);
|
|
81
|
+
await act(async () => resolve({}));
|
|
82
|
+
await advance(1600);
|
|
83
|
+
expect(mocks.metadata).toHaveBeenCalledTimes(2);
|
|
84
|
+
unmount();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("uses the latest metadata setters without restarting the polling timer", async () => {
|
|
88
|
+
const first = applier();
|
|
89
|
+
const second = applier();
|
|
90
|
+
const { rerender } = renderHook(({ meta }) => useThreadMetadataPolling("t1", true, meta),
|
|
91
|
+
{ initialProps: { meta: first } });
|
|
92
|
+
await advance(800);
|
|
93
|
+
rerender({ meta: second });
|
|
94
|
+
await advance(800);
|
|
95
|
+
expect(mocks.metadata).toHaveBeenCalledTimes(1);
|
|
96
|
+
expect(first.setHotSinceSeq).not.toHaveBeenCalled();
|
|
97
|
+
expect(second.setHotSinceSeq).toHaveBeenCalledWith(4);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("aborts and ignores stale responses after switching threads", async () => {
|
|
101
|
+
let resolve!: (value: object) => void;
|
|
102
|
+
mocks.metadata.mockReturnValueOnce(new Promise((r) => { resolve = r; }));
|
|
103
|
+
const meta = applier();
|
|
104
|
+
const { rerender } = renderHook(({ id }) => useThreadMetadataPolling(id, true, meta),
|
|
105
|
+
{ initialProps: { id: "t1" } });
|
|
106
|
+
await advance(1600);
|
|
107
|
+
const signal = mocks.metadata.mock.calls[0][1] as AbortSignal;
|
|
108
|
+
rerender({ id: "t2" });
|
|
109
|
+
expect(signal.aborted).toBe(true);
|
|
110
|
+
await act(async () => resolve({ hot_since_seq: 99 }));
|
|
111
|
+
expect(meta.setHotSinceSeq).not.toHaveBeenCalled();
|
|
112
|
+
await advance(1600);
|
|
113
|
+
expect(mocks.metadata).toHaveBeenLastCalledWith("t2", expect.any(AbortSignal));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("logs failures and retries without replacing metadata with defaults", async () => {
|
|
117
|
+
const error = new Error("offline");
|
|
118
|
+
mocks.metadata.mockRejectedValueOnce(error);
|
|
119
|
+
const log = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
120
|
+
const meta = applier();
|
|
121
|
+
renderHook(() => useThreadMetadataPolling("t1", true, meta));
|
|
122
|
+
await advance(1600);
|
|
123
|
+
expect(log).toHaveBeenCalledWith("thread metadata refresh failed", error);
|
|
124
|
+
expect(meta.setHotSinceSeq).not.toHaveBeenCalled();
|
|
125
|
+
await advance(1600);
|
|
126
|
+
expect(meta.setHotSinceSeq).toHaveBeenCalledWith(4);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { api } from "@/api/client";
|
|
4
|
+
import { applyThreadMeta, type ThreadMetaApplier } from "./chat-helpers";
|
|
5
|
+
|
|
6
|
+
export function useThreadMetadataPolling(
|
|
7
|
+
threadId: string | null,
|
|
8
|
+
enabled: boolean,
|
|
9
|
+
metaApplier: ThreadMetaApplier,
|
|
10
|
+
) {
|
|
11
|
+
const metaRef = useRef(metaApplier);
|
|
12
|
+
metaRef.current = metaApplier;
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!threadId || !enabled) return;
|
|
16
|
+
const targetThreadId = threadId;
|
|
17
|
+
let cancelled = false;
|
|
18
|
+
let inFlight = false;
|
|
19
|
+
let timer: number | undefined;
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
const schedule = () => {
|
|
22
|
+
if (!cancelled && !document.hidden) timer = window.setTimeout(tick, 1600);
|
|
23
|
+
};
|
|
24
|
+
async function tick() {
|
|
25
|
+
if (cancelled || inFlight || document.hidden) return;
|
|
26
|
+
inFlight = true;
|
|
27
|
+
try {
|
|
28
|
+
const metadata = await api.threads.metadata(targetThreadId, controller.signal);
|
|
29
|
+
if (!cancelled) applyThreadMeta(metaRef.current, metadata);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (!cancelled) console.error("thread metadata refresh failed", err);
|
|
32
|
+
} finally {
|
|
33
|
+
inFlight = false;
|
|
34
|
+
schedule();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const onVisibilityChange = () => {
|
|
38
|
+
window.clearTimeout(timer);
|
|
39
|
+
if (!document.hidden) void tick();
|
|
40
|
+
};
|
|
41
|
+
schedule();
|
|
42
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
43
|
+
return () => {
|
|
44
|
+
cancelled = true;
|
|
45
|
+
controller.abort();
|
|
46
|
+
window.clearTimeout(timer);
|
|
47
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
48
|
+
};
|
|
49
|
+
}, [threadId, enabled]);
|
|
50
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
3
|
+
import { expect, it, vi } from "vitest";
|
|
4
|
+
import { useUserProfileAndAgent } from "./useUserProfileAndAgent";
|
|
5
|
+
|
|
6
|
+
const { getAgent, getProfile } = vi.hoisted(() => ({
|
|
7
|
+
getAgent: vi.fn(),
|
|
8
|
+
getProfile: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
vi.mock("@/api/client", () => ({
|
|
11
|
+
api: { agents: { get: getAgent }, profile: { get: getProfile } },
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
it("warms startup reads and ignores an agent response after switching agents", async () => {
|
|
15
|
+
let resolveOld!: (config: { id: string }) => void;
|
|
16
|
+
getProfile.mockResolvedValue({ name: "Test user" });
|
|
17
|
+
getAgent.mockImplementation((id: string) => id === "old"
|
|
18
|
+
? new Promise((resolve) => { resolveOld = resolve; })
|
|
19
|
+
: Promise.resolve({ id }));
|
|
20
|
+
const { result, rerender } = renderHook(
|
|
21
|
+
({ id }) => useUserProfileAndAgent(id),
|
|
22
|
+
{ initialProps: { id: "old" } },
|
|
23
|
+
);
|
|
24
|
+
rerender({ id: "new" });
|
|
25
|
+
await waitFor(() => expect(result.current.agentConfig?.id).toBe("new"));
|
|
26
|
+
await act(async () => resolveOld({ id: "old" }));
|
|
27
|
+
expect(result.current.agentConfig?.id).toBe("new");
|
|
28
|
+
expect(result.current.agentConfigLoading).toBe(false);
|
|
29
|
+
expect(getProfile).toHaveBeenCalledWith({ startup: true });
|
|
30
|
+
expect(getAgent).toHaveBeenCalledWith("new", { startup: true });
|
|
31
|
+
});
|
|
@@ -18,7 +18,12 @@ export function useUserProfileAndAgent(agentId: string | null): Result {
|
|
|
18
18
|
|
|
19
19
|
useEffect(() => {
|
|
20
20
|
setProfileLoading(true);
|
|
21
|
-
|
|
21
|
+
let cancelled = false;
|
|
22
|
+
api.profile.get({ startup: true })
|
|
23
|
+
.then((profile) => { if (!cancelled) setUserProfile(profile); })
|
|
24
|
+
.catch((error) => { if (!cancelled) console.error(error); })
|
|
25
|
+
.finally(() => { if (!cancelled) setProfileLoading(false); });
|
|
26
|
+
return () => { cancelled = true; };
|
|
22
27
|
}, []);
|
|
23
28
|
|
|
24
29
|
useEffect(() => {
|
|
@@ -27,8 +32,14 @@ export function useUserProfileAndAgent(agentId: string | null): Result {
|
|
|
27
32
|
setAgentConfigLoading(false);
|
|
28
33
|
return;
|
|
29
34
|
}
|
|
35
|
+
let cancelled = false;
|
|
36
|
+
setAgentConfig(null);
|
|
30
37
|
setAgentConfigLoading(true);
|
|
31
|
-
api.agents.get(agentId
|
|
38
|
+
api.agents.get(agentId, { startup: true })
|
|
39
|
+
.then((config) => { if (!cancelled) setAgentConfig(config); })
|
|
40
|
+
.catch((error) => { if (!cancelled) console.error(error); })
|
|
41
|
+
.finally(() => { if (!cancelled) setAgentConfigLoading(false); });
|
|
42
|
+
return () => { cancelled = true; };
|
|
32
43
|
}, [agentId]);
|
|
33
44
|
|
|
34
45
|
return { userProfile, profileLoading, agentConfig, agentConfigLoading };
|
|
@@ -54,7 +54,7 @@ export function DashboardPanel() {
|
|
|
54
54
|
return (
|
|
55
55
|
<div className="relative h-full overflow-y-auto p-4 md:p-6 space-y-4">
|
|
56
56
|
<div className="mx-auto max-w-7xl space-y-4">
|
|
57
|
-
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
|
57
|
+
<div className="dashboard-atmosphere pointer-events-none absolute inset-0 overflow-hidden">
|
|
58
58
|
<div className="absolute -top-20 -right-20 w-72 h-72 rounded-full bg-cyan-500/10 blur-3xl" />
|
|
59
59
|
<div className="absolute top-64 -left-24 w-80 h-80 rounded-full bg-emerald-500/10 blur-3xl" />
|
|
60
60
|
</div>
|
|
@@ -15,6 +15,7 @@ export function InteractiveTokenChart({ series, selectedDay, onSelectDay }: Toke
|
|
|
15
15
|
const [barsReady, setBarsReady] = useState(false);
|
|
16
16
|
const maxTotal = series.reduce((max, p) => Math.max(max, p.input_tokens_est + p.output_tokens_est), 0);
|
|
17
17
|
const active = hovered != null ? series[hovered] : null;
|
|
18
|
+
const labelInterval = Math.max(1, Math.ceil(series.length / 7));
|
|
18
19
|
const animationSignature = series.map((s) => `${s.day}:${s.input_tokens_est}:${s.output_tokens_est}`).join("|");
|
|
19
20
|
|
|
20
21
|
useEffect(() => {
|
|
@@ -30,29 +31,35 @@ export function InteractiveTokenChart({ series, selectedDay, onSelectDay }: Toke
|
|
|
30
31
|
return (
|
|
31
32
|
<div>
|
|
32
33
|
<div className="relative h-48 rounded-xl bg-[var(--bg-primary)]/35 p-2">
|
|
34
|
+
<div className="h-full overflow-x-auto overscroll-x-contain">
|
|
35
|
+
<div className="h-full flex items-end gap-1 pt-5" style={{ minWidth: `${series.length * 18}px` }}>
|
|
36
|
+
{series.map((point, idx) => (
|
|
37
|
+
<TokenBar
|
|
38
|
+
key={point.day}
|
|
39
|
+
point={point}
|
|
40
|
+
idx={idx}
|
|
41
|
+
maxTotal={maxTotal}
|
|
42
|
+
barsReady={barsReady}
|
|
43
|
+
hovered={hovered === idx}
|
|
44
|
+
selected={selectedDay === point.day}
|
|
45
|
+
showDateLabel={idx % labelInterval === 0 || idx === series.length - 1}
|
|
46
|
+
onHover={setHovered}
|
|
47
|
+
onSelectDay={onSelectDay}
|
|
48
|
+
/>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
33
52
|
<div
|
|
34
|
-
className={`absolute right-2 top-2 inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[10px] tracking-wide transition-opacity ${
|
|
53
|
+
className={`pointer-events-none absolute right-2 top-2 inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[10px] tracking-wide transition-opacity ${
|
|
35
54
|
active ? "bg-[var(--bg-secondary)]/85 text-[var(--text-primary)] opacity-100" : "text-[var(--text-secondary)] opacity-60"
|
|
36
55
|
}`}
|
|
37
56
|
>
|
|
38
|
-
{active
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
<TokenBar
|
|
45
|
-
key={point.day}
|
|
46
|
-
point={point}
|
|
47
|
-
idx={idx}
|
|
48
|
-
maxTotal={maxTotal}
|
|
49
|
-
barsReady={barsReady}
|
|
50
|
-
hovered={hovered === idx}
|
|
51
|
-
selected={selectedDay === point.day}
|
|
52
|
-
onHover={setHovered}
|
|
53
|
-
onSelectDay={onSelectDay}
|
|
54
|
-
/>
|
|
55
|
-
))}
|
|
57
|
+
{active ? `${active.day} · ${formatInt(active.input_tokens_est + active.output_tokens_est)} tokens` : (
|
|
58
|
+
<>
|
|
59
|
+
<span className="hidden sm:inline">hover bars for details</span>
|
|
60
|
+
<span className="sm:hidden">tap bars; swipe for dates</span>
|
|
61
|
+
</>
|
|
62
|
+
)}
|
|
56
63
|
</div>
|
|
57
64
|
</div>
|
|
58
65
|
<TokenChartLegend active={active} />
|
|
@@ -67,11 +74,12 @@ interface TokenBarProps {
|
|
|
67
74
|
barsReady: boolean;
|
|
68
75
|
hovered: boolean;
|
|
69
76
|
selected: boolean;
|
|
77
|
+
showDateLabel: boolean;
|
|
70
78
|
onHover: (i: number | null) => void;
|
|
71
79
|
onSelectDay?: (day: string) => void;
|
|
72
80
|
}
|
|
73
81
|
|
|
74
|
-
function TokenBar({ point, idx, maxTotal, barsReady, hovered, selected, onHover, onSelectDay }: TokenBarProps) {
|
|
82
|
+
function TokenBar({ point, idx, maxTotal, barsReady, hovered, selected, showDateLabel, onHover, onSelectDay }: TokenBarProps) {
|
|
75
83
|
const total = point.input_tokens_est + point.output_tokens_est;
|
|
76
84
|
const hasData = total > 0;
|
|
77
85
|
const totalHeight = hasData && maxTotal > 0 ? Math.max(4, Math.round((total / maxTotal) * 150)) : 0;
|
|
@@ -152,7 +160,12 @@ function TokenBar({ point, idx, maxTotal, barsReady, hovered, selected, onHover,
|
|
|
152
160
|
) : null}
|
|
153
161
|
</div>
|
|
154
162
|
<span className={`mt-1 text-[10px] truncate w-full text-center ${hasData ? "text-[var(--text-secondary)]" : "text-[var(--text-secondary)]/50"}`}>
|
|
155
|
-
{
|
|
163
|
+
{showDateLabel && (
|
|
164
|
+
<>
|
|
165
|
+
<span className="hidden sm:inline">{point.day.slice(5)}</span>
|
|
166
|
+
<span className="sm:hidden">{point.day.slice(8)}</span>
|
|
167
|
+
</>
|
|
168
|
+
)}
|
|
156
169
|
</span>
|
|
157
170
|
</div>
|
|
158
171
|
);
|
|
@@ -17,7 +17,7 @@ export function SummaryStrip({ data, currencyInfo, windowTokenTotal, activeDays,
|
|
|
17
17
|
const cacheHitPct = (data.summary.cache_hit_rate * 100).toFixed(1);
|
|
18
18
|
const hasCacheStats = data.summary.cache_read_tokens > 0;
|
|
19
19
|
return (
|
|
20
|
-
<div className={`relative grid gap-2 ${hasCacheStats ? "md:grid-cols-5" : "md:grid-cols-4"}`}>
|
|
20
|
+
<div className={`relative grid grid-cols-2 gap-2 ${hasCacheStats ? "md:grid-cols-5" : "md:grid-cols-4"}`}>
|
|
21
21
|
<InsightChip
|
|
22
22
|
label="Window tokens"
|
|
23
23
|
value={formatInt(windowTokenTotal)}
|