@brainpilot/web 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/assets/index-Br55rkHb.css +1 -0
  2. package/dist/assets/index-CeUzk-ej.js +445 -0
  3. package/dist/index.html +2 -2
  4. package/index.html +13 -0
  5. package/package.json +12 -3
  6. package/src/App.tsx +10 -0
  7. package/src/__tests__/agentsReducer.test.ts +67 -0
  8. package/src/__tests__/api.test.ts +221 -0
  9. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  10. package/src/__tests__/demoConversation.test.ts +73 -0
  11. package/src/__tests__/demoReset.test.ts +24 -0
  12. package/src/__tests__/messageGroups.test.ts +80 -0
  13. package/src/__tests__/newUiComponents.test.tsx +101 -0
  14. package/src/__tests__/newUiEvents.test.ts +236 -0
  15. package/src/__tests__/runningToast.test.ts +29 -0
  16. package/src/__tests__/tokenUsage.test.ts +48 -0
  17. package/src/__tests__/toolDisplay.test.ts +55 -0
  18. package/src/__tests__/traceReducer.test.ts +62 -0
  19. package/src/components/chat/AskUserCard.tsx +123 -0
  20. package/src/components/chat/AutoRetryIndicator.tsx +71 -0
  21. package/src/components/chat/ComposerInput.tsx +73 -0
  22. package/src/components/chat/ComposerSendButton.tsx +26 -0
  23. package/src/components/chat/MarkdownMessage.tsx +24 -0
  24. package/src/components/chat/MessageStream.tsx +505 -0
  25. package/src/components/chat/PromptComposer.tsx +489 -0
  26. package/src/components/chat/SystemMessageBubble.tsx +46 -0
  27. package/src/components/chat/chatScrollMemory.ts +49 -0
  28. package/src/components/demo/DemoFileTree.tsx +146 -0
  29. package/src/components/demo/DemoView.tsx +730 -0
  30. package/src/components/demo/TraceNodeModal.tsx +80 -0
  31. package/src/components/demo/demoBundle.ts +223 -0
  32. package/src/components/demo/demoCache.ts +42 -0
  33. package/src/components/demo/demoReset.ts +16 -0
  34. package/src/components/files/FilePreviewView.tsx +153 -0
  35. package/src/components/files/FileSidebar.tsx +664 -0
  36. package/src/components/files/filePreview.ts +113 -0
  37. package/src/components/primitives/CustomSelect.tsx +200 -0
  38. package/src/components/primitives/IconButton.tsx +27 -0
  39. package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
  40. package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
  41. package/src/components/quota/QuotaFileManager.tsx +197 -0
  42. package/src/components/search/SearchDialog.tsx +101 -0
  43. package/src/components/session/AgentNetwork.tsx +1233 -0
  44. package/src/components/session/AgentTraceViews.tsx +346 -0
  45. package/src/components/session/AnalyticsTab.tsx +220 -0
  46. package/src/components/session/GlobalOverview.tsx +108 -0
  47. package/src/components/session/NodeTooltip.tsx +127 -0
  48. package/src/components/session/TimelineTab.tsx +320 -0
  49. package/src/components/session/TraceGraphView.tsx +307 -0
  50. package/src/components/session/TraceNodeDetail.tsx +179 -0
  51. package/src/components/session/agentAnalytics.ts +397 -0
  52. package/src/components/session/agentNetworkShared.ts +339 -0
  53. package/src/components/session/traceLayout.ts +182 -0
  54. package/src/components/settings/SettingsDialog.tsx +737 -0
  55. package/src/components/shell/DesktopShell.tsx +261 -0
  56. package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
  57. package/src/components/shell/SandboxStatus.tsx +287 -0
  58. package/src/components/shell/TerminalDrawer.tsx +387 -0
  59. package/src/components/sidebar/Sidebar.tsx +191 -0
  60. package/src/config.ts +10 -0
  61. package/src/contexts/AppProviders.tsx +20 -0
  62. package/src/contexts/AuthContext.tsx +61 -0
  63. package/src/contexts/PreferencesContext.tsx +125 -0
  64. package/src/contexts/SSEContext.tsx +264 -0
  65. package/src/contexts/SandboxContext.tsx +310 -0
  66. package/src/contexts/SessionContext.tsx +919 -0
  67. package/src/contexts/agentsReducer.ts +49 -0
  68. package/src/contexts/draftStore.ts +103 -0
  69. package/src/contexts/messageFilters.ts +29 -0
  70. package/src/contexts/messageGroups.ts +77 -0
  71. package/src/contexts/messageReducer.ts +401 -0
  72. package/src/contexts/newUiEvents.ts +190 -0
  73. package/src/contexts/runningToast.ts +33 -0
  74. package/src/contexts/traceReducer.ts +62 -0
  75. package/src/contexts/turnTimer.test.ts +97 -0
  76. package/src/contexts/turnTimer.ts +108 -0
  77. package/src/contexts/useTurnTimer.ts +104 -0
  78. package/src/contracts/backend.ts +897 -0
  79. package/src/contracts/demoBundle.ts +83 -0
  80. package/src/i18n/messages/analytics.ts +106 -0
  81. package/src/i18n/messages/chat.ts +130 -0
  82. package/src/i18n/messages/contexts.ts +42 -0
  83. package/src/i18n/messages/demo.ts +80 -0
  84. package/src/i18n/messages/files.ts +82 -0
  85. package/src/i18n/messages/network.ts +190 -0
  86. package/src/i18n/messages/profile.ts +44 -0
  87. package/src/i18n/messages/quota.ts +36 -0
  88. package/src/i18n/messages/sandbox.ts +116 -0
  89. package/src/i18n/messages/search.ts +16 -0
  90. package/src/i18n/messages/settings.ts +188 -0
  91. package/src/i18n/messages/shell.ts +38 -0
  92. package/src/i18n/messages/sidebar.ts +52 -0
  93. package/src/i18n/messages/terminal.ts +22 -0
  94. package/src/i18n/messages/trace.ts +136 -0
  95. package/src/i18n/messages.ts +32 -0
  96. package/src/i18n/translate.ts +46 -0
  97. package/src/i18n/types.ts +15 -0
  98. package/src/i18n/useT.ts +15 -0
  99. package/src/main.tsx +13 -0
  100. package/src/mocks/backend.ts +729 -0
  101. package/src/styles/global.css +7578 -0
  102. package/src/styles/tokens.css +161 -0
  103. package/src/utils/api.ts +724 -0
  104. package/src/utils/download.ts +18 -0
  105. package/src/utils/format.ts +7 -0
  106. package/src/utils/toolDisplay.ts +74 -0
  107. package/src/utils/zip.ts +119 -0
  108. package/src/vite-env.d.ts +1 -0
  109. package/tsconfig.app.json +22 -0
  110. package/tsconfig.json +7 -0
  111. package/tsconfig.node.json +13 -0
  112. package/vite.config.ts +13 -0
  113. package/dist/assets/index-Cd0Mi_WU.css +0 -1
  114. package/dist/assets/index-FGg-DeYR.js +0 -448
@@ -0,0 +1,505 @@
1
+ import { Check, ChevronDown, Copy } from "lucide-react";
2
+ import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ import type { ChatMessage } from "../../contracts/backend";
4
+ import { buildRenderItems } from "../../contexts/messageGroups";
5
+ import { useT } from "../../i18n/useT";
6
+ import { MarkdownMessage } from "./MarkdownMessage";
7
+ import { SystemMessageBubble } from "./SystemMessageBubble";
8
+ import { AskUserCard } from "./AskUserCard";
9
+ import { AutoRetryIndicator } from "./AutoRetryIndicator";
10
+ import { formatToolName, formatPayload } from "../../utils/toolDisplay";
11
+ import { getChatScroll, setChatScroll, resolveScrollTop } from "./chatScrollMemory";
12
+
13
+ interface MessageStreamProps {
14
+ /** Already filtered / time-sliced by the host. */
15
+ messages: ChatMessage[];
16
+ /** Pin to bottom as new messages arrive (live chat). Default false. */
17
+ autoScroll?: boolean;
18
+ /**
19
+ * #89 — session id used to remember scroll position/pinned intent across
20
+ * tab switches (Chat is unmounted when Agents/Trace is active). When set, the
21
+ * stream restores its prior position on mount instead of replaying a visible
22
+ * top-to-bottom scroll. Omit in read-only contexts (demo replay).
23
+ */
24
+ scrollKey?: string;
25
+ /** Show the "N messages" toolbar row. Default true. */
26
+ showToolbarCount?: boolean;
27
+ /** Show per-agent elapsed timers + total conversation time. Live chat only. */
28
+ showTiming?: boolean;
29
+ /**
30
+ * #99: whole-turn timing (user input → all agents finished). When provided,
31
+ * the footer shows this authoritative turn duration instead of a per-message
32
+ * span estimate. `running` drives a live ticking display.
33
+ */
34
+ turnTiming?: { running: boolean; elapsedMs: number | null; lastDurationMs: number | null };
35
+ className?: string;
36
+ ariaLabel?: string;
37
+ /** 修正6 — submit an ask_user answer. Omitted in read-only contexts (demo). */
38
+ onAskUserSubmit?: (requestId: string, answer: string) => void;
39
+ /** 修正6 — cancel a pending auto-retry. Omitted in read-only contexts. */
40
+ onRetryCancel?: () => void;
41
+ /**
42
+ * Names of agents whose run is currently active (RUN_STARTED..RUN_FINISHED).
43
+ * Keeps a folded activity block "in progress" across ReAct rounds even when
44
+ * no single step is momentarily streaming. Omitted in read-only contexts
45
+ * (demo replay), where messages are already terminal.
46
+ */
47
+ runningAgents?: ReadonlySet<string>;
48
+ }
49
+
50
+ // Whether this message participates in same-agent avatar merging. User
51
+ // prompts, errors, hooks and status notes always stand on their own; only
52
+ // assistant/system text rows fold under a shared avatar.
53
+ function isMergeable(message: ChatMessage): boolean {
54
+ if (message.role === "user") return false;
55
+ if (message.kind === "error" || message.kind === "hook" || message.kind === "status") return false;
56
+ return true;
57
+ }
58
+
59
+ function mergeName(message: ChatMessage): string {
60
+ return message.agent || (message.role === "system" ? "system" : "principal");
61
+ }
62
+
63
+ // Compact elapsed formatter: "3.2s" under a minute, "1m 05s" above.
64
+ function formatElapsed(ms: number): string {
65
+ if (ms < 0) ms = 0;
66
+ const totalSeconds = ms / 1000;
67
+ if (totalSeconds < 60) {
68
+ return `${totalSeconds.toFixed(1)}s`;
69
+ }
70
+ const minutes = Math.floor(totalSeconds / 60);
71
+ const seconds = Math.floor(totalSeconds % 60);
72
+ return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
73
+ }
74
+
75
+ /**
76
+ * Presentational chat message stack — message bubbles, agent rows, hook notes,
77
+ * and folded reasoning/tool "activity" blocks. Extracted from PromptComposer so
78
+ * the live chat and the demo replay render identically. Owns its own copy state
79
+ * so callers don't have to thread clipboard logic.
80
+ */
81
+ function MessageStreamImpl({
82
+ messages,
83
+ autoScroll = false,
84
+ scrollKey,
85
+ showToolbarCount = true,
86
+ showTiming = false,
87
+ turnTiming,
88
+ className,
89
+ ariaLabel,
90
+ onAskUserSubmit,
91
+ onRetryCancel,
92
+ runningAgents,
93
+ }: MessageStreamProps) {
94
+ const t = useT();
95
+ const [copiedId, setCopiedId] = useState<string | null>(null);
96
+ const stackRef = useRef<HTMLDivElement | null>(null);
97
+ const isPinnedRef = useRef(true);
98
+
99
+ const renderItems = useMemo(
100
+ () => buildRenderItems(messages, runningAgents),
101
+ [messages, runningAgents],
102
+ );
103
+
104
+ // Avatar merging: a mergeable assistant/system row whose immediately
105
+ // preceding render item is a mergeable single from the same agent hides its
106
+ // repeated avatar + name. Activity blocks, user prompts, errors and hooks
107
+ // all break the run.
108
+ const continuationIds = useMemo(() => {
109
+ const set = new Set<string>();
110
+ let prevName: string | null = null;
111
+ for (const item of renderItems) {
112
+ if (item.type === "single" && isMergeable(item.message)) {
113
+ const name = mergeName(item.message);
114
+ if (prevName === name) {
115
+ set.add(item.message.id);
116
+ }
117
+ prevName = name;
118
+ } else {
119
+ prevName = null;
120
+ }
121
+ }
122
+ return set;
123
+ }, [renderItems]);
124
+
125
+ // Last assistant/system message that is still streaming — gets the live
126
+ // left-to-right highlight sweep.
127
+ const liveStreamingId = useMemo(() => {
128
+ for (let i = messages.length - 1; i >= 0; i--) {
129
+ const m = messages[i];
130
+ if (m.streaming && m.role !== "user") return m.id;
131
+ }
132
+ return null;
133
+ }, [messages]);
134
+
135
+ // #99: per-message timer is shown ONLY on the live streaming message — it is a
136
+ // live "this run has been going for Ns" indicator, never attached to a
137
+ // completed message or a user bubble (which previously drifted with wall-clock
138
+ // age). The authoritative whole-turn duration lives in the footer (turnTiming).
139
+ const anyStreaming = liveStreamingId !== null;
140
+ const [, setNow] = useState(0);
141
+ useEffect(() => {
142
+ if (!showTiming || !anyStreaming) return;
143
+ const id = window.setInterval(() => setNow((n) => n + 1), 1000);
144
+ return () => window.clearInterval(id);
145
+ }, [showTiming, anyStreaming]);
146
+
147
+ const elapsedLabel = (message: ChatMessage): string | null => {
148
+ if (!showTiming) return null;
149
+ // Only the currently-streaming message carries a live timer.
150
+ if (message.id !== liveStreamingId) return null;
151
+ const startMs = message.createdAt ? Date.parse(message.createdAt) : NaN;
152
+ if (Number.isNaN(startMs)) return null;
153
+ return formatElapsed(Date.now() - startMs);
154
+ };
155
+
156
+ // #89 — restore scroll position on (re)mount BEFORE the browser paints, so
157
+ // returning to Chat from another tab lands at the right place with no visible
158
+ // top-to-bottom replay. Reads the per-session memory: pinned/fresh → bottom,
159
+ // otherwise the saved history position. A double rAF re-applies after async
160
+ // layout (Markdown, images) settles, in case scrollHeight grew post-mount.
161
+ useLayoutEffect(() => {
162
+ const node = stackRef.current;
163
+ if (!node) return;
164
+ const mem = getChatScroll(scrollKey);
165
+ isPinnedRef.current = mem ? mem.pinned : true;
166
+ const apply = () => {
167
+ const n = stackRef.current;
168
+ if (!n) return;
169
+ n.scrollTop = resolveScrollTop(mem, n.scrollHeight);
170
+ };
171
+ apply();
172
+ let raf2 = 0;
173
+ const raf1 = window.requestAnimationFrame(() => {
174
+ apply();
175
+ raf2 = window.requestAnimationFrame(apply);
176
+ });
177
+ return () => {
178
+ window.cancelAnimationFrame(raf1);
179
+ if (raf2) window.cancelAnimationFrame(raf2);
180
+ };
181
+ // Mount-only restore; live append is handled by the autoScroll effect below.
182
+ // eslint-disable-next-line react-hooks/exhaustive-deps
183
+ }, [scrollKey]);
184
+
185
+ useEffect(() => {
186
+ if (!autoScroll) {
187
+ return;
188
+ }
189
+ const node = stackRef.current;
190
+ if (!node || !isPinnedRef.current) {
191
+ return;
192
+ }
193
+ node.scrollTop = node.scrollHeight;
194
+ }, [messages, autoScroll]);
195
+
196
+ const handleScroll = () => {
197
+ const node = stackRef.current;
198
+ if (!node) {
199
+ return;
200
+ }
201
+ const distanceFromBottom = node.scrollHeight - node.scrollTop - node.clientHeight;
202
+ isPinnedRef.current = distanceFromBottom < 24;
203
+ // #89 — persist intent so a tab switch (which unmounts Chat) can restore it.
204
+ setChatScroll(scrollKey, { scrollTop: node.scrollTop, pinned: isPinnedRef.current });
205
+ };
206
+
207
+ const handleCopy = async (id: string, text: string) => {
208
+ const markCopied = () => {
209
+ setCopiedId(id);
210
+ setTimeout(() => setCopiedId((current) => (current === id ? null : current)), 2000);
211
+ };
212
+
213
+ // Modern Clipboard API only works in secure contexts (https / localhost).
214
+ // When the app is served over plain http (e.g. deployed on an IP/domain),
215
+ // navigator.clipboard is undefined, so we fall back to execCommand('copy').
216
+ try {
217
+ if (navigator.clipboard && window.isSecureContext) {
218
+ await navigator.clipboard.writeText(text);
219
+ markCopied();
220
+ return;
221
+ }
222
+ } catch {
223
+ // fall through to legacy fallback below
224
+ }
225
+
226
+ try {
227
+ const textarea = document.createElement("textarea");
228
+ textarea.value = text;
229
+ textarea.style.position = "fixed";
230
+ textarea.style.top = "0";
231
+ textarea.style.left = "0";
232
+ textarea.style.width = "1px";
233
+ textarea.style.height = "1px";
234
+ textarea.style.padding = "0";
235
+ textarea.style.border = "none";
236
+ textarea.style.outline = "none";
237
+ textarea.style.boxShadow = "none";
238
+ textarea.style.background = "transparent";
239
+ textarea.setAttribute("readonly", "");
240
+ document.body.appendChild(textarea);
241
+ textarea.select();
242
+ textarea.setSelectionRange(0, text.length);
243
+ const succeeded = document.execCommand("copy");
244
+ document.body.removeChild(textarea);
245
+ if (succeeded) {
246
+ markCopied();
247
+ }
248
+ } catch {
249
+ // ignore — copy is best-effort
250
+ }
251
+ };
252
+
253
+ const copyButtonFor = (message: ChatMessage) => {
254
+ const isCopied = copiedId === message.id;
255
+ return (
256
+ <button
257
+ className={`message-card__copy ${isCopied ? "is-copied" : ""}`}
258
+ onClick={(event) => {
259
+ event.stopPropagation();
260
+ void handleCopy(message.id, message.content || "");
261
+ }}
262
+ title={isCopied ? t("chat.copied") : t("chat.copy")}
263
+ aria-label={isCopied ? t("chat.copied") : t("chat.aria.copyMessage")}
264
+ type="button"
265
+ >
266
+ {isCopied ? <Check size={12} /> : <Copy size={12} />}
267
+ </button>
268
+ );
269
+ };
270
+
271
+ // Circular monogram avatar for an agent. Scales to any agent name; principal
272
+ // uses the info accent, expert agents the success/green accent. When
273
+ // `ghost` is set (a continuation row) the avatar is rendered as an invisible
274
+ // placeholder so the body stays aligned but the monogram isn't repeated.
275
+ const renderAvatar = (agent: string | undefined, isExpert: boolean, ghost = false) => {
276
+ const name = agent || "principal";
277
+ const initial = name.charAt(0).toUpperCase() || "·";
278
+ if (ghost) {
279
+ return <div className="message-avatar message-avatar--ghost" aria-hidden="true" />;
280
+ }
281
+ return (
282
+ <div
283
+ className={`message-avatar ${isExpert ? "message-avatar--expert" : ""}`}
284
+ aria-hidden="true"
285
+ title={name}
286
+ >
287
+ {initial}
288
+ </div>
289
+ );
290
+ };
291
+
292
+ // A standalone message: user prompts (right-aligned bubble), every assistant
293
+ // text reply from Principal or Expert agents (borderless row with avatar +
294
+ // name), errors, and hook diagnostics. Only reasoning and tool calls/results
295
+ // fold into activity blocks via buildRenderItems().
296
+ const renderSingle = (message: ChatMessage, isContinuation = false) => {
297
+ // 修正6 — new-UI kinds render via their dedicated components.
298
+ if (message.kind === "system_message" && message.systemMessage) {
299
+ return <SystemMessageBubble key={message.id} view={message.systemMessage} />;
300
+ }
301
+ if (message.kind === "ask_user" && message.askUser) {
302
+ return (
303
+ <AskUserCard
304
+ key={message.id}
305
+ view={message.askUser}
306
+ onSubmit={(requestId, answer) => onAskUserSubmit?.(requestId, answer)}
307
+ />
308
+ );
309
+ }
310
+ if (message.kind === "auto_retry" && message.autoRetry) {
311
+ return (
312
+ <AutoRetryIndicator
313
+ key={message.id}
314
+ view={message.autoRetry}
315
+ onCancel={() => onRetryCancel?.()}
316
+ />
317
+ );
318
+ }
319
+
320
+ if (message.kind === "hook") {
321
+ const levelIcon =
322
+ message.hookLevel === "error" ? "❌" :
323
+ message.hookLevel === "warning" ? "⚠️" :
324
+ message.hookLevel === "debug" ? "·" :
325
+ "🪝";
326
+ return (
327
+ <div
328
+ className={`message-hook message-hook--${message.hookLevel ?? "info"}`}
329
+ key={message.id}
330
+ >
331
+ <details>
332
+ <summary>
333
+ <span className="message-hook__label">
334
+ {levelIcon} hook · {message.hookFamily ?? "?"} · {message.hookPhase ?? "?"}
335
+ {message.agent ? ` · ${message.agent}` : ""}
336
+ </span>
337
+ <span className="message-hook__text">{message.content}</span>
338
+ </summary>
339
+ {message.hookData && Object.keys(message.hookData).length > 0 ? (
340
+ <pre>{JSON.stringify(message.hookData, null, 2)}</pre>
341
+ ) : null}
342
+ </details>
343
+ </div>
344
+ );
345
+ }
346
+
347
+ if (message.role === "user") {
348
+ return (
349
+ <div className="message-row message-row--user" key={message.id}>
350
+ <div className="message-bubble">
351
+ {message.content || (message.streaming ? t("chat.generating") : "")}
352
+ </div>
353
+ <div className="message-row__user-actions">{copyButtonFor(message)}</div>
354
+ </div>
355
+ );
356
+ }
357
+
358
+ const isExpert = message.role === "assistant" && !!message.agent && message.agent !== "principal";
359
+ const displayName = message.agent || (message.role === "system" ? "system" : "principal");
360
+ const isLive = message.id === liveStreamingId;
361
+ const timing = elapsedLabel(message);
362
+ const hasContent = !!message.content.trim();
363
+ const displayContent = hasContent ? message.content : (message.streaming ? t("chat.streamingPending") : "");
364
+ const content = (
365
+ <div className={`message-row__content ${message.streaming && !hasContent ? "message-row__content--pending" : ""}`}>
366
+ {message.kind === "error" ? (
367
+ <p className="message-card__content--plain message-row__error">{displayContent}</p>
368
+ ) : (
369
+ <MarkdownMessage content={displayContent} />
370
+ )}
371
+ {message.streaming && message.kind !== "error" ? (
372
+ <span className="message-row__streaming-cursor" aria-hidden="true" />
373
+ ) : null}
374
+ </div>
375
+ );
376
+ return (
377
+ <div
378
+ className={`message-row message-row--${message.role} ${isExpert ? "message-row--expert" : ""} ${isContinuation ? "message-row--continuation" : ""} ${isLive ? "message-row--live" : ""}`}
379
+ key={message.id}
380
+ >
381
+ {renderAvatar(displayName, isExpert, isContinuation)}
382
+ <div className="message-row__body">
383
+ {isContinuation ? null : (
384
+ <div className="message-row__head">
385
+ <span className={`message-row__name ${isExpert ? "message-row__name--expert" : ""}`}>
386
+ {displayName}
387
+ </span>
388
+ {timing ? <span className="message-row__timer">· {timing}</span> : null}
389
+ {message.streaming ? <span className="message-row__streaming">{t("chat.streaming")}</span> : null}
390
+ {copyButtonFor(message)}
391
+ </div>
392
+ )}
393
+ {content}
394
+ </div>
395
+ </div>
396
+ );
397
+ };
398
+
399
+ // One folded step inside an activity block (reasoning, tool call/result, or
400
+ // intermediate assistant text).
401
+ const renderActivityStep = (step: ChatMessage) => {
402
+ const isExpert = !!step.agent && step.agent !== "principal";
403
+ if (step.kind === "tool") {
404
+ // #84: render a friendly tool name (mcp__server__tool → server · tool) and
405
+ // un-escaped payloads. The raw name stays in `title` for debugging/copy.
406
+ const friendly = t("chat.toolPrefix", { name: formatToolName(step.toolName) });
407
+ const input = formatPayload(step.toolInput);
408
+ const result = formatPayload(step.toolResult);
409
+ return (
410
+ <div className="activity-step" key={step.id}>
411
+ <details>
412
+ <summary title={step.toolName || undefined}>
413
+ {isExpert ? <span className="message-card__agent-badge">{step.agent}</span> : null}
414
+ {friendly}
415
+ </summary>
416
+ {input ? (
417
+ <div className="activity-step__io">
418
+ <span className="activity-step__io-label">{t("chat.toolArgs")}</span>
419
+ <pre>{input}</pre>
420
+ </div>
421
+ ) : null}
422
+ {result ? (
423
+ <div className="activity-step__io">
424
+ <span className="activity-step__io-label">{t("chat.toolResult")}</span>
425
+ <pre>{result}</pre>
426
+ </div>
427
+ ) : null}
428
+ </details>
429
+ </div>
430
+ );
431
+ }
432
+ if (step.kind === "thinking") {
433
+ return (
434
+ <div className="activity-step" key={step.id}>
435
+ <p className="message-card__content--plain">{step.reasoning || step.content}</p>
436
+ </div>
437
+ );
438
+ }
439
+ return (
440
+ <div className="activity-step" key={step.id}>
441
+ {isExpert ? <span className="message-card__agent-badge">{step.agent}</span> : null}
442
+ <MarkdownMessage content={step.content || (step.streaming ? t("chat.streamingPending") : "")} />
443
+ </div>
444
+ );
445
+ };
446
+
447
+ // Collapsed by default; while streaming the summary shows a live one-line
448
+ // preview of the latest step instead of a step count.
449
+ const activitySubtitle = (steps: ChatMessage[], streaming: boolean) => {
450
+ if (!streaming) return t("chat.thinkingSteps", { count: steps.length });
451
+ const last = steps[steps.length - 1];
452
+ if (last?.kind === "tool") return t("chat.toolCall", { name: formatToolName(last.toolName) });
453
+ const text = (last?.reasoning || last?.content || "").trim();
454
+ if (text) return text.length > 80 ? `${text.slice(0, 80)}…` : text;
455
+ return t("chat.thinking");
456
+ };
457
+
458
+ return (
459
+ <div
460
+ className={`message-stack ${className ?? ""}`}
461
+ aria-label={ariaLabel ?? t("chat.aria.messages")}
462
+ onScroll={handleScroll}
463
+ ref={stackRef}
464
+ >
465
+ {showToolbarCount ? (
466
+ <div className="message-stack__toolbar">
467
+ <span>{t("chat.messageCount", { count: messages.length })}</span>
468
+ </div>
469
+ ) : null}
470
+ {renderItems.map((item) =>
471
+ item.type === "single" ? (
472
+ renderSingle(item.message, continuationIds.has(item.message.id))
473
+ ) : (
474
+ <div className="activity-block" key={item.id}>
475
+ <details>
476
+ <summary className="activity-summary" aria-label={t("chat.aria.expandThinking")}>
477
+ {item.streaming ? <span className="activity-summary__dot" /> : null}
478
+ <ChevronDown size={14} className="activity-summary__chevron" aria-hidden="true" />
479
+ <span className="activity-summary__subtitle">
480
+ {activitySubtitle(item.steps, item.streaming)}
481
+ </span>
482
+ </summary>
483
+ <div className="activity-steps">
484
+ {item.steps.map(renderActivityStep)}
485
+ </div>
486
+ </details>
487
+ </div>
488
+ ),
489
+ )}
490
+ {showTiming && turnTiming && turnTiming.elapsedMs !== null ? (
491
+ <div className="message-stack__total" role="status">
492
+ {t(turnTiming.running ? "chat.turnTimeRunning" : "chat.totalTime", {
493
+ time: formatElapsed(turnTiming.elapsedMs),
494
+ })}
495
+ </div>
496
+ ) : null}
497
+ </div>
498
+ );
499
+ }
500
+
501
+ // Memoized so unrelated parent re-renders (slash menu toggling, model select
502
+ // updates, agent-running toast state) don't traverse the whole message list.
503
+ // Callers pass a `messages` array that's stable across re-renders thanks to
504
+ // useMemo upstream, so default shallow compare is correct.
505
+ export const MessageStream = memo(MessageStreamImpl);