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