@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/assets/index-DkoqxJfs.css +1 -0
- package/dist/assets/index-DtLW483q.js +451 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/__tests__/api.test.ts +49 -1
- package/src/__tests__/messageGroups.test.ts +150 -0
- package/src/__tests__/newUiEvents.test.ts +32 -0
- package/src/__tests__/runningScripts.test.ts +139 -0
- package/src/components/chat/MessageStream.tsx +103 -43
- package/src/components/chat/PromptComposer.tsx +28 -10
- package/src/components/chat/RunningScriptsPanel.tsx +118 -0
- package/src/components/chat/runningScripts.ts +88 -0
- package/src/components/demo/DemoView.tsx +1 -1
- package/src/components/session/AgentTraceViews.tsx +5 -9
- package/src/components/settings/KnowledgeBasePanel.tsx +758 -0
- package/src/components/settings/SettingsDialog.tsx +127 -61
- package/src/components/shell/SandboxStatus.tsx +128 -84
- package/src/contexts/messageGroups.ts +110 -4
- package/src/contexts/messageReducer.ts +11 -1
- package/src/i18n/messages/chat.ts +14 -0
- package/src/i18n/messages/sandbox.ts +3 -0
- package/src/i18n/messages/settings.ts +93 -0
- package/src/i18n/messages/trace.ts +0 -2
- package/src/styles/global.css +970 -80
- package/src/utils/api.ts +188 -3
- package/src/utils/format.ts +9 -0
- package/dist/assets/index-CJNvdeGz.js +0 -445
- package/dist/assets/index-DWOsU22G.css +0 -1
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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 === "
|
|
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">
|