@brainpilot/web 0.0.4 → 0.0.5

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