@schlessera/brain-ui-react 0.18.0 → 0.20.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 (86) hide show
  1. package/dist/components/activity/activity-page.d.ts +11 -0
  2. package/dist/components/activity/activity-page.d.ts.map +1 -0
  3. package/dist/components/activity/activity-page.js +243 -0
  4. package/dist/components/activity/activity-page.js.map +1 -0
  5. package/dist/components/activity/digest-card.d.ts +10 -0
  6. package/dist/components/activity/digest-card.d.ts.map +1 -0
  7. package/dist/components/activity/digest-card.js +55 -0
  8. package/dist/components/activity/digest-card.js.map +1 -0
  9. package/dist/components/activity/push-toggle.d.ts +2 -0
  10. package/dist/components/activity/push-toggle.d.ts.map +1 -0
  11. package/dist/components/activity/push-toggle.js +121 -0
  12. package/dist/components/activity/push-toggle.js.map +1 -0
  13. package/dist/components/activity/span-bits.d.ts +76 -0
  14. package/dist/components/activity/span-bits.d.ts.map +1 -0
  15. package/dist/components/activity/span-bits.js +127 -0
  16. package/dist/components/activity/span-bits.js.map +1 -0
  17. package/dist/components/chat/chat-page.d.ts.map +1 -1
  18. package/dist/components/chat/chat-page.js +4 -1
  19. package/dist/components/chat/chat-page.js.map +1 -1
  20. package/dist/components/chat/session-drawer.d.ts.map +1 -1
  21. package/dist/components/chat/session-drawer.js +1 -15
  22. package/dist/components/chat/session-drawer.js.map +1 -1
  23. package/dist/components/chat/subagent-view.d.ts +18 -0
  24. package/dist/components/chat/subagent-view.d.ts.map +1 -0
  25. package/dist/components/chat/subagent-view.js +83 -0
  26. package/dist/components/chat/subagent-view.js.map +1 -0
  27. package/dist/components/chat/tool-call-timeline.d.ts.map +1 -1
  28. package/dist/components/chat/tool-call-timeline.js +40 -4
  29. package/dist/components/chat/tool-call-timeline.js.map +1 -1
  30. package/dist/components/chat/tool-views.d.ts +4 -0
  31. package/dist/components/chat/tool-views.d.ts.map +1 -1
  32. package/dist/components/chat/tool-views.js +26 -0
  33. package/dist/components/chat/tool-views.js.map +1 -1
  34. package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -1
  35. package/dist/components/layout/mobile-tab-bar.js +10 -4
  36. package/dist/components/layout/mobile-tab-bar.js.map +1 -1
  37. package/dist/components/layout/side-rail.d.ts.map +1 -1
  38. package/dist/components/layout/side-rail.js +7 -4
  39. package/dist/components/layout/side-rail.js.map +1 -1
  40. package/dist/components/settings/models-tab.d.ts +15 -0
  41. package/dist/components/settings/models-tab.d.ts.map +1 -1
  42. package/dist/components/settings/models-tab.js +100 -21
  43. package/dist/components/settings/models-tab.js.map +1 -1
  44. package/dist/hooks/use-now.d.ts +8 -0
  45. package/dist/hooks/use-now.d.ts.map +1 -0
  46. package/dist/hooks/use-now.js +32 -0
  47. package/dist/hooks/use-now.js.map +1 -0
  48. package/dist/hooks/use-websocket.d.ts.map +1 -1
  49. package/dist/hooks/use-websocket.js +46 -0
  50. package/dist/hooks/use-websocket.js.map +1 -1
  51. package/dist/index.d.ts +1 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +1 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/lib/api-client.d.ts +67 -1
  56. package/dist/lib/api-client.d.ts.map +1 -1
  57. package/dist/lib/api-client.js +41 -0
  58. package/dist/lib/api-client.js.map +1 -1
  59. package/dist/stores/activity-store.d.ts +108 -0
  60. package/dist/stores/activity-store.d.ts.map +1 -0
  61. package/dist/stores/activity-store.js +359 -0
  62. package/dist/stores/activity-store.js.map +1 -0
  63. package/dist/stores/ui-store.d.ts +9 -1
  64. package/dist/stores/ui-store.d.ts.map +1 -1
  65. package/dist/stores/ui-store.js +3 -0
  66. package/dist/stores/ui-store.js.map +1 -1
  67. package/dist/styles.css +1 -1
  68. package/package.json +2 -2
  69. package/src/components/activity/activity-page.tsx +623 -0
  70. package/src/components/activity/digest-card.tsx +105 -0
  71. package/src/components/activity/push-toggle.tsx +157 -0
  72. package/src/components/activity/span-bits.tsx +194 -0
  73. package/src/components/chat/chat-page.tsx +17 -0
  74. package/src/components/chat/session-drawer.tsx +1 -12
  75. package/src/components/chat/subagent-view.tsx +239 -0
  76. package/src/components/chat/tool-call-timeline.tsx +61 -3
  77. package/src/components/chat/tool-views.tsx +22 -0
  78. package/src/components/layout/mobile-tab-bar.tsx +25 -5
  79. package/src/components/layout/side-rail.tsx +17 -0
  80. package/src/components/settings/models-tab.tsx +135 -21
  81. package/src/hooks/use-now.ts +32 -0
  82. package/src/hooks/use-websocket.ts +44 -0
  83. package/src/index.ts +1 -0
  84. package/src/lib/api-client.ts +111 -0
  85. package/src/stores/activity-store.ts +451 -0
  86. package/src/stores/ui-store.ts +13 -1
@@ -0,0 +1,105 @@
1
+ import { useEffect, useState } from "react";
2
+ import { AlertTriangle, Newspaper, X } from "lucide-react";
3
+
4
+ import { api, type ActivityDigest } from "../../lib/api-client.js";
5
+ import { useUIStore } from "../../stores/ui-store.js";
6
+ import { cn } from "../../lib/utils.js";
7
+ import { digestCostClause } from "./span-bits.js";
8
+
9
+ /**
10
+ * The while-you-were-away card: presented PROACTIVELY on app open when a
11
+ * fresh digest exists (planning decision — built passively, the flow
12
+ * collapses back into asking the agent what happened). Once per digest:
13
+ * dismissing persists server-side, so a phone and a laptop don't each nag.
14
+ * An empty window stays quiet — no card for "nothing happened". The window
15
+ * is labeled: the card says what period it covers, never implies "now".
16
+ */
17
+ export function DigestCard() {
18
+ const [digest, setDigest] = useState<ActivityDigest | null>(null);
19
+ const [visible, setVisible] = useState(false);
20
+ const setActiveView = useUIStore((s) => s.setActiveView);
21
+
22
+ useEffect(() => {
23
+ void api
24
+ .activityDigest()
25
+ .then(({ digest, dismissedAt }) => {
26
+ if (!digest || digest.runs === 0) return;
27
+ if (digest.generatedAt <= dismissedAt) return;
28
+ setDigest(digest);
29
+ setVisible(true);
30
+ })
31
+ .catch(() => {
32
+ // No digest is a quiet state, never an error surface.
33
+ });
34
+ }, []);
35
+
36
+ if (!visible || !digest) return null;
37
+
38
+ function dismiss() {
39
+ setVisible(false);
40
+ void api.activityDigestDismiss().catch(() => {});
41
+ }
42
+
43
+ const windowLabel = `${formatDay(digest.windowStart)} – ${formatDay(digest.windowEnd)}`;
44
+ // Effective-only spend clause; a digest persisted before pricing shipped
45
+ // falls back to its old list-cost clause inside the helper.
46
+ const costClause = digestCostClause(digest);
47
+
48
+ return (
49
+ <div
50
+ className={cn(
51
+ "mx-auto mb-3 w-full max-w-2xl rounded-xl border p-3 text-xs",
52
+ digest.failures > 0
53
+ ? "border-destructive/30 bg-destructive/5"
54
+ : "border-border-subtle bg-surface"
55
+ )}
56
+ >
57
+ <div className="flex items-center gap-2">
58
+ <Newspaper className="h-4 w-4 shrink-0 text-muted-foreground" />
59
+ <span className="font-medium">While you were away</span>
60
+ <span className="text-[10px] text-muted-foreground/60">{windowLabel}</span>
61
+ <button
62
+ type="button"
63
+ onClick={dismiss}
64
+ className="ml-auto rounded p-1 text-muted-foreground transition-colors hover:text-foreground"
65
+ aria-label="Dismiss"
66
+ >
67
+ <X className="h-3.5 w-3.5" />
68
+ </button>
69
+ </div>
70
+ <div className="mt-1.5 text-muted-foreground">
71
+ {digest.runs} run{digest.runs === 1 ? "" : "s"}
72
+ {digest.failures > 0 && (
73
+ <span className="text-destructive"> · {digest.failures} failed</span>
74
+ )}
75
+ {costClause && ` · ${costClause}`}
76
+ </div>
77
+ {digest.notable.length > 0 && (
78
+ <div className="mt-1.5 space-y-0.5">
79
+ {digest.notable.slice(0, 3).map((n) => (
80
+ <div key={n.runId} className="flex items-center gap-1.5 text-[11px]">
81
+ <AlertTriangle className="h-3 w-3 shrink-0 text-destructive" />
82
+ <span className="truncate">
83
+ {n.jobName ?? n.name} — {n.outcome}
84
+ </span>
85
+ </div>
86
+ ))}
87
+ </div>
88
+ )}
89
+ <button
90
+ type="button"
91
+ onClick={() => {
92
+ dismiss();
93
+ setActiveView("activity");
94
+ }}
95
+ className="mt-2 text-[11px] font-medium text-primary hover:underline"
96
+ >
97
+ Open Activity
98
+ </button>
99
+ </div>
100
+ );
101
+ }
102
+
103
+ function formatDay(ms: number): string {
104
+ return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" });
105
+ }
@@ -0,0 +1,157 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Bell, BellOff } from "lucide-react";
3
+
4
+ import { api } from "../../lib/api-client.js";
5
+ import { cn } from "../../lib/utils.js";
6
+
7
+ /**
8
+ * The push enable/disable control, living on the Activity surface (where the
9
+ * alerts it governs land). The permission model is THREE-state, not a bare
10
+ * toggle: once the browser prompt is denied, Chrome silently ignores every
11
+ * further in-page request — so the blocked state must say where the real
12
+ * switch lives (site settings) instead of offering a button that does
13
+ * nothing. Where the platform has no push at all (iOS Safari in-browser,
14
+ * pre-16.4), the control renders disabled-with-explanation, not hidden.
15
+ */
16
+ /** The subscription's bound applicationServerKey, in the base64url form the
17
+ * server hands out — comparable against `pushPublicKey()` directly. */
18
+ function keyToBase64Url(key: ArrayBuffer): string {
19
+ let bin = "";
20
+ for (const b of new Uint8Array(key)) bin += String.fromCharCode(b);
21
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
22
+ }
23
+
24
+ type PushState =
25
+ | "unsupported"
26
+ | "not-asked"
27
+ | "blocked"
28
+ | "subscribed"
29
+ | "unsubscribed";
30
+
31
+ export function PushToggle() {
32
+ const [state, setState] = useState<PushState>("unsupported");
33
+ const [busy, setBusy] = useState(false);
34
+
35
+ useEffect(() => {
36
+ void (async () => {
37
+ if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) {
38
+ setState("unsupported");
39
+ return;
40
+ }
41
+ if (Notification.permission === "denied") {
42
+ setState("blocked");
43
+ return;
44
+ }
45
+ if (Notification.permission === "default") {
46
+ setState("not-asked");
47
+ return;
48
+ }
49
+ const registration = await navigator.serviceWorker.ready;
50
+ const subscription = await registration.pushManager.getSubscription();
51
+ if (subscription) {
52
+ // The browser having a subscription doesn't mean the SERVER can
53
+ // still use it. Two disagreement cases: the server pruned/lost the
54
+ // row (a dead-endpoint send, a DB restore) — healed by re-asserting,
55
+ // an idempotent upsert; or the server's VAPID keypair changed, which
56
+ // makes this subscription permanently unsendable — its
57
+ // applicationServerKey no longer matches, so drop it and surface the
58
+ // re-enable button. Offline, trust the browser's own state.
59
+ try {
60
+ const { publicKey } = await api.pushPublicKey();
61
+ const boundKey = subscription.options.applicationServerKey;
62
+ if (boundKey && keyToBase64Url(boundKey) !== publicKey) {
63
+ await subscription.unsubscribe().catch(() => {});
64
+ setState("unsubscribed");
65
+ return;
66
+ }
67
+ await api.pushSubscribe(subscription.toJSON(), navigator.userAgent.slice(0, 100));
68
+ } catch {
69
+ // Server unreachable — keep the browser's answer.
70
+ }
71
+ }
72
+ setState(subscription ? "subscribed" : "unsubscribed");
73
+ })();
74
+ }, []);
75
+
76
+ async function enable() {
77
+ setBusy(true);
78
+ try {
79
+ const permission = await Notification.requestPermission();
80
+ if (permission === "denied") {
81
+ setState("blocked");
82
+ return;
83
+ }
84
+ if (permission !== "granted") {
85
+ setState("not-asked");
86
+ return;
87
+ }
88
+ const { publicKey } = await api.pushPublicKey();
89
+ const registration = await navigator.serviceWorker.ready;
90
+ const subscription = await registration.pushManager.subscribe({
91
+ userVisibleOnly: true,
92
+ applicationServerKey: publicKey,
93
+ });
94
+ await api.pushSubscribe(subscription.toJSON(), navigator.userAgent.slice(0, 100));
95
+ setState("subscribed");
96
+ } catch {
97
+ setState("unsubscribed");
98
+ } finally {
99
+ setBusy(false);
100
+ }
101
+ }
102
+
103
+ async function disable() {
104
+ setBusy(true);
105
+ try {
106
+ const registration = await navigator.serviceWorker.ready;
107
+ const subscription = await registration.pushManager.getSubscription();
108
+ if (subscription) {
109
+ await api.pushUnsubscribe(subscription.endpoint).catch(() => {});
110
+ await subscription.unsubscribe();
111
+ }
112
+ setState("unsubscribed");
113
+ } finally {
114
+ setBusy(false);
115
+ }
116
+ }
117
+
118
+ if (state === "unsupported") {
119
+ return (
120
+ <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground/60" title="This browser has no web push. On iOS, install the app to the home screen (iOS 16.4+).">
121
+ <BellOff className="h-3.5 w-3.5" />
122
+ No push here
123
+ </div>
124
+ );
125
+ }
126
+
127
+ if (state === "blocked") {
128
+ return (
129
+ <div
130
+ className="flex items-center gap-1.5 text-[11px] text-muted-foreground"
131
+ title="Notifications are blocked at the browser level. Allow them in this site's browser settings, then reload."
132
+ >
133
+ <BellOff className="h-3.5 w-3.5 text-destructive" />
134
+ Blocked in browser settings
135
+ </div>
136
+ );
137
+ }
138
+
139
+ const on = state === "subscribed";
140
+ return (
141
+ <button
142
+ type="button"
143
+ disabled={busy}
144
+ onClick={() => void (on ? disable() : enable())}
145
+ className={cn(
146
+ "flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] transition-colors disabled:opacity-50",
147
+ on
148
+ ? "text-primary hover:bg-surface-raised"
149
+ : "text-muted-foreground hover:bg-surface-raised hover:text-foreground"
150
+ )}
151
+ title={on ? "Push notifications are on for this device" : "Get a push notification when background work fails"}
152
+ >
153
+ {on ? <Bell className="h-3.5 w-3.5" /> : <BellOff className="h-3.5 w-3.5" />}
154
+ {on ? "Push on" : "Enable push"}
155
+ </button>
156
+ );
157
+ }
@@ -0,0 +1,194 @@
1
+ import { useEffect } from "react";
2
+ import { useShallow } from "zustand/react/shallow";
3
+ import { isFailureOutcome, SPAN_TOOL_NAME_PREFIX } from "@schlessera/brain-ui-sdk/protocol";
4
+ import type { ActivitySpan, ActivitySpanOutcome } from "@schlessera/brain-ui-sdk/protocol";
5
+
6
+ import {
7
+ useActivityStore,
8
+ payloadEventsFor,
9
+ loadSpanPayloads,
10
+ } from "../../stores/activity-store.js";
11
+ import { cn } from "../../lib/utils.js";
12
+ import { getToolLabel } from "../chat/tool-views.js";
13
+
14
+ /**
15
+ * THE status dot for activity spans — one outcome→color mapping so every
16
+ * surface (timeline rows, drill-in, activity index) agrees on what a color
17
+ * means: running pulses primary, success is accent, failures (per
18
+ * `isFailureOutcome`) and denied are destructive, cancelled is muted.
19
+ */
20
+ export function SpanStatusDot({
21
+ span,
22
+ outcome,
23
+ running,
24
+ className,
25
+ }: {
26
+ span?: ActivitySpan;
27
+ /** Alternative to `span` when only the outcome is at hand. */
28
+ outcome?: ActivitySpanOutcome | string | null;
29
+ running?: boolean;
30
+ /** Size/position overrides; defaults to h-1.5 w-1.5. */
31
+ className?: string;
32
+ }) {
33
+ const resolved = span ? span.outcome : outcome;
34
+ const isRunning = running ?? (span !== undefined && span.outcome === undefined);
35
+ return (
36
+ <span
37
+ className={cn(
38
+ "h-1.5 w-1.5 shrink-0 rounded-full",
39
+ isRunning
40
+ ? "animate-pulse bg-primary"
41
+ : resolved === "success"
42
+ ? "bg-accent"
43
+ : isFailureOutcome(resolved) || resolved === "denied"
44
+ ? "bg-destructive"
45
+ : "bg-muted-foreground",
46
+ className
47
+ )}
48
+ />
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Display label for a span. Prefers the server-lifted `toolName`; root spans
54
+ * label by kind (a turn or cron root is not a tool call); the last resort
55
+ * un-parses the `execute_tool` naming convention for spans recorded before
56
+ * the field existed.
57
+ */
58
+ export function spanToolLabel(span: ActivitySpan): string {
59
+ if (span.toolName) return getToolLabel(span.toolName);
60
+ if (span.kind === "turn") return "Turn";
61
+ if (span.kind === "cron") return span.jobName ?? span.name;
62
+ return getToolLabel(
63
+ span.name.startsWith(SPAN_TOOL_NAME_PREFIX)
64
+ ? span.name.slice(SPAN_TOOL_NAME_PREFIX.length)
65
+ : span.name
66
+ );
67
+ }
68
+
69
+ /**
70
+ * The recorded input/output of one tool span, expanded under its row — the
71
+ * ONE payload renderer shared by the subagent drill-in and the run detail.
72
+ * Mounted only while expanded (collapsed rows never subscribe). A span with
73
+ * no payload events (recorded before capture shipped) states so instead of
74
+ * offering an empty block (AE7); a payload clipped at persist time carries
75
+ * the wire `truncated` flag, rendered as a hint line under the block.
76
+ */
77
+ export function SpanPayload({ spanId }: { spanId: string }) {
78
+ const events = useActivityStore(useShallow((s) => payloadEventsFor(s, spanId)));
79
+ // History fetches skip payload bodies (they exist for duration badges);
80
+ // the first expand of a finished run backfills them over REST. A no-op for
81
+ // live runs (payloads ride the WS deltas) and once per run thereafter.
82
+ useEffect(() => {
83
+ if (events.length === 0) void loadSpanPayloads(spanId);
84
+ }, [spanId, events.length]);
85
+ if (events.length === 0) {
86
+ return (
87
+ <p className="px-2 py-1 text-[11px] text-muted-foreground/60">
88
+ No payload recorded for this run.
89
+ </p>
90
+ );
91
+ }
92
+ return (
93
+ <div className="space-y-1.5 px-2 py-1">
94
+ {events.map((event) => (
95
+ <div key={`${event.spanId}:${event.eventIndex}`}>
96
+ <div className="mb-0.5 text-[10px] uppercase text-muted-foreground/60">
97
+ {event.eventType === "tool_input" ? "Input" : "Output"}
98
+ </div>
99
+ <pre className="max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-md bg-background/60 p-2 font-[family-name:var(--font-mono)] text-[11px] leading-relaxed text-muted-foreground">
100
+ {typeof event.payload === "string" ? event.payload : JSON.stringify(event.payload)}
101
+ </pre>
102
+ {event.truncated && (
103
+ <p className="mt-0.5 text-[10px] italic text-muted-foreground/60">… truncated</p>
104
+ )}
105
+ </div>
106
+ ))}
107
+ </div>
108
+ );
109
+ }
110
+
111
+ /**
112
+ * THE effective-cost glyph — one three-state rule so no surface ever renders
113
+ * an unknown cost as $0.00 (AE3): absent/NULL is "—" (we don't know), 0 is
114
+ * "free" (we know — subscription-billed or genuinely zero), positive is
115
+ * dollars, "~"-prefixed when computed from estimated rates. Sub-cent costs
116
+ * floor at "<$0.01" rather than rounding down to a zero look-alike.
117
+ */
118
+ export function formatEffectiveCost(
119
+ costUsd: number | null | undefined,
120
+ estimate?: boolean
121
+ ): string {
122
+ if (costUsd === null || costUsd === undefined) return "—";
123
+ if (costUsd === 0) return "free";
124
+ const amount = costUsd < 0.005 ? "<$0.01" : `$${costUsd.toFixed(2)}`;
125
+ return estimate ? `~${amount}` : amount;
126
+ }
127
+
128
+ /**
129
+ * An aggregate's effective-cost sum. Sums exclude unknown-cost runs, so a
130
+ * nonzero `unpricedRuns` makes the number a floor ("≥ $X"), never a total.
131
+ * A known sub-cent sum floors at "<$0.01" like the per-run glyph — only an
132
+ * exact 0 (genuinely nothing to add up) renders "$0.00" (AE3).
133
+ */
134
+ export function formatAggregateCost(effectiveUsd: number, unpricedRuns: number): string {
135
+ const amount =
136
+ effectiveUsd > 0 && effectiveUsd < 0.005 ? "<$0.01" : `$${effectiveUsd.toFixed(2)}`;
137
+ return unpricedRuns > 0 ? `≥ ${amount}` : amount;
138
+ }
139
+
140
+ /**
141
+ * A run row's cost text, three-way on `effectiveCostUsd`: ABSENT means a
142
+ * pre-pricing server never sent the field — fall back to the original
143
+ * list-cost rendering (a positive `costUsd`, else nothing) instead of
144
+ * claiming unknown; explicit null means THIS server computed "unknown" and
145
+ * renders the em dash; a number goes through `formatEffectiveCost` (AE3).
146
+ */
147
+ export function runCostText(run: {
148
+ costUsd: number | null;
149
+ effectiveCostUsd?: number | null;
150
+ pricingEstimate?: boolean;
151
+ }): string | null {
152
+ if (run.effectiveCostUsd === undefined) {
153
+ return run.costUsd !== null && run.costUsd > 0 ? `$${run.costUsd.toFixed(2)}` : null;
154
+ }
155
+ return formatEffectiveCost(run.effectiveCostUsd, run.pricingEstimate);
156
+ }
157
+
158
+ /**
159
+ * The digest card's cost clause (no leading separator), effective-only, or
160
+ * null for "say nothing". A digest persisted before pricing shipped carries
161
+ * neither new field and keeps its old list-cost clause.
162
+ */
163
+ export function digestCostClause(digest: {
164
+ costUsd: number;
165
+ effectiveCostUsd?: number;
166
+ unpricedRuns?: number;
167
+ }): string | null {
168
+ if (digest.effectiveCostUsd === undefined) {
169
+ return digest.costUsd > 0 ? `$${digest.costUsd.toFixed(2)} spent` : null;
170
+ }
171
+ const unpriced = digest.unpricedRuns ?? 0;
172
+ const qualifier = unpriced > 0 ? ` (${unpriced} unpriced)` : "";
173
+ if (digest.effectiveCostUsd > 0) {
174
+ return `${formatAggregateCost(digest.effectiveCostUsd, unpriced)} spent${qualifier}`;
175
+ }
176
+ // Known-zero spend: nothing to add up, but the unknowns still get named.
177
+ if (unpriced > 0) return `${unpriced} unpriced`;
178
+ return "free";
179
+ }
180
+
181
+ /** The "9+" unread-count bubble shared by the rail and the tab bar. Hidden at 0. */
182
+ export function CountBadge({ count, className }: { count: number; className?: string }) {
183
+ if (count <= 0) return null;
184
+ return (
185
+ <span
186
+ className={cn(
187
+ "absolute flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-semibold text-white",
188
+ className
189
+ )}
190
+ >
191
+ {count > 9 ? "9+" : count}
192
+ </span>
193
+ );
194
+ }
@@ -33,6 +33,8 @@ import { WelcomeState } from "./welcome-state.js";
33
33
  import { ShareIntake } from "./share-card.js";
34
34
  import { CommandPalette } from "./command-palette.js";
35
35
  import { SessionDrawer } from "./session-drawer.js";
36
+ import { SubagentView } from "./subagent-view.js";
37
+ import { DigestCard } from "../activity/digest-card.js";
36
38
  import { SettingsPanel } from "../settings/settings-panel.js";
37
39
  import { StreamingPanel } from "../quick-actions/streaming-modal.js";
38
40
  import { WhatsupPanel } from "../quick-actions/whatsup-modal.js";
@@ -125,6 +127,7 @@ export function ChatPage() {
125
127
 
126
128
  const sessionPanelOpen = useUIStore((s) => s.sessionPanelOpen);
127
129
  const setSessionPanelOpen = useUIStore((s) => s.setSessionPanelOpen);
130
+ const subagentStack = useUIStore((s) => s.subagentStack);
128
131
  const syncPanelOpen = useUIStore((s) => s.syncPanelOpen);
129
132
  const setSyncPanelOpen = useUIStore((s) => s.setSyncPanelOpen);
130
133
  const whatsupPanelOpen = useUIStore((s) => s.whatsupPanelOpen);
@@ -521,6 +524,17 @@ export function ChatPage() {
521
524
  }
522
525
  />
523
526
 
527
+ {/* Subagent drill-in: an overlay stack — chat is the residence, the
528
+ drill-in opens over it and backs out level by level. */}
529
+ {subagentStack.length > 0 && (
530
+ <div className="fixed inset-0 z-40 bg-background">
531
+ <SubagentView
532
+ spanId={subagentStack[subagentStack.length - 1]!}
533
+ onApproval={handleToolApproval}
534
+ />
535
+ </div>
536
+ )}
537
+
524
538
  {/* Panels */}
525
539
  <SessionDrawer
526
540
  open={sessionPanelOpen}
@@ -558,6 +572,9 @@ export function ChatPage() {
558
572
  <div className="relative flex-1 overflow-hidden">
559
573
  {messages.length === 0 ? (
560
574
  <div className="h-full overflow-y-auto">
575
+ <div className="px-4 pt-3 md:px-6">
576
+ <DigestCard />
577
+ </div>
561
578
  <WelcomeState onAction={handleWelcomeAction} />
562
579
  </div>
563
580
  ) : (
@@ -4,6 +4,7 @@ import { api } from "../../lib/api-client.js";
4
4
  import { useChatStore } from "../../stores/chat-store.js";
5
5
  import { SlidePanel } from "../layout/slide-panel.js";
6
6
  import { cn } from "../../lib/utils.js";
7
+ import { formatRelativeTime } from "./tool-views.js";
7
8
 
8
9
  interface SessionInfo {
9
10
  id: string;
@@ -215,15 +216,3 @@ function groupSessionsByDate(sessions: SessionInfo[]): GroupedSessions[] {
215
216
  .filter(([, sessions]) => sessions.length > 0)
216
217
  .map(([label, sessions]) => ({ label, sessions }));
217
218
  }
218
-
219
- function formatRelativeTime(ms: number): string {
220
- const diff = Date.now() - ms;
221
- const minutes = Math.floor(diff / 60000);
222
- if (minutes < 1) return "just now";
223
- if (minutes < 60) return `${minutes}m ago`;
224
- const hours = Math.floor(minutes / 60);
225
- if (hours < 24) return `${hours}h ago`;
226
- const days = Math.floor(hours / 24);
227
- if (days < 7) return `${days}d ago`;
228
- return new Date(ms).toLocaleDateString();
229
- }