"use client"; import { useState, useCallback, useRef, useEffect, useLayoutEffect, useMemo, useReducer } from "react"; import type { AgentMessage, BlockingExtensionUiRequest, ExtensionStatusItem, ExtensionUiRequest, ExtensionWidgetItem, SessionInfo, SessionTreeNode, UserMessage, } from "@/lib/types"; import { isBlockingExtensionUiRequest } from "@/lib/browser-notifications"; import { normalizeToolCalls } from "@/lib/normalize"; import { isPromptRejectedError, sendAgentCommand } from "@/lib/agent-client"; import { clearDraft, rekeyDraft, restoreDraftSubmission } from "@/lib/draft-store"; import { getPreferredToolPreset, setPreferredToolPreset } from "@/lib/tool-preset-preference"; import { getToolNamesForPreset, type ToolEntry, type ToolPreset } from "@/lib/tool-presets"; import type { SessionStatsInfo } from "@/lib/pi-types"; import { userMessageKey } from "@/lib/prompt-recovery"; import { AgentEventConnection } from "@/lib/agent-event-connection"; import { CHAT_SCROLL_REATTACH_TOLERANCE, CHAT_SCROLL_TAIL_TOLERANCE, getLiveFollowAttached, } from "@/lib/chat-lazy-load"; import { INITIAL_STREAMING_STATE, streamReducer, type ClientAssistantMessageEvent, } from "@/lib/streaming-message"; export interface SessionData { sessionId: string; filePath: string; totalActiveMs: number; tree: SessionTreeNode[]; leafId: string | null; context: { messages: AgentMessage[]; entryIds: string[]; thinkingLevel: string; model: { provider: string; modelId: string } | null; }; } interface AgentEvent { type: string; [key: string]: unknown; } interface CompactCommandResult { tokensBefore?: number; estimatedTokensAfter?: number; } interface LastAssistantTextResponse { text?: string; } type AgentStateResponse = { contextUsage?: { percent: number | null; contextWindow: number; tokens: number | null } | null; systemPrompt?: string; thinkingLevel?: string; isStreaming?: boolean; isPromptRunning?: boolean; isBashRunning?: boolean; isCompacting?: boolean; extensionStatuses?: ExtensionStatusItem[]; extensionWidgets?: ExtensionWidgetItem[]; queuedMessages?: { steering?: string[]; followUp?: string[] } | null; }; export interface QueuedMessages { steering: string[]; followUp: string[]; } function normalizeQueuedMessages(q?: { steering?: string[]; followUp?: string[] } | null): QueuedMessages { return { steering: q?.steering ?? [], followUp: q?.followUp ?? [] }; } type ExtensionUiDialogRequest = Extract; type ExtensionUiCustomRequest = Extract; export type NoticeType = "info" | "success" | "warning" | "error"; export type NoticeItem = { id: string; message: string; type: NoticeType; exiting?: boolean; }; type NoticeState = { visible: NoticeItem[]; pending: NoticeItem[]; }; type NoticeAction = | { type: "add"; notice: NoticeItem } | { type: "mark_oldest_exiting" } | { type: "remove"; id: string }; export type AgentPhase = | { kind: "waiting_model" } | { kind: "running_command" } | { kind: "running_tools"; tools: { id: string; name: string }[] } | null; export interface CompactResultInfo { reason: "manual" | "threshold" | "overflow" | "auto" | string; tokensBefore: number; estimatedTokensAfter: number; } export interface SlashCommandInfo { name: string; description?: string; source: "extension" | "prompt" | "skill"; sourceInfo?: { path: string; source: string; scope: "user" | "project" | "temporary"; origin: "package" | "top-level"; baseDir?: string; }; } export type BuiltinSlashCommandResult = | { handled: false } | { handled: true; message?: string; error?: string; action?: "openSessionStats" }; export interface UseAgentSessionOptions { session: SessionInfo | null; sessionRunning?: boolean; newSessionCwd: string | null; newSessionDraftKey: string | null; onAgentEnd?: () => void; onAttentionNeeded?: (request: BlockingExtensionUiRequest) => void; onSessionCreated?: (session: SessionInfo, sourceDraftKey: string) => void; onSessionForked?: (newSessionId: string) => void; modelsRefreshKey?: number; chatInputRef?: React.RefObject; onBranchDataChange?: (tree: SessionTreeNode[], activeLeafId: string | null, onLeafChange: (leafId: string | null) => void) => void; onSystemPromptChange?: (prompt: string | null) => void; /** Registers an action that lazily starts the session and returns its system prompt. */ onSystemPromptLoaderChange?: (loader: (() => Promise) | null) => void; onSessionStatsPanelOpen?: () => void; setToolPreset?: (preset: ToolPreset) => void; } export type ThinkingLevelOption = "auto" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; const PROMPT_SETTLE_INITIAL_DELAY_MS = 800; const PROMPT_SETTLE_POLL_MS = 600; const PROMPT_SETTLE_MAX_MS = 20_000; const EVENT_STREAM_IDLE_GRACE_MS = 30_000; const AGENT_STATE_RECONCILE_MS = 15_000; const BASH_STATE_RECONCILE_MS = 1_000; const EVENT_STREAM_READY_TIMEOUT_MS = 60_000; const EVENT_STREAM_RECONNECT_DELAY_MS = 1_000; const MAX_NOTICES = 5; const NOTICE_VISIBLE_MS = 5000; const NOTICE_EXIT_ANIMATION_MS = 180; function createNoticeId(): string { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } return `${Date.now()}-${Math.random().toString(36).slice(2)}`; } function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function markOldestNoticeExiting(notices: NoticeItem[]): NoticeItem[] { const index = notices.findIndex((notice) => !notice.exiting); if (index === -1) return notices; return notices.map((notice, i) => ( i === index ? { ...notice, exiting: true } : notice )); } function fillPendingNotices(visible: NoticeItem[], pending: NoticeItem[]): NoticeState { let nextVisible = visible; let nextPending = pending; while (nextPending.length > 0 && nextVisible.length < MAX_NOTICES) { const [next, ...rest] = nextPending; nextVisible = [...nextVisible, next]; nextPending = rest; } if (nextPending.length > 0 && !nextVisible.some((notice) => notice.exiting)) { nextVisible = markOldestNoticeExiting(nextVisible); } return { visible: nextVisible, pending: nextPending }; } function noticeReducer(state: NoticeState, action: NoticeAction): NoticeState { switch (action.type) { case "add": { if (state.visible.some((notice) => notice.exiting) || state.visible.length >= MAX_NOTICES) { return { visible: state.visible.some((notice) => notice.exiting) ? state.visible : markOldestNoticeExiting(state.visible), pending: [...state.pending, action.notice], }; } return { ...state, visible: [...state.visible, action.notice] }; } case "mark_oldest_exiting": return { ...state, visible: markOldestNoticeExiting(state.visible) }; case "remove": { const visible = state.visible.filter((notice) => notice.id !== action.id); return fillPendingNotices(visible, state.pending); } default: return state; } } function readCompactResult(result: unknown, reason: string): CompactResultInfo | null { if (!result || typeof result !== "object") return null; const r = result as CompactCommandResult; if (typeof r.tokensBefore !== "number" || typeof r.estimatedTokensAfter !== "number") return null; return { reason, tokensBefore: r.tokensBefore, estimatedTokensAfter: r.estimatedTokensAfter }; } export interface ChatInputHandle { insertText: (text: string) => void; insertIfEmpty: (content: string) => void; replaceMessage: (message: UserMessage) => void; prependText: (text: string) => void; addImages: (files: File[]) => void; rekeyDraft: (previousKey: string, nextKey: string) => void; restoreSubmission: (text: string, images?: Array<{ data: string; mimeType: string }>, targetDraftKey?: string) => void; } export interface AttachedImage { data: string; mimeType: string; previewUrl: string; } type SelectedModel = { provider: string; modelId: string }; type ModelEntry = { id: string; name: string; provider: string }; type ModelsResponse = { models: Record; modelList?: ModelEntry[]; defaultModel?: SelectedModel | null; thinkingLevels?: Record; thinkingLevelMaps?: Record>; thinkingLevelPins?: Record; modelError?: string; modelScopeWarnings?: string[]; }; type SlashCommandsResponse = { commands?: SlashCommandInfo[]; }; export function useAgentSession(opts: UseAgentSessionOptions) { const { session, sessionRunning, newSessionCwd, newSessionDraftKey, onAgentEnd, onAttentionNeeded, onSessionCreated, onSessionForked, modelsRefreshKey, onBranchDataChange, onSystemPromptChange, onSystemPromptLoaderChange, onSessionStatsPanelOpen, } = opts; const isNew = session === null && newSessionCwd !== null; const [data, setData] = useState(null); const [loading, setLoading] = useState(!isNew); const [error, setError] = useState(null); const [activeLeafId, setActiveLeafId] = useState(null); const [messages, setMessages] = useState([]); const [entryIds, setEntryIds] = useState([]); const [streamState, dispatch] = useReducer(streamReducer, INITIAL_STREAMING_STATE); const [agentRunning, setAgentRunning] = useState(false); const [bashRunning, setBashRunning] = useState(false); const [pendingBash, setPendingBash] = useState<{ command: string; excludeFromContext: boolean } | null>(null); const [modelNames, setModelNames] = useState>({}); const [modelList, setModelList] = useState([]); const [modelError, setModelError] = useState(null); const [modelScopeWarnings, setModelScopeWarnings] = useState([]); const [modelThinkingLevels, setModelThinkingLevels] = useState>({}); const [modelThinkingLevelMaps, setModelThinkingLevelMaps] = useState>>({}); const [newSessionModel, setNewSessionModel] = useState(null); const [newSessionDefaultModel, setNewSessionDefaultModel] = useState(null); const [toolPreset, setToolPreset] = useState("default"); const [thinkingLevel, setThinkingLevel] = useState("auto"); const [retryInfo, setRetryInfo] = useState<{ attempt: number; maxAttempts: number; errorMessage?: string } | null>(null); const [contextUsage, setContextUsage] = useState<{ percent: number | null; contextWindow: number; tokens: number | null } | null>(null); const [systemPrompt, setSystemPrompt] = useState(null); const [forkingEntryId, setForkingEntryId] = useState(null); const [currentModelOverride, setCurrentModelOverride] = useState<{ provider: string; modelId: string } | null>(null); const [pendingModel, setPendingModel] = useState<{ provider: string; modelId: string } | null>(null); const [modelSwitching, setModelSwitching] = useState(false); const [isCompacting, setIsCompacting] = useState(false); const [compactError, setCompactError] = useState(null); const [compactResult, setCompactResult] = useState(null); const [agentPhase, setAgentPhase] = useState(null); const [promptAnchorActive, setPromptAnchorActive] = useState(false); const [slashCommands, setSlashCommands] = useState([]); const [slashCommandsLoading, setSlashCommandsLoading] = useState(false); const [noticeState, dispatchNotice] = useReducer(noticeReducer, { visible: [], pending: [] }); const [sessionStatsOverride, setSessionStatsOverride] = useState(null); const [extensionDialog, setExtensionDialog] = useState(null); const [extensionCustomUi, setExtensionCustomUi] = useState(null); const [extensionStatuses, setExtensionStatuses] = useState([]); const [extensionWidgets, setExtensionWidgets] = useState([]); const [queuedMessages, setQueuedMessages] = useState({ steering: [], followUp: [] }); const eventConnectionRef = useRef(null); const eventStreamGraceTimerRef = useRef | null>(null); const eventStreamGraceGenerationRef = useRef(0); const eventStreamGraceActiveRef = useRef(false); const sessionIdRef = useRef(session?.id ?? null); const sessionPropIdRef = useRef(session?.id ?? null); const sessionRunningRef = useRef(Boolean(sessionRunning)); const agentRunningRef = useRef(false); const sdkAgentActiveRef = useRef(false); const rpcPromptPendingRef = useRef(false); const notifiedPromptRunIdRef = useRef(-1); const bashRunningRef = useRef(false); const bashRecoveryIdRef = useRef(0); const handleAgentEventRef = useRef<((event: AgentEvent) => void) | null>(null); const initialScrollDoneRef = useRef(false); const lastUserMsgRef = useRef(null); const pendingScrollToUserRef = useRef(false); const isNearBottomRef = useRef(true); const previousScrollTopRef = useRef(0); const liveFollowFrameRef = useRef(null); const executeBashRef = useRef<(command: string, excludeFromContext: boolean) => Promise | undefined>(undefined); const messagesEndRef = useRef(null); const scrollContainerRef = useRef(null); const ensuringNewSessionRef = useRef | null>(null); const newSessionPromotedRef = useRef(false); const newSessionModelOverrideRef = useRef(null); const thinkingLevelOverrideRef = useRef | null>(null); const promptRunIdRef = useRef(0); const optimisticUserMessageKeyRef = useRef(null); const modelSwitchPendingRef = useRef(false); const draftKeyAliasesRef = useRef(new Map()); const sessionHookMountedRef = useRef(true); sessionPropIdRef.current = session?.id ?? null; sessionRunningRef.current = Boolean(sessionRunning); if (!eventConnectionRef.current) { eventConnectionRef.current = new AgentEventConnection({ createSource: (sid) => new EventSource(`/api/agent/${encodeURIComponent(sid)}/events`), onEvent: (event) => handleAgentEventRef.current?.(event as AgentEvent), shouldMaintain: (sid) => ( sessionHookMountedRef.current && sessionIdRef.current === sid && ( agentRunningRef.current || eventStreamGraceActiveRef.current || (sessionPropIdRef.current === sid && sessionRunningRef.current) ) ), readinessTimeoutMs: EVENT_STREAM_READY_TIMEOUT_MS, reconnectDelayMs: EVENT_STREAM_RECONNECT_DELAY_MS, onUnexpectedError: (error) => { console.error("Failed to maintain the agent event stream:", error); }, }); } const setToolPresetState = opts.setToolPreset ?? setToolPreset; useLayoutEffect(() => { if (!isNew || sessionIdRef.current) return; setToolPresetState(getPreferredToolPreset()); }, [isNew, setToolPresetState]); const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { const container = scrollContainerRef.current; messagesEndRef.current?.scrollIntoView({ behavior }); if (container) previousScrollTopRef.current = container.scrollTop; }, []); const currentModel = currentModelOverride ?? data?.context.model ?? pendingModel ?? null; const displayModel = isNew ? (newSessionModel ?? newSessionDefaultModel) : currentModel; const composerDraftKey = session?.id ?? newSessionDraftKey ?? undefined; const resolveComposerDraftKey = useCallback((key: string | undefined) => { if (!key) return undefined; let resolved = key; const visited = new Set(); while (!visited.has(resolved)) { visited.add(resolved); const next = draftKeyAliasesRef.current.get(resolved); if (!next) break; resolved = next; } return resolved; }, []); const restoreSubmission = useCallback(( text: string, images: AttachedImage[] | undefined, targetDraftKey: string | undefined, ) => { const draftImages = images?.map(({ data, mimeType }) => ({ data, mimeType })); const destinationDraftKey = resolveComposerDraftKey(targetDraftKey); if ( !sessionHookMountedRef.current && !newSessionPromotedRef.current && targetDraftKey === newSessionDraftKey ) return; const input = opts.chatInputRef?.current; if (input) { input.restoreSubmission(text, draftImages, destinationDraftKey); } else if (destinationDraftKey) { restoreDraftSubmission(destinationDraftKey, text, draftImages); } }, [newSessionDraftKey, opts.chatInputRef, resolveComposerDraftKey]); const sessionStats = useMemo(() => { if (sessionStatsOverride) { return { ...sessionStatsOverride, totalActiveMs: data?.totalActiveMs }; } const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }; let cost = 0; let userMessages = 0; let assistantMessages = 0; let toolResults = 0; let toolCalls = 0; for (const msg of messages) { if (msg.role === "user") userMessages += 1; if (msg.role === "toolResult") toolResults += 1; if (msg.role !== "assistant") continue; assistantMessages += 1; const u = (msg as import("@/lib/types").AssistantMessage).usage; toolCalls += (msg as import("@/lib/types").AssistantMessage).content.filter((c) => c.type === "toolCall").length; if (!u) continue; tokens.input += u.input ?? 0; tokens.output += u.output ?? 0; tokens.cacheRead += u.cacheRead ?? 0; tokens.cacheWrite += u.cacheWrite ?? 0; cost += u.cost?.total ?? 0; } tokens.total = tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite; if (tokens.total === 0 && messages.length === 0) return null; return { sessionFile: data?.filePath || undefined, sessionId: sessionIdRef.current ?? session?.id ?? "", sessionName: session?.name, userMessages, assistantMessages, toolCalls, toolResults, totalMessages: messages.length, tokens, cost, totalActiveMs: data?.totalActiveMs, ...(contextUsage ? { contextUsage } : {}), } satisfies SessionStatsInfo; }, [messages, sessionStatsOverride, contextUsage, data?.filePath, data?.totalActiveMs, session?.id, session?.name]); const loadSession = useCallback(async (sid: string, showLoading = false, includeState = false) => { let messagesLoaded = false; try { if (showLoading) setLoading(true); const params = new URLSearchParams({ deferThinking: "1", deferMedia: "1" }); const res = await fetch(`/api/sessions/${encodeURIComponent(sid)}?${params}`); if (res.status === 404) { if (showLoading) { setData(null); setActiveLeafId(null); setMessages([]); setError(null); } return null; } if (!res.ok) throw new Error(`HTTP ${res.status}`); const d = await res.json() as SessionData; if (sessionIdRef.current !== sid) return null; const persistedMessages = d.context.messages; setData(d); setActiveLeafId(d.leafId); setMessages(persistedMessages); setEntryIds(d.context.entryIds ?? []); setCurrentModelOverride((current) => modelSwitchPendingRef.current ? current : null); setError(null); if (d.context.thinkingLevel && d.context.thinkingLevel !== "off") { setThinkingLevel(d.context.thinkingLevel as ThinkingLevelOption); } messagesLoaded = true; if (showLoading) setLoading(false); if (!includeState) return null; try { const stateRes = await fetch(`/api/sessions/${encodeURIComponent(sid)}/state`); if (!stateRes.ok) throw new Error(`HTTP ${stateRes.status}`); const agentState = await stateRes.json() as { running: boolean; state?: AgentStateResponse }; if (sessionIdRef.current !== sid) return null; const liveState = agentState.state; if (liveState) { if (liveState.contextUsage !== undefined) setContextUsage(liveState.contextUsage ?? null); if (liveState.systemPrompt !== undefined) setSystemPrompt(liveState.systemPrompt ?? null); if (liveState.thinkingLevel !== undefined) setThinkingLevel((liveState.thinkingLevel as ThinkingLevelOption) ?? "auto"); if (liveState.extensionStatuses !== undefined) setExtensionStatuses(liveState.extensionStatuses ?? []); if (liveState.extensionWidgets !== undefined) setExtensionWidgets(liveState.extensionWidgets ?? []); if (liveState.queuedMessages !== undefined) setQueuedMessages(normalizeQueuedMessages(liveState.queuedMessages)); } else if (!agentState.running) { setQueuedMessages({ steering: [], followUp: [] }); } return agentState; } catch (e) { console.error("Failed to load agent state:", e); return null; } } catch (e) { setError(String(e)); return null; } finally { if (showLoading && !messagesLoaded) setLoading(false); } }, []); const loadContext = useCallback(async (sid: string, leafId: string | null) => { try { const params = new URLSearchParams({ deferThinking: "1", deferMedia: "1" }); if (leafId) params.set("leafId", leafId); const url = `/api/sessions/${encodeURIComponent(sid)}/context?${params}`; const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const d = await res.json() as { context: { messages: AgentMessage[]; entryIds: string[] } }; setMessages(d.context.messages); setEntryIds(d.context.entryIds ?? []); } catch (e) { console.error("Failed to load context:", e); } }, []); const loadTools = useCallback(async (sid: string) => { try { const tools = await sendAgentCommand(sid, { type: "get_tools" }); if (tools) { const { getPresetFromTools } = await import("@/lib/tool-presets"); setToolPresetState(getPresetFromTools(tools)); } } catch (e) { console.error("Failed to load tools:", e); } }, [setToolPresetState]); const promoteNewSession = useCallback((messageCount = 0, firstMessage = "(no messages)") => { const sid = sessionIdRef.current; if (!isNew || !newSessionCwd || !sid || newSessionPromotedRef.current) return; newSessionPromotedRef.current = true; const provisionalDraftKey = newSessionDraftKey; if (!provisionalDraftKey) return; if (provisionalDraftKey !== sid) { draftKeyAliasesRef.current.set(provisionalDraftKey, sid); const input = opts.chatInputRef?.current; if (input) input.rekeyDraft(provisionalDraftKey, sid); else rekeyDraft(provisionalDraftKey, sid); } onSessionCreated?.({ id: sid, path: "", cwd: newSessionCwd, name: undefined, created: new Date().toISOString(), modified: new Date().toISOString(), messageCount, firstMessage, transient: true, }, provisionalDraftKey); }, [isNew, newSessionCwd, newSessionDraftKey, onSessionCreated, opts.chatInputRef]); const ensureNewSession = useCallback(async () => { if (sessionIdRef.current) return sessionIdRef.current; if (!isNew || !newSessionCwd) return sessionIdRef.current; if (ensuringNewSessionRef.current) return ensuringNewSessionRef.current; const promise = (async () => { // Only send explicit user overrides. The server resolves the current // enabledModels scope atomically with AgentSession construction. const selectedModel = newSessionModelOverrideRef.current; const selectedThinkingLevel = thinkingLevelOverrideRef.current; if (selectedModel) setPendingModel(selectedModel); const toolNames = getToolNamesForPreset(toolPreset); const res = await fetch("/api/agent/new", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cwd: newSessionCwd, type: "ensure_session", toolNames, ...(selectedModel ? { provider: selectedModel.provider, modelId: selectedModel.modelId } : {}), ...(selectedThinkingLevel ? { thinkingLevel: selectedThinkingLevel } : {}), }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const result = await res.json() as { sessionId: string; model?: SelectedModel | null; thinkingLevel?: ThinkingLevelOption; }; const realId = result.sessionId; sessionIdRef.current = realId; if (result.model && newSessionModelOverrideRef.current === selectedModel) { setPendingModel(result.model); if (!selectedModel) setNewSessionDefaultModel(result.model); } if ( result.thinkingLevel && thinkingLevelOverrideRef.current === selectedThinkingLevel ) { setThinkingLevel(result.thinkingLevel); } return realId; })(); ensuringNewSessionRef.current = promise; try { return await promise; } finally { ensuringNewSessionRef.current = null; } }, [isNew, newSessionCwd, toolPreset]); // Opening the System panel is also allowed to initialize an otherwise dormant // session. This is deliberately a non-prompt command: it creates no message // or model run, but lets users inspect the exact prompt before sending one. const loadSystemPrompt = useCallback(async () => { const sid = sessionIdRef.current ?? await ensureNewSession(); if (!sid) return; const state = await sendAgentCommand(sid, { type: "get_state" }); if (!sessionHookMountedRef.current || sessionIdRef.current !== sid) return; setSystemPrompt(state.systemPrompt ?? ""); }, [ensureNewSession]); const loadSlashCommands = useCallback(async () => { const sid = sessionIdRef.current ?? await ensureNewSession(); if (!sid) { setSlashCommands([]); return [] as SlashCommandInfo[]; } setSlashCommandsLoading(true); try { const data = await sendAgentCommand(sid, { type: "get_commands" }); const commands = data?.commands ?? []; setSlashCommands(commands); return commands; } catch (e) { console.error("Failed to load slash commands:", e); setSlashCommands([]); return [] as SlashCommandInfo[]; } finally { setSlashCommandsLoading(false); } }, [ensureNewSession]); const cancelEventStreamGrace = useCallback(() => { eventStreamGraceGenerationRef.current += 1; eventStreamGraceActiveRef.current = false; if (eventStreamGraceTimerRef.current) { clearTimeout(eventStreamGraceTimerRef.current); eventStreamGraceTimerRef.current = null; } }, []); const closeEvents = useCallback(() => { eventConnectionRef.current?.close(); }, []); const ensureEventsConnected = useCallback((sid: string) => ( eventConnectionRef.current!.ensureConnected(sid) ), []); const maintainEventsConnected = useCallback((sid: string) => { eventConnectionRef.current!.maintain(sid); }, []); // A different browser can start this session after it was opened here. // The sidebar's lightweight running-state poll gives us a cheap signal to // attach to the existing SSE stream without adding another synchronization // protocol to the chat. useEffect(() => { if (!session?.id || !sessionRunning) return; maintainEventsConnected(session.id); return () => { if ( sessionIdRef.current === session.id && !agentRunningRef.current && !eventStreamGraceActiveRef.current && (sessionPropIdRef.current !== session.id || !sessionRunningRef.current) ) { eventConnectionRef.current?.close(); } }; }, [maintainEventsConnected, session?.id, sessionRunning]); const respondToExtensionUi = useCallback(async ( request: ExtensionUiDialogRequest, response: { value: string } | { confirmed: boolean } | { cancelled: true }, ) => { const sid = sessionIdRef.current; setExtensionDialog((current) => current?.id === request.id ? null : current); if (!sid) return; try { await sendAgentCommand(sid, { type: "extension_ui_response", id: request.id, ...response, }); } catch (e) { console.error("Failed to send extension UI response:", e); } }, []); const sendExtensionCustomInput = useCallback(async (request: ExtensionUiCustomRequest, data: string) => { const sid = sessionIdRef.current; if (!sid) return; try { await sendAgentCommand(sid, { type: "extension_ui_input", id: request.id, data, }); } catch (e) { console.error("Failed to send extension custom UI input:", e); } }, []); const addNotice = useCallback((notice: { id?: string; message: string; type?: NoticeType }) => { const message = notice.message.trim(); if (!message) return; dispatchNotice({ type: "add", notice: { id: notice.id ?? createNoticeId(), message, type: notice.type ?? "info", }, }); }, []); const handleExtensionUiRequest = useCallback((request: ExtensionUiRequest) => { if (isBlockingExtensionUiRequest(request)) onAttentionNeeded?.(request); switch (request.method) { case "select": case "confirm": case "input": case "editor": setExtensionDialog(request); break; case "notify": { addNotice({ id: request.id, message: request.message, type: request.notifyType ?? "info", }); break; } case "setStatus": setExtensionStatuses((prev) => { const rest = prev.filter((item) => item.key !== request.statusKey); return request.statusText !== undefined ? [...rest, { key: request.statusKey, text: request.statusText }] : rest; }); break; case "setWidget": setExtensionWidgets((prev) => { const rest = prev.filter((item) => item.key !== request.widgetKey); return request.widgetLines ? [...rest, { key: request.widgetKey, lines: request.widgetLines, placement: request.widgetPlacement ?? "aboveEditor", }] : rest; }); break; case "setTitle": if (request.title) document.title = request.title; break; case "set_editor_text": opts.chatInputRef?.current?.insertText(request.text); break; case "custom": setExtensionCustomUi((current) => { if (request.closed) return current?.id === request.id ? null : current; return request; }); break; } }, [addNotice, onAttentionNeeded, opts.chatInputRef]); const settleUiStage = useCallback(() => { const wasRunning = agentRunningRef.current; agentRunningRef.current = false; setAgentRunning(false); setAgentPhase(null); setRetryInfo(null); dispatch({ type: "end" }); return wasRunning; }, []); const notifyPromptStage = useCallback((runId: number) => { if (notifiedPromptRunIdRef.current === runId) return false; notifiedPromptRunIdRef.current = runId; onAgentEnd?.(); return true; }, [onAgentEnd]); const scheduleEventStreamClose = useCallback((sid: string) => { cancelEventStreamGrace(); eventStreamGraceActiveRef.current = true; const generation = eventStreamGraceGenerationRef.current; const checkServerIdle = async () => { if ( generation !== eventStreamGraceGenerationRef.current || sessionIdRef.current !== sid || !eventStreamGraceActiveRef.current ) return; try { const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json() as { running?: boolean; state?: AgentStateResponse }; if ( generation !== eventStreamGraceGenerationRef.current || sessionIdRef.current !== sid || !eventStreamGraceActiveRef.current ) return; const state = data.state; const promptActive = Boolean(data.running && state && (state.isStreaming || state.isPromptRunning)); if (promptActive) { eventStreamGraceActiveRef.current = false; eventStreamGraceTimerRef.current = null; sdkAgentActiveRef.current = Boolean(state?.isStreaming); rpcPromptPendingRef.current = Boolean(state?.isPromptRunning); agentRunningRef.current = true; setAgentRunning(true); setAgentPhase(state?.isStreaming ? { kind: "waiting_model" } : { kind: "running_command" }); return; } if (data.running && state?.isCompacting) { setIsCompacting(true); eventStreamGraceTimerRef.current = setTimeout(() => void checkServerIdle(), PROMPT_SETTLE_POLL_MS); return; } eventStreamGraceActiveRef.current = false; eventStreamGraceTimerRef.current = null; closeEvents(); } catch { // Keep the stream alive while state cannot be verified. if ( generation !== eventStreamGraceGenerationRef.current || sessionIdRef.current !== sid || !eventStreamGraceActiveRef.current ) return; eventStreamGraceTimerRef.current = setTimeout(() => void checkServerIdle(), PROMPT_SETTLE_POLL_MS); } }; eventStreamGraceTimerRef.current = setTimeout(() => void checkServerIdle(), EVENT_STREAM_IDLE_GRACE_MS); }, [cancelEventStreamGrace, closeEvents]); const finishPromptWithoutStream = useCallback(async (sid: string | null = sessionIdRef.current, runId = promptRunIdRef.current) => { // Bail out before loadSession too: a stale finish for a previous run // must not overwrite the messages of the run currently streaming. if (promptRunIdRef.current !== runId) return; try { if (sid) await loadSession(sid); } finally { if (promptRunIdRef.current !== runId) return; const promptWasPending = rpcPromptPendingRef.current; const agentWasActive = sdkAgentActiveRef.current; rpcPromptPendingRef.current = false; sdkAgentActiveRef.current = false; optimisticUserMessageKeyRef.current = null; const wasRunning = settleUiStage(); if (promptWasPending) { notifyPromptStage(runId); } else if (agentWasActive && wasRunning) { onAgentEnd?.(); } if (sid) scheduleEventStreamClose(sid); } }, [loadSession, notifyPromptStage, onAgentEnd, scheduleEventStreamClose, settleUiStage]); const waitForPromptSettlement = useCallback(async (sid: string, runId?: number) => { await delay(PROMPT_SETTLE_INITIAL_DELAY_MS); const startedAt = Date.now(); while (agentRunningRef.current && Date.now() - startedAt < PROMPT_SETTLE_MAX_MS) { if (runId !== undefined && promptRunIdRef.current !== runId) return; try { const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`); if (res.ok) { const data = await res.json() as { running?: boolean; state?: AgentStateResponse }; const state = data.state; if (!data.running || !state || (!state.isStreaming && !state.isPromptRunning)) { await finishPromptWithoutStream(sid, runId); return; } } } catch { // SSE remains the primary completion path. } await delay(PROMPT_SETTLE_POLL_MS); } }, [finishPromptWithoutStream]); const waitForBashSettlement = useCallback(async (sid: string) => { const recoveryId = bashRecoveryIdRef.current + 1; bashRecoveryIdRef.current = recoveryId; while ( bashRunningRef.current && bashRecoveryIdRef.current === recoveryId && sessionIdRef.current === sid ) { await delay(BASH_STATE_RECONCILE_MS); try { const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`); if (!res.ok) continue; const data = await res.json() as { state?: AgentStateResponse }; if (data.state?.isBashRunning) continue; await loadSession(sid); if (bashRecoveryIdRef.current !== recoveryId || sessionIdRef.current !== sid) return; bashRunningRef.current = false; setBashRunning(false); setPendingBash(null); return; } catch { // Keep polling while the page is mounted; network recovery is transparent. } } }, [loadSession]); // Reconcile client streaming state with the server. When SSE events are // missed (network drop, mobile tab backgrounded, half-open connection), // agent_end never arrives and the UI stays in streaming state forever. // If the server reports idle while we still think it's running, finish // through the same settlement path used by non-streaming prompts. const reconcileAgentState = useCallback(async (sid: string) => { if (!agentRunningRef.current || sessionIdRef.current !== sid) return; const runId = promptRunIdRef.current; try { const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`); if (!res.ok) return; const data = await res.json() as { running?: boolean; state?: AgentStateResponse }; // A slow response can straddle a run boundary (previous run finished // and the user already started the next one while this request was in // flight) — everything in it is stale, drop it. if (sessionIdRef.current !== sid || promptRunIdRef.current !== runId) return; const state = data.state; // Mirror compaction state unconditionally: a missed compaction_end // would otherwise leave the "Stop compaction" UI stuck. No state // (wrapper destroyed) means nothing is compacting. setIsCompacting(state?.isCompacting ?? false); setQueuedMessages(normalizeQueuedMessages(state?.queuedMessages)); const busy = data.running && state && (state.isStreaming || state.isPromptRunning || state.isCompacting); if (busy) { sdkAgentActiveRef.current = Boolean(state.isStreaming); rpcPromptPendingRef.current = Boolean(state.isPromptRunning); return; } if (!agentRunningRef.current) return; if (state) { if (state.contextUsage !== undefined) setContextUsage(state.contextUsage ?? null); if (state.systemPrompt !== undefined) setSystemPrompt(state.systemPrompt ?? null); if (state.extensionStatuses !== undefined) setExtensionStatuses(state.extensionStatuses ?? []); if (state.extensionWidgets !== undefined) setExtensionWidgets(state.extensionWidgets ?? []); } await finishPromptWithoutStream(sid, runId); } catch { // Network still down — the next poll / visibility / online tick retries. } }, [finishPromptWithoutStream]); // Recovery net for missed SSE events: while the agent is running, verify // against the server periodically and whenever the tab returns to the // foreground or the network comes back. useEffect(() => { if (!agentRunning) return; const reconcile = () => { // Read the ref on every tick: for brand-new sessions the id is // assigned only after ensure_session returns. const sid = sessionIdRef.current; if (sid) void reconcileAgentState(sid); }; const onVisible = () => { if (document.visibilityState === "visible") reconcile(); }; const interval = setInterval(reconcile, AGENT_STATE_RECONCILE_MS); document.addEventListener("visibilitychange", onVisible); window.addEventListener("online", reconcile); return () => { clearInterval(interval); document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("online", reconcile); }; }, [agentRunning, reconcileAgentState]); useEffect(() => { agentRunningRef.current = agentRunning; }, [agentRunning]); const handleAgentEvent = useCallback((event: AgentEvent) => { switch (event.type) { case "connected": { dispatch({ type: "end" }); if (event.isStreaming === true) { cancelEventStreamGrace(); sdkAgentActiveRef.current = true; agentRunningRef.current = true; setAgentRunning(true); setAgentPhase({ kind: "waiting_model" }); } break; } case "agent_start": cancelEventStreamGrace(); sdkAgentActiveRef.current = true; agentRunningRef.current = true; setAgentRunning(true); setAgentPhase({ kind: "waiting_model" }); dispatch({ type: "start" }); break; case "agent_end": // One logical prompt can emit multiple agent_end events before retrying, // compacting, or continuing messages queued by extension handlers. // Keep the stream open until prompt_done/agent_settled and the idle grace. if (!agentRunningRef.current) break; setAgentPhase(null); setRetryInfo(null); dispatch({ type: "end" }); if (sessionIdRef.current) { loadSession(sessionIdRef.current); fetch(`/api/agent/${encodeURIComponent(sessionIdRef.current)}`) .then((r) => r.json()) .then((d: { state?: AgentStateResponse }) => { if (d.state?.contextUsage !== undefined) setContextUsage(d.state.contextUsage ?? null); if (d.state?.systemPrompt !== undefined) setSystemPrompt(d.state.systemPrompt ?? null); if (d.state?.extensionStatuses !== undefined) setExtensionStatuses(d.state.extensionStatuses ?? []); if (d.state?.extensionWidgets !== undefined) setExtensionWidgets(d.state.extensionWidgets ?? []); // Aborted turns can leave messages queued in pi (delivered with the // next turn); dead wrapper (no state) means the queue is gone. setQueuedMessages(normalizeQueuedMessages(d.state?.queuedMessages)); }) .catch(() => {}); } break; case "agent_settled": { const agentWasActive = sdkAgentActiveRef.current; sdkAgentActiveRef.current = false; if (!agentWasActive || rpcPromptPendingRef.current) break; const sid = sessionIdRef.current; const wasRunning = settleUiStage(); setIsCompacting(false); if (sid) { void loadSession(sid); scheduleEventStreamClose(sid); } if (wasRunning) onAgentEnd?.(); break; } case "prompt_done": { const runId = promptRunIdRef.current; const promptWasPending = rpcPromptPendingRef.current; rpcPromptPendingRef.current = false; optimisticUserMessageKeyRef.current = null; const firstNotification = notifyPromptStage(runId); if (!promptWasPending && !firstNotification) break; const sid = sessionIdRef.current; if (sid) void loadSession(sid); // An extension-injected agent may already have started before the // command's prompt_done. Keep that active stage visible and let its // agent_settled event perform the next completion transition. if (!sdkAgentActiveRef.current) { settleUiStage(); if (sid) scheduleEventStreamClose(sid); } } break; case "prompt_error": addNotice({ type: "error", message: (event.errorMessage as string | undefined) ?? "Command failed" }); break; case "extension_error": addNotice({ type: "error", message: (event.error as string | undefined) ?? "Extension command failed", }); break; case "message_start": case "message_update": { // Ignore streaming events arriving after this run already finished // (e.g. SSE data buffered while the tab was frozen, flushed after // reconcile) — they would resurrect a ghost streaming bubble. if (!agentRunningRef.current) break; if (event.type === "message_start") { const msg = event.message as AgentMessage | undefined; if (msg?.role === "user") break; if (msg?.role === "assistant") { dispatch({ type: "snapshot", message: msg }); if (msg.content.length > 0) setAgentPhase(null); } else if (msg) { setAgentPhase(null); } } else { const delta = event.assistantMessageEvent as ClientAssistantMessageEvent | undefined; if (delta) { dispatch({ type: "delta", event: delta }); if (delta.type !== "toolcall_start" && delta.type !== "toolcall_delta") { setAgentPhase(null); } } } // Live-follow the streaming output only when the user is already near // the bottom of the message list. If they scrolled up, leave them there. if (!pendingScrollToUserRef.current && isNearBottomRef.current && liveFollowFrameRef.current === null) { // Defer the scroll so React has time to update the DOM with the new // streaming content; otherwise scrollIntoView may target stale layout. liveFollowFrameRef.current = requestAnimationFrame(() => { liveFollowFrameRef.current = null; if (isNearBottomRef.current) scrollToBottom("auto"); }); } break; } case "message_end": { // Same late-event guard: after reconcile finished this run, // loadSession already loaded this message from the session file — // appending it again would duplicate it. if (!agentRunningRef.current) break; const completed = event.message as AgentMessage | undefined; if (completed && completed.role === "user") { // Delivered steering/follow-up messages surface here as user // messages. The run's initial prompt also emits one, but handleSend // already appended it optimistically. Consume only the still-adjacent // optimistic bubble; later same-text queue deliveries must render. const delivered = normalizeToolCalls(completed); const deliveredKey = userMessageKey(delivered); const optimisticKey = optimisticUserMessageKeyRef.current; optimisticUserMessageKeyRef.current = null; setMessages((prev) => { const last = prev[prev.length - 1]; if (optimisticKey && last?.role === "user" && userMessageKey(last) === optimisticKey) { return optimisticKey === deliveredKey ? prev : [...prev.slice(0, -1), delivered]; } return [...prev, delivered]; }); } else if (completed) { setMessages((prev) => [...prev, normalizeToolCalls(completed)]); } dispatch({ type: "end" }); setAgentPhase({ kind: "waiting_model" }); break; } case "tool_execution_start": { const id = event.toolCallId as string; const name = event.toolName as string; setAgentPhase((prev) => { const tools = prev?.kind === "running_tools" ? [...prev.tools] : []; if (!tools.some((t) => t.id === id)) tools.push({ id, name }); return { kind: "running_tools", tools }; }); break; } case "tool_execution_end": { const id = event.toolCallId as string; setAgentPhase((prev) => { if (prev?.kind !== "running_tools") return prev; const tools = prev.tools.filter((t) => t.id !== id); if (tools.length === 0) return { kind: "waiting_model" }; return { kind: "running_tools", tools }; }); break; } case "queue_update": setQueuedMessages({ steering: [...((event.steering as string[] | undefined) ?? [])], followUp: [...((event.followUp as string[] | undefined) ?? [])], }); break; case "auto_retry_start": setRetryInfo({ attempt: event.attempt as number, maxAttempts: event.maxAttempts as number, errorMessage: event.errorMessage as string | undefined }); break; case "auto_retry_end": setRetryInfo(null); break; case "auto_compaction_start": case "compaction_start": setIsCompacting(true); setCompactError(null); setCompactResult(null); break; case "auto_compaction_end": case "compaction_end": setIsCompacting(false); if (event.errorMessage) { setCompactError(event.errorMessage as string); setCompactResult(null); } else if (!event.aborted) { setCompactResult(readCompactResult(event.result, (event.reason as string | undefined) ?? "auto")); if (sessionIdRef.current) loadSession(sessionIdRef.current); } break; case "extension_ui_request": handleExtensionUiRequest(event as ExtensionUiRequest); break; } }, [addNotice, cancelEventStreamGrace, handleExtensionUiRequest, loadSession, notifyPromptStage, onAgentEnd, scheduleEventStreamClose, scrollToBottom, settleUiStage]); handleAgentEventRef.current = handleAgentEvent; const handleSend = useCallback(async (message: string, images?: AttachedImage[]) => { const trimmedMessage = message.trim(); if (!trimmedMessage && !images?.length) return; if (agentRunningRef.current || bashRunningRef.current) { restoreSubmission(message, images, composerDraftKey); return; } const isSlashCommandPrompt = !images?.length && trimmedMessage.startsWith("/"); const isBashCommand = !images?.length && trimmedMessage.startsWith("!"); if (isBashCommand) { const isExcluded = trimmedMessage.startsWith("!!"); const bashCmd = (isExcluded ? trimmedMessage.slice(2) : trimmedMessage.slice(1)).trim(); if (!bashCmd) { restoreSubmission(message, images, composerDraftKey); return; } await executeBashRef.current?.(bashCmd, isExcluded); return; } const promptRunId = promptRunIdRef.current + 1; cancelEventStreamGrace(); rpcPromptPendingRef.current = true; const imageBlocks = images?.map((img) => ({ type: "image" as const, source: { type: "base64" as const, media_type: img.mimeType, data: img.data } })); const userMsg: AgentMessage = { role: "user", content: imageBlocks?.length ? [...(message.trim() ? [{ type: "text" as const, text: message }] : []), ...imageBlocks] : message, timestamp: Date.now(), }; setMessages((prev) => [...prev, userMsg]); optimisticUserMessageKeyRef.current = userMessageKey(userMsg); promptRunIdRef.current = promptRunId; agentRunningRef.current = true; setAgentRunning(true); setAgentPhase(isSlashCommandPrompt ? { kind: "running_command" } : { kind: "waiting_model" }); dispatch({ type: "start" }); pendingScrollToUserRef.current = true; setPromptAnchorActive(true); const piImages = images?.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType })); let sentSessionId: string | null = null; let promptRequestStarted = false; try { if (isNew && newSessionCwd) { const selectedModel = newSessionModel; const existingSid = sessionIdRef.current ?? await ensuringNewSessionRef.current; const sid = existingSid ?? await ensureNewSession(); if (!sid) throw new Error("Unable to create a session for the prompt"); sentSessionId = sid; if (selectedModel) { setPendingModel(selectedModel); if (existingSid) { await sendAgentCommand(sid, { type: "set_model", provider: selectedModel.provider, modelId: selectedModel.modelId }); } } await ensureEventsConnected(sid); promptRequestStarted = true; await sendAgentCommand(sid, { type: "prompt", message, ...(piImages?.length ? { images: piImages } : {}), }); promoteNewSession(1, message); } else if (session) { sentSessionId = session.id; await ensureEventsConnected(session.id); promptRequestStarted = true; await sendAgentCommand(session.id, { type: "prompt", message, ...(piImages?.length ? { images: piImages } : {}), }); } else { throw new Error("No active session for the prompt"); } if (isSlashCommandPrompt && sentSessionId) { void waitForPromptSettlement(sentSessionId, promptRunId); } } catch (e) { console.error("Failed to send message:", e); const definitivelyRejected = !promptRequestStarted || isPromptRejectedError(e); // A transport/proxy failure after dispatch is ambiguous: the server may // have accepted the prompt before the response was lost. Keep SSE alive // until server state confirms the run is idle. if (!definitivelyRejected && sentSessionId) { void waitForPromptSettlement(sentSessionId, promptRunId); return; } rpcPromptPendingRef.current = false; setMessages((prev) => { const optimisticIndex = prev.lastIndexOf(userMsg); return optimisticIndex === -1 ? prev : [...prev.slice(0, optimisticIndex), ...prev.slice(optimisticIndex + 1)]; }); addNotice({ type: "error", message: e instanceof Error ? e.message : String(e) }); restoreSubmission(message, images, composerDraftKey); optimisticUserMessageKeyRef.current = null; // Rejection only describes this submission. Another tab or an event we // missed may still have a real run active for the same session, so keep // its SSE connection until server state says the wrapper is idle. if (sentSessionId) { void reconcileAgentState(sentSessionId); return; } agentRunningRef.current = false; closeEvents(); setAgentRunning(false); setAgentPhase(null); dispatch({ type: "end" }); } }, [isNew, newSessionCwd, newSessionModel, session, ensureNewSession, ensureEventsConnected, promoteNewSession, waitForPromptSettlement, addNotice, cancelEventStreamGrace, closeEvents, composerDraftKey, reconcileAgentState, restoreSubmission]); const executeBash = useCallback(async (command: string, excludeFromContext: boolean) => { if (agentRunningRef.current || bashRunningRef.current) return; const inputText = `${excludeFromContext ? "!!" : "!"}${command}`; bashRunningRef.current = true; setPendingBash({ command, excludeFromContext }); setBashRunning(true); try { const sid = sessionIdRef.current ?? session?.id ?? await ensureNewSession(); if (!sid) throw new Error("Unable to create a session for the shell command"); await sendAgentCommand(sid, { type: "bash", command, excludeFromContext, }); await loadSession(sid); promoteNewSession(1, inputText); } catch (e) { console.error("Failed to execute shell command:", e); addNotice({ type: "error", message: e instanceof Error ? e.message : String(e) }); restoreSubmission(inputText, undefined, composerDraftKey); } finally { bashRunningRef.current = false; setPendingBash(null); setBashRunning(false); } }, [addNotice, composerDraftKey, ensureNewSession, loadSession, promoteNewSession, restoreSubmission, session]); executeBashRef.current = executeBash; const handleAbort = useCallback(async () => { const sid = sessionIdRef.current; if (!sid) return; if (bashRunningRef.current) { try { await sendAgentCommand(sid, { type: "abort_bash" }); } catch (e) { console.error("Failed to abort bash:", e); } return; } try { await sendAgentCommand(sid, { type: "abort" }); } catch (e) { console.error("Failed to abort:", e); } }, []); const handleFork = useCallback(async (entryId: string) => { if (bashRunningRef.current) return; const sid = sessionIdRef.current; if (!sid) return; setForkingEntryId(entryId); try { const result = await sendAgentCommand<{ cancelled?: boolean; newSessionId?: string }>(sid, { type: "fork", entryId, }); const { cancelled, newSessionId } = result ?? {}; if (!cancelled && newSessionId) { onSessionForked?.(newSessionId); } } catch (e) { console.error("Fork failed:", e); } finally { setForkingEntryId(null); } }, [onSessionForked]); const handleNavigate = useCallback(async (entryId: string) => { if (bashRunningRef.current) return; const sid = sessionIdRef.current; if (!sid) return; sendAgentCommand(sid, { type: "navigate_tree", targetId: entryId }).catch(() => {}); setActiveLeafId(entryId); await loadContext(sid, entryId); }, [loadContext]); const handleLeafChange = useCallback(async (leafId: string | null) => { if (bashRunningRef.current) return; setActiveLeafId(leafId); const sid = sessionIdRef.current; if (!sid) return; await loadContext(sid, leafId); if (leafId) { sendAgentCommand(sid, { type: "navigate_tree", targetId: leafId }).catch(() => {}); } }, [loadContext]); const handleModelChange = useCallback(async (provider: string, modelId: string) => { if (isNew) { const selectedModel = { provider, modelId }; newSessionModelOverrideRef.current = selectedModel; setNewSessionModel(selectedModel); setPendingModel(selectedModel); const sid = sessionIdRef.current ?? await ensuringNewSessionRef.current; if (!sid) return; try { await sendAgentCommand(sid, { type: "set_model", provider, modelId }); } catch (e) { console.error("Failed to set model:", e); } return; } const sid = sessionIdRef.current; if (!sid || modelSwitchPendingRef.current) return; const target = { provider, modelId }; const previousOverride = currentModelOverride; modelSwitchPendingRef.current = true; setCurrentModelOverride(target); setModelSwitching(true); try { await sendAgentCommand(sid, { type: "set_model", provider, modelId }); // Pi persists model_change synchronously. Reload the canonical session so // the model, thinking level, and active leaf all advance together. modelSwitchPendingRef.current = false; await loadSession(sid); } catch (e) { console.error("Failed to set model:", e); modelSwitchPendingRef.current = false; setCurrentModelOverride(previousOverride); addNotice({ type: "error", message: `Failed to switch model: ${e instanceof Error ? e.message : String(e)}`, }); // A failed response can still follow a server-side write (for example, a // dropped connection), so let the session file settle the displayed model. await loadSession(sid); } finally { modelSwitchPendingRef.current = false; setModelSwitching(false); } }, [addNotice, currentModelOverride, isNew, loadSession, setNewSessionModel]); const handleCompact = useCallback(async () => { const sid = sessionIdRef.current; if (!sid || isCompacting) return; setIsCompacting(true); setCompactError(null); setCompactResult(null); try { const result = await sendAgentCommand(sid, { type: "compact" }); setCompactResult(readCompactResult(result, "manual")); await loadSession(sid, true); } catch (e) { setCompactError(e instanceof Error ? e.message : String(e)); setCompactResult(null); } finally { setIsCompacting(false); } }, [isCompacting, loadSession]); const loadModels = useCallback(async (signal?: AbortSignal) => { const modelCwd = newSessionCwd ?? session?.cwd ?? ""; const modelsUrl = modelCwd ? `/api/models?cwd=${encodeURIComponent(modelCwd)}` : "/api/models"; const res = await fetch(modelsUrl, signal ? { signal } : undefined); if (!res.ok) throw new Error(`HTTP ${res.status}`); const d = await res.json() as ModelsResponse; setModelNames(d.models); setModelError(d.modelError ?? null); setModelScopeWarnings(d.modelScopeWarnings ?? []); setModelThinkingLevels(d.thinkingLevels ?? {}); setModelThinkingLevelMaps(d.thinkingLevelMaps ?? {}); const nextModelList = d.modelList ?? []; setModelList(nextModelList); if (isNew && !sessionIdRef.current) { const match = d.defaultModel ? nextModelList.find((m) => m.id === d.defaultModel?.modelId && m.provider === d.defaultModel?.provider) : undefined; const displayModel = match ?? nextModelList[0]; setNewSessionDefaultModel(displayModel ? { provider: displayModel.provider, modelId: displayModel.id } : null); // An `enabledModels` pattern may pin a thinking level (`anthropic/*:high`). // Like pi, apply it to the model a new session starts with. const pinned = displayModel && d.thinkingLevelPins?.[`${displayModel.provider}/${displayModel.id}`]; if (thinkingLevelOverrideRef.current === null) { setThinkingLevel((pinned as ThinkingLevelOption | undefined) ?? "auto"); } } }, [isNew, newSessionCwd, session?.cwd]); const handleBuiltinSlashCommand = useCallback(async (text: string): Promise => { if (!text.startsWith("/")) return { handled: false }; const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/); if (!match) return { handled: false }; const [, commandName, rawArgs = ""] = match; const args = rawArgs.trim(); const sid = sessionIdRef.current ?? await ensureNewSession(); const complete = (result: BuiltinSlashCommandResult): BuiltinSlashCommandResult => { if (!result.handled) return result; if (result.error) { addNotice({ type: "error", message: result.error }); } else if (result.action !== "openSessionStats") { addNotice({ type: "success", message: result.message ?? "Command completed" }); } return result; }; try { switch (commandName) { case "compact": { if (!sid || isCompacting) return complete({ handled: true, error: "No active session to compact" }); setIsCompacting(true); setCompactError(null); setCompactResult(null); const result = await sendAgentCommand(sid, { type: "compact", ...(args ? { customInstructions: args } : {}), }); setCompactResult(readCompactResult(result, "manual")); if (await loadSession(sid, true)) promoteNewSession(); return complete({ handled: true, message: "Compacted context" }); } case "reload": { if (!sid) return complete({ handled: true, error: "No active session to reload" }); await sendAgentCommand(sid, { type: "reload" }); await Promise.all([ loadSession(sid, false, true), loadTools(sid), loadSlashCommands(), loadModels(), ]); return complete({ handled: true, message: "Reloaded session resources" }); } case "name": { if (!sid) return complete({ handled: true, error: "No active session to name" }); if (!args) return complete({ handled: true, error: "Usage: /name " }); await sendAgentCommand(sid, { type: "set_session_name", name: args }); if (await loadSession(sid)) promoteNewSession(); return complete({ handled: true, message: `Session renamed to ${args}` }); } case "session": { if (!sid) return complete({ handled: true, error: "No active session" }); const stats = await sendAgentCommand(sid, { type: "get_session_stats" }); if (stats) { setSessionStatsOverride(stats); } onSessionStatsPanelOpen?.(); return complete({ handled: true, action: "openSessionStats" }); } case "copy": { if (!sid) return complete({ handled: true, error: "No active session" }); const data = await sendAgentCommand(sid, { type: "get_last_assistant_text" }); const textToCopy = data?.text ?? ""; if (!textToCopy) return complete({ handled: true, error: "No assistant message to copy" }); await navigator.clipboard.writeText(textToCopy); return complete({ handled: true, message: "Copied last assistant message" }); } default: return { handled: false }; } } catch (e) { return complete({ handled: true, error: e instanceof Error ? e.message : String(e) }); } finally { if (commandName === "compact") setIsCompacting(false); } }, [addNotice, ensureNewSession, isCompacting, loadModels, loadSession, loadSlashCommands, loadTools, promoteNewSession, onSessionStatsPanelOpen]); // Let AgentSession.prompt decide atomically whether to queue against the // current run or start a new turn if it settled while the request was in // flight. Direct steer/followUp calls can strand a message in an idle queue. const sendStreamingPrompt = useCallback(async ( message: string, behavior: "steer" | "followUp", images?: AttachedImage[], ) => { const sid = sessionIdRef.current; const restore = () => restoreSubmission(message, images, composerDraftKey); if (!sid) { restore(); addNotice({ type: "error", message: "No active session for the queued message" }); return; } const piImages = images?.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType })); try { await sendAgentCommand(sid, { type: "prompt", message, streamingBehavior: behavior, ...(piImages?.length ? { images: piImages } : {}), }); } catch (e) { console.error("Failed to submit streaming prompt:", e); // A transport failure after dispatch is ambiguous: the server may have // accepted the queued prompt before the response was lost. Restoring in // that case would invite a duplicate turn. if (isPromptRejectedError(e)) restore(); addNotice({ type: "error", message: e instanceof Error ? e.message : String(e), }); } }, [addNotice, composerDraftKey, restoreSubmission]); const handleSteer = useCallback(async (message: string, images?: AttachedImage[]) => { await sendStreamingPrompt(message, "steer", images); }, [sendStreamingPrompt]); const handlePromptWithStreamingBehavior = useCallback(async ( message: string, behavior: "steer" | "followUp", images?: AttachedImage[], ) => { await sendStreamingPrompt(message, behavior, images); }, [sendStreamingPrompt]); const handleFollowUp = useCallback(async (message: string, images?: AttachedImage[]) => { await sendStreamingPrompt(message, "followUp", images); }, [sendStreamingPrompt]); const handleAbortCompaction = useCallback(async () => { const sid = sessionIdRef.current; if (!sid) return; try { await sendAgentCommand(sid, { type: "abort_compaction" }); } catch (e) { console.error("Failed to abort compaction:", e); } }, []); const handleRecallQueue = useCallback(async () => { const sid = sessionIdRef.current; if (!sid) return; try { const result = await sendAgentCommand<{ steering?: string[]; followUp?: string[] }>(sid, { type: "clear_queue" }); // clearQueue also emits an empty queue_update, but that only reaches us // while SSE is connected — clear locally so idle recalls update the UI. setQueuedMessages({ steering: [], followUp: [] }); const texts = [...(result?.steering ?? []), ...(result?.followUp ?? [])]; if (texts.length > 0) { opts.chatInputRef?.current?.prependText(texts.join("\n\n")); } } catch (e) { console.error("Failed to recall queued messages:", e); addNotice({ type: "error", message: "Failed to recall queued messages" }); } }, [opts.chatInputRef, addNotice]); const handleThinkingLevelChange = useCallback(async (level: ThinkingLevelOption) => { setThinkingLevel(level); if (isNew && !sessionIdRef.current) { thinkingLevelOverrideRef.current = level === "auto" ? null : level; } if (level === "auto") return; // "auto" leaves pi's current setting untouched const sid = sessionIdRef.current ?? await ensuringNewSessionRef.current; if (!sid) return; try { await sendAgentCommand(sid, { type: "set_thinking_level", level }); } catch (e) { console.error("Failed to set thinking level:", e); } }, [isNew]); const handleToolPresetChange = useCallback(async (preset: ToolPreset) => { const toolNames = getToolNamesForPreset(preset); setPreferredToolPreset(preset); setToolPresetState(preset); const sid = sessionIdRef.current ?? await ensuringNewSessionRef.current; if (!sid) return; try { await sendAgentCommand(sid, { type: "set_tools", toolNames }); } catch (e) { console.error("Failed to set tools:", e); } }, [setToolPresetState]); const scrollUserMsgToTop = useCallback(() => { const container = scrollContainerRef.current; const el = lastUserMsgRef.current; if (!container || !el) return; const elAbsTop = el.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop; const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight); const targetTop = Math.min(Math.max(0, elAbsTop - 16), maxScrollTop); if (liveFollowFrameRef.current !== null) { cancelAnimationFrame(liveFollowFrameRef.current); liveFollowFrameRef.current = null; } isNearBottomRef.current = true; previousScrollTopRef.current = targetTop; container.scrollTo({ top: targetTop, behavior: "auto" }); }, []); const handleScrollPositionChange = useCallback(() => { const container = scrollContainerRef.current; if (container) { const { scrollTop, clientHeight, scrollHeight } = container; const isAgentRunning = agentRunningRef.current; const wasAttached = isNearBottomRef.current; const isAttached = getLiveFollowAttached( wasAttached, previousScrollTopRef.current, scrollTop, clientHeight, scrollHeight, isAgentRunning ? CHAT_SCROLL_REATTACH_TOLERANCE : CHAT_SCROLL_TAIL_TOLERANCE, ); isNearBottomRef.current = isAttached; previousScrollTopRef.current = scrollTop; if (!wasAttached && isAttached && isAgentRunning) { scrollToBottom("auto"); } else if (!isAttached && liveFollowFrameRef.current !== null) { cancelAnimationFrame(liveFollowFrameRef.current); liveFollowFrameRef.current = null; } } }, [scrollToBottom]); // Load session on mount useEffect(() => { sessionHookMountedRef.current = true; if (session) { sessionIdRef.current = session.id; loadSession(session.id, true, true).then((agentState) => { if (agentState?.running) { loadTools(session.id); if (agentState.state?.isStreaming || agentState.state?.isPromptRunning) { sdkAgentActiveRef.current = Boolean(agentState.state.isStreaming); rpcPromptPendingRef.current = Boolean(agentState.state.isPromptRunning); agentRunningRef.current = true; setAgentRunning(true); setAgentPhase(agentState.state.isStreaming ? { kind: "waiting_model" } : { kind: "running_command" }); dispatch({ type: "start" }); void maintainEventsConnected(session.id); if (!agentState.state.isStreaming && agentState.state.isPromptRunning) { void waitForPromptSettlement(session.id); } } if (agentState.state?.isBashRunning) { bashRunningRef.current = true; setBashRunning(true); void waitForBashSettlement(session.id); } } if (agentState?.state) { if (agentState.state.isCompacting !== undefined) setIsCompacting(agentState.state.isCompacting); if (agentState.state.contextUsage !== undefined) setContextUsage(agentState.state.contextUsage ?? null); if (agentState.state.systemPrompt !== undefined) setSystemPrompt(agentState.state.systemPrompt ?? null); if (agentState.state.thinkingLevel !== undefined) setThinkingLevel((agentState.state.thinkingLevel as ThinkingLevelOption) ?? "auto"); if (agentState.state.extensionStatuses !== undefined) setExtensionStatuses(agentState.state.extensionStatuses ?? []); if (agentState.state.extensionWidgets !== undefined) setExtensionWidgets(agentState.state.extensionWidgets ?? []); if (agentState.state.queuedMessages !== undefined) setQueuedMessages(normalizeQueuedMessages(agentState.state.queuedMessages)); } }); } return () => { sessionHookMountedRef.current = false; const abandonedDraftKey = isNew ? newSessionDraftKey : null; if (abandonedDraftKey) { queueMicrotask(() => { if (!sessionHookMountedRef.current && !newSessionPromotedRef.current) { clearDraft(abandonedDraftKey); } }); } if (liveFollowFrameRef.current !== null) { cancelAnimationFrame(liveFollowFrameRef.current); liveFollowFrameRef.current = null; } bashRecoveryIdRef.current += 1; cancelEventStreamGrace(); closeEvents(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { onSystemPromptChange?.(systemPrompt); }, [systemPrompt, onSystemPromptChange]); useEffect(() => { onSystemPromptLoaderChange?.(loadSystemPrompt); return () => onSystemPromptLoaderChange?.(null); }, [loadSystemPrompt, onSystemPromptLoaderChange]); useEffect(() => { if (!onBranchDataChange) return; onBranchDataChange(data?.tree ?? [], activeLeafId, handleLeafChange); }, [data?.tree, activeLeafId, handleLeafChange, onBranchDataChange]); useEffect(() => { const container = scrollContainerRef.current; if (!container) return; previousScrollTopRef.current = container.scrollTop; container.addEventListener("scroll", handleScrollPositionChange, { passive: true }); return () => { container.removeEventListener("scroll", handleScrollPositionChange); }; }, [messages.length, loading, handleScrollPositionChange]); useEffect(() => { if (!agentRunning) setPromptAnchorActive(false); }, [agentRunning]); useLayoutEffect(() => { if (messages.length > 0) { if (pendingScrollToUserRef.current) { pendingScrollToUserRef.current = false; initialScrollDoneRef.current = true; scrollUserMsgToTop(); } else if (!initialScrollDoneRef.current) { initialScrollDoneRef.current = true; scrollToBottom("instant"); } else if (!agentRunningRef.current && isNearBottomRef.current) { scrollToBottom("auto"); } } }, [messages.length, agentRunning, scrollToBottom, scrollUserMsgToTop]); // Load model list useEffect(() => { const controller = new AbortController(); loadModels(controller.signal).catch((e) => { if (e instanceof DOMException && e.name === "AbortError") return; }); return () => controller.abort(); }, [loadModels, modelsRefreshKey]); useEffect(() => { if (!compactResult) return; const t = setTimeout(() => setCompactResult(null), 6000); return () => clearTimeout(t); }, [compactResult]); useEffect(() => { if (noticeState.visible.length === 0) return; const exiting = noticeState.visible.find((notice) => notice.exiting); if (exiting) { const t = setTimeout(() => { dispatchNotice({ type: "remove", id: exiting.id }); }, NOTICE_EXIT_ANIMATION_MS); return () => clearTimeout(t); } const oldest = noticeState.visible[0]; if (!oldest) return; const t = setTimeout(() => { dispatchNotice({ type: "mark_oldest_exiting" }); }, NOTICE_VISIBLE_MS); return () => clearTimeout(t); }, [noticeState.visible]); useEffect(() => { setSessionStatsOverride(null); }, [messages.length, contextUsage?.tokens, contextUsage?.percent, contextUsage?.contextWindow]); return { // State data, loading, error, activeLeafId, messages, entryIds, streamState, agentRunning, modelNames, modelList, modelError, modelScopeWarnings, modelThinkingLevels, modelThinkingLevelMaps, newSessionModel, toolPreset, thinkingLevel, retryInfo, contextUsage, systemPrompt, forkingEntryId, isCompacting, compactError, compactResult, currentModel, displayModel, modelSwitching, sessionStats, slashCommands, slashCommandsLoading, queuedMessages, notices: noticeState.visible, extensionDialog, extensionCustomUi, extensionStatuses, extensionWidgets, respondToExtensionUi, sendExtensionCustomInput, isAutoModelSelection: isNew && newSessionModel === null, agentPhase, isNew, promptAnchorActive, // Refs sessionIdRef, messagesEndRef, scrollContainerRef, lastUserMsgRef, pendingScrollToUserRef, initialScrollDoneRef, // Actions handleSend, handleAbort, handleFork, handleNavigate, handleModelChange, handleCompact, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction, handleRecallQueue, handleBuiltinSlashCommand, handleToolPresetChange, handleThinkingLevelChange, loadTools, loadSlashCommands, setActiveLeafId, setData, setMessages, scrollToBottom, scrollUserMsgToTop, dispatch, setAgentRunning, setForkingEntryId, bashRunning, pendingBash, // Subscriptions handleAgentEventRef, }; }