@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
@@ -0,0 +1,919 @@
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, SessionTokenUsage, TraceGraph, normalizeWebSocketEvent, /* 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
+ import { reduceAgentsForEvent } from "./agentsReducer";
18
+ import { reduceTraceForEvent } from "./traceReducer";
19
+
20
+ export interface AgentMessageFilter {
21
+ hideMessages: boolean;
22
+ hideTools: boolean;
23
+ hideHooks: boolean;
24
+ }
25
+
26
+ interface SessionContextValue {
27
+ sessions: Session[];
28
+ currentSession: Session | null;
29
+ messages: ChatMessage[];
30
+ isLoading: boolean;
31
+ isSending: boolean;
32
+ isRefreshingMessages: boolean;
33
+ isConnected: boolean;
34
+ /**
35
+ * True when the user has opened a new conversation that has not yet been
36
+ * persisted. No session file exists until the first message is sent.
37
+ */
38
+ isDraft: boolean;
39
+ error: string | null;
40
+ currentView: "chat" | "agents" | "trace";
41
+ agents: AgentStatus[];
42
+ /**
43
+ * #99: authoritative whole-turn run-active signal from session_state.runState
44
+ * (trace agent excluded, delivery loops included), with the backend timestamp
45
+ * of the snapshot. null until the first session_state arrives. Drives the
46
+ * whole-turn timer in the Chat footer.
47
+ */
48
+ runActive: { active: boolean; atMs: number } | null;
49
+ /**
50
+ * Cumulative real token usage for the current session (total + per-agent),
51
+ * fed live from `session_state` frames. null until the first frame carrying
52
+ * usage arrives.
53
+ */
54
+ tokenUsage: SessionTokenUsage | null;
55
+ agentFilters: Record<string, AgentMessageFilter>;
56
+ /** Live Graph of Trace for the current session (#79), or null if none/unloaded. */
57
+ currentTrace: TraceGraph | null;
58
+ /** Re-seed the trace graph from the HTTP route (manual refresh). */
59
+ refreshTrace: (sessionId: string) => Promise<void>;
60
+ selectSession: (sessionId: string) => void;
61
+ createSession: (title?: string, opts?: { providerId?: string; modelId?: string }) => Promise<Session | null>;
62
+ /**
63
+ * Open a fresh draft conversation without persisting anything. Idempotent —
64
+ * repeated calls collapse to the single draft state. The real session is
65
+ * created lazily on the first sendPrompt.
66
+ */
67
+ startDraftSession: () => void;
68
+ updateSessionTitle: (sessionId: string, title: string) => Promise<void>;
69
+ deleteSession: (sessionId: string) => Promise<void>;
70
+ /** Resolves true when the message was accepted, false on validation/timeout/
71
+ * error — the composer uses this to restore the draft so input isn't lost. */
72
+ sendPrompt: (content: string, opts?: { providerId?: string; modelId?: string }) => Promise<boolean>;
73
+ interruptCurrent: () => Promise<void>;
74
+ /**
75
+ * 修正6 — answer an ask_user (user_input_request) card. Optimistically
76
+ * resolves the card locally and posts a user_input_response to the runtime.
77
+ */
78
+ respondToInput: (requestId: string, answer: string) => Promise<void>;
79
+ refreshSessions: () => Promise<void>;
80
+ refreshMessages: () => Promise<void>;
81
+ setCurrentView: (view: "chat" | "agents" | "trace") => void;
82
+ setAgentFilter: (agentName: string, hideMessages: boolean, hideTools: boolean, hideHooks?: boolean) => void;
83
+ messageFilters: MessageFilterRule[];
84
+ }
85
+
86
+ // Stable key for the not-yet-persisted draft session. The composer keys its
87
+ // draft text by this id while `isDraft` is true; once the real session is
88
+ // created on first send, the composer switches to the real session id.
89
+ export const DRAFT_SESSION_ID = "__draft__";
90
+
91
+ const SessionContext = createContext<SessionContextValue | null>(null);
92
+ // Runtime treats `limit=0` as "return the full persisted event log". Rehydrate
93
+ // must not use a fixed tail size because slicing through TEXT_MESSAGE_START /
94
+ // CONTENT / END leaves old long sessions looking empty.
95
+ const HISTORY_REHYDRATE_LIMIT = 0;
96
+
97
+ function foldSessionHistory(events: unknown[], sessionId: string): {
98
+ messages: ChatMessage[];
99
+ trace: TraceGraph | null;
100
+ agents: AgentStatus[] | null;
101
+ tokenUsage: SessionTokenUsage | null;
102
+ } {
103
+ let messages: ChatMessage[] = [];
104
+ let trace: TraceGraph | null = null;
105
+ let lastAgents: AgentStatus[] | null = null;
106
+ let lastUsage: SessionTokenUsage | null = null;
107
+
108
+ for (const raw of events) {
109
+ const ev = normalizeWebSocketEvent(raw);
110
+ if (!ev) continue;
111
+ messages = reduceMessagesForEvent(messages, ev);
112
+ trace = reduceTraceForEvent(trace, ev, sessionId);
113
+ if (ev.type === "CUSTOM" && ev.name === "session_state") {
114
+ const v = (ev.value ?? {}) as Record<string, unknown>;
115
+ if (Array.isArray(v.agents)) {
116
+ lastAgents = (v.agents as Array<Record<string, unknown>>).map((agent) => ({
117
+ name: String(agent.name ?? ""),
118
+ status: String(agent.status ?? "idle"),
119
+ task: String(agent.task ?? ""),
120
+ updatedAt:
121
+ typeof agent.updatedAt === "string"
122
+ ? agent.updatedAt
123
+ : new Date().toISOString(),
124
+ alive: typeof agent.alive === "boolean" ? agent.alive : undefined,
125
+ }));
126
+ }
127
+ const usage = parseTokenUsageValue(v.tokenUsage);
128
+ if (usage) lastUsage = usage;
129
+ }
130
+ }
131
+
132
+ return { messages, trace, agents: lastAgents, tokenUsage: lastUsage };
133
+ }
134
+
135
+ /**
136
+ * Coerce a wire `session_state.tokenUsage` value into SessionTokenUsage, or
137
+ * null when absent/malformed. Mirrors `normalizeSessionTokenUsage` in the
138
+ * backend contract but works off the already-shaped CUSTOM event value.
139
+ */
140
+ function parseTokenUsageValue(rawValue: unknown): SessionTokenUsage | null {
141
+ if (rawValue == null || typeof rawValue !== "object") return null;
142
+ const raw = rawValue as Record<string, unknown>;
143
+ const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
144
+ const one = (x: unknown) => {
145
+ const u = (x ?? {}) as Record<string, unknown>;
146
+ return {
147
+ input: num(u.input),
148
+ output: num(u.output),
149
+ cacheRead: num(u.cacheRead),
150
+ cacheWrite: num(u.cacheWrite),
151
+ total: num(u.total),
152
+ };
153
+ };
154
+ const byAgentRaw = (raw.byAgent ?? {}) as Record<string, unknown>;
155
+ const byAgent: SessionTokenUsage["byAgent"] = {};
156
+ for (const [name, value] of Object.entries(byAgentRaw)) byAgent[name] = one(value);
157
+ return { total: one(raw.total), byAgent };
158
+ }
159
+
160
+ function defaultAgentFilter(agentName: string): AgentMessageFilter {
161
+ const isTrace = agentName === "trace";
162
+ return {
163
+ hideMessages: isTrace,
164
+ hideTools: isTrace,
165
+ hideHooks: true,
166
+ };
167
+ }
168
+
169
+ export function SessionProvider({ children }: { children: ReactNode }) {
170
+ const { isAuthReady } = useAuth();
171
+ const { currentSandbox } = useSandbox();
172
+ const { connectSession, disconnectSession, queueRef, tick, connections } = useSSE();
173
+ const [sessions, setSessions] = useState<Session[]>([]);
174
+ const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
175
+ const [isDraft, setIsDraft] = useState(false);
176
+ // Mirror of isDraft for reading inside callbacks that must not re-create when
177
+ // the draft flag flips (e.g. refreshSessions, keyed only on isAuthReady).
178
+ const isDraftRef = useRef(false);
179
+ useEffect(() => {
180
+ isDraftRef.current = isDraft;
181
+ }, [isDraft]);
182
+ const [messagesBySession, setMessagesBySession] = useState<Record<string, ChatMessage[]>>({});
183
+ const [isLoading, setIsLoading] = useState(false);
184
+ const [isSending, setIsSending] = useState(false);
185
+ const [isRefreshingMessages, setIsRefreshingMessages] = useState(false);
186
+ const [error, setError] = useState<string | null>(null);
187
+ const [currentView, setCurrentView] = useState<"chat" | "agents" | "trace">("chat");
188
+ // Unsent textarea drafts live in a module-level store (see contexts/draftStore.ts)
189
+ // so keystrokes don't re-render the whole chat subtree. Drafts are keyed by
190
+ // session id and survive PromptComposer unmount (tab switches).
191
+ const [agents, setAgents] = useState<AgentStatus[]>([]);
192
+ // #99: authoritative whole-turn run-active flag from session_state.runState
193
+ // (derived by the runtime: trace agent excluded, delivery loops included),
194
+ // paired with the backend ISO timestamp of the snapshot that carried it. The
195
+ // turn timer keys off transitions of this flag, not the per-agent list.
196
+ const [runActive, setRunActive] = useState<{ active: boolean; atMs: number } | null>(null);
197
+ // Cumulative real token usage for the current session, fed from session_state.
198
+ const [tokenUsage, setTokenUsage] = useState<SessionTokenUsage | null>(null);
199
+ const [agentFilters, setAgentFilters] = useState<Record<string, AgentMessageFilter>>({});
200
+ const [messageFilters, setMessageFilters] = useState<MessageFilterRule[]>(defaultFilterRules);
201
+ // #79: live Graph of Trace per session. Seeded by a fetch on session change,
202
+ // then kept live by CUSTOM:trace_node SSE events (see the queue drain below).
203
+ const [traceBySession, setTraceBySession] = useState<Record<string, TraceGraph>>({});
204
+
205
+
206
+ const currentSession = useMemo(
207
+ () => sessions.find((session) => session.id === currentSessionId) ?? null,
208
+ [currentSessionId, sessions],
209
+ );
210
+ const messages = currentSessionId ? messagesBySession[currentSessionId] ?? [] : [];
211
+
212
+ const isConnected = currentSessionId
213
+ ? connections.get(currentSessionId) === "open"
214
+ : false;
215
+
216
+ const refreshSessions = useCallback(async () => {
217
+ if (!isAuthReady) {
218
+ setSessions([]);
219
+ setCurrentSessionId(null);
220
+ return;
221
+ }
222
+
223
+ setIsLoading(true);
224
+ setError(null);
225
+ try {
226
+ const nextSessions = await api.sessions.list();
227
+ const sorted = [...nextSessions].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
228
+ setSessions(sorted);
229
+ // Don't auto-select an existing session while the user is composing a
230
+ // draft — that would yank them out of the new conversation.
231
+ setCurrentSessionId((current) => (isDraftRef.current ? current : current ?? sorted[0]?.id ?? null));
232
+ } catch (err) {
233
+ setError(err instanceof Error ? err.message : tg("ctx.session.loadFailed"));
234
+ } finally {
235
+ setIsLoading(false);
236
+ }
237
+ }, [isAuthReady]);
238
+
239
+ useEffect(() => {
240
+ void refreshSessions();
241
+ }, [refreshSessions]);
242
+
243
+ // Sessions whose persisted history has already been pulled this page-load.
244
+ // Guards against re-hydrating on every tab switch — once SSE is attached we
245
+ // own the up-to-date state and don't want to refetch the .jsonl tail.
246
+ const hydratedSessionsRef = useRef<Set<string>>(new Set());
247
+
248
+ useEffect(() => {
249
+ if (!currentSessionId) {
250
+ return;
251
+ }
252
+ const sessionId = currentSessionId;
253
+ if (hydratedSessionsRef.current.has(sessionId)) {
254
+ return;
255
+ }
256
+ let cancelled = false;
257
+
258
+ async function loadHistory() {
259
+ setIsRefreshingMessages(true);
260
+ try {
261
+ const { events } = await api.sessions.getHistory(sessionId, { limit: HISTORY_REHYDRATE_LIMIT });
262
+ if (cancelled) return;
263
+
264
+ // Replay the persisted event stream through the same reducers SSE uses
265
+ // (messageReducer / traceReducer / agents seed via session_state). The
266
+ // SSE ring buffer that arrives next is deduped by messageId/toolCallId
267
+ // inside the reducer, so any overlap is a no-op.
268
+ const { messages: nextMessages, trace: nextTrace, agents: lastAgents, tokenUsage: lastUsage } =
269
+ foldSessionHistory(events, sessionId);
270
+
271
+ if (cancelled) return;
272
+ hydratedSessionsRef.current.add(sessionId);
273
+ if (lastUsage) setTokenUsage(lastUsage);
274
+
275
+ // Only seed the message list if the user hasn't already started typing
276
+ // / receiving live SSE for this session in the brief window before
277
+ // history arrived (otherwise we'd clobber their in-flight messages).
278
+ setMessagesBySession((current) => {
279
+ if ((current[sessionId]?.length ?? 0) > 0) return current;
280
+ return { ...current, [sessionId]: nextMessages };
281
+ });
282
+ if (nextTrace) {
283
+ setTraceBySession((current) =>
284
+ current[sessionId] ? current : { ...current, [sessionId]: nextTrace! },
285
+ );
286
+ }
287
+ // Only seed agents when the current panel is empty — live SSE may have
288
+ // already pushed an authoritative session_state since selection.
289
+ if (lastAgents && lastAgents.length > 0) {
290
+ setAgents((current) => (current.length === 0 ? lastAgents! : current));
291
+ setAgentFilters((current) => {
292
+ let changed = false;
293
+ const next = { ...current };
294
+ for (const agent of lastAgents) {
295
+ if (!next[agent.name]) {
296
+ changed = true;
297
+ next[agent.name] = defaultAgentFilter(agent.name);
298
+ }
299
+ }
300
+ return changed ? next : current;
301
+ });
302
+ }
303
+ } catch (err) {
304
+ // Best-effort. SSE will eventually drive the panel; we shouldn't surface
305
+ // a banner just because the history file was unreachable for a moment.
306
+ console.warn(`[SessionContext] history rehydrate failed for ${sessionId}:`, err);
307
+ } finally {
308
+ if (!cancelled) setIsRefreshingMessages(false);
309
+ }
310
+ }
311
+
312
+ void loadHistory();
313
+ return () => {
314
+ cancelled = true;
315
+ };
316
+ }, [currentSessionId]);
317
+
318
+ const selectSession = useCallback((sessionId: string) => {
319
+ console.log(`[SessionContext] selectSession: ${sessionId}`);
320
+ setIsDraft(false);
321
+ setCurrentSessionId(sessionId);
322
+ setCurrentView("chat");
323
+ setRunActive(null); // #99: drop the previous session's turn-active signal
324
+ setTokenUsage(null); // drop the previous session's token totals
325
+ connectSession(sessionId);
326
+ }, [connectSession]);
327
+
328
+ // Open a fresh, unpersisted conversation. Idempotent: if a draft is already
329
+ // open, this just keeps it. The real session is created on first send.
330
+ const startDraftSession = useCallback(() => {
331
+ setIsDraft(true);
332
+ setCurrentSessionId(null);
333
+ setCurrentView("chat");
334
+ }, []);
335
+
336
+ const updateSessionTitle = useCallback(async (sessionId: string, title: string) => {
337
+ setError(null);
338
+ try {
339
+ const updated = await api.sessions.update(sessionId, title);
340
+ setSessions((current) =>
341
+ [updated, ...current.filter((session) => session.id !== sessionId)].sort((a, b) =>
342
+ b.updatedAt.localeCompare(a.updatedAt),
343
+ ),
344
+ );
345
+ } catch (err) {
346
+ setError(err instanceof Error ? err.message : tg("ctx.session.updateFailed"));
347
+ throw err;
348
+ }
349
+ }, []);
350
+
351
+ const deleteSession = useCallback(async (sessionId: string) => {
352
+ setError(null);
353
+ try {
354
+ await api.sessions.remove(sessionId);
355
+ disconnectSession(sessionId);
356
+ setSessions((current) => {
357
+ const next = current.filter((session) => session.id !== sessionId);
358
+ setCurrentSessionId((currentId) => (currentId === sessionId ? next[0]?.id ?? null : currentId));
359
+ return next;
360
+ });
361
+ setMessagesBySession((current) => {
362
+ const next = { ...current };
363
+ delete next[sessionId];
364
+ return next;
365
+ });
366
+ // Drop any unsent draft so re-creating a session with the same id (rare,
367
+ // but possible) doesn't resurrect stale text.
368
+ draftStore.delete(sessionId);
369
+ hydratedSessionsRef.current.delete(sessionId);
370
+ } catch (err) {
371
+ setError(err instanceof Error ? err.message : tg("ctx.session.deleteFailed"));
372
+ throw err;
373
+ }
374
+ }, [disconnectSession]);
375
+
376
+ const createSession = useCallback(
377
+ async (title = "New research session", opts: { providerId?: string; modelId?: string } = {}) => {
378
+ if (!currentSandbox || currentSandbox.status !== "running") {
379
+ setError(tg("ctx.session.startSandbox"));
380
+ return null;
381
+ }
382
+
383
+ setIsLoading(true);
384
+ setError(null);
385
+ try {
386
+ const session = await api.sessions.create(title, opts);
387
+ setSessions((current) => [session, ...current.filter((item) => item.id !== session.id)]);
388
+ setIsDraft(false);
389
+ setCurrentSessionId(session.id);
390
+ setMessagesBySession((current) => ({ ...current, [session.id]: current[session.id] ?? [] }));
391
+ return session;
392
+ } catch (err) {
393
+ setError(err instanceof Error ? err.message : tg("ctx.session.createFailed"));
394
+ throw err;
395
+ } finally {
396
+ setIsLoading(false);
397
+ }
398
+ },
399
+ [currentSandbox],
400
+ );
401
+
402
+ useEffect(() => {
403
+ if (sessions.length === 0 && !isLoading && currentSandbox?.status === "running" && !currentSessionId && !isDraft) {
404
+ startDraftSession();
405
+ }
406
+ }, [sessions.length, isLoading, currentSandbox, currentSessionId, isDraft, startDraftSession]);
407
+
408
+ const sendPrompt = useCallback(
409
+ async (content: string, opts: { providerId?: string; modelId?: string } = {}) => {
410
+ const trimmed = content.trim();
411
+ console.log(`[SessionContext] sendPrompt: "${trimmed.slice(0, 40)}...", isConnected=${isConnected}, isDraft=${isDraft}`);
412
+ if (!trimmed) {
413
+ return false;
414
+ }
415
+ // A draft has no SSE connection yet — the session is created and
416
+ // connected below. Only block on connection for an already-persisted
417
+ // session.
418
+ if (!currentSession && !isDraft) {
419
+ setError(tg("ctx.session.noConnection"));
420
+ return false;
421
+ }
422
+ if (currentSession && !isConnected) {
423
+ setError(tg("ctx.session.noConnection"));
424
+ return false;
425
+ }
426
+
427
+ setIsSending(true);
428
+ setError(null);
429
+ // Tracked so we can roll back the optimistic user message if the post
430
+ // fails/times out (#106) — otherwise the bubble lingers and a retry
431
+ // duplicates it.
432
+ let optimistic: { sessionId: string; messageId: string } | null = null;
433
+ try {
434
+ const session = currentSession ?? (await createSession(trimmed.slice(0, 48), opts));
435
+ if (!session) {
436
+ return false;
437
+ }
438
+ // Freshly created (draft → persisted): open the SSE stream so the
439
+ // assistant's streamed reply is received.
440
+ if (!currentSession) {
441
+ connectSession(session.id);
442
+ // Migrate any draft text the composer stored under the sentinel id
443
+ // so a tab switch mid-send doesn't lose it.
444
+ draftStore.delete(DRAFT_SESSION_ID);
445
+ }
446
+
447
+ const timestamp = new Date().toISOString();
448
+ const uuid = generateUUID();
449
+ const userMessage: ChatMessage = {
450
+ id: uuid,
451
+ role: "user",
452
+ content: trimmed,
453
+ createdAt: timestamp,
454
+ agent: "user",
455
+ };
456
+ optimistic = { sessionId: session.id, messageId: uuid };
457
+ setMessagesBySession((current) => ({
458
+ ...current,
459
+ [session.id]: [...(current[session.id] ?? []), userMessage],
460
+ }));
461
+ console.log(`[SessionContext] posting message to ${session.id}`);
462
+ await api.sessions.postMessage(session.id, { content: trimmed, uuid, timestamp });
463
+ console.log(`[SessionContext] postMessage success`);
464
+ return true;
465
+ } catch (err) {
466
+ console.error(`[SessionContext] sendPrompt error:`, err);
467
+ // #106: roll back the optimistic bubble so the failed send doesn't
468
+ // leave a ghost message (and a retry doesn't duplicate it).
469
+ if (optimistic) {
470
+ const { sessionId: sid, messageId } = optimistic;
471
+ setMessagesBySession((current) => ({
472
+ ...current,
473
+ [sid]: (current[sid] ?? []).filter((m) => m.id !== messageId),
474
+ }));
475
+ }
476
+ // AbortSignal.timeout() rejects with a TimeoutError (and a hard abort
477
+ // with AbortError). Surface a clear, retryable message instead of the
478
+ // raw DOMException text, and let `finally` release isSending so the
479
+ // composer never stays stuck on "正在准备发送".
480
+ const isTimeout =
481
+ err instanceof DOMException &&
482
+ (err.name === "TimeoutError" || err.name === "AbortError");
483
+ setError(
484
+ isTimeout
485
+ ? tg("ctx.session.sendTimeout")
486
+ : err instanceof Error
487
+ ? err.message
488
+ : tg("ctx.session.sendFailed"),
489
+ );
490
+ return false;
491
+ } finally {
492
+ setIsSending(false);
493
+ }
494
+ },
495
+ [createSession, currentSession, isConnected, isDraft, connectSession],
496
+ );
497
+
498
+ const interruptCurrent = useCallback(
499
+ async () => {
500
+ if (!currentSession) return;
501
+ const sid = currentSession.id;
502
+ // #90: NOT optimistic. Wait for the interrupt to actually land before
503
+ // touching the UI — the old code optimistically forced every agent idle
504
+ // and inserted a "stopped" message even when the request hit the wrong
505
+ // endpoint and failed, permanently masking the failure while the runtime
506
+ // kept the agent running. We never speculatively mutate agent state now, so
507
+ // a failed interrupt leaves the true (still-running) state visible via the
508
+ // authoritative SSE session_state stream.
509
+ try {
510
+ const { interrupted } = await api.sessions.interrupt(sid);
511
+ if (!interrupted) {
512
+ // Nothing was running to interrupt (or the session is gone). Surface it
513
+ // rather than pretending the task stopped.
514
+ setError(tg("ctx.session.interruptFailed"));
515
+ return;
516
+ }
517
+ // Confirmed stopped: insert the status line. Agent state (incl. the PI
518
+ // interrupt-acknowledgement run the runtime now fires) flows in via SSE.
519
+ setMessagesBySession((current) => {
520
+ const msgs = current[sid] ?? [];
521
+ const stoppedMsg: ChatMessage = {
522
+ id: generateUUID(),
523
+ role: "system",
524
+ content: tg("chat.stoppedByUser"),
525
+ createdAt: new Date().toISOString(),
526
+ kind: "status",
527
+ };
528
+ return { ...current, [sid]: [...finalizeAssistant(msgs), stoppedMsg] };
529
+ });
530
+ } catch (err) {
531
+ setError(err instanceof Error ? err.message : tg("ctx.session.interruptFailed"));
532
+ }
533
+ },
534
+ [currentSession],
535
+ );
536
+
537
+ const respondToInput = useCallback(
538
+ async (requestId: string, answer: string) => {
539
+ const sid = currentSession?.id;
540
+ if (!sid || !requestId) return;
541
+ // Optimistically resolve the matching ask_user card so the buttons
542
+ // disable immediately, without waiting for the echoed user_input_response.
543
+ setMessagesBySession((current) => {
544
+ const msgs = current[sid] ?? [];
545
+ return {
546
+ ...current,
547
+ [sid]: msgs.map((m) =>
548
+ m.kind === "ask_user" && m.askUser?.requestId === requestId
549
+ ? { ...m, askUser: { ...m.askUser, answer } }
550
+ : m,
551
+ ),
552
+ };
553
+ });
554
+ try {
555
+ await api.sessions.respondToInput(sid, { requestId, answer });
556
+ } catch (err) {
557
+ setError(err instanceof Error ? err.message : tg("ctx.session.sendFailed"));
558
+ }
559
+ },
560
+ [currentSession],
561
+ );
562
+
563
+ const refreshMessages = useCallback(async () => {
564
+ if (!currentSession) {
565
+ return;
566
+ }
567
+ console.log(`[SessionContext] refreshMessages: ${currentSession.id}`);
568
+ setIsRefreshingMessages(true);
569
+ setError(null);
570
+ try {
571
+ const { events } = await api.sessions.getHistory(currentSession.id, {
572
+ limit: HISTORY_REHYDRATE_LIMIT,
573
+ });
574
+ const { messages: nextMessages, trace: nextTrace, agents: nextAgents, tokenUsage: nextUsage } =
575
+ foldSessionHistory(events, currentSession.id);
576
+
577
+ if (nextUsage) setTokenUsage(nextUsage);
578
+ setMessagesBySession((current) => ({ ...current, [currentSession.id]: nextMessages }));
579
+ if (nextTrace) {
580
+ setTraceBySession((current) => ({ ...current, [currentSession.id]: nextTrace }));
581
+ }
582
+ if (nextAgents && nextAgents.length > 0) {
583
+ setAgents(nextAgents);
584
+ setAgentFilters((current) => {
585
+ let changed = false;
586
+ const next = { ...current };
587
+ for (const agent of nextAgents) {
588
+ if (!next[agent.name]) {
589
+ changed = true;
590
+ next[agent.name] = defaultAgentFilter(agent.name);
591
+ }
592
+ }
593
+ return changed ? next : current;
594
+ });
595
+ }
596
+ hydratedSessionsRef.current.add(currentSession.id);
597
+
598
+ // Re-connect the SSE stream so live updates continue after the disk
599
+ // history has refreshed the local cache.
600
+ disconnectSession(currentSession.id);
601
+ connectSession(currentSession.id);
602
+ } catch (err) {
603
+ setError(err instanceof Error ? err.message : tg("ctx.session.refreshFailed"));
604
+ } finally {
605
+ setIsRefreshingMessages(false);
606
+ }
607
+ }, [currentSession, disconnectSession, connectSession]);
608
+
609
+ useEffect(() => {
610
+ if (!currentSession?.id) {
611
+ setAgents([]);
612
+ return;
613
+ }
614
+ let cancelled = false;
615
+ // Backstop fetch in case the SSE first-frame CUSTOM:session_state hasn't
616
+ // arrived yet (e.g. tab restore while SSE is reconnecting). The same
617
+ // payload arrives via SSE moments later and overwrites this wholesale.
618
+ void api.sessions.state(currentSession.id).then((snap) => {
619
+ if (!cancelled) {
620
+ setAgents(snap.agents.map((agent) => ({
621
+ ...agent,
622
+ updatedAt: agent.updatedAt || new Date().toISOString(),
623
+ })));
624
+ }
625
+ }).catch(() => {
626
+ if (!cancelled) {
627
+ setAgents([]);
628
+ }
629
+ });
630
+ return () => {
631
+ cancelled = true;
632
+ };
633
+ }, [currentSession?.id]);
634
+
635
+ // #79: seed the Graph of Trace once per session. Live updates thereafter
636
+ // arrive incrementally via CUSTOM:trace_node (queue drain below), so no poll.
637
+ const refreshTrace = useCallback(async (sessionId: string) => {
638
+ try {
639
+ const graph = await api.sessions.getTrace(sessionId);
640
+ setTraceBySession((current) => ({ ...current, [sessionId]: graph }));
641
+ } catch {
642
+ // Non-fatal — the panel shows an empty graph until events arrive.
643
+ }
644
+ }, []);
645
+
646
+ useEffect(() => {
647
+ const sid = currentSession?.id;
648
+ if (!sid) return;
649
+ void refreshTrace(sid);
650
+ }, [currentSession?.id, refreshTrace]);
651
+
652
+ // Auto-connect SSE whenever the current session changes. Other connections
653
+ // are kept open so background sessions continue receiving events.
654
+ useEffect(() => {
655
+ if (currentSessionId) {
656
+ connectSession(currentSessionId);
657
+ }
658
+ }, [currentSessionId, connectSession]);
659
+
660
+ // Drain the SSE event queue for the current session on every tick.
661
+ useEffect(() => {
662
+ if (!currentSessionId || !queueRef.current) {
663
+ return;
664
+ }
665
+ const sid = currentSessionId;
666
+ const queue = queueRef.current.get(sid) || [];
667
+ if (queue.length === 0) {
668
+ return;
669
+ }
670
+ // Atomically clear the queue so events added during processing are handled
671
+ // in the next effect run.
672
+ queueRef.current.set(sid, []);
673
+
674
+ console.log(`[SessionContext] draining queue: sid=${sid}, events=${queue.length}`);
675
+
676
+ // Process session title updates
677
+ for (const event of queue) {
678
+ if (event.type === "CUSTOM" && event.name === "session_title") {
679
+ const newTitle = (event.value as { title: string })?.title;
680
+ if (newTitle) {
681
+ setSessions((current) =>
682
+ current.map((s) =>
683
+ s.id === sid ? { ...s, title: newTitle } : s,
684
+ ),
685
+ );
686
+ }
687
+ continue;
688
+ }
689
+ }
690
+
691
+ // Process session-state events first so agents state is up to date
692
+ // before any UI tied to spinner / agent list re-renders.
693
+ for (const event of queue) {
694
+ if (event.type === "CUSTOM" && event.name === "session_state") {
695
+ // Authoritative live state from the sandbox. Replaces the agents list
696
+ // wholesale on every occurrence — same payload arrives via SSE first
697
+ // frame on reconnect and on every semantic transition thereafter.
698
+ const value = (event.value ?? {}) as Record<string, unknown>;
699
+ const agentsRaw = Array.isArray(value.agents) ? (value.agents as Array<Record<string, unknown>>) : [];
700
+ const nextAgents = agentsRaw.map((agent) => ({
701
+ name: String(agent.name ?? ""),
702
+ status: String(agent.status ?? "idle"),
703
+ task: String(agent.task ?? ""),
704
+ updatedAt: typeof agent.updatedAt === "string" ? agent.updatedAt : new Date().toISOString(),
705
+ alive: typeof agent.alive === "boolean" ? agent.alive : undefined,
706
+ }));
707
+ setAgents(nextAgents);
708
+ // #99: feed the whole-turn timer. runState.active is the authoritative
709
+ // "the user's task is still running" flag; lastActivityTs is the backend
710
+ // timestamp of this snapshot (fallback to now() if absent).
711
+ const rs = (value.runState ?? {}) as Record<string, unknown>;
712
+ const tsRaw = typeof value.lastActivityTs === "string" ? Date.parse(value.lastActivityTs) : NaN;
713
+ setRunActive({
714
+ active: rs.active === true,
715
+ atMs: Number.isNaN(tsRaw) ? Date.now() : tsRaw,
716
+ });
717
+ // Cumulative real token usage rides on the same frame (optional).
718
+ const usage = parseTokenUsageValue(value.tokenUsage);
719
+ if (usage) setTokenUsage(usage);
720
+ // Apply default filters for newly discovered agents:
721
+ // - trace agent: hide messages and tools by default
722
+ // - all agents: hide hooks by default
723
+ setAgentFilters((current) => {
724
+ let changed = false;
725
+ const next = { ...current };
726
+ for (const agent of nextAgents) {
727
+ const existing = current[agent.name];
728
+ if (!existing) {
729
+ changed = true;
730
+ next[agent.name] = defaultAgentFilter(agent.name);
731
+ }
732
+ }
733
+ return changed ? next : current;
734
+ });
735
+ // 当 agent_runtime 进程重建(container restart / eviction)后,
736
+ // 内存中的 SessionState 被 hydrate_from_fold 重置为 idle。
737
+ // 向前端插入系统提示,避免用户误以为 agent 仍在运行。
738
+ if (value.recovered === true) {
739
+ setMessagesBySession((current) => {
740
+ const msgs = current[sid] ?? [];
741
+ const recoveredMsg: ChatMessage = {
742
+ id: generateUUID(),
743
+ role: "system",
744
+ content: "⚠️ Session connection recovered. Agent states may have been reset due to a runtime restart.",
745
+ createdAt: new Date().toISOString(),
746
+ kind: "status",
747
+ };
748
+ return { ...current, [sid]: [...msgs, recoveredMsg] };
749
+ });
750
+ }
751
+ continue;
752
+ }
753
+ // session_heartbeat carries only timestamps — no agents / run touch.
754
+ // Currently no UI binds to last_activity_ts; placeholder for the future
755
+ // "session has gone silent for N seconds" indicator.
756
+ if (event.type === "CUSTOM" && event.name === "session_heartbeat") {
757
+ continue;
758
+ }
759
+ }
760
+
761
+ // #70: keep the Agents panel live between session_state snapshots by merging
762
+ // standalone agent_status_update events. session_state (above) remains the
763
+ // wholesale authority; this applies single-agent deltas on top so the panel
764
+ // updates on the first run without a reload/reselect. The queue is already
765
+ // scoped to the current session (queueRef.get(sid)), so background-session
766
+ // events don't leak in here.
767
+ setAgents((current) => {
768
+ let nextAgents = current;
769
+ const appended: AgentStatus[] = [];
770
+ for (const event of queue) {
771
+ if (event.type !== "agent_status_update") continue;
772
+ const before = nextAgents;
773
+ nextAgents = reduceAgentsForEvent(nextAgents, event);
774
+ if (nextAgents.length > before.length) {
775
+ appended.push(nextAgents[nextAgents.length - 1]!);
776
+ }
777
+ }
778
+ if (appended.length > 0) {
779
+ // Apply the same default filters session_state uses for newly seen
780
+ // agents (trace: hide messages/tools; all: hide hooks).
781
+ setAgentFilters((cur) => {
782
+ let changed = false;
783
+ const nf = { ...cur };
784
+ for (const agent of appended) {
785
+ if (!cur[agent.name]) {
786
+ changed = true;
787
+ nf[agent.name] = defaultAgentFilter(agent.name);
788
+ }
789
+ }
790
+ return changed ? nf : cur;
791
+ });
792
+ }
793
+ return nextAgents;
794
+ });
795
+
796
+ // #79: fold CUSTOM:trace_node events into the live Graph of Trace. The queue
797
+ // is already scoped to the current session, so only this session's graph
798
+ // updates. reduceTraceForEvent ignores non-trace events (same reference).
799
+ setTraceBySession((current) => {
800
+ const start = current[sid] ?? null;
801
+ let graph: TraceGraph | null = start;
802
+ for (const event of queue) {
803
+ graph = reduceTraceForEvent(graph, event, sid);
804
+ }
805
+ return graph && graph !== start ? { ...current, [sid]: graph } : current;
806
+ });
807
+
808
+ // Process all events through the message reducer.
809
+ setMessagesBySession((current) => {
810
+ let messages = current[sid] ?? [];
811
+ for (const event of queue) {
812
+ const eventSid = eventSessionId(event);
813
+ if (eventSid && eventSid !== sid) {
814
+ // Background session event — also update its message list.
815
+ messages = current[eventSid] ?? [];
816
+ const next = reduceMessagesForEvent(messages, event);
817
+ current = { ...current, [eventSid]: next };
818
+ messages = current[sid] ?? [];
819
+ continue;
820
+ }
821
+ messages = reduceMessagesForEvent(messages, event);
822
+ }
823
+ return { ...current, [sid]: messages };
824
+ });
825
+ }, [currentSessionId, tick]);
826
+
827
+ const setAgentFilter = useCallback(
828
+ (agentName: string, hideMessages: boolean, hideTools: boolean, hideHooks?: boolean) => {
829
+ setAgentFilters((current) => {
830
+ const prev = current[agentName] ?? { hideMessages: false, hideTools: false, hideHooks: true };
831
+ return {
832
+ ...current,
833
+ [agentName]: { hideMessages, hideTools, hideHooks: hideHooks ?? prev.hideHooks },
834
+ };
835
+ });
836
+ },
837
+ [],
838
+ );
839
+
840
+ const currentTrace = useMemo(
841
+ () => (currentSessionId ? (traceBySession[currentSessionId] ?? null) : null),
842
+ [currentSessionId, traceBySession],
843
+ );
844
+
845
+ const value = useMemo(
846
+ () => ({
847
+ sessions,
848
+ currentSession,
849
+ messages,
850
+ isLoading,
851
+ isSending,
852
+ isRefreshingMessages,
853
+ isConnected,
854
+ isDraft,
855
+ error,
856
+ currentView,
857
+ agents,
858
+ runActive,
859
+ tokenUsage,
860
+ agentFilters,
861
+ currentTrace,
862
+ refreshTrace,
863
+ selectSession,
864
+ createSession,
865
+ startDraftSession,
866
+ updateSessionTitle,
867
+ deleteSession,
868
+ sendPrompt,
869
+ interruptCurrent,
870
+ respondToInput,
871
+ refreshSessions,
872
+ refreshMessages,
873
+ setCurrentView,
874
+ setAgentFilter,
875
+ messageFilters,
876
+ }),
877
+ [
878
+ sessions,
879
+ currentSession,
880
+ messages,
881
+ isLoading,
882
+ isSending,
883
+ isRefreshingMessages,
884
+ isConnected,
885
+ isDraft,
886
+ error,
887
+ currentView,
888
+ agents,
889
+ runActive,
890
+ tokenUsage,
891
+ agentFilters,
892
+ currentTrace,
893
+ refreshTrace,
894
+ selectSession,
895
+ createSession,
896
+ startDraftSession,
897
+ updateSessionTitle,
898
+ deleteSession,
899
+ sendPrompt,
900
+ interruptCurrent,
901
+ respondToInput,
902
+ refreshSessions,
903
+ refreshMessages,
904
+ setCurrentView,
905
+ setAgentFilter,
906
+ messageFilters,
907
+ ],
908
+ );
909
+
910
+ return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
911
+ }
912
+
913
+ export function useSessions() {
914
+ const value = useContext(SessionContext);
915
+ if (!value) {
916
+ throw new Error("useSessions must be used within SessionProvider");
917
+ }
918
+ return value;
919
+ }