@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,239 @@
1
+ import { useMemo, useState } from "react";
2
+ import { ArrowLeft, Bot, Check, ChevronRight, X } from "lucide-react";
3
+ import { motion } from "framer-motion";
4
+ import { useShallow } from "zustand/react/shallow";
5
+ import { isFailureOutcome } from "@schlessera/brain-ui-sdk/protocol";
6
+ import type { ActivitySpan, ActivitySpanEvent } from "@schlessera/brain-ui-sdk/protocol";
7
+
8
+ import { useActivityStore, childSpans, eventsFor, spanForTool } from "../../stores/activity-store.js";
9
+ import { useUIStore } from "../../stores/ui-store.js";
10
+ import { useChatStore, activeChat } from "../../stores/chat-store.js";
11
+ import { cn } from "../../lib/utils.js";
12
+ import { getToolLabel, formatDuration } from "./tool-views.js";
13
+ import { SpanPayload, SpanStatusDot, spanToolLabel } from "../activity/span-bits.js";
14
+
15
+ /**
16
+ * Drill-in view of one subagent: the span tree under its Agent tool call,
17
+ * transcript excerpts interleaved by time, live while the subagent runs.
18
+ *
19
+ * Deliberately observation-shaped — no composer, no way to address the
20
+ * subagent — with ONE exception, decided during planning: an approval
21
+ * request raised by a tool inside this subagent renders actionable here as
22
+ * well as in the parent chat, because a fan-out stalled on an unaddressable
23
+ * approval is worse than a relaxed rule.
24
+ *
25
+ * When transcript forwarding is off (or the backend cannot forward), the
26
+ * view degrades to activity-only and says so, rather than looking empty.
27
+ */
28
+ export function SubagentView({
29
+ spanId,
30
+ onApproval,
31
+ }: {
32
+ spanId: string;
33
+ onApproval?: (toolUseId: string, approved: boolean) => void;
34
+ }) {
35
+ const popSubagentView = useUIStore((s) => s.popSubagentView);
36
+ const pushSubagentView = useUIStore((s) => s.pushSubagentView);
37
+ const span = useActivityStore((s) => spanForTool(s, spanId));
38
+ const children = useActivityStore(useShallow((s) => childSpans(s, spanId)));
39
+ const events = useActivityStore((s) => eventsFor(s, spanId));
40
+ const pendingApprovals = useChatStore(
41
+ useShallow((s) => {
42
+ const chat = activeChat(s);
43
+ const last = chat.messages.at(-1);
44
+ return (last?.toolCalls ?? []).filter((t) => t.status === "pending_approval");
45
+ })
46
+ );
47
+
48
+ const timelineItems = useMemo(
49
+ () => interleave(children, events),
50
+ [children, events]
51
+ );
52
+
53
+ if (!span) {
54
+ return (
55
+ <div className="flex flex-col gap-3 p-4">
56
+ <BackButton onClick={popSubagentView} />
57
+ <p className="text-sm text-muted-foreground">
58
+ No recorded activity for this subagent — it may have been pruned.
59
+ </p>
60
+ </div>
61
+ );
62
+ }
63
+
64
+ const description = span.subagent?.description ?? null;
65
+ const subagentType = span.subagent?.type ?? null;
66
+ const summary = span.subagent?.summary ?? null;
67
+ const running = span.outcome === undefined;
68
+ const duration =
69
+ span.endedAt !== undefined ? formatDuration(span.endedAt - span.startedAt) : null;
70
+ const hasTranscript = events.some((e) => e.eventType.startsWith("transcript_"));
71
+ const approvalsHere = pendingApprovals.filter((t) =>
72
+ children.some((c) => c.spanId === t.id)
73
+ );
74
+
75
+ return (
76
+ <div className="flex h-full flex-col">
77
+ <div className="flex items-center gap-2 border-b border-border-subtle px-4 py-3">
78
+ <BackButton onClick={popSubagentView} />
79
+ <Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
80
+ <div className="min-w-0 flex-1">
81
+ <div className="truncate text-sm font-medium">
82
+ {description ?? "Subagent"}
83
+ </div>
84
+ <div className="truncate text-[11px] text-muted-foreground">
85
+ {subagentType ?? "agent"}
86
+ {running ? " · running" : span.outcome ? ` · ${span.outcome}` : ""}
87
+ {duration ? ` · ${duration}` : ""}
88
+ </div>
89
+ </div>
90
+ {running && (
91
+ <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-primary" />
92
+ )}
93
+ </div>
94
+
95
+ <div className="flex-1 space-y-1.5 overflow-y-auto p-4">
96
+ {approvalsHere.map((tool) => (
97
+ <div
98
+ key={tool.id}
99
+ className="rounded-lg border-2 border-primary/40 bg-primary/5 p-3 text-xs"
100
+ >
101
+ <div className="mb-2 font-medium">
102
+ Approval needed: {getToolLabel(tool.name)}
103
+ </div>
104
+ {onApproval && (
105
+ <div className="flex gap-2">
106
+ <button
107
+ onClick={() => onApproval(tool.id, true)}
108
+ className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground hover:brightness-110"
109
+ >
110
+ <Check className="h-3 w-3" /> Allow
111
+ </button>
112
+ <button
113
+ onClick={() => onApproval(tool.id, false)}
114
+ className="flex items-center gap-1.5 rounded-lg border border-destructive/30 px-4 py-1.5 text-xs font-medium text-destructive hover:bg-destructive/10"
115
+ >
116
+ <X className="h-3 w-3" /> Deny
117
+ </button>
118
+ </div>
119
+ )}
120
+ </div>
121
+ ))}
122
+
123
+ {timelineItems.length === 0 && (
124
+ <p className="text-xs text-muted-foreground">
125
+ {running ? "Waiting for the first step…" : "No recorded steps."}
126
+ </p>
127
+ )}
128
+
129
+ {timelineItems.map((item) =>
130
+ item.kind === "span" ? (
131
+ <SpanRow
132
+ key={item.span.spanId}
133
+ span={item.span}
134
+ onOpen={
135
+ item.span.kind === "subagent"
136
+ ? () => pushSubagentView(item.span.spanId)
137
+ : undefined
138
+ }
139
+ />
140
+ ) : (
141
+ <TranscriptRow key={`${item.event.spanId}:${item.event.eventIndex}`} event={item.event} />
142
+ )
143
+ )}
144
+
145
+ {!hasTranscript && children.length > 0 && (
146
+ <p className="pt-2 text-[11px] text-muted-foreground/60">
147
+ Activity only — this subagent's transcript was not captured.
148
+ </p>
149
+ )}
150
+ {summary && !running && (
151
+ <div className="mt-2 rounded-lg border border-border-subtle bg-surface p-3 text-xs">
152
+ {summary}
153
+ </div>
154
+ )}
155
+ </div>
156
+ </div>
157
+ );
158
+ }
159
+
160
+ function BackButton({ onClick }: { onClick: () => void }) {
161
+ return (
162
+ <button
163
+ type="button"
164
+ onClick={onClick}
165
+ className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground"
166
+ aria-label="Back"
167
+ >
168
+ <ArrowLeft className="h-4 w-4" />
169
+ </button>
170
+ );
171
+ }
172
+
173
+ function SpanRow({ span, onOpen }: { span: ActivitySpan; onOpen?: () => void }) {
174
+ // Tool rows expand to their recorded payload (AE7); a pre-feature span
175
+ // expands to the no-payload notice, so the affordance stays uniform.
176
+ const [expanded, setExpanded] = useState(false);
177
+ const running = span.outcome === undefined;
178
+ const failed = isFailureOutcome(span.outcome);
179
+ const expandable = !onOpen && span.kind === "tool";
180
+ const duration =
181
+ span.endedAt !== undefined
182
+ ? formatDuration(span.endedAt - (span.waitUntil ?? span.startedAt))
183
+ : null;
184
+ return (
185
+ <motion.div initial={{ opacity: 0, y: 2 }} animate={{ opacity: 1, y: 0 }}>
186
+ <div
187
+ className={cn(
188
+ "flex items-center gap-2 rounded-md px-2 py-1.5 text-xs",
189
+ failed ? "text-destructive/80" : "text-muted-foreground",
190
+ (onOpen || expandable) && "cursor-pointer hover:text-foreground"
191
+ )}
192
+ onClick={onOpen ?? (expandable ? () => setExpanded((v) => !v) : undefined)}
193
+ >
194
+ <SpanStatusDot span={span} className="h-2 w-2" />
195
+ <span className="font-[family-name:var(--font-mono)] font-medium">
196
+ {spanToolLabel(span)}
197
+ </span>
198
+ {span.outcome && span.outcome !== "success" && (
199
+ <span className="text-[10px] uppercase">{span.outcome}</span>
200
+ )}
201
+ <span className="ml-auto font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/50">
202
+ {duration ?? (running ? "…" : "")}
203
+ </span>
204
+ {expandable && (
205
+ <ChevronRight
206
+ className={cn("h-3 w-3 shrink-0 transition-transform", expanded && "rotate-90")}
207
+ />
208
+ )}
209
+ </div>
210
+ {expanded && <SpanPayload spanId={span.spanId} />}
211
+ </motion.div>
212
+ );
213
+ }
214
+
215
+ function TranscriptRow({ event }: { event: ActivitySpanEvent }) {
216
+ const text = typeof event.payload === "string" ? event.payload : JSON.stringify(event.payload);
217
+ if (!text) return null;
218
+ return (
219
+ <div className="whitespace-pre-wrap rounded-md bg-surface px-3 py-2 text-xs text-foreground/80">
220
+ {text}
221
+ {event.truncated && <span className="text-muted-foreground/60"> …</span>}
222
+ </div>
223
+ );
224
+ }
225
+
226
+ type TimelineItem =
227
+ | { kind: "span"; span: ActivitySpan; ts: number }
228
+ | { kind: "event"; event: ActivitySpanEvent; ts: number };
229
+
230
+ /** Child steps and transcript excerpts in one chronological stream. */
231
+ function interleave(spans: ActivitySpan[], events: ActivitySpanEvent[]): TimelineItem[] {
232
+ const items: TimelineItem[] = [
233
+ ...spans.map((span) => ({ kind: "span" as const, span, ts: span.startedAt })),
234
+ ...events
235
+ .filter((e) => e.eventType.startsWith("transcript_"))
236
+ .map((event) => ({ kind: "event" as const, event, ts: event.ts })),
237
+ ];
238
+ return items.sort((a, b) => a.ts - b.ts);
239
+ }
@@ -13,9 +13,14 @@ import { resolveToolRenderer } from "@schlessera/brain-ui-sdk/client";
13
13
  import type { ToolCall } from "../../stores/chat-store.js";
14
14
  import { cn } from "../../lib/utils.js";
15
15
  import { motion, AnimatePresence } from "framer-motion";
16
- import { getToolLabel, getTouchedFile, formatDuration } from "./tool-views.js";
16
+ import { getToolLabel, getTouchedFile, formatDuration, formatTokenCount } from "./tool-views.js";
17
17
  import { registerBuiltinRenderers, GENERIC_RENDERER } from "./renderers/index.js";
18
18
  import { riskHints } from "./risk-hints.js";
19
+ import { useShallow } from "zustand/react/shallow";
20
+ import { useActivityStore, spanForTool, childSpans } from "../../stores/activity-store.js";
21
+ import { useUIStore } from "../../stores/ui-store.js";
22
+ import { useNow } from "../../hooks/use-now.js";
23
+ import { SpanStatusDot } from "../activity/span-bits.js";
19
24
 
20
25
  // Register the built-in renderer packs once. Registration is build-time; this
21
26
  // runs on module load.
@@ -127,13 +132,26 @@ function ToolCallEntry({
127
132
  toolCall.status === "pending_approval"
128
133
  );
129
134
  const prevStatus = useRef(toolCall.status);
135
+ // One clock for live and reloaded views: when the activity stream carries
136
+ // this call's span, its server-stamped timing wins over the client stamps
137
+ // (which don't exist at all for history-loaded messages). Selecting the
138
+ // span itself (a stable reference) rather than a derived timing object
139
+ // keeps every other entry from re-rendering on every activity delta.
140
+ const span = useActivityStore((s) => spanForTool(s, toolCall.id));
141
+ const timed: ToolCall = span
142
+ ? {
143
+ ...toolCall,
144
+ startedAt: span.waitUntil ?? span.startedAt,
145
+ ...(span.endedAt !== undefined ? { endedAt: span.endedAt } : {}),
146
+ }
147
+ : toolCall;
130
148
  // Resolve the renderer for this tool (backend-scoped exact -> global exact ->
131
149
  // shape-sniffing predicate). Falls back to the generic renderer.
132
150
  const renderer = resolveToolRenderer(toolCall, BACKEND_ID) ?? GENERIC_RENDERER;
133
151
  const Icon = renderer.icon ?? FileText;
134
152
  const isPending = toolCall.status === "pending_approval";
135
- const summary = renderer.summary?.(toolCall) ?? null;
136
- const meta = renderer.meta?.(toolCall) ?? null;
153
+ const summary = renderer.summary?.(timed) ?? null;
154
+ const meta = renderer.meta?.(timed) ?? null;
137
155
  const Input = renderer.Input;
138
156
  const Output = renderer.Output;
139
157
 
@@ -208,6 +226,9 @@ function ToolCallEntry({
208
226
  </span>
209
227
  </button>
210
228
 
229
+ {/* Live subagent state for Agent fan-outs, fed by the activity stream. */}
230
+ {toolCall.name === "Agent" && <SubagentEntryRows agentToolUseId={toolCall.id} />}
231
+
211
232
  {/* Expandable detail */}
212
233
  <AnimatePresence>
213
234
  {expanded && (
@@ -275,6 +296,43 @@ function ToolCallEntry({
275
296
  );
276
297
  }
277
298
 
299
+ /**
300
+ * Compact live status of one Agent fan-out: what the subagent is, its state,
301
+ * elapsed time and step count — with a tap opening the drill-in view. Data
302
+ * comes from the activity stream (session subscription); on a host without
303
+ * activity recording this renders nothing and the entry stays as before.
304
+ */
305
+ function SubagentEntryRows({ agentToolUseId }: { agentToolUseId: string }) {
306
+ const span = useActivityStore((s) => spanForTool(s, agentToolUseId));
307
+ const stepCount = useActivityStore(
308
+ useShallow((s) => childSpans(s, agentToolUseId).length)
309
+ );
310
+ const pushSubagentView = useUIStore((s) => s.pushSubagentView);
311
+ const now = useNow();
312
+ if (!span || span.kind !== "subagent") return null;
313
+
314
+ const description = span.subagent?.description ?? "subagent";
315
+ const tokens = span.subagent?.totalTokens ?? null;
316
+ const elapsed = formatDuration((span.endedAt ?? now) - span.startedAt);
317
+
318
+ return (
319
+ <button
320
+ type="button"
321
+ onClick={() => pushSubagentView(agentToolUseId)}
322
+ className="ml-6 flex w-[calc(100%-1.5rem)] items-center gap-2 rounded-md px-2 py-1 text-[11px] text-muted-foreground/80 transition-colors hover:text-foreground"
323
+ >
324
+ <SpanStatusDot span={span} />
325
+ <span className="truncate">{description}</span>
326
+ <span className="ml-auto shrink-0 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/50">
327
+ {stepCount > 0 && `${stepCount} step${stepCount === 1 ? "" : "s"} · `}
328
+ {tokens !== null && `${formatTokenCount(tokens)} tok · `}
329
+ {elapsed}
330
+ </span>
331
+ <ChevronRight className="h-3 w-3 shrink-0" />
332
+ </button>
333
+ );
334
+ }
335
+
278
336
  function RiskHints({ toolCall }: { toolCall: ToolCall }) {
279
337
  const hints = riskHints(toolCall);
280
338
  if (hints.length === 0) return null;
@@ -68,6 +68,8 @@ function fileLabel(path: string): ReactNode {
68
68
  }
69
69
 
70
70
  export function formatDuration(ms: number): string {
71
+ // Server and browser clocks can skew; a negative duration is just 0.
72
+ ms = Math.max(0, ms);
71
73
  if (ms < 1000) return `${(ms / 1000).toFixed(1)}s`;
72
74
  if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
73
75
  const m = Math.floor(ms / 60_000);
@@ -75,6 +77,26 @@ export function formatDuration(ms: number): string {
75
77
  return `${m}m ${s}s`;
76
78
  }
77
79
 
80
+ /** Compact token count: 1234 → "1k", 2_345_678 → "2.3M". */
81
+ export function formatTokenCount(n: number): string {
82
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
83
+ if (n >= 1_000) return `${Math.round(n / 1_000)}k`;
84
+ return String(n);
85
+ }
86
+
87
+ /** Relative timestamp for list rows ("3m ago"); dates beyond a week. */
88
+ export function formatRelativeTime(ms: number): string {
89
+ const diff = Date.now() - ms;
90
+ const minutes = Math.floor(diff / 60000);
91
+ if (minutes < 1) return "just now";
92
+ if (minutes < 60) return `${minutes}m ago`;
93
+ const hours = Math.floor(minutes / 60);
94
+ if (hours < 24) return `${hours}h ago`;
95
+ const days = Math.floor(hours / 24);
96
+ if (days < 7) return `${days}d ago`;
97
+ return new Date(ms).toLocaleDateString();
98
+ }
99
+
78
100
  export function countLines(text: string): number {
79
101
  let n = 1;
80
102
  for (let i = 0; i < text.length; i++) if (text[i] === "\n") n++;
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import {
3
+ Activity,
3
4
  Brain,
4
5
  RefreshCw,
5
6
  History,
@@ -11,7 +12,9 @@ import {
11
12
  } from "lucide-react";
12
13
  import { useUIStore } from "../../stores/ui-store.js";
13
14
  import { useChatStore } from "../../stores/chat-store.js";
15
+ import { useActivityStore } from "../../stores/activity-store.js";
14
16
  import { cn } from "../../lib/utils.js";
17
+ import { CountBadge } from "../activity/span-bits.js";
15
18
 
16
19
  export function MobileTabBar() {
17
20
  const activeView = useUIStore((s) => s.activeView);
@@ -21,6 +24,7 @@ export function MobileTabBar() {
21
24
  const toggleFilePanel = useUIStore((s) => s.toggleFilePanel);
22
25
  const toggleSettingsPanel = useUIStore((s) => s.toggleSettingsPanel);
23
26
  const clearMessages = useChatStore((s) => s.clearMessages);
27
+ const inboxCount = useActivityStore((s) => s.inbox.length);
24
28
 
25
29
  /** Chat-scoped panels live in the chat page — surface it before opening them. */
26
30
  function inChat(toggle: () => void) {
@@ -66,11 +70,15 @@ export function MobileTabBar() {
66
70
  clearMessages();
67
71
  }}
68
72
  />
73
+ {/* Activity holds the tab-bar slot; Graph moved into the More menu —
74
+ the phone glance-check ("is real work happening?") is the headline
75
+ flow and must stay one tap away (planning decision). */}
69
76
  <TabIcon
70
- icon={Waypoints}
71
- label="Graph"
72
- active={activeView === "graph"}
73
- onClick={() => setActiveView("graph")}
77
+ icon={Activity}
78
+ label="Activity"
79
+ active={activeView === "activity"}
80
+ badge={inboxCount}
81
+ onClick={() => setActiveView("activity")}
74
82
  />
75
83
  <TabIcon icon={FolderTree} label="Files" onClick={toggleFilePanel} />
76
84
  <div ref={moreRef} className="relative">
@@ -85,6 +93,14 @@ export function MobileTabBar() {
85
93
  role="menu"
86
94
  className="absolute bottom-full right-0 z-50 mb-2 min-w-[10rem] overflow-hidden rounded-xl border border-border bg-surface-overlay py-1 shadow-2xl"
87
95
  >
96
+ <MoreItem
97
+ icon={Waypoints}
98
+ label="Graph"
99
+ onClick={() => {
100
+ setMoreOpen(false);
101
+ setActiveView("graph");
102
+ }}
103
+ />
88
104
  <MoreItem
89
105
  icon={RefreshCw}
90
106
  label="Sync"
@@ -121,21 +137,25 @@ function TabIcon({
121
137
  label,
122
138
  active,
123
139
  onClick,
140
+ badge,
124
141
  }: {
125
142
  icon: typeof Brain;
126
143
  label: string;
127
144
  active?: boolean;
128
145
  onClick?: () => void;
146
+ /** Unread-count dot (the inbox badge). Hidden at 0. */
147
+ badge?: number;
129
148
  }) {
130
149
  return (
131
150
  <button
132
151
  onClick={onClick}
133
152
  className={cn(
134
- "flex flex-col items-center gap-0.5 px-2 py-1 text-[10px]",
153
+ "relative flex flex-col items-center gap-0.5 px-2 py-1 text-[10px]",
135
154
  active ? "text-primary" : "text-muted-foreground"
136
155
  )}
137
156
  >
138
157
  <Icon className="h-5 w-5" />
158
+ {badge !== undefined && <CountBadge count={badge} className="right-0 top-0" />}
139
159
  {label}
140
160
  </button>
141
161
  );
@@ -1,4 +1,5 @@
1
1
  import {
2
+ Activity,
2
3
  Brain,
3
4
  RefreshCw,
4
5
  Newspaper,
@@ -9,9 +10,11 @@ import {
9
10
  Waypoints,
10
11
  } from "lucide-react";
11
12
  import { useConnectionStore } from "../../stores/connection-store.js";
13
+ import { useActivityStore } from "../../stores/activity-store.js";
12
14
  import { useUIStore } from "../../stores/ui-store.js";
13
15
  import { useChatStore, activeChat } from "../../stores/chat-store.js";
14
16
  import { cn } from "../../lib/utils.js";
17
+ import { CountBadge } from "../activity/span-bits.js";
15
18
 
16
19
  export function SideRail() {
17
20
  const wsStatus = useConnectionStore((s) => s.wsStatus);
@@ -25,6 +28,7 @@ export function SideRail() {
25
28
  const clearMessages = useChatStore((s) => s.clearMessages);
26
29
  const hasMessages = useChatStore((s) => activeChat(s).messages.length > 0);
27
30
  const isStreaming = useChatStore((s) => activeChat(s).isStreaming);
31
+ const inboxCount = useActivityStore((s) => s.inbox.length);
28
32
 
29
33
  /** Chat-scoped panels live in the chat page — surface it before opening them. */
30
34
  function inChat(toggle: () => void) {
@@ -79,6 +83,15 @@ export function SideRail() {
79
83
  setActiveView(activeView === "graph" ? "chat" : "graph")
80
84
  }
81
85
  />
86
+ <RailButton
87
+ icon={Activity}
88
+ label="Activity"
89
+ active={activeView === "activity"}
90
+ badge={inboxCount}
91
+ onClick={() =>
92
+ setActiveView(activeView === "activity" ? "chat" : "activity")
93
+ }
94
+ />
82
95
 
83
96
  {/* Spacer */}
84
97
  <div className="flex-1" />
@@ -125,12 +138,15 @@ function RailButton({
125
138
  onClick,
126
139
  disabled,
127
140
  active,
141
+ badge,
128
142
  }: {
129
143
  icon: typeof Brain;
130
144
  label: string;
131
145
  onClick: () => void;
132
146
  disabled?: boolean;
133
147
  active?: boolean;
148
+ /** Unread-count dot (the inbox badge). Hidden at 0. */
149
+ badge?: number;
134
150
  }) {
135
151
  return (
136
152
  <button
@@ -143,6 +159,7 @@ function RailButton({
143
159
  )}
144
160
  >
145
161
  <Icon className="h-4.5 w-4.5" />
162
+ {badge !== undefined && <CountBadge count={badge} className="-right-0.5 -top-0.5" />}
146
163
  {/* Tooltip */}
147
164
  <span className="pointer-events-none absolute left-full ml-2 whitespace-nowrap rounded-md bg-surface-overlay px-2.5 py-1 text-xs font-medium text-foreground opacity-0 shadow-lg transition-opacity group-hover:opacity-100 border border-border">
148
165
  {label}