@brainpilot/web 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/assets/index-Br55rkHb.css +1 -0
  2. package/dist/assets/index-CeUzk-ej.js +445 -0
  3. package/dist/index.html +2 -2
  4. package/index.html +13 -0
  5. package/package.json +12 -3
  6. package/src/App.tsx +10 -0
  7. package/src/__tests__/agentsReducer.test.ts +67 -0
  8. package/src/__tests__/api.test.ts +221 -0
  9. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  10. package/src/__tests__/demoConversation.test.ts +73 -0
  11. package/src/__tests__/demoReset.test.ts +24 -0
  12. package/src/__tests__/messageGroups.test.ts +80 -0
  13. package/src/__tests__/newUiComponents.test.tsx +101 -0
  14. package/src/__tests__/newUiEvents.test.ts +236 -0
  15. package/src/__tests__/runningToast.test.ts +29 -0
  16. package/src/__tests__/tokenUsage.test.ts +48 -0
  17. package/src/__tests__/toolDisplay.test.ts +55 -0
  18. package/src/__tests__/traceReducer.test.ts +62 -0
  19. package/src/components/chat/AskUserCard.tsx +123 -0
  20. package/src/components/chat/AutoRetryIndicator.tsx +71 -0
  21. package/src/components/chat/ComposerInput.tsx +73 -0
  22. package/src/components/chat/ComposerSendButton.tsx +26 -0
  23. package/src/components/chat/MarkdownMessage.tsx +24 -0
  24. package/src/components/chat/MessageStream.tsx +505 -0
  25. package/src/components/chat/PromptComposer.tsx +489 -0
  26. package/src/components/chat/SystemMessageBubble.tsx +46 -0
  27. package/src/components/chat/chatScrollMemory.ts +49 -0
  28. package/src/components/demo/DemoFileTree.tsx +146 -0
  29. package/src/components/demo/DemoView.tsx +730 -0
  30. package/src/components/demo/TraceNodeModal.tsx +80 -0
  31. package/src/components/demo/demoBundle.ts +223 -0
  32. package/src/components/demo/demoCache.ts +42 -0
  33. package/src/components/demo/demoReset.ts +16 -0
  34. package/src/components/files/FilePreviewView.tsx +153 -0
  35. package/src/components/files/FileSidebar.tsx +664 -0
  36. package/src/components/files/filePreview.ts +113 -0
  37. package/src/components/primitives/CustomSelect.tsx +200 -0
  38. package/src/components/primitives/IconButton.tsx +27 -0
  39. package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
  40. package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
  41. package/src/components/quota/QuotaFileManager.tsx +197 -0
  42. package/src/components/search/SearchDialog.tsx +101 -0
  43. package/src/components/session/AgentNetwork.tsx +1233 -0
  44. package/src/components/session/AgentTraceViews.tsx +346 -0
  45. package/src/components/session/AnalyticsTab.tsx +220 -0
  46. package/src/components/session/GlobalOverview.tsx +108 -0
  47. package/src/components/session/NodeTooltip.tsx +127 -0
  48. package/src/components/session/TimelineTab.tsx +320 -0
  49. package/src/components/session/TraceGraphView.tsx +307 -0
  50. package/src/components/session/TraceNodeDetail.tsx +179 -0
  51. package/src/components/session/agentAnalytics.ts +397 -0
  52. package/src/components/session/agentNetworkShared.ts +339 -0
  53. package/src/components/session/traceLayout.ts +182 -0
  54. package/src/components/settings/SettingsDialog.tsx +737 -0
  55. package/src/components/shell/DesktopShell.tsx +261 -0
  56. package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
  57. package/src/components/shell/SandboxStatus.tsx +287 -0
  58. package/src/components/shell/TerminalDrawer.tsx +387 -0
  59. package/src/components/sidebar/Sidebar.tsx +191 -0
  60. package/src/config.ts +10 -0
  61. package/src/contexts/AppProviders.tsx +20 -0
  62. package/src/contexts/AuthContext.tsx +61 -0
  63. package/src/contexts/PreferencesContext.tsx +125 -0
  64. package/src/contexts/SSEContext.tsx +264 -0
  65. package/src/contexts/SandboxContext.tsx +310 -0
  66. package/src/contexts/SessionContext.tsx +919 -0
  67. package/src/contexts/agentsReducer.ts +49 -0
  68. package/src/contexts/draftStore.ts +103 -0
  69. package/src/contexts/messageFilters.ts +29 -0
  70. package/src/contexts/messageGroups.ts +77 -0
  71. package/src/contexts/messageReducer.ts +401 -0
  72. package/src/contexts/newUiEvents.ts +190 -0
  73. package/src/contexts/runningToast.ts +33 -0
  74. package/src/contexts/traceReducer.ts +62 -0
  75. package/src/contexts/turnTimer.test.ts +97 -0
  76. package/src/contexts/turnTimer.ts +108 -0
  77. package/src/contexts/useTurnTimer.ts +104 -0
  78. package/src/contracts/backend.ts +897 -0
  79. package/src/contracts/demoBundle.ts +83 -0
  80. package/src/i18n/messages/analytics.ts +106 -0
  81. package/src/i18n/messages/chat.ts +130 -0
  82. package/src/i18n/messages/contexts.ts +42 -0
  83. package/src/i18n/messages/demo.ts +80 -0
  84. package/src/i18n/messages/files.ts +82 -0
  85. package/src/i18n/messages/network.ts +190 -0
  86. package/src/i18n/messages/profile.ts +44 -0
  87. package/src/i18n/messages/quota.ts +36 -0
  88. package/src/i18n/messages/sandbox.ts +116 -0
  89. package/src/i18n/messages/search.ts +16 -0
  90. package/src/i18n/messages/settings.ts +188 -0
  91. package/src/i18n/messages/shell.ts +38 -0
  92. package/src/i18n/messages/sidebar.ts +52 -0
  93. package/src/i18n/messages/terminal.ts +22 -0
  94. package/src/i18n/messages/trace.ts +136 -0
  95. package/src/i18n/messages.ts +32 -0
  96. package/src/i18n/translate.ts +46 -0
  97. package/src/i18n/types.ts +15 -0
  98. package/src/i18n/useT.ts +15 -0
  99. package/src/main.tsx +13 -0
  100. package/src/mocks/backend.ts +729 -0
  101. package/src/styles/global.css +7578 -0
  102. package/src/styles/tokens.css +161 -0
  103. package/src/utils/api.ts +724 -0
  104. package/src/utils/download.ts +18 -0
  105. package/src/utils/format.ts +7 -0
  106. package/src/utils/toolDisplay.ts +74 -0
  107. package/src/utils/zip.ts +119 -0
  108. package/src/vite-env.d.ts +1 -0
  109. package/tsconfig.app.json +22 -0
  110. package/tsconfig.json +7 -0
  111. package/tsconfig.node.json +13 -0
  112. package/vite.config.ts +13 -0
  113. package/dist/assets/index-Cd0Mi_WU.css +0 -1
  114. package/dist/assets/index-FGg-DeYR.js +0 -448
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-FGg-DeYR.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-Cd0Mi_WU.css">
8
+ <script type="module" crossorigin src="/assets/index-CeUzk-ej.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-Br55rkHb.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/index.html ADDED
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="color-scheme" content="light dark" />
7
+ <title>BrainPilot</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
package/package.json CHANGED
@@ -1,10 +1,19 @@
1
1
  {
2
2
  "name": "@brainpilot/web",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
+ "engines": {
5
+ "node": ">=22"
6
+ },
4
7
  "license": "Apache-2.0",
5
8
  "type": "module",
6
9
  "files": [
7
- "dist"
10
+ "dist",
11
+ "src",
12
+ "index.html",
13
+ "vite.config.ts",
14
+ "tsconfig.json",
15
+ "tsconfig.app.json",
16
+ "tsconfig.node.json"
8
17
  ],
9
18
  "repository": {
10
19
  "type": "git",
@@ -22,7 +31,7 @@
22
31
  },
23
32
  "dependencies": {},
24
33
  "devDependencies": {
25
- "@brainpilot/protocol": "^0.0.4",
34
+ "@brainpilot/protocol": "^0.0.6",
26
35
  "@fontsource-variable/geist": "^5.2.9",
27
36
  "@fontsource-variable/geist-mono": "^5.2.8",
28
37
  "@types/react": "^18.3.12",
package/src/App.tsx ADDED
@@ -0,0 +1,10 @@
1
+ import { DesktopShell } from "./components/shell/DesktopShell";
2
+ import { AppProviders } from "./contexts/AppProviders";
3
+
4
+ export function App() {
5
+ return (
6
+ <AppProviders>
7
+ <DesktopShell />
8
+ </AppProviders>
9
+ );
10
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { reduceAgentsForEvent } from "../contexts/agentsReducer";
3
+ import type { AgentStatus, WebSocketEvent } from "../contracts/backend";
4
+
5
+ // #70: events arrive post-normalizeAgUiEvent (camelCase). agent_status_update's
6
+ // authoritative agent key is the top-level `name` field; status is one of
7
+ // idle|running|error|stopped (retrying normalized to running).
8
+
9
+ const ev = (name: string, status: string): WebSocketEvent =>
10
+ ({ type: "agent_status_update", name, status } as WebSocketEvent);
11
+
12
+ describe("reduceAgentsForEvent (#70)", () => {
13
+ it("appends an unknown agent with empty task", () => {
14
+ const out = reduceAgentsForEvent([], ev("librarian", "running"));
15
+ expect(out).toHaveLength(1);
16
+ expect(out[0]).toMatchObject({ name: "librarian", status: "running", task: "", alive: true });
17
+ });
18
+
19
+ it("updates a known agent's status but preserves its task", () => {
20
+ const prev: AgentStatus[] = [
21
+ { name: "librarian", status: "idle", task: "读文档", updatedAt: "t0", alive: true },
22
+ ];
23
+ const out = reduceAgentsForEvent(prev, ev("librarian", "running"));
24
+ expect(out[0]!.status).toBe("running");
25
+ expect(out[0]!.task).toBe("读文档"); // task preserved
26
+ expect(out).not.toBe(prev); // new reference on change
27
+ });
28
+
29
+ it("returns the same reference on a no-op status", () => {
30
+ const prev: AgentStatus[] = [
31
+ { name: "principal", status: "running", task: "", updatedAt: "t0", alive: true },
32
+ ];
33
+ expect(reduceAgentsForEvent(prev, ev("principal", "running"))).toBe(prev);
34
+ });
35
+
36
+ it("ignores non agent_status_update events (same reference)", () => {
37
+ const prev: AgentStatus[] = [
38
+ { name: "principal", status: "idle", task: "", updatedAt: "t0", alive: true },
39
+ ];
40
+ expect(reduceAgentsForEvent(prev, { type: "RUN_STARTED" } as WebSocketEvent)).toBe(prev);
41
+ });
42
+
43
+ it("ignores events with an empty name", () => {
44
+ const prev: AgentStatus[] = [];
45
+ expect(reduceAgentsForEvent(prev, ev("", "running"))).toBe(prev);
46
+ });
47
+
48
+ it("marks a stopped agent as not alive", () => {
49
+ const out = reduceAgentsForEvent([], ev("librarian", "stopped"));
50
+ expect(out[0]).toMatchObject({ status: "stopped", alive: false });
51
+ });
52
+
53
+ it("normalizes retrying to running for the panel", () => {
54
+ const out = reduceAgentsForEvent([], ev("principal", "retrying"));
55
+ expect(out[0]!.status).toBe("running");
56
+ });
57
+
58
+ it("merges multiple agents independently", () => {
59
+ let agents: AgentStatus[] = [];
60
+ agents = reduceAgentsForEvent(agents, ev("principal", "running"));
61
+ agents = reduceAgentsForEvent(agents, ev("librarian", "running"));
62
+ agents = reduceAgentsForEvent(agents, ev("principal", "idle"));
63
+ expect(agents).toHaveLength(2);
64
+ expect(agents.find((a) => a.name === "principal")!.status).toBe("idle");
65
+ expect(agents.find((a) => a.name === "librarian")!.status).toBe("running");
66
+ });
67
+ });
@@ -0,0 +1,221 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { api } from "../utils/api";
3
+
4
+ // These exercise the real-fetch path (runtimeConfig.useMockBackend is false in
5
+ // tests — VITE_USE_MOCK_BACKEND is unset). We stub globalThis.fetch and a
6
+ // minimal localStorage so authHeaders()/getStoredToken() don't blow up in the
7
+ // node test environment.
8
+
9
+ type FetchResponseInit = {
10
+ ok?: boolean;
11
+ status?: number;
12
+ contentType?: string;
13
+ json?: unknown;
14
+ /** When set, res.json() rejects with this (mirrors a non-JSON body). */
15
+ jsonThrows?: boolean;
16
+ };
17
+
18
+ function makeResponse(init: FetchResponseInit): Response {
19
+ const status = init.status ?? (init.ok === false ? 500 : 200);
20
+ const ok = init.ok ?? (status >= 200 && status < 300);
21
+ const headers = new Map<string, string>();
22
+ if (init.contentType) headers.set("content-type", init.contentType);
23
+ return {
24
+ ok,
25
+ status,
26
+ headers: { get: (k: string) => headers.get(k.toLowerCase()) ?? null },
27
+ json: async () => {
28
+ if (init.jsonThrows) throw new SyntaxError("Unexpected token '<'");
29
+ return init.json;
30
+ },
31
+ text: async () => (typeof init.json === "string" ? init.json : JSON.stringify(init.json ?? "")),
32
+ } as unknown as Response;
33
+ }
34
+
35
+ let fetchMock: ReturnType<typeof vi.fn>;
36
+
37
+ beforeEach(() => {
38
+ fetchMock = vi.fn();
39
+ vi.stubGlobal("fetch", fetchMock);
40
+ vi.stubGlobal("localStorage", {
41
+ getItem: () => null,
42
+ setItem: () => undefined,
43
+ removeItem: () => undefined,
44
+ });
45
+ });
46
+
47
+ afterEach(() => {
48
+ vi.unstubAllGlobals();
49
+ });
50
+
51
+ describe("api.sessions.list — unwraps { sessions } and tolerates shape", () => {
52
+ it("unwraps the runtime's { sessions: [...] } envelope", async () => {
53
+ fetchMock.mockResolvedValueOnce(
54
+ makeResponse({ contentType: "application/json", json: { sessions: [{ id: "a" }, { id: "b" }] } }),
55
+ );
56
+ const out = await api.sessions.list();
57
+ expect(out).toHaveLength(2);
58
+ expect(out[0].id).toBe("a");
59
+ });
60
+
61
+ it("tolerates a bare array response (legacy / mock shape)", async () => {
62
+ fetchMock.mockResolvedValueOnce(
63
+ makeResponse({ contentType: "application/json", json: [{ id: "x" }] }),
64
+ );
65
+ const out = await api.sessions.list();
66
+ expect(out).toHaveLength(1);
67
+ expect(out[0].id).toBe("x");
68
+ });
69
+
70
+ it("returns [] (never throws .map) for an unexpected shape", async () => {
71
+ fetchMock.mockResolvedValueOnce(makeResponse({ contentType: "application/json", json: {} }));
72
+ await expect(api.sessions.list()).resolves.toEqual([]);
73
+ });
74
+
75
+ it("returns [] for a null body", async () => {
76
+ fetchMock.mockResolvedValueOnce(makeResponse({ contentType: "application/json", json: null }));
77
+ await expect(api.sessions.list()).resolves.toEqual([]);
78
+ });
79
+ });
80
+
81
+ describe("api.sessions.create — unwraps the { id, session } envelope (#96)", () => {
82
+ it("reads the real title from the runtime's { id, session } envelope", async () => {
83
+ // The runtime's POST /sessions returns `{ id, session }` (server.ts), unlike
84
+ // the GET routes which return the bare session. Before the fix the whole
85
+ // envelope was handed to normalizeSession, so `raw.title` was undefined and
86
+ // the sidebar/header fell back to `Session <id8>` until a reload.
87
+ fetchMock.mockResolvedValueOnce(
88
+ makeResponse({
89
+ contentType: "application/json",
90
+ json: { id: "f8f35032", session: { id: "f8f35032", title: "请用两句话介绍 BrainPilot" } },
91
+ }),
92
+ );
93
+ const out = await api.sessions.create("请用两句话介绍 BrainPilot");
94
+ expect(out.id).toBe("f8f35032");
95
+ expect(out.title).toBe("请用两句话介绍 BrainPilot");
96
+ });
97
+
98
+ it("tolerates a bare session object (no envelope)", async () => {
99
+ fetchMock.mockResolvedValueOnce(
100
+ makeResponse({
101
+ contentType: "application/json",
102
+ json: { id: "abc", title: "bare title" },
103
+ }),
104
+ );
105
+ const out = await api.sessions.create("bare title");
106
+ expect(out.id).toBe("abc");
107
+ expect(out.title).toBe("bare title");
108
+ });
109
+ });
110
+
111
+ describe("composer-driving requests carry an abort timeout (#106)", () => {
112
+ it("create passes an AbortSignal so a hung POST can't wedge the composer", async () => {
113
+ fetchMock.mockResolvedValueOnce(
114
+ makeResponse({ contentType: "application/json", json: { id: "x", session: { id: "x", title: "t" } } }),
115
+ );
116
+ await api.sessions.create("t");
117
+ const init = fetchMock.mock.calls[0][1] as RequestInit;
118
+ expect(init.signal).toBeInstanceOf(AbortSignal);
119
+ });
120
+
121
+ it("postMessage passes an AbortSignal", async () => {
122
+ fetchMock.mockResolvedValueOnce(
123
+ makeResponse({ contentType: "application/json", json: { status: "ok" } }),
124
+ );
125
+ await api.sessions.postMessage("s1", { content: "hi", uuid: "u", timestamp: "2026-06-18T00:00:00Z" });
126
+ const init = fetchMock.mock.calls[0][1] as RequestInit;
127
+ expect(init.signal).toBeInstanceOf(AbortSignal);
128
+ });
129
+
130
+ it("propagates the timeout rejection to the caller (releases isSending upstream)", async () => {
131
+ // Simulate AbortSignal.timeout firing: fetch rejects with a TimeoutError.
132
+ fetchMock.mockRejectedValueOnce(new DOMException("timed out", "TimeoutError"));
133
+ await expect(
134
+ api.sessions.postMessage("s1", { content: "hi", uuid: "u", timestamp: "2026-06-18T00:00:00Z" }),
135
+ ).rejects.toBeInstanceOf(DOMException);
136
+ });
137
+ });
138
+
139
+ describe("api.sessions.getEvents — tolerates SSE / non-JSON responses", () => {
140
+ it("returns the events array for a JSON { events } body", async () => {
141
+ fetchMock.mockResolvedValueOnce(
142
+ makeResponse({ contentType: "application/json", json: { events: [{ type: "RUN_STARTED" }] } }),
143
+ );
144
+ const out = await api.sessions.getEvents("s1");
145
+ expect(out).toHaveLength(1);
146
+ });
147
+
148
+ it("returns [] (does NOT throw) when the endpoint is an SSE stream", async () => {
149
+ // /sessions/:id/events is wired to sseHandler in backend-core → text/event-stream.
150
+ // Calling res.json() on it would reject; getEvents must short-circuit to [].
151
+ fetchMock.mockResolvedValueOnce(
152
+ makeResponse({ contentType: "text/event-stream", jsonThrows: true }),
153
+ );
154
+ await expect(api.sessions.getEvents("s1")).resolves.toEqual([]);
155
+ });
156
+
157
+ it("returns [] for a non-ok response", async () => {
158
+ fetchMock.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, contentType: "application/json", json: { error: "not found" } }));
159
+ await expect(api.sessions.getEvents("s1")).resolves.toEqual([]);
160
+ });
161
+ });
162
+
163
+ describe("api.sessions.getHistory — persisted events.jsonl rehydration", () => {
164
+ it("returns the envelope shape from a JSON response", async () => {
165
+ fetchMock.mockResolvedValueOnce(
166
+ makeResponse({
167
+ contentType: "application/json",
168
+ json: { events: [{ type: "TEXT_MESSAGE_CHUNK", delta: "hi" }], total: 1, truncated: false },
169
+ }),
170
+ );
171
+ const out = await api.sessions.getHistory("s1");
172
+ expect(out.events).toHaveLength(1);
173
+ expect(out.total).toBe(1);
174
+ expect(out.truncated).toBe(false);
175
+ });
176
+
177
+ it("forwards the limit query string", async () => {
178
+ fetchMock.mockResolvedValueOnce(
179
+ makeResponse({ contentType: "application/json", json: { events: [], total: 0, truncated: false } }),
180
+ );
181
+ await api.sessions.getHistory("s1", { limit: 42 });
182
+ const url = String(fetchMock.mock.calls[0]![0]);
183
+ expect(url).toContain("/sessions/s1/history?limit=42");
184
+ });
185
+
186
+ it("returns the empty envelope on a non-ok response", async () => {
187
+ fetchMock.mockResolvedValueOnce(makeResponse({ ok: false, status: 404 }));
188
+ const out = await api.sessions.getHistory("s1");
189
+ expect(out).toEqual({ events: [], total: 0, truncated: false });
190
+ });
191
+
192
+ it("returns the empty envelope when the body is null", async () => {
193
+ fetchMock.mockResolvedValueOnce(makeResponse({ contentType: "application/json", json: null }));
194
+ const out = await api.sessions.getHistory("s1");
195
+ expect(out).toEqual({ events: [], total: 0, truncated: false });
196
+ });
197
+ });
198
+
199
+ describe("api.sessions.interrupt — hits the interrupt route, not /messages (#90)", () => {
200
+ it("POSTs to /sessions/:id/interrupt and returns { interrupted }", async () => {
201
+ fetchMock.mockResolvedValueOnce(
202
+ makeResponse({ contentType: "application/json", json: { interrupted: true } }),
203
+ );
204
+ const out = await api.sessions.interrupt("s1");
205
+
206
+ const [url, init] = fetchMock.mock.calls[0]!;
207
+ expect(String(url)).toMatch(/\/sessions\/s1\/interrupt$/);
208
+ expect(String(url)).not.toMatch(/\/messages$/);
209
+ expect((init as RequestInit).method).toBe("POST");
210
+ expect(out.interrupted).toBe(true);
211
+ });
212
+
213
+ it("never routes the Stop action through the messages endpoint", async () => {
214
+ fetchMock.mockResolvedValueOnce(
215
+ makeResponse({ contentType: "application/json", json: { interrupted: true } }),
216
+ );
217
+ await api.sessions.interrupt("abc");
218
+ const url = String(fetchMock.mock.calls[0]![0]);
219
+ expect(url.endsWith("/messages")).toBe(false);
220
+ });
221
+ });
@@ -0,0 +1,49 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import {
3
+ getChatScroll,
4
+ setChatScroll,
5
+ resolveScrollTop,
6
+ } from "../components/chat/chatScrollMemory";
7
+
8
+ describe("chatScrollMemory (#89)", () => {
9
+ beforeEach(() => {
10
+ // Each test uses unique keys, but clear shared rows defensively.
11
+ setChatScroll("s1", { scrollTop: 0, pinned: true });
12
+ setChatScroll("s2", { scrollTop: 0, pinned: true });
13
+ });
14
+
15
+ it("stores and reads per-session state", () => {
16
+ setChatScroll("s1", { scrollTop: 120, pinned: false });
17
+ expect(getChatScroll("s1")).toEqual({ scrollTop: 120, pinned: false });
18
+ });
19
+
20
+ it("isolates sessions", () => {
21
+ setChatScroll("s1", { scrollTop: 50, pinned: false });
22
+ setChatScroll("s2", { scrollTop: 999, pinned: true });
23
+ expect(getChatScroll("s1")?.scrollTop).toBe(50);
24
+ expect(getChatScroll("s2")?.scrollTop).toBe(999);
25
+ });
26
+
27
+ it("ignores undefined keys", () => {
28
+ setChatScroll(undefined, { scrollTop: 5, pinned: false });
29
+ expect(getChatScroll(undefined)).toBeUndefined();
30
+ });
31
+
32
+ describe("resolveScrollTop", () => {
33
+ it("returns bottom when there is no memory (fresh conversation)", () => {
34
+ expect(resolveScrollTop(undefined, 4000)).toBe(4000);
35
+ });
36
+
37
+ it("returns bottom when the user was pinned (following live output)", () => {
38
+ expect(resolveScrollTop({ scrollTop: 100, pinned: true }, 4000)).toBe(4000);
39
+ });
40
+
41
+ it("restores the saved position when the user was reading history", () => {
42
+ expect(resolveScrollTop({ scrollTop: 800, pinned: false }, 4000)).toBe(800);
43
+ });
44
+
45
+ it("clamps a stale position to the current scrollHeight", () => {
46
+ expect(resolveScrollTop({ scrollTop: 9000, pinned: false }, 4000)).toBe(4000);
47
+ });
48
+ });
49
+ });
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ChatMessage } from "../contracts/backend";
3
+ import { isDemoConversational } from "../components/demo/DemoView";
4
+
5
+ function msg(overrides: Partial<ChatMessage>): ChatMessage {
6
+ return {
7
+ id: overrides.id ?? "m1",
8
+ role: overrides.role ?? "assistant",
9
+ content: overrides.content ?? "",
10
+ createdAt: "2026-06-18T00:00:00.000Z",
11
+ ...overrides,
12
+ };
13
+ }
14
+
15
+ describe("isDemoConversational (#98 multi-agent transcript)", () => {
16
+ it("keeps non-empty user prompts", () => {
17
+ expect(isDemoConversational(msg({ role: "user", content: "hello" }))).toBe(true);
18
+ expect(isDemoConversational(msg({ role: "user", content: " " }))).toBe(false);
19
+ });
20
+
21
+ it("keeps the principal's substantive text replies", () => {
22
+ expect(
23
+ isDemoConversational(msg({ role: "assistant", agent: "principal", kind: "text", content: "done" })),
24
+ ).toBe(true);
25
+ });
26
+
27
+ it("keeps EXPERT agent text replies (the core #98 regression)", () => {
28
+ // librarian / engineer / experimentalist replies must survive — the live
29
+ // Chat shows them, so the demo must too.
30
+ expect(
31
+ isDemoConversational(msg({ role: "assistant", agent: "librarian", kind: "text", content: "searching…" })),
32
+ ).toBe(true);
33
+ expect(
34
+ isDemoConversational(msg({ role: "assistant", agent: "engineer", kind: undefined, content: "built it" })),
35
+ ).toBe(true);
36
+ });
37
+
38
+ it("treats a missing agent as conversational (older bundles)", () => {
39
+ expect(isDemoConversational(msg({ role: "assistant", kind: "text", content: "hi" }))).toBe(true);
40
+ });
41
+
42
+ it("keeps error bubbles with content", () => {
43
+ expect(isDemoConversational(msg({ role: "system", kind: "error", content: "librarian failed" }))).toBe(true);
44
+ expect(isDemoConversational(msg({ role: "system", kind: "error", content: "" }))).toBe(false);
45
+ });
46
+
47
+ it("keeps system_message bubbles that carry a payload", () => {
48
+ expect(
49
+ isDemoConversational(
50
+ msg({
51
+ role: "system",
52
+ kind: "system_message",
53
+ content: "",
54
+ systemMessage: { level: "error", message: "librarian error", recoverable: true },
55
+ }),
56
+ ),
57
+ ).toBe(true);
58
+ // No payload → nothing to render.
59
+ expect(isDemoConversational(msg({ role: "system", kind: "system_message", content: "" }))).toBe(false);
60
+ });
61
+
62
+ it("drops reasoning, tool calls/results, hooks and interactive cards", () => {
63
+ expect(isDemoConversational(msg({ kind: "thinking", content: "let me think" }))).toBe(false);
64
+ expect(isDemoConversational(msg({ kind: "tool", content: "Tool: read" }))).toBe(false);
65
+ expect(isDemoConversational(msg({ role: "system", kind: "hook", content: "reset" }))).toBe(false);
66
+ expect(isDemoConversational(msg({ kind: "ask_user", content: "pick one" }))).toBe(false);
67
+ expect(isDemoConversational(msg({ kind: "auto_retry", content: "retrying" }))).toBe(false);
68
+ });
69
+
70
+ it("drops empty text placeholders", () => {
71
+ expect(isDemoConversational(msg({ role: "assistant", kind: "text", content: " " }))).toBe(false);
72
+ });
73
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { shouldResetDemo } from "../components/demo/demoReset";
3
+
4
+ describe("shouldResetDemo (#111 sidebar re-click returns to landing)", () => {
5
+ it("does not reset on the initial mount (same value)", () => {
6
+ // prev === next on first render — packing/importing a bundle must survive.
7
+ expect(shouldResetDemo(0, 0)).toBe(false);
8
+ expect(shouldResetDemo(3, 3)).toBe(false);
9
+ });
10
+
11
+ it("resets when the signal advances (sidebar clicked again)", () => {
12
+ expect(shouldResetDemo(0, 1)).toBe(true);
13
+ expect(shouldResetDemo(1, 2)).toBe(true);
14
+ });
15
+
16
+ it("treats an undefined signal (standalone mount) as no reset", () => {
17
+ // DemoView mounted without the prop: prev and next are both undefined.
18
+ expect(shouldResetDemo(undefined, undefined)).toBe(false);
19
+ });
20
+
21
+ it("resets when transitioning from undefined to a number", () => {
22
+ expect(shouldResetDemo(undefined, 1)).toBe(true);
23
+ });
24
+ });
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildRenderItems } from "../contexts/messageGroups";
3
+ import type { ChatMessage } from "../contracts/backend";
4
+
5
+ // Folded "activity" blocks (reasoning + tool steps) must stay "in progress"
6
+ // while their owning agent's run is still active, even when no single step is
7
+ // momentarily streaming. Without run-active awareness the per-message streaming
8
+ // flags all clear between ReAct rounds and the block flashes "完成思考" in the
9
+ // gap before the next round. See messageGroups.ts buildRenderItems.
10
+
11
+ // A folded step (reasoning/tool) — these are the only kinds that group into an
12
+ // activity block; assistant text / user / errors render standalone.
13
+ function step(over: Partial<ChatMessage> = {}): ChatMessage {
14
+ return {
15
+ id: over.id ?? `s-${Math.random().toString(36).slice(2)}`,
16
+ role: "assistant",
17
+ content: "",
18
+ createdAt: new Date().toISOString(),
19
+ agent: "principal",
20
+ streaming: false,
21
+ kind: "thinking",
22
+ ...over,
23
+ };
24
+ }
25
+
26
+ describe("buildRenderItems — activity block run-active awareness", () => {
27
+ it("without runningAgents, block streaming derives only from step flags (legacy behavior)", () => {
28
+ const streamingBlock = buildRenderItems([step({ streaming: true })]);
29
+ expect(streamingBlock[0]).toMatchObject({ type: "activity", streaming: true });
30
+
31
+ const doneBlock = buildRenderItems([step({ streaming: false })]);
32
+ expect(doneBlock[0]).toMatchObject({ type: "activity", streaming: false });
33
+ });
34
+
35
+ it("keeps a block in progress when its agent's run is active but no step is streaming (the bug)", () => {
36
+ // Between ReAct rounds: every step's END already cleared streaming, but the
37
+ // principal run has NOT finished — the block must NOT show as done.
38
+ const items = buildRenderItems(
39
+ [step({ agent: "principal", streaming: false, kind: "tool" })],
40
+ new Set(["principal"]),
41
+ );
42
+ expect(items[0]).toMatchObject({ type: "activity", streaming: true });
43
+ });
44
+
45
+ it("marks a block done when its agent is idle and no step is streaming", () => {
46
+ const items = buildRenderItems(
47
+ [step({ agent: "principal", streaming: false })],
48
+ new Set<string>(), // principal not running anymore
49
+ );
50
+ expect(items[0]).toMatchObject({ type: "activity", streaming: false });
51
+ });
52
+
53
+ it("falls back to 'principal' for unattributed steps", () => {
54
+ const items = buildRenderItems([step({ agent: undefined, streaming: false })], new Set(["principal"]));
55
+ expect(items[0]).toMatchObject({ type: "activity", streaming: true });
56
+ });
57
+
58
+ it("multi-agent: an idle agent's block shows done even while another agent is still running", () => {
59
+ // worker has finished (not in the running set); expert is still running.
60
+ // Each block is scoped to its own agent's run state.
61
+ const workerStep = step({ id: "w1", agent: "worker", streaming: false, kind: "tool" });
62
+ const userBreak: ChatMessage = {
63
+ id: "u1",
64
+ role: "user",
65
+ content: "next",
66
+ createdAt: new Date().toISOString(),
67
+ agent: "user",
68
+ streaming: false,
69
+ kind: "text",
70
+ };
71
+ const expertStep = step({ id: "e1", agent: "expert", streaming: false, kind: "thinking" });
72
+
73
+ const items = buildRenderItems([workerStep, userBreak, expertStep], new Set(["expert"]));
74
+ const activities = items.filter((i) => i.type === "activity");
75
+ expect(activities).toHaveLength(2);
76
+ // worker block (agent idle) → done; expert block (agent running) → in progress
77
+ expect(activities[0]).toMatchObject({ streaming: false });
78
+ expect(activities[1]).toMatchObject({ streaming: true });
79
+ });
80
+ });