@raingor/pi-web-switch 0.4.1 → 0.4.2

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.
@@ -1,1104 +0,0 @@
1
- // useAgentSession — core hook for managing pi agent chat sessions.
2
- // Ported and simplified from pi-web's hooks/useAgentSession.ts.
3
- // Handles: session loading, SSE events, message sending, model switching, abort, compact.
4
-
5
- import { useState, useCallback, useRef, useEffect, useMemo, useReducer } from "react";
6
- import type {
7
- AgentMessage,
8
- AssistantMessage,
9
- SessionData,
10
- SessionInfo,
11
- SessionStatsInfo,
12
- AttachedImage,
13
- ChatInputHandle,
14
- ModelEntry,
15
- AgentStateResponse,
16
- NoticeItem,
17
- AgentPhase,
18
- QueuedMessages,
19
- SlashCommandInfo,
20
- ContextUsage,
21
- ToolResultMessage,
22
- } from "@/types/chat";
23
-
24
- // ─── Helpers ─────────────────────────────────────────────
25
-
26
- export async function sendAgentCommand<T = unknown>(
27
- sessionId: string,
28
- command: Record<string, unknown>,
29
- ): Promise<T> {
30
- const res = await fetch(`/api/chat/agent/${encodeURIComponent(sessionId)}`, {
31
- method: "POST",
32
- headers: { "Content-Type": "application/json" },
33
- body: JSON.stringify(command),
34
- });
35
- const body = (await res.json().catch(() => ({}))) as {
36
- success?: boolean;
37
- data?: T;
38
- error?: string;
39
- };
40
- if (!res.ok || body.error) {
41
- throw new Error(body.error ?? `HTTP ${res.status}`);
42
- }
43
- return body.data as T;
44
- }
45
-
46
- function normalizeToolCalls(message: AgentMessage): AgentMessage {
47
- if (message.role !== "assistant") return message;
48
- const am = message as AssistantMessage;
49
- let changed = false;
50
- const content = am.content.map((block) => {
51
- if (block.type === "toolCall" && (block as any).tool_name && !block.toolName) {
52
- changed = true;
53
- const { tool_name, ...rest } = block as any;
54
- return { ...rest, toolName: tool_name };
55
- }
56
- if (block.type === "toolCall" && (block as any).tool_input && !block.input) {
57
- changed = true;
58
- const { tool_input, ...rest } = block as any;
59
- return { ...rest, input: tool_input };
60
- }
61
- return block;
62
- });
63
- return changed ? { ...am, content } : am;
64
- }
65
-
66
- function createNoticeId(): string {
67
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
68
- return crypto.randomUUID();
69
- }
70
- return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
71
- }
72
-
73
- function extractMessageText(message: Partial<AgentMessage>): string {
74
- const content = (message as { content?: unknown }).content;
75
- if (typeof content === "string") return content;
76
- if (!Array.isArray(content)) return "";
77
- return content
78
- .map((block) =>
79
- block && typeof block === "object"
80
- && (block as { type?: string }).type === "text"
81
- && typeof (block as { text?: unknown }).text === "string"
82
- ? (block as { text: string }).text
83
- : "")
84
- .filter(Boolean)
85
- .join("\n");
86
- }
87
-
88
- function imageSignature(block: unknown): string {
89
- if (!block || typeof block !== "object" || (block as { type?: unknown }).type !== "image") return "";
90
- const source = (block as { source?: unknown }).source;
91
- if (source && typeof source === "object") {
92
- const src = source as { type?: unknown; media_type?: unknown; data?: unknown; url?: unknown };
93
- return [src.type === "url" ? "url" : "base64", typeof src.media_type === "string" ? src.media_type : "", typeof src.data === "string" ? src.data : "", typeof src.url === "string" ? src.url : ""].join(":");
94
- }
95
- const flat = block as { data?: unknown; mimeType?: unknown };
96
- return ["base64", typeof flat.mimeType === "string" ? flat.mimeType : "", typeof flat.data === "string" ? flat.data : "", ""].join(":");
97
- }
98
-
99
- function userMessageKey(message: Partial<AgentMessage>): string {
100
- const content = (message as { content?: unknown }).content;
101
- if (typeof content === "string") return JSON.stringify({ text: content, images: [] });
102
- if (!Array.isArray(content)) return JSON.stringify({ text: "", images: [] });
103
- return JSON.stringify({
104
- text: extractMessageText(message),
105
- images: content.map(imageSignature).filter(Boolean),
106
- });
107
- }
108
-
109
- // ─── Stream Reducer ──────────────────────────────────────
110
-
111
- interface StreamingState {
112
- isStreaming: boolean;
113
- streamingMessage: Partial<AgentMessage> | null;
114
- }
115
-
116
- type StreamAction =
117
- | { type: "start" }
118
- | { type: "update"; message: Partial<AgentMessage> }
119
- | { type: "end" }
120
- | { type: "reset" };
121
-
122
- function streamReducer(state: StreamingState, action: StreamAction): StreamingState {
123
- switch (action.type) {
124
- case "start":
125
- return { isStreaming: true, streamingMessage: null };
126
- case "update":
127
- return { isStreaming: true, streamingMessage: action.message };
128
- case "end":
129
- case "reset":
130
- return { isStreaming: false, streamingMessage: null };
131
- default:
132
- return state;
133
- }
134
- }
135
-
136
- // ─── Notice Reducer ──────────────────────────────────────
137
-
138
- type NoticeAction =
139
- | { type: "add"; notice: NoticeItem }
140
- | { type: "mark_oldest_exiting" }
141
- | { type: "remove"; id: string };
142
-
143
- type NoticeState = { visible: NoticeItem[]; pending: NoticeItem[] };
144
-
145
- const MAX_NOTICES = 5;
146
- const NOTICE_VISIBLE_MS = 5000;
147
- const NOTICE_EXIT_ANIMATION_MS = 180;
148
-
149
- function markOldestNoticeExiting(notices: NoticeItem[]): NoticeItem[] {
150
- const index = notices.findIndex((notice) => !notice.exiting);
151
- if (index === -1) return notices;
152
- return notices.map((notice, i) => (i === index ? { ...notice, exiting: true } : notice));
153
- }
154
-
155
- function fillPendingNotices(visible: NoticeItem[], pending: NoticeItem[]): NoticeState {
156
- let nextVisible = visible;
157
- let nextPending = pending;
158
- while (nextPending.length > 0 && nextVisible.length < MAX_NOTICES) {
159
- const [next, ...rest] = nextPending;
160
- if (!next) break;
161
- nextVisible = [...nextVisible, next];
162
- nextPending = rest;
163
- }
164
- if (nextPending.length > 0 && !nextVisible.some((notice) => notice.exiting)) {
165
- nextVisible = markOldestNoticeExiting(nextVisible);
166
- }
167
- return { visible: nextVisible, pending: nextPending };
168
- }
169
-
170
- function noticeReducer(state: NoticeState, action: NoticeAction): NoticeState {
171
- switch (action.type) {
172
- case "add": {
173
- if (state.visible.some((notice) => notice.exiting) || state.visible.length >= MAX_NOTICES) {
174
- return {
175
- visible: state.visible.some((notice) => notice.exiting)
176
- ? state.visible
177
- : markOldestNoticeExiting(state.visible),
178
- pending: [...state.pending, action.notice],
179
- };
180
- }
181
- return { ...state, visible: [...state.visible, action.notice] };
182
- }
183
- case "mark_oldest_exiting":
184
- return { ...state, visible: markOldestNoticeExiting(state.visible) };
185
- case "remove": {
186
- const visible = state.visible.filter((notice) => notice.id !== action.id);
187
- return fillPendingNotices(visible, state.pending);
188
- }
189
- default:
190
- return state;
191
- }
192
- }
193
-
194
- // ─── Hook Options ────────────────────────────────────────
195
-
196
- export interface UseAgentSessionOptions {
197
- session: SessionInfo | null;
198
- newSessionCwd: string | null;
199
- onAgentEnd?: () => void;
200
- onSessionCreated?: (session: SessionInfo) => void;
201
- onSessionForked?: (newSessionId: string) => void;
202
- modelsRefreshKey?: number;
203
- chatInputRef?: React.RefObject<ChatInputHandle | null>;
204
- onSessionStatsPanelOpen?: () => void;
205
- }
206
-
207
- export type ThinkingLevelOption = "auto" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
208
-
209
- // ─── Main Hook ───────────────────────────────────────────
210
-
211
- export function useAgentSession(opts: UseAgentSessionOptions) {
212
- const {
213
- session, newSessionCwd, onAgentEnd, onSessionCreated, onSessionForked,
214
- modelsRefreshKey, onSessionStatsPanelOpen,
215
- } = opts;
216
-
217
- const isNew = session === null && newSessionCwd !== null;
218
-
219
- const [data, setData] = useState<SessionData | null>(null);
220
- const [loading, setLoading] = useState(!isNew);
221
- const [error, setError] = useState<string | null>(null);
222
- const [messages, setMessages] = useState<AgentMessage[]>([]);
223
- const [entryIds, setEntryIds] = useState<string[]>([]);
224
- const [streamState, dispatch] = useReducer(streamReducer, { isStreaming: false, streamingMessage: null });
225
- const [agentRunning, setAgentRunning] = useState(false);
226
- const [bashRunning, setBashRunning] = useState(false);
227
- const [pendingBash, setPendingBash] = useState<{ command: string; excludeFromContext: boolean } | null>(null);
228
- const [modelNames, setModelNames] = useState<Record<string, string>>({});
229
- const [modelList, setModelList] = useState<ModelEntry[]>([]);
230
- const [modelError, setModelError] = useState<string | null>(null);
231
- const [newSessionModel, setNewSessionModel] = useState<{ provider: string; modelId: string } | null>(null);
232
- const [newSessionDefaultModel, setNewSessionDefaultModel] = useState<{ provider: string; modelId: string } | null>(null);
233
- const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevelOption>("auto");
234
- const [contextUsage, setContextUsage] = useState<ContextUsage | null>(null);
235
- const [systemPrompt, setSystemPrompt] = useState<string | null>(null);
236
- const [forkingEntryId, setForkingEntryId] = useState<string | null>(null);
237
- const [currentModelOverride, setCurrentModelOverride] = useState<{ provider: string; modelId: string } | null>(null);
238
- const [pendingModel, setPendingModel] = useState<{ provider: string; modelId: string } | null>(null);
239
- const [isCompacting, setIsCompacting] = useState(false);
240
- const [compactError, setCompactError] = useState<string | null>(null);
241
- const [compactResult, setCompactResult] = useState<{ reason: string; tokensBefore: number; estimatedTokensAfter: number } | null>(null);
242
- const [agentPhase, setAgentPhase] = useState<AgentPhase>(null);
243
- const [slashCommands, setSlashCommands] = useState<SlashCommandInfo[]>([]);
244
- const [slashCommandsLoading, setSlashCommandsLoading] = useState(false);
245
- const [queuedMessages, setQueuedMessages] = useState<QueuedMessages>({ steering: [], followUp: [] });
246
- const [noticeState, dispatchNotice] = useReducer(noticeReducer, { visible: [], pending: [] });
247
- const [retryInfo, setRetryInfo] = useState<{ attempt: number; maxAttempts: number; errorMessage?: string } | null>(null);
248
-
249
- const eventSourceRef = useRef<EventSource | null>(null);
250
- const sessionIdRef = useRef<string | null>(session?.id ?? null);
251
- const agentRunningRef = useRef(false);
252
- const handleAgentEventRef = useRef<((event: any) => void) | null>(null);
253
- const initialScrollDoneRef = useRef(false);
254
- const lastUserMsgRef = useRef<HTMLDivElement | null>(null);
255
- const messagesEndRef = useRef<HTMLDivElement | null>(null);
256
- const scrollContainerRef = useRef<HTMLDivElement | null>(null);
257
- const ensuringNewSessionRef = useRef<Promise<string | null> | null>(null);
258
- const newSessionPromotedRef = useRef(false);
259
- const newSessionModelOverrideRef = useRef<{ provider: string; modelId: string } | null>(null);
260
- const thinkingLevelOverrideRef = useRef<Exclude<ThinkingLevelOption, "auto"> | null>(null);
261
- const promptRunIdRef = useRef(0);
262
- const optimisticUserMessageKeyRef = useRef<string | null>(null);
263
- const toolPresetRef = useRef<"none" | "default" | "full">("default");
264
- const [toolPreset, setToolPresetState] = useState<"none" | "default" | "full">("default");
265
-
266
- const currentModel = currentModelOverride ?? data?.context.model ?? pendingModel ?? null;
267
- const displayModel = isNew ? (newSessionModel ?? newSessionDefaultModel) : currentModel;
268
-
269
- // ─── Session Stats ─────────────────────────────────────
270
-
271
- const sessionStats = useMemo(() => {
272
- const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
273
- let cost = 0;
274
- let userMessages = 0;
275
- let assistantMessages = 0;
276
- let toolResults = 0;
277
- let toolCalls = 0;
278
- for (const msg of messages) {
279
- if (msg.role === "user") userMessages += 1;
280
- if (msg.role === "toolResult") toolResults += 1;
281
- if (msg.role !== "assistant") continue;
282
- assistantMessages += 1;
283
- const u = (msg as AssistantMessage).usage;
284
- toolCalls += (msg as AssistantMessage).content.filter((c) => c.type === "toolCall").length;
285
- if (!u) continue;
286
- tokens.input += u.input ?? 0;
287
- tokens.output += u.output ?? 0;
288
- tokens.cacheRead += u.cacheRead ?? 0;
289
- tokens.cacheWrite += u.cacheWrite ?? 0;
290
- cost += u.cost?.total ?? 0;
291
- }
292
- tokens.total = tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite;
293
- if (tokens.total === 0 && messages.length === 0) return null;
294
- return {
295
- sessionFile: data?.filePath || undefined,
296
- sessionId: sessionIdRef.current ?? session?.id ?? "",
297
- sessionName: session?.name,
298
- userMessages,
299
- assistantMessages,
300
- toolCalls,
301
- toolResults,
302
- totalMessages: messages.length,
303
- tokens,
304
- cost,
305
- ...(contextUsage ? { contextUsage } : {}),
306
- } satisfies SessionStatsInfo;
307
- }, [messages, contextUsage, data?.filePath, session?.id, session?.name]);
308
-
309
- // ─── Load Session ──────────────────────────────────────
310
-
311
- const loadSession = useCallback(async (sid: string, showLoading = false) => {
312
- try {
313
- if (showLoading) setLoading(true);
314
- const res = await fetch(`/api/chat/sessions/${encodeURIComponent(sid)}`);
315
- if (res.status === 404) {
316
- if (showLoading) {
317
- setData(null);
318
- setMessages([]);
319
- setError(null);
320
- }
321
- return null;
322
- }
323
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
324
- const d = await res.json() as SessionData;
325
- if (sessionIdRef.current !== sid) return null;
326
- setData(d);
327
- setMessages(d.context.messages);
328
- setEntryIds(d.context.entryIds ?? []);
329
- setCurrentModelOverride(null);
330
- setError(null);
331
- if (showLoading) setLoading(false);
332
-
333
- // Load agent state if running
334
- try {
335
- const stateRes = await fetch(`/api/chat/agent/${encodeURIComponent(sid)}`);
336
- if (stateRes.ok) {
337
- const agentState = await stateRes.json() as { running?: boolean; state?: AgentStateResponse };
338
- if (sessionIdRef.current !== sid) return null;
339
- const liveState = agentState.state;
340
- if (liveState) {
341
- if (liveState.contextUsage !== undefined) setContextUsage(liveState.contextUsage ?? null);
342
- if (liveState.systemPrompt !== undefined) setSystemPrompt(liveState.systemPrompt ?? null);
343
- if (liveState.thinkingLevel !== undefined) setThinkingLevel((liveState.thinkingLevel as ThinkingLevelOption) ?? "auto");
344
- }
345
- if (agentState.running && liveState?.isStreaming) {
346
- agentRunningRef.current = true;
347
- setAgentRunning(true);
348
- setAgentPhase({ kind: "waiting_model" });
349
- dispatch({ type: "start" });
350
- }
351
- }
352
- } catch {
353
- // ignore state fetch errors
354
- }
355
- return null;
356
- } catch (e) {
357
- setError(String(e));
358
- return null;
359
- } finally {
360
- if (showLoading) setLoading(false);
361
- }
362
- }, []);
363
-
364
- // ─── Load Models ───────────────────────────────────────
365
-
366
- const loadModels = useCallback(async (signal?: AbortSignal) => {
367
- const modelCwd = newSessionCwd ?? session?.cwd ?? "";
368
- const modelsUrl = modelCwd ? `/api/chat/models?cwd=${encodeURIComponent(modelCwd)}` : "/api/chat/models";
369
- console.log("[loadModels] Fetching models for cwd:", modelCwd, "URL:", modelsUrl);
370
- try {
371
- const res = await fetch(modelsUrl, signal ? { signal } : undefined);
372
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
373
- const d = await res.json() as any;
374
- console.log("[loadModels] Response:", { models: d.models, modelList: d.modelList, defaultModel: d.defaultModel, modelError: d.modelError });
375
- setModelNames(d.models ?? {});
376
- setModelError(d.modelError ?? null);
377
- const nextModelList = d.modelList ?? [];
378
- setModelList(nextModelList);
379
- if (isNew && !sessionIdRef.current) {
380
- const match = d.defaultModel
381
- ? nextModelList.find((m: ModelEntry) => m.id === d.defaultModel?.modelId && m.provider === d.defaultModel?.provider)
382
- : undefined;
383
- const display = match ?? nextModelList[0];
384
- setNewSessionDefaultModel(display ? { provider: display.provider, modelId: display.id } : null);
385
- }
386
- } catch (e) {
387
- if (e instanceof DOMException && e.name === "AbortError") return;
388
- console.error("Failed to load models:", e);
389
- }
390
- }, [isNew, newSessionCwd, session?.cwd]);
391
-
392
- // ─── Ensure New Session ────────────────────────────────
393
-
394
- const ensureNewSession = useCallback(async (): Promise<string | null> => {
395
- if (sessionIdRef.current) return sessionIdRef.current;
396
- if (!isNew || !newSessionCwd) return sessionIdRef.current;
397
- if (ensuringNewSessionRef.current) return ensuringNewSessionRef.current;
398
-
399
- const promise = (async () => {
400
- const selectedModel = newSessionModelOverrideRef.current;
401
- const selectedThinkingLevel = thinkingLevelOverrideRef.current;
402
- if (selectedModel) setPendingModel(selectedModel);
403
- const toolNames = toolPresetRef.current === "default"
404
- ? ["read", "bash", "edit", "write", "grep", "find", "ls"]
405
- : toolPresetRef.current === "full"
406
- ? ["read", "bash", "edit", "write", "grep", "find", "ls"]
407
- : [];
408
- const res = await fetch("/api/chat/agent/new", {
409
- method: "POST",
410
- headers: { "Content-Type": "application/json" },
411
- body: JSON.stringify({
412
- cwd: newSessionCwd,
413
- type: "ensure_session",
414
- toolNames,
415
- ...(selectedModel ? { provider: selectedModel.provider, modelId: selectedModel.modelId } : {}),
416
- ...(selectedThinkingLevel ? { thinkingLevel: selectedThinkingLevel } : {}),
417
- }),
418
- });
419
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
420
- const result = await res.json() as {
421
- sessionId: string;
422
- model?: { provider: string; modelId: string } | null;
423
- thinkingLevel?: ThinkingLevelOption;
424
- };
425
- const realId = result.sessionId;
426
- sessionIdRef.current = realId;
427
- if (result.model) {
428
- setPendingModel(result.model);
429
- if (!selectedModel) setNewSessionDefaultModel(result.model);
430
- }
431
- if (result.thinkingLevel) {
432
- setThinkingLevel(result.thinkingLevel);
433
- }
434
- return realId;
435
- })();
436
-
437
- ensuringNewSessionRef.current = promise;
438
- try {
439
- return await promise;
440
- } finally {
441
- ensuringNewSessionRef.current = null;
442
- }
443
- }, [isNew, newSessionCwd]);
444
-
445
- // ─── Promote New Session ───────────────────────────────
446
-
447
- const promoteNewSession = useCallback((messageCount = 0, firstMessage = "(no messages)") => {
448
- const sid = sessionIdRef.current;
449
- if (!isNew || !newSessionCwd || !sid || newSessionPromotedRef.current) return;
450
- newSessionPromotedRef.current = true;
451
- onSessionCreated?.({
452
- id: sid,
453
- path: "",
454
- cwd: newSessionCwd,
455
- created: new Date().toISOString(),
456
- modified: new Date().toISOString(),
457
- messageCount,
458
- firstMessage,
459
- });
460
- }, [isNew, newSessionCwd, onSessionCreated]);
461
-
462
- // ─── Event Source Connection ───────────────────────────
463
-
464
- const closeEvents = useCallback(() => {
465
- eventSourceRef.current?.close();
466
- eventSourceRef.current = null;
467
- }, []);
468
-
469
- const connectEvents = useCallback((sid: string) => {
470
- closeEvents();
471
- const es = new EventSource(`/api/chat/agent/${encodeURIComponent(sid)}/events`);
472
- eventSourceRef.current = es;
473
-
474
- es.onmessage = (e) => {
475
- try {
476
- const event = JSON.parse(e.data);
477
- if (event.type === "connected") return;
478
- handleAgentEventRef.current?.(event);
479
- } catch {
480
- // ignore
481
- }
482
- };
483
- es.onerror = () => {
484
- // EventSource will auto-reconnect for recoverable errors
485
- };
486
- }, [closeEvents]);
487
-
488
- // ─── Agent Event Handler ───────────────────────────────
489
-
490
- const addNotice = useCallback((notice: { id?: string; message: string; type?: "info" | "success" | "warning" | "error" }) => {
491
- const message = notice.message.trim();
492
- if (!message) return;
493
- dispatchNotice({
494
- type: "add",
495
- notice: {
496
- id: notice.id ?? createNoticeId(),
497
- message,
498
- type: notice.type ?? "info",
499
- },
500
- });
501
- }, []);
502
-
503
- const handleAgentEvent = useCallback((event: any) => {
504
- switch (event.type) {
505
- case "agent_start":
506
- agentRunningRef.current = true;
507
- setAgentRunning(true);
508
- setAgentPhase({ kind: "waiting_model" });
509
- dispatch({ type: "start" });
510
- break;
511
-
512
- case "agent_end":
513
- setAgentPhase(null);
514
- setRetryInfo(null);
515
- dispatch({ type: "end" });
516
- if (sessionIdRef.current) {
517
- loadSession(sessionIdRef.current);
518
- fetch(`/api/chat/agent/${encodeURIComponent(sessionIdRef.current)}`)
519
- .then((r) => r.json())
520
- .then((d: { state?: AgentStateResponse }) => {
521
- if (d.state?.contextUsage !== undefined) setContextUsage(d.state.contextUsage ?? null);
522
- if (d.state?.systemPrompt !== undefined) setSystemPrompt(d.state.systemPrompt ?? null);
523
- })
524
- .catch(() => {});
525
- }
526
- break;
527
-
528
- case "agent_settled": {
529
- const wasRunning = agentRunningRef.current;
530
- agentRunningRef.current = false;
531
- if (!wasRunning) break;
532
- setAgentRunning(false);
533
- setAgentPhase(null);
534
- setRetryInfo(null);
535
- dispatch({ type: "end" });
536
- setIsCompacting(false);
537
- if (sessionIdRef.current) {
538
- loadSession(sessionIdRef.current);
539
- }
540
- if (wasRunning) onAgentEnd?.();
541
- break;
542
- }
543
-
544
- case "prompt_done": {
545
- const runId = promptRunIdRef.current;
546
- const promptWasPending = true;
547
- promptRunIdRef.current = 0;
548
- optimisticUserMessageKeyRef.current = null;
549
- if (!promptWasPending) break;
550
- const sid = sessionIdRef.current;
551
- if (sid) loadSession(sid);
552
- if (!agentRunningRef.current) {
553
- setAgentRunning(false);
554
- setAgentPhase(null);
555
- dispatch({ type: "end" });
556
- }
557
- onAgentEnd?.();
558
- break;
559
- }
560
-
561
- case "prompt_error":
562
- addNotice({ type: "error", message: (event.errorMessage as string | undefined) ?? "Command failed" });
563
- break;
564
-
565
- case "message_start":
566
- case "message_update": {
567
- if (!agentRunningRef.current) break;
568
- const msg = event.message as Partial<AgentMessage> | undefined;
569
- if (msg?.role === "user") break;
570
- if (msg) {
571
- dispatch({ type: "update", message: normalizeToolCalls(msg as AgentMessage) });
572
- }
573
- setAgentPhase(null);
574
- break;
575
- }
576
-
577
- case "message_end": {
578
- if (!agentRunningRef.current) break;
579
- const completed = event.message as AgentMessage | undefined;
580
- if (completed && completed.role === "user") {
581
- const delivered = normalizeToolCalls(completed);
582
- const deliveredKey = userMessageKey(delivered);
583
- const optimisticKey = optimisticUserMessageKeyRef.current;
584
- optimisticUserMessageKeyRef.current = null;
585
- setMessages((prev) => {
586
- const last = prev[prev.length - 1];
587
- if (optimisticKey && last?.role === "user" && userMessageKey(last) === optimisticKey) {
588
- return optimisticKey === deliveredKey ? prev : [...prev.slice(0, -1), delivered];
589
- }
590
- return [...prev, delivered];
591
- });
592
- } else if (completed) {
593
- setMessages((prev) => [...prev, normalizeToolCalls(completed)]);
594
- }
595
- dispatch({ type: "reset" });
596
- setAgentPhase({ kind: "waiting_model" });
597
- break;
598
- }
599
-
600
- case "tool_execution_start": {
601
- const id = event.toolCallId as string;
602
- const name = event.toolName as string;
603
- setAgentPhase((prev) => {
604
- const tools = prev?.kind === "running_tools" ? [...prev.tools] : [];
605
- if (!tools.some((t) => t.id === id)) tools.push({ id, name });
606
- return { kind: "running_tools", tools };
607
- });
608
- break;
609
- }
610
-
611
- case "tool_execution_end": {
612
- const id = event.toolCallId as string;
613
- setAgentPhase((prev) => {
614
- if (prev?.kind !== "running_tools") return prev;
615
- const tools = prev.tools.filter((t) => t.id !== id);
616
- if (tools.length === 0) return { kind: "waiting_model" };
617
- return { kind: "running_tools", tools };
618
- });
619
- break;
620
- }
621
-
622
- case "queue_update":
623
- setQueuedMessages({
624
- steering: [...((event.steering as string[] | undefined) ?? [])],
625
- followUp: [...((event.followUp as string[] | undefined) ?? [])],
626
- });
627
- break;
628
-
629
- case "auto_retry_start":
630
- setRetryInfo({ attempt: event.attempt as number, maxAttempts: event.maxAttempts as number, errorMessage: event.errorMessage as string | undefined });
631
- break;
632
-
633
- case "auto_retry_end":
634
- setRetryInfo(null);
635
- break;
636
-
637
- case "auto_compaction_start":
638
- case "compaction_start":
639
- setIsCompacting(true);
640
- setCompactError(null);
641
- setCompactResult(null);
642
- break;
643
-
644
- case "auto_compaction_end":
645
- case "compaction_end":
646
- setIsCompacting(false);
647
- if (event.errorMessage) {
648
- setCompactError(event.errorMessage as string);
649
- setCompactResult(null);
650
- } else if (!event.aborted) {
651
- if (event.result) {
652
- setCompactResult({
653
- reason: (event.reason as string | undefined) ?? "auto",
654
- tokensBefore: (event.result as any).tokensBefore ?? 0,
655
- estimatedTokensAfter: (event.result as any).estimatedTokensAfter ?? 0,
656
- });
657
- }
658
- if (sessionIdRef.current) loadSession(sessionIdRef.current);
659
- }
660
- break;
661
- }
662
- }, [addNotice, loadSession, onAgentEnd]);
663
-
664
- handleAgentEventRef.current = handleAgentEvent;
665
-
666
- // ─── Send Message ──────────────────────────────────────
667
-
668
- const handleSend = useCallback(async (message: string, images?: AttachedImage[]) => {
669
- const trimmedMessage = message.trim();
670
- if (!trimmedMessage && !images?.length) return;
671
- if (agentRunningRef.current) return;
672
-
673
- const isBashCommand = !images?.length && trimmedMessage.startsWith("!");
674
- if (isBashCommand) {
675
- const isExcluded = trimmedMessage.startsWith("!!");
676
- const bashCmd = (isExcluded ? trimmedMessage.slice(2) : trimmedMessage.slice(1)).trim();
677
- if (!bashCmd) return;
678
- // Execute bash command
679
- try {
680
- const sid = sessionIdRef.current ?? await ensureNewSession();
681
- if (!sid) return;
682
- setBashRunning(true);
683
- setPendingBash({ command: bashCmd, excludeFromContext: isExcluded });
684
- await sendAgentCommand(sid, { type: "bash", command: bashCmd, excludeFromContext: isExcluded });
685
- await loadSession(sid);
686
- promoteNewSession(1, trimmedMessage);
687
- } catch (e) {
688
- addNotice({ type: "error", message: e instanceof Error ? e.message : String(e) });
689
- } finally {
690
- setPendingBash(null);
691
- setBashRunning(false);
692
- }
693
- return;
694
- }
695
-
696
- const promptRunId = promptRunIdRef.current + 1;
697
- const imageBlocks = images?.map((img) => ({ type: "image" as const, source: { type: "base64" as const, media_type: img.mimeType, data: img.data } }));
698
- const userMsg: AgentMessage = {
699
- role: "user",
700
- content: imageBlocks?.length
701
- ? [...(message.trim() ? [{ type: "text" as const, text: message }] : []), ...imageBlocks]
702
- : message,
703
- timestamp: Date.now(),
704
- };
705
- setMessages((prev) => [...prev, userMsg]);
706
- optimisticUserMessageKeyRef.current = userMessageKey(userMsg);
707
- promptRunIdRef.current = promptRunId;
708
- agentRunningRef.current = true;
709
- setAgentRunning(true);
710
- setAgentPhase({ kind: "waiting_model" });
711
- dispatch({ type: "start" });
712
-
713
- const piImages = images?.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType }));
714
-
715
- try {
716
- if (isNew && newSessionCwd) {
717
- const selectedModel = newSessionModel;
718
- const sid = await ensureNewSession();
719
- if (sid) {
720
- if (selectedModel) {
721
- setPendingModel(selectedModel);
722
- await sendAgentCommand(sid, { type: "set_model", provider: selectedModel.provider, modelId: selectedModel.modelId });
723
- }
724
- connectEvents(sid);
725
- await sendAgentCommand(sid, {
726
- type: "prompt",
727
- message,
728
- ...(piImages?.length ? { images: piImages } : {}),
729
- });
730
- promoteNewSession(1, message);
731
- }
732
- } else if (session) {
733
- connectEvents(session.id);
734
- await sendAgentCommand(session.id, {
735
- type: "prompt",
736
- message,
737
- ...(piImages?.length ? { images: piImages } : {}),
738
- });
739
- }
740
- } catch (e) {
741
- console.error("Failed to send message:", e);
742
- addNotice({ type: "error", message: e instanceof Error ? e.message : String(e) });
743
- const optimisticKey = optimisticUserMessageKeyRef.current;
744
- if (optimisticKey) {
745
- setMessages((prev) => {
746
- const last = prev[prev.length - 1];
747
- return last?.role === "user" && userMessageKey(last) === optimisticKey ? prev.slice(0, -1) : prev;
748
- });
749
- }
750
- optimisticUserMessageKeyRef.current = null;
751
- agentRunningRef.current = false;
752
- setAgentRunning(false);
753
- setAgentPhase(null);
754
- dispatch({ type: "end" });
755
- closeEvents();
756
- if (message) opts.chatInputRef?.current?.insertIfEmpty(message);
757
- }
758
- }, [isNew, newSessionCwd, newSessionModel, session, ensureNewSession, connectEvents, promoteNewSession, addNotice, closeEvents, loadSession, opts.chatInputRef]);
759
-
760
- // ─── Abort ─────────────────────────────────────────────
761
-
762
- const handleAbort = useCallback(async () => {
763
- const sid = sessionIdRef.current;
764
- if (!sid) return;
765
- try {
766
- await sendAgentCommand(sid, { type: "abort" });
767
- } catch (e) {
768
- console.error("Failed to abort:", e);
769
- }
770
- }, []);
771
-
772
- // ─── Fork ──────────────────────────────────────────────
773
-
774
- const handleFork = useCallback(async (entryId: string) => {
775
- const sid = sessionIdRef.current;
776
- if (!sid) return;
777
- setForkingEntryId(entryId);
778
- try {
779
- const result = await sendAgentCommand<{ cancelled?: boolean; newSessionId?: string }>(sid, {
780
- type: "fork",
781
- entryId,
782
- });
783
- if (!result?.cancelled && result?.newSessionId) {
784
- onSessionForked?.(result.newSessionId);
785
- }
786
- } catch (e) {
787
- console.error("Fork failed:", e);
788
- } finally {
789
- setForkingEntryId(null);
790
- }
791
- }, [onSessionForked]);
792
-
793
- // ─── Model Change ──────────────────────────────────────
794
-
795
- const handleModelChange = useCallback(async (provider: string, modelId: string) => {
796
- if (isNew) {
797
- const selectedModel = { provider, modelId };
798
- newSessionModelOverrideRef.current = selectedModel;
799
- setNewSessionModel(selectedModel);
800
- setPendingModel(selectedModel);
801
- const sid = sessionIdRef.current ?? await ensuringNewSessionRef.current;
802
- if (!sid) return;
803
- try {
804
- await sendAgentCommand(sid, { type: "set_model", provider, modelId });
805
- } catch (e) {
806
- console.error("Failed to set model:", e);
807
- }
808
- return;
809
- }
810
- const sid = sessionIdRef.current;
811
- if (!sid) return;
812
- try {
813
- await sendAgentCommand(sid, { type: "set_model", provider, modelId });
814
- setCurrentModelOverride({ provider, modelId });
815
- } catch (e) {
816
- console.error("Failed to set model:", e);
817
- }
818
- }, [isNew]);
819
-
820
- // ─── Compact ───────────────────────────────────────────
821
-
822
- const handleCompact = useCallback(async () => {
823
- const sid = sessionIdRef.current;
824
- if (!sid || isCompacting) return;
825
- setIsCompacting(true);
826
- setCompactError(null);
827
- setCompactResult(null);
828
- try {
829
- const result = await sendAgentCommand<any>(sid, { type: "compact" });
830
- if (result) {
831
- setCompactResult({
832
- reason: "manual",
833
- tokensBefore: result.tokensBefore ?? 0,
834
- estimatedTokensAfter: result.estimatedTokensAfter ?? 0,
835
- });
836
- }
837
- await loadSession(sid, true);
838
- } catch (e) {
839
- setCompactError(e instanceof Error ? e.message : String(e));
840
- setCompactResult(null);
841
- } finally {
842
- setIsCompacting(false);
843
- }
844
- }, [isCompacting, loadSession]);
845
-
846
- // ─── Steering & Follow-up ──────────────────────────────
847
-
848
- const handleSteer = useCallback(async (message: string, images?: AttachedImage[]) => {
849
- const sid = sessionIdRef.current;
850
- if (!sid) return;
851
- const piImages = images?.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType }));
852
- try {
853
- await sendAgentCommand(sid, { type: "steer", message, ...(piImages?.length ? { images: piImages } : {}) });
854
- } catch (e) {
855
- console.error("Failed to steer:", e);
856
- }
857
- }, []);
858
-
859
- const handleFollowUp = useCallback(async (message: string, images?: AttachedImage[]) => {
860
- const sid = sessionIdRef.current;
861
- if (!sid) return;
862
- const piImages = images?.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType }));
863
- try {
864
- await sendAgentCommand(sid, { type: "follow_up", message, ...(piImages?.length ? { images: piImages } : {}) });
865
- } catch (e) {
866
- console.error("Failed to follow up:", e);
867
- }
868
- }, []);
869
-
870
- // ─── Recall Queue ──────────────────────────────────────
871
-
872
- const handleRecallQueue = useCallback(async () => {
873
- const sid = sessionIdRef.current;
874
- if (!sid) return;
875
- try {
876
- const result = await sendAgentCommand<{ steering?: string[]; followUp?: string[] }>(sid, { type: "clear_queue" });
877
- setQueuedMessages({ steering: [], followUp: [] });
878
- const texts = [...(result?.steering ?? []), ...(result?.followUp ?? [])];
879
- if (texts.length > 0) {
880
- opts.chatInputRef?.current?.prependText(texts.join("\n\n"));
881
- }
882
- } catch (e) {
883
- console.error("Failed to recall queued messages:", e);
884
- addNotice({ type: "error", message: "Failed to recall queued messages" });
885
- }
886
- }, [opts.chatInputRef, addNotice]);
887
-
888
- // ─── Thinking Level ────────────────────────────────────
889
-
890
- const handleThinkingLevelChange = useCallback(async (level: ThinkingLevelOption) => {
891
- setThinkingLevel(level);
892
- if (isNew && !sessionIdRef.current) {
893
- thinkingLevelOverrideRef.current = level === "auto" ? null : level;
894
- }
895
- if (level === "auto") return;
896
- const sid = sessionIdRef.current ?? await ensuringNewSessionRef.current;
897
- if (!sid) return;
898
- try {
899
- await sendAgentCommand(sid, { type: "set_thinking_level", level });
900
- } catch (e) {
901
- console.error("Failed to set thinking level:", e);
902
- }
903
- }, [isNew]);
904
-
905
- // ─── Tool Preset ───────────────────────────────────────
906
-
907
- const handleToolPresetChange = useCallback(async (preset: "none" | "default" | "full") => {
908
- const toolNames = preset === "default" || preset === "full"
909
- ? ["read", "bash", "edit", "write", "grep", "find", "ls"]
910
- : [];
911
- setToolPresetState(preset);
912
- toolPresetRef.current = preset;
913
- const sid = sessionIdRef.current ?? await ensuringNewSessionRef.current;
914
- if (!sid) return;
915
- try {
916
- await sendAgentCommand(sid, { type: "set_tools", toolNames });
917
- } catch (e) {
918
- console.error("Failed to set tools:", e);
919
- }
920
- }, []);
921
-
922
- // ─── Slash Commands ────────────────────────────────────
923
-
924
- const loadSlashCommands = useCallback(async () => {
925
- const sid = sessionIdRef.current ?? await ensureNewSession();
926
- if (!sid) {
927
- setSlashCommands([]);
928
- return [];
929
- }
930
- setSlashCommandsLoading(true);
931
- try {
932
- const data = await sendAgentCommand<{ commands?: SlashCommandInfo[] }>(sid, { type: "get_commands" });
933
- const commands = data?.commands ?? [];
934
- setSlashCommands(commands);
935
- return commands;
936
- } catch {
937
- setSlashCommands([]);
938
- return [];
939
- } finally {
940
- setSlashCommandsLoading(false);
941
- }
942
- }, [ensureNewSession]);
943
-
944
- // ─── Built-in Slash Commands ───────────────────────────
945
-
946
- const handleBuiltinSlashCommand = useCallback(async (text: string): Promise<{ handled: boolean; message?: string; error?: string }> => {
947
- if (!text.startsWith("/")) return { handled: false };
948
- const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
949
- if (!match) return { handled: false };
950
- const [, commandName, rawArgs = ""] = match;
951
- const args = rawArgs.trim();
952
- const sid = sessionIdRef.current ?? await ensureNewSession();
953
- if (!sid) return { handled: true, error: "No active session" };
954
-
955
- try {
956
- switch (commandName) {
957
- case "compact": {
958
- if (isCompacting) return { handled: true, error: "Already compacting" };
959
- setIsCompacting(true);
960
- setCompactError(null);
961
- setCompactResult(null);
962
- const result = await sendAgentCommand<any>(sid, { type: "compact", ...(args ? { customInstructions: args } : {}) });
963
- if (result) {
964
- setCompactResult({ reason: "manual", tokensBefore: result.tokensBefore ?? 0, estimatedTokensAfter: result.estimatedTokensAfter ?? 0 });
965
- }
966
- await loadSession(sid, true);
967
- return { handled: true, message: "Compacted context" };
968
- }
969
- case "name": {
970
- if (!args) return { handled: true, error: "Usage: /name <name>" };
971
- await sendAgentCommand(sid, { type: "set_session_name", name: args });
972
- await loadSession(sid);
973
- return { handled: true, message: `Session renamed to ${args}` };
974
- }
975
- case "copy": {
976
- const data = await sendAgentCommand<{ text?: string }>(sid, { type: "get_last_assistant_text" });
977
- const textToCopy = data?.text ?? "";
978
- if (!textToCopy) return { handled: true, error: "No assistant message to copy" };
979
- await navigator.clipboard.writeText(textToCopy);
980
- return { handled: true, message: "Copied last assistant message" };
981
- }
982
- default:
983
- return { handled: false };
984
- }
985
- } catch (e) {
986
- return { handled: true, error: e instanceof Error ? e.message : String(e) };
987
- } finally {
988
- if (commandName === "compact") setIsCompacting(false);
989
- }
990
- }, [ensureNewSession, isCompacting, loadSession]);
991
-
992
- // ─── Abort Compaction ──────────────────────────────────
993
-
994
- const handleAbortCompaction = useCallback(async () => {
995
- const sid = sessionIdRef.current;
996
- if (!sid) return;
997
- try {
998
- await sendAgentCommand(sid, { type: "abort_compaction" });
999
- } catch (e) {
1000
- console.error("Failed to abort compaction:", e);
1001
- }
1002
- }, []);
1003
-
1004
- // ─── Navigate ──────────────────────────────────────────
1005
-
1006
- const handleNavigate = useCallback(async (entryId: string) => {
1007
- const sid = sessionIdRef.current;
1008
- if (!sid) return;
1009
- sendAgentCommand(sid, { type: "navigate_tree", targetId: entryId }).catch(() => {});
1010
- // Load context for the branch
1011
- try {
1012
- const res = await fetch(`/api/chat/sessions/${encodeURIComponent(sid)}/context?leafId=${encodeURIComponent(entryId)}`);
1013
- if (!res.ok) return;
1014
- const d = await res.json() as { context: { messages: AgentMessage[]; entryIds: string[] } };
1015
- setMessages(d.context.messages);
1016
- setEntryIds(d.context.entryIds ?? []);
1017
- } catch (e) {
1018
- console.error("Failed to load context:", e);
1019
- }
1020
- }, []);
1021
-
1022
- // ─── Effects ───────────────────────────────────────────
1023
-
1024
- // Load session when session.id changes
1025
- useEffect(() => {
1026
- if (session) {
1027
- sessionIdRef.current = session.id;
1028
- loadSession(session.id, true).then(() => {
1029
- if (agentRunningRef.current) {
1030
- connectEvents(session.id);
1031
- }
1032
- });
1033
- } else {
1034
- // Clear session data when no session is selected
1035
- sessionIdRef.current = null;
1036
- setData(null);
1037
- setMessages([]);
1038
- setEntryIds([]);
1039
- setError(null);
1040
- }
1041
- return () => {
1042
- closeEvents();
1043
- };
1044
- }, [session?.id, loadSession, connectEvents, closeEvents]);
1045
-
1046
- // Load models - also reload when session changes to ensure we get the correct models for the session's cwd
1047
- useEffect(() => {
1048
- const controller = new AbortController();
1049
- loadModels(controller.signal);
1050
- return () => controller.abort();
1051
- }, [loadModels, modelsRefreshKey, session?.id]);
1052
-
1053
- // Notice auto-dismiss
1054
- useEffect(() => {
1055
- if (noticeState.visible.length === 0) return;
1056
- const exiting = noticeState.visible.find((notice) => notice.exiting);
1057
- if (exiting) {
1058
- const t = setTimeout(() => dispatchNotice({ type: "remove", id: exiting.id }), NOTICE_EXIT_ANIMATION_MS);
1059
- return () => clearTimeout(t);
1060
- }
1061
- const oldest = noticeState.visible[0];
1062
- if (!oldest) return;
1063
- const t = setTimeout(() => dispatchNotice({ type: "mark_oldest_exiting" }), NOTICE_VISIBLE_MS);
1064
- return () => clearTimeout(t);
1065
- }, [noticeState.visible]);
1066
-
1067
- // Compact result auto-dismiss
1068
- useEffect(() => {
1069
- if (!compactResult) return;
1070
- const t = setTimeout(() => setCompactResult(null), 6000);
1071
- return () => clearTimeout(t);
1072
- }, [compactResult]);
1073
-
1074
- // Auto-scroll to bottom on new messages
1075
- useEffect(() => {
1076
- if (messages.length > 0 && messagesEndRef.current) {
1077
- messagesEndRef.current.scrollIntoView({ behavior: agentRunning ? "smooth" : "auto" });
1078
- }
1079
- }, [messages.length, agentRunning]);
1080
-
1081
- return {
1082
- // State
1083
- data, loading, error, messages, entryIds, streamState,
1084
- agentRunning, modelNames, modelList, modelError,
1085
- newSessionModel, toolPreset, thinkingLevel,
1086
- retryInfo, contextUsage, systemPrompt, forkingEntryId,
1087
- isCompacting, compactError, compactResult, currentModel, displayModel, sessionStats,
1088
- slashCommands, slashCommandsLoading, queuedMessages,
1089
- notices: noticeState.visible,
1090
- agentPhase,
1091
- isNew,
1092
- bashRunning, pendingBash,
1093
- // Refs
1094
- sessionIdRef, messagesEndRef, scrollContainerRef, lastUserMsgRef,
1095
- // Actions
1096
- handleSend, handleAbort, handleFork, handleNavigate, handleModelChange,
1097
- handleCompact, handleSteer, handleFollowUp, handleAbortCompaction,
1098
- handleRecallQueue, handleBuiltinSlashCommand,
1099
- handleToolPresetChange, handleThinkingLevelChange, loadSlashCommands,
1100
- addNotice,
1101
- // Derived
1102
- isAutoModelSelection: isNew && newSessionModel === null,
1103
- };
1104
- }