@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.
@@ -6,6 +6,7 @@ import { useAuth } from "../../contexts/AuthContext";
6
6
  import { usePreferences } from "../../contexts/PreferencesContext";
7
7
  import { useT } from "../../i18n/useT";
8
8
  import { api } from "../../utils/api";
9
+ import { runtimeConfig } from "../../config";
9
10
  import { EXAMPLE_MODEL } from "@brainpilot/protocol";
10
11
  import { CustomSelect } from "../primitives/CustomSelect";
11
12
  import { IconButton } from "../primitives/IconButton";
@@ -18,7 +19,7 @@ type SettingsDialogProps = {
18
19
  onClose: () => void;
19
20
  };
20
21
 
21
- const tabs: Array<{ id: SettingsTab; labelKey: string; icon: LucideIcon }> = [
22
+ const ALL_TABS: Array<{ id: SettingsTab; labelKey: string; icon: LucideIcon }> = [
22
23
  { id: "account", labelKey: "settings.tab.account", icon: UserRound },
23
24
  { id: "providers", labelKey: "settings.tab.providers", icon: SlidersHorizontal },
24
25
  { id: "mcp", labelKey: "settings.tab.mcp", icon: Plug },
@@ -26,6 +27,12 @@ const tabs: Array<{ id: SettingsTab; labelKey: string; icon: LucideIcon }> = [
26
27
  { id: "preferences", labelKey: "settings.tab.preferences", icon: Settings },
27
28
  ];
28
29
 
30
+ // Local single-user mode has no host-managed identity — the account tab would
31
+ // only show placeholder "local / local / 1970" values, so drop it entirely.
32
+ const tabs = runtimeConfig.localMode
33
+ ? ALL_TABS.filter((tab) => tab.id !== "account")
34
+ : ALL_TABS;
35
+
29
36
  const DEFAULT_PROVIDER_FORM = {
30
37
  name: "",
31
38
  baseUrl: "https://api.anthropic.com",
@@ -59,7 +66,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
59
66
  const { user } = useAuth();
60
67
  const preferences = usePreferences();
61
68
  const t = useT();
62
- const [activeTab, setActiveTab] = useState<SettingsTab>("account");
69
+ const [activeTab, setActiveTab] = useState<SettingsTab>(tabs[0].id);
63
70
  const [providers, setProviders] = useState<ProviderProfile[]>([]);
64
71
  const [mcpServers, setMcpServers] = useState<McpServerEntry[]>([]);
65
72
  const [providerForm, setProviderForm] = useState(DEFAULT_PROVIDER_FORM);
@@ -367,7 +374,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
367
374
  </div>
368
375
  </dl>
369
376
  <p className="settings-note">{t("settings.account.managedByHost")}</p>
370
- {version ? <span className="settings-version">{version}</span> : null}
371
377
  </section>
372
378
  ) : null}
373
379
 
@@ -443,6 +449,19 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
443
449
  </article>
444
450
  );
445
451
 
452
+ if (providers.length === 0) {
453
+ return (
454
+ <div className="settings-empty">
455
+ <SlidersHorizontal size={22} />
456
+ <strong>{t("settings.providers.empty")}</strong>
457
+ <p>{t("settings.providers.emptyHint")}</p>
458
+ <button className="settings-button" onClick={openProviderForm} type="button">
459
+ {t("settings.providers.add")}
460
+ </button>
461
+ </div>
462
+ );
463
+ }
464
+
446
465
  return (
447
466
  <>
448
467
  {sharedProviders.length > 0 ? (
@@ -478,21 +497,34 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
478
497
  {t("settings.mcp.addServer")}
479
498
  </button>
480
499
  </div>
481
- <div className="settings-list">
482
- {mcpServers.map((server) => (
483
- <article className="settings-list-item" key={server.name}>
484
- <div>
485
- <strong>{server.name}</strong>
486
- <span>{server.type === "stdio" ? [server.command, ...(server.args || [])].filter(Boolean).join(" ") : server.url}</span>
487
- <small>{server.type}</small>
488
- </div>
489
- <div className="settings-list-item__actions mcp-actions">
490
- <button onClick={() => editMcpServer(server)} type="button">{t("settings.mcp.edit")}</button>
491
- <button onClick={() => void removeMcpServer(server.name)} type="button">{t("settings.mcp.remove")}</button>
492
- </div>
493
- </article>
494
- ))}
495
- </div>
500
+ {mcpServers.length === 0 ? (
501
+ <div className="settings-empty">
502
+ <Plug size={22} />
503
+ <strong>{t("settings.mcp.empty")}</strong>
504
+ <p>{t("settings.mcp.emptyHint")}</p>
505
+ <button className="settings-button" onClick={openMcpForm} type="button">
506
+ {t("settings.mcp.addServer")}
507
+ </button>
508
+ </div>
509
+ ) : (
510
+ <div className="settings-list">
511
+ {mcpServers.map((server) => (
512
+ <article className="settings-list-item" key={server.name}>
513
+ <div>
514
+ <strong>
515
+ {server.name}
516
+ <span className={`mcp-transport-chip mcp-transport-chip--${server.type}`}>{server.type}</span>
517
+ </strong>
518
+ <span>{server.type === "stdio" ? [server.command, ...(server.args || [])].filter(Boolean).join(" ") : server.url}</span>
519
+ </div>
520
+ <div className="settings-list-item__actions mcp-actions">
521
+ <button onClick={() => editMcpServer(server)} type="button">{t("settings.mcp.edit")}</button>
522
+ <button onClick={() => void removeMcpServer(server.name)} type="button">{t("settings.mcp.remove")}</button>
523
+ </div>
524
+ </article>
525
+ ))}
526
+ </div>
527
+ )}
496
528
  </section>
497
529
  ) : null}
498
530
 
@@ -501,51 +533,77 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
501
533
  {activeTab === "preferences" ? (
502
534
  <section className="settings-section">
503
535
  <h3>{t("settings.prefs.title")}</h3>
504
- <div className="settings-field">
505
- <span>{t("settings.prefs.theme")}</span>
506
- <CustomSelect
507
- ariaLabel={t("settings.prefs.theme")}
508
- onChange={(value) => preferences.setTheme(value as typeof preferences.theme)}
509
- options={[
510
- { label: t("settings.prefs.themeLight"), value: "light" },
511
- { label: t("settings.prefs.themeDark"), value: "dark" },
512
- { label: t("settings.prefs.themeSystem"), value: "system" },
513
- ]}
514
- value={preferences.theme}
515
- />
536
+
537
+ <div className="settings-group">
538
+ <h4 className="settings-group__title">{t("settings.prefs.groupAppearance")}</h4>
539
+ <div className="settings-field settings-field--split">
540
+ <div className="settings-field__label">
541
+ <span>{t("settings.prefs.theme")}</span>
542
+ <small>{t("settings.prefs.themeDesc")}</small>
543
+ </div>
544
+ <CustomSelect
545
+ ariaLabel={t("settings.prefs.theme")}
546
+ onChange={(value) => preferences.setTheme(value as typeof preferences.theme)}
547
+ options={[
548
+ { label: t("settings.prefs.themeLight"), value: "light" },
549
+ { label: t("settings.prefs.themeDark"), value: "dark" },
550
+ { label: t("settings.prefs.themeSystem"), value: "system" },
551
+ ]}
552
+ value={preferences.theme}
553
+ />
554
+ </div>
555
+ <div className="settings-field settings-field--split">
556
+ <div className="settings-field__label">
557
+ <span>{t("settings.prefs.language")}</span>
558
+ <small>{t("settings.prefs.languageDesc")}</small>
559
+ </div>
560
+ <CustomSelect
561
+ ariaLabel={t("settings.prefs.language")}
562
+ onChange={(value) => preferences.setLanguage(value as typeof preferences.language)}
563
+ options={[
564
+ { label: "简体中文", value: "zh-CN" },
565
+ { label: "English", value: "en-US" },
566
+ ]}
567
+ value={preferences.language}
568
+ />
569
+ </div>
516
570
  </div>
517
- <div className="settings-field">
518
- <span>{t("settings.prefs.language")}</span>
519
- <CustomSelect
520
- ariaLabel={t("settings.prefs.language")}
521
- onChange={(value) => preferences.setLanguage(value as typeof preferences.language)}
522
- options={[
523
- { label: "简体中文", value: "zh-CN" },
524
- { label: "English", value: "en-US" },
525
- ]}
526
- value={preferences.language}
527
- />
571
+
572
+ <div className="settings-group">
573
+ <h4 className="settings-group__title">{t("settings.prefs.groupBehavior")}</h4>
574
+ <label className="settings-toggle-row">
575
+ <span className="settings-toggle-row__text">
576
+ <span>{t("settings.prefs.confirmDangerous")}</span>
577
+ <small>{t("settings.prefs.confirmDangerousDesc")}</small>
578
+ </span>
579
+ <input
580
+ checked={preferences.security.confirmDangerousActions}
581
+ onChange={(event) => preferences.setSecurity({ ...preferences.security, confirmDangerousActions: event.target.checked })}
582
+ type="checkbox"
583
+ />
584
+ </label>
585
+ <label className="settings-toggle-row">
586
+ <span className="settings-toggle-row__text">
587
+ <span>{t("settings.prefs.notifyDone")}</span>
588
+ <small>{t("settings.prefs.notifyDoneDesc")}</small>
589
+ </span>
590
+ <input
591
+ checked={preferences.notifications.agentDone}
592
+ onChange={(event) => preferences.setNotifications({ ...preferences.notifications, agentDone: event.target.checked })}
593
+ type="checkbox"
594
+ />
595
+ </label>
528
596
  </div>
529
- <label className="settings-check">
530
- <input
531
- checked={preferences.security.confirmDangerousActions}
532
- onChange={(event) => preferences.setSecurity({ ...preferences.security, confirmDangerousActions: event.target.checked })}
533
- type="checkbox"
534
- />
535
- <span>{t("settings.prefs.confirmDangerous")}</span>
536
- </label>
537
- <label className="settings-check">
538
- <input
539
- checked={preferences.notifications.agentDone}
540
- onChange={(event) => preferences.setNotifications({ ...preferences.notifications, agentDone: event.target.checked })}
541
- type="checkbox"
542
- />
543
- <span>{t("settings.prefs.notifyDone")}</span>
544
- </label>
545
597
  </section>
546
598
  ) : null}
547
599
  </div>
548
600
  </div>
601
+
602
+ {version ? (
603
+ <footer className="settings-modal__footer">
604
+ <span className="settings-version">{version}</span>
605
+ </footer>
606
+ ) : null}
549
607
  </section>
550
608
 
551
609
  {isProviderFormOpen ? (
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
+ import { Radio, Server } from "lucide-react";
2
3
  import { useSandbox } from "../../contexts/SandboxContext";
3
4
  import { useSessions } from "../../contexts/SessionContext";
4
5
  import { useT } from "../../i18n/useT";
@@ -24,6 +25,13 @@ function formatBytes(bytes?: number) {
24
25
  return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`;
25
26
  }
26
27
 
28
+ /** Clamp a percentage into [0, 100] and pick a severity band for the meter fill. */
29
+ function meterLevel(percent?: number): { pct: number; level: "ok" | "warning" | "critical" } {
30
+ const pct = Math.max(0, Math.min(100, Math.round(percent ?? 0)));
31
+ const level = pct >= 90 ? "critical" : pct >= 75 ? "warning" : "ok";
32
+ return { pct, level };
33
+ }
34
+
27
35
  function getConnectionState(status: string, isConnected: boolean): SandboxConnectionState {
28
36
  if (status === "running" && isConnected) {
29
37
  return "connected";
@@ -64,6 +72,7 @@ function getStatusKey(status: string, isConnected: boolean) {
64
72
  export function SandboxStatus() {
65
73
  const [isOpen, setIsOpen] = useState(false);
66
74
  const [logs, setLogs] = useState("");
75
+ const [showLogs, setShowLogs] = useState(false);
67
76
  const [health, setHealth] = useState<Record<string, unknown> | null>(null);
68
77
  const [detailsLoading, setDetailsLoading] = useState(false);
69
78
  const rootRef = useRef<HTMLDivElement | null>(null);
@@ -157,98 +166,119 @@ export function SandboxStatus() {
157
166
  id="sandbox-status-popover"
158
167
  role="dialog"
159
168
  >
160
- <div className="sandbox-status__header">
161
- <span className="sandbox-status__eyebrow">Sandbox</span>
162
- <strong>{t(connectionLabelKey[connection])}</strong>
169
+ <div className={`sandbox-status__hero sandbox-status__hero--${connection}`}>
170
+ <span className="sandbox-status__hero-dot" aria-hidden="true" />
171
+ <div className="sandbox-status__hero-text">
172
+ <strong>{t(connectionLabelKey[connection])}</strong>
173
+ <span className={isLoading ? "is-loading" : ""}>
174
+ {(() => {
175
+ const notice = getStatusKey(effectiveStatus, isConnected);
176
+ return t(notice.key, notice.vars);
177
+ })()}
178
+ </span>
179
+ </div>
163
180
  </div>
164
-
165
- <p className={`sandbox-status__notice ${isLoading ? "is-loading" : ""}`}>
166
- {(() => {
167
- const notice = getStatusKey(effectiveStatus, isConnected);
168
- return t(notice.key, notice.vars);
169
- })()}
170
- </p>
171
181
  {error ? <p className="sandbox-status__empty">{error}</p> : null}
172
182
 
173
183
  {hasSandbox ? (
174
184
  <>
175
- <dl className="sandbox-status__grid">
176
- <div>
177
- <dt>{t("sandbox.label.name")}</dt>
178
- <dd>{currentSandbox.name}</dd>
179
- </div>
180
- <div>
181
- <dt>{t("sandbox.label.id")}</dt>
182
- <dd>{currentSandbox.id}</dd>
183
- </div>
184
- <div>
185
- <dt>{t("sandbox.label.container")}</dt>
186
- <dd>{currentSandbox.containerName || "-"}</dd>
187
- </div>
188
- <div>
189
- <dt>{t("sandbox.label.hostApi")}</dt>
190
- <dd>{currentSandbox.hostApiUrl || "-"}</dd>
191
- </div>
192
- <div>
193
- <dt>{t("sandbox.label.port")}</dt>
194
- <dd>{currentSandbox.port ?? "-"}</dd>
195
- </div>
196
- <div>
197
- <dt>{t("sandbox.label.created")}</dt>
198
- <dd>{new Date(currentSandbox.createdAt).toLocaleString()}</dd>
199
- </div>
200
- </dl>
201
-
202
- <div className="sandbox-status__metrics">
203
- <div>
204
- <span>{t("sandbox.label.memory")}</span>
205
- <strong>
206
- {formatBytes(stats?.memory.usedBytes)} / {formatBytes(stats?.memory.limitBytes)}
207
- </strong>
208
- <small>{stats?.memory.percent ?? 0}%</small>
209
- </div>
210
- <div>
211
- <span>{t("sandbox.label.cpu")}</span>
212
- <strong>{t("sandbox.cpuUsed", { percent: stats?.cpu.usedPercent ?? 0 })}</strong>
213
- <small>
214
- {t("sandbox.cpuQuota", { quota: stats?.cpu.quotaPercent ?? 0, cpus: stats?.cpu.onlineCpus ?? 0 })}
215
- </small>
216
- </div>
217
- <div>
218
- <span>{t("sandbox.label.pids")}</span>
219
- <strong>
220
- {stats?.pids.current ?? 0} / {stats?.pids.limit ?? t("sandbox.unlimited")}
221
- </strong>
222
- </div>
223
- <div>
224
- <span>{t("sandbox.label.disk")}</span>
225
- <strong>
226
- {formatBytes(stats?.disk.workspaceUsedBytes)} / {formatBytes(stats?.disk.quotaBytes)}
227
- </strong>
228
- <small>{stats?.disk.percentOfQuota ?? 0}%</small>
229
- </div>
185
+ <div className="sandbox-status__meters">
186
+ {(() => {
187
+ const rows: Array<{ label: string; percent?: number; detail: string }> = [
188
+ {
189
+ label: t("sandbox.label.memory"),
190
+ percent: stats?.memory.percent,
191
+ detail: `${formatBytes(stats?.memory.usedBytes)} / ${formatBytes(stats?.memory.limitBytes)}`,
192
+ },
193
+ {
194
+ label: t("sandbox.label.disk"),
195
+ percent: stats?.disk.percentOfQuota,
196
+ detail: `${formatBytes(stats?.disk.workspaceUsedBytes)} / ${formatBytes(stats?.disk.quotaBytes)}`,
197
+ },
198
+ {
199
+ label: t("sandbox.label.cpu"),
200
+ percent: stats?.cpu.usedPercent,
201
+ detail: t("sandbox.cpuQuota", { quota: stats?.cpu.quotaPercent ?? 0, cpus: stats?.cpu.onlineCpus ?? 0 }),
202
+ },
203
+ ];
204
+ return rows.map((row) => {
205
+ const { pct, level } = meterLevel(row.percent);
206
+ return (
207
+ <div className="sandbox-meter" key={row.label}>
208
+ <div className="sandbox-meter__head">
209
+ <span>{row.label}</span>
210
+ <strong>{pct}%</strong>
211
+ </div>
212
+ <div className="sandbox-meter__track" aria-hidden="true">
213
+ <span className={`sandbox-meter__fill sandbox-meter__fill--${level}`} style={{ width: `${pct}%` }} />
214
+ </div>
215
+ <small>{row.detail}</small>
216
+ </div>
217
+ );
218
+ });
219
+ })()}
230
220
  </div>
231
221
 
232
- <div className="sandbox-status__health">
233
- <div>
234
- <span>{t("sandbox.label.health")}</span>
235
- <strong>{String(health?.status ?? (detailsLoading ? t("sandbox.health.checking") : t("sandbox.health.unknown")))}</strong>
236
- </div>
237
- <div>
238
- <span>{t("sandbox.label.runtime")}</span>
239
- <strong>{health?.agent_runtime === false ? t("sandbox.health.offline") : health?.agent_runtime ? t("sandbox.health.online") : "-"}</strong>
240
- </div>
241
- <div>
242
- <span>{t("sandbox.label.sse")}</span>
243
- <strong>{isConnected ? t("sandbox.health.connected") : t("sandbox.health.offline")}</strong>
244
- </div>
245
- <div>
246
- <span>{t("sandbox.label.checked")}</span>
247
- <strong>{health?.checked_at ? new Date(String(health.checked_at)).toLocaleTimeString() : "-"}</strong>
248
- </div>
222
+ <div className="sandbox-status__chips">
223
+ {(() => {
224
+ const runtimeOnline = health?.agent_runtime !== false && !!health?.agent_runtime;
225
+ const runtimeKnown = health?.agent_runtime !== undefined;
226
+ return (
227
+ <span className={`sandbox-chip sandbox-chip--${runtimeKnown ? (runtimeOnline ? "ok" : "off") : "unknown"}`}>
228
+ <Server size={13} />
229
+ {t("sandbox.label.runtime")}
230
+ <i className="sandbox-chip__dot" aria-hidden="true" />
231
+ </span>
232
+ );
233
+ })()}
234
+ <span className={`sandbox-chip sandbox-chip--${isConnected ? "ok" : "off"}`}>
235
+ <Radio size={13} />
236
+ {t("sandbox.label.sse")}
237
+ <i className="sandbox-chip__dot" aria-hidden="true" />
238
+ </span>
249
239
  </div>
250
240
 
251
- <pre className="sandbox-status__logs">{detailsLoading ? t("sandbox.logs.loading") : logs || t("sandbox.logs.empty")}</pre>
241
+ <details className="sandbox-status__details">
242
+ <summary>{t("sandbox.details.more")}</summary>
243
+ <dl className="sandbox-status__grid">
244
+ <div>
245
+ <dt>{t("sandbox.label.name")}</dt>
246
+ <dd>{currentSandbox.name}</dd>
247
+ </div>
248
+ <div>
249
+ <dt>{t("sandbox.label.id")}</dt>
250
+ <dd>{currentSandbox.id}</dd>
251
+ </div>
252
+ <div>
253
+ <dt>{t("sandbox.label.container")}</dt>
254
+ <dd>{currentSandbox.containerName || "-"}</dd>
255
+ </div>
256
+ <div>
257
+ <dt>{t("sandbox.label.hostApi")}</dt>
258
+ <dd>{currentSandbox.hostApiUrl || "-"}</dd>
259
+ </div>
260
+ <div>
261
+ <dt>{t("sandbox.label.port")}</dt>
262
+ <dd>{currentSandbox.port ?? "-"}</dd>
263
+ </div>
264
+ <div>
265
+ <dt>{t("sandbox.label.pids")}</dt>
266
+ <dd>{stats?.pids.current ?? 0} / {stats?.pids.limit ?? t("sandbox.unlimited")}</dd>
267
+ </div>
268
+ <div>
269
+ <dt>{t("sandbox.label.created")}</dt>
270
+ <dd>{new Date(currentSandbox.createdAt).toLocaleString()}</dd>
271
+ </div>
272
+ <div>
273
+ <dt>{t("sandbox.label.checked")}</dt>
274
+ <dd>{health?.checked_at ? new Date(String(health.checked_at)).toLocaleTimeString() : "-"}</dd>
275
+ </div>
276
+ </dl>
277
+ </details>
278
+
279
+ {showLogs ? (
280
+ <pre className="sandbox-status__logs">{detailsLoading ? t("sandbox.logs.loading") : logs || t("sandbox.logs.empty")}</pre>
281
+ ) : null}
252
282
  </>
253
283
  ) : (
254
284
  <p className="sandbox-status__empty">{t("sandbox.empty")}</p>
@@ -263,7 +293,21 @@ export function SandboxStatus() {
263
293
  <button disabled={isLoading} onClick={() => void refresh()} type="button">
264
294
  {t("sandbox.action.refresh")}
265
295
  </button>
266
- <button disabled={detailsLoading} onClick={() => void loadDetails()} type="button">
296
+ <button
297
+ aria-pressed={showLogs}
298
+ className={showLogs ? "is-active" : ""}
299
+ disabled={detailsLoading}
300
+ onClick={() => {
301
+ setShowLogs((current) => {
302
+ const next = !current;
303
+ if (next) {
304
+ void loadDetails();
305
+ }
306
+ return next;
307
+ });
308
+ }}
309
+ type="button"
310
+ >
267
311
  {t("sandbox.action.logs")}
268
312
  </button>
269
313
  <button
@@ -2,12 +2,17 @@ import { ChatMessage } from "../contracts/backend";
2
2
 
3
3
  /**
4
4
  * A unit of rendering in the chat stream. Either a single standalone message
5
- * (user prompt, assistant text, error, hook note) or an "activity" group that
6
- * folds adjacent reasoning and tool calls/results into one collapsible block.
5
+ * (user prompt, assistant text, error, hook note), an "activity" group that
6
+ * folds adjacent reasoning and tool calls/results into one collapsible block,
7
+ * or (#219) an "expertGroup" that folds a run of non-PI (specialist) agent
8
+ * render items behind one more level of disclosure so the PI narrative reads
9
+ * cleanly by default. An expertGroup nests already-built single/activity items
10
+ * so the reasoning/tool folds inside it are preserved when expanded.
7
11
  */
8
12
  export type RenderItem =
9
13
  | { type: "single"; message: ChatMessage }
10
- | { type: "activity"; id: string; steps: ChatMessage[]; streaming: boolean };
14
+ | { type: "activity"; id: string; steps: ChatMessage[]; streaming: boolean }
15
+ | { type: "expertGroup"; id: string; agents: string[]; items: RenderItem[]; streaming: boolean };
11
16
 
12
17
  /**
13
18
  * #134 — tool visibility model. Internal tools are part of the agent's plumbing
@@ -101,6 +106,7 @@ function isStandalone(message: ChatMessage): boolean {
101
106
  export function buildRenderItems(
102
107
  messages: ChatMessage[],
103
108
  runningAgents?: ReadonlySet<string>,
109
+ groupExpert = false,
104
110
  ): RenderItem[] {
105
111
  const items: RenderItem[] = [];
106
112
  // #134 — internal tools (trace bookkeeping) are hidden from the chat UI.
@@ -129,5 +135,105 @@ export function buildRenderItems(
129
135
  }
130
136
  }
131
137
  flush();
132
- return items;
138
+ // #219 — second pass: fold consecutive specialist (non-PI) items into
139
+ // collapsible expert groups. Off by default (demo replay / legacy callers).
140
+ return groupExpert ? groupExpertItems(items, runningAgents) : items;
141
+ }
142
+
143
+ /* -------------------------------------------------------------------------- *
144
+ * #219 — expert-agent activity grouping.
145
+ * -------------------------------------------------------------------------- */
146
+
147
+ /** The principal (PI) agent name; unattributed items default to it. */
148
+ const PRINCIPAL = "principal";
149
+
150
+ /**
151
+ * Important events stay visible even when they come from a specialist agent —
152
+ * they must never be buried inside a collapsed group (issue #219 UX goal:
153
+ * "avoid hiding important failures, blockers, or user-action-required events").
154
+ * Errors, approval/user-input requests, and warning+ system messages / hooks
155
+ * escape grouping and render standalone.
156
+ */
157
+ function isImportantEvent(message: ChatMessage): boolean {
158
+ if (message.kind === "error" || message.kind === "ask_user") return true;
159
+ if (message.kind === "system_message") {
160
+ const level = message.systemMessage?.level;
161
+ return level === "warning" || level === "error" || level === "fatal";
162
+ }
163
+ if (message.kind === "hook") {
164
+ return message.hookLevel === "warning" || message.hookLevel === "error";
165
+ }
166
+ return false;
167
+ }
168
+
169
+ /** Owning agent of a render item: the message's agent, or the activity's first step. */
170
+ function itemAgent(item: RenderItem): string {
171
+ if (item.type === "single") return item.message.agent ?? PRINCIPAL;
172
+ if (item.type === "activity") return item.steps[0]?.agent ?? PRINCIPAL;
173
+ return PRINCIPAL;
174
+ }
175
+
176
+ /**
177
+ * A render item is a foldable specialist item when it belongs to a non-PI agent
178
+ * AND (for singles) is not an important event that must stay surfaced. User
179
+ * prompts and PI/unattributed items always break the run.
180
+ */
181
+ function isFoldableExpertItem(item: RenderItem): boolean {
182
+ if (itemAgent(item) === PRINCIPAL) return false;
183
+ if (item.type === "single") {
184
+ if (item.message.role === "user") return false;
185
+ if (isImportantEvent(item.message)) return false;
186
+ }
187
+ return true;
188
+ }
189
+
190
+ function itemStreaming(item: RenderItem, runningAgents?: ReadonlySet<string>): boolean {
191
+ if (item.type === "activity") return item.streaming;
192
+ if (item.type === "single") {
193
+ return !!item.message.streaming || (runningAgents?.has(itemAgent(item)) ?? false);
194
+ }
195
+ return false;
196
+ }
197
+
198
+ /** Fold consecutive foldable specialist items into one expertGroup each. */
199
+ function groupExpertItems(items: RenderItem[], runningAgents?: ReadonlySet<string>): RenderItem[] {
200
+ const out: RenderItem[] = [];
201
+ let run: RenderItem[] = [];
202
+ const flushRun = () => {
203
+ if (run.length === 0) return;
204
+ // A lone expert `activity` item is already collapsed on its own — wrapping
205
+ // it in a second disclosure level just makes the user click twice. Leave it.
206
+ // A lone expert `single` (standalone text) still gets grouped so specialist
207
+ // chatter is collapsed by default (issue #219 acceptance criteria).
208
+ if (run.length === 1 && run[0].type === "activity") {
209
+ out.push(run[0]);
210
+ run = [];
211
+ return;
212
+ }
213
+ const agents = Array.from(new Set(run.map(itemAgent)));
214
+ out.push({
215
+ type: "expertGroup",
216
+ id: `expert-${idOf(run[0])}`,
217
+ agents,
218
+ items: run,
219
+ streaming: run.some((it) => itemStreaming(it, runningAgents)),
220
+ });
221
+ run = [];
222
+ };
223
+ for (const item of items) {
224
+ if (isFoldableExpertItem(item)) {
225
+ run.push(item);
226
+ } else {
227
+ flushRun();
228
+ out.push(item);
229
+ }
230
+ }
231
+ flushRun();
232
+ return out;
233
+ }
234
+
235
+ /** Stable id for a render item (drives <details> DOM reuse across re-renders). */
236
+ function idOf(item: RenderItem): string {
237
+ if (item.type === "single") return item.message.id;
238
+ return item.id;
133
239
  }
@@ -338,7 +338,17 @@ export function reduceMessagesForEvent(existing: ChatMessage[], event: WebSocket
338
338
 
339
339
  // 修正6 — system_message: 4-level styled bubble in the conversation stream.
340
340
  case "system_message": {
341
- return [...existing, systemMessageToChatMessage(event)];
341
+ const msg = systemMessageToChatMessage(event);
342
+ // #167: coalesce by stable id — a repeated system_message carrying an id
343
+ // that already exists (e.g. an agent's retry warning ticking n/N) updates
344
+ // the existing bubble in place instead of stacking a new one. Messages
345
+ // without a stable id (random-id path) always append, as before.
346
+ const e = event as Record<string, unknown>;
347
+ const hasStableId = typeof (e.id ?? e.messageId) === "string";
348
+ if (hasStableId && existing.some((m) => m.id === msg.id)) {
349
+ return existing.map((m) => (m.id === msg.id ? msg : m));
350
+ }
351
+ return [...existing, msg];
342
352
  }
343
353
 
344
354
  // 修正6 — user_input_request (ask_user): interactive card. Keyed by