@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.
- package/dist/assets/index-DWOsU22G.css +1 -0
- package/dist/assets/index-j3rGyO6m.js +445 -0
- package/dist/index.html +2 -2
- package/package.json +6 -3
- package/src/__tests__/agentsReducer.test.ts +67 -0
- package/src/__tests__/api.test.ts +118 -0
- package/src/__tests__/chatScrollBehavior.test.ts +48 -0
- package/src/__tests__/chatScrollMemory.test.ts +49 -0
- package/src/__tests__/demoConversation.test.ts +96 -0
- package/src/__tests__/demoReset.test.ts +24 -0
- package/src/__tests__/internalToolStrip.test.ts +108 -0
- package/src/__tests__/runningToast.test.ts +29 -0
- package/src/__tests__/tokenUsage.test.ts +48 -0
- package/src/__tests__/toolDisplay.test.ts +55 -0
- package/src/__tests__/traceReducer.test.ts +62 -0
- package/src/components/chat/MessageStream.tsx +104 -56
- package/src/components/chat/PromptComposer.tsx +120 -29
- package/src/components/chat/chatScrollMemory.ts +49 -0
- package/src/components/demo/DemoView.tsx +98 -29
- package/src/components/demo/TraceNodeModal.tsx +6 -2
- package/src/components/demo/demoBundle.ts +7 -2
- package/src/components/demo/demoReset.ts +16 -0
- package/src/components/session/AgentNetwork.tsx +68 -75
- package/src/components/session/AgentTraceViews.tsx +35 -70
- package/src/components/session/AnalyticsTab.tsx +58 -224
- package/src/components/session/TraceGraphView.tsx +36 -30
- package/src/components/session/TraceNodeDetail.tsx +61 -24
- package/src/components/session/agentNetworkShared.ts +10 -0
- package/src/components/session/traceLayout.ts +32 -0
- package/src/components/settings/SettingsDialog.tsx +19 -1
- package/src/components/shell/DesktopShell.tsx +72 -17
- package/src/components/sidebar/SessionList.tsx +127 -0
- package/src/components/sidebar/Sidebar.tsx +94 -98
- package/src/contexts/SSEContext.tsx +90 -1
- package/src/contexts/SessionContext.tsx +397 -43
- package/src/contexts/agentsReducer.ts +49 -0
- package/src/contexts/messageGroups.ts +56 -0
- package/src/contexts/messageReducer.ts +4 -0
- package/src/contexts/runningToast.ts +33 -0
- package/src/contexts/traceReducer.ts +62 -0
- package/src/contexts/turnTimer.test.ts +97 -0
- package/src/contexts/turnTimer.ts +108 -0
- package/src/contexts/useTurnTimer.ts +104 -0
- package/src/contracts/backend.ts +53 -2
- package/src/i18n/messages/analytics.ts +16 -6
- package/src/i18n/messages/chat.ts +26 -4
- package/src/i18n/messages/contexts.ts +2 -0
- package/src/i18n/messages/network.ts +13 -9
- package/src/i18n/messages/profile.ts +4 -0
- package/src/i18n/messages/settings.ts +4 -0
- package/src/i18n/messages/shell.ts +2 -0
- package/src/i18n/messages/trace.ts +69 -17
- package/src/mocks/backend.ts +7 -0
- package/src/styles/global.css +289 -70
- package/src/utils/api.ts +105 -8
- package/src/utils/toolDisplay.ts +74 -0
- package/dist/assets/index-C-8G4D4j.js +0 -448
- 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
|
-
|
|
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
|
-
|
|
278
|
+
|
|
279
|
+
async function loadHistory() {
|
|
143
280
|
setIsRefreshingMessages(true);
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
311
|
-
// the
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
588
|
+
console.log(`[SessionContext] refreshMessages: ${currentSession.id}`);
|
|
366
589
|
setIsRefreshingMessages(true);
|
|
367
590
|
setError(null);
|
|
368
591
|
try {
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
+
}
|