import { type ApprovalRequestId, DEFAULT_MODEL, DEFAULT_REASONING, EDITORS, type EditorId, type KeybindingCommand, type MessageId, type ProjectId, type ProjectEntry, type ProjectScript, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, REASONING_OPTIONS, type ReasoningEffort, type ResolvedKeybindingsConfig, type ProviderApprovalDecision, type ThreadId, type TurnId, normalizeModelSlug, resolveModelSlug, OrchestrationThreadActivity, } from "@t3tools/contracts"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { measureElement as measureVirtualElement, type VirtualItem, useVirtualizer, } from "@tanstack/react-virtual"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { type ComposerTrigger, type ComposerTriggerKind, detectComposerTrigger, expandCollapsedComposerCursor, replaceTextRange, } from "../composer-logic"; import { derivePendingApprovals, derivePhase, deriveTimelineEntries, type PendingApproval, deriveWorkLogEntries, hasToolActivityForTurn, isLatestTurnSettled, formatElapsed, formatTimestamp, } from "../session-logic"; import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll"; import { useStore } from "../store"; import { truncateTitle } from "../truncateTitle"; import { DEFAULT_THREAD_TERMINAL_ID, MAX_THREAD_TERMINAL_COUNT, type ChatMessage, type Thread, type TurnDiffSummary, } 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 { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { BotIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, CircleAlertIcon, FileIcon, FolderIcon, DiffIcon, FolderClosedIcon, InfoIcon, LockIcon, LockOpenIcon, Undo2Icon, XIcon, CopyIcon, CheckIcon, } 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, VisualStudioCode, Zed } from "./Icons"; import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { Badge } from "./ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Command, CommandItem, CommandList } from "./ui/command"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId, projectScriptRuntimeEnv, projectScriptIdFromCommand, setupProjectScript, } from "~/projectScripts"; import { Toggle } from "./ui/toggle"; import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { getAppModelOptions, useAppSettings } from "../appSettings"; import { type ComposerImageAttachment, type DraftThreadEnvMode, type DraftThreadState, type PersistedComposerImageAttachment, useComposerDraftStore, useComposerThreadDraft, } from "../composerDraftStore"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { clamp } from "effect/Number"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; 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 ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; 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_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; const WORKTREE_BRANCH_PREFIX = "t3code"; 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"; } interface ExpandedImageItem { src: string; name: string; } interface ExpandedImagePreview { images: ExpandedImageItem[]; index: number; } function buildExpandedImagePreview( images: ReadonlyArray<{ id: string; name: string; previewUrl?: string }>, selectedImageId: string, ): ExpandedImagePreview | null { const previewableImages = images.flatMap((image) => image.previewUrl ? [{ id: image.id, src: image.previewUrl, name: image.name }] : [], ); if (previewableImages.length === 0) { return null; } const selectedIndex = previewableImages.findIndex((image) => image.id === selectedImageId); if (selectedIndex < 0) { return null; } return { images: previewableImages.map((image) => ({ src: image.src, name: image.name })), index: selectedIndex, }; } function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, fallbackModel: string, error: string | null, ): Thread { return { id: threadId, codexThreadId: null, projectId: draftThread.projectId, title: "New thread", model: fallbackModel, session: null, messages: [], error, createdAt: draftThread.createdAt, latestTurn: null, lastVisitedAt: draftThread.createdAt, branch: draftThread.branch, worktreePath: draftThread.worktreePath, turnDiffSummaries: [], activities: [], }; } function revokeBlobPreviewUrl(previewUrl: string | undefined): void { if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { return; } URL.revokeObjectURL(previewUrl); } function revokeUserMessagePreviewUrls(message: ChatMessage): void { if (message.role !== "user" || !message.attachments) { return; } for (const attachment of message.attachments) { if (attachment.type !== "image") { continue; } revokeBlobPreviewUrl(attachment.previewUrl); } } function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] { if (message.role !== "user" || !message.attachments) { return []; } const previewUrls: string[] = []; for (const attachment of message.attachments) { if (attachment.type !== "image") continue; if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue; previewUrls.push(attachment.previewUrl); } return previewUrls; } 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: string; label: string; description: string; }; type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; 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); }); } function buildTemporaryWorktreeBranchName(): string { // Keep the 8-hex suffix shape for backend temporary-branch detection. const token = crypto.randomUUID().slice(0, 8).toLowerCase(); return `${WORKTREE_BRANCH_PREFIX}/${token}`; } function cloneComposerImageForRetry(image: ComposerImageAttachment): ComposerImageAttachment { if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) { return image; } try { return { ...image, previewUrl: URL.createObjectURL(image.file), }; } catch { return image; } } const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { pathValue: string; kind: "file" | "directory"; theme: "light" | "dark"; }) { const [failedIconUrl, setFailedIconUrl] = useState(null); const iconUrl = useMemo( () => getVscodeIconUrlForEntry(props.pathValue, props.kind, props.theme), [props.kind, props.pathValue, props.theme], ); const failed = failedIconUrl === iconUrl; if (failed) { return props.kind === "directory" ? ( ) : ( ); } return ( setFailedIconUrl(iconUrl)} /> ); }); const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { item: ComposerCommandItem; resolvedTheme: "light" | "dark"; isActive: boolean; 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; activeItemId: string | null; onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; }) { 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: ThreadId; } export default function ChatView({ threadId }: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); const runtimeMode = useStore((store) => store.runtimeMode); const markThreadVisited = useStore((store) => store.markThreadVisited); const setStoreThreadError = useStore((store) => store.setError); const setStoreRuntimeMode = useStore((store) => store.setRuntimeMode); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, select: (params) => parseDiffRouteSearch(params), }); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); const composerDraft = useComposerThreadDraft(threadId); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const setComposerDraftModel = useComposerDraftStore((store) => store.setModel); const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort); const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const draftThread = useComposerDraftStore( (store) => store.draftThreadsByThreadId[threadId] ?? null, ); const promptRef = useRef(prompt); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< Record >({}); const [sendPhase, setSendPhase] = useState("idle"); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); 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 [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); const [composerCursor, setComposerCursor] = useState(() => prompt.length); const [composerTrigger, setComposerTrigger] = useState(() => detectComposerTrigger(prompt, prompt.length), ); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< Record >(() => readLastInvokedScriptByProjectFromStorage()); const messagesScrollRef = useRef(null); const [messagesScrollElement, setMessagesScrollElement] = useState(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 composerEditorRef = useRef(null); const composerCommandInputRef = useRef(null); const composerFormRef = useRef(null); const composerFormHeightRef = useRef(0); const composerImagesRef = useRef([]); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef([]); const activeComposerMenuItemRef = useRef(null); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { messagesScrollRef.current = element; setMessagesScrollElement(element); }, []); const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadId, threadId), ); const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); const setPrompt = useCallback( (nextPrompt: string) => { setComposerDraftPrompt(threadId, nextPrompt); }, [setComposerDraftPrompt, threadId], ); const addComposerImage = useCallback( (image: ComposerImageAttachment) => { addComposerDraftImage(threadId, image); }, [addComposerDraftImage, threadId], ); const addComposerImagesToDraft = useCallback( (images: ComposerImageAttachment[]) => { addComposerDraftImages(threadId, images); }, [addComposerDraftImages, threadId], ); const removeComposerImageFromDraft = useCallback( (imageId: string) => { removeComposerDraftImage(threadId, imageId); }, [removeComposerDraftImage, threadId], ); const serverThread = threads.find((t) => t.id === threadId); const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => draftThread ? buildLocalDraftThread( threadId, draftThread, fallbackDraftProject?.model ?? DEFAULT_MODEL, localDraftError, ) : undefined, [draftThread, fallbackDraftProject?.model, localDraftError, threadId], ); const activeThread = serverThread ?? localDraftThread; const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const diffSearch = useMemo( () => parseDiffRouteSearch(rawSearch as Record), [rawSearch], ); const diffOpen = diffSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeLatestTurn = activeThread?.latestTurn ?? null; const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProject = projects.find((p) => p.id === activeThread?.projectId); useEffect(() => { if (!activeThread?.id) return; if (!latestTurnSettled) return; if (!activeLatestTurn?.completedAt) return; const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); if (Number.isNaN(turnCompletedAt)) return; const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; markThreadVisited(activeThread.id); }, [ activeThread?.id, activeThread?.lastVisitedAt, activeLatestTurn?.completedAt, latestTurnSettled, markThreadVisited, ]); const baseThreadModel = resolveModelSlug( activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, ); const selectedModel = resolveModelSlug(composerDraft.model ?? baseThreadModel); const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING; const modelOptions = useMemo( () => getAppModelOptions(settings.customCodexModels, selectedModel), [selectedModel, settings.customCodexModels], ); const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); const latestTurnHasToolActivity = useMemo( () => hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId), [activeLatestTurn?.turnId, threadActivities], ); const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), [threadActivities], ); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); const clearAttachmentPreviewHandoffs = useCallback(() => { for (const timeoutId of Object.values(attachmentPreviewHandoffTimeoutByMessageIdRef.current)) { window.clearTimeout(timeoutId); } attachmentPreviewHandoffTimeoutByMessageIdRef.current = {}; for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { for (const previewUrl of previewUrls) { revokeBlobPreviewUrl(previewUrl); } } attachmentPreviewHandoffByMessageIdRef.current = {}; setAttachmentPreviewHandoffByMessageId({}); }, []); useEffect(() => { return () => { clearAttachmentPreviewHandoffs(); for (const message of optimisticUserMessagesRef.current) { revokeUserMessagePreviewUrls(message); } }; }, [clearAttachmentPreviewHandoffs]); const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { if (previewUrls.length === 0) return; const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; for (const previewUrl of previousPreviewUrls) { if (!previewUrls.includes(previewUrl)) { revokeBlobPreviewUrl(previewUrl); } } setAttachmentPreviewHandoffByMessageId((existing) => { const next = { ...existing, [messageId]: previewUrls, }; attachmentPreviewHandoffByMessageIdRef.current = next; return next; }); const existingTimeout = attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; if (typeof existingTimeout === "number") { window.clearTimeout(existingTimeout); } attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId] = window.setTimeout(() => { const currentPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId]; if (currentPreviewUrls) { for (const previewUrl of currentPreviewUrls) { revokeBlobPreviewUrl(previewUrl); } } setAttachmentPreviewHandoffByMessageId((existing) => { if (!(messageId in existing)) return existing; const next = { ...existing }; delete next[messageId]; attachmentPreviewHandoffByMessageIdRef.current = next; return next; }); delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS); }, []); const serverMessages = activeThread?.messages; const timelineMessages = useMemo(() => { const messages = serverMessages ?? []; const serverMessagesWithPreviewHandoff = Object.keys(attachmentPreviewHandoffByMessageId).length === 0 ? messages : // Spread only fires for the few messages that actually changed; // unchanged ones early-return their original reference. // In-place mutation would break React's immutable state contract. // oxlint-disable-next-line no-map-spread messages.map((message) => { if ( message.role !== "user" || !message.attachments || message.attachments.length === 0 ) { return message; } const handoffPreviewUrls = attachmentPreviewHandoffByMessageId[message.id]; if (!handoffPreviewUrls || handoffPreviewUrls.length === 0) { return message; } let changed = false; let imageIndex = 0; const attachments = message.attachments.map((attachment) => { if (attachment.type !== "image") { return attachment; } const handoffPreviewUrl = handoffPreviewUrls[imageIndex]; imageIndex += 1; if (!handoffPreviewUrl || attachment.previewUrl === handoffPreviewUrl) { return attachment; } changed = true; return { ...attachment, previewUrl: handoffPreviewUrl, }; }); return changed ? { ...message, attachments } : message; }); if (optimisticUserMessages.length === 0) { return serverMessagesWithPreviewHandoff; } const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id)); const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); if (pendingMessages.length === 0) { return serverMessagesWithPreviewHandoff; } return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); const timelineEntries = useMemo( () => deriveTimelineEntries(timelineMessages, workLogEntries), [timelineMessages, 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 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]); const completionSummary = useMemo(() => { if (!latestTurnSettled) return null; if (!activeLatestTurn?.startedAt) return null; if (!activeLatestTurn.completedAt) return null; if (!latestTurnHasToolActivity) return null; const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt); return elapsed ? `Worked for ${elapsed}` : null; }, [ activeLatestTurn?.completedAt, activeLatestTurn?.startedAt, latestTurnHasToolActivity, latestTurnSettled, ]); const completionDividerBeforeEntryId = useMemo(() => { if (!latestTurnSettled) return null; if (!activeLatestTurn?.startedAt) return null; if (!activeLatestTurn.completedAt) return null; if (!completionSummary) return null; const turnStartedAt = Date.parse(activeLatestTurn.startedAt); const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); 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; }, [ activeLatestTurn?.completedAt, activeLatestTurn?.startedAt, completionSummary, latestTurnSettled, timelineEntries, ]); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; 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(gitCwd)); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ 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 modelOptions .map(({ slug, name }) => ({ slug, name, searchSlug: slug.toLowerCase(), searchName: name.toLowerCase(), })) .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" as const, model: slug, label: name, description: slug, })); }, [composerTrigger, modelOptions, workspaceEntries]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () => composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? composerMenuItems[0] ?? null, [composerHighlightedItemId, composerMenuItems], ); composerMenuOpenRef.current = composerMenuOpen; composerMenuItemsRef.current = composerMenuItems; activeComposerMenuItemRef.current = activeComposerMenuItem; const nonPersistedComposerImageIdSet = useMemo( () => new Set(nonPersistedComposerImageIds), [nonPersistedComposerImageIds], ); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const threadTerminalRuntimeEnv = useMemo(() => { if (!activeProjectCwd) return {}; return projectScriptRuntimeEnv({ project: { cwd: activeProjectCwd, }, worktreePath: activeThreadWorktreePath, }); }, [activeProjectCwd, activeThreadWorktreePath]); // 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 diffPanelShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "diff.toggle"), [keybindings], ); const onToggleDiff = useCallback(() => { void navigate({ to: "/$threadId", params: { threadId }, replace: true, search: (previous) => { const rest = stripDiffSearchParams(previous); return diffOpen ? rest : { ...rest, diff: "1" }; }, }); }, [diffOpen, navigate, threadId]); const envLocked = Boolean( activeThread && (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); const hasReachedTerminalLimit = terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT; const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; if (threads.some((thread) => thread.id === targetThreadId)) { setStoreThreadError(targetThreadId, error); return; } setLocalDraftErrorsByThreadId((existing) => { if ((existing[targetThreadId] ?? null) === error) { return existing; } return { ...existing, [targetThreadId]: error, }; }); }, [setStoreThreadError, threads], ); const focusComposer = useCallback(() => { composerEditorRef.current?.focusAtEnd(); }, []); const scheduleComposerFocus = useCallback(() => { window.requestAnimationFrame(() => { focusComposer(); }); }, [focusComposer]); const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadId) return; storeSetTerminalOpen(activeThreadId, open); }, [activeThreadId, storeSetTerminalOpen], ); const setTerminalHeight = useCallback( (height: number) => { if (!activeThreadId) return; storeSetTerminalHeight(activeThreadId, height); }, [activeThreadId, storeSetTerminalHeight], ); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadId) return; setTerminalOpen(!terminalState.terminalOpen); }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); const splitTerminal = useCallback(() => { if (!activeThreadId || hasReachedTerminalLimit) return; const terminalId = `terminal-${crypto.randomUUID()}`; storeSplitTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, storeSplitTerminal, hasReachedTerminalLimit]); const createNewTerminal = useCallback(() => { if (!activeThreadId || hasReachedTerminalLimit) return; const terminalId = `terminal-${crypto.randomUUID()}`; storeNewTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, storeNewTerminal, hasReachedTerminalLimit]); const activateTerminal = useCallback( (terminalId: string) => { if (!activeThreadId) return; storeSetActiveTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, storeSetActiveTerminal], ); const closeTerminal = useCallback( (terminalId: string) => { const api = readNativeApi(); if (!activeThreadId || !api) return; const isFinalTerminal = terminalState.terminalIds.length <= 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(); } storeCloseTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, storeCloseTerminal, terminalState.terminalIds.length], ); const runProjectScript = useCallback( async ( script: ProjectScript, options?: { cwd?: string; env?: Record; worktreePath?: string | null; preferNewTerminal?: boolean; rememberAsLastInvoked?: boolean; allowLocalDraftThread?: boolean; }, ) => { const api = readNativeApi(); if (!api || !activeThreadId || !activeProject || !activeThread) return; if (!isServerThread && !options?.allowLocalDraftThread) 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 = terminalState.activeTerminalId || terminalState.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId); const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; const shouldCreateNewTerminal = wantsNewTerminal && terminalState.terminalIds.length < MAX_THREAD_TERMINAL_COUNT; const targetTerminalId = shouldCreateNewTerminal ? `terminal-${crypto.randomUUID()}` : baseTerminalId; setTerminalOpen(true); if (shouldCreateNewTerminal) { storeNewTerminal(activeThreadId, targetTerminalId); } else { storeSetActiveTerminal(activeThreadId, targetTerminalId); } setTerminalFocusRequestId((value) => value + 1); const runtimeEnv = projectScriptRuntimeEnv({ project: { cwd: activeProject.cwd, }, worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, ...(options?.env ? { extraEnv: options.env } : {}), }); const openTerminalInput: Parameters[0] = shouldCreateNewTerminal ? { threadId: activeThreadId, terminalId: targetTerminalId, cwd: targetCwd, env: runtimeEnv, cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS, } : { threadId: activeThreadId, terminalId: targetTerminalId, cwd: targetCwd, env: runtimeEnv, }; try { await api.terminal.open(openTerminalInput); await api.terminal.write({ threadId: activeThreadId, terminalId: targetTerminalId, data: `${script.command}\r`, }); } catch (error) { setThreadError( activeThreadId, error instanceof Error ? error.message : `Failed to run script "${script.name}".`, ); } }, [ activeProject, activeThread, activeThreadId, gitCwd, isServerThread, setTerminalOpen, setThreadError, storeNewTerminal, storeSetActiveTerminal, terminalState.activeTerminalId, terminalState.runningTerminalIds, terminalState.terminalIds, ], ); const persistProjectScripts = useCallback( async (input: { projectId: ProjectId; projectCwd: string; previousScripts: ProjectScript[]; nextScripts: ProjectScript[]; keybinding?: string | null; keybindingCommand: KeybindingCommand; }) => { const api = readNativeApi(); if (!api) return; await api.orchestration.dispatchCommand({ type: "project.meta.update", commandId: newCommandId(), projectId: input.projectId, scripts: input.nextScripts, }); const keybindingRule = decodeProjectScriptKeybindingRule({ keybinding: input.keybinding, command: input.keybindingCommand, }); if (isElectron && keybindingRule) { await api.server.upsertKeybinding(keybindingRule); await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); } }, [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 === runtimeMode) return; setStoreRuntimeMode(mode); scheduleComposerFocus(); const api = readNativeApi(); if (!api) return; const runningThreadIds = threads .filter((thread) => thread.session !== null && thread.session.status !== "closed") .map((thread) => thread.id); if (runningThreadIds.length === 0) return; setIsSwitchingRuntimeMode(true); await Promise.all( runningThreadIds.map((threadId) => api.orchestration .dispatchCommand({ type: "thread.session.stop", commandId: newCommandId(), threadId, createdAt: new Date().toISOString(), }) .catch(() => undefined), ), ); 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-scroll on new messages const messageCount = timelineMessages.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 cancelPendingStickToBottom = useCallback(() => { const pendingFrame = pendingAutoScrollFrameRef.current; if (pendingFrame === null) return; pendingAutoScrollFrameRef.current = null; window.cancelAnimationFrame(pendingFrame); }, []); const scheduleStickToBottom = useCallback(() => { if (pendingAutoScrollFrameRef.current !== null) return; pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { pendingAutoScrollFrameRef.current = null; scrollMessagesToBottom(); }); }, [scrollMessagesToBottom]); const forceStickToBottom = useCallback(() => { cancelPendingStickToBottom(); scrollMessagesToBottom(); scheduleStickToBottom(); }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]); const onMessagesScroll = useCallback(() => { const scrollContainer = messagesScrollRef.current; if (!scrollContainer) return; const currentScrollTop = scrollContainer.scrollTop; const isNearBottom = isScrollContainerNearBottom(scrollContainer); if (!shouldAutoScrollRef.current && isNearBottom) { 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) { const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; if (scrolledUp) { shouldAutoScrollRef.current = false; } } else if (shouldAutoScrollRef.current && !isNearBottom) { // Catch-all for keyboard/assistive scroll interactions. const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; if (scrolledUp) { shouldAutoScrollRef.current = false; } } lastKnownScrollTopRef.current = currentScrollTop; }, []); const onMessagesWheel = useCallback((event: React.WheelEvent) => { if (event.deltaY < 0) { pendingUserScrollUpIntentRef.current = true; } }, []); const onMessagesPointerDown = useCallback((_event: React.PointerEvent) => { isPointerScrollActiveRef.current = true; }, []); const onMessagesPointerUp = useCallback((_event: React.PointerEvent) => { isPointerScrollActiveRef.current = false; }, []); const onMessagesPointerCancel = useCallback((_event: React.PointerEvent) => { isPointerScrollActiveRef.current = false; }, []); const onMessagesTouchStart = useCallback((event: React.TouchEvent) => { const touch = event.touches[0]; if (!touch) return; lastTouchClientYRef.current = touch.clientY; }, []); const onMessagesTouchMove = useCallback((event: React.TouchEvent) => { const touch = event.touches[0]; if (!touch) return; const previousTouchY = lastTouchClientYRef.current; if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { pendingUserScrollUpIntentRef.current = true; } lastTouchClientYRef.current = touch.clientY; }, []); const onMessagesTouchEnd = useCallback((_event: React.TouchEvent) => { lastTouchClientYRef.current = null; }, []); useEffect(() => { return () => { cancelPendingStickToBottom(); }; }, [cancelPendingStickToBottom]); useLayoutEffect(() => { if (!activeThread?.id) return; shouldAutoScrollRef.current = true; scheduleStickToBottom(); const timeout = window.setTimeout(() => { const scrollContainer = messagesScrollRef.current; if (!scrollContainer) return; if (isScrollContainerNearBottom(scrollContainer)) return; scheduleStickToBottom(); }, 96); return () => { window.clearTimeout(timeout); }; }, [activeThread?.id, scheduleStickToBottom]); useLayoutEffect(() => { const composerForm = composerFormRef.current; if (!composerForm) return; composerFormHeightRef.current = composerForm.getBoundingClientRect().height; if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver((entries) => { const [entry] = entries; if (!entry) return; const nextHeight = entry.contentRect.height; const previousHeight = composerFormHeightRef.current; composerFormHeightRef.current = nextHeight; if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return; if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); }); observer.observe(composerForm); return () => { observer.disconnect(); }; }, [activeThread?.id, scheduleStickToBottom]); useEffect(() => { if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); }, [messageCount, scheduleStickToBottom]); useEffect(() => { if (phase !== "running") return; if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); }, [phase, scheduleStickToBottom, timelineEntries]); useEffect(() => { setExpandedWorkGroups({}); }, [activeThread?.id]); useEffect(() => { if (!composerMenuOpen) { setComposerHighlightedItemId(null); return; } setComposerHighlightedItemId((existing) => existing && composerMenuItems.some((item) => item.id === existing) ? existing : (composerMenuItems[0]?.id ?? null), ); }, [composerMenuItems, composerMenuOpen]); useEffect(() => { setIsRevertingCheckpoint(false); }, [activeThread?.id]); useEffect(() => { if (!activeThread?.id || terminalState.terminalOpen) return; const frame = window.requestAnimationFrame(() => { focusComposer(); }); return () => { window.cancelAnimationFrame(frame); }; }, [activeThread?.id, focusComposer, terminalState.terminalOpen]); useEffect(() => { composerImagesRef.current = composerImages; }, [composerImages]); useEffect(() => { if (!activeThread?.id) return; if (activeThread.messages.length === 0) { return; } const serverIds = new Set(activeThread.messages.map((message) => message.id)); const removedMessages = optimisticUserMessages.filter((message) => serverIds.has(message.id)); if (removedMessages.length === 0) { return; } const timer = window.setTimeout(() => { setOptimisticUserMessages((existing) => existing.filter((message) => !serverIds.has(message.id)), ); }, 0); for (const removedMessage of removedMessages) { const previewUrls = collectUserMessageBlobPreviewUrls(removedMessage); if (previewUrls.length > 0) { handoffAttachmentPreviews(removedMessage.id, previewUrls); continue; } revokeUserMessagePreviewUrls(removedMessage); } return () => { window.clearTimeout(timer); }; }, [activeThread?.id, activeThread?.messages, handoffAttachmentPreviews, optimisticUserMessages]); useEffect(() => { promptRef.current = prompt; setComposerCursor((existing) => Math.min(Math.max(0, existing), prompt.length)); }, [prompt]); useEffect(() => { setOptimisticUserMessages((existing) => { for (const message of existing) { revokeUserMessagePreviewUrls(message); } return []; }); setSendPhase("idle"); setComposerHighlightedItemId(null); setComposerCursor(promptRef.current.length); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); }, [threadId]); useEffect(() => { let cancelled = false; void (async () => { if (composerImages.length === 0) { clearComposerDraftPersistedAttachments(threadId); return; } const getPersistedAttachmentsForThread = () => useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? []; try { const currentPersistedAttachments = getPersistedAttachmentsForThread(); const existingPersistedById = new Map( currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), ); const stagedAttachmentById = new Map(); await Promise.all( composerImages.map(async (image) => { try { const dataUrl = await readFileAsDataUrl(image.file); stagedAttachmentById.set(image.id, { id: image.id, name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, dataUrl, }); } catch { const existingPersisted = existingPersistedById.get(image.id); if (existingPersisted) { stagedAttachmentById.set(image.id, existingPersisted); } } }), ); const serialized = Array.from(stagedAttachmentById.values()); if (cancelled) { return; } // Stage attachments in persisted draft state first so persist middleware can write them. syncComposerDraftPersistedAttachments(threadId, serialized); } catch { const currentImageIds = new Set(composerImages.map((image) => image.id)); const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); const fallbackPersistedIds = fallbackPersistedAttachments .map((attachment) => attachment.id) .filter((id) => currentImageIds.has(id)); const fallbackPersistedIdSet = new Set(fallbackPersistedIds); const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => fallbackPersistedIdSet.has(attachment.id), ); if (cancelled) { return; } syncComposerDraftPersistedAttachments(threadId, fallbackAttachments); } })(); return () => { cancelled = true; }; }, [ clearComposerDraftPersistedAttachments, composerImages, syncComposerDraftPersistedAttachments, threadId, ]); const closeExpandedImage = useCallback(() => { setExpandedImage(null); }, []); const navigateExpandedImage = useCallback((direction: -1 | 1) => { setExpandedImage((existing) => { if (!existing || existing.images.length <= 1) { return existing; } const nextIndex = (existing.index + direction + existing.images.length) % existing.images.length; if (nextIndex === existing.index) { return existing; } return { ...existing, index: nextIndex }; }); }, []); useEffect(() => { if (!expandedImage) { return; } const onKeyDown = (event: globalThis.KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeExpandedImage(); return; } if (expandedImage.images.length <= 1) { return; } if (event.key === "ArrowLeft") { event.preventDefault(); event.stopPropagation(); navigateExpandedImage(-1); return; } if (event.key !== "ArrowRight") return; event.preventDefault(); event.stopPropagation(); navigateExpandedImage(1); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [closeExpandedImage, expandedImage, navigateExpandedImage]); const activeWorktreePath = activeThread?.worktreePath; const envMode: DraftThreadEnvMode = activeWorktreePath ? "worktree" : isLocalDraftThread ? (draftThread?.envMode ?? "local") : "local"; 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(terminalState.terminalOpen); if (!previous && current) { terminalOpenByThreadRef.current[activeThreadId] = current; setTerminalFocusRequestId((value) => value + 1); return; } else if (previous && !current) { terminalOpenByThreadRef.current[activeThreadId] = current; const frame = window.requestAnimationFrame(() => { focusComposer(); }); return () => { window.cancelAnimationFrame(frame); }; } terminalOpenByThreadRef.current[activeThreadId] = current; }, [activeThreadId, focusComposer, terminalState.terminalOpen]); 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(terminalState.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 (!terminalState.terminalOpen) { setTerminalOpen(true); } splitTerminal(); return; } if (command === "terminal.close") { event.preventDefault(); event.stopPropagation(); if (!terminalState.terminalOpen) return; closeTerminal(terminalState.activeTerminalId); return; } if (command === "terminal.new") { event.preventDefault(); event.stopPropagation(); if (!terminalState.terminalOpen) { setTerminalOpen(true); } createNewTerminal(); return; } if (command === "diff.toggle") { event.preventDefault(); event.stopPropagation(); onToggleDiff(); 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, terminalState.terminalOpen, terminalState.activeTerminalId, activeThreadId, closeTerminal, createNewTerminal, setTerminalOpen, runProjectScript, splitTerminal, keybindings, onToggleDiff, toggleTerminalVisibility, ]); const addComposerImages = (files: File[]) => { if (!activeThreadId || files.length === 0) return; const nextImages: ComposerImageAttachment[] = []; let nextImageCount = composerImagesRef.current.length; 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 (nextImageCount >= 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, }); nextImageCount += 1; } if (nextImages.length === 1 && nextImages[0]) { addComposerImage(nextImages[0]); } else if (nextImages.length > 1) { addComposerImagesToDraft(nextImages); } setThreadError(activeThreadId, error); }; const removeComposerImage = (imageId: string) => { removeComposerImageFromDraft(imageId); }; const onComposerPaste = (event: React.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: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) { return; } event.preventDefault(); dragDepthRef.current += 1; setIsDragOverComposer(true); }; const onComposerDragOver = (event: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) { return; } event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setIsDragOverComposer(true); }; const onComposerDragLeave = (event: React.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: React.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 onRevertToTurnCount = useCallback( async (turnCount: number) => { const api = readNativeApi(); if (!api || !activeThread || isRevertingCheckpoint) return; if (phase === "running" || isSendBusy || 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 { await api.orchestration.dispatchCommand({ type: "thread.checkpoint.revert", commandId: newCommandId(), threadId: activeThread.id, turnCount, createdAt: new Date().toISOString(), }); } catch (err) { setThreadError( activeThread.id, err instanceof Error ? err.message : "Failed to revert thread state.", ); } setIsRevertingCheckpoint(false); }, [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], ); const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); const api = readNativeApi(); if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; const trimmed = prompt.trim(); if (!trimmed && composerImages.length === 0) return; if (!activeProject) return; const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = isFirstMessage && envMode === "worktree" && !activeThread.worktreePath ? activeThread.branch : null; // In worktree mode, require an explicit base branch so we don't silently // fall back to local execution when branch selection is missing. const shouldCreateWorktree = isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; if (shouldCreateWorktree && !activeThread.branch) { setStoreThreadError( threadIdForSend, "Select a base branch before sending in New worktree mode.", ); return; } sendInFlightRef.current = true; setSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); const composerImagesSnapshot = [...composerImages]; const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ type: "image" as const, name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, dataUrl: await readFileAsDataUrl(image.file), })), ); const optimisticAttachments = composerImagesSnapshot.map((image) => ({ type: "image" as const, id: image.id, name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, previewUrl: image.previewUrl, })); setOptimisticUserMessages((existing) => [ ...existing, { id: messageIdForSend, role: "user", text: trimmed, ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), createdAt: messageCreatedAt, streaming: false, }, ]); // Sending a message should always bring the latest user turn into view. shouldAutoScrollRef.current = true; forceStickToBottom(); setThreadError(threadIdForSend, null); promptRef.current = ""; clearComposerDraftContent(threadIdForSend); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); let createdServerThreadForLocalDraft = false; let turnStartSucceeded = false; let nextThreadBranch = activeThread.branch; let nextThreadWorktreePath = activeThread.worktreePath; await (async () => { // On first message: lock in branch + create worktree if needed. if (baseBranchForWorktree) { setSendPhase("preparing-worktree"); const newBranch = buildTemporaryWorktreeBranchName(); const result = await createWorktreeMutation.mutateAsync({ cwd: activeProject.cwd, branch: baseBranchForWorktree, newBranch, }); nextThreadBranch = result.worktree.branch; nextThreadWorktreePath = result.worktree.path; if (isServerThread) { await api.orchestration.dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), threadId: threadIdForSend, branch: result.worktree.branch, worktreePath: result.worktree.path, }); // Keep local thread state in sync immediately so terminal drawer opens // with the worktree cwd/env instead of briefly using the project root. setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); } } let firstComposerImageName: string | null = null; if (composerImagesSnapshot.length > 0) { const firstComposerImage = composerImagesSnapshot[0]; if (firstComposerImage) { firstComposerImageName = firstComposerImage.name; } } let titleSeed = trimmed; if (!titleSeed) { if (firstComposerImageName) { titleSeed = `Image: ${firstComposerImageName}`; } else { titleSeed = "New thread"; } } const title = truncateTitle(titleSeed); let threadCreateModel = selectedModel; if (!threadCreateModel) { threadCreateModel = activeProject.model; } if (!threadCreateModel) { threadCreateModel = DEFAULT_MODEL; } if (isLocalDraftThread) { await api.orchestration.dispatchCommand({ type: "thread.create", commandId: newCommandId(), threadId: threadIdForSend, projectId: activeProject.id, title, model: threadCreateModel, branch: nextThreadBranch, worktreePath: nextThreadWorktreePath, createdAt: activeThread.createdAt, }); createdServerThreadForLocalDraft = true; } let setupScript: ProjectScript | null = null; if (baseBranchForWorktree) { setupScript = setupProjectScript(activeProject.scripts); } if (setupScript) { let shouldRunSetupScript = false; if (isServerThread) { shouldRunSetupScript = true; } else { if (createdServerThreadForLocalDraft) { shouldRunSetupScript = true; } } if (shouldRunSetupScript) { const setupScriptOptions: Parameters[1] = { worktreePath: nextThreadWorktreePath, rememberAsLastInvoked: false, allowLocalDraftThread: createdServerThreadForLocalDraft, }; if (nextThreadWorktreePath) { setupScriptOptions.cwd = nextThreadWorktreePath; } await runProjectScript(setupScript, setupScriptOptions); } } // Auto-title from first message if (isFirstMessage && isServerThread) { await api.orchestration.dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), threadId: threadIdForSend, title, }); } setSendPhase("sending-turn"); const turnAttachments = await turnAttachmentsPromise; const approvalPolicy = runtimeMode === "full-access" ? "never" : "on-request"; const sandboxMode = runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; await api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), threadId: threadIdForSend, message: { messageId: messageIdForSend, role: "user", text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, attachments: turnAttachments, }, model: selectedModel || undefined, effort: selectedEffort || undefined, assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", approvalPolicy, sandboxMode, createdAt: messageCreatedAt, }); turnStartSucceeded = true; if (isFirstMessage) { clearDraftThread(threadIdForSend); } })().catch(async (err: unknown) => { if (createdServerThreadForLocalDraft && !turnStartSucceeded) { await api.orchestration .dispatchCommand({ type: "thread.delete", commandId: newCommandId(), threadId: threadIdForSend, }) .catch(() => undefined); } if ( !turnStartSucceeded && promptRef.current.length === 0 && composerImagesRef.current.length === 0 ) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); for (const message of removed) { revokeUserMessagePreviewUrls(message); } const next = existing.filter((message) => message.id !== messageIdForSend); return next.length === existing.length ? existing : next; }); promptRef.current = trimmed; setPrompt(trimmed); setComposerCursor(trimmed.length); addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length)); } setThreadError( threadIdForSend, err instanceof Error ? err.message : "Failed to send message.", ); }); sendInFlightRef.current = false; setSendPhase("idle"); }; const onInterrupt = async () => { const api = readNativeApi(); if (!api || !activeThread) return; await api.orchestration.dispatchCommand({ type: "thread.turn.interrupt", commandId: newCommandId(), threadId: activeThread.id, createdAt: new Date().toISOString(), }); }; const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { const api = readNativeApi(); if (!api || !activeThreadId) return; setRespondingRequestIds((existing) => existing.includes(requestId) ? existing : [...existing, requestId], ); await api.orchestration .dispatchCommand({ type: "thread.approval.respond", commandId: newCommandId(), threadId: activeThreadId, requestId, decision, createdAt: new Date().toISOString(), }) .catch((err: unknown) => { setStoreThreadError( activeThreadId, err instanceof Error ? err.message : "Failed to submit approval decision.", ); }); setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); }, [activeThreadId, setStoreThreadError], ); const onModelSelect = useCallback( (model: string) => { const normalizedModel = normalizeModelSlug(model); const resolvedModel = normalizedModel ?? baseThreadModel; setComposerDraftModel(threadId, resolvedModel === baseThreadModel ? null : resolvedModel); const api = readNativeApi(); if (api && isServerThread && activeThread) { void api.orchestration.dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), threadId: activeThread.id, model: resolvedModel, }); } scheduleComposerFocus(); }, [ activeThread, baseThreadModel, isServerThread, scheduleComposerFocus, setComposerDraftModel, threadId, ], ); const onEffortSelect = useCallback( (effort: ReasoningEffort) => { setComposerDraftEffort(threadId, effort); scheduleComposerFocus(); }, [scheduleComposerFocus, setComposerDraftEffort, threadId], ); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (isLocalDraftThread) { setDraftThreadContext(threadId, { envMode: mode }); } scheduleComposerFocus(); }, [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId], ); const applyPromptReplacement = useCallback( ( rangeStart: number, rangeEnd: number, replacement: string, options?: { expectedText?: string }, ): boolean => { const currentText = promptRef.current; const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); if ( options?.expectedText !== undefined && currentText.slice(safeStart, safeEnd) !== options.expectedText ) { return false; } const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); promptRef.current = next.text; setPrompt(next.text); setComposerCursor(next.cursor); setComposerTrigger(detectComposerTrigger(next.text, next.cursor)); window.requestAnimationFrame(() => { composerEditorRef.current?.focusAt(next.cursor); }); return true; }, [setPrompt], ); const readComposerSnapshot = useCallback((): { value: string; cursor: number } => { const editorSnapshot = composerEditorRef.current?.readSnapshot(); if (editorSnapshot) { return editorSnapshot; } return { value: promptRef.current, cursor: composerCursor }; }, [composerCursor]); const resolveActiveComposerTrigger = useCallback((): { snapshot: { value: string; cursor: number }; trigger: ComposerTrigger | null; } => { const snapshot = readComposerSnapshot(); const expandedCursor = expandCollapsedComposerCursor(snapshot.value, snapshot.cursor); return { snapshot, trigger: detectComposerTrigger(snapshot.value, expandedCursor), }; }, [readComposerSnapshot]); const onSelectComposerItem = useCallback( (item: ComposerCommandItem) => { if (composerSelectLockRef.current) return; composerSelectLockRef.current = true; window.requestAnimationFrame(() => { composerSelectLockRef.current = false; }); const { snapshot, trigger } = resolveActiveComposerTrigger(); if (!trigger) return; const expectedToken = snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd); if (item.type === "path") { const applied = applyPromptReplacement( trigger.rangeStart, trigger.rangeEnd, `@${item.path} `, { expectedText: expectedToken }, ); if (applied) { setComposerHighlightedItemId(null); } return; } if (item.type === "slash-command") { const applied = applyPromptReplacement( trigger.rangeStart, trigger.rangeEnd, "/model ", { expectedText: expectedToken }, ); if (applied) { setComposerHighlightedItemId(null); } return; } onModelSelect(item.model); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: expectedToken, }); if (applied) { setComposerHighlightedItemId(null); } }, [applyPromptReplacement, onModelSelect, resolveActiveComposerTrigger], ); const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { setComposerHighlightedItemId(itemId); }, []); const nudgeComposerMenuHighlight = useCallback( (key: "ArrowDown" | "ArrowUp") => { if (composerMenuItems.length === 0) { return; } const highlightedIndex = composerMenuItems.findIndex( (item) => item.id === composerHighlightedItemId, ); const normalizedIndex = highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; const offset = key === "ArrowDown" ? 1 : -1; const nextIndex = (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; const nextItem = composerMenuItems[nextIndex]; setComposerHighlightedItemId(nextItem?.id ?? null); }, [composerHighlightedItemId, composerMenuItems], ); const isComposerMenuLoading = composerTriggerKind === "path" && ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || workspaceEntriesQuery.isLoading || workspaceEntriesQuery.isFetching); const onPromptChange = useCallback((nextPrompt: string, nextCursor: number, cursorAdjacentToMention: boolean) => { promptRef.current = nextPrompt; setPrompt(nextPrompt); setComposerCursor(nextCursor); setComposerTrigger( cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandCollapsedComposerCursor(nextPrompt, nextCursor)), ); }, [setPrompt]); const onComposerCommandKey = (key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent) => { const { trigger } = resolveActiveComposerTrigger(); const menuIsActive = composerMenuOpenRef.current || trigger !== null; if (menuIsActive) { const currentItems = composerMenuItemsRef.current; if (key === "ArrowDown" && currentItems.length > 0) { nudgeComposerMenuHighlight("ArrowDown"); return true; } if (key === "ArrowUp" && currentItems.length > 0) { nudgeComposerMenuHighlight("ArrowUp"); return true; } if (key === "Tab" || key === "Enter") { const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; if (selectedItem) { onSelectComposerItem(selectedItem); return true; } } } if (key === "Enter" && !event.shiftKey) { void onSend(); return true; } return false; }; const onToggleWorkGroup = useCallback((groupId: string) => { setExpandedWorkGroups((existing) => ({ ...existing, [groupId]: !existing[groupId], })); }, []); const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { void navigate({ to: "/$threadId", params: { threadId }, search: (previous) => { const rest = stripDiffSearchParams(previous); return filePath ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } : { ...rest, diff: "1", diffTurnId: turnId }; }, }); }, [navigate, threadId], ); const onRevertUserMessage = (messageId: MessageId) => { const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); if (typeof targetTurnCount !== "number") { return; } void onRevertToTurnCount(targetTurnCount); }; // Empty state: no active thread if (!activeThread) { return (
{!isElectron && (
Threads
)} {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} activeTurnInProgress={!latestTurnSettled} activeTurnStartedAt={activeLatestTurn?.startedAt ?? null} scrollContainer={messagesScrollElement} timelineEntries={timelineEntries} completionDividerBeforeEntryId={completionDividerBeforeEntryId} completionSummary={completionSummary} turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} nowIso={nowIso} expandedWorkGroups={expandedWorkGroups} onToggleWorkGroup={onToggleWorkGroup} onOpenTurnDiff={onOpenTurnDiff} revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} onRevertUserMessage={onRevertUserMessage} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} markdownCwd={gitCwd ?? undefined} />
{/* Input bar */}
{/* Textarea area */}
{composerMenuOpen && (
)} {composerImages.length > 0 && (
{composerImages.map((image) => (
{image.previewUrl ? ( ) : (
{image.name}
)} {nonPersistedComposerImageIdSet.has(image.id) && ( } /> Draft attachment could not be saved locally and may be lost on navigation. )}
))}
)}
{/* Bottom toolbar */}
{/* Model picker */} {/* Divider */} {/* Reasoning effort */} {/* Divider */} {/* Runtime mode toggle */}
{/* Right side: send / stop button */}
{isPreparingWorktree ? ( Preparing worktree... ) : null} {phase === "running" ? ( ) : ( )}
{isGitRepo && ( )} {(() => { if (!terminalState.terminalOpen || !activeProject) { return null; } return ( ); })()} {expandedImage && expandedImageItem && (
)}
{expandedImageItem.name}

{expandedImageItem.name} {expandedImage.images.length > 1 ? ` (${expandedImage.index + 1}/${expandedImage.images.length})` : ""}

{expandedImage.images.length > 1 && ( )}
)}
); } interface ChatHeaderProps { activeThreadId: ThreadId; activeThreadTitle: string; activeProjectName: string | undefined; activeProjectScripts: ProjectScript[] | undefined; preferredScriptId: string | null; keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; diffToggleShortcutLabel: string | null; gitCwd: string | null; diffOpen: boolean; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; onToggleDiff: () => void; } const ChatHeader = memo(function ChatHeader({ activeThreadId, activeThreadTitle, activeProjectName, activeProjectScripts, preferredScriptId, keybindings, availableEditors, diffToggleShortcutLabel, gitCwd, diffOpen, onRunProjectScript, onAddProjectScript, onUpdateProjectScript, onToggleDiff, }: ChatHeaderProps) { return (

{activeThreadTitle}

{activeProjectName && ( {activeProjectName} )}
{activeProjectScripts && ( )} {activeProjectName && ( )} {activeProjectName && } } /> {diffToggleShortcutLabel ? `Toggle diff panel (${diffToggleShortcutLabel})` : "Toggle diff panel"}
); }); const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: string | null }) { if (!error) return null; return (
{error}
); }); interface PendingApprovalsPanelProps { pendingApprovals: PendingApproval[]; respondingRequestIds: ApprovalRequestId[]; onRespondToApproval: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, ) => Promise; } const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ pendingApprovals, respondingRequestIds, onRespondToApproval, }: PendingApprovalsPanelProps) { if (pendingApprovals.length === 0) return null; return (
{pendingApprovals.map((approval) => { const isResponding = respondingRequestIds.includes(approval.requestId); return ( {approval.requestKind === "command" ? "Command approval requested" : "File-change approval requested"} {approval.detail} ); })}
); }); const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); const handleCopy = useCallback(() => { void navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000); }, [text]); return ( ); }); interface MessagesTimelineProps { hasMessages: boolean; isWorking: boolean; activeTurnInProgress: boolean; activeTurnStartedAt: string | null; scrollContainer: HTMLDivElement | null; timelineEntries: ReturnType; completionDividerBeforeEntryId: string | null; completionSummary: string | null; turnDiffSummaryByAssistantMessageId: Map; nowIso: string; expandedWorkGroups: Record; onToggleWorkGroup: (groupId: string) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; revertTurnCountByUserMessageId: Map; onRevertUserMessage: (messageId: MessageId) => void; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; markdownCwd: string | undefined; } type TimelineEntry = ReturnType[number]; type TimelineMessage = Extract["message"]; type TimelineWorkEntry = Extract["entry"]; type TimelineRow = | { kind: "work"; id: string; createdAt: string; groupedEntries: TimelineWorkEntry[]; } | { kind: "message"; id: string; createdAt: string; message: TimelineMessage; showCompletionDivider: boolean; } | { kind: "working"; id: string; createdAt: string | null }; function estimateTimelineMessageHeight(message: TimelineMessage): number { const textLength = message.text.length; if (message.role === "assistant") { const estimatedLines = Math.max(1, Math.ceil(textLength / 72)); return 78 + Math.min(estimatedLines * 22, 820); } const estimatedLines = Math.max(1, Math.ceil(textLength / 56)); const attachmentCount = message.attachments?.length ?? 0; const attachmentRows = Math.ceil(attachmentCount / 2); const attachmentHeight = attachmentRows * 124; return 96 + Math.min(estimatedLines * 22, 620) + attachmentHeight; } const MessagesTimeline = memo(function MessagesTimeline({ hasMessages, isWorking, activeTurnInProgress, activeTurnStartedAt, scrollContainer, timelineEntries, completionDividerBeforeEntryId, completionSummary, turnDiffSummaryByAssistantMessageId, nowIso, expandedWorkGroups, onToggleWorkGroup, onOpenTurnDiff, revertTurnCountByUserMessageId, onRevertUserMessage, isRevertingCheckpoint, onImageExpand, markdownCwd, }: MessagesTimelineProps) { const rows = useMemo(() => { const nextRows: TimelineRow[] = []; for (let index = 0; index < timelineEntries.length; index += 1) { const timelineEntry = timelineEntries[index]; if (!timelineEntry) { continue; } if (timelineEntry.kind === "work") { const groupedEntries = [timelineEntry.entry]; let cursor = index + 1; while (cursor < timelineEntries.length) { const nextEntry = timelineEntries[cursor]; if (!nextEntry || nextEntry.kind !== "work") break; groupedEntries.push(nextEntry.entry); cursor += 1; } nextRows.push({ kind: "work", id: timelineEntry.id, createdAt: timelineEntry.createdAt, groupedEntries, }); index = cursor - 1; continue; } nextRows.push({ kind: "message", id: timelineEntry.id, createdAt: timelineEntry.createdAt, message: timelineEntry.message, showCompletionDivider: timelineEntry.message.role === "assistant" && completionDividerBeforeEntryId === timelineEntry.id, }); } if (isWorking) { nextRows.push({ kind: "working", id: "working-indicator-row", createdAt: activeTurnStartedAt, }); } return nextRows; }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]); const firstUnvirtualizedRowIndex = useMemo(() => { const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); if (!activeTurnInProgress) return firstTailRowIndex; const turnStartedAtMs = typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN; let firstCurrentTurnRowIndex = -1; if (!Number.isNaN(turnStartedAtMs)) { firstCurrentTurnRowIndex = rows.findIndex((row) => { if (row.kind === "working") return true; if (!row.createdAt) return false; const rowCreatedAtMs = Date.parse(row.createdAt); return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs; }); } if (firstCurrentTurnRowIndex < 0) { firstCurrentTurnRowIndex = rows.findIndex( (row) => row.kind === "message" && row.message.streaming, ); } if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex; for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) { const previousRow = rows[index]; if (!previousRow || previousRow.kind !== "message") continue; if (previousRow.message.role === "user") { return Math.min(index, firstTailRowIndex); } if (previousRow.message.role === "assistant" && !previousRow.message.streaming) { break; } } return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex); }, [activeTurnInProgress, activeTurnStartedAt, rows]); const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, { minimum: 0, maximum: rows.length, }); const rowVirtualizer = useVirtualizer({ count: virtualizedRowCount, getScrollElement: () => scrollContainer, // Use stable row ids so virtual measurements do not leak across thread switches. getItemKey: (index: number) => rows[index]?.id ?? index, estimateSize: (index: number) => { const row = rows[index]; if (!row) return 96; if (row.kind === "work") return 112; if (row.kind === "working") return 40; return estimateTimelineMessageHeight(row.message); }, measureElement: measureVirtualElement, useAnimationFrameWithResizeObserver: true, overscan: 8, }); useEffect(() => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { const viewportHeight = instance.scrollRect?.height ?? 0; const scrollOffset = instance.scrollOffset ?? 0; const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; }; return () => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; }; }, [rowVirtualizer]); const pendingMeasureFrameRef = useRef(null); const onTimelineImageLoad = useCallback(() => { if (pendingMeasureFrameRef.current !== null) return; pendingMeasureFrameRef.current = window.requestAnimationFrame(() => { pendingMeasureFrameRef.current = null; rowVirtualizer.measure(); }); }, [rowVirtualizer]); useEffect(() => { return () => { const frame = pendingMeasureFrameRef.current; if (frame !== null) { window.cancelAnimationFrame(frame); } }; }, []); const virtualRows = rowVirtualizer.getVirtualItems(); const nonVirtualizedRows = rows.slice(virtualizedRowCount); const renderRowContent = (row: TimelineRow) => (
{row.kind === "work" && (() => { const groupId = row.id; const groupedEntries = row.groupedEntries; const isExpanded = expandedWorkGroups[groupId] ?? false; const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; const visibleEntries = hasOverflow && !isExpanded ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : groupedEntries; const hiddenCount = groupedEntries.length - visibleEntries.length; const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); const groupLabel = onlyToolEntries ? groupedEntries.length === 1 ? "Tool call" : `Tool calls (${groupedEntries.length})` : groupedEntries.length === 1 ? "Work event" : `Work log (${groupedEntries.length})`; return (

{groupLabel}

{hasOverflow && ( )}
{visibleEntries.map((workEntry) => (

{workEntry.detail ? ( <> {workEntry.label} {workEntry.detail} ) : ( workEntry.label )}

))}
); })()} {row.kind === "message" && row.message.role === "user" && (() => { const userImages = row.message.attachments ?? []; const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); return (
{userImages.length > 0 && (
{userImages.map( (image: NonNullable[number]) => (
{image.previewUrl ? ( ) : (
{image.name}
)}
), )}
)} {row.message.text && (
                    {row.message.text}
                  
)}
{row.message.text && } {canRevertAgentWork && ( )}

{formatTimestamp(row.message.createdAt)}

); })()} {row.kind === "message" && row.message.role === "assistant" && (() => { const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); return ( <> {row.showCompletionDivider && (
{completionSummary ? `Response • ${completionSummary}` : "Response"}
)}
{(() => { const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); if (!turnSummary) return null; const checkpointFiles = turnSummary.files; if (checkpointFiles.length === 0) return null; const summaryStat = checkpointFiles.reduce( (acc, file) => { if ( typeof file.additions !== "number" || typeof file.deletions !== "number" ) { return acc; } return { additions: acc.additions + file.additions, deletions: acc.deletions + file.deletions, }; }, { additions: 0, deletions: 0 }, ); const changedFileCountLabel = String(checkpointFiles.length); return (

Changed files ({changedFileCountLabel}) {(summaryStat.additions > 0 || summaryStat.deletions > 0) && ( <> • +{summaryStat.additions} / -{summaryStat.deletions} )}

{checkpointFiles.map((file) => ( ))}
); })()}

{formatMessageMeta( row.message.createdAt, row.message.streaming ? formatElapsed(row.message.createdAt, nowIso) : formatElapsed(row.message.createdAt, row.message.completedAt), )}

); })()} {row.kind === "working" && (
)}
); if (!hasMessages && !isWorking) { return (

Send a message to start the conversation.

); } return (
{virtualizedRowCount > 0 && (
{virtualRows.map((virtualRow: VirtualItem) => { const row = rows[virtualRow.index]; if (!row) return null; return (
{renderRowContent(row)}
); })}
)} {nonVirtualizedRows.map((row) => (
{renderRowContent(row)}
))}
); }); const ModelPicker = memo(function ModelPicker(props: { model: string; options: ReadonlyArray<{ slug: string; name: string }>; onModelChange: (model: string) => void; }) { return ( ); }); const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: { effort: ReasoningEffort; onEffortChange: (effort: ReasoningEffort) => void; }) { return ( ); }); const OpenInPicker = memo(function OpenInPicker({ keybindings, availableEditors, activeThreadId, }: { keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; activeThreadId: ThreadId | null; }) { const [lastEditor, setLastEditor] = useState(() => { const stored = localStorage.getItem(LAST_EDITOR_KEY); return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; }); const allOptions = useMemo>( () => [ { label: "Cursor", Icon: CursorIcon, value: "cursor", }, { label: "VS Code", Icon: VisualStudioCode, value: "vscode", }, { label: "Zed", Icon: Zed, value: "zed", }, { label: isMacPlatform(navigator.platform) ? "Finder" : isWindowsPlatform(navigator.platform) ? "Explorer" : "Files", Icon: FolderClosedIcon, value: "file-manager", }, ], [], ); const options = useMemo( () => allOptions.filter((option) => availableEditors.includes(option.value)), [allOptions, availableEditors], ); const effectiveEditor = options.some((option) => option.value === lastEditor) ? lastEditor : (options[0]?.value ?? null); const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; const activeThread = useStore((store) => activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, ); const activeProjectId = activeThread?.projectId ?? null; const activeProject = useStore((store) => activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, ); const openInEditor = useCallback( (editorId: EditorId | null) => { const api = readNativeApi(); if (!api || !activeProject) return; const editor = editorId ?? effectiveEditor; if (!editor) return; const cwd = activeThread?.worktreePath ?? activeProject.cwd; void api.shell.openInEditor(cwd, editor); localStorage.setItem(LAST_EDITOR_KEY, editor); setLastEditor(editor); }, [activeProject, activeThread, effectiveEditor, setLastEditor], ); const openFavoriteEditorShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "editor.openFavorite"), [keybindings], ); useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { const api = readNativeApi(); if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; if (!api || !activeProject) return; if (!effectiveEditor) return; e.preventDefault(); const cwd = activeThread?.worktreePath ?? activeProject.cwd; void api.shell.openInEditor(cwd, effectiveEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [activeProject, activeThread, keybindings, effectiveEditor]); return ( }> {options.length === 0 && No installed editors found} {options.map(({ label, Icon, value }) => ( openInEditor(value)}> ))} ); });