import { EDITORS, type EditorId, type NativeApi, type ProjectEntry, type ProjectScript, ModelSlug, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ResolvedKeybindingsConfig, type ProviderApprovalDecision, type ProviderSendTurnAttachmentInput, } from "@t3tools/contracts"; import { type ClipboardEvent, type DragEvent, type FormEvent, type KeyboardEvent, type PointerEvent, type TouchEvent, type WheelEvent, memo, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; import { buildBootstrapInput } from "../historyBootstrap"; import { type ComposerTriggerKind, detectComposerTrigger, replaceTextRange, } from "../composer-logic"; import { DEFAULT_MODEL, DEFAULT_REASONING, MODEL_OPTIONS, REASONING_OPTIONS, ReasoningEffort, resolveModelSlug, } from "../model-logic"; import { derivePendingApprovals, derivePhase, deriveTurnDiffFilesFromUnifiedDiff, deriveTimelineEntries, type TurnDiffSummary, type PendingApproval, deriveWorkLogEntries, formatDuration, formatElapsed, formatTimestamp, } from "../session-logic"; import { isScrollContainerAtBottom } from "../chat-scroll"; import { useStore } from "../store"; import { truncateTitle } from "../truncateTitle"; import { DEFAULT_THREAD_TERMINAL_ID, MAX_THREAD_TERMINAL_COUNT, type ChatImageAttachment, type TurnDiffFileChange, } from "../types"; import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { isOpenFavoriteEditorShortcut, resolveShortcutCommand, shortcutLabelForCommand, } from "../keybindings"; import ChatMarkdown from "./ChatMarkdown"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { useNativeApi } from "../hooks/useNativeApi"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { BotIcon, ChevronDownIcon, CircleAlertIcon, FileIcon, FolderIcon, DiffIcon, FolderClosedIcon, InfoIcon, LockIcon, LockOpenIcon, Undo2Icon, XIcon, } from "lucide-react"; import { Button } from "./ui/button"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { Separator } from "./ui/separator"; import { Group, GroupSeparator } from "./ui/group"; import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"; import { CursorIcon, Icon } from "./Icons"; import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { Badge } from "./ui/badge"; import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId, projectScriptRuntimeEnv, projectScriptIdFromCommand, setupProjectScript, } from "~/projectScripts"; import { Toggle } from "./ui/toggle"; function formatMessageMeta(createdAt: string, duration: string | null): string { if (!duration) return formatTimestamp(createdAt); return `${formatTimestamp(createdAt)} • ${duration}`; } const LAST_EDITOR_KEY = "t3code:last-editor"; const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({ slug, name, searchSlug: slug.toLowerCase(), searchName: name.toLowerCase(), })); const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; function readLastInvokedScriptByProjectFromStorage(): Record { const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); if (!stored) return {}; try { const parsed: unknown = JSON.parse(stored); if (!parsed || typeof parsed !== "object") return {}; return Object.fromEntries( Object.entries(parsed).filter( (entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string", ), ); } catch { return {}; } } function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; if (tone === "tool") return "text-muted-foreground/70"; if (tone === "thinking") return "text-muted-foreground/50"; return "text-muted-foreground/40"; } type SessionContinuityState = "resumed" | "new" | "fallback_new"; interface EnsuredSessionInfo { sessionId: string; resolvedThreadId: string | null; continuityState: SessionContinuityState; } interface ComposerImageAttachment extends Omit { previewUrl: string; file: File; } interface ExpandedImagePreview { src: string; name: string; } type TurnCheckpointFilesState = | { status: "pending" | "loading"; files: TurnDiffFileChange[]; errorMessage?: undefined; } | { status: "error"; files: TurnDiffFileChange[]; errorMessage: string; } | { status: "ready"; files: TurnDiffFileChange[]; errorMessage?: undefined; }; function checkpointErrorMessage(error: unknown): string { if (error instanceof Error && error.message.trim().length > 0) { return error.message; } if (typeof error === "string" && error.trim().length > 0) { return error; } return "Checkpoint diff is unavailable."; } type ComposerCommandItem = | { id: string; type: "path"; path: string; pathKind: ProjectEntry["kind"]; label: string; description: string; } | { id: string; type: "slash-command"; label: string; description: string; } | { id: string; type: "model"; model: ModelSlug; label: string; description: string; }; function readFileAsDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.addEventListener("load", () => { if (typeof reader.result === "string") { resolve(reader.result); return; } reject(new Error("Could not read image data.")); }); reader.addEventListener("error", () => { reject(reader.error ?? new Error("Failed to read image.")); }); reader.readAsDataURL(file); }); } const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { pathValue: string; kind: "file" | "directory"; theme: "light" | "dark"; }) { const [failed, setFailed] = useState(false); const iconUrl = useMemo( () => getVscodeIconUrlForEntry(props.pathValue, props.kind, props.theme), [props.kind, props.pathValue, props.theme], ); useEffect(() => { setFailed(false); }, [iconUrl]); if (failed) { return props.kind === "directory" ? ( ) : ( ); } return ( setFailed(true)} /> ); }); const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { item: ComposerCommandItem; resolvedTheme: "light" | "dark"; onSelect: (item: ComposerCommandItem) => void; }) { return ( { event.preventDefault(); }} onClick={() => { props.onSelect(props.item); }} > {props.item.type === "path" ? ( ) : null} {props.item.type === "slash-command" ? ( ) : null} {props.item.type === "model" ? ( model ) : null} {props.item.label} {props.item.description} ); }); const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { items: ComposerCommandItem[]; resolvedTheme: "light" | "dark"; isLoading: boolean; triggerKind: ComposerTriggerKind | null; onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; commandInputRef: RefObject; }) { return ( { props.onHighlightedItemChange( typeof highlightedValue === "string" ? highlightedValue : null, ); }} >
{props.items.map((item) => ( ))} {props.items.length === 0 && (

{props.isLoading ? "Searching workspace files..." : props.triggerKind === "path" ? "No matching files or folders." : "No matching command."}

)}
); }); interface ChatViewProps { threadId: string; } export default function ChatView({ threadId }: ChatViewProps) { const { state, dispatch } = useStore(); const api = useNativeApi(); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation( gitCreateWorktreeMutationOptions({ api, queryClient }), ); const [prompt, setPrompt] = useState(""); const promptRef = useRef(prompt); const [composerImages, setComposerImages] = useState([]); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [isSending, setIsSending] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [composerCursor, setComposerCursor] = useState(0); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< Record >(() => readLastInvokedScriptByProjectFromStorage()); const messagesScrollRef = useRef(null); const shouldAutoScrollRef = useRef(true); const lastKnownScrollTopRef = useRef(0); const isPointerScrollActiveRef = useRef(false); const lastTouchClientYRef = useRef(null); const pendingUserScrollUpIntentRef = useRef(false); const pendingAutoScrollFrameRef = useRef(null); const textareaRef = useRef(null); const composerCommandInputRef = useRef(null); const composerImagesRef = useRef([]); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); const checkpointHydrationSessionRequestRef = useRef(new Set()); const checkpointTurnCountSyncFingerprintRef = useRef(new Map()); const activeThread = state.threads.find((t) => t.id === threadId); const activeThreadId = activeThread?.id ?? null; const activeSessionId = activeThread?.session?.sessionId ?? null; const activeThreadRuntimeId = activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); const selectedModel = resolveModelSlug( activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, ); const phase = derivePhase(activeThread?.session ?? null); const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const workLogEntries = useMemo( () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), [activeThread?.events], ); const latestTurnWorkEntries = useMemo( () => deriveWorkLogEntries(activeThread?.events ?? [], activeThread?.latestTurnId), [activeThread?.events, activeThread?.latestTurnId], ); const pendingApprovals = useMemo( () => derivePendingApprovals(activeThread?.events ?? []), [activeThread?.events], ); const assistantCompletionByItemId = useMemo(() => { const map = new Map(); const ordered = [...(activeThread?.events ?? [])].toReversed(); for (const event of ordered) { if (event.method !== "item/completed") continue; if (!event.itemId) continue; map.set(event.itemId, event.createdAt); } return map; }, [activeThread?.events]); const timelineEntries = useMemo( () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), [activeThread?.messages, workLogEntries], ); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const turnDiffSummaryByAssistantMessageId = useMemo(() => { const byMessageId = new Map(); for (const summary of turnDiffSummaries) { if (!summary.assistantMessageId) continue; byMessageId.set(summary.assistantMessageId, summary); } return byMessageId; }, [turnDiffSummaries]); const checkpointDiffInputsByTurn = useMemo( () => turnDiffSummaries .filter((summary) => summary.assistantMessageId) .map((summary) => { const checkpointTurnCount = summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; return { turnId: summary.turnId, checkpointTurnCount: typeof checkpointTurnCount === "number" ? checkpointTurnCount : null, }; }), [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], ); const checkpointDiffQueriesByTurn = useQueries({ queries: checkpointDiffInputsByTurn.map((input) => checkpointDiffQueryOptions(api, { sessionId: activeSessionId, threadRuntimeId: activeThreadRuntimeId, fromTurnCount: typeof input.checkpointTurnCount === "number" ? Math.max(0, input.checkpointTurnCount - 1) : null, toTurnCount: input.checkpointTurnCount, cacheScope: `turn:${input.turnId}`, }), ), }); const turnCheckpointFilesByTurnId = useMemo(() => { const byTurnId = new Map(); for (let index = 0; index < checkpointDiffInputsByTurn.length; index += 1) { const input = checkpointDiffInputsByTurn[index]; const query = checkpointDiffQueriesByTurn[index]; if (!input || !query) continue; if (!activeSessionId || typeof input.checkpointTurnCount !== "number") { byTurnId.set(input.turnId, { status: "pending", files: [] }); continue; } const diff = query.data?.diff; if (typeof diff === "string") { byTurnId.set(input.turnId, { status: "ready", files: deriveTurnDiffFilesFromUnifiedDiff(diff), }); continue; } if (query.isError) { byTurnId.set(input.turnId, { status: "error", files: [], errorMessage: checkpointErrorMessage(query.error), }); continue; } byTurnId.set(input.turnId, { status: "loading", files: [] }); } return byTurnId; }, [activeSessionId, checkpointDiffInputsByTurn, checkpointDiffQueriesByTurn]); const revertTurnCountByUserMessageId = useMemo(() => { const byUserMessageId = new Map(); for (let index = 0; index < timelineEntries.length; index += 1) { const entry = timelineEntries[index]; if (!entry || entry.kind !== "message" || entry.message.role !== "user") { continue; } for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) { const nextEntry = timelineEntries[nextIndex]; if (!nextEntry || nextEntry.kind !== "message") { continue; } if (nextEntry.message.role === "user") { break; } const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); if (!summary) { continue; } const turnCount = summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; if (typeof turnCount !== "number") { break; } byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1)); break; } } return byUserMessageId; }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); useEffect(() => { if (!activeThreadId || turnDiffSummaries.length === 0) { return; } const inferredMissingTurnCounts = turnDiffSummaries.reduce>( (acc, summary) => { if (typeof summary.checkpointTurnCount === "number") { return acc; } const inferredTurnCount = inferredCheckpointTurnCountByTurnId[summary.turnId]; if (typeof inferredTurnCount !== "number") { return acc; } acc[summary.turnId] = inferredTurnCount; return acc; }, {}, ); if (Object.keys(inferredMissingTurnCounts).length === 0) { return; } dispatch({ type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", threadId: activeThreadId, checkpointTurnCountByTurnId: inferredMissingTurnCounts, }); }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); const completionSummary = useMemo(() => { if (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; if (!latestTurnWorkEntries.some((entry) => entry.tone === "tool")) { return null; } if ( typeof activeThread.latestTurnDurationMs === "number" && Number.isFinite(activeThread.latestTurnDurationMs) && activeThread.latestTurnDurationMs >= 0 ) { return `Worked for ${formatDuration(activeThread.latestTurnDurationMs)}`; } const elapsed = formatElapsed( activeThread.latestTurnStartedAt, activeThread.latestTurnCompletedAt, ); return elapsed ? `Worked for ${elapsed}` : null; }, [ activeThread?.latestTurnStartedAt, activeThread?.latestTurnCompletedAt, activeThread?.latestTurnDurationMs, latestTurnWorkEntries, ]); const completionDividerBeforeEntryId = useMemo(() => { if (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; if (!completionSummary) return null; const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt); const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt); if (Number.isNaN(turnStartedAt)) return null; if (Number.isNaN(turnCompletedAt)) return null; let inRangeMatch: string | null = null; let fallbackMatch: string | null = null; for (const timelineEntry of timelineEntries) { if (timelineEntry.kind !== "message") continue; if (timelineEntry.message.role !== "assistant") continue; const messageAt = Date.parse(timelineEntry.message.createdAt); if (Number.isNaN(messageAt) || messageAt < turnStartedAt) continue; fallbackMatch = timelineEntry.id; if (messageAt <= turnCompletedAt) { inRangeMatch = timelineEntry.id; } } return inRangeMatch ?? fallbackMatch; }, [ activeThread?.latestTurnCompletedAt, activeThread?.latestTurnStartedAt, completionSummary, timelineEntries, ]); const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; const runtimeSandboxMode = state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; const composerTrigger = useMemo( () => detectComposerTrigger(prompt, composerCursor), [prompt, composerCursor], ); const composerTriggerKind = composerTrigger?.kind ?? null; const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( pathTriggerQuery, { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, (debouncerState) => ({ isPending: debouncerState.isPending }), ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); const keybindingsQuery = useQuery({ ...serverConfigQueryOptions(api), select: (config) => config.keybindings, }); const workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ api, cwd: gitCwd, query: effectivePathQuery, enabled: isPathTrigger, limit: 80, }), ); const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { return workspaceEntries.map((entry) => ({ id: `path:${entry.kind}:${entry.path}`, type: "path", path: entry.path, pathKind: entry.kind, label: basenameOfPath(entry.path), description: entry.parentPath ?? "", })); } if (composerTrigger.kind === "slash-command") { if (!"model".includes(composerTrigger.query.toLowerCase())) { return []; } return [ { id: "slash:model", type: "slash-command", label: "/model", description: "Switch response model for this thread", }, ]; } return SEARCHABLE_MODEL_OPTIONS.filter(({ searchSlug, searchName }) => { const query = composerTrigger.query.trim().toLowerCase(); if (!query) return true; return searchSlug.includes(query) || searchName.includes(query); }).map(({ slug, name }) => ({ id: `model:${slug}`, type: "model", model: slug, label: name, description: slug, })); }, [composerTrigger, workspaceEntries]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () => composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? composerMenuItems[0] ?? null, [composerHighlightedItemId, composerMenuItems], ); const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; const threadTerminalRuntimeEnv = useMemo(() => { if (!activeProject?.cwd) return {}; return projectScriptRuntimeEnv({ project: { cwd: activeProject.cwd, }, worktreePath: activeThread?.worktreePath ?? null, }); }, [activeProject?.cwd, activeThread?.worktreePath]); // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; const splitTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.split"), [keybindings], ); const newTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.new"), [keybindings], ); const closeTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.close"), [keybindings], ); const envLocked = Boolean( activeThread && (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); const hasReachedTerminalLimit = (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT; const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { for (const image of images) { if (!image.previewUrl) continue; URL.revokeObjectURL(image.previewUrl); } }, []); const focusComposer = useCallback(() => { const textarea = textareaRef.current; if (!textarea) return; textarea.focus(); const cursor = textarea.value.length; textarea.setSelectionRange(cursor, cursor); }, []); const scheduleComposerFocus = useCallback(() => { window.requestAnimationFrame(() => { focusComposer(); }); }, [focusComposer]); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadId) return; dispatch({ type: "SET_THREAD_TERMINAL_OPEN", threadId: activeThreadId, open: !activeThread?.terminalOpen, }); }, [activeThread?.terminalOpen, activeThreadId, dispatch]); const splitTerminal = useCallback(() => { if (!activeThreadId || hasReachedTerminalLimit) return; dispatch({ type: "SPLIT_THREAD_TERMINAL", threadId: activeThreadId, terminalId: `terminal-${crypto.randomUUID()}`, }); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, dispatch, hasReachedTerminalLimit]); const createNewTerminal = useCallback(() => { if (!activeThreadId || hasReachedTerminalLimit) return; dispatch({ type: "NEW_THREAD_TERMINAL", threadId: activeThreadId, terminalId: `terminal-${crypto.randomUUID()}`, }); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, dispatch, hasReachedTerminalLimit]); const activateTerminal = useCallback( (terminalId: string) => { if (!activeThreadId) return; dispatch({ type: "SET_THREAD_ACTIVE_TERMINAL", threadId: activeThreadId, terminalId, }); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, dispatch], ); const closeTerminal = useCallback( (terminalId: string) => { if (!activeThreadId || !api) return; const isFinalTerminal = (activeThread?.terminalIds.length ?? 0) <= 1; const fallbackExitWrite = () => api.terminal .write({ threadId: activeThreadId, terminalId, data: "exit\n" }) .catch(() => undefined); if ("close" in api.terminal && typeof api.terminal.close === "function") { void (async () => { if (isFinalTerminal) { await api.terminal .clear({ threadId: activeThreadId, terminalId }) .catch(() => undefined); } await api.terminal.close({ threadId: activeThreadId, terminalId, deleteHistory: true }); })().catch(() => fallbackExitWrite()); } else { void fallbackExitWrite(); } dispatch({ type: "CLOSE_THREAD_TERMINAL", threadId: activeThreadId, terminalId, }); setTerminalFocusRequestId((value) => value + 1); }, [activeThread?.terminalIds.length, activeThreadId, api, dispatch], ); const runProjectScript = useCallback( async ( script: ProjectScript, options?: { cwd?: string; env?: Record; worktreePath?: string | null; preferNewTerminal?: boolean; rememberAsLastInvoked?: boolean; }, ) => { if (!api || !activeThreadId || !activeProject || !activeThread) return; if (options?.rememberAsLastInvoked !== false) { setLastInvokedScriptByProjectId((current) => { if (current[activeProject.id] === script.id) return current; return { ...current, [activeProject.id]: script.id }; }); } const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; const baseTerminalId = activeThread.activeTerminalId || activeThread.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId); const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; const shouldCreateNewTerminal = wantsNewTerminal && activeThread.terminalIds.length < MAX_THREAD_TERMINAL_COUNT; const targetTerminalId = shouldCreateNewTerminal ? `terminal-${crypto.randomUUID()}` : baseTerminalId; dispatch({ type: "SET_THREAD_TERMINAL_OPEN", threadId: activeThreadId, open: true, }); if (shouldCreateNewTerminal) { dispatch({ type: "NEW_THREAD_TERMINAL", threadId: activeThreadId, terminalId: targetTerminalId, }); } else { dispatch({ type: "SET_THREAD_ACTIVE_TERMINAL", threadId: activeThreadId, terminalId: targetTerminalId, }); } setTerminalFocusRequestId((value) => value + 1); const runtimeEnv = projectScriptRuntimeEnv({ project: { cwd: activeProject.cwd, }, worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, ...(options?.env ? { extraEnv: options.env } : {}), }); try { await api.terminal.open({ threadId: activeThreadId, terminalId: targetTerminalId, cwd: targetCwd, env: runtimeEnv, ...(shouldCreateNewTerminal ? { cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS, } : {}), }); await api.terminal.write({ threadId: activeThreadId, terminalId: targetTerminalId, data: `${script.command}\r`, }); } catch (error) { dispatch({ type: "SET_ERROR", threadId: activeThreadId, error: error instanceof Error ? error.message : `Failed to run script "${script.name}".`, }); } }, [activeProject, activeThread, activeThreadId, api, dispatch, gitCwd], ); const persistProjectScripts = useCallback( async (input: { projectId: string; projectCwd: string; previousScripts: ProjectScript[]; nextScripts: ProjectScript[]; keybinding?: string | null; keybindingCommand: string; }) => { dispatch({ type: "SET_PROJECT_SCRIPTS", projectId: input.projectId, scripts: input.nextScripts, }); if (!isElectron || !api) return; let scriptsPersisted = false; try { const updated = await api.projects.updateScripts({ id: input.projectId, scripts: input.nextScripts, }); scriptsPersisted = true; if (input.keybinding) { const keybindingUpdate = await api.server.upsertKeybinding({ key: input.keybinding, command: input.keybindingCommand, }); queryClient.setQueryData( serverQueryKeys.config(), (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) => current ? { ...current, keybindings: keybindingUpdate.keybindings } : { cwd: input.projectCwd, keybindings: keybindingUpdate.keybindings, }, ); } dispatch({ type: "SET_PROJECT_SCRIPTS", projectId: updated.project.id, scripts: updated.project.scripts, }); } catch (error) { if (scriptsPersisted) { await api.projects .updateScripts({ id: input.projectId, scripts: input.previousScripts, }) .catch(() => undefined); } dispatch({ type: "SET_PROJECT_SCRIPTS", projectId: input.projectId, scripts: input.previousScripts, }); throw error; } }, [api, dispatch, queryClient], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput) => { if (!activeProject) return; const nextId = nextProjectScriptId( input.name, activeProject.scripts.map((script) => script.id), ); const nextScript: ProjectScript = { id: nextId, name: input.name, command: input.command, icon: input.icon, runOnWorktreeCreate: input.runOnWorktreeCreate, }; const nextScripts = input.runOnWorktreeCreate ? [ ...activeProject.scripts.map((script) => script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, ), nextScript, ] : [...activeProject.scripts, nextScript]; await persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.cwd, previousScripts: activeProject.scripts, nextScripts, keybinding: input.keybinding, keybindingCommand: commandForProjectScript(nextId), }); }, [activeProject, persistProjectScripts], ); const updateProjectScript = useCallback( async (scriptId: string, input: NewProjectScriptInput) => { if (!activeProject) return; const existingScript = activeProject.scripts.find((script) => script.id === scriptId); if (!existingScript) { throw new Error("Script not found."); } const updatedScript: ProjectScript = { ...existingScript, name: input.name, command: input.command, icon: input.icon, runOnWorktreeCreate: input.runOnWorktreeCreate, }; const nextScripts = activeProject.scripts.map((script) => script.id === scriptId ? updatedScript : input.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, ); await persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.cwd, previousScripts: activeProject.scripts, nextScripts, keybinding: input.keybinding, keybindingCommand: commandForProjectScript(scriptId), }); }, [activeProject, persistProjectScripts], ); const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { if (mode === state.runtimeMode) return; dispatch({ type: "SET_RUNTIME_MODE", mode }); scheduleComposerFocus(); if (!api) return; const sessionIds = state.threads .map((t) => t.session) .filter((s): s is NonNullable => s !== null && s.status !== "closed") .map((s) => s.sessionId); if (sessionIds.length === 0) return; setIsSwitchingRuntimeMode(true); try { await Promise.all( sessionIds.map((id) => api.providers.stopSession({ sessionId: id }).catch(() => undefined)), ); } finally { setIsSwitchingRuntimeMode(false); } }; useEffect(() => { try { if (Object.keys(lastInvokedScriptByProjectId).length === 0) { localStorage.removeItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); return; } localStorage.setItem( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, JSON.stringify(lastInvokedScriptByProjectId), ); } catch { // Ignore storage write failures (private mode, quota exceeded, etc.) } }, [lastInvokedScriptByProjectId]); // Auto-tail while the user stays at the bottom. const workLogCount = workLogEntries.length; const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { const scrollContainer = messagesScrollRef.current; if (!scrollContainer) return; scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); lastKnownScrollTopRef.current = scrollContainer.scrollTop; shouldAutoScrollRef.current = true; }, []); const requestAutoTailToBottom = useCallback(() => { if (pendingAutoScrollFrameRef.current !== null) return; pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { pendingAutoScrollFrameRef.current = null; if (!shouldAutoScrollRef.current) return; scrollMessagesToBottom(); }); }, [scrollMessagesToBottom]); const onMessagesScroll = useCallback(() => { const scrollContainer = messagesScrollRef.current; if (!scrollContainer) return; const currentScrollTop = scrollContainer.scrollTop; const atBottom = isScrollContainerAtBottom(scrollContainer); if (!shouldAutoScrollRef.current && atBottom) { shouldAutoScrollRef.current = true; pendingUserScrollUpIntentRef.current = false; } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) { const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; if (scrolledUp) { shouldAutoScrollRef.current = false; } pendingUserScrollUpIntentRef.current = false; } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { // Pointer-driven upward scroll means user takes control. const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; if (scrolledUp) { shouldAutoScrollRef.current = false; } } lastKnownScrollTopRef.current = currentScrollTop; }, []); const onMessagesWheel = useCallback((event: WheelEvent) => { // Wheel-up indicates user intent to move away from bottom. if (event.deltaY < 0) { pendingUserScrollUpIntentRef.current = true; } }, []); const onMessagesPointerDown = useCallback((_event: PointerEvent) => { isPointerScrollActiveRef.current = true; }, []); const onMessagesPointerUp = useCallback((_event: PointerEvent) => { isPointerScrollActiveRef.current = false; }, []); const onMessagesPointerCancel = useCallback((_event: PointerEvent) => { isPointerScrollActiveRef.current = false; }, []); const onMessagesTouchStart = useCallback((event: TouchEvent) => { const touch = event.touches[0]; if (!touch) return; lastTouchClientYRef.current = touch.clientY; }, []); const onMessagesTouchMove = useCallback((event: TouchEvent) => { const touch = event.touches[0]; if (!touch) return; const previousTouchY = lastTouchClientYRef.current; if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { // Finger moved downward => user intends to scroll content upward. pendingUserScrollUpIntentRef.current = true; } lastTouchClientYRef.current = touch.clientY; }, []); const onMessagesTouchEnd = useCallback((_event: TouchEvent) => { lastTouchClientYRef.current = null; }, []); useLayoutEffect(() => { if (!activeThread?.id) return; scrollMessagesToBottom(); }, [activeThread?.id, scrollMessagesToBottom]); useLayoutEffect(() => { if (!shouldAutoScrollRef.current) return; requestAutoTailToBottom(); }, [activeThread?.messages, workLogCount, requestAutoTailToBottom]); useEffect( () => () => { if (pendingAutoScrollFrameRef.current === null) return; window.cancelAnimationFrame(pendingAutoScrollFrameRef.current); pendingAutoScrollFrameRef.current = null; }, [], ); useEffect(() => { setExpandedWorkGroups({}); }, [activeThread?.id]); useEffect(() => { if (!composerMenuOpen) { setComposerHighlightedItemId(null); return; } setComposerHighlightedItemId((existing) => existing && composerMenuItems.some((item) => item.id === existing) ? existing : null, ); }, [composerMenuItems, composerMenuOpen]); useEffect(() => { setIsRevertingCheckpoint(false); }, [activeThread?.id]); useEffect(() => { if (!activeThread?.id || activeThread.terminalOpen) return; const frame = window.requestAnimationFrame(() => { focusComposer(); }); return () => { window.cancelAnimationFrame(frame); }; }, [activeThread?.id, activeThread?.terminalOpen, focusComposer]); useEffect(() => { composerImagesRef.current = composerImages; }, [composerImages]); useEffect(() => { setComposerImages((existing) => { revokePreviewUrls(existing); return []; }); setPrompt(""); promptRef.current = ""; setIsSending(false); setComposerCursor(0); setComposerHighlightedItemId(null); dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); }, [activeThread?.id, revokePreviewUrls]); useEffect(() => { return () => { revokePreviewUrls(composerImagesRef.current); }; }, [revokePreviewUrls]); /** * Close expanded image on Escape key */ useEffect(() => { if (!expandedImage) { return; } const onKeyDown = (event: globalThis.KeyboardEvent) => { if (event.key !== "Escape") return; setExpandedImage(null); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [expandedImage]); const activeWorktreePath = activeThread?.worktreePath; useEffect(() => { if (!activeThread?.id) return; setEnvMode(activeWorktreePath ? "worktree" : "local"); }, [activeThread?.id, activeWorktreePath]); // Auto-resize textarea useEffect(() => { const ta = textareaRef.current; if (!ta) return; ta.style.height = "auto"; ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; }, [prompt]); useEffect(() => { if (phase !== "running") return; const timer = window.setInterval(() => { setNowTick(Date.now()); }, 1000); return () => { window.clearInterval(timer); }; }, [phase]); useEffect(() => { if (!activeThreadId) return; const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; const current = Boolean(activeThread?.terminalOpen); if (!previous && current) { setTerminalFocusRequestId((value) => value + 1); } else if (previous && !current) { terminalOpenByThreadRef.current[activeThreadId] = current; const frame = window.requestAnimationFrame(() => { focusComposer(); }); return () => { window.cancelAnimationFrame(frame); }; } terminalOpenByThreadRef.current[activeThreadId] = current; }, [activeThread?.terminalOpen, activeThreadId, focusComposer]); useEffect(() => { const isTerminalFocused = (): boolean => { const activeElement = document.activeElement; if (!(activeElement instanceof HTMLElement)) return false; if (activeElement.classList.contains("xterm-helper-textarea")) return true; return activeElement.closest(".thread-terminal-drawer .xterm") !== null; }; const handler = (event: globalThis.KeyboardEvent) => { if (!activeThreadId || event.defaultPrevented) return; const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(activeThread?.terminalOpen), }; const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext }); if (!command) return; if (command === "terminal.toggle") { event.preventDefault(); event.stopPropagation(); toggleTerminalVisibility(); return; } if (command === "terminal.split") { event.preventDefault(); event.stopPropagation(); if (!activeThread?.terminalOpen) { dispatch({ type: "SET_THREAD_TERMINAL_OPEN", threadId: activeThreadId, open: true, }); } splitTerminal(); return; } if (command === "terminal.close") { event.preventDefault(); event.stopPropagation(); if (!activeThread?.terminalOpen) return; closeTerminal(activeThread.activeTerminalId); return; } if (command === "terminal.new") { event.preventDefault(); event.stopPropagation(); if (!activeThread?.terminalOpen) { dispatch({ type: "SET_THREAD_TERMINAL_OPEN", threadId: activeThreadId, open: true, }); } createNewTerminal(); return; } const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; const script = activeProject.scripts.find((entry) => entry.id === scriptId); if (!script) return; event.preventDefault(); event.stopPropagation(); void runProjectScript(script); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [ activeProject, activeThread?.terminalOpen, activeThread?.activeTerminalId, activeThreadId, closeTerminal, createNewTerminal, dispatch, runProjectScript, splitTerminal, keybindings, toggleTerminalVisibility, ]); const setThreadError = useCallback( (threadId: string | null, error: string | null) => { if (!threadId) return; dispatch({ type: "SET_ERROR", threadId, error, }); }, [dispatch], ); const addComposerImages = (files: File[]) => { if (!activeThreadId || files.length === 0) return; const nextImages = [...composerImagesRef.current]; let error: string | null = null; for (const file of files) { if (!file.type.startsWith("image/")) { error = `Unsupported file type for '${file.name}'. Please attach image files only.`; continue; } if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; continue; } if (nextImages.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; break; } const previewUrl = URL.createObjectURL(file); nextImages.push({ type: "image", id: crypto.randomUUID(), name: file.name || "image", mimeType: file.type, sizeBytes: file.size, previewUrl, file, }); } setComposerImages(nextImages); setThreadError(activeThreadId, error); }; const removeComposerImage = (imageId: string) => { setComposerImages((existing) => { const match = existing.find((image) => image.id === imageId); if (match?.previewUrl) { URL.revokeObjectURL(match.previewUrl); } return existing.filter((image) => image.id !== imageId); }); }; const onComposerPaste = (event: ClipboardEvent) => { const files = Array.from(event.clipboardData.files); if (files.length === 0) { return; } const imageFiles = files.filter((file) => file.type.startsWith("image/")); if (imageFiles.length === 0) { return; } event.preventDefault(); addComposerImages(imageFiles); }; const onComposerDragEnter = (event: DragEvent) => { if (!event.dataTransfer.types.includes("Files")) { return; } event.preventDefault(); dragDepthRef.current += 1; setIsDragOverComposer(true); }; const onComposerDragOver = (event: DragEvent) => { if (!event.dataTransfer.types.includes("Files")) { return; } event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setIsDragOverComposer(true); }; const onComposerDragLeave = (event: DragEvent) => { if (!event.dataTransfer.types.includes("Files")) { return; } event.preventDefault(); const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { return; } dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) { setIsDragOverComposer(false); } }; const onComposerDrop = (event: DragEvent) => { if (!event.dataTransfer.types.includes("Files")) { return; } event.preventDefault(); dragDepthRef.current = 0; setIsDragOverComposer(false); const files = Array.from(event.dataTransfer.files); addComposerImages(files); focusComposer(); }; const syncCheckpointTurnCounts = useCallback( async (input: { threadId: string; sessionId: string; threadRuntimeId: string | null; }): Promise => { if (!api || turnDiffSummaries.length === 0) { return; } const turnIds = turnDiffSummaries.map((summary) => summary.turnId); if (turnIds.length === 0) { return; } const syncKey = `${input.threadId}:${input.sessionId}:${input.threadRuntimeId ?? "none"}`; const turnFingerprint = turnIds.join(","); if (checkpointTurnCountSyncFingerprintRef.current.get(syncKey) === turnFingerprint) { return; } checkpointTurnCountSyncFingerprintRef.current.set(syncKey, turnFingerprint); try { const result = await api.providers.listCheckpoints({ sessionId: input.sessionId, }); const turnIdSet = new Set(turnIds); const checkpointTurnCountByTurnId = Object.fromEntries( result.checkpoints .filter((checkpoint) => checkpoint.turnCount > 0 && turnIdSet.has(checkpoint.id)) .map((checkpoint) => [checkpoint.id, checkpoint.turnCount] as const), ); if (Object.keys(checkpointTurnCountByTurnId).length === 0) { return; } dispatch({ type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", threadId: input.threadId, checkpointTurnCountByTurnId, }); } catch { checkpointTurnCountSyncFingerprintRef.current.delete(syncKey); } }, [api, dispatch, turnDiffSummaries], ); const ensureSession = useCallback( async (cwdOverride?: string): Promise => { if (!api || !activeThread || !activeProject) return null; if (activeThread.session && activeThread.session.status !== "closed") { const sessionThreadId = activeThread.session.threadId ?? null; const continuityState: SessionContinuityState = activeThread.codexThreadId === null ? "new" : sessionThreadId === activeThread.codexThreadId ? "resumed" : "fallback_new"; await syncCheckpointTurnCounts({ threadId: activeThread.id, sessionId: activeThread.session.sessionId, threadRuntimeId: sessionThreadId, }); return { sessionId: activeThread.session.sessionId, resolvedThreadId: sessionThreadId, continuityState, } satisfies EnsuredSessionInfo; } const priorCodexThreadId = activeThread.codexThreadId; setIsConnecting(true); try { const session = await api.providers.startSession({ provider: "codex", cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd, model: selectedModel || undefined, resumeThreadId: priorCodexThreadId ?? undefined, approvalPolicy: runtimeApprovalPolicy, sandboxMode: runtimeSandboxMode, }); dispatch({ type: "UPDATE_SESSION", threadId: activeThread.id, session, }); const resolvedThreadId = session.threadId ?? null; const continuityState: SessionContinuityState = priorCodexThreadId === null ? "new" : resolvedThreadId === priorCodexThreadId ? "resumed" : "fallback_new"; await syncCheckpointTurnCounts({ threadId: activeThread.id, sessionId: session.sessionId, threadRuntimeId: resolvedThreadId, }); return { sessionId: session.sessionId, resolvedThreadId, continuityState, }; } catch (err) { dispatch({ type: "SET_ERROR", threadId: activeThread.id, error: err instanceof Error ? err.message : "Failed to connect.", }); return null; } finally { setIsConnecting(false); } }, [ activeProject, activeThread, api, dispatch, runtimeApprovalPolicy, runtimeSandboxMode, selectedModel, syncCheckpointTurnCounts, ], ); useEffect(() => { if (!api || !activeThreadId || activeSessionId) return; if (phase === "running" || isSending || isConnecting || isRevertingCheckpoint) return; const hasThreadIdentity = Boolean(activeThreadRuntimeId); if (!hasThreadIdentity) return; if (turnDiffSummaries.length === 0) return; const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`; if (checkpointHydrationSessionRequestRef.current.has(requestKey)) { return; } checkpointHydrationSessionRequestRef.current.add(requestKey); setThreadError(activeThreadId, null); void ensureSession().then( () => { checkpointHydrationSessionRequestRef.current.delete(requestKey); }, () => { // Keep the key on failure so this effect does not re-enter in a tight loop. }, ); }, [ activeThreadId, activeThreadRuntimeId, activeSessionId, api, ensureSession, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError, turnDiffSummaries, ]); const onRevertToTurnCount = useCallback( async (turnCount: number) => { if (!api || !activeThread || isRevertingCheckpoint) { return; } if (phase === "running" || isSending || isConnecting) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); return; } const confirmed = await api.dialogs.confirm( [ `Revert this thread to checkpoint ${turnCount}?`, "This will discard newer messages and turn diffs in this thread.", "This action cannot be undone.", ].join("\n"), ); if (!confirmed) { return; } setIsRevertingCheckpoint(true); setThreadError(activeThread.id, null); try { const sessionInfo = await ensureSession(); if (!sessionInfo) { return; } const result = await api.providers.revertToCheckpoint({ sessionId: sessionInfo.sessionId, turnCount, }); dispatch({ type: "REVERT_TO_CHECKPOINT", threadId: activeThread.id, sessionId: sessionInfo.sessionId, threadRuntimeId: result.threadId, turnCount: result.turnCount, messageCount: result.messageCount, }); dispatch({ type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", threadId: activeThread.id, checkpointTurnCountByTurnId: Object.fromEntries( result.checkpoints .filter((entry) => entry.turnCount > 0) .map((entry) => [entry.id, entry.turnCount] as const), ), }); } catch (err) { setThreadError( activeThread.id, err instanceof Error ? err.message : "Failed to revert thread state.", ); } finally { setIsRevertingCheckpoint(false); } }, [ activeThread, api, dispatch, ensureSession, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError, ], ); const onSend = async (e: FormEvent) => { e.preventDefault(); if (!api || !activeThread || isSending || isConnecting) return; const trimmed = prompt.trim(); if (!trimmed && composerImages.length === 0) return; if (!activeProject) return; const composerImagesSnapshot = [...composerImages]; // On first message: lock in branch + create worktree if needed. let sessionCwd: string | undefined; if ( activeThread.messages.length === 0 && activeThread.branch && envMode === "worktree" && !activeThread.worktreePath ) { try { const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`; const result = await createWorktreeMutation.mutateAsync({ cwd: activeProject.cwd, branch: activeThread.branch, newBranch, }); sessionCwd = result.worktree.path; dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThread.id, branch: result.worktree.branch, worktreePath: result.worktree.path, }); const setupScript = setupProjectScript(activeProject.scripts); if (setupScript) { void runProjectScript(setupScript, { cwd: result.worktree.path, worktreePath: result.worktree.path, rememberAsLastInvoked: false, }); } } catch (err) { dispatch({ type: "SET_ERROR", threadId: activeThread.id, error: err instanceof Error ? err.message : "Failed to create worktree", }); return; } } // Auto-title from first message if (activeThread.messages.length === 0) { const titleSeed = trimmed || (composerImagesSnapshot.length > 0 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}` : "New thread"); const title = truncateTitle(titleSeed); dispatch({ type: "SET_THREAD_TITLE", threadId: activeThread.id, title, }); } setThreadError(activeThread.id, null); const messageAttachments: ChatImageAttachment[] = composerImagesSnapshot.map((image) => ({ type: "image", id: image.id, name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, previewUrl: image.previewUrl, })); dispatch({ type: "PUSH_USER_MESSAGE", threadId: activeThread.id, id: crypto.randomUUID(), text: trimmed, ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), }); const previousMessages = activeThread.messages; promptRef.current = ""; setPrompt(""); setComposerImages([]); setComposerCursor(0); setComposerHighlightedItemId(null); const sessionInfo = await ensureSession(sessionCwd); if (!sessionInfo) return; setIsSending(true); try { const turnAttachments = await Promise.all( composerImagesSnapshot.map( async (image): Promise => ({ type: "image", name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, dataUrl: await readFileAsDataUrl(image.file), }), ), ); const shouldBootstrap = previousMessages.length > 0 && (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new"); const latestPromptForBootstrap = trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT; const input = shouldBootstrap ? buildBootstrapInput( previousMessages, latestPromptForBootstrap, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ).text : trimmed || undefined; await api.providers.sendTurn({ sessionId: sessionInfo.sessionId, ...(input ? { input } : {}), ...(turnAttachments.length > 0 ? { attachments: turnAttachments } : {}), model: selectedModel || undefined, effort: selectedEffort || undefined, }); } catch (err) { setThreadError( activeThread.id, err instanceof Error ? err.message : "Failed to send message.", ); } finally { setIsSending(false); } }; const onInterrupt = async () => { if (!api || !activeThread?.session) return; await api.providers.interruptTurn({ sessionId: activeThread.session.sessionId, turnId: activeThread.session.activeTurnId, }); }; const onRespondToApproval = useCallback( async (requestId: string, decision: ProviderApprovalDecision) => { if (!api || !activeSessionId || !activeThreadId) return; setRespondingRequestIds((existing) => existing.includes(requestId) ? existing : [...existing, requestId], ); try { await api.providers.respondToRequest({ sessionId: activeSessionId, requestId, decision, }); } catch (err) { dispatch({ type: "SET_ERROR", threadId: activeThreadId, error: err instanceof Error ? err.message : "Failed to submit approval decision.", }); } finally { setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); } }, [activeSessionId, activeThreadId, api, dispatch], ); const onModelSelect = useCallback( (model: ModelSlug) => { if (!activeThread) return; dispatch({ type: "SET_THREAD_MODEL", threadId: activeThread.id, model: resolveModelSlug(model), }); scheduleComposerFocus(); }, [activeThread, dispatch, scheduleComposerFocus], ); const onEffortSelect = useCallback( (effort: ReasoningEffort) => { setSelectedEffort(effort); scheduleComposerFocus(); }, [scheduleComposerFocus], ); const onEnvModeChange = useCallback( (mode: "local" | "worktree") => { setEnvMode(mode); scheduleComposerFocus(); }, [scheduleComposerFocus], ); const applyPromptReplacement = useCallback( (rangeStart: number, rangeEnd: number, replacement: string) => { const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); promptRef.current = next.text; setPrompt(next.text); window.requestAnimationFrame(() => { const textarea = textareaRef.current; if (!textarea) return; textarea.focus(); textarea.setSelectionRange(next.cursor, next.cursor); setComposerCursor(next.cursor); }); }, [], ); const onSelectComposerItem = useCallback( (item: ComposerCommandItem) => { if (!composerTrigger) return; if (item.type === "path") { applyPromptReplacement( composerTrigger.rangeStart, composerTrigger.rangeEnd, `@${item.path} `, ); return; } if (item.type === "slash-command") { applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, "/model "); return; } onModelSelect(item.model); applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, ""); }, [applyPromptReplacement, composerTrigger, onModelSelect], ); const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { setComposerHighlightedItemId(itemId); }, []); const nudgeComposerMenuHighlight = useCallback((key: "ArrowDown" | "ArrowUp") => { const commandInput = composerCommandInputRef.current; if (!commandInput) return; commandInput.dispatchEvent( new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), ); }, []); const isComposerMenuLoading = composerTriggerKind === "path" && ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || workspaceEntriesQuery.isLoading || workspaceEntriesQuery.isFetching); const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { promptRef.current = nextPrompt; setPrompt(nextPrompt); setComposerCursor(nextCursor); }, []); const onKeyDown = (e: KeyboardEvent) => { if (composerMenuOpen && composerMenuItems.length > 0) { if (e.key === "ArrowDown") { e.preventDefault(); nudgeComposerMenuHighlight("ArrowDown"); return; } if (e.key === "ArrowUp") { e.preventDefault(); nudgeComposerMenuHighlight("ArrowUp"); return; } if (e.key === "Tab" || e.key === "Enter") { const selectedItem = activeComposerMenuItem ?? composerMenuItems[0]; if (selectedItem) { e.preventDefault(); onSelectComposerItem(selectedItem); return; } } } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void onSend(e as unknown as FormEvent); } }; const onToggleWorkGroup = useCallback((groupId: string) => { setExpandedWorkGroups((existing) => ({ ...existing, [groupId]: !existing[groupId], })); }, []); const onExpandTimelineImage = useCallback((image: ExpandedImagePreview) => { setExpandedImage(image); }, []); const onToggleDiff = useCallback(() => { if (state.diffOpen) { dispatch({ type: "CLOSE_DIFF" }); return; } if (!activeThread) { dispatch({ type: "TOGGLE_DIFF" }); return; } dispatch({ type: "OPEN_DIFF", threadId: activeThread.id, }); }, [activeThread, dispatch, state.diffOpen]); const onOpenTurnDiff = useCallback( (turnId: string, filePath?: string) => { if (!activeThread) return; dispatch({ type: "OPEN_DIFF", threadId: activeThread.id, turnId, ...(filePath ? { filePath } : {}), }); }, [activeThread, dispatch], ); const onRevertUserMessage = useCallback( (messageId: string) => { const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); if (typeof targetTurnCount !== "number") { return; } void onRevertToTurnCount(targetTurnCount); }, [onRevertToTurnCount, revertTurnCountByUserMessageId], ); // Empty state: no active thread if (!activeThread) { return (
{isElectron && (
No active thread
)}

Select a thread or create a new one to get started.

); } return (
{/* Top bar */}
{ void runProjectScript(script); }} onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} onToggleDiff={onToggleDiff} />
{/* Error banner */} {/* Messages */}
0} isWorking={isWorking} scrollContainerRef={messagesScrollRef} timelineEntries={timelineEntries} completionDividerBeforeEntryId={completionDividerBeforeEntryId} completionSummary={completionSummary} assistantCompletionByItemId={assistantCompletionByItemId} turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} turnCheckpointFilesByTurnId={turnCheckpointFilesByTurnId} nowIso={nowIso} expandedWorkGroups={expandedWorkGroups} onToggleWorkGroup={onToggleWorkGroup} onOpenTurnDiff={onOpenTurnDiff} revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} onRevertUserMessage={onRevertUserMessage} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} />
{/* Input bar */}
{/* Textarea area */}
{composerMenuOpen && (
)} {composerImages.length > 0 && (
{composerImages.map((image) => (
{image.previewUrl ? ( {image.name} setExpandedImage({ src: image.previewUrl, name: image.name }) } /> ) : (
{image.name}
)}
))}
)}