@brainpilot/web 0.0.3 → 0.0.5

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 (97) hide show
  1. package/dist/assets/index-C-8G4D4j.js +448 -0
  2. package/dist/assets/index-C501m5OS.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/index.html +13 -0
  5. package/package.json +9 -3
  6. package/src/App.tsx +10 -0
  7. package/src/__tests__/api.test.ts +103 -0
  8. package/src/__tests__/messageGroups.test.ts +80 -0
  9. package/src/__tests__/newUiComponents.test.tsx +101 -0
  10. package/src/__tests__/newUiEvents.test.ts +236 -0
  11. package/src/components/chat/AskUserCard.tsx +123 -0
  12. package/src/components/chat/AutoRetryIndicator.tsx +71 -0
  13. package/src/components/chat/ComposerInput.tsx +73 -0
  14. package/src/components/chat/ComposerSendButton.tsx +26 -0
  15. package/src/components/chat/MarkdownMessage.tsx +24 -0
  16. package/src/components/chat/MessageStream.tsx +464 -0
  17. package/src/components/chat/PromptComposer.tsx +398 -0
  18. package/src/components/chat/SystemMessageBubble.tsx +46 -0
  19. package/src/components/demo/DemoFileTree.tsx +146 -0
  20. package/src/components/demo/DemoView.tsx +668 -0
  21. package/src/components/demo/TraceNodeModal.tsx +76 -0
  22. package/src/components/demo/demoBundle.ts +218 -0
  23. package/src/components/demo/demoCache.ts +42 -0
  24. package/src/components/files/FilePreviewView.tsx +153 -0
  25. package/src/components/files/FileSidebar.tsx +664 -0
  26. package/src/components/files/filePreview.ts +113 -0
  27. package/src/components/primitives/CustomSelect.tsx +200 -0
  28. package/src/components/primitives/IconButton.tsx +27 -0
  29. package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
  30. package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
  31. package/src/components/quota/QuotaFileManager.tsx +197 -0
  32. package/src/components/search/SearchDialog.tsx +101 -0
  33. package/src/components/session/AgentNetwork.tsx +1240 -0
  34. package/src/components/session/AgentTraceViews.tsx +381 -0
  35. package/src/components/session/AnalyticsTab.tsx +386 -0
  36. package/src/components/session/GlobalOverview.tsx +108 -0
  37. package/src/components/session/NodeTooltip.tsx +127 -0
  38. package/src/components/session/TimelineTab.tsx +320 -0
  39. package/src/components/session/TraceGraphView.tsx +301 -0
  40. package/src/components/session/TraceNodeDetail.tsx +142 -0
  41. package/src/components/session/agentAnalytics.ts +397 -0
  42. package/src/components/session/agentNetworkShared.ts +329 -0
  43. package/src/components/session/traceLayout.ts +150 -0
  44. package/src/components/settings/SettingsDialog.tsx +719 -0
  45. package/src/components/shell/DesktopShell.tsx +236 -0
  46. package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
  47. package/src/components/shell/SandboxStatus.tsx +287 -0
  48. package/src/components/shell/TerminalDrawer.tsx +387 -0
  49. package/src/components/sidebar/Sidebar.tsx +187 -0
  50. package/src/config.ts +10 -0
  51. package/src/contexts/AppProviders.tsx +20 -0
  52. package/src/contexts/AuthContext.tsx +61 -0
  53. package/src/contexts/PreferencesContext.tsx +125 -0
  54. package/src/contexts/SSEContext.tsx +175 -0
  55. package/src/contexts/SandboxContext.tsx +310 -0
  56. package/src/contexts/SessionContext.tsx +608 -0
  57. package/src/contexts/draftStore.ts +103 -0
  58. package/src/contexts/messageFilters.ts +29 -0
  59. package/src/contexts/messageGroups.ts +77 -0
  60. package/src/contexts/messageReducer.ts +401 -0
  61. package/src/contexts/newUiEvents.ts +190 -0
  62. package/src/contracts/backend.ts +846 -0
  63. package/src/contracts/demoBundle.ts +83 -0
  64. package/src/i18n/messages/analytics.ts +96 -0
  65. package/src/i18n/messages/chat.ts +108 -0
  66. package/src/i18n/messages/contexts.ts +40 -0
  67. package/src/i18n/messages/demo.ts +80 -0
  68. package/src/i18n/messages/files.ts +82 -0
  69. package/src/i18n/messages/network.ts +186 -0
  70. package/src/i18n/messages/profile.ts +40 -0
  71. package/src/i18n/messages/quota.ts +36 -0
  72. package/src/i18n/messages/sandbox.ts +116 -0
  73. package/src/i18n/messages/search.ts +16 -0
  74. package/src/i18n/messages/settings.ts +184 -0
  75. package/src/i18n/messages/shell.ts +38 -0
  76. package/src/i18n/messages/sidebar.ts +52 -0
  77. package/src/i18n/messages/terminal.ts +22 -0
  78. package/src/i18n/messages/trace.ts +84 -0
  79. package/src/i18n/messages.ts +32 -0
  80. package/src/i18n/translate.ts +46 -0
  81. package/src/i18n/types.ts +15 -0
  82. package/src/i18n/useT.ts +15 -0
  83. package/src/main.tsx +13 -0
  84. package/src/mocks/backend.ts +722 -0
  85. package/src/styles/global.css +7429 -0
  86. package/src/styles/tokens.css +161 -0
  87. package/src/utils/api.ts +627 -0
  88. package/src/utils/download.ts +18 -0
  89. package/src/utils/format.ts +7 -0
  90. package/src/utils/zip.ts +119 -0
  91. package/src/vite-env.d.ts +1 -0
  92. package/tsconfig.app.json +22 -0
  93. package/tsconfig.json +7 -0
  94. package/tsconfig.node.json +13 -0
  95. package/vite.config.ts +13 -0
  96. package/dist/assets/index-Cd0Mi_WU.css +0 -1
  97. package/dist/assets/index-FGg-DeYR.js +0 -448
@@ -0,0 +1,608 @@
1
+ import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2
+ // TODO(dead-code): SessionEventEntry removed with pre-AG-UI polling protocol.
3
+ import { AgentStatus, ChatMessage, MessageFilterRule, Session, /* SessionEventEntry, */ SessionMessageEntry } from "../contracts/backend";
4
+ import { api } from "../utils/api";
5
+ import { tg } from "../i18n/translate";
6
+ import { useAuth } from "./AuthContext";
7
+ import { useSandbox } from "./SandboxContext";
8
+ import { useSSE } from "./SSEContext";
9
+ import { draftStore } from "./draftStore";
10
+ import { defaultFilterRules } from "./messageFilters";
11
+ import {
12
+ eventSessionId,
13
+ finalizeAssistant,
14
+ generateUUID,
15
+ reduceMessagesForEvent,
16
+ } from "./messageReducer";
17
+
18
+ export interface AgentMessageFilter {
19
+ hideMessages: boolean;
20
+ hideTools: boolean;
21
+ hideHooks: boolean;
22
+ }
23
+
24
+ interface SessionContextValue {
25
+ sessions: Session[];
26
+ currentSession: Session | null;
27
+ messages: ChatMessage[];
28
+ isLoading: boolean;
29
+ isSending: boolean;
30
+ isRefreshingMessages: boolean;
31
+ isConnected: boolean;
32
+ /**
33
+ * True when the user has opened a new conversation that has not yet been
34
+ * persisted. No session file exists until the first message is sent.
35
+ */
36
+ isDraft: boolean;
37
+ error: string | null;
38
+ currentView: "chat" | "agents" | "trace";
39
+ agents: AgentStatus[];
40
+ agentFilters: Record<string, AgentMessageFilter>;
41
+ selectSession: (sessionId: string) => void;
42
+ createSession: (title?: string, opts?: { providerId?: string; modelId?: string }) => Promise<Session | null>;
43
+ /**
44
+ * Open a fresh draft conversation without persisting anything. Idempotent —
45
+ * repeated calls collapse to the single draft state. The real session is
46
+ * created lazily on the first sendPrompt.
47
+ */
48
+ startDraftSession: () => void;
49
+ updateSessionTitle: (sessionId: string, title: string) => Promise<void>;
50
+ deleteSession: (sessionId: string) => Promise<void>;
51
+ sendPrompt: (content: string, opts?: { providerId?: string; modelId?: string }) => Promise<void>;
52
+ interruptCurrent: () => Promise<void>;
53
+ /**
54
+ * 修正6 — answer an ask_user (user_input_request) card. Optimistically
55
+ * resolves the card locally and posts a user_input_response to the runtime.
56
+ */
57
+ respondToInput: (requestId: string, answer: string) => Promise<void>;
58
+ refreshSessions: () => Promise<void>;
59
+ refreshMessages: () => Promise<void>;
60
+ setCurrentView: (view: "chat" | "agents" | "trace") => void;
61
+ setAgentFilter: (agentName: string, hideMessages: boolean, hideTools: boolean, hideHooks?: boolean) => void;
62
+ messageFilters: MessageFilterRule[];
63
+ }
64
+
65
+ // Stable key for the not-yet-persisted draft session. The composer keys its
66
+ // draft text by this id while `isDraft` is true; once the real session is
67
+ // created on first send, the composer switches to the real session id.
68
+ export const DRAFT_SESSION_ID = "__draft__";
69
+
70
+ const SessionContext = createContext<SessionContextValue | null>(null);
71
+
72
+ export function SessionProvider({ children }: { children: ReactNode }) {
73
+ const { isAuthReady } = useAuth();
74
+ const { currentSandbox } = useSandbox();
75
+ const { connectSession, disconnectSession, queueRef, tick, connections } = useSSE();
76
+ const [sessions, setSessions] = useState<Session[]>([]);
77
+ const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
78
+ const [isDraft, setIsDraft] = useState(false);
79
+ // Mirror of isDraft for reading inside callbacks that must not re-create when
80
+ // the draft flag flips (e.g. refreshSessions, keyed only on isAuthReady).
81
+ const isDraftRef = useRef(false);
82
+ useEffect(() => {
83
+ isDraftRef.current = isDraft;
84
+ }, [isDraft]);
85
+ const [messagesBySession, setMessagesBySession] = useState<Record<string, ChatMessage[]>>({});
86
+ const [isLoading, setIsLoading] = useState(false);
87
+ const [isSending, setIsSending] = useState(false);
88
+ const [isRefreshingMessages, setIsRefreshingMessages] = useState(false);
89
+ const [error, setError] = useState<string | null>(null);
90
+ const [currentView, setCurrentView] = useState<"chat" | "agents" | "trace">("chat");
91
+ // Unsent textarea drafts live in a module-level store (see contexts/draftStore.ts)
92
+ // so keystrokes don't re-render the whole chat subtree. Drafts are keyed by
93
+ // session id and survive PromptComposer unmount (tab switches).
94
+ const [agents, setAgents] = useState<AgentStatus[]>([]);
95
+ const [agentFilters, setAgentFilters] = useState<Record<string, AgentMessageFilter>>({});
96
+ const [messageFilters, setMessageFilters] = useState<MessageFilterRule[]>(defaultFilterRules);
97
+
98
+
99
+ const currentSession = useMemo(
100
+ () => sessions.find((session) => session.id === currentSessionId) ?? null,
101
+ [currentSessionId, sessions],
102
+ );
103
+ const messages = currentSessionId ? messagesBySession[currentSessionId] ?? [] : [];
104
+
105
+ const isConnected = currentSessionId
106
+ ? connections.get(currentSessionId) === "open"
107
+ : false;
108
+
109
+ const refreshSessions = useCallback(async () => {
110
+ if (!isAuthReady) {
111
+ setSessions([]);
112
+ setCurrentSessionId(null);
113
+ return;
114
+ }
115
+
116
+ setIsLoading(true);
117
+ setError(null);
118
+ try {
119
+ const nextSessions = await api.sessions.list();
120
+ const sorted = [...nextSessions].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
121
+ setSessions(sorted);
122
+ // Don't auto-select an existing session while the user is composing a
123
+ // draft — that would yank them out of the new conversation.
124
+ setCurrentSessionId((current) => (isDraftRef.current ? current : current ?? sorted[0]?.id ?? null));
125
+ } catch (err) {
126
+ setError(err instanceof Error ? err.message : tg("ctx.session.loadFailed"));
127
+ } finally {
128
+ setIsLoading(false);
129
+ }
130
+ }, [isAuthReady]);
131
+
132
+ useEffect(() => {
133
+ void refreshSessions();
134
+ }, [refreshSessions]);
135
+
136
+ useEffect(() => {
137
+ if (!currentSessionId) {
138
+ return;
139
+ }
140
+ const sessionId = currentSessionId;
141
+ let cancelled = false;
142
+ async function loadMessages() {
143
+ setIsRefreshingMessages(true);
144
+ if (!cancelled) {
145
+ setMessagesBySession((current) => ({ ...current, [sessionId]: current[sessionId] ?? [] }));
146
+ }
147
+ setIsRefreshingMessages(false);
148
+ }
149
+ void loadMessages();
150
+ return () => {
151
+ cancelled = true;
152
+ };
153
+ return () => {
154
+ cancelled = true;
155
+ };
156
+ }, [currentSessionId]);
157
+
158
+ const selectSession = useCallback((sessionId: string) => {
159
+ console.log(`[SessionContext] selectSession: ${sessionId}`);
160
+ setIsDraft(false);
161
+ setCurrentSessionId(sessionId);
162
+ setCurrentView("chat");
163
+ connectSession(sessionId);
164
+ }, [connectSession]);
165
+
166
+ // Open a fresh, unpersisted conversation. Idempotent: if a draft is already
167
+ // open, this just keeps it. The real session is created on first send.
168
+ const startDraftSession = useCallback(() => {
169
+ setIsDraft(true);
170
+ setCurrentSessionId(null);
171
+ setCurrentView("chat");
172
+ }, []);
173
+
174
+ const updateSessionTitle = useCallback(async (sessionId: string, title: string) => {
175
+ setError(null);
176
+ try {
177
+ const updated = await api.sessions.update(sessionId, title);
178
+ setSessions((current) =>
179
+ [updated, ...current.filter((session) => session.id !== sessionId)].sort((a, b) =>
180
+ b.updatedAt.localeCompare(a.updatedAt),
181
+ ),
182
+ );
183
+ } catch (err) {
184
+ setError(err instanceof Error ? err.message : tg("ctx.session.updateFailed"));
185
+ throw err;
186
+ }
187
+ }, []);
188
+
189
+ const deleteSession = useCallback(async (sessionId: string) => {
190
+ setError(null);
191
+ try {
192
+ await api.sessions.remove(sessionId);
193
+ disconnectSession(sessionId);
194
+ setSessions((current) => {
195
+ const next = current.filter((session) => session.id !== sessionId);
196
+ setCurrentSessionId((currentId) => (currentId === sessionId ? next[0]?.id ?? null : currentId));
197
+ return next;
198
+ });
199
+ setMessagesBySession((current) => {
200
+ const next = { ...current };
201
+ delete next[sessionId];
202
+ return next;
203
+ });
204
+ // Drop any unsent draft so re-creating a session with the same id (rare,
205
+ // but possible) doesn't resurrect stale text.
206
+ draftStore.delete(sessionId);
207
+ } catch (err) {
208
+ setError(err instanceof Error ? err.message : tg("ctx.session.deleteFailed"));
209
+ throw err;
210
+ }
211
+ }, [disconnectSession]);
212
+
213
+ const createSession = useCallback(
214
+ async (title = "New research session", opts: { providerId?: string; modelId?: string } = {}) => {
215
+ if (!currentSandbox || currentSandbox.status !== "running") {
216
+ setError(tg("ctx.session.startSandbox"));
217
+ return null;
218
+ }
219
+
220
+ setIsLoading(true);
221
+ setError(null);
222
+ try {
223
+ const session = await api.sessions.create(title, opts);
224
+ setSessions((current) => [session, ...current.filter((item) => item.id !== session.id)]);
225
+ setIsDraft(false);
226
+ setCurrentSessionId(session.id);
227
+ setMessagesBySession((current) => ({ ...current, [session.id]: current[session.id] ?? [] }));
228
+ return session;
229
+ } catch (err) {
230
+ setError(err instanceof Error ? err.message : tg("ctx.session.createFailed"));
231
+ throw err;
232
+ } finally {
233
+ setIsLoading(false);
234
+ }
235
+ },
236
+ [currentSandbox],
237
+ );
238
+
239
+ useEffect(() => {
240
+ if (sessions.length === 0 && !isLoading && currentSandbox?.status === "running" && !currentSessionId && !isDraft) {
241
+ startDraftSession();
242
+ }
243
+ }, [sessions.length, isLoading, currentSandbox, currentSessionId, isDraft, startDraftSession]);
244
+
245
+ const sendPrompt = useCallback(
246
+ async (content: string, opts: { providerId?: string; modelId?: string } = {}) => {
247
+ const trimmed = content.trim();
248
+ console.log(`[SessionContext] sendPrompt: "${trimmed.slice(0, 40)}...", isConnected=${isConnected}, isDraft=${isDraft}`);
249
+ if (!trimmed) {
250
+ return;
251
+ }
252
+ // A draft has no SSE connection yet — the session is created and
253
+ // connected below. Only block on connection for an already-persisted
254
+ // session.
255
+ if (!currentSession && !isDraft) {
256
+ setError(tg("ctx.session.noConnection"));
257
+ return;
258
+ }
259
+ if (currentSession && !isConnected) {
260
+ setError(tg("ctx.session.noConnection"));
261
+ return;
262
+ }
263
+
264
+ setIsSending(true);
265
+ setError(null);
266
+ try {
267
+ const session = currentSession ?? (await createSession(trimmed.slice(0, 48), opts));
268
+ if (!session) {
269
+ return;
270
+ }
271
+ // Freshly created (draft → persisted): open the SSE stream so the
272
+ // assistant's streamed reply is received.
273
+ if (!currentSession) {
274
+ connectSession(session.id);
275
+ // Migrate any draft text the composer stored under the sentinel id
276
+ // so a tab switch mid-send doesn't lose it.
277
+ draftStore.delete(DRAFT_SESSION_ID);
278
+ }
279
+
280
+ const timestamp = new Date().toISOString();
281
+ const uuid = generateUUID();
282
+ const userMessage: ChatMessage = {
283
+ id: uuid,
284
+ role: "user",
285
+ content: trimmed,
286
+ createdAt: timestamp,
287
+ agent: "user",
288
+ };
289
+ setMessagesBySession((current) => ({
290
+ ...current,
291
+ [session.id]: [...(current[session.id] ?? []), userMessage],
292
+ }));
293
+ console.log(`[SessionContext] posting message to ${session.id}`);
294
+ await api.sessions.postMessage(session.id, { content: trimmed, uuid, timestamp });
295
+ console.log(`[SessionContext] postMessage success`);
296
+ } catch (err) {
297
+ console.error(`[SessionContext] sendPrompt error:`, err);
298
+ setError(err instanceof Error ? err.message : tg("ctx.session.sendFailed"));
299
+ } finally {
300
+ setIsSending(false);
301
+ }
302
+ },
303
+ [createSession, currentSession, isConnected, isDraft, connectSession],
304
+ );
305
+
306
+ const interruptCurrent = useCallback(
307
+ async () => {
308
+ if (!currentSession) return;
309
+ const sid = currentSession.id;
310
+ // Optimistic update: immediately stop the spinner so the user can type
311
+ // the next message without waiting for the backend RUN_FINISHED round-trip.
312
+ setMessagesBySession((current) => {
313
+ const msgs = current[sid] ?? [];
314
+ const stoppedMsg: ChatMessage = {
315
+ id: generateUUID(),
316
+ role: "system",
317
+ content: "Task stopped by user",
318
+ createdAt: new Date().toISOString(),
319
+ kind: "status",
320
+ };
321
+ return { ...current, [sid]: [...finalizeAssistant(msgs), stoppedMsg] };
322
+ });
323
+ setAgents((current) =>
324
+ current.map((a) => ({ ...a, status: "idle", task: "" })),
325
+ );
326
+ try {
327
+ await api.sessions.interrupt(sid);
328
+ } catch (err) {
329
+ setError(err instanceof Error ? err.message : tg("ctx.session.interruptFailed"));
330
+ }
331
+ },
332
+ [currentSession],
333
+ );
334
+
335
+ const respondToInput = useCallback(
336
+ async (requestId: string, answer: string) => {
337
+ const sid = currentSession?.id;
338
+ if (!sid || !requestId) return;
339
+ // Optimistically resolve the matching ask_user card so the buttons
340
+ // disable immediately, without waiting for the echoed user_input_response.
341
+ setMessagesBySession((current) => {
342
+ const msgs = current[sid] ?? [];
343
+ return {
344
+ ...current,
345
+ [sid]: msgs.map((m) =>
346
+ m.kind === "ask_user" && m.askUser?.requestId === requestId
347
+ ? { ...m, askUser: { ...m.askUser, answer } }
348
+ : m,
349
+ ),
350
+ };
351
+ });
352
+ try {
353
+ await api.sessions.respondToInput(sid, { requestId, answer });
354
+ } catch (err) {
355
+ setError(err instanceof Error ? err.message : tg("ctx.session.sendFailed"));
356
+ }
357
+ },
358
+ [currentSession],
359
+ );
360
+
361
+ const refreshMessages = useCallback(async () => {
362
+ if (!currentSession) {
363
+ return;
364
+ }
365
+ console.log(`[SessionContext] refreshMessages: ${currentSession.id}`);
366
+ setIsRefreshingMessages(true);
367
+ setError(null);
368
+ try {
369
+ // Re-connect the SSE stream — the server will emit a fresh
370
+ // MESSAGES_SNAPSHOT as the first frame, replacing the local cache.
371
+ disconnectSession(currentSession.id);
372
+ // Clear local state so the snapshot becomes the authoritative source.
373
+ setMessagesBySession((current) => ({ ...current, [currentSession.id]: [] }));
374
+ connectSession(currentSession.id);
375
+ } catch (err) {
376
+ setError(err instanceof Error ? err.message : tg("ctx.session.refreshFailed"));
377
+ } finally {
378
+ setIsRefreshingMessages(false);
379
+ }
380
+ }, [currentSession, disconnectSession, connectSession]);
381
+
382
+ useEffect(() => {
383
+ if (!currentSession?.id) {
384
+ setAgents([]);
385
+ return;
386
+ }
387
+ let cancelled = false;
388
+ // Backstop fetch in case the SSE first-frame CUSTOM:session_state hasn't
389
+ // arrived yet (e.g. tab restore while SSE is reconnecting). The same
390
+ // payload arrives via SSE moments later and overwrites this wholesale.
391
+ void api.sessions.state(currentSession.id).then((snap) => {
392
+ if (!cancelled) {
393
+ setAgents(snap.agents.map((agent) => ({
394
+ ...agent,
395
+ updatedAt: agent.updatedAt || new Date().toISOString(),
396
+ })));
397
+ }
398
+ }).catch(() => {
399
+ if (!cancelled) {
400
+ setAgents([]);
401
+ }
402
+ });
403
+ return () => {
404
+ cancelled = true;
405
+ };
406
+ }, [currentSession?.id]);
407
+
408
+ // Auto-connect SSE whenever the current session changes. Other connections
409
+ // are kept open so background sessions continue receiving events.
410
+ useEffect(() => {
411
+ if (currentSessionId) {
412
+ connectSession(currentSessionId);
413
+ }
414
+ }, [currentSessionId, connectSession]);
415
+
416
+ // Drain the SSE event queue for the current session on every tick.
417
+ useEffect(() => {
418
+ if (!currentSessionId || !queueRef.current) {
419
+ return;
420
+ }
421
+ const sid = currentSessionId;
422
+ const queue = queueRef.current.get(sid) || [];
423
+ if (queue.length === 0) {
424
+ return;
425
+ }
426
+ // Atomically clear the queue so events added during processing are handled
427
+ // in the next effect run.
428
+ queueRef.current.set(sid, []);
429
+
430
+ console.log(`[SessionContext] draining queue: sid=${sid}, events=${queue.length}`);
431
+
432
+ // Process session title updates
433
+ for (const event of queue) {
434
+ if (event.type === "CUSTOM" && event.name === "session_title") {
435
+ const newTitle = (event.value as { title: string })?.title;
436
+ if (newTitle) {
437
+ setSessions((current) =>
438
+ current.map((s) =>
439
+ s.id === sid ? { ...s, title: newTitle } : s,
440
+ ),
441
+ );
442
+ }
443
+ continue;
444
+ }
445
+ }
446
+
447
+ // Process session-state events first so agents state is up to date
448
+ // before any UI tied to spinner / agent list re-renders.
449
+ for (const event of queue) {
450
+ if (event.type === "CUSTOM" && event.name === "session_state") {
451
+ // Authoritative live state from the sandbox. Replaces the agents list
452
+ // wholesale on every occurrence — same payload arrives via SSE first
453
+ // frame on reconnect and on every semantic transition thereafter.
454
+ const value = (event.value ?? {}) as Record<string, unknown>;
455
+ const agentsRaw = Array.isArray(value.agents) ? (value.agents as Array<Record<string, unknown>>) : [];
456
+ const nextAgents = agentsRaw.map((agent) => ({
457
+ name: String(agent.name ?? ""),
458
+ status: String(agent.status ?? "idle"),
459
+ task: String(agent.task ?? ""),
460
+ updatedAt: typeof agent.updatedAt === "string" ? agent.updatedAt : new Date().toISOString(),
461
+ alive: typeof agent.alive === "boolean" ? agent.alive : undefined,
462
+ }));
463
+ setAgents(nextAgents);
464
+ // Apply default filters for newly discovered agents:
465
+ // - trace agent: hide messages and tools by default
466
+ // - all agents: hide hooks by default
467
+ setAgentFilters((current) => {
468
+ let changed = false;
469
+ const next = { ...current };
470
+ for (const agent of nextAgents) {
471
+ const existing = current[agent.name];
472
+ if (!existing) {
473
+ changed = true;
474
+ const isTrace = agent.name === "trace";
475
+ next[agent.name] = {
476
+ hideMessages: isTrace,
477
+ hideTools: isTrace,
478
+ hideHooks: true,
479
+ };
480
+ }
481
+ }
482
+ return changed ? next : current;
483
+ });
484
+ // 当 agent_runtime 进程重建(container restart / eviction)后,
485
+ // 内存中的 SessionState 被 hydrate_from_fold 重置为 idle。
486
+ // 向前端插入系统提示,避免用户误以为 agent 仍在运行。
487
+ if (value.recovered === true) {
488
+ setMessagesBySession((current) => {
489
+ const msgs = current[sid] ?? [];
490
+ const recoveredMsg: ChatMessage = {
491
+ id: generateUUID(),
492
+ role: "system",
493
+ content: "⚠️ Session connection recovered. Agent states may have been reset due to a runtime restart.",
494
+ createdAt: new Date().toISOString(),
495
+ kind: "status",
496
+ };
497
+ return { ...current, [sid]: [...msgs, recoveredMsg] };
498
+ });
499
+ }
500
+ continue;
501
+ }
502
+ // session_heartbeat carries only timestamps — no agents / run touch.
503
+ // Currently no UI binds to last_activity_ts; placeholder for the future
504
+ // "session has gone silent for N seconds" indicator.
505
+ if (event.type === "CUSTOM" && event.name === "session_heartbeat") {
506
+ continue;
507
+ }
508
+ }
509
+
510
+ // Process all events through the message reducer.
511
+ setMessagesBySession((current) => {
512
+ let messages = current[sid] ?? [];
513
+ for (const event of queue) {
514
+ const eventSid = eventSessionId(event);
515
+ if (eventSid && eventSid !== sid) {
516
+ // Background session event — also update its message list.
517
+ messages = current[eventSid] ?? [];
518
+ const next = reduceMessagesForEvent(messages, event);
519
+ current = { ...current, [eventSid]: next };
520
+ messages = current[sid] ?? [];
521
+ continue;
522
+ }
523
+ messages = reduceMessagesForEvent(messages, event);
524
+ }
525
+ return { ...current, [sid]: messages };
526
+ });
527
+ }, [currentSessionId, tick]);
528
+
529
+ const setAgentFilter = useCallback(
530
+ (agentName: string, hideMessages: boolean, hideTools: boolean, hideHooks?: boolean) => {
531
+ setAgentFilters((current) => {
532
+ const prev = current[agentName] ?? { hideMessages: false, hideTools: false, hideHooks: true };
533
+ return {
534
+ ...current,
535
+ [agentName]: { hideMessages, hideTools, hideHooks: hideHooks ?? prev.hideHooks },
536
+ };
537
+ });
538
+ },
539
+ [],
540
+ );
541
+
542
+ const value = useMemo(
543
+ () => ({
544
+ sessions,
545
+ currentSession,
546
+ messages,
547
+ isLoading,
548
+ isSending,
549
+ isRefreshingMessages,
550
+ isConnected,
551
+ isDraft,
552
+ error,
553
+ currentView,
554
+ agents,
555
+ agentFilters,
556
+ selectSession,
557
+ createSession,
558
+ startDraftSession,
559
+ updateSessionTitle,
560
+ deleteSession,
561
+ sendPrompt,
562
+ interruptCurrent,
563
+ respondToInput,
564
+ refreshSessions,
565
+ refreshMessages,
566
+ setCurrentView,
567
+ setAgentFilter,
568
+ messageFilters,
569
+ }),
570
+ [
571
+ sessions,
572
+ currentSession,
573
+ messages,
574
+ isLoading,
575
+ isSending,
576
+ isRefreshingMessages,
577
+ isConnected,
578
+ isDraft,
579
+ error,
580
+ currentView,
581
+ agents,
582
+ agentFilters,
583
+ selectSession,
584
+ createSession,
585
+ startDraftSession,
586
+ updateSessionTitle,
587
+ deleteSession,
588
+ sendPrompt,
589
+ interruptCurrent,
590
+ respondToInput,
591
+ refreshSessions,
592
+ refreshMessages,
593
+ setCurrentView,
594
+ setAgentFilter,
595
+ messageFilters,
596
+ ],
597
+ );
598
+
599
+ return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
600
+ }
601
+
602
+ export function useSessions() {
603
+ const value = useContext(SessionContext);
604
+ if (!value) {
605
+ throw new Error("useSessions must be used within SessionProvider");
606
+ }
607
+ return value;
608
+ }
@@ -0,0 +1,103 @@
1
+ import { useCallback, useSyncExternalStore } from "react";
2
+
3
+ /**
4
+ * Module-scoped store for unsent textarea drafts, keyed by session id.
5
+ *
6
+ * Why this exists: keeping draft state in SessionContext caused every keystroke
7
+ * to re-render the whole chat subtree (MessageStream + all MarkdownMessage
8
+ * children), producing visible input lag once the conversation grew past a few
9
+ * hundred messages. Pulling draft state out of React context and subscribing to
10
+ * it only from the ComposerInput leaf component lets typing skip the list
11
+ * entirely.
12
+ *
13
+ * Why a module-level store rather than per-component useState:
14
+ * - PromptComposer unmounts when the user switches to the Agents/Trace tab,
15
+ * so local state would lose the unsent draft.
16
+ * - Drafts must be isolated per session — switching sessions keeps the
17
+ * composer mounted but should swap which draft is visible.
18
+ */
19
+ class DraftStore {
20
+ private drafts = new Map<string, string>();
21
+ private listeners = new Map<string, Set<() => void>>();
22
+
23
+ get(sessionId: string): string {
24
+ return this.drafts.get(sessionId) ?? "";
25
+ }
26
+
27
+ set(sessionId: string, value: string): void {
28
+ if (this.drafts.get(sessionId) === value) {
29
+ // Skip notify on no-op writes so React doesn't schedule needless work.
30
+ return;
31
+ }
32
+ this.drafts.set(sessionId, value);
33
+ const subs = this.listeners.get(sessionId);
34
+ if (subs) {
35
+ subs.forEach((listener) => listener());
36
+ }
37
+ }
38
+
39
+ subscribe(sessionId: string, listener: () => void): () => void {
40
+ let subs = this.listeners.get(sessionId);
41
+ if (!subs) {
42
+ subs = new Set();
43
+ this.listeners.set(sessionId, subs);
44
+ }
45
+ subs.add(listener);
46
+ return () => {
47
+ const current = this.listeners.get(sessionId);
48
+ if (!current) return;
49
+ current.delete(listener);
50
+ if (current.size === 0) {
51
+ this.listeners.delete(sessionId);
52
+ }
53
+ };
54
+ }
55
+
56
+ delete(sessionId: string): void {
57
+ this.drafts.delete(sessionId);
58
+ // Keep listener set alive — if a component is currently mounted on this id
59
+ // (rare, but possible during async deletion), it will still get notified
60
+ // of the implicit "" snapshot via get().
61
+ }
62
+ }
63
+
64
+ export const draftStore = new DraftStore();
65
+
66
+ const NOOP_UNSUBSCRIBE = () => {};
67
+ const EMPTY_SUBSCRIBE = (_listener: () => void) => NOOP_UNSUBSCRIBE;
68
+ const EMPTY_SNAPSHOT = () => "";
69
+
70
+ /**
71
+ * Subscribe to the draft for a given session id.
72
+ *
73
+ * When sessionId is null (no active session) returns ["", noop] and does not
74
+ * subscribe to anything — keeps the hook safe to call unconditionally.
75
+ */
76
+ export function useDraft(sessionId: string | null): [string, (value: string) => void] {
77
+ const subscribe = useCallback(
78
+ (listener: () => void) => {
79
+ if (sessionId === null) return NOOP_UNSUBSCRIBE;
80
+ return draftStore.subscribe(sessionId, listener);
81
+ },
82
+ [sessionId],
83
+ );
84
+ const getSnapshot = useCallback(
85
+ () => (sessionId === null ? "" : draftStore.get(sessionId)),
86
+ [sessionId],
87
+ );
88
+
89
+ const draft = useSyncExternalStore(
90
+ sessionId === null ? EMPTY_SUBSCRIBE : subscribe,
91
+ sessionId === null ? EMPTY_SNAPSHOT : getSnapshot,
92
+ );
93
+
94
+ const setDraft = useCallback(
95
+ (value: string) => {
96
+ if (sessionId === null) return;
97
+ draftStore.set(sessionId, value);
98
+ },
99
+ [sessionId],
100
+ );
101
+
102
+ return [draft, setDraft];
103
+ }