"use client"; import { useState, useCallback, useRef, useEffect, useMemo, useReducer } from "react"; import type { AgentMessage, ExtensionStatusItem, ExtensionUiRequest, ExtensionWidgetItem, SessionInfo, SessionTreeNode, } from "@/lib/types"; import { normalizeToolCalls } from "@/lib/normalize"; import { sendAgentCommand } from "@/lib/agent-client"; import { getToolNamesForPreset, type ToolEntry } from "@/lib/tool-presets"; import type { SessionStatsInfo } from "@/lib/pi-types"; export interface SessionData { sessionId: string; filePath: string; tree: SessionTreeNode[]; leafId: string | null; context: { messages: AgentMessage[]; entryIds: string[]; thinkingLevel: string; model: { provider: string; modelId: string } | null; }; } interface StreamingState { isStreaming: boolean; streamingMessage: Partial | null; } type StreamAction = | { type: "start" } | { type: "update"; message: Partial } | { type: "end" } | { type: "reset" }; function streamReducer(state: StreamingState, action: StreamAction): StreamingState { switch (action.type) { case "start": return { isStreaming: true, streamingMessage: null }; case "update": return { isStreaming: true, streamingMessage: action.message }; case "end": case "reset": return { isStreaming: false, streamingMessage: null }; default: return state; } } 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; newSessionCwd: string | null; onAgentEnd?: () => void; onSessionCreated?: (session: SessionInfo) => 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; onSessionStatsPanelOpen?: () => void; setToolPreset?: (preset: "none" | "default" | "full") => void; } export type ThinkingLevelOption = "auto" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; const PROGRAMMATIC_SCROLL_IGNORE_MS = 700; const USER_SCROLL_INTENT_MS = 1200; const PROMPT_SETTLE_INITIAL_DELAY_MS = 800; const PROMPT_SETTLE_POLL_MS = 600; const PROMPT_SETTLE_MAX_MS = 20_000; const AGENT_STATE_RECONCILE_MS = 15_000; const BASH_STATE_RECONCILE_MS = 1_000; const EVENT_STREAM_CONNECT_TIMEOUT_MS = 5_000; const MAX_NOTICES = 5; const NOTICE_VISIBLE_MS = 5000; const NOTICE_EXIT_ANIMATION_MS = 180; const SCROLL_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Space", "Spacebar"]); type EventStreamConnectionStatus = "connected" | "timeout" | "closed"; type EventStreamConnectionResult = { status: EventStreamConnectionStatus; source: EventSource; }; class EventStreamConnectionError extends Error { constructor(public readonly status: Exclude) { super(status === "timeout" ? "Timed out connecting to the agent event stream. Please try again." : "Failed to connect to the agent event stream. Please try again."); this.name = "EventStreamConnectionError"; } } 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 extractMessageText(message: Partial): string { const content = (message as { content?: unknown }).content; if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .map((block) => block && typeof block === "object" && (block as { type?: string }).type === "text" && typeof (block as { text?: unknown }).text === "string" ? (block as { text: string }).text : "") .filter(Boolean) .join("\n"); } function imageSignature(block: unknown): string { if (!block || typeof block !== "object" || (block as { type?: unknown }).type !== "image") return ""; const source = (block as { source?: unknown }).source; if (source && typeof source === "object") { const src = source as { type?: unknown; media_type?: unknown; data?: unknown; url?: unknown }; 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(":"); } const flat = block as { data?: unknown; mimeType?: unknown }; return [ "base64", typeof flat.mimeType === "string" ? flat.mimeType : "", typeof flat.data === "string" ? flat.data : "", "", ].join(":"); } function userMessageKey(message: Partial): string { const content = (message as { content?: unknown }).content; if (typeof content === "string") return JSON.stringify({ text: content, images: [] }); if (!Array.isArray(content)) return JSON.stringify({ text: "", images: [] }); return JSON.stringify({ text: extractMessageText(message), images: content.map(imageSignature).filter(Boolean), }); } 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; prependText: (text: string) => void; addImages: (files: File[]) => 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>; modelError?: string; }; type SlashCommandsResponse = { commands?: SlashCommandInfo[]; }; export function useAgentSession(opts: UseAgentSessionOptions) { const { session, newSessionCwd, onAgentEnd, onSessionCreated, onSessionForked, modelsRefreshKey, onBranchDataChange, onSystemPromptChange, 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, { isStreaming: false, streamingMessage: null }); 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 [modelThinkingLevels, setModelThinkingLevels] = useState>({}); const [modelThinkingLevelMaps, setModelThinkingLevelMaps] = useState>>({}); const [newSessionModel, setNewSessionModel] = useState(null); const [newSessionDefaultModel, setNewSessionDefaultModel] = useState(null); const [toolPreset, setToolPreset] = useState<"none" | "default" | "full">("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 [isCompacting, setIsCompacting] = useState(false); const [compactError, setCompactError] = useState(null); const [compactResult, setCompactResult] = useState(null); const [agentPhase, setAgentPhase] = useState(null); 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 eventSourceRef = useRef(null); const sessionIdRef = useRef(session?.id ?? null); const agentRunningRef = useRef(false); 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 completionScrollAllowedRef = useRef(true); const executeBashRef = useRef<(command: string, excludeFromContext: boolean) => Promise | undefined>(undefined); const userScrollIntentUntilRef = useRef(0); const ignoreProgrammaticScrollUntilRef = useRef(0); const messagesEndRef = useRef(null); const scrollContainerRef = useRef(null); const ensuringNewSessionRef = useRef | null>(null); const newSessionPromotedRef = useRef(false); const promptRunIdRef = useRef(0); const optimisticUserMessageKeyRef = useRef(null); const setToolPresetState = opts.setToolPreset ?? setToolPreset; const currentModel = currentModelOverride ?? data?.context.model ?? pendingModel ?? null; const displayModel = isNew ? (newSessionModel ?? newSessionDefaultModel) : currentModel; const sessionStats = useMemo(() => { if (sessionStatsOverride) return sessionStatsOverride; 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, ...(contextUsage ? { contextUsage } : {}), } satisfies SessionStatsInfo; }, [messages, sessionStatsOverride, contextUsage, data?.filePath, 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; setData(d); setActiveLeafId(d.leafId); setMessages(d.context.messages); setEntryIds(d.context.entryIds ?? []); setCurrentModelOverride(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.status === 404) { if (sessionIdRef.current === sid) { setQueuedMessages({ steering: [], followUp: [] }); } return { running: false }; } 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; onSessionCreated?.({ id: sid, path: "", cwd: newSessionCwd, name: undefined, created: new Date().toISOString(), modified: new Date().toISOString(), messageCount, firstMessage, }); }, [isNew, newSessionCwd, onSessionCreated]); 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 () => { const selectedModel = newSessionModel ?? newSessionDefaultModel; 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 } : {}), ...(thinkingLevel !== "auto" ? { thinkingLevel } : {}), }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const result = await res.json() as { sessionId: string }; const realId = result.sessionId; sessionIdRef.current = realId; return realId; })(); ensuringNewSessionRef.current = promise; try { return await promise; } finally { ensuringNewSessionRef.current = null; } }, [isNew, newSessionCwd, newSessionModel, newSessionDefaultModel, toolPreset, thinkingLevel]); 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 connectEvents = useCallback((sid: string): Promise => { if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } const es = new EventSource(`/api/agent/${encodeURIComponent(sid)}/events`); eventSourceRef.current = es; return new Promise((resolve) => { let settled = false; const settle = (status: EventStreamConnectionStatus) => { if (settled) return; settled = true; clearTimeout(timeout); resolve({ status, source: es }); }; const timeout = setTimeout(() => settle("timeout"), EVENT_STREAM_CONNECT_TIMEOUT_MS); es.onmessage = (e) => { try { const event = JSON.parse(e.data) as AgentEvent; if (event.type === "connected") settle("connected"); handleAgentEventRef.current?.(event); } catch { // ignore } }; es.onerror = () => { if (es.readyState === EventSource.CLOSED) { // Fatal error (404/500/content-type mismatch): browser won't // auto-reconnect. Settle the Promise and manually reconnect for // already-running sessions. settle("closed"); if (eventSourceRef.current === es && agentRunningRef.current) { eventSourceRef.current = null; setTimeout(() => { if (agentRunningRef.current) void connectEvents(sid); }, 1000); } } // Recoverable errors (CONNECTING): let EventSource auto-reconnect. // The timeout above resolves only to let callers decide whether this // connection must be ready before they continue. }; }); }, []); const ensureEventsConnected = useCallback(async (sid: string) => { const result = await connectEvents(sid); if (result.status === "connected" || result.source.readyState === EventSource.OPEN) return; if (eventSourceRef.current === result.source) eventSourceRef.current = null; result.source.close(); throw new EventStreamConnectionError(result.status); }, [connectEvents]); 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) => { 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, opts.chatInputRef]); const finishPromptWithoutStream = useCallback(async (sid: string | null = sessionIdRef.current, runId?: number) => { // Bail out before loadSession too: a stale finish for a previous run // must not overwrite the messages of the run currently streaming. if (runId !== undefined && promptRunIdRef.current !== runId) return; try { if (sid) await loadSession(sid); } finally { if (runId !== undefined && promptRunIdRef.current !== runId) return; optimisticUserMessageKeyRef.current = null; if (!agentRunningRef.current) return; agentRunningRef.current = false; setAgentRunning(false); setAgentPhase(null); setRetryInfo(null); dispatch({ type: "end" }); onAgentEnd?.(); } }, [loadSession, onAgentEnd]); 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) 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 (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 || !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 "agent_start": agentRunningRef.current = true; setAgentRunning(true); setAgentPhase({ kind: "waiting_model" }); dispatch({ type: "start" }); break; case "agent_end": // A late agent_end can arrive over SSE after reconcileAgentState // already finished this run — don't re-trigger completion. if (!agentRunningRef.current) break; agentRunningRef.current = false; setAgentRunning(false); 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(() => {}); } onAgentEnd?.(); break; case "prompt_done": if (!agentRunningRef.current) break; // Extension commands can call pi.sendUserMessage(), which starts its // agent run asynchronously. In that case prompt_done for the command // arrives before agent_start for the injected message. Give that run // time to start and settle against server state instead of ending the // UI immediately and dropping its subsequent streaming events. if (sessionIdRef.current) { void waitForPromptSettlement(sessionIdRef.current, promptRunIdRef.current); } 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; const msg = event.message as Partial | undefined; if (msg?.role === "user") { break; } if (msg) { dispatch({ type: "update", message: normalizeToolCalls(msg as AgentMessage) }); } setAgentPhase(null); 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: "reset" }); 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, handleExtensionUiRequest, loadSession, onAgentEnd, waitForPromptSettlement]); 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) 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) return; await executeBashRef.current?.(bashCmd, isExcluded); return; } const promptRunId = promptRunIdRef.current + 1; 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; completionScrollAllowedRef.current = true; const piImages = images?.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType })); try { let sentSessionId: string | null = null; if (isNew && newSessionCwd) { const selectedModel = newSessionModel; const existingSid = sessionIdRef.current ?? await ensuringNewSessionRef.current; const sid = existingSid ?? await ensureNewSession(); if (sid) { sentSessionId = sid; if (selectedModel) { setPendingModel(selectedModel); if (existingSid) { await sendAgentCommand(sid, { type: "set_model", provider: selectedModel.provider, modelId: selectedModel.modelId }); } } await ensureEventsConnected(sid); await sendAgentCommand(sid, { type: "prompt", message, ...(piImages?.length ? { images: piImages } : {}), }); promoteNewSession(1, message); } } else if (session) { sentSessionId = session.id; await ensureEventsConnected(session.id); await sendAgentCommand(session.id, { type: "prompt", message, ...(piImages?.length ? { images: piImages } : {}), }); } if (isSlashCommandPrompt && sentSessionId) { void waitForPromptSettlement(sentSessionId, promptRunId); } } catch (e) { console.error("Failed to send message:", e); if (e instanceof EventStreamConnectionError) { const optimisticKey = optimisticUserMessageKeyRef.current; if (optimisticKey) { setMessages((prev) => { const last = prev[prev.length - 1]; return last?.role === "user" && userMessageKey(last) === optimisticKey ? prev.slice(0, -1) : prev; }); } addNotice({ type: "error", message: e.message }); // The prompt never reached the agent, so restore the user's text into // the input instead of losing it. Mirrors the shell-command recovery in // executeBash; insertIfEmpty avoids clobbering anything typed since. if (message) opts.chatInputRef?.current?.insertIfEmpty(message); } optimisticUserMessageKeyRef.current = null; agentRunningRef.current = false; setAgentRunning(false); setAgentPhase(null); dispatch({ type: "end" }); } }, [isNew, newSessionCwd, newSessionModel, session, ensureNewSession, ensureEventsConnected, promoteNewSession, waitForPromptSettlement, addNotice, opts.chatInputRef]); 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) }); opts.chatInputRef?.current?.insertIfEmpty(inputText); } finally { bashRunningRef.current = false; setPendingBash(null); setBashRunning(false); } }, [addNotice, ensureNewSession, loadSession, opts.chatInputRef, promoteNewSession, 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) { setNewSessionModel({ provider, modelId }); setPendingModel({ provider, modelId }); 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) return; try { await sendAgentCommand(sid, { type: "set_model", provider, modelId }); setCurrentModelOverride({ provider, modelId }); } catch (e) { console.error("Failed to set model:", e); } }, [isNew, 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); setModelThinkingLevels(d.thinkingLevels ?? {}); setModelThinkingLevelMaps(d.thinkingLevelMaps ?? {}); const nextModelList = d.modelList ?? []; setModelList(nextModelList); if (isNew) { 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); } }, [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]); // Queued (undelivered) messages live in the queue panel only; the chat gets // the real user message when pi delivers it (user message_end event). An // optimistic chat bubble here would duplicate the queue panel and turn into // a ghost message if the queue is recalled. const handleSteer = useCallback(async (message: string, images?: AttachedImage[]) => { const sid = sessionIdRef.current; if (!sid) return; const piImages = images?.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType })); try { await sendAgentCommand(sid, { type: "steer", message, ...(piImages?.length ? { images: piImages } : {}), }); } catch (e) { console.error("Failed to steer:", e); } }, []); const handlePromptWithStreamingBehavior = useCallback(async ( message: string, behavior: "steer" | "followUp", images?: AttachedImage[], ) => { const sid = sessionIdRef.current; if (!sid) 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 queue prompt:", e); } }, []); const handleFollowUp = useCallback(async (message: string, images?: AttachedImage[]) => { const sid = sessionIdRef.current; if (!sid) return; const piImages = images?.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType })); try { await sendAgentCommand(sid, { type: "follow_up", message, ...(piImages?.length ? { images: piImages } : {}), }); } catch (e) { console.error("Failed to follow up:", e); } }, []); 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 (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); } }, []); const handleToolPresetChange = useCallback(async (preset: "none" | "default" | "full") => { const toolNames = getToolNamesForPreset(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 scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { ignoreProgrammaticScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_IGNORE_MS; messagesEndRef.current?.scrollIntoView({ behavior }); }, []); 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; ignoreProgrammaticScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_IGNORE_MS; container.scrollTo({ top: elAbsTop - 16, behavior: "smooth" }); }, []); const markUserScrollIntent = useCallback((event: Event) => { if (event instanceof KeyboardEvent) { if (!SCROLL_KEYS.has(event.key)) return; if (event.target instanceof Element && event.target.closest("input, textarea, [contenteditable='true']")) return; } userScrollIntentUntilRef.current = Date.now() + USER_SCROLL_INTENT_MS; }, []); const handleScrollPositionChange = useCallback(() => { if (!agentRunningRef.current) return; if (Date.now() < ignoreProgrammaticScrollUntilRef.current) return; if (Date.now() > userScrollIntentUntilRef.current) return; completionScrollAllowedRef.current = false; }, []); // Load session on mount useEffect(() => { 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) { agentRunningRef.current = true; setAgentRunning(true); setAgentPhase(agentState.state.isStreaming ? { kind: "waiting_model" } : { kind: "running_command" }); dispatch({ type: "start" }); void connectEvents(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 () => { bashRecoveryIdRef.current += 1; eventSourceRef.current?.close(); eventSourceRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { onSystemPromptChange?.(systemPrompt); }, [systemPrompt, onSystemPromptChange]); useEffect(() => { if (!onBranchDataChange) return; onBranchDataChange(data?.tree ?? [], activeLeafId, handleLeafChange); }, [data?.tree, activeLeafId, handleLeafChange, onBranchDataChange]); useEffect(() => { window.addEventListener("keydown", markUserScrollIntent); window.addEventListener("pointerdown", markUserScrollIntent, { passive: true }); return () => { window.removeEventListener("keydown", markUserScrollIntent); window.removeEventListener("pointerdown", markUserScrollIntent); }; }, [markUserScrollIntent]); useEffect(() => { const container = scrollContainerRef.current; if (!container) return; container.addEventListener("wheel", markUserScrollIntent, { passive: true }); container.addEventListener("touchstart", markUserScrollIntent, { passive: true }); container.addEventListener("scroll", handleScrollPositionChange, { passive: true }); return () => { container.removeEventListener("wheel", markUserScrollIntent); container.removeEventListener("touchstart", markUserScrollIntent); container.removeEventListener("scroll", handleScrollPositionChange); }; }, [messages.length, loading, handleScrollPositionChange, markUserScrollIntent]); useEffect(() => { 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 && completionScrollAllowedRef.current) { scrollToBottom("smooth"); } } }, [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, modelThinkingLevels, modelThinkingLevelMaps, newSessionModel, toolPreset, thinkingLevel, retryInfo, contextUsage, systemPrompt, forkingEntryId, isCompacting, compactError, compactResult, currentModel, displayModel, sessionStats, slashCommands, slashCommandsLoading, queuedMessages, notices: noticeState.visible, extensionDialog, extensionCustomUi, extensionStatuses, extensionWidgets, respondToExtensionUi, sendExtensionCustomInput, isAutoModelSelection: isNew && newSessionModel === null, agentPhase, isNew, // Refs sessionIdRef, eventSourceRef, 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, dispatch, setAgentRunning, setForkingEntryId, bashRunning, pendingBash, // Subscriptions handleAgentEventRef, }; }