@brainpilot/web 0.0.5 → 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 (52) 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/package.json +5 -2
  5. package/src/__tests__/agentsReducer.test.ts +67 -0
  6. package/src/__tests__/api.test.ts +118 -0
  7. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  8. package/src/__tests__/demoConversation.test.ts +73 -0
  9. package/src/__tests__/demoReset.test.ts +24 -0
  10. package/src/__tests__/runningToast.test.ts +29 -0
  11. package/src/__tests__/tokenUsage.test.ts +48 -0
  12. package/src/__tests__/toolDisplay.test.ts +55 -0
  13. package/src/__tests__/traceReducer.test.ts +62 -0
  14. package/src/components/chat/MessageStream.tsx +97 -56
  15. package/src/components/chat/PromptComposer.tsx +120 -29
  16. package/src/components/chat/chatScrollMemory.ts +49 -0
  17. package/src/components/demo/DemoView.tsx +91 -29
  18. package/src/components/demo/TraceNodeModal.tsx +6 -2
  19. package/src/components/demo/demoBundle.ts +7 -2
  20. package/src/components/demo/demoReset.ts +16 -0
  21. package/src/components/session/AgentNetwork.tsx +68 -75
  22. package/src/components/session/AgentTraceViews.tsx +35 -70
  23. package/src/components/session/AnalyticsTab.tsx +58 -224
  24. package/src/components/session/TraceGraphView.tsx +36 -30
  25. package/src/components/session/TraceNodeDetail.tsx +61 -24
  26. package/src/components/session/agentNetworkShared.ts +10 -0
  27. package/src/components/session/traceLayout.ts +32 -0
  28. package/src/components/settings/SettingsDialog.tsx +19 -1
  29. package/src/components/shell/DesktopShell.tsx +39 -14
  30. package/src/components/sidebar/Sidebar.tsx +6 -2
  31. package/src/contexts/SSEContext.tsx +90 -1
  32. package/src/contexts/SessionContext.tsx +354 -43
  33. package/src/contexts/agentsReducer.ts +49 -0
  34. package/src/contexts/runningToast.ts +33 -0
  35. package/src/contexts/traceReducer.ts +62 -0
  36. package/src/contexts/turnTimer.test.ts +97 -0
  37. package/src/contexts/turnTimer.ts +108 -0
  38. package/src/contexts/useTurnTimer.ts +104 -0
  39. package/src/contracts/backend.ts +53 -2
  40. package/src/i18n/messages/analytics.ts +16 -6
  41. package/src/i18n/messages/chat.ts +26 -4
  42. package/src/i18n/messages/contexts.ts +2 -0
  43. package/src/i18n/messages/network.ts +13 -9
  44. package/src/i18n/messages/profile.ts +4 -0
  45. package/src/i18n/messages/settings.ts +4 -0
  46. package/src/i18n/messages/trace.ts +69 -17
  47. package/src/mocks/backend.ts +7 -0
  48. package/src/styles/global.css +204 -55
  49. package/src/utils/api.ts +105 -8
  50. package/src/utils/toolDisplay.ts +74 -0
  51. package/dist/assets/index-C-8G4D4j.js +0 -448
  52. 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,24 @@ 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
+ /** Re-seed the trace graph from the HTTP route (manual refresh). */
59
+ refreshTrace: (sessionId: string) => Promise<void>;
41
60
  selectSession: (sessionId: string) => void;
42
61
  createSession: (title?: string, opts?: { providerId?: string; modelId?: string }) => Promise<Session | null>;
43
62
  /**
@@ -48,7 +67,9 @@ interface SessionContextValue {
48
67
  startDraftSession: () => void;
49
68
  updateSessionTitle: (sessionId: string, title: string) => Promise<void>;
50
69
  deleteSession: (sessionId: string) => Promise<void>;
51
- sendPrompt: (content: string, opts?: { providerId?: string; modelId?: 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>;
52
73
  interruptCurrent: () => Promise<void>;
53
74
  /**
54
75
  * 修正6 — answer an ask_user (user_input_request) card. Optimistically
@@ -68,6 +89,82 @@ interface SessionContextValue {
68
89
  export const DRAFT_SESSION_ID = "__draft__";
69
90
 
70
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
+ }
71
168
 
72
169
  export function SessionProvider({ children }: { children: ReactNode }) {
73
170
  const { isAuthReady } = useAuth();
@@ -92,8 +189,18 @@ export function SessionProvider({ children }: { children: ReactNode }) {
92
189
  // so keystrokes don't re-render the whole chat subtree. Drafts are keyed by
93
190
  // session id and survive PromptComposer unmount (tab switches).
94
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);
95
199
  const [agentFilters, setAgentFilters] = useState<Record<string, AgentMessageFilter>>({});
96
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>>({});
97
204
 
98
205
 
99
206
  const currentSession = useMemo(
@@ -133,23 +240,76 @@ export function SessionProvider({ children }: { children: ReactNode }) {
133
240
  void refreshSessions();
134
241
  }, [refreshSessions]);
135
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
+
136
248
  useEffect(() => {
137
249
  if (!currentSessionId) {
138
250
  return;
139
251
  }
140
252
  const sessionId = currentSessionId;
253
+ if (hydratedSessionsRef.current.has(sessionId)) {
254
+ return;
255
+ }
141
256
  let cancelled = false;
142
- async function loadMessages() {
257
+
258
+ async function loadHistory() {
143
259
  setIsRefreshingMessages(true);
144
- if (!cancelled) {
145
- setMessagesBySession((current) => ({ ...current, [sessionId]: current[sessionId] ?? [] }));
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);
146
309
  }
147
- setIsRefreshingMessages(false);
148
310
  }
149
- void loadMessages();
150
- return () => {
151
- cancelled = true;
152
- };
311
+
312
+ void loadHistory();
153
313
  return () => {
154
314
  cancelled = true;
155
315
  };
@@ -160,6 +320,8 @@ export function SessionProvider({ children }: { children: ReactNode }) {
160
320
  setIsDraft(false);
161
321
  setCurrentSessionId(sessionId);
162
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
163
325
  connectSession(sessionId);
164
326
  }, [connectSession]);
165
327
 
@@ -204,6 +366,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
204
366
  // Drop any unsent draft so re-creating a session with the same id (rare,
205
367
  // but possible) doesn't resurrect stale text.
206
368
  draftStore.delete(sessionId);
369
+ hydratedSessionsRef.current.delete(sessionId);
207
370
  } catch (err) {
208
371
  setError(err instanceof Error ? err.message : tg("ctx.session.deleteFailed"));
209
372
  throw err;
@@ -247,26 +410,30 @@ export function SessionProvider({ children }: { children: ReactNode }) {
247
410
  const trimmed = content.trim();
248
411
  console.log(`[SessionContext] sendPrompt: "${trimmed.slice(0, 40)}...", isConnected=${isConnected}, isDraft=${isDraft}`);
249
412
  if (!trimmed) {
250
- return;
413
+ return false;
251
414
  }
252
415
  // A draft has no SSE connection yet — the session is created and
253
416
  // connected below. Only block on connection for an already-persisted
254
417
  // session.
255
418
  if (!currentSession && !isDraft) {
256
419
  setError(tg("ctx.session.noConnection"));
257
- return;
420
+ return false;
258
421
  }
259
422
  if (currentSession && !isConnected) {
260
423
  setError(tg("ctx.session.noConnection"));
261
- return;
424
+ return false;
262
425
  }
263
426
 
264
427
  setIsSending(true);
265
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;
266
433
  try {
267
434
  const session = currentSession ?? (await createSession(trimmed.slice(0, 48), opts));
268
435
  if (!session) {
269
- return;
436
+ return false;
270
437
  }
271
438
  // Freshly created (draft → persisted): open the SSE stream so the
272
439
  // assistant's streamed reply is received.
@@ -286,6 +453,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
286
453
  createdAt: timestamp,
287
454
  agent: "user",
288
455
  };
456
+ optimistic = { sessionId: session.id, messageId: uuid };
289
457
  setMessagesBySession((current) => ({
290
458
  ...current,
291
459
  [session.id]: [...(current[session.id] ?? []), userMessage],
@@ -293,9 +461,33 @@ export function SessionProvider({ children }: { children: ReactNode }) {
293
461
  console.log(`[SessionContext] posting message to ${session.id}`);
294
462
  await api.sessions.postMessage(session.id, { content: trimmed, uuid, timestamp });
295
463
  console.log(`[SessionContext] postMessage success`);
464
+ return true;
296
465
  } catch (err) {
297
466
  console.error(`[SessionContext] sendPrompt error:`, err);
298
- setError(err instanceof Error ? err.message : tg("ctx.session.sendFailed"));
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;
299
491
  } finally {
300
492
  setIsSending(false);
301
493
  }
@@ -307,24 +499,34 @@ export function SessionProvider({ children }: { children: ReactNode }) {
307
499
  async () => {
308
500
  if (!currentSession) return;
309
501
  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
- );
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.
326
509
  try {
327
- await api.sessions.interrupt(sid);
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
+ });
328
530
  } catch (err) {
329
531
  setError(err instanceof Error ? err.message : tg("ctx.session.interruptFailed"));
330
532
  }
@@ -362,15 +564,40 @@ export function SessionProvider({ children }: { children: ReactNode }) {
362
564
  if (!currentSession) {
363
565
  return;
364
566
  }
365
- console.log(`[SessionContext] refreshMessages: ${currentSession.id}`);
567
+ console.log(`[SessionContext] refreshMessages: ${currentSession.id}`);
366
568
  setIsRefreshingMessages(true);
367
569
  setError(null);
368
570
  try {
369
- // Re-connect the SSE stream the server will emit a fresh
370
- // MESSAGES_SNAPSHOT as the first frame, replacing the local cache.
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.
371
600
  disconnectSession(currentSession.id);
372
- // Clear local state so the snapshot becomes the authoritative source.
373
- setMessagesBySession((current) => ({ ...current, [currentSession.id]: [] }));
374
601
  connectSession(currentSession.id);
375
602
  } catch (err) {
376
603
  setError(err instanceof Error ? err.message : tg("ctx.session.refreshFailed"));
@@ -405,6 +632,23 @@ export function SessionProvider({ children }: { children: ReactNode }) {
405
632
  };
406
633
  }, [currentSession?.id]);
407
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
+
408
652
  // Auto-connect SSE whenever the current session changes. Other connections
409
653
  // are kept open so background sessions continue receiving events.
410
654
  useEffect(() => {
@@ -461,6 +705,18 @@ export function SessionProvider({ children }: { children: ReactNode }) {
461
705
  alive: typeof agent.alive === "boolean" ? agent.alive : undefined,
462
706
  }));
463
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);
464
720
  // Apply default filters for newly discovered agents:
465
721
  // - trace agent: hide messages and tools by default
466
722
  // - all agents: hide hooks by default
@@ -471,12 +727,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
471
727
  const existing = current[agent.name];
472
728
  if (!existing) {
473
729
  changed = true;
474
- const isTrace = agent.name === "trace";
475
- next[agent.name] = {
476
- hideMessages: isTrace,
477
- hideTools: isTrace,
478
- hideHooks: true,
479
- };
730
+ next[agent.name] = defaultAgentFilter(agent.name);
480
731
  }
481
732
  }
482
733
  return changed ? next : current;
@@ -507,6 +758,53 @@ export function SessionProvider({ children }: { children: ReactNode }) {
507
758
  }
508
759
  }
509
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
+
510
808
  // Process all events through the message reducer.
511
809
  setMessagesBySession((current) => {
512
810
  let messages = current[sid] ?? [];
@@ -539,6 +837,11 @@ export function SessionProvider({ children }: { children: ReactNode }) {
539
837
  [],
540
838
  );
541
839
 
840
+ const currentTrace = useMemo(
841
+ () => (currentSessionId ? (traceBySession[currentSessionId] ?? null) : null),
842
+ [currentSessionId, traceBySession],
843
+ );
844
+
542
845
  const value = useMemo(
543
846
  () => ({
544
847
  sessions,
@@ -552,7 +855,11 @@ export function SessionProvider({ children }: { children: ReactNode }) {
552
855
  error,
553
856
  currentView,
554
857
  agents,
858
+ runActive,
859
+ tokenUsage,
555
860
  agentFilters,
861
+ currentTrace,
862
+ refreshTrace,
556
863
  selectSession,
557
864
  createSession,
558
865
  startDraftSession,
@@ -579,7 +886,11 @@ export function SessionProvider({ children }: { children: ReactNode }) {
579
886
  error,
580
887
  currentView,
581
888
  agents,
889
+ runActive,
890
+ tokenUsage,
582
891
  agentFilters,
892
+ currentTrace,
893
+ refreshTrace,
583
894
  selectSession,
584
895
  createSession,
585
896
  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
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Pure helper for the "X 正在工作 / X is working" toast above the composer (#76).
3
+ *
4
+ * Selects which i18n key + vars the toast should render given the set of agents
5
+ * currently working. Kept pure (no React) so it's unit-testable without a DOM —
6
+ * the component just calls `t(key, vars)` with the result. The trace agent is
7
+ * excluded by the caller (it self-records continuously and isn't "the user's
8
+ * task"), matching the runtime's run-active aggregation.
9
+ */
10
+ export interface ToastLabel {
11
+ key: "chat.agentWorking" | "chat.agentsWorking" | "chat.agentThinking";
12
+ /** Interpolation vars; shape matches i18n `TranslateVars` (string|number). */
13
+ vars?: Record<string, string>;
14
+ }
15
+
16
+ /**
17
+ * @param workingAgentNames names of non-trace agents with status "running"
18
+ * @param separator locale-appropriate join for multiple names (default "、")
19
+ */
20
+ export function runningToastLabel(
21
+ workingAgentNames: readonly string[],
22
+ separator = "、",
23
+ ): ToastLabel {
24
+ if (workingAgentNames.length === 1) {
25
+ return { key: "chat.agentWorking", vars: { name: workingAgentNames[0]! } };
26
+ }
27
+ if (workingAgentNames.length > 1) {
28
+ return { key: "chat.agentsWorking", vars: { names: workingAgentNames.join(separator) } };
29
+ }
30
+ // Streaming but no named running agent yet (status not yet "running"): keep the
31
+ // generic label so the toast never renders blank.
32
+ return { key: "chat.agentThinking" };
33
+ }