import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, type EnvironmentId, type MessageId, type ModelSelection, type ProjectScript, type ProviderKind, type ProjectId, type ProviderApprovalDecision, type ServerProvider, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, type TurnId, type KeybindingCommand, OrchestrationThreadActivity, ProviderInteractionMode, RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; import { parseScopedThreadKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; import { applyClaudePromptEffortPrefix, createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { Debouncer } from "@tanstack/react-pacer"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { collapseExpandedComposerCursor, parseStandaloneComposerSlashCommand, } from "../composer-logic"; import { deriveCompletionDividerBeforeEntryId, derivePendingApprovals, derivePendingUserInputs, derivePhase, deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, findSidebarProposedPlan, findLatestProposedPlan, deriveWorkLogEntries, hasActionableProposedPlan, hasToolActivityForTurn, isLatestTurnSettled, formatElapsed, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { selectProjectsAcrossEnvironments, selectThreadsAcrossEnvironments, useStore, } from "../store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, resolvePlanFollowUpSubmission, } from "../proposedPlan"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, type SessionPhase, type Thread, type TurnDiffSummary, } from "../types"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon } from "lucide-react"; import { cn, randomUUID } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId, projectScriptIdFromCommand, } from "~/projectScripts"; import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, type DraftThreadEnvMode, useComposerDraftStore, type DraftId, } from "../composerDraftStore"; import { appendTerminalContextsToPrompt, formatTerminalContextLabel, type TerminalContextDraft, type TerminalContextSelection, } from "../lib/terminalContext"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, hasServerAcknowledgedLocalDispatch, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, type LocalDispatchSnapshot, PullRequestDialogState, cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, shouldWriteThreadErrorToCurrentServerThread, waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; import { useServerAvailableEditors, useServerConfig, useServerKeybindings, } from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { retainThreadDetailSubscription } from "../environments/runtime/service"; import { RightPanelSheet } from "./RightPanelSheet"; 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_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; type ThreadPlanCatalogEntry = Pick; function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { return useStore( useMemo(() => { let previousThreadIds: readonly ThreadId[] = []; let previousResult: ThreadPlanCatalogEntry[] = []; let previousEntries = new Map< ThreadId, { shell: object | null; proposedPlanIds: readonly string[] | undefined; proposedPlansById: Record | undefined; entry: ThreadPlanCatalogEntry; } >(); return (state) => { const sameThreadIds = previousThreadIds.length === threadIds.length && previousThreadIds.every((id, index) => id === threadIds[index]); const nextEntries = new Map< ThreadId, { shell: object | null; proposedPlanIds: readonly string[] | undefined; proposedPlansById: Record | undefined; entry: ThreadPlanCatalogEntry; } >(); const nextResult: ThreadPlanCatalogEntry[] = []; let changed = !sameThreadIds; for (const threadId of threadIds) { let shell: object | undefined; let proposedPlanIds: readonly string[] | undefined; let proposedPlansById: Record | undefined; for (const environmentState of Object.values(state.environmentStateById)) { const matchedShell = environmentState.threadShellById[threadId]; if (!matchedShell) { continue; } shell = matchedShell; proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId]; proposedPlansById = environmentState.proposedPlanByThreadId[threadId] as | Record | undefined; break; } if (!shell) { const previous = previousEntries.get(threadId); if ( previous && previous.shell === null && previous.proposedPlanIds === undefined && previous.proposedPlansById === undefined ) { nextEntries.set(threadId, previous); continue; } changed = true; nextEntries.set(threadId, { shell: null, proposedPlanIds: undefined, proposedPlansById: undefined, entry: { id: threadId, proposedPlans: EMPTY_PROPOSED_PLANS }, }); continue; } const previous = previousEntries.get(threadId); if ( previous && previous.shell === shell && previous.proposedPlanIds === proposedPlanIds && previous.proposedPlansById === proposedPlansById ) { nextEntries.set(threadId, previous); nextResult.push(previous.entry); continue; } changed = true; const proposedPlans = proposedPlanIds && proposedPlanIds.length > 0 && proposedPlansById ? proposedPlanIds.flatMap((planId) => { const proposedPlan = proposedPlansById?.[planId]; return proposedPlan ? [proposedPlan] : []; }) : EMPTY_PROPOSED_PLANS; const entry = { id: threadId, proposedPlans }; nextEntries.set(threadId, { shell, proposedPlanIds, proposedPlansById, entry, }); nextResult.push(entry); } if (!changed && previousResult.length === nextResult.length) { return previousResult; } previousThreadIds = threadIds; previousEntries = nextEntries; previousResult = nextResult; return nextResult; }; }, [threadIds]), ); } function formatOutgoingPrompt(params: { provider: ProviderKind; model: string | null; models: ReadonlyArray; effort: string | null; text: string; }): string { const caps = getProviderModelCapabilities(params.models, params.model, params.provider); const promptEffort = resolvePromptInjectedEffort(caps, params.effort); return applyClaudePromptEffortPrefix(params.text, promptEffort); } const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; type ChatViewProps = | { environmentId: EnvironmentId; threadId: ThreadId; onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; routeKind: "server"; draftId?: never; } | { environmentId: EnvironmentId; threadId: ThreadId; onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; routeKind: "draft"; draftId: DraftId; }; interface TerminalLaunchContext { threadId: ThreadId; cwd: string; worktreePath: string | null; } type PersistentTerminalLaunchContext = Pick; function useLocalDispatchState(input: { activeThread: Thread | undefined; activeLatestTurn: Thread["latestTurn"] | null; phase: SessionPhase; activePendingApproval: ApprovalRequestId | null; activePendingUserInput: ApprovalRequestId | null; threadError: string | null | undefined; }) { const [localDispatch, setLocalDispatch] = useState(null); const beginLocalDispatch = useCallback( (options?: { preparingWorktree?: boolean }) => { const preparingWorktree = Boolean(options?.preparingWorktree); setLocalDispatch((current) => { if (current) { return current.preparingWorktree === preparingWorktree ? current : { ...current, preparingWorktree }; } return createLocalDispatchSnapshot(input.activeThread, options); }); }, [input.activeThread], ); const resetLocalDispatch = useCallback(() => { setLocalDispatch(null); }, []); const serverAcknowledgedLocalDispatch = useMemo( () => hasServerAcknowledgedLocalDispatch({ localDispatch, phase: input.phase, latestTurn: input.activeLatestTurn, session: input.activeThread?.session ?? null, hasPendingApproval: input.activePendingApproval !== null, hasPendingUserInput: input.activePendingUserInput !== null, threadError: input.threadError, }), [ input.activeLatestTurn, input.activePendingApproval, input.activePendingUserInput, input.activeThread?.session, input.phase, input.threadError, localDispatch, ], ); useEffect(() => { if (!serverAcknowledgedLocalDispatch) { return; } resetLocalDispatch(); }, [resetLocalDispatch, serverAcknowledgedLocalDispatch]); return { beginLocalDispatch, resetLocalDispatch, localDispatchStartedAt: localDispatch?.startedAt ?? null, isPreparingWorktree: localDispatch?.preparingWorktree ?? false, isSendBusy: localDispatch !== null && !serverAcknowledgedLocalDispatch, }; } interface PersistentThreadTerminalDrawerProps { threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; threadId: ThreadId; visible: boolean; launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; splitShortcutLabel: string | undefined; newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; keybindings: ResolvedKeybindingsConfig; onAddTerminalContext: (selection: TerminalContextSelection) => void; } const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ threadRef, threadId, visible, launchContext, focusRequestId, splitShortcutLabel, newShortcutLabel, closeShortcutLabel, keybindings, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); const projectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadKey, threadRef), ); const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight); const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal); const storeNewTerminal = useTerminalStateStore((state) => state.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((state) => state.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((state) => state.closeTerminal); const [localFocusRequestId, setLocalFocusRequestId] = useState(0); const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; const effectiveWorktreePath = useMemo(() => { if (launchContext !== null) { return launchContext.worktreePath; } return worktreePath; }, [launchContext, worktreePath]); const cwd = useMemo( () => launchContext?.cwd ?? (project ? projectScriptCwd({ project: { cwd: project.cwd }, worktreePath: effectiveWorktreePath, }) : null), [effectiveWorktreePath, launchContext?.cwd, project], ); const runtimeEnv = useMemo( () => project ? projectScriptRuntimeEnv({ project: { cwd: project.cwd }, worktreePath: effectiveWorktreePath, }) : {}, [effectiveWorktreePath, project], ); const bumpFocusRequestId = useCallback(() => { if (!visible) { return; } setLocalFocusRequestId((value) => value + 1); }, [visible]); const setTerminalHeight = useCallback( (height: number) => { storeSetTerminalHeight(threadRef, height); }, [storeSetTerminalHeight, threadRef], ); const splitTerminal = useCallback(() => { storeSplitTerminal(threadRef, `terminal-${randomUUID()}`); bumpFocusRequestId(); }, [bumpFocusRequestId, storeSplitTerminal, threadRef]); const createNewTerminal = useCallback(() => { storeNewTerminal(threadRef, `terminal-${randomUUID()}`); bumpFocusRequestId(); }, [bumpFocusRequestId, storeNewTerminal, threadRef]); const activateTerminal = useCallback( (terminalId: string) => { storeSetActiveTerminal(threadRef, terminalId); bumpFocusRequestId(); }, [bumpFocusRequestId, storeSetActiveTerminal, threadRef], ); const closeTerminal = useCallback( (terminalId: string) => { const api = readEnvironmentApi(threadRef.environmentId); if (!api) return; const isFinalTerminal = terminalState.terminalIds.length <= 1; const fallbackExitWrite = () => api.terminal.write({ threadId, 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, terminalId }).catch(() => undefined); } await api.terminal.close({ threadId, terminalId, deleteHistory: true, }); })().catch(() => fallbackExitWrite()); } else { void fallbackExitWrite(); } storeCloseTerminal(threadRef, terminalId); bumpFocusRequestId(); }, [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId, threadRef], ); const handleAddTerminalContext = useCallback( (selection: TerminalContextSelection) => { if (!visible) { return; } onAddTerminalContext(selection); }, [onAddTerminalContext, visible], ); if (!project || !terminalState.terminalOpen || !cwd) { return null; } return (
); }); export default function ChatView(props: ChatViewProps) { const { environmentId, threadId, routeKind, onDiffPanelOpen, reserveTitleBarControlInset = true, } = props; const draftId = routeKind === "draft" ? props.draftId : null; const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; const serverThread = useStore( useMemo( () => createThreadSelectorByRef(routeKind === "server" ? routeThreadRef : null), [routeKind, routeThreadRef], ), ); const setStoreThreadError = useStore((store) => store.setError); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore((store) => routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, ); const settings = useSettings(); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, select: (params) => parseDiffRouteSearch(params), }); const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. const composerRuntimeMode = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.runtimeMode ?? null, ); const composerInteractionMode = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.interactionMode ?? null, ); const composerActiveProvider = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, ); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftSessionByLogicalProjectKey = useComposerDraftStore( (store) => store.getDraftSessionByLogicalProjectKey, ); const getDraftSession = useComposerDraftStore((store) => store.getDraftSession); const setLogicalProjectDraftThreadId = useComposerDraftStore( (store) => store.setLogicalProjectDraftThreadId, ); const draftThread = useComposerDraftStore((store) => routeKind === "server" ? store.getDraftSessionByRef(routeThreadRef) : draftId ? store.getDraftSession(draftId) : null, ); const promptRef = useRef(""); const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< Record >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] >([]); const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< Record> >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. const planSidebarOpenOnNextThreadRef = useRef(false); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); const [terminalLaunchContext, setTerminalLaunchContext] = useState( null, ); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] = useState(null); const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState(); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, LastInvokedScriptByProjectSchema, ); const legendListRef = useRef(null); const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const terminalOpenByThreadRef = useRef>({}); const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef), ); const openTerminalThreadKeys = useTerminalStateStore( useShallow((state) => Object.entries(state.terminalStateByThreadKey).flatMap(([nextThreadKey, nextTerminalState]) => nextTerminalState.terminalOpen ? [nextThreadKey] : [], ), ), ); const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); 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 serverThreadKeys = useStore( useShallow((state) => selectThreadsAcrossEnvironments(state).map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ), ), ); const storeServerTerminalLaunchContext = useTerminalStateStore( (s) => s.terminalLaunchContextByThreadKey[scopedThreadKey(routeThreadRef)] ?? null, ); const storeClearTerminalLaunchContext = useTerminalStateStore( (s) => s.clearTerminalLaunchContext, ); const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); const draftThreadKeys = useMemo( () => Object.values(draftThreadsByThreadKey).map((draftThread) => scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), ), [draftThreadsByThreadKey], ); const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); const mountedTerminalThreadRefs = useMemo( () => mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; }), [mountedTerminalThreadKeys], ); const fallbackDraftProjectRef = draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; const fallbackDraftProject = useStore( useMemo(() => createProjectSelectorByRef(fallbackDraftProjectRef), [fallbackDraftProjectRef]), ); const localDraftError = routeKind === "server" && serverThread ? null : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null); const localDraftThread = useMemo( () => draftThread ? buildLocalDraftThread( threadId, draftThread, fallbackDraftProject?.defaultModelSelection ?? { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, localDraftError, ) : undefined, [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], ); const isServerThread = routeKind === "server" && serverThread !== undefined; const activeThread = isServerThread ? serverThread : localDraftThread; const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeThreadRef = useMemo( () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; const threadPlanCatalog = useThreadPlanCatalog( useMemo(() => { const threadIds: ThreadId[] = []; if (activeThread?.id) { threadIds.push(activeThread.id); } const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; if (sourceThreadId && sourceThreadId !== activeThread?.id) { threadIds.push(sourceThreadId); } return threadIds; }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]), ); useEffect(() => { setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ currentThreadIds, openThreadIds: existingOpenTerminalThreadKeys, activeThreadId: activeThreadKey, activeThreadTerminalOpen: Boolean(activeThreadKey && terminalState.terminalOpen), maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, }); return currentThreadIds.length === nextThreadIds.length && currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) ? currentThreadIds : nextThreadIds; }); }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null; const activeProject = useStore( useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), ); useEffect(() => { if (routeKind !== "server") { return; } return retainThreadDetailSubscription(environmentId, threadId); }, [environmentId, routeKind, threadId]); // Compute the list of environments this logical project spans, used to // drive the environment picker in BranchToolbar. const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); const primaryEnvironmentId = usePrimaryEnvironmentId(); const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); const projectGroupingSettings = useSettings((settings) => ({ sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, })); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); const memberProjects = allProjects.filter( (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, ); const seen = new Set(); const envs: Array<{ environmentId: EnvironmentId; projectId: ProjectId; label: string; isPrimary: boolean; }> = []; for (const p of memberProjects) { if (seen.has(p.environmentId)) continue; seen.add(p.environmentId); const isPrimary = p.environmentId === primaryEnvironmentId; const savedRecord = savedEnvironmentRegistry[p.environmentId]; const runtimeState = savedEnvironmentRuntimeById[p.environmentId]; const label = resolveEnvironmentOptionLabel({ isPrimary, environmentId: p.environmentId, runtimeLabel: runtimeState?.descriptor?.label ?? null, savedLabel: savedRecord?.label ?? null, }); envs.push({ environmentId: p.environmentId, projectId: p.id, label, isPrimary, }); } // Sort: primary first, then alphabetical envs.sort((a, b) => { if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; return a.label.localeCompare(b.label); }); return envs; }, [ activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, savedEnvironmentRegistry, savedEnvironmentRuntimeById, ]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; const openPullRequestDialog = useCallback( (reference?: string) => { if (!canCheckoutPullRequestIntoThread) { return; } setPullRequestDialogState({ initialReference: reference ?? null, key: Date.now(), }); }, [canCheckoutPullRequestIntoThread], ); const closePullRequestDialog = useCallback(() => { setPullRequestDialogState(null); }, []); const openOrReuseProjectDraftThread = useCallback( async (input: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => { if (!activeProject) { throw new Error("No active project is available for this pull request."); } const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); const logicalProjectKey = deriveLogicalProjectKeyFromSettings( activeProject, projectGroupingSettings, ); const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); if (storedDraftSession) { setDraftThreadContext(storedDraftSession.draftId, input); setLogicalProjectDraftThreadId( logicalProjectKey, activeProjectRef, storedDraftSession.draftId, { threadId: storedDraftSession.threadId, ...input, }, ); if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) { await navigate({ to: "/draft/$draftId", params: buildDraftThreadRouteParams(storedDraftSession.draftId), }); } return storedDraftSession.threadId; } const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null; if ( !isServerThread && activeDraftSession?.logicalProjectKey === logicalProjectKey && draftId ) { setDraftThreadContext(draftId, input); setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, draftId, { threadId: activeDraftSession.threadId, createdAt: activeDraftSession.createdAt, runtimeMode: activeDraftSession.runtimeMode, interactionMode: activeDraftSession.interactionMode, ...input, }); return activeDraftSession.threadId; } const nextDraftId = newDraftId(); const nextThreadId = newThreadId(); setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { threadId: nextThreadId, createdAt: new Date().toISOString(), runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); await navigate({ to: "/draft/$draftId", params: buildDraftThreadRouteParams(nextDraftId), }); return nextThreadId; }, [ activeProject, draftId, getDraftSession, getDraftSessionByLogicalProjectKey, isServerThread, navigate, projectGroupingSettings, routeKind, setDraftThreadContext, setLogicalProjectDraftThreadId, ], ); const handlePreparedPullRequestThread = useCallback( async (input: { branch: string; worktreePath: string | null }) => { await openOrReuseProjectDraftThread({ branch: input.branch, worktreePath: input.worktreePath, envMode: input.worktreePath ? "worktree" : "local", }); }, [openOrReuseProjectDraftThread], ); useEffect(() => { if (!serverThread?.id) return; if (!latestTurnSettled) return; if (!activeLatestTurn?.completedAt) return; const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); if (Number.isNaN(turnCompletedAt)) return; const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; markThreadVisited(scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id))); }, [ activeLatestTurn?.completedAt, activeThreadLastVisitedAt, latestTurnSettled, markThreadVisited, serverThread?.environmentId, serverThread?.id, ]); const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null; const lockedProvider = deriveLockedProvider({ thread: activeThread, selectedProvider: selectedProviderByThreadId, threadProvider, }); const primaryServerConfig = useServerConfig(); const activeEnvRuntimeState = useSavedEnvironmentRuntimeStore((s) => activeThread?.environmentId ? s.byId[activeThread.environmentId] : null, ); // Use the server config for the thread's environment. For the primary // environment fall back to the global atom; for remote environments use // the runtime state stored by the environment manager. const serverConfig = primaryEnvironmentId && activeThread?.environmentId === primaryEnvironmentId ? primaryServerConfig : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, selectedProviderByThreadId ?? threadProvider ?? "codex", ); const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); 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], ); const pendingUserInputs = useMemo( () => derivePendingUserInputs(threadActivities), [threadActivities], ); const activePendingUserInput = pendingUserInputs[0] ?? null; const activePendingDraftAnswers = useMemo( () => activePendingUserInput ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ?? EMPTY_PENDING_USER_INPUT_ANSWERS) : EMPTY_PENDING_USER_INPUT_ANSWERS, [activePendingUserInput, pendingUserInputAnswersByRequestId], ); const activePendingQuestionIndex = activePendingUserInput ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0) : 0; const activePendingProgress = useMemo( () => activePendingUserInput ? derivePendingUserInputProgress( activePendingUserInput.questions, activePendingDraftAnswers, activePendingQuestionIndex, ) : null, [activePendingDraftAnswers, activePendingQuestionIndex, activePendingUserInput], ); const activePendingResolvedAnswers = useMemo( () => activePendingUserInput ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingDraftAnswers) : null, [activePendingDraftAnswers, activePendingUserInput], ); const activePendingIsResponding = activePendingUserInput ? respondingUserInputRequestIds.includes(activePendingUserInput.requestId) : false; const activeProposedPlan = useMemo(() => { if (!latestTurnSettled) { return null; } return findLatestProposedPlan( activeThread?.proposedPlans ?? [], activeLatestTurn?.turnId ?? null, ); }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); const sidebarProposedPlan = useMemo( () => findSidebarProposedPlan({ threads: threadPlanCatalog, latestTurn: activeLatestTurn, latestTurnSettled, threadId: activeThread?.id ?? null, }), [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog], ); const activePlan = useMemo( () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && latestTurnSettled && hasActionableProposedPlan(activeProposedPlan); const activePendingApproval = pendingApprovals[0] ?? null; const { beginLocalDispatch, resetLocalDispatch, localDispatchStartedAt, isPreparingWorktree, isSendBusy, } = useLocalDispatchState({ activeThread, activeLatestTurn, phase, activePendingApproval: activePendingApproval?.requestId ?? null, activePendingUserInput: activePendingUserInput?.requestId ?? null, threadError: activeThread?.error, }); const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, localDispatchStartedAt, ); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); const clearAttachmentPreviewHandoff = useCallback( (messageId: MessageId, previewUrls?: ReadonlyArray) => { delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; const currentPreviewUrls = previewUrls ?? attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; setAttachmentPreviewHandoffByMessageId((existing) => { if (!(messageId in existing)) { return existing; } const next = { ...existing }; delete next[messageId]; attachmentPreviewHandoffByMessageIdRef.current = next; return next; }); for (const previewUrl of currentPreviewUrls) { revokeBlobPreviewUrl(previewUrl); } }, [], ); const clearAttachmentPreviewHandoffs = useCallback(() => { attachmentPreviewPromotionInFlightByMessageIdRef.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 serverMessages = activeThread?.messages; useEffect(() => { if (typeof Image === "undefined" || !serverMessages || serverMessages.length === 0) { return; } const cleanups: Array<() => void> = []; for (const [messageId, handoffPreviewUrls] of Object.entries( attachmentPreviewHandoffByMessageId, )) { if (attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]) { continue; } const serverMessage = serverMessages.find( (message) => message.id === messageId && message.role === "user", ); if (!serverMessage?.attachments || serverMessage.attachments.length === 0) { continue; } const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) => attachment.type === "image" && attachment.previewUrl ? [attachment.previewUrl] : [], ); if ( serverPreviewUrls.length === 0 || serverPreviewUrls.length !== handoffPreviewUrls.length || serverPreviewUrls.some((previewUrl) => previewUrl.startsWith("blob:")) ) { continue; } attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId] = true; let cancelled = false; const imageInstances: HTMLImageElement[] = []; const preloadServerPreviews = Promise.all( serverPreviewUrls.map( (previewUrl) => new Promise((resolve, reject) => { const image = new Image(); imageInstances.push(image); const handleLoad = () => resolve(); const handleError = () => reject(new Error(`Failed to load server preview for ${messageId}.`)); image.addEventListener("load", handleLoad, { once: true }); image.addEventListener("error", handleError, { once: true }); image.src = previewUrl; }), ), ); void preloadServerPreviews .then(() => { if (cancelled) { return; } clearAttachmentPreviewHandoff(messageId as MessageId, handoffPreviewUrls); }) .catch(() => { if (!cancelled) { delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; } }); cleanups.push(() => { cancelled = true; delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; for (const image of imageInstances) { image.src = ""; } }); } return () => { for (const cleanup of cleanups) { cleanup(); } }; }, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, serverMessages]); 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, activeThread?.proposedPlans ?? [], workLogEntries), [activeThread?.proposedPlans, 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 (!completionSummary) return null; return deriveCompletionDividerBeforeEntryId(timelineEntries, activeLatestTurn); }, [activeLatestTurn, completionSummary, latestTurnSettled, timelineEntries]); const gitCwd = activeProject ? projectScriptCwd({ project: { cwd: activeProject.cwd }, worktreePath: activeThread?.worktreePath ?? null, }) : null; const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); const activeProviderStatus = useMemo( () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, [selectedProvider, providerStatuses], ); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; const activeTerminalLaunchContext = terminalLaunchContext?.threadId === activeThreadId ? terminalLaunchContext : (storeServerTerminalLaunchContext ?? null); // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const terminalShortcutLabelOptions = useMemo( () => ({ context: { terminalFocus: true, terminalOpen: Boolean(terminalState.terminalOpen), }, }), [terminalState.terminalOpen], ); const nonTerminalShortcutLabelOptions = useMemo( () => ({ context: { terminalFocus: false, terminalOpen: Boolean(terminalState.terminalOpen), }, }), [terminalState.terminalOpen], ); const terminalToggleShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.toggle"), [keybindings], ); const splitTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], ); const newTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], ); const closeTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], ); const diffPanelShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), [keybindings, nonTerminalShortcutLabelOptions], ); const onToggleDiff = useCallback(() => { if (!isServerThread) { return; } if (!diffOpen) { onDiffPanelOpen?.(); } void navigate({ to: "/$environmentId/$threadId", params: { environmentId, threadId, }, replace: true, search: (previous) => { const rest = stripDiffSearchParams(previous); return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; }, }); }, [diffOpen, environmentId, isServerThread, navigate, onDiffPanelOpen, threadId]); const envLocked = Boolean( activeThread && (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); // Handle environment change for draft threads. When the user picks a // different environment we update the draft context to point at the physical // project in that environment while keeping the same logical project. const onEnvironmentChange = useCallback( (nextEnvironmentId: EnvironmentId) => { if (envLocked || !draftId) return; const target = logicalProjectEnvironments.find( (env) => env.environmentId === nextEnvironmentId, ); if (!target) return; setDraftThreadContext(draftId, { projectRef: scopeProjectRef(target.environmentId, target.projectId), }); }, [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], ); const activeTerminalGroup = terminalState.terminalGroups.find( (group) => group.id === terminalState.activeTerminalGroupId, ) ?? terminalState.terminalGroups.find((group) => group.terminalIds.includes(terminalState.activeTerminalId), ) ?? null; const hasReachedSplitLimit = (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; const nextError = sanitizeThreadErrorMessage(error); const isCurrentServerThread = shouldWriteThreadErrorToCurrentServerThread({ serverThread, routeThreadRef, targetThreadId, }); if (isCurrentServerThread) { setStoreThreadError(targetThreadId, nextError); return; } const localDraftErrorKey = draftId ?? targetThreadId; setLocalDraftErrorsByDraftId((existing) => { if ((existing[localDraftErrorKey] ?? null) === nextError) { return existing; } return { ...existing, [localDraftErrorKey]: nextError, }; }); }, [draftId, routeThreadRef, serverThread, setStoreThreadError], ); const focusComposer = useCallback(() => { composerRef.current?.focusAtEnd(); }, []); const scheduleComposerFocus = useCallback(() => { window.requestAnimationFrame(() => { focusComposer(); }); }, [focusComposer]); const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => { composerRef.current?.addTerminalContext(selection); }, []); const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadRef) return; storeSetTerminalOpen(activeThreadRef, open); }, [activeThreadRef, storeSetTerminalOpen], ); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadRef) return; setTerminalOpen(!terminalState.terminalOpen); }, [activeThreadRef, setTerminalOpen, terminalState.terminalOpen]); const splitTerminal = useCallback(() => { if (!activeThreadRef || hasReachedSplitLimit) return; const terminalId = `terminal-${randomUUID()}`; storeSplitTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadRef, hasReachedSplitLimit, storeSplitTerminal]); const createNewTerminal = useCallback(() => { if (!activeThreadRef) return; const terminalId = `terminal-${randomUUID()}`; storeNewTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadRef, storeNewTerminal]); const closeTerminal = useCallback( (terminalId: string) => { const api = readEnvironmentApi(environmentId); 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(); } if (activeThreadRef) { storeCloseTerminal(activeThreadRef, terminalId); } setTerminalFocusRequestId((value) => value + 1); }, [ activeThreadId, activeThreadRef, environmentId, storeCloseTerminal, terminalState.terminalIds.length, ], ); const runProjectScript = useCallback( async ( script: ProjectScript, options?: { cwd?: string; env?: Record; worktreePath?: string | null; preferNewTerminal?: boolean; rememberAsLastInvoked?: boolean; }, ) => { const api = readEnvironmentApi(environmentId); 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 = terminalState.activeTerminalId || terminalState.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId); const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; const shouldCreateNewTerminal = wantsNewTerminal; const targetTerminalId = shouldCreateNewTerminal ? `terminal-${randomUUID()}` : baseTerminalId; const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; setTerminalLaunchContext({ threadId: activeThreadId, cwd: targetCwd, worktreePath: targetWorktreePath, }); setTerminalOpen(true); if (!activeThreadRef) { return; } if (shouldCreateNewTerminal) { storeNewTerminal(activeThreadRef, targetTerminalId); } else { storeSetActiveTerminal(activeThreadRef, targetTerminalId); } setTerminalFocusRequestId((value) => value + 1); const runtimeEnv = projectScriptRuntimeEnv({ project: { cwd: activeProject.cwd, }, worktreePath: targetWorktreePath, ...(options?.env ? { extraEnv: options.env } : {}), }); const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal ? { threadId: activeThreadId, terminalId: targetTerminalId, cwd: targetCwd, ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), env: runtimeEnv, cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS, } : { threadId: activeThreadId, terminalId: targetTerminalId, cwd: targetCwd, ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), 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, activeThreadRef, gitCwd, setTerminalOpen, setThreadError, storeNewTerminal, storeSetActiveTerminal, setLastInvokedScriptByProjectId, environmentId, 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 = readEnvironmentApi(environmentId); 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) { const localApi = readLocalApi(); if (!localApi) { throw new Error("Local API unavailable."); } await localApi.server.upsertKeybinding(keybindingRule); } }, [environmentId], ); 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 deleteProjectScript = useCallback( async (scriptId: string) => { if (!activeProject) return; const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId); const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name; try { await persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.cwd, previousScripts: activeProject.scripts, nextScripts, keybinding: null, keybindingCommand: commandForProjectScript(scriptId), }); toastManager.add({ type: "success", title: `Deleted action "${deletedName ?? "Unknown"}"`, }); } catch (error) { toastManager.add( stackedThreadToast({ type: "error", title: "Could not delete action", description: error instanceof Error ? error.message : "An unexpected error occurred.", }), ); } }, [activeProject, persistProjectScripts], ); const handleRuntimeModeChange = useCallback( (mode: RuntimeMode) => { if (mode === runtimeMode) return; setComposerDraftRuntimeMode(composerDraftTarget, mode); if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { runtimeMode: mode }); } scheduleComposerFocus(); }, [ isLocalDraftThread, runtimeMode, scheduleComposerFocus, composerDraftTarget, setComposerDraftRuntimeMode, setDraftThreadContext, ], ); const handleInteractionModeChange = useCallback( (mode: ProviderInteractionMode) => { if (mode === interactionMode) return; setComposerDraftInteractionMode(composerDraftTarget, mode); if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { interactionMode: mode }); } scheduleComposerFocus(); }, [ interactionMode, isLocalDraftThread, scheduleComposerFocus, composerDraftTarget, setComposerDraftInteractionMode, setDraftThreadContext, ], ); const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); const togglePlanSidebar = useCallback(() => { setPlanSidebarOpen((open) => { if (open) { planSidebarDismissedForTurnRef.current = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; } else { planSidebarDismissedForTurnRef.current = null; } return !open; }); }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); const closePlanSidebar = useCallback(() => { setPlanSidebarOpen(false); planSidebarDismissedForTurnRef.current = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); const persistThreadSettingsForNextTurn = useCallback( async (input: { threadId: ThreadId; createdAt: string; modelSelection?: ModelSelection; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; }) => { if (!serverThread) { return; } const api = readEnvironmentApi(environmentId); if (!api) { return; } if ( input.modelSelection !== undefined && (input.modelSelection.model !== serverThread.modelSelection.model || input.modelSelection.provider !== serverThread.modelSelection.provider || JSON.stringify(input.modelSelection.options ?? null) !== JSON.stringify(serverThread.modelSelection.options ?? null)) ) { await api.orchestration.dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), threadId: input.threadId, modelSelection: input.modelSelection, }); } if (input.runtimeMode !== serverThread.runtimeMode) { await api.orchestration.dispatchCommand({ type: "thread.runtime-mode.set", commandId: newCommandId(), threadId: input.threadId, runtimeMode: input.runtimeMode, createdAt: input.createdAt, }); } if (input.interactionMode !== serverThread.interactionMode) { await api.orchestration.dispatchCommand({ type: "thread.interaction-mode.set", commandId: newCommandId(), threadId: input.threadId, interactionMode: input.interactionMode, createdAt: input.createdAt, }); } }, [environmentId, serverThread], ); // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd. const scrollToEnd = useCallback((animated = false) => { legendListRef.current?.scrollToEnd?.({ animated }); }, []); // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during // thread switches. LegendList fires scroll events with isAtEnd=false while // initialScrollAtEnd is settling; hiding is always immediate. const showScrollDebouncer = useRef( new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), ); const onIsAtEndChange = useCallback((isAtEnd: boolean) => { if (isAtEndRef.current === isAtEnd) return; isAtEndRef.current = isAtEnd; if (isAtEnd) { showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); } else { showScrollDebouncer.current.maybeExecute(); } }, []); useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); if (planSidebarOpenOnNextThreadRef.current) { planSidebarOpenOnNextThreadRef.current = false; setPlanSidebarOpen(true); } else { setPlanSidebarOpen(false); } planSidebarDismissedForTurnRef.current = null; }, [activeThread?.id]); // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. // Don't auto-open for plans carried over from a previous turn (the user can open manually). useEffect(() => { if (!activePlan) return; if (planSidebarOpen) return; const latestTurnId = activeLatestTurn?.turnId ?? null; if (latestTurnId && activePlan.turnId !== latestTurnId) return; const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; if (planSidebarDismissedForTurnRef.current === turnKey) return; setPlanSidebarOpen(true); }, [activePlan, activeLatestTurn?.turnId, planSidebarOpen, sidebarProposedPlan?.turnId]); 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(() => { 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(() => { setOptimisticUserMessages((existing) => { for (const message of existing) { revokeUserMessagePreviewUrls(message); } return []; }); resetLocalDispatch(); setExpandedImage(null); }, [draftId, resetLocalDispatch, threadId]); const closeExpandedImage = useCallback(() => { setExpandedImage(null); }, []); const activeWorktreePath = activeThread?.worktreePath ?? null; const derivedEnvMode: DraftThreadEnvMode = resolveEffectiveEnvMode({ activeWorktreePath, hasServerThread: isServerThread, draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined, }); const canOverrideServerThreadEnvMode = Boolean( isServerThread && activeThread && activeThread.messages.length === 0 && activeThread.worktreePath === null && !envLocked, ); const envMode: DraftThreadEnvMode = canOverrideServerThreadEnvMode ? (pendingServerThreadEnvMode ?? draftThread?.envMode ?? derivedEnvMode) : derivedEnvMode; const activeThreadBranch = canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined ? pendingServerThreadBranch : (activeThread?.branch ?? null); const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, isGitRepo, }); useEffect(() => { setPendingServerThreadEnvMode(null); setPendingServerThreadBranch(undefined); }, [activeThread?.id]); useEffect(() => { if (canOverrideServerThreadEnvMode) { return; } setPendingServerThreadEnvMode(null); setPendingServerThreadBranch(undefined); }, [canOverrideServerThreadEnvMode]); useEffect(() => { if (!activeThreadId) { setTerminalLaunchContext(null); storeClearTerminalLaunchContext(routeThreadRef); return; } setTerminalLaunchContext((current) => { if (!current) return current; if (current.threadId === activeThreadId) return current; return null; }); }, [activeThreadId, routeThreadRef, storeClearTerminalLaunchContext]); useEffect(() => { if (!activeThreadId || !activeProjectCwd) { return; } setTerminalLaunchContext((current) => { if (!current || current.threadId !== activeThreadId) { return current; } const settledCwd = projectScriptCwd({ project: { cwd: activeProjectCwd }, worktreePath: activeThreadWorktreePath, }); if ( settledCwd === current.cwd && (activeThreadWorktreePath ?? null) === current.worktreePath ) { if (activeThreadRef) { storeClearTerminalLaunchContext(activeThreadRef); } return null; } return current; }); }, [ activeProjectCwd, activeThreadId, activeThreadRef, activeThreadWorktreePath, storeClearTerminalLaunchContext, ]); useEffect(() => { if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) { return; } const settledCwd = projectScriptCwd({ project: { cwd: activeProjectCwd }, worktreePath: activeThreadWorktreePath, }); if ( settledCwd === storeServerTerminalLaunchContext.cwd && (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath ) { if (activeThreadRef) { storeClearTerminalLaunchContext(activeThreadRef); } } }, [ activeProjectCwd, activeThreadId, activeThreadRef, activeThreadWorktreePath, storeClearTerminalLaunchContext, storeServerTerminalLaunchContext, ]); useEffect(() => { if (terminalState.terminalOpen) { return; } if (activeThreadRef) { storeClearTerminalLaunchContext(activeThreadRef); } setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current)); }, [ activeThreadId, activeThreadRef, storeClearTerminalLaunchContext, terminalState.terminalOpen, ]); useEffect(() => { if (!activeThreadKey) return; const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false; const current = Boolean(terminalState.terminalOpen); if (!previous && current) { terminalOpenByThreadRef.current[activeThreadKey] = current; setTerminalFocusRequestId((value) => value + 1); return; } else if (previous && !current) { terminalOpenByThreadRef.current[activeThreadKey] = current; const frame = window.requestAnimationFrame(() => { focusComposer(); }); return () => { window.cancelAnimationFrame(frame); }; } terminalOpenByThreadRef.current[activeThreadKey] = current; }, [activeThreadKey, focusComposer, terminalState.terminalOpen]); useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) { return; } const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(terminalState.terminalOpen), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, }; 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; } if (command === "modelPicker.toggle") { event.preventDefault(); event.stopPropagation(); composerRef.current?.toggleModelPicker(); 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, true); return () => window.removeEventListener("keydown", handler, true); }, [ activeProject, terminalState.terminalOpen, terminalState.activeTerminalId, activeThreadId, closeTerminal, createNewTerminal, setTerminalOpen, runProjectScript, splitTerminal, keybindings, onToggleDiff, toggleTerminalVisibility, ]); const onRevertToTurnCount = useCallback( async (turnCount: number) => { const api = readEnvironmentApi(environmentId); const localApi = readLocalApi(); if (!api || !localApi || !activeThread || isRevertingCheckpoint) return; if (phase === "running" || isSendBusy || isConnecting) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); return; } const confirmed = await localApi.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, environmentId, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError, ], ); const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); const api = readEnvironmentApi(environmentId); if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; if (activePendingProgress) { onAdvanceActivePendingUserInput(); return; } const sendCtx = composerRef.current?.getSendContext(); if (!sendCtx) return; const { images: composerImages, terminalContexts: composerTerminalContexts, selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, selectedPromptEffort: ctxSelectedPromptEffort, selectedModelSelection: ctxSelectedModelSelection, } = sendCtx; const promptForSend = promptRef.current; const { trimmedPrompt: trimmed, sendableTerminalContexts: sendableComposerTerminalContexts, expiredTerminalContextCount, hasSendableContent, } = deriveComposerSendState({ prompt: promptForSend, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, }); if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, planMarkdown: activeProposedPlan.planMarkdown, }); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); await onSubmitPlanFollowUp({ text: followUp.text, interactionMode: followUp.interactionMode, }); return; } const standaloneSlashCommand = composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); return; } if (!hasSendableContent) { if (expiredTerminalContextCount > 0) { const toastCopy = buildExpiredTerminalContextToastCopy( expiredTerminalContextCount, "empty", ); toastManager.add( stackedThreadToast({ type: "warning", title: toastCopy.title, description: toastCopy.description, }), ); } return; } if (!activeProject) return; const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath ? activeThreadBranch : 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 && sendEnvMode === "worktree" && !activeThread.worktreePath; if (shouldCreateWorktree && !activeThreadBranch) { setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; } sendInFlightRef.current = true; beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const messageTextForSend = appendTerminalContextsToPrompt( promptForSend, composerTerminalContextsSnapshot, ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, models: ctxSelectedProviderModels, effort: ctxSelectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); 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, })); // Scroll to the current end *before* adding the optimistic message. // This sets LegendList's internal isAtEnd=true so maintainScrollAtEnd // automatically pins to the new item when the data changes. isAtEndRef.current = true; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); await legendListRef.current?.scrollToEnd?.({ animated: false }); setOptimisticUserMessages((existing) => [ ...existing, { id: messageIdForSend, role: "user", text: outgoingMessageText, ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), createdAt: messageCreatedAt, streaming: false, }, ]); setThreadError(threadIdForSend, null); if (expiredTerminalContextCount > 0) { const toastCopy = buildExpiredTerminalContextToastCopy( expiredTerminalContextCount, "omitted", ); toastManager.add( stackedThreadToast({ type: "warning", title: toastCopy.title, description: toastCopy.description, }), ); } promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); let turnStartSucceeded = false; await (async () => { 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 if (composerTerminalContextsSnapshot.length > 0) { titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); } else { titleSeed = "New thread"; } } const title = truncate(titleSeed); const threadCreateModelSelection = createModelSelection( ctxSelectedProvider, ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL_BY_PROVIDER.codex, ctxSelectedModelSelection.options, ); // Auto-title from first message if (isFirstMessage && isServerThread) { await api.orchestration.dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), threadId: threadIdForSend, title, }); } if (isServerThread) { await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, createdAt: messageCreatedAt, ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}), runtimeMode, interactionMode, }); } const turnAttachments = await turnAttachmentsPromise; const bootstrap = isLocalDraftThread || baseBranchForWorktree ? { ...(isLocalDraftThread ? { createThread: { projectId: activeProject.id, title, modelSelection: threadCreateModelSelection, runtimeMode, interactionMode, branch: activeThreadBranch, worktreePath: activeThread.worktreePath, createdAt: activeThread.createdAt, }, } : {}), ...(baseBranchForWorktree ? { prepareWorktree: { projectCwd: activeProject.cwd, baseBranch: baseBranchForWorktree, branch: buildTemporaryWorktreeBranchName(), }, runSetupScript: true, } : {}), } : undefined; beginLocalDispatch({ preparingWorktree: false }); await api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), threadId: threadIdForSend, message: { messageId: messageIdForSend, role: "user", text: outgoingMessageText, attachments: turnAttachments, }, modelSelection: ctxSelectedModelSelection, titleSeed: title, runtimeMode, interactionMode, ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }); turnStartSucceeded = true; })().catch(async (err: unknown) => { if ( !turnStartSucceeded && promptRef.current.length === 0 && composerImagesRef.current.length === 0 && composerTerminalContextsRef.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 = promptForSend; const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); composerImagesRef.current = retryComposerImages; composerTerminalContextsRef.current = composerTerminalContextsSnapshot; setComposerDraftPrompt(composerDraftTarget, promptForSend); addComposerDraftImages(composerDraftTarget, retryComposerImages); setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); composerRef.current?.resetCursorState({ cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), prompt: promptForSend, detectTrigger: true, }); } setThreadError( threadIdForSend, err instanceof Error ? err.message : "Failed to send message.", ); }); sendInFlightRef.current = false; if (!turnStartSucceeded) { resetLocalDispatch(); } }; const onInterrupt = async () => { const api = readEnvironmentApi(environmentId); 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 = readEnvironmentApi(environmentId); 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) => { setThreadError( activeThreadId, err instanceof Error ? err.message : "Failed to submit approval decision.", ); }); setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); }, [activeThreadId, environmentId, setThreadError], ); const onRespondToUserInput = useCallback( async (requestId: ApprovalRequestId, answers: Record) => { const api = readEnvironmentApi(environmentId); if (!api || !activeThreadId) return; setRespondingUserInputRequestIds((existing) => existing.includes(requestId) ? existing : [...existing, requestId], ); await api.orchestration .dispatchCommand({ type: "thread.user-input.respond", commandId: newCommandId(), threadId: activeThreadId, requestId, answers, createdAt: new Date().toISOString(), }) .catch((err: unknown) => { setThreadError( activeThreadId, err instanceof Error ? err.message : "Failed to submit user input.", ); }); setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); }, [activeThreadId, environmentId, setThreadError], ); const setActivePendingUserInputQuestionIndex = useCallback( (nextQuestionIndex: number) => { if (!activePendingUserInput) { return; } setPendingUserInputQuestionIndexByRequestId((existing) => ({ ...existing, [activePendingUserInput.requestId]: nextQuestionIndex, })); }, [activePendingUserInput], ); const onSelectActivePendingUserInputOption = useCallback( (questionId: string, optionLabel: string) => { if (!activePendingUserInput) { return; } setPendingUserInputAnswersByRequestId((existing) => { const question = (activePendingProgress?.activeQuestion?.id === questionId ? activePendingProgress.activeQuestion : undefined) ?? activePendingUserInput.questions.find((entry) => entry.id === questionId); if (!question) { return existing; } return { ...existing, [activePendingUserInput.requestId]: { ...existing[activePendingUserInput.requestId], [questionId]: togglePendingUserInputOptionSelection( question, existing[activePendingUserInput.requestId]?.[questionId], optionLabel, ), }, }; }); promptRef.current = ""; composerRef.current?.resetCursorState({ cursor: 0 }); }, [activePendingProgress?.activeQuestion, activePendingUserInput], ); const onChangeActivePendingUserInputCustomAnswer = useCallback( ( questionId: string, value: string, nextCursor: number, expandedCursor: number, _cursorAdjacentToMention: boolean, ) => { if (!activePendingUserInput) { return; } promptRef.current = value; setPendingUserInputAnswersByRequestId((existing) => ({ ...existing, [activePendingUserInput.requestId]: { ...existing[activePendingUserInput.requestId], [questionId]: setPendingUserInputCustomAnswer( existing[activePendingUserInput.requestId]?.[questionId], value, ), }, })); const snapshot = composerRef.current?.readSnapshot(); if ( snapshot?.value !== value || snapshot.cursor !== nextCursor || snapshot.expandedCursor !== expandedCursor ) { composerRef.current?.focusAt(nextCursor); } }, [activePendingUserInput], ); const onAdvanceActivePendingUserInput = useCallback(() => { if (!activePendingUserInput || !activePendingProgress) { return; } if (activePendingProgress.isLastQuestion) { if (activePendingResolvedAnswers) { void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers); } return; } setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); }, [ activePendingProgress, activePendingResolvedAnswers, activePendingUserInput, onRespondToUserInput, setActivePendingUserInputQuestionIndex, ]); const onPreviousActivePendingUserInputQuestion = useCallback(() => { if (!activePendingProgress) { return; } setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0)); }, [activePendingProgress, setActivePendingUserInputQuestionIndex]); const onSubmitPlanFollowUp = useCallback( async ({ text, interactionMode: nextInteractionMode, }: { text: string; interactionMode: "default" | "plan"; }) => { const api = readEnvironmentApi(environmentId); if ( !api || !activeThread || !isServerThread || isSendBusy || isConnecting || sendInFlightRef.current ) { return; } const trimmed = text.trim(); if (!trimmed) { return; } const sendCtx = composerRef.current?.getSendContext(); if (!sendCtx) { return; } const { selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, selectedPromptEffort: ctxSelectedPromptEffort, selectedModelSelection: ctxSelectedModelSelection, } = sendCtx; const threadIdForSend = activeThread.id; const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, models: ctxSelectedProviderModels, effort: ctxSelectedPromptEffort, text: trimmed, }); sendInFlightRef.current = true; beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); // Scroll to the current end *before* adding the optimistic message. isAtEndRef.current = true; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); await legendListRef.current?.scrollToEnd?.({ animated: false }); setOptimisticUserMessages((existing) => [ ...existing, { id: messageIdForSend, role: "user", text: outgoingMessageText, createdAt: messageCreatedAt, streaming: false, }, ]); try { await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, createdAt: messageCreatedAt, modelSelection: ctxSelectedModelSelection, runtimeMode, interactionMode: nextInteractionMode, }); // Keep the mode toggle and plan-follow-up banner in sync immediately // while the same-thread implementation turn is starting. setComposerDraftInteractionMode( scopeThreadRef(activeThread.environmentId, threadIdForSend), nextInteractionMode, ); await api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), threadId: threadIdForSend, message: { messageId: messageIdForSend, role: "user", text: outgoingMessageText, attachments: [], }, modelSelection: ctxSelectedModelSelection, titleSeed: activeThread.title, runtimeMode, interactionMode: nextInteractionMode, ...(nextInteractionMode === "default" && activeProposedPlan ? { sourceProposedPlan: { threadId: activeThread.id, planId: activeProposedPlan.id, }, } : {}), createdAt: messageCreatedAt, }); // Optimistically open the plan sidebar when implementing (not refining). // "default" mode here means the agent is executing the plan, which produces // step-tracking activities that the sidebar will display. if (nextInteractionMode === "default") { planSidebarDismissedForTurnRef.current = null; setPlanSidebarOpen(true); } sendInFlightRef.current = false; } catch (err) { setOptimisticUserMessages((existing) => existing.filter((message) => message.id !== messageIdForSend), ); setThreadError( threadIdForSend, err instanceof Error ? err.message : "Failed to send plan follow-up.", ); sendInFlightRef.current = false; resetLocalDispatch(); } }, [ activeThread, activeProposedPlan, beginLocalDispatch, isConnecting, isSendBusy, isServerThread, persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, setComposerDraftInteractionMode, setThreadError, environmentId, ], ); const onImplementPlanInNewThread = useCallback(async () => { const api = readEnvironmentApi(environmentId); if ( !api || !activeThread || !activeProject || !activeProposedPlan || !isServerThread || isSendBusy || isConnecting || sendInFlightRef.current ) { return; } const sendCtx = composerRef.current?.getSendContext(); if (!sendCtx) { return; } const { selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, selectedPromptEffort: ctxSelectedPromptEffort, selectedModelSelection: ctxSelectedModelSelection, } = sendCtx; const createdAt = new Date().toISOString(); const nextThreadId = newThreadId(); const planMarkdown = activeProposedPlan.planMarkdown; const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); const outgoingImplementationPrompt = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, models: ctxSelectedProviderModels, effort: ctxSelectedPromptEffort, text: implementationPrompt, }); const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection; sendInFlightRef.current = true; beginLocalDispatch({ preparingWorktree: false }); const finish = () => { sendInFlightRef.current = false; resetLocalDispatch(); }; await api.orchestration .dispatchCommand({ type: "thread.create", commandId: newCommandId(), threadId: nextThreadId, projectId: activeProject.id, title: nextThreadTitle, modelSelection: nextThreadModelSelection, runtimeMode, interactionMode: "default", branch: activeThreadBranch, worktreePath: activeThread.worktreePath, createdAt, }) .then(() => { return api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), threadId: nextThreadId, message: { messageId: newMessageId(), role: "user", text: outgoingImplementationPrompt, attachments: [], }, modelSelection: ctxSelectedModelSelection, titleSeed: nextThreadTitle, runtimeMode, interactionMode: "default", sourceProposedPlan: { threadId: activeThread.id, planId: activeProposedPlan.id, }, createdAt, }); }) .then(() => { return waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)); }) .then(() => { // Signal that the plan sidebar should open on the new thread. planSidebarOpenOnNextThreadRef.current = true; return navigate({ to: "/$environmentId/$threadId", params: { environmentId: activeThread.environmentId, threadId: nextThreadId, }, }); }) .catch(async (err: unknown) => { await api.orchestration .dispatchCommand({ type: "thread.delete", commandId: newCommandId(), threadId: nextThreadId, }) .catch(() => undefined); toastManager.add( stackedThreadToast({ type: "error", title: "Could not start implementation thread", description: err instanceof Error ? err.message : "An error occurred while creating the new thread.", }), ); }) .then(finish, finish); }, [ activeProject, activeProposedPlan, activeThreadBranch, activeThread, beginLocalDispatch, isConnecting, isSendBusy, isServerThread, navigate, resetLocalDispatch, runtimeMode, environmentId, ]); const onProviderModelSelect = useCallback( (provider: ProviderKind, model: string) => { if (!activeThread) return; if (lockedProvider !== null && provider !== lockedProvider) { scheduleComposerFocus(); return; } const resolvedProvider = resolveSelectableProvider(providerStatuses, provider); const resolvedModel = resolveAppModelSelection( resolvedProvider, settings, providerStatuses, model, ); const nextModelSelection: ModelSelection = { provider: resolvedProvider, model: resolvedModel, }; setComposerDraftModelSelection( scopeThreadRef(activeThread.environmentId, activeThread.id), nextModelSelection, ); setStickyComposerModelSelection(nextModelSelection); scheduleComposerFocus(); }, [ activeThread, lockedProvider, scheduleComposerFocus, setComposerDraftModelSelection, setStickyComposerModelSelection, providerStatuses, settings, ], ); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (canOverrideServerThreadEnvMode) { setPendingServerThreadEnvMode(mode); scheduleComposerFocus(); return; } if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { envMode: mode, ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), }); } scheduleComposerFocus(); }, [ canOverrideServerThreadEnvMode, composerDraftTarget, draftThread?.worktreePath, isLocalDraftThread, setPendingServerThreadEnvMode, scheduleComposerFocus, setDraftThreadContext, ], ); const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { if (!isServerThread) { return; } onDiffPanelOpen?.(); void navigate({ to: "/$environmentId/$threadId", params: { environmentId, threadId, }, search: (previous) => { const rest = stripDiffSearchParams(previous); return filePath ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } : { ...rest, diff: "1", diffTurnId: turnId }; }, }); }, [environmentId, isServerThread, navigate, onDiffPanelOpen, threadId], ); // Both the Map and the revert handler are read from refs at call-time so // the callback reference is fully stable and never busts context identity. const revertTurnCountRef = useRef(revertTurnCountByUserMessageId); revertTurnCountRef.current = revertTurnCountByUserMessageId; const onRevertToTurnCountRef = useRef(onRevertToTurnCount); onRevertToTurnCountRef.current = onRevertToTurnCount; const onRevertUserMessage = useCallback((messageId: MessageId) => { const targetTurnCount = revertTurnCountRef.current.get(messageId); if (typeof targetTurnCount !== "number") { return; } void onRevertToTurnCountRef.current(targetTurnCount); }, []); // Empty state: no active thread if (!activeThread) { return ; } return (
{/* Top bar */}
{/* Error banner */} setThreadError(activeThread.id, null)} /> {/* Main content area with optional plan sidebar */}
{/* Chat column */}
{/* Messages Wrapper */}
{/* Messages — LegendList handles virtualization and scrolling internally */} {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} {showScrollToBottom && (
)}
{/* Input bar */}
{isGitRepo && ( )} {pullRequestDialogState ? ( { if (!open) { closePullRequestDialog(); } }} onPrepared={handlePreparedPullRequestThread} /> ) : null}
{/* end chat column */} {/* Plan sidebar */} {planSidebarOpen && !shouldUsePlanSidebarSheet ? ( ) : null}
{/* end horizontal flex container */} {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( ))} {shouldUsePlanSidebarSheet ? ( ) : null} {expandedImage && ( )}
); }