@brainpilot/web 0.0.10 → 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-D63mUJxx.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-D8J9Cnup.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.10",
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.10",
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",
@@ -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", () => {
@@ -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";
@@ -46,6 +46,12 @@ interface MessageStreamProps {
46
46
  * (demo replay), where messages are already terminal.
47
47
  */
48
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;
49
55
  }
50
56
 
51
57
  // Whether this message participates in same-agent avatar merging. User
@@ -80,15 +86,24 @@ function MessageStreamImpl({
80
86
  onAskUserSubmit,
81
87
  onRetryCancel,
82
88
  runningAgents,
89
+ groupExpertActivity = false,
83
90
  }: MessageStreamProps) {
84
91
  const t = useT();
85
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);
86
96
  const stackRef = useRef<HTMLDivElement | null>(null);
87
97
  const isPinnedRef = useRef(true);
88
98
 
89
99
  const renderItems = useMemo(
90
- () => buildRenderItems(messages, runningAgents),
91
- [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],
92
107
  );
93
108
 
94
109
  // Avatar merging: a mergeable assistant/system row whose immediately
@@ -98,17 +113,26 @@ function MessageStreamImpl({
98
113
  const continuationIds = useMemo(() => {
99
114
  const set = new Set<string>();
100
115
  let prevName: string | null = null;
101
- for (const item of renderItems) {
102
- if (item.type === "single" && isMergeable(item.message)) {
103
- const name = mergeName(item.message);
104
- if (prevName === name) {
105
- 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;
106
132
  }
107
- prevName = name;
108
- } else {
109
- prevName = null;
110
133
  }
111
- }
134
+ };
135
+ walk(renderItems);
112
136
  return set;
113
137
  }, [renderItems]);
114
138
 
@@ -452,6 +476,58 @@ function MessageStreamImpl({
452
476
  return t("chat.thinking");
453
477
  };
454
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
+
455
531
  return (
456
532
  <div
457
533
  className={`message-stack ${className ?? ""}`}
@@ -459,30 +535,24 @@ function MessageStreamImpl({
459
535
  onScroll={handleScroll}
460
536
  ref={stackRef}
461
537
  >
462
- {showToolbarCount ? (
538
+ {showToolbarCount || hasExpertGroup ? (
463
539
  <div className="message-stack__toolbar">
464
- <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}
465
552
  </div>
466
553
  ) : null}
467
554
  {renderItems.map((item) =>
468
- item.type === "single" ? (
469
- renderSingle(item.message, continuationIds.has(item.message.id))
470
- ) : (
471
- <div className="activity-block" key={item.id}>
472
- <details>
473
- <summary className="activity-summary" aria-label={t("chat.aria.expandThinking")}>
474
- {item.streaming ? <span className="activity-summary__dot" /> : null}
475
- <ChevronDown size={14} className="activity-summary__chevron" aria-hidden="true" />
476
- <span className="activity-summary__subtitle">
477
- {activitySubtitle(item.steps, item.streaming)}
478
- </span>
479
- </summary>
480
- <div className="activity-steps">
481
- {item.steps.map(renderActivityStep)}
482
- </div>
483
- </details>
484
- </div>
485
- ),
555
+ item.type === "expertGroup" ? renderExpertGroup(item) : renderItem(item),
486
556
  )}
487
557
  {showTiming && turnTiming && turnTiming.elapsedMs !== null ? (
488
558
  <div className="message-stack__total" role="status">
@@ -308,6 +308,7 @@ export function PromptComposer() {
308
308
  showTiming
309
309
  turnTiming={turnTiming}
310
310
  runningAgents={runningAgents}
311
+ groupExpertActivity
311
312
  onAskUserSubmit={(requestId, answer) => void respondToInput(requestId, answer)}
312
313
  onRetryCancel={() => void interruptCurrent()}
313
314
  />
@@ -614,7 +614,7 @@ export function DemoView({ resetSignal }: DemoViewProps = {}) {
614
614
  {condensedMessages.length === 0 ? (
615
615
  <p className="demo-panel__empty">{t("demo.conversation.empty")}</p>
616
616
  ) : (
617
- <MessageStream messages={condensedMessages} showToolbarCount={false} className="demo-message-stream" />
617
+ <MessageStream messages={condensedMessages} showToolbarCount={false} groupExpertActivity className="demo-message-stream" />
618
618
  )}
619
619
  </section>
620
620
 
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
- import { Network, Pause, Play, RefreshCw, Search, UserRoundCog, X } from "lucide-react";
2
+ import { Network, Pause, Play, RefreshCw, Search, X } from "lucide-react";
3
3
  import { TraceNode } from "../../contracts/backend";
4
4
  import { useSessions } from "../../contexts/SessionContext";
5
5
  import { useT } from "../../i18n/useT";
@@ -24,14 +24,10 @@ export function AgentsPanel() {
24
24
  <section className="workspace-panel" aria-labelledby="agents-panel-heading">
25
25
  <div className="workspace-panel__inner workspace-panel__inner--trace">
26
26
  <header className="workspace-panel__header">
27
- <div>
28
- <span className="workspace-panel__eyebrow">
29
- <Network size={11} style={{ marginRight: 4, verticalAlign: "-1px" }} />
30
- {t("trace.agents.eyebrow")}
31
- </span>
32
- <h2 id="agents-panel-heading">{t("trace.agents.title")}</h2>
33
- </div>
34
- <UserRoundCog size={18} />
27
+ <h2 id="agents-panel-heading" className="workspace-panel__title-icon">
28
+ <Network size={18} />
29
+ {t("trace.agents.eyebrow")}
30
+ </h2>
35
31
  </header>
36
32
 
37
33
  {!currentSession ? (