@brainpilot/web 0.0.9 → 0.0.11

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.
package/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <meta name="color-scheme" content="light dark" />
7
7
  <title>BrainPilot</title>
8
- <script type="module" crossorigin src="/assets/index-CJNvdeGz.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-DWOsU22G.css">
8
+ <script type="module" crossorigin src="/assets/index-DtLW483q.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DkoqxJfs.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainpilot/web",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "dependencies": {},
33
33
  "devDependencies": {
34
- "@brainpilot/protocol": "^0.0.9",
34
+ "@brainpilot/protocol": "^0.0.11",
35
35
  "@fontsource-variable/geist": "^5.2.9",
36
36
  "@fontsource-variable/geist-mono": "^5.2.8",
37
37
  "@types/react": "^18.3.12",
@@ -183,12 +183,17 @@ describe("api.sessions.getHistory — persisted events.jsonl rehydration", () =>
183
183
  expect(url).toContain("/sessions/s1/history?limit=42");
184
184
  });
185
185
 
186
- it("returns the empty envelope on a non-ok response", async () => {
186
+ it("returns the empty envelope on a 404 (session has no transcript)", async () => {
187
187
  fetchMock.mockResolvedValueOnce(makeResponse({ ok: false, status: 404 }));
188
188
  const out = await api.sessions.getHistory("s1");
189
189
  expect(out).toEqual({ events: [], total: 0, truncated: false });
190
190
  });
191
191
 
192
+ it("throws on a non-404 failure instead of masking it as empty (#223)", async () => {
193
+ fetchMock.mockResolvedValueOnce(makeResponse({ ok: false, status: 500 }));
194
+ await expect(api.sessions.getHistory("s1")).rejects.toThrow(/history fetch failed: 500/);
195
+ });
196
+
192
197
  it("returns the empty envelope when the body is null", async () => {
193
198
  fetchMock.mockResolvedValueOnce(makeResponse({ contentType: "application/json", json: null }));
194
199
  const out = await api.sessions.getHistory("s1");
@@ -260,3 +265,46 @@ describe("api.sandbox.uploadFile — #47 base64 upload to the workspace", () =>
260
265
  await expect(api.sandbox.uploadFile("s1", "big.bin", new Blob(["hi"]))).rejects.toThrow("file too large");
261
266
  });
262
267
  });
268
+
269
+ // #206: parseError previously read only `detail`, so the backend's Zod shape
270
+ // `{ error, details }` and bare `{ error }` (e.g. 409) degraded to the generic
271
+ // "Request failed (...)". Driven through api.providers.create (handleJson path).
272
+ describe("#206 parseError surfaces { error, details }", () => {
273
+ const validCreate = { name: "x", baseUrl: "https://x", apiKey: "k", models: ["m"] } as never;
274
+
275
+ it("renders field-level Zod issues from { error, details }", async () => {
276
+ fetchMock.mockResolvedValueOnce(
277
+ makeResponse({
278
+ ok: false,
279
+ status: 400,
280
+ contentType: "application/json",
281
+ json: {
282
+ error: "invalid provider profile",
283
+ details: [{ path: ["base_url"], message: "must be a valid URL" }],
284
+ },
285
+ }),
286
+ );
287
+ await expect(api.providers.create(validCreate)).rejects.toThrow(
288
+ "invalid provider profile (base_url: must be a valid URL)",
289
+ );
290
+ });
291
+
292
+ it("surfaces a bare { error } (409 conflict) message", async () => {
293
+ fetchMock.mockResolvedValueOnce(
294
+ makeResponse({
295
+ ok: false,
296
+ status: 409,
297
+ contentType: "application/json",
298
+ json: { error: 'a provider named "sqz" already exists' },
299
+ }),
300
+ );
301
+ await expect(api.providers.create(validCreate)).rejects.toThrow('a provider named "sqz" already exists');
302
+ });
303
+
304
+ it("still prefers a plain { detail } string", async () => {
305
+ fetchMock.mockResolvedValueOnce(
306
+ makeResponse({ ok: false, status: 400, contentType: "application/json", json: { detail: "nope" } }),
307
+ );
308
+ await expect(api.providers.create(validCreate)).rejects.toThrow("nope");
309
+ });
310
+ });
@@ -78,3 +78,153 @@ describe("buildRenderItems — activity block run-active awareness", () => {
78
78
  expect(activities[1]).toMatchObject({ streaming: true });
79
79
  });
80
80
  });
81
+
82
+ /* -------------------------------------------------------------------------- *
83
+ * #219 — expert-agent activity grouping (groupExpert=true, 3rd arg).
84
+ * -------------------------------------------------------------------------- */
85
+
86
+ // A standalone assistant text row for an agent.
87
+ function text(over: Partial<ChatMessage> = {}): ChatMessage {
88
+ return {
89
+ id: over.id ?? `t-${Math.random().toString(36).slice(2)}`,
90
+ role: "assistant",
91
+ content: over.content ?? "hello",
92
+ createdAt: new Date().toISOString(),
93
+ agent: over.agent ?? "principal",
94
+ streaming: false,
95
+ kind: "text",
96
+ ...over,
97
+ };
98
+ }
99
+
100
+ describe("buildRenderItems — #219 expert grouping", () => {
101
+ it("legacy: default (groupExpert off) never emits an expertGroup", () => {
102
+ const items = buildRenderItems(
103
+ [text({ agent: "analyst" }), text({ agent: "analyst", id: "a2" })],
104
+ undefined,
105
+ );
106
+ expect(items.some((i) => i.type === "expertGroup")).toBe(false);
107
+ });
108
+
109
+ it("folds a consecutive run of specialist text into one expertGroup", () => {
110
+ const items = buildRenderItems(
111
+ [
112
+ text({ id: "p1", agent: "principal", content: "PI intro" }),
113
+ text({ id: "a1", agent: "analyst" }),
114
+ text({ id: "a2", agent: "analyst" }),
115
+ text({ id: "p2", agent: "principal", content: "PI wrap" }),
116
+ ],
117
+ undefined,
118
+ true,
119
+ );
120
+ // principal / group / principal
121
+ expect(items.map((i) => i.type)).toEqual(["single", "expertGroup", "single"]);
122
+ const group = items.find((i) => i.type === "expertGroup")!;
123
+ expect(group).toMatchObject({ type: "expertGroup", agents: ["analyst"] });
124
+ expect((group as { items: unknown[] }).items).toHaveLength(2);
125
+ });
126
+
127
+ it("PI item breaks the specialist run into separate groups", () => {
128
+ const items = buildRenderItems(
129
+ [
130
+ text({ id: "a1", agent: "analyst" }),
131
+ text({ id: "a2", agent: "analyst" }),
132
+ text({ id: "p1", agent: "principal" }),
133
+ text({ id: "w1", agent: "writer" }),
134
+ text({ id: "w2", agent: "writer" }),
135
+ ],
136
+ undefined,
137
+ true,
138
+ );
139
+ expect(items.filter((i) => i.type === "expertGroup")).toHaveLength(2);
140
+ });
141
+
142
+ it("important events from a specialist escape the group and stay standalone", () => {
143
+ const err: ChatMessage = text({ id: "e1", agent: "analyst", kind: "error", content: "boom" });
144
+ const ask: ChatMessage = {
145
+ ...text({ id: "q1", agent: "analyst" }),
146
+ kind: "ask_user",
147
+ askUser: { requestId: "r1", agent: "analyst", question: "?" } as never,
148
+ };
149
+ const items = buildRenderItems(
150
+ [text({ id: "a1", agent: "analyst" }), err, ask, text({ id: "a2", agent: "analyst" })],
151
+ undefined,
152
+ true,
153
+ );
154
+ // error + ask_user MUST render standalone (never buried in a collapsed group).
155
+ const singleIds = items
156
+ .filter((i) => i.type === "single")
157
+ .map((i) => (i as { message: ChatMessage }).message.id);
158
+ expect(singleIds).toContain("e1");
159
+ expect(singleIds).toContain("q1");
160
+ // the escapes are NOT swallowed into any group
161
+ const groupedIds = items
162
+ .filter((i) => i.type === "expertGroup")
163
+ .flatMap((i) => (i as { items: { type: string; message?: ChatMessage; id: string }[] }).items)
164
+ .map((it) => (it.type === "single" ? it.message!.id : it.id));
165
+ expect(groupedIds).not.toContain("e1");
166
+ expect(groupedIds).not.toContain("q1");
167
+ });
168
+
169
+ it("warning+ system_message from a specialist escapes; info-level folds in", () => {
170
+ const warn: ChatMessage = {
171
+ ...text({ id: "sw", agent: "analyst" }),
172
+ kind: "system_message",
173
+ systemMessage: { level: "warning", message: "heads up", recoverable: true } as never,
174
+ };
175
+ const info: ChatMessage = {
176
+ ...text({ id: "si", agent: "analyst" }),
177
+ kind: "system_message",
178
+ systemMessage: { level: "info", message: "fyi", recoverable: true } as never,
179
+ };
180
+ const items = buildRenderItems(
181
+ [info, text({ id: "a1", agent: "analyst" }), warn],
182
+ undefined,
183
+ true,
184
+ );
185
+ // info + a1 fold together; warning stays standalone.
186
+ const group = items.find((i) => i.type === "expertGroup") as { items: RenderItemLike[] } | undefined;
187
+ expect(group).toBeTruthy();
188
+ expect(items.some((i) => i.type === "single" && (i as { message: ChatMessage }).message.id === "sw")).toBe(true);
189
+ });
190
+
191
+ it("principal items never fold into a group", () => {
192
+ const items = buildRenderItems(
193
+ [text({ id: "p1", agent: "principal" }), text({ id: "p2", agent: "principal" })],
194
+ undefined,
195
+ true,
196
+ );
197
+ expect(items.every((i) => i.type === "single")).toBe(true);
198
+ });
199
+
200
+ it("a lone specialist activity is left as-is (no double wrapper)", () => {
201
+ const items = buildRenderItems(
202
+ [step({ id: "a1", agent: "analyst", kind: "tool" })],
203
+ undefined,
204
+ true,
205
+ );
206
+ expect(items).toHaveLength(1);
207
+ expect(items[0].type).toBe("activity");
208
+ });
209
+
210
+ it("multi-agent run dedups agent names and sets streaming from running set", () => {
211
+ const items = buildRenderItems(
212
+ [
213
+ text({ id: "a1", agent: "analyst" }),
214
+ text({ id: "w1", agent: "writer" }),
215
+ text({ id: "a2", agent: "analyst" }),
216
+ ],
217
+ new Set(["writer"]),
218
+ true,
219
+ );
220
+ const group = items.find((i) => i.type === "expertGroup") as
221
+ | { agents: string[]; streaming: boolean }
222
+ | undefined;
223
+ expect(group).toBeTruthy();
224
+ expect(group!.agents.sort()).toEqual(["analyst", "writer"]);
225
+ expect(group!.streaming).toBe(true);
226
+ });
227
+ });
228
+
229
+ // Minimal structural alias for readability in the escape test above.
230
+ type RenderItemLike = { type: string };
@@ -56,6 +56,38 @@ describe("system_message mapping", () => {
56
56
  expect(out[0].systemMessage?.level).toBe("warning");
57
57
  expect(out[0].content).toBe("watch out");
58
58
  });
59
+
60
+ it("#167: coalesces repeated system_messages sharing a stable id (retry ticks)", () => {
61
+ const mk = (attempt: number) =>
62
+ ({
63
+ type: "system_message",
64
+ id: "retry-librarian-run_1",
65
+ level: "warning",
66
+ message: `retrying (${attempt}/3)`,
67
+ agent: "librarian",
68
+ }) as WebSocketEvent;
69
+ let msgs = reduceMessagesForEvent([], mk(1));
70
+ msgs = reduceMessagesForEvent(msgs, mk(2));
71
+ msgs = reduceMessagesForEvent(msgs, mk(3));
72
+ // One bubble, updated in place to the latest attempt.
73
+ expect(msgs).toHaveLength(1);
74
+ expect(msgs[0].id).toBe("retry-librarian-run_1");
75
+ expect(msgs[0].content).toBe("retrying (3/3)");
76
+ });
77
+
78
+ it("#167: system_messages without a stable id still append", () => {
79
+ let msgs = reduceMessagesForEvent([], {
80
+ type: "system_message",
81
+ level: "warning",
82
+ message: "one",
83
+ } as WebSocketEvent);
84
+ msgs = reduceMessagesForEvent(msgs, {
85
+ type: "system_message",
86
+ level: "warning",
87
+ message: "two",
88
+ } as WebSocketEvent);
89
+ expect(msgs).toHaveLength(2);
90
+ });
59
91
  });
60
92
 
61
93
  describe("ask_user mapping + submit + reducer round-trip", () => {
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ extractCommand,
4
+ isBashTool,
5
+ selectActiveScripts,
6
+ } from "../components/chat/runningScripts";
7
+ import type { ChatMessage } from "../contracts/backend";
8
+
9
+ function bashCall(over: Partial<ChatMessage> = {}): ChatMessage {
10
+ return {
11
+ id: over.id ?? "call-1",
12
+ role: "assistant",
13
+ content: "Tool: bash",
14
+ createdAt: "2026-07-01T00:00:00.000Z",
15
+ agent: over.agent ?? "principal",
16
+ kind: "tool",
17
+ toolName: "bash",
18
+ streaming: true,
19
+ toolInput: JSON.stringify({ command: "pytest -x tests/unit" }),
20
+ ...over,
21
+ };
22
+ }
23
+
24
+ describe("isBashTool", () => {
25
+ it("matches the bare bash tool", () => {
26
+ expect(isBashTool("bash")).toBe(true);
27
+ });
28
+
29
+ it("matches the mcp-namespaced bash tool", () => {
30
+ expect(isBashTool("mcp__local__bash")).toBe(true);
31
+ });
32
+
33
+ it("does not match other tool names", () => {
34
+ expect(isBashTool("read")).toBe(false);
35
+ expect(isBashTool("mcp__local__read")).toBe(false);
36
+ expect(isBashTool("bash_history")).toBe(false); // suffix match required
37
+ expect(isBashTool(undefined)).toBe(false);
38
+ });
39
+ });
40
+
41
+ describe("extractCommand", () => {
42
+ it("pulls `command` out of a fully-formed JSON args string", () => {
43
+ expect(extractCommand('{"command":"ls -la"}')).toBe("ls -la");
44
+ });
45
+
46
+ it("falls back to `cmd`/`script`/`shell` keys", () => {
47
+ expect(extractCommand('{"cmd":"echo hi"}')).toBe("echo hi");
48
+ expect(extractCommand('{"script":"./run.sh"}')).toBe("./run.sh");
49
+ expect(extractCommand('{"shell":"bash -c foo"}')).toBe("bash -c foo");
50
+ });
51
+
52
+ it("returns the raw string when the JSON is still partial", () => {
53
+ // TOOL_CALL_ARGS deltas may arrive as `{"comm` before the full JSON has
54
+ // buffered. Better to show *something* than to render an empty row.
55
+ expect(extractCommand('{"command":"pyt')).toBe('{"command":"pyt');
56
+ });
57
+
58
+ it("returns empty for non-strings and empty strings", () => {
59
+ expect(extractCommand(undefined)).toBe("");
60
+ expect(extractCommand(null)).toBe("");
61
+ expect(extractCommand("")).toBe("");
62
+ expect(extractCommand({ command: "not a string" })).toBe("");
63
+ });
64
+ });
65
+
66
+ describe("selectActiveScripts", () => {
67
+ it("returns empty when there are no tool messages", () => {
68
+ expect(selectActiveScripts([])).toEqual([]);
69
+ expect(
70
+ selectActiveScripts([
71
+ {
72
+ id: "t1",
73
+ role: "assistant",
74
+ content: "hello",
75
+ createdAt: "",
76
+ kind: "text",
77
+ },
78
+ ]),
79
+ ).toEqual([]);
80
+ });
81
+
82
+ it("returns only streaming bash calls, ignoring completed ones", () => {
83
+ const active = bashCall({ id: "a", streaming: true });
84
+ const done = bashCall({ id: "b", streaming: false });
85
+ const result = selectActiveScripts([active, done]);
86
+ expect(result.map((s) => s.id)).toEqual(["a"]);
87
+ });
88
+
89
+ it("ignores non-bash tool calls even when they are streaming", () => {
90
+ const bash = bashCall({ id: "a", streaming: true });
91
+ const read = bashCall({
92
+ id: "b",
93
+ streaming: true,
94
+ toolName: "read",
95
+ toolInput: JSON.stringify({ file: "foo.txt" }),
96
+ });
97
+ const trace = bashCall({
98
+ id: "c",
99
+ streaming: true,
100
+ toolName: "mcp__brainpilot__record_trace",
101
+ toolInput: "{}",
102
+ });
103
+ const result = selectActiveScripts([bash, read, trace]);
104
+ expect(result.map((s) => s.id)).toEqual(["a"]);
105
+ });
106
+
107
+ it("preserves arrival order across multiple concurrent bash calls", () => {
108
+ const first = bashCall({
109
+ id: "first",
110
+ agent: "principal",
111
+ toolInput: JSON.stringify({ command: "pytest" }),
112
+ });
113
+ const second = bashCall({
114
+ id: "second",
115
+ agent: "engineer",
116
+ toolInput: JSON.stringify({ command: "npm test" }),
117
+ });
118
+ const result = selectActiveScripts([first, second]);
119
+ expect(result).toEqual([
120
+ expect.objectContaining({ id: "first", agent: "principal", command: "pytest" }),
121
+ expect.objectContaining({ id: "second", agent: "engineer", command: "npm test" }),
122
+ ]);
123
+ });
124
+
125
+ it("defaults the agent name to 'principal' when unattributed", () => {
126
+ const noAgent = bashCall({ id: "a", agent: undefined });
127
+ const [row] = selectActiveScripts([noAgent]);
128
+ expect(row.agent).toBe("principal");
129
+ });
130
+
131
+ it("shows the raw arg fragment while args are still streaming", () => {
132
+ const partial = bashCall({
133
+ id: "a",
134
+ toolInput: '{"command":"pyt', // TOOL_CALL_ARGS half-arrived
135
+ });
136
+ const [row] = selectActiveScripts([partial]);
137
+ expect(row.command).toBe('{"command":"pyt');
138
+ });
139
+ });
@@ -1,4 +1,4 @@
1
- import { Check, ChevronDown, Copy } from "lucide-react";
1
+ import { Check, ChevronDown, Copy, Users } from "lucide-react";
2
2
  import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
3
  import type { ChatMessage } from "../../contracts/backend";
4
4
  import { buildRenderItems } from "../../contexts/messageGroups";
@@ -8,6 +8,7 @@ import { SystemMessageBubble } from "./SystemMessageBubble";
8
8
  import { AskUserCard } from "./AskUserCard";
9
9
  import { AutoRetryIndicator } from "./AutoRetryIndicator";
10
10
  import { formatToolName, formatPayload } from "../../utils/toolDisplay";
11
+ import { formatElapsed } from "../../utils/format";
11
12
  import { getChatScroll, setChatScroll, resolveScrollTop } from "./chatScrollMemory";
12
13
 
13
14
  interface MessageStreamProps {
@@ -45,6 +46,12 @@ interface MessageStreamProps {
45
46
  * (demo replay), where messages are already terminal.
46
47
  */
47
48
  runningAgents?: ReadonlySet<string>;
49
+ /**
50
+ * #219 — fold non-PI (specialist) agent activity into collapsible per-run
51
+ * groups so the Principal narrative reads cleanly by default. Off by default
52
+ * so demo replay keeps its flat, curated presentation.
53
+ */
54
+ groupExpertActivity?: boolean;
48
55
  }
49
56
 
50
57
  // Whether this message participates in same-agent avatar merging. User
@@ -60,17 +67,6 @@ function mergeName(message: ChatMessage): string {
60
67
  return message.agent || (message.role === "system" ? "system" : "principal");
61
68
  }
62
69
 
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
70
 
75
71
  /**
76
72
  * Presentational chat message stack — message bubbles, agent rows, hook notes,
@@ -90,15 +86,24 @@ function MessageStreamImpl({
90
86
  onAskUserSubmit,
91
87
  onRetryCancel,
92
88
  runningAgents,
89
+ groupExpertActivity = false,
93
90
  }: MessageStreamProps) {
94
91
  const t = useT();
95
92
  const [copiedId, setCopiedId] = useState<string | null>(null);
93
+ // #219 — audit mode: force every specialist group open (reasoning/tool folds
94
+ // inside stay independent, per issue).
95
+ const [expandAll, setExpandAll] = useState(false);
96
96
  const stackRef = useRef<HTMLDivElement | null>(null);
97
97
  const isPinnedRef = useRef(true);
98
98
 
99
99
  const renderItems = useMemo(
100
- () => buildRenderItems(messages, runningAgents),
101
- [messages, runningAgents],
100
+ () => buildRenderItems(messages, runningAgents, groupExpertActivity),
101
+ [messages, runningAgents, groupExpertActivity],
102
+ );
103
+
104
+ const hasExpertGroup = useMemo(
105
+ () => renderItems.some((item) => item.type === "expertGroup"),
106
+ [renderItems],
102
107
  );
103
108
 
104
109
  // Avatar merging: a mergeable assistant/system row whose immediately
@@ -108,17 +113,26 @@ function MessageStreamImpl({
108
113
  const continuationIds = useMemo(() => {
109
114
  const set = new Set<string>();
110
115
  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
+ const walk = (items: typeof renderItems) => {
117
+ for (const item of items) {
118
+ if (item.type === "single" && isMergeable(item.message)) {
119
+ const name = mergeName(item.message);
120
+ if (prevName === name) {
121
+ set.add(item.message.id);
122
+ }
123
+ prevName = name;
124
+ } else if (item.type === "expertGroup") {
125
+ // A group is its own merge scope: the first row inside always shows
126
+ // its avatar, and the group boundary breaks the outer run.
127
+ prevName = null;
128
+ walk(item.items);
129
+ prevName = null;
130
+ } else {
131
+ prevName = null;
116
132
  }
117
- prevName = name;
118
- } else {
119
- prevName = null;
120
133
  }
121
- }
134
+ };
135
+ walk(renderItems);
122
136
  return set;
123
137
  }, [renderItems]);
124
138
 
@@ -462,6 +476,58 @@ function MessageStreamImpl({
462
476
  return t("chat.thinking");
463
477
  };
464
478
 
479
+ // A folded reasoning/tool activity block. Extracted so it renders identically
480
+ // at the top level and nested inside an expert group (#219).
481
+ const renderActivityBlock = (id: string, steps: ChatMessage[], streaming: boolean) => (
482
+ <div className="activity-block" key={id}>
483
+ <details>
484
+ <summary className="activity-summary" aria-label={t("chat.aria.expandThinking")}>
485
+ {streaming ? <span className="activity-summary__dot" /> : null}
486
+ <ChevronDown size={14} className="activity-summary__chevron" aria-hidden="true" />
487
+ <span className="activity-summary__subtitle">{activitySubtitle(steps, streaming)}</span>
488
+ </summary>
489
+ <div className="activity-steps">{steps.map(renderActivityStep)}</div>
490
+ </details>
491
+ </div>
492
+ );
493
+
494
+ // Render one top-level or nested render item (single row or activity block).
495
+ // expertGroup is handled by renderExpertGroup, not here.
496
+ const renderItem = (item: (typeof renderItems)[number]) => {
497
+ if (item.type === "single") {
498
+ return renderSingle(item.message, continuationIds.has(item.message.id));
499
+ }
500
+ if (item.type === "activity") {
501
+ return renderActivityBlock(item.id, item.steps, item.streaming);
502
+ }
503
+ return null;
504
+ };
505
+
506
+ // #219 — a collapsed run of specialist-agent activity. Summary names the
507
+ // agent(s) and item count; the body reuses the normal single/activity
508
+ // renderers so reasoning/tool folds inside are preserved. `expandAll` (audit
509
+ // mode) forces every group open.
510
+ const renderExpertGroup = (item: Extract<(typeof renderItems)[number], { type: "expertGroup" }>) => {
511
+ const count = item.items.length;
512
+ const summary =
513
+ item.agents.length === 1
514
+ ? t("chat.expertGroup.summary", { agent: item.agents[0], count })
515
+ : t("chat.expertGroup.summaryMulti", { n: item.agents.length, count });
516
+ return (
517
+ <div className="expert-group" key={item.id}>
518
+ <details open={expandAll || undefined}>
519
+ <summary className="expert-group__summary" aria-label={t("chat.aria.expandExpert")}>
520
+ {item.streaming ? <span className="activity-summary__dot" /> : null}
521
+ <ChevronDown size={14} className="activity-summary__chevron" aria-hidden="true" />
522
+ <Users size={13} className="expert-group__icon" aria-hidden="true" />
523
+ <span className="activity-summary__subtitle">{summary}</span>
524
+ </summary>
525
+ <div className="expert-group__body">{item.items.map(renderItem)}</div>
526
+ </details>
527
+ </div>
528
+ );
529
+ };
530
+
465
531
  return (
466
532
  <div
467
533
  className={`message-stack ${className ?? ""}`}
@@ -469,30 +535,24 @@ function MessageStreamImpl({
469
535
  onScroll={handleScroll}
470
536
  ref={stackRef}
471
537
  >
472
- {showToolbarCount ? (
538
+ {showToolbarCount || hasExpertGroup ? (
473
539
  <div className="message-stack__toolbar">
474
- <span>{t("chat.messageCount", { count: messages.length })}</span>
540
+ {showToolbarCount ? <span>{t("chat.messageCount", { count: messages.length })}</span> : <span />}
541
+ {hasExpertGroup ? (
542
+ <button
543
+ className={`message-stack__audit-toggle ${expandAll ? "is-active" : ""}`}
544
+ onClick={() => setExpandAll((v) => !v)}
545
+ type="button"
546
+ aria-pressed={expandAll}
547
+ >
548
+ <Users size={12} aria-hidden="true" />
549
+ {expandAll ? t("chat.expertGroup.collapseAll") : t("chat.expertGroup.expandAll")}
550
+ </button>
551
+ ) : null}
475
552
  </div>
476
553
  ) : null}
477
554
  {renderItems.map((item) =>
478
- item.type === "single" ? (
479
- renderSingle(item.message, continuationIds.has(item.message.id))
480
- ) : (
481
- <div className="activity-block" key={item.id}>
482
- <details>
483
- <summary className="activity-summary" aria-label={t("chat.aria.expandThinking")}>
484
- {item.streaming ? <span className="activity-summary__dot" /> : null}
485
- <ChevronDown size={14} className="activity-summary__chevron" aria-hidden="true" />
486
- <span className="activity-summary__subtitle">
487
- {activitySubtitle(item.steps, item.streaming)}
488
- </span>
489
- </summary>
490
- <div className="activity-steps">
491
- {item.steps.map(renderActivityStep)}
492
- </div>
493
- </details>
494
- </div>
495
- ),
555
+ item.type === "expertGroup" ? renderExpertGroup(item) : renderItem(item),
496
556
  )}
497
557
  {showTiming && turnTiming && turnTiming.elapsedMs !== null ? (
498
558
  <div className="message-stack__total" role="status">