@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.
Files changed (140) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +1 -1
  3. package/.next/standalone/.next/build-manifest.json +2 -2
  4. package/.next/standalone/.next/prerender-manifest.json +4 -4
  5. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.html +14 -2
  13. package/.next/standalone/.next/server/app/_not-found.rsc +16 -4
  14. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +16 -4
  15. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +16 -4
  16. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  18. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +1 -1
  19. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js +1 -1
  20. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js +1 -1
  21. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js +1 -1
  22. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js +1 -1
  23. package/.next/standalone/.next/server/app/api/v1/dashboard/pricing-task/route.js +1 -1
  24. package/.next/standalone/.next/server/app/api/v1/documents/sources/[id]/route.js +1 -1
  25. package/.next/standalone/.next/server/app/api/v1/documents/sources/route.js +1 -1
  26. package/.next/standalone/.next/server/app/api/v1/events/route.js +1 -1
  27. package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js +1 -1
  28. package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js +1 -1
  29. package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js +1 -1
  30. package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js +1 -1
  31. package/.next/standalone/.next/server/app/api/v1/extensions/route.js +1 -1
  32. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/config/route.js +1 -1
  33. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/route.js +1 -1
  34. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/secrets/route.js +1 -1
  35. package/.next/standalone/.next/server/app/api/v1/packages/defaults/[id]/route.js +28 -4
  36. package/.next/standalone/.next/server/app/api/v1/packages/reload/route.js +28 -4
  37. package/.next/standalone/.next/server/app/api/v1/packages/route.js +28 -4
  38. package/.next/standalone/.next/server/app/api/v1/page-capture/route.js +1 -1
  39. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +578 -58
  40. package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/run/route.js +1 -1
  41. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/route.js +118 -26
  42. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +1 -1
  43. package/.next/standalone/.next/server/app/api/v1/tools/route.js +1 -1
  44. package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/api/v1/watchers/[id]/run/route.js +1 -1
  46. package/.next/standalone/.next/server/app/api/v1/watchers/route.js +1 -1
  47. package/.next/standalone/.next/server/app/page.js +31202 -30960
  48. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app-paths-manifest.json +1 -1
  51. package/.next/standalone/.next/server/chunks/2471.js +17 -17
  52. package/.next/standalone/.next/server/chunks/2653.js +80 -1
  53. package/.next/standalone/.next/server/chunks/3399.js +10695 -10695
  54. package/.next/standalone/.next/server/chunks/3566.js +1 -1
  55. package/.next/standalone/.next/server/chunks/4189.js +1 -1
  56. package/.next/standalone/.next/server/chunks/4508.js +2 -2
  57. package/.next/standalone/.next/server/chunks/4596.js +28 -4
  58. package/.next/standalone/.next/server/chunks/46.js +18 -3
  59. package/.next/standalone/.next/server/chunks/466.js +53 -7
  60. package/.next/standalone/.next/server/chunks/4955.js +30 -3
  61. package/.next/standalone/.next/server/chunks/5585.js +28 -4
  62. package/.next/standalone/.next/server/chunks/6649.js +1 -1
  63. package/.next/standalone/.next/server/chunks/9164.js +65 -14
  64. package/.next/standalone/.next/server/chunks/9590.js +18 -3
  65. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  66. package/.next/standalone/.next/server/middleware.js +18 -3
  67. package/.next/standalone/.next/server/pages/404.html +14 -2
  68. package/.next/standalone/.next/server/pages/500.html +1 -1
  69. package/.next/standalone/.next/server/preview-props.json +3 -3
  70. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  71. package/.next/standalone/.next/static/chunks/{1395-e4a99912b8f83044.js → 1395-bfe1f3ef760c0f50.js} +54 -8
  72. package/.next/standalone/.next/static/chunks/app/{layout-9bc0b10127cb7d42.js → layout-7b24ad13faa3944b.js} +69 -2
  73. package/.next/standalone/.next/static/chunks/app/{page-04748847c6ff866f.js → page-2ef8d75d40eb4311.js} +10661 -10352
  74. package/.next/standalone/.next/static/css/c160ea862133acd2.css +5 -0
  75. package/.next/standalone/docs/adoption/changesets.json +2756 -0
  76. package/.next/standalone/package.json +1 -1
  77. package/CHANGELOG.md +33 -0
  78. package/api/client.models.test.ts +10 -0
  79. package/api/client.startup.test.ts +129 -0
  80. package/api/client.ts +51 -9
  81. package/api/thread-metadata.test.ts +72 -0
  82. package/api/types.ts +6 -3
  83. package/app/api/v1/providers/[provider]/models/route.test.ts +216 -6
  84. package/app/api/v1/providers/[provider]/models/route.ts +259 -40
  85. package/app/api/v1/threads/[thread_id]/route.ts +26 -23
  86. package/app/globals.css +119 -29
  87. package/app/layout.tsx +12 -0
  88. package/components/agents/AgentsPanel.tsx +5 -13
  89. package/components/chat/ChatView.tsx +9 -18
  90. package/components/chat/InputBar.tsx +1 -1
  91. package/components/chat/MessageBubble.tsx +1 -1
  92. package/components/chat/MessageList.test.tsx +45 -4
  93. package/components/chat/MessageList.tsx +123 -89
  94. package/components/chat/ToolList.tsx +88 -84
  95. package/components/chat/useThreadActivity.test.tsx +2 -2
  96. package/components/chat/useThreadActivity.ts +2 -2
  97. package/components/chat/useThreadData.ts +1 -30
  98. package/components/chat/useThreadMetadataPolling.test.tsx +128 -0
  99. package/components/chat/useThreadMetadataPolling.ts +50 -0
  100. package/components/chat/useUserProfileAndAgent.test.tsx +31 -0
  101. package/components/chat/useUserProfileAndAgent.ts +13 -2
  102. package/components/dashboard/DashboardPanel.tsx +1 -1
  103. package/components/dashboard/Series.tsx +34 -21
  104. package/components/dashboard/SummaryCards.tsx +1 -1
  105. package/components/layout/AppShell.tsx +22 -7
  106. package/components/layout/MenuPanel.tsx +14 -24
  107. package/components/profile/ProfilePanel.tsx +2 -2
  108. package/components/settings/AppearancePanel.tsx +70 -3
  109. package/components/settings/SettingsPanel.tsx +2 -2
  110. package/components/tools/ToolsPanel.tsx +2 -7
  111. package/components/ui/AppearancePreferences.test.tsx +68 -0
  112. package/components/ui/BootScreen.test.tsx +44 -0
  113. package/components/ui/BootScreen.tsx +24 -11
  114. package/components/ui/NotificationStatus.test.tsx +2 -2
  115. package/components/ui/NotificationStatus.tsx +5 -5
  116. package/components/ui/SubTabBar.tsx +2 -3
  117. package/contexts/ThemeContext.tsx +84 -1
  118. package/hooks/useAgentSession.test.ts +12 -1
  119. package/hooks/useAgentSession.ts +6 -4
  120. package/hooks/useUrlSync.test.ts +13 -0
  121. package/hooks/useUrlSync.ts +0 -4
  122. package/lib/db/migrations.test.ts +61 -0
  123. package/lib/db/migrations.ts +13 -0
  124. package/lib/embeddings/performance.test.ts +114 -0
  125. package/lib/stores/dashboard-metrics-cache.test.ts +99 -0
  126. package/lib/stores/dashboard-metrics.ts +30 -4
  127. package/lib/stores/message-embedding-jobs.test.ts +27 -0
  128. package/lib/stores/message-embedding-jobs.ts +5 -3
  129. package/lib/stores/message-usage.test.ts +21 -1
  130. package/lib/stores/pricing-cache.test.ts +31 -0
  131. package/lib/stores/pricing.ts +3 -0
  132. package/lib/tools/packages/default-packages.test.ts +21 -0
  133. package/lib/tools/packages/optional-package.test.ts +72 -0
  134. package/lib/tools/packages/optional-package.ts +33 -4
  135. package/lib/ui/navigate.test.ts +13 -0
  136. package/lib/ui/navigate.ts +10 -2
  137. package/package.json +1 -1
  138. package/.next/standalone/.next/static/css/2669e8744cc8c212.css +0 -5
  139. /package/.next/standalone/.next/static/{hwujW4hR1b-r3k5WdMxZO → 544OFJV-q0RC6Gjgje4LT}/_buildManifest.js +0 -0
  140. /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
- <CollapseChevron open={open} size={9} className="text-fg-faint/70" />
439
- <ToolIcon toolName={group.name} />
440
- <span className="font-medium text-fg-muted shrink-0">{group.name}</span>
441
- <span
442
- className="relative truncate text-fg-faint font-normal flex-1 min-w-0"
443
- title={summaryTitle || undefined}
444
- style={{
445
- // Fade the tail of the args summary into the surface so the
446
- // status pills + progress bar on the right stay legible even
447
- // when the args fill the available space.
448
- WebkitMaskImage:
449
- "linear-gradient(to right, black 0, black calc(100% - 28px), transparent 100%)",
450
- maskImage:
451
- "linear-gradient(to right, black 0, black calc(100% - 28px), transparent 100%)",
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
- asks
506
- </Badge>
507
- )}
508
- <StatusIndicator
509
- status={group.status}
510
- deadlineMs={group.deadlineMs}
511
- startedAt={startedAt}
512
- stepCount={transcriptSteps.length}
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-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5 text-[11px] font-mono ${toneClass}`}>
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 break-words whitespace-pre-wrap">{renderKvValue(v)}</dd>
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 break-words max-w-full ${toneClass}`}>
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-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5">
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 break-words whitespace-pre-wrap">{renderKvValue(vv)}</dd>
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
- get: (...args: unknown[]) => mocks.getThread(...args),
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", { limit: 1 });
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.get(threadId, { limit: 1 })
76
+ api.threads.metadata(threadId)
77
77
  .then((meta) => { if (!cancelled) applyThreadMeta(deps.current.metaApplier, meta); })
78
- .catch(() => { /* next poll retries */ });
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
- api.profile.get().then(setUserProfile).catch(console.error).finally(() => setProfileLoading(false));
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).then(setAgentConfig).catch(console.error).finally(() => setAgentConfigLoading(false));
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
- ? `${active.day} · ${formatInt(active.input_tokens_est + active.output_tokens_est)} tokens`
40
- : "hover bars for details"}
41
- </div>
42
- <div className="h-full flex items-end gap-1 pt-5">
43
- {series.map((point, idx) => (
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
- {point.day.slice(5)}
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)}