@brainpilot/web 0.0.5 → 0.0.7

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 (58) hide show
  1. package/dist/assets/index-DWOsU22G.css +1 -0
  2. package/dist/assets/index-j3rGyO6m.js +445 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +6 -3
  5. package/src/__tests__/agentsReducer.test.ts +67 -0
  6. package/src/__tests__/api.test.ts +118 -0
  7. package/src/__tests__/chatScrollBehavior.test.ts +48 -0
  8. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  9. package/src/__tests__/demoConversation.test.ts +96 -0
  10. package/src/__tests__/demoReset.test.ts +24 -0
  11. package/src/__tests__/internalToolStrip.test.ts +108 -0
  12. package/src/__tests__/runningToast.test.ts +29 -0
  13. package/src/__tests__/tokenUsage.test.ts +48 -0
  14. package/src/__tests__/toolDisplay.test.ts +55 -0
  15. package/src/__tests__/traceReducer.test.ts +62 -0
  16. package/src/components/chat/MessageStream.tsx +104 -56
  17. package/src/components/chat/PromptComposer.tsx +120 -29
  18. package/src/components/chat/chatScrollMemory.ts +49 -0
  19. package/src/components/demo/DemoView.tsx +98 -29
  20. package/src/components/demo/TraceNodeModal.tsx +6 -2
  21. package/src/components/demo/demoBundle.ts +7 -2
  22. package/src/components/demo/demoReset.ts +16 -0
  23. package/src/components/session/AgentNetwork.tsx +68 -75
  24. package/src/components/session/AgentTraceViews.tsx +35 -70
  25. package/src/components/session/AnalyticsTab.tsx +58 -224
  26. package/src/components/session/TraceGraphView.tsx +36 -30
  27. package/src/components/session/TraceNodeDetail.tsx +61 -24
  28. package/src/components/session/agentNetworkShared.ts +10 -0
  29. package/src/components/session/traceLayout.ts +32 -0
  30. package/src/components/settings/SettingsDialog.tsx +19 -1
  31. package/src/components/shell/DesktopShell.tsx +72 -17
  32. package/src/components/sidebar/SessionList.tsx +127 -0
  33. package/src/components/sidebar/Sidebar.tsx +94 -98
  34. package/src/contexts/SSEContext.tsx +90 -1
  35. package/src/contexts/SessionContext.tsx +397 -43
  36. package/src/contexts/agentsReducer.ts +49 -0
  37. package/src/contexts/messageGroups.ts +56 -0
  38. package/src/contexts/messageReducer.ts +4 -0
  39. package/src/contexts/runningToast.ts +33 -0
  40. package/src/contexts/traceReducer.ts +62 -0
  41. package/src/contexts/turnTimer.test.ts +97 -0
  42. package/src/contexts/turnTimer.ts +108 -0
  43. package/src/contexts/useTurnTimer.ts +104 -0
  44. package/src/contracts/backend.ts +53 -2
  45. package/src/i18n/messages/analytics.ts +16 -6
  46. package/src/i18n/messages/chat.ts +26 -4
  47. package/src/i18n/messages/contexts.ts +2 -0
  48. package/src/i18n/messages/network.ts +13 -9
  49. package/src/i18n/messages/profile.ts +4 -0
  50. package/src/i18n/messages/settings.ts +4 -0
  51. package/src/i18n/messages/shell.ts +2 -0
  52. package/src/i18n/messages/trace.ts +69 -17
  53. package/src/mocks/backend.ts +7 -0
  54. package/src/styles/global.css +289 -70
  55. package/src/utils/api.ts +105 -8
  56. package/src/utils/toolDisplay.ts +74 -0
  57. package/dist/assets/index-C-8G4D4j.js +0 -448
  58. package/dist/assets/index-C501m5OS.css +0 -1
@@ -1,6 +1,6 @@
1
1
  import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2
2
  // TODO(dead-code): SessionEventEntry removed with pre-AG-UI polling protocol.
3
- import { AgentStatus, ChatMessage, MessageFilterRule, Session, /* SessionEventEntry, */ SessionMessageEntry } from "../contracts/backend";
3
+ import { AgentStatus, ChatMessage, MessageFilterRule, Session, SessionTokenUsage, TraceGraph, normalizeWebSocketEvent, /* SessionEventEntry, */ SessionMessageEntry } from "../contracts/backend";
4
4
  import { api } from "../utils/api";
5
5
  import { tg } from "../i18n/translate";
6
6
  import { useAuth } from "./AuthContext";
@@ -14,6 +14,8 @@ import {
14
14
  generateUUID,
15
15
  reduceMessagesForEvent,
16
16
  } from "./messageReducer";
17
+ import { reduceAgentsForEvent } from "./agentsReducer";
18
+ import { reduceTraceForEvent } from "./traceReducer";
17
19
 
18
20
  export interface AgentMessageFilter {
19
21
  hideMessages: boolean;
@@ -37,7 +39,32 @@ interface SessionContextValue {
37
39
  error: string | null;
38
40
  currentView: "chat" | "agents" | "trace";
39
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;
40
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
+ /**
59
+ * #134 — whether the current session has trace updates the user hasn't seen
60
+ * since last opening the Trace view. Drives a quiet dot on the Trace tab so
61
+ * trace stays a transparency layer instead of noisy chat output. Per-session:
62
+ * switching sessions reflects that session's unread state. False on initial
63
+ * hydration (only live post-open trace_node events set it).
64
+ */
65
+ traceUnread: boolean;
66
+ /** Re-seed the trace graph from the HTTP route (manual refresh). */
67
+ refreshTrace: (sessionId: string) => Promise<void>;
41
68
  selectSession: (sessionId: string) => void;
42
69
  createSession: (title?: string, opts?: { providerId?: string; modelId?: string }) => Promise<Session | null>;
43
70
  /**
@@ -48,7 +75,9 @@ interface SessionContextValue {
48
75
  startDraftSession: () => void;
49
76
  updateSessionTitle: (sessionId: string, title: string) => Promise<void>;
50
77
  deleteSession: (sessionId: string) => Promise<void>;
51
- sendPrompt: (content: string, opts?: { providerId?: string; modelId?: string }) => Promise<void>;
78
+ /** Resolves true when the message was accepted, false on validation/timeout/
79
+ * error — the composer uses this to restore the draft so input isn't lost. */
80
+ sendPrompt: (content: string, opts?: { providerId?: string; modelId?: string }) => Promise<boolean>;
52
81
  interruptCurrent: () => Promise<void>;
53
82
  /**
54
83
  * 修正6 — answer an ask_user (user_input_request) card. Optimistically
@@ -68,6 +97,82 @@ interface SessionContextValue {
68
97
  export const DRAFT_SESSION_ID = "__draft__";
69
98
 
70
99
  const SessionContext = createContext<SessionContextValue | null>(null);
100
+ // Runtime treats `limit=0` as "return the full persisted event log". Rehydrate
101
+ // must not use a fixed tail size because slicing through TEXT_MESSAGE_START /
102
+ // CONTENT / END leaves old long sessions looking empty.
103
+ const HISTORY_REHYDRATE_LIMIT = 0;
104
+
105
+ function foldSessionHistory(events: unknown[], sessionId: string): {
106
+ messages: ChatMessage[];
107
+ trace: TraceGraph | null;
108
+ agents: AgentStatus[] | null;
109
+ tokenUsage: SessionTokenUsage | null;
110
+ } {
111
+ let messages: ChatMessage[] = [];
112
+ let trace: TraceGraph | null = null;
113
+ let lastAgents: AgentStatus[] | null = null;
114
+ let lastUsage: SessionTokenUsage | null = null;
115
+
116
+ for (const raw of events) {
117
+ const ev = normalizeWebSocketEvent(raw);
118
+ if (!ev) continue;
119
+ messages = reduceMessagesForEvent(messages, ev);
120
+ trace = reduceTraceForEvent(trace, ev, sessionId);
121
+ if (ev.type === "CUSTOM" && ev.name === "session_state") {
122
+ const v = (ev.value ?? {}) as Record<string, unknown>;
123
+ if (Array.isArray(v.agents)) {
124
+ lastAgents = (v.agents as Array<Record<string, unknown>>).map((agent) => ({
125
+ name: String(agent.name ?? ""),
126
+ status: String(agent.status ?? "idle"),
127
+ task: String(agent.task ?? ""),
128
+ updatedAt:
129
+ typeof agent.updatedAt === "string"
130
+ ? agent.updatedAt
131
+ : new Date().toISOString(),
132
+ alive: typeof agent.alive === "boolean" ? agent.alive : undefined,
133
+ }));
134
+ }
135
+ const usage = parseTokenUsageValue(v.tokenUsage);
136
+ if (usage) lastUsage = usage;
137
+ }
138
+ }
139
+
140
+ return { messages, trace, agents: lastAgents, tokenUsage: lastUsage };
141
+ }
142
+
143
+ /**
144
+ * Coerce a wire `session_state.tokenUsage` value into SessionTokenUsage, or
145
+ * null when absent/malformed. Mirrors `normalizeSessionTokenUsage` in the
146
+ * backend contract but works off the already-shaped CUSTOM event value.
147
+ */
148
+ function parseTokenUsageValue(rawValue: unknown): SessionTokenUsage | null {
149
+ if (rawValue == null || typeof rawValue !== "object") return null;
150
+ const raw = rawValue as Record<string, unknown>;
151
+ const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
152
+ const one = (x: unknown) => {
153
+ const u = (x ?? {}) as Record<string, unknown>;
154
+ return {
155
+ input: num(u.input),
156
+ output: num(u.output),
157
+ cacheRead: num(u.cacheRead),
158
+ cacheWrite: num(u.cacheWrite),
159
+ total: num(u.total),
160
+ };
161
+ };
162
+ const byAgentRaw = (raw.byAgent ?? {}) as Record<string, unknown>;
163
+ const byAgent: SessionTokenUsage["byAgent"] = {};
164
+ for (const [name, value] of Object.entries(byAgentRaw)) byAgent[name] = one(value);
165
+ return { total: one(raw.total), byAgent };
166
+ }
167
+
168
+ function defaultAgentFilter(agentName: string): AgentMessageFilter {
169
+ const isTrace = agentName === "trace";
170
+ return {
171
+ hideMessages: isTrace,
172
+ hideTools: isTrace,
173
+ hideHooks: true,
174
+ };
175
+ }
71
176
 
72
177
  export function SessionProvider({ children }: { children: ReactNode }) {
73
178
  const { isAuthReady } = useAuth();
@@ -88,12 +193,35 @@ export function SessionProvider({ children }: { children: ReactNode }) {
88
193
  const [isRefreshingMessages, setIsRefreshingMessages] = useState(false);
89
194
  const [error, setError] = useState<string | null>(null);
90
195
  const [currentView, setCurrentView] = useState<"chat" | "agents" | "trace">("chat");
196
+ // #134 — read currentView inside the SSE queue-drain effect (keyed on
197
+ // session/tick, not view) to decide whether an incoming trace update should
198
+ // raise the unread dot. A live trace_node that arrives while the user is
199
+ // already on the Trace view is "seen", so it must not flag unread.
200
+ const currentViewRef = useRef<"chat" | "agents" | "trace">("chat");
201
+ useEffect(() => {
202
+ currentViewRef.current = currentView;
203
+ }, [currentView]);
91
204
  // Unsent textarea drafts live in a module-level store (see contexts/draftStore.ts)
92
205
  // so keystrokes don't re-render the whole chat subtree. Drafts are keyed by
93
206
  // session id and survive PromptComposer unmount (tab switches).
94
207
  const [agents, setAgents] = useState<AgentStatus[]>([]);
208
+ // #99: authoritative whole-turn run-active flag from session_state.runState
209
+ // (derived by the runtime: trace agent excluded, delivery loops included),
210
+ // paired with the backend ISO timestamp of the snapshot that carried it. The
211
+ // turn timer keys off transitions of this flag, not the per-agent list.
212
+ const [runActive, setRunActive] = useState<{ active: boolean; atMs: number } | null>(null);
213
+ // Cumulative real token usage for the current session, fed from session_state.
214
+ const [tokenUsage, setTokenUsage] = useState<SessionTokenUsage | null>(null);
95
215
  const [agentFilters, setAgentFilters] = useState<Record<string, AgentMessageFilter>>({});
96
216
  const [messageFilters, setMessageFilters] = useState<MessageFilterRule[]>(defaultFilterRules);
217
+ // #79: live Graph of Trace per session. Seeded by a fetch on session change,
218
+ // then kept live by CUSTOM:trace_node SSE events (see the queue drain below).
219
+ const [traceBySession, setTraceBySession] = useState<Record<string, TraceGraph>>({});
220
+ // #134 — per-session "trace changed since you last looked" flag. Set only by
221
+ // live CUSTOM:trace_node events while the user is NOT on the Trace view;
222
+ // cleared when they open Trace for that session. Hydration/seed paths never
223
+ // set it, so a freshly-opened session with existing trace shows no false dot.
224
+ const [traceUnreadBySession, setTraceUnreadBySession] = useState<Record<string, boolean>>({});
97
225
 
98
226
 
99
227
  const currentSession = useMemo(
@@ -133,23 +261,76 @@ export function SessionProvider({ children }: { children: ReactNode }) {
133
261
  void refreshSessions();
134
262
  }, [refreshSessions]);
135
263
 
264
+ // Sessions whose persisted history has already been pulled this page-load.
265
+ // Guards against re-hydrating on every tab switch — once SSE is attached we
266
+ // own the up-to-date state and don't want to refetch the .jsonl tail.
267
+ const hydratedSessionsRef = useRef<Set<string>>(new Set());
268
+
136
269
  useEffect(() => {
137
270
  if (!currentSessionId) {
138
271
  return;
139
272
  }
140
273
  const sessionId = currentSessionId;
274
+ if (hydratedSessionsRef.current.has(sessionId)) {
275
+ return;
276
+ }
141
277
  let cancelled = false;
142
- async function loadMessages() {
278
+
279
+ async function loadHistory() {
143
280
  setIsRefreshingMessages(true);
144
- if (!cancelled) {
145
- setMessagesBySession((current) => ({ ...current, [sessionId]: current[sessionId] ?? [] }));
281
+ try {
282
+ const { events } = await api.sessions.getHistory(sessionId, { limit: HISTORY_REHYDRATE_LIMIT });
283
+ if (cancelled) return;
284
+
285
+ // Replay the persisted event stream through the same reducers SSE uses
286
+ // (messageReducer / traceReducer / agents seed via session_state). The
287
+ // SSE ring buffer that arrives next is deduped by messageId/toolCallId
288
+ // inside the reducer, so any overlap is a no-op.
289
+ const { messages: nextMessages, trace: nextTrace, agents: lastAgents, tokenUsage: lastUsage } =
290
+ foldSessionHistory(events, sessionId);
291
+
292
+ if (cancelled) return;
293
+ hydratedSessionsRef.current.add(sessionId);
294
+ if (lastUsage) setTokenUsage(lastUsage);
295
+
296
+ // Only seed the message list if the user hasn't already started typing
297
+ // / receiving live SSE for this session in the brief window before
298
+ // history arrived (otherwise we'd clobber their in-flight messages).
299
+ setMessagesBySession((current) => {
300
+ if ((current[sessionId]?.length ?? 0) > 0) return current;
301
+ return { ...current, [sessionId]: nextMessages };
302
+ });
303
+ if (nextTrace) {
304
+ setTraceBySession((current) =>
305
+ current[sessionId] ? current : { ...current, [sessionId]: nextTrace! },
306
+ );
307
+ }
308
+ // Only seed agents when the current panel is empty — live SSE may have
309
+ // already pushed an authoritative session_state since selection.
310
+ if (lastAgents && lastAgents.length > 0) {
311
+ setAgents((current) => (current.length === 0 ? lastAgents! : current));
312
+ setAgentFilters((current) => {
313
+ let changed = false;
314
+ const next = { ...current };
315
+ for (const agent of lastAgents) {
316
+ if (!next[agent.name]) {
317
+ changed = true;
318
+ next[agent.name] = defaultAgentFilter(agent.name);
319
+ }
320
+ }
321
+ return changed ? next : current;
322
+ });
323
+ }
324
+ } catch (err) {
325
+ // Best-effort. SSE will eventually drive the panel; we shouldn't surface
326
+ // a banner just because the history file was unreachable for a moment.
327
+ console.warn(`[SessionContext] history rehydrate failed for ${sessionId}:`, err);
328
+ } finally {
329
+ if (!cancelled) setIsRefreshingMessages(false);
146
330
  }
147
- setIsRefreshingMessages(false);
148
331
  }
149
- void loadMessages();
150
- return () => {
151
- cancelled = true;
152
- };
332
+
333
+ void loadHistory();
153
334
  return () => {
154
335
  cancelled = true;
155
336
  };
@@ -160,6 +341,8 @@ export function SessionProvider({ children }: { children: ReactNode }) {
160
341
  setIsDraft(false);
161
342
  setCurrentSessionId(sessionId);
162
343
  setCurrentView("chat");
344
+ setRunActive(null); // #99: drop the previous session's turn-active signal
345
+ setTokenUsage(null); // drop the previous session's token totals
163
346
  connectSession(sessionId);
164
347
  }, [connectSession]);
165
348
 
@@ -204,6 +387,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
204
387
  // Drop any unsent draft so re-creating a session with the same id (rare,
205
388
  // but possible) doesn't resurrect stale text.
206
389
  draftStore.delete(sessionId);
390
+ hydratedSessionsRef.current.delete(sessionId);
207
391
  } catch (err) {
208
392
  setError(err instanceof Error ? err.message : tg("ctx.session.deleteFailed"));
209
393
  throw err;
@@ -247,26 +431,30 @@ export function SessionProvider({ children }: { children: ReactNode }) {
247
431
  const trimmed = content.trim();
248
432
  console.log(`[SessionContext] sendPrompt: "${trimmed.slice(0, 40)}...", isConnected=${isConnected}, isDraft=${isDraft}`);
249
433
  if (!trimmed) {
250
- return;
434
+ return false;
251
435
  }
252
436
  // A draft has no SSE connection yet — the session is created and
253
437
  // connected below. Only block on connection for an already-persisted
254
438
  // session.
255
439
  if (!currentSession && !isDraft) {
256
440
  setError(tg("ctx.session.noConnection"));
257
- return;
441
+ return false;
258
442
  }
259
443
  if (currentSession && !isConnected) {
260
444
  setError(tg("ctx.session.noConnection"));
261
- return;
445
+ return false;
262
446
  }
263
447
 
264
448
  setIsSending(true);
265
449
  setError(null);
450
+ // Tracked so we can roll back the optimistic user message if the post
451
+ // fails/times out (#106) — otherwise the bubble lingers and a retry
452
+ // duplicates it.
453
+ let optimistic: { sessionId: string; messageId: string } | null = null;
266
454
  try {
267
455
  const session = currentSession ?? (await createSession(trimmed.slice(0, 48), opts));
268
456
  if (!session) {
269
- return;
457
+ return false;
270
458
  }
271
459
  // Freshly created (draft → persisted): open the SSE stream so the
272
460
  // assistant's streamed reply is received.
@@ -286,6 +474,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
286
474
  createdAt: timestamp,
287
475
  agent: "user",
288
476
  };
477
+ optimistic = { sessionId: session.id, messageId: uuid };
289
478
  setMessagesBySession((current) => ({
290
479
  ...current,
291
480
  [session.id]: [...(current[session.id] ?? []), userMessage],
@@ -293,9 +482,33 @@ export function SessionProvider({ children }: { children: ReactNode }) {
293
482
  console.log(`[SessionContext] posting message to ${session.id}`);
294
483
  await api.sessions.postMessage(session.id, { content: trimmed, uuid, timestamp });
295
484
  console.log(`[SessionContext] postMessage success`);
485
+ return true;
296
486
  } catch (err) {
297
487
  console.error(`[SessionContext] sendPrompt error:`, err);
298
- setError(err instanceof Error ? err.message : tg("ctx.session.sendFailed"));
488
+ // #106: roll back the optimistic bubble so the failed send doesn't
489
+ // leave a ghost message (and a retry doesn't duplicate it).
490
+ if (optimistic) {
491
+ const { sessionId: sid, messageId } = optimistic;
492
+ setMessagesBySession((current) => ({
493
+ ...current,
494
+ [sid]: (current[sid] ?? []).filter((m) => m.id !== messageId),
495
+ }));
496
+ }
497
+ // AbortSignal.timeout() rejects with a TimeoutError (and a hard abort
498
+ // with AbortError). Surface a clear, retryable message instead of the
499
+ // raw DOMException text, and let `finally` release isSending so the
500
+ // composer never stays stuck on "正在准备发送".
501
+ const isTimeout =
502
+ err instanceof DOMException &&
503
+ (err.name === "TimeoutError" || err.name === "AbortError");
504
+ setError(
505
+ isTimeout
506
+ ? tg("ctx.session.sendTimeout")
507
+ : err instanceof Error
508
+ ? err.message
509
+ : tg("ctx.session.sendFailed"),
510
+ );
511
+ return false;
299
512
  } finally {
300
513
  setIsSending(false);
301
514
  }
@@ -307,24 +520,34 @@ export function SessionProvider({ children }: { children: ReactNode }) {
307
520
  async () => {
308
521
  if (!currentSession) return;
309
522
  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
- );
523
+ // #90: NOT optimistic. Wait for the interrupt to actually land before
524
+ // touching the UI the old code optimistically forced every agent idle
525
+ // and inserted a "stopped" message even when the request hit the wrong
526
+ // endpoint and failed, permanently masking the failure while the runtime
527
+ // kept the agent running. We never speculatively mutate agent state now, so
528
+ // a failed interrupt leaves the true (still-running) state visible via the
529
+ // authoritative SSE session_state stream.
326
530
  try {
327
- await api.sessions.interrupt(sid);
531
+ const { interrupted } = await api.sessions.interrupt(sid);
532
+ if (!interrupted) {
533
+ // Nothing was running to interrupt (or the session is gone). Surface it
534
+ // rather than pretending the task stopped.
535
+ setError(tg("ctx.session.interruptFailed"));
536
+ return;
537
+ }
538
+ // Confirmed stopped: insert the status line. Agent state (incl. the PI
539
+ // interrupt-acknowledgement run the runtime now fires) flows in via SSE.
540
+ setMessagesBySession((current) => {
541
+ const msgs = current[sid] ?? [];
542
+ const stoppedMsg: ChatMessage = {
543
+ id: generateUUID(),
544
+ role: "system",
545
+ content: tg("chat.stoppedByUser"),
546
+ createdAt: new Date().toISOString(),
547
+ kind: "status",
548
+ };
549
+ return { ...current, [sid]: [...finalizeAssistant(msgs), stoppedMsg] };
550
+ });
328
551
  } catch (err) {
329
552
  setError(err instanceof Error ? err.message : tg("ctx.session.interruptFailed"));
330
553
  }
@@ -362,15 +585,40 @@ export function SessionProvider({ children }: { children: ReactNode }) {
362
585
  if (!currentSession) {
363
586
  return;
364
587
  }
365
- console.log(`[SessionContext] refreshMessages: ${currentSession.id}`);
588
+ console.log(`[SessionContext] refreshMessages: ${currentSession.id}`);
366
589
  setIsRefreshingMessages(true);
367
590
  setError(null);
368
591
  try {
369
- // Re-connect the SSE stream the server will emit a fresh
370
- // MESSAGES_SNAPSHOT as the first frame, replacing the local cache.
592
+ const { events } = await api.sessions.getHistory(currentSession.id, {
593
+ limit: HISTORY_REHYDRATE_LIMIT,
594
+ });
595
+ const { messages: nextMessages, trace: nextTrace, agents: nextAgents, tokenUsage: nextUsage } =
596
+ foldSessionHistory(events, currentSession.id);
597
+
598
+ if (nextUsage) setTokenUsage(nextUsage);
599
+ setMessagesBySession((current) => ({ ...current, [currentSession.id]: nextMessages }));
600
+ if (nextTrace) {
601
+ setTraceBySession((current) => ({ ...current, [currentSession.id]: nextTrace }));
602
+ }
603
+ if (nextAgents && nextAgents.length > 0) {
604
+ setAgents(nextAgents);
605
+ setAgentFilters((current) => {
606
+ let changed = false;
607
+ const next = { ...current };
608
+ for (const agent of nextAgents) {
609
+ if (!next[agent.name]) {
610
+ changed = true;
611
+ next[agent.name] = defaultAgentFilter(agent.name);
612
+ }
613
+ }
614
+ return changed ? next : current;
615
+ });
616
+ }
617
+ hydratedSessionsRef.current.add(currentSession.id);
618
+
619
+ // Re-connect the SSE stream so live updates continue after the disk
620
+ // history has refreshed the local cache.
371
621
  disconnectSession(currentSession.id);
372
- // Clear local state so the snapshot becomes the authoritative source.
373
- setMessagesBySession((current) => ({ ...current, [currentSession.id]: [] }));
374
622
  connectSession(currentSession.id);
375
623
  } catch (err) {
376
624
  setError(err instanceof Error ? err.message : tg("ctx.session.refreshFailed"));
@@ -405,6 +653,23 @@ export function SessionProvider({ children }: { children: ReactNode }) {
405
653
  };
406
654
  }, [currentSession?.id]);
407
655
 
656
+ // #79: seed the Graph of Trace once per session. Live updates thereafter
657
+ // arrive incrementally via CUSTOM:trace_node (queue drain below), so no poll.
658
+ const refreshTrace = useCallback(async (sessionId: string) => {
659
+ try {
660
+ const graph = await api.sessions.getTrace(sessionId);
661
+ setTraceBySession((current) => ({ ...current, [sessionId]: graph }));
662
+ } catch {
663
+ // Non-fatal — the panel shows an empty graph until events arrive.
664
+ }
665
+ }, []);
666
+
667
+ useEffect(() => {
668
+ const sid = currentSession?.id;
669
+ if (!sid) return;
670
+ void refreshTrace(sid);
671
+ }, [currentSession?.id, refreshTrace]);
672
+
408
673
  // Auto-connect SSE whenever the current session changes. Other connections
409
674
  // are kept open so background sessions continue receiving events.
410
675
  useEffect(() => {
@@ -461,6 +726,18 @@ export function SessionProvider({ children }: { children: ReactNode }) {
461
726
  alive: typeof agent.alive === "boolean" ? agent.alive : undefined,
462
727
  }));
463
728
  setAgents(nextAgents);
729
+ // #99: feed the whole-turn timer. runState.active is the authoritative
730
+ // "the user's task is still running" flag; lastActivityTs is the backend
731
+ // timestamp of this snapshot (fallback to now() if absent).
732
+ const rs = (value.runState ?? {}) as Record<string, unknown>;
733
+ const tsRaw = typeof value.lastActivityTs === "string" ? Date.parse(value.lastActivityTs) : NaN;
734
+ setRunActive({
735
+ active: rs.active === true,
736
+ atMs: Number.isNaN(tsRaw) ? Date.now() : tsRaw,
737
+ });
738
+ // Cumulative real token usage rides on the same frame (optional).
739
+ const usage = parseTokenUsageValue(value.tokenUsage);
740
+ if (usage) setTokenUsage(usage);
464
741
  // Apply default filters for newly discovered agents:
465
742
  // - trace agent: hide messages and tools by default
466
743
  // - all agents: hide hooks by default
@@ -471,12 +748,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
471
748
  const existing = current[agent.name];
472
749
  if (!existing) {
473
750
  changed = true;
474
- const isTrace = agent.name === "trace";
475
- next[agent.name] = {
476
- hideMessages: isTrace,
477
- hideTools: isTrace,
478
- hideHooks: true,
479
- };
751
+ next[agent.name] = defaultAgentFilter(agent.name);
480
752
  }
481
753
  }
482
754
  return changed ? next : current;
@@ -507,6 +779,63 @@ export function SessionProvider({ children }: { children: ReactNode }) {
507
779
  }
508
780
  }
509
781
 
782
+ // #70: keep the Agents panel live between session_state snapshots by merging
783
+ // standalone agent_status_update events. session_state (above) remains the
784
+ // wholesale authority; this applies single-agent deltas on top so the panel
785
+ // updates on the first run without a reload/reselect. The queue is already
786
+ // scoped to the current session (queueRef.get(sid)), so background-session
787
+ // events don't leak in here.
788
+ setAgents((current) => {
789
+ let nextAgents = current;
790
+ const appended: AgentStatus[] = [];
791
+ for (const event of queue) {
792
+ if (event.type !== "agent_status_update") continue;
793
+ const before = nextAgents;
794
+ nextAgents = reduceAgentsForEvent(nextAgents, event);
795
+ if (nextAgents.length > before.length) {
796
+ appended.push(nextAgents[nextAgents.length - 1]!);
797
+ }
798
+ }
799
+ if (appended.length > 0) {
800
+ // Apply the same default filters session_state uses for newly seen
801
+ // agents (trace: hide messages/tools; all: hide hooks).
802
+ setAgentFilters((cur) => {
803
+ let changed = false;
804
+ const nf = { ...cur };
805
+ for (const agent of appended) {
806
+ if (!cur[agent.name]) {
807
+ changed = true;
808
+ nf[agent.name] = defaultAgentFilter(agent.name);
809
+ }
810
+ }
811
+ return changed ? nf : cur;
812
+ });
813
+ }
814
+ return nextAgents;
815
+ });
816
+
817
+ // #79: fold CUSTOM:trace_node events into the live Graph of Trace. The queue
818
+ // is already scoped to the current session, so only this session's graph
819
+ // updates. reduceTraceForEvent ignores non-trace events (same reference).
820
+ setTraceBySession((current) => {
821
+ const start = current[sid] ?? null;
822
+ let graph: TraceGraph | null = start;
823
+ for (const event of queue) {
824
+ graph = reduceTraceForEvent(graph, event, sid);
825
+ }
826
+ if (graph && graph !== start) {
827
+ // #134 — a live trace update landed. Raise the per-session unread dot
828
+ // unless the user is already looking at this session's Trace view (then
829
+ // it's seen). Hydration/seed go through other code paths, so this only
830
+ // ever fires for genuine post-open SSE trace_node events.
831
+ if (currentViewRef.current !== "trace") {
832
+ setTraceUnreadBySession((u) => (u[sid] ? u : { ...u, [sid]: true }));
833
+ }
834
+ return { ...current, [sid]: graph };
835
+ }
836
+ return current;
837
+ });
838
+
510
839
  // Process all events through the message reducer.
511
840
  setMessagesBySession((current) => {
512
841
  let messages = current[sid] ?? [];
@@ -539,6 +868,21 @@ export function SessionProvider({ children }: { children: ReactNode }) {
539
868
  [],
540
869
  );
541
870
 
871
+ const currentTrace = useMemo(
872
+ () => (currentSessionId ? (traceBySession[currentSessionId] ?? null) : null),
873
+ [currentSessionId, traceBySession],
874
+ );
875
+
876
+ // #134 — opening the Trace view for a session marks its trace as seen, so the
877
+ // tab dot clears. Keyed on (session, view) so it also clears when a trace
878
+ // update arrives while already on the view (the drain guards that case too).
879
+ useEffect(() => {
880
+ if (currentView !== "trace" || !currentSessionId) return;
881
+ setTraceUnreadBySession((u) => (u[currentSessionId] ? { ...u, [currentSessionId]: false } : u));
882
+ }, [currentView, currentSessionId]);
883
+
884
+ const traceUnread = currentSessionId ? (traceUnreadBySession[currentSessionId] ?? false) : false;
885
+
542
886
  const value = useMemo(
543
887
  () => ({
544
888
  sessions,
@@ -552,7 +896,12 @@ export function SessionProvider({ children }: { children: ReactNode }) {
552
896
  error,
553
897
  currentView,
554
898
  agents,
899
+ runActive,
900
+ tokenUsage,
555
901
  agentFilters,
902
+ currentTrace,
903
+ traceUnread,
904
+ refreshTrace,
556
905
  selectSession,
557
906
  createSession,
558
907
  startDraftSession,
@@ -579,7 +928,12 @@ export function SessionProvider({ children }: { children: ReactNode }) {
579
928
  error,
580
929
  currentView,
581
930
  agents,
931
+ runActive,
932
+ tokenUsage,
582
933
  agentFilters,
934
+ currentTrace,
935
+ traceUnread,
936
+ refreshTrace,
583
937
  selectSession,
584
938
  createSession,
585
939
  startDraftSession,
@@ -0,0 +1,49 @@
1
+ import type { AgentStatus, WebSocketEvent } from "../contracts/backend";
2
+
3
+ /**
4
+ * #70: merge a single `agent_status_update` event into the Agents-panel list.
5
+ *
6
+ * The runtime emits `agent_status_update` on every agent status transition
7
+ * (idle/running/error/stopped). The panel's authoritative source is the
8
+ * wholesale `CUSTOM:session_state` snapshot, but those arrive only on
9
+ * transitions too — between them, and on the very first run, this incremental
10
+ * merge keeps the panel live without a reload/reselect.
11
+ *
12
+ * Merge rules:
13
+ * - non-`agent_status_update` events return the same reference (no-op);
14
+ * - the agent key is the event's top-level `name` (schema field; camelize
15
+ * leaves the single word `name`/`status` untouched);
16
+ * - a name not in the list is appended (task="" — the event carries no task);
17
+ * - a known name has its status/updatedAt/alive updated but its **task
18
+ * preserved** (only session_state / a /state poll carry task);
19
+ * - a no-op status change returns the same reference to avoid a re-render;
20
+ * - `retrying`/`auto_retry*` is normalized to `running` so the panel never
21
+ * shows a non-canonical status (the auto-retry chat card is produced
22
+ * independently by the message reducer).
23
+ */
24
+ const RETRY_STATUSES = new Set(["retrying", "auto_retry", "auto_retry_start"]);
25
+
26
+ export function reduceAgentsForEvent(
27
+ agents: AgentStatus[],
28
+ event: WebSocketEvent,
29
+ ): AgentStatus[] {
30
+ const e = event as Record<string, unknown>;
31
+ if (e.type !== "agent_status_update") return agents;
32
+
33
+ const name = String(e.name ?? "");
34
+ if (!name) return agents;
35
+
36
+ const rawStatus = String(e.status ?? "idle");
37
+ const status = RETRY_STATUSES.has(rawStatus.toLowerCase()) ? "running" : rawStatus;
38
+ const updatedAt = typeof e.updatedAt === "string" ? e.updatedAt : new Date().toISOString();
39
+ const alive = status !== "stopped";
40
+
41
+ const idx = agents.findIndex((a) => a.name === name);
42
+ if (idx === -1) {
43
+ return [...agents, { name, status, task: "", updatedAt, alive }];
44
+ }
45
+ if (agents[idx]!.status === status) return agents; // no-op → stable reference
46
+ const next = agents.slice();
47
+ next[idx] = { ...agents[idx]!, status, updatedAt, alive };
48
+ return next;
49
+ }