import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, type MessageId, type ProjectScript, type ModelSlug, type ProviderKind, type ProjectEntry, type ProjectId, type ProviderApprovalDecision, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ResolvedKeybindingsConfig, type ServerProviderStatus, type ThreadId, type TurnId, type EditorId, type KeybindingCommand, OrchestrationThreadActivity, ProviderInteractionMode, RuntimeMode,} from "@t3tools/contracts";import { applyClaudePromptEffortPrefix, getDefaultModel, getDefaultReasoningEffort, getReasoningEffortOptions, isClaudeUltrathinkPrompt, normalizeClaudeModelOptions, normalizeCodexModelOptions, normalizeModelSlug, resolveReasoningEffortForProvider, resolveModelSlugForProvider, supportsClaudeUltrathinkKeyword,} from "@t3tools/shared/model";import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";import { useDebouncedValue } from "@tanstack/react-pacer";import { useNavigate, useSearch } from "@tanstack/react-router";import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";import { isElectron } from "../env";import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";import { clampCollapsedComposerCursor, type ComposerTrigger, collapseExpandedComposerCursor, detectComposerTrigger, expandCollapsedComposerCursor, parseStandaloneComposerSlashCommand, replaceTextRange,} from "../composer-logic";import { derivePendingApprovals, derivePendingUserInputs, derivePhase, deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, findSidebarProposedPlan, findLatestProposedPlan, deriveWorkLogEntries, hasActionableProposedPlan, hasToolActivityForTurn, isLatestTurnSettled, formatElapsed,} from "../session-logic";import { isScrollContainerNearBottom } from "../chat-scroll";import { buildPendingUserInputAnswers, derivePendingUserInputProgress, setPendingUserInputCustomAnswer, type PendingUserInputDraftAnswer,} from "../pendingUserInput";import { useStore } from "../store";import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, proposedPlanTitle, resolvePlanFollowUpSubmission,} from "../proposedPlan";import { truncateTitle } from "../truncateTitle";import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, type TurnDiffSummary,} from "../types";import { basenameOfPath } from "../vscode-icons";import { useTheme } from "../hooks/useTheme";import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";import BranchToolbar from "./BranchToolbar";import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";import PlanSidebar from "./PlanSidebar";import ThreadTerminalDrawer from "./ThreadTerminalDrawer";import { BotIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, CircleAlertIcon, ListTodoIcon, LockIcon, LockOpenIcon, XIcon,} from "lucide-react";import { Button } from "./ui/button";import { Separator } from "./ui/separator";import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";import { cn, randomUUID } from "~/lib/utils";import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";import { toastManager } from "./ui/toast";import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";import { type NewProjectScriptInput } from "./ProjectScriptsControl";import { commandForProjectScript, nextProjectScriptId, projectScriptRuntimeEnv, projectScriptIdFromCommand, setupProjectScript,} from "~/projectScripts";import { SidebarTrigger } from "./ui/sidebar";import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";import { readNativeApi } from "~/nativeApi";import { resolveAppModelSelection, useAppSettings } from "../appSettings";import { isTerminalFocused } from "../lib/terminalFocus";import { type ComposerImageAttachment, type DraftThreadEnvMode, type PersistedComposerImageAttachment, useComposerDraftStore, useComposerThreadDraft,} from "../composerDraftStore";import { appendTerminalContextsToPrompt, formatTerminalContextLabel, insertInlineTerminalContextPlaceholder, removeInlineTerminalContextPlaceholder, type TerminalContextDraft, type TerminalContextSelection,} from "../lib/terminalContext";import { shouldUseCompactComposerFooter } from "./composerFooterLayout";import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";import { PullRequestThreadDialog } from "./PullRequestThreadDialog";import { MessagesTimeline } from "./chat/MessagesTimeline";import { ChatHeader } from "./chat/ChatHeader";import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview";import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker";import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu";import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions";import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./chat/ClaudeTraitsPicker";import { CodexTraitsMenuContent, CodexTraitsPicker } from "./chat/CodexTraitsPicker";import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu";import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel";import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel";import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner";import { ProviderHealthBanner } from "./chat/ProviderHealthBanner";import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";import { buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildTemporaryWorktreeBranchName, cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, deriveComposerSendState, getCustomModelOptionsByProvider, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, PullRequestDialogState, readFileAsDataUrl, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, SendPhase,} from "./ChatView.logic";import { useLocalStorage } from "~/hooks/useLocalStorage"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = [];const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; function formatOutgoingPrompt(params: { provider: ProviderKind; effort: string | null; text: string;}): string { if (params.provider === "claudeAgent" && params.effort === "ultrathink") { return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); } return params.text;}const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;const SCRIPT_TERMINAL_COLS = 120;const SCRIPT_TERMINAL_ROWS = 30; const extendReplacementRangeForTrailingSpace = ( text: string, rangeEnd: number, replacement: string,): number => { if (!replacement.endsWith(" ")) { return rangeEnd; } return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd;}; const syncTerminalContextsByIds = ( contexts: ReadonlyArray<TerminalContextDraft>, ids: ReadonlyArray<string>,): TerminalContextDraft[] => { const contextsById = new Map(contexts.map((context) => [context.id, context])); return ids.flatMap((id) => { const context = contextsById.get(id); return context ? [context] : []; });}; const terminalContextIdListsEqual = ( contexts: ReadonlyArray<TerminalContextDraft>, ids: ReadonlyArray<string>,): boolean => contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); interface ChatViewProps { threadId: ThreadId;} export default function ChatView({ threadId }: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); const markThreadVisited = useStore((store) => store.markThreadVisited); const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, select: (params) => parseDiffRouteSearch(params), }); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); const composerDraft = useComposerThreadDraft(threadId); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; const composerSendState = useMemo( () => deriveComposerSendState({ prompt, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, }), [composerImages.length, composerTerminalContexts, prompt], ); const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider); const setComposerDraftModel = useComposerDraftStore((store) => store.setModel); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, ); const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); const insertComposerDraftTerminalContext = useComposerDraftStore( (store) => store.insertTerminalContext, ); const addComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.addTerminalContexts, ); const removeComposerDraftTerminalContext = useComposerDraftStore( (store) => store.removeTerminalContext, ); const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); const draftThread = useComposerDraftStore( (store) => store.draftThreadsByThreadId[threadId] ?? null, ); const promptRef = useRef(prompt); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const composerTerminalContextsRef = useRef<TerminalContextDraft[]>(composerTerminalContexts); const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< Record<ThreadId, string | null> >({}); const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); const [sendStartedAt, setSendStartedAt] = useState<string | null>(null); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]); const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] >([]); const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< Record<string, Record<string, PendingUserInputDraftAnswer>> >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState<Record<string, number>>({}); const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef<string | null>(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 [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); const [pullRequestDialogState, setPullRequestDialogState] = useState<PullRequestDialogState | null>(null); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record<string, string[]> >({}); const [composerCursor, setComposerCursor] = useState(() => collapseExpandedComposerCursor(prompt, prompt.length), ); const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() => detectComposerTrigger(prompt, prompt.length), ); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, LastInvokedScriptByProjectSchema, ); const messagesScrollRef = useRef<HTMLDivElement>(null); const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null); const shouldAutoScrollRef = useRef(true); const lastKnownScrollTopRef = useRef(0); const isPointerScrollActiveRef = useRef(false); const lastTouchClientYRef = useRef<number | null>(null); const pendingUserScrollUpIntentRef = useRef(false); const pendingAutoScrollFrameRef = useRef<number | null>(null); const pendingInteractionAnchorRef = useRef<{ element: HTMLElement; top: number; } | null>(null); const pendingInteractionAnchorFrameRef = useRef<number | null>(null); const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); const composerFormRef = useRef<HTMLFormElement>(null); const composerFormHeightRef = useRef(0); const composerImagesRef = useRef<ComposerImageAttachment[]>([]); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]); const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null); const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef<Record<string, number>>({}); const sendInFlightRef = useRef(false); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { messagesScrollRef.current = element; setMessagesScrollElement(element); }, []); const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadId, threadId), ); const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); const setPrompt = useCallback( (nextPrompt: string) => { setComposerDraftPrompt(threadId, nextPrompt); }, [setComposerDraftPrompt, threadId], ); const addComposerImage = useCallback( (image: ComposerImageAttachment) => { addComposerDraftImage(threadId, image); }, [addComposerDraftImage, threadId], ); const addComposerImagesToDraft = useCallback( (images: ComposerImageAttachment[]) => { addComposerDraftImages(threadId, images); }, [addComposerDraftImages, threadId], ); const addComposerTerminalContextsToDraft = useCallback( (contexts: TerminalContextDraft[]) => { addComposerDraftTerminalContexts(threadId, contexts); }, [addComposerDraftTerminalContexts, threadId], ); const removeComposerImageFromDraft = useCallback( (imageId: string) => { removeComposerDraftImage(threadId, imageId); }, [removeComposerDraftImage, threadId], ); const removeComposerTerminalContextFromDraft = useCallback( (contextId: string) => { const contextIndex = composerTerminalContexts.findIndex( (context) => context.id === contextId, ); if (contextIndex < 0) { return; } const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); promptRef.current = nextPrompt.prompt; setPrompt(nextPrompt.prompt); removeComposerDraftTerminalContext(threadId, contextId); setComposerCursor(nextPrompt.cursor); setComposerTrigger( detectComposerTrigger( nextPrompt.prompt, expandCollapsedComposerCursor(nextPrompt.prompt, nextPrompt.cursor), ), ); }, [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], ); const serverThread = threads.find((t) => t.id === threadId); const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => draftThread ? buildLocalDraftThread( threadId, draftThread, fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex, localDraftError, ) : undefined, [draftThread, fallbackDraftProject?.model, localDraftError, threadId], ); const activeThread = serverThread ?? localDraftThread; const runtimeMode = composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeLatestTurn = activeThread?.latestTurn ?? null; const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProject = projects.find((p) => p.id === activeThread?.projectId); const openPullRequestDialog = useCallback( (reference?: string) => { if (!canCheckoutPullRequestIntoThread) { return; } setPullRequestDialogState({ initialReference: reference ?? null, key: Date.now(), }); setComposerHighlightedItemId(null); }, [canCheckoutPullRequestIntoThread], ); const closePullRequestDialog = useCallback(() => { setPullRequestDialogState(null);Lines 1–500 of 4257. Load more
import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, type MessageId, type ProjectScript, type ModelSlug, type ProviderKind, type ProjectEntry, type ProjectId, type ProviderApprovalDecision, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ResolvedKeybindingsConfig, type ServerProviderStatus, type ThreadId, type TurnId, type EditorId, type KeybindingCommand, OrchestrationThreadActivity, ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, getDefaultModel, getDefaultReasoningEffort, getReasoningEffortOptions, isClaudeUltrathinkPrompt, normalizeClaudeModelOptions, normalizeCodexModelOptions, normalizeModelSlug, resolveReasoningEffortForProvider, resolveModelSlugForProvider, supportsClaudeUltrathinkKeyword, } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { clampCollapsedComposerCursor, type ComposerTrigger, collapseExpandedComposerCursor, detectComposerTrigger, expandCollapsedComposerCursor, parseStandaloneComposerSlashCommand, replaceTextRange, } from "../composer-logic"; import { derivePendingApprovals, derivePendingUserInputs, derivePhase, deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, findSidebarProposedPlan, findLatestProposedPlan, deriveWorkLogEntries, hasActionableProposedPlan, hasToolActivityForTurn, isLatestTurnSettled, formatElapsed, } from "../session-logic"; import { isScrollContainerNearBottom } from "../chat-scroll"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, setPendingUserInputCustomAnswer, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { useStore } from "../store"; import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, proposedPlanTitle, resolvePlanFollowUpSubmission, } from "../proposedPlan"; import { truncateTitle } from "../truncateTitle"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, type TurnDiffSummary, } from "../types"; import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { BotIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, CircleAlertIcon, ListTodoIcon, LockIcon, LockOpenIcon, XIcon, } from "lucide-react"; import { Button } from "./ui/button"; import { Separator } from "./ui/separator"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { cn, randomUUID } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId, projectScriptRuntimeEnv, projectScriptIdFromCommand, setupProjectScript, } from "~/projectScripts"; import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { resolveAppModelSelection, useAppSettings } from "../appSettings"; import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, type DraftThreadEnvMode, type PersistedComposerImageAttachment, useComposerDraftStore, useComposerThreadDraft, } from "../composerDraftStore"; import { appendTerminalContextsToPrompt, formatTerminalContextLabel, insertInlineTerminalContextPlaceholder, removeInlineTerminalContextPlaceholder, type TerminalContextDraft, type TerminalContextSelection, } from "../lib/terminalContext"; import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./chat/ClaudeTraitsPicker"; import { CodexTraitsMenuContent, CodexTraitsPicker } from "./chat/CodexTraitsPicker"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildTemporaryWorktreeBranchName, cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, deriveComposerSendState, getCustomModelOptionsByProvider, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, PullRequestDialogState, readFileAsDataUrl, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, SendPhase, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; function formatOutgoingPrompt(params: { provider: ProviderKind; effort: string | null; text: string; }): string { if (params.provider === "claudeAgent" && params.effort === "ultrathink") { return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); } return params.text; } const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; const extendReplacementRangeForTrailingSpace = ( text: string, rangeEnd: number, replacement: string, ): number => { if (!replacement.endsWith(" ")) { return rangeEnd; } return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; }; const syncTerminalContextsByIds = ( contexts: ReadonlyArray<TerminalContextDraft>, ids: ReadonlyArray<string>, ): TerminalContextDraft[] => { const contextsById = new Map(contexts.map((context) => [context.id, context])); return ids.flatMap((id) => { const context = contextsById.get(id); return context ? [context] : []; }); }; const terminalContextIdListsEqual = ( contexts: ReadonlyArray<TerminalContextDraft>, ids: ReadonlyArray<string>, ): boolean => contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); interface ChatViewProps { threadId: ThreadId; } export default function ChatView({ threadId }: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); const markThreadVisited = useStore((store) => store.markThreadVisited); const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, select: (params) => parseDiffRouteSearch(params), }); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); const composerDraft = useComposerThreadDraft(threadId); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; const composerSendState = useMemo( () => deriveComposerSendState({ prompt, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, }), [composerImages.length, composerTerminalContexts, prompt], ); const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider); const setComposerDraftModel = useComposerDraftStore((store) => store.setModel); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, ); const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); const insertComposerDraftTerminalContext = useComposerDraftStore( (store) => store.insertTerminalContext, ); const addComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.addTerminalContexts, ); const removeComposerDraftTerminalContext = useComposerDraftStore( (store) => store.removeTerminalContext, ); const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); const draftThread = useComposerDraftStore( (store) => store.draftThreadsByThreadId[threadId] ?? null, ); const promptRef = useRef(prompt); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const composerTerminalContextsRef = useRef<TerminalContextDraft[]>(composerTerminalContexts); const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< Record<ThreadId, string | null> >({}); const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); const [sendStartedAt, setSendStartedAt] = useState<string | null>(null); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]); const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] >([]); const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< Record<string, Record<string, PendingUserInputDraftAnswer>> >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState<Record<string, number>>({}); const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef<string | null>(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 [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); const [pullRequestDialogState, setPullRequestDialogState] = useState<PullRequestDialogState | null>(null); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record<string, string[]> >({}); const [composerCursor, setComposerCursor] = useState(() => collapseExpandedComposerCursor(prompt, prompt.length), ); const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() => detectComposerTrigger(prompt, prompt.length), ); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, LastInvokedScriptByProjectSchema, ); const messagesScrollRef = useRef<HTMLDivElement>(null); const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null); const shouldAutoScrollRef = useRef(true); const lastKnownScrollTopRef = useRef(0); const isPointerScrollActiveRef = useRef(false); const lastTouchClientYRef = useRef<number | null>(null); const pendingUserScrollUpIntentRef = useRef(false); const pendingAutoScrollFrameRef = useRef<number | null>(null); const pendingInteractionAnchorRef = useRef<{ element: HTMLElement; top: number; } | null>(null); const pendingInteractionAnchorFrameRef = useRef<number | null>(null); const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); const composerFormRef = useRef<HTMLFormElement>(null); const composerFormHeightRef = useRef(0); const composerImagesRef = useRef<ComposerImageAttachment[]>([]); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]); const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null); const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef<Record<string, number>>({}); const sendInFlightRef = useRef(false); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { messagesScrollRef.current = element; setMessagesScrollElement(element); }, []); const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadId, threadId), ); const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); const setPrompt = useCallback( (nextPrompt: string) => { setComposerDraftPrompt(threadId, nextPrompt); }, [setComposerDraftPrompt, threadId], ); const addComposerImage = useCallback( (image: ComposerImageAttachment) => { addComposerDraftImage(threadId, image); }, [addComposerDraftImage, threadId], ); const addComposerImagesToDraft = useCallback( (images: ComposerImageAttachment[]) => { addComposerDraftImages(threadId, images); }, [addComposerDraftImages, threadId], ); const addComposerTerminalContextsToDraft = useCallback( (contexts: TerminalContextDraft[]) => { addComposerDraftTerminalContexts(threadId, contexts); }, [addComposerDraftTerminalContexts, threadId], ); const removeComposerImageFromDraft = useCallback( (imageId: string) => { removeComposerDraftImage(threadId, imageId); }, [removeComposerDraftImage, threadId], ); const removeComposerTerminalContextFromDraft = useCallback( (contextId: string) => { const contextIndex = composerTerminalContexts.findIndex( (context) => context.id === contextId, ); if (contextIndex < 0) { return; } const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); promptRef.current = nextPrompt.prompt; setPrompt(nextPrompt.prompt); removeComposerDraftTerminalContext(threadId, contextId); setComposerCursor(nextPrompt.cursor); setComposerTrigger( detectComposerTrigger( nextPrompt.prompt, expandCollapsedComposerCursor(nextPrompt.prompt, nextPrompt.cursor), ), ); }, [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], ); const serverThread = threads.find((t) => t.id === threadId); const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => draftThread ? buildLocalDraftThread( threadId, draftThread, fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex, localDraftError, ) : undefined, [draftThread, fallbackDraftProject?.model, localDraftError, threadId], ); const activeThread = serverThread ?? localDraftThread; const runtimeMode = composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeLatestTurn = activeThread?.latestTurn ?? null; const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProject = projects.find((p) => p.id === activeThread?.projectId); const openPullRequestDialog = useCallback( (reference?: string) => { if (!canCheckoutPullRequestIntoThread) { return; } setPullRequestDialogState({ initialReference: reference ?? null, key: Date.now(), }); setComposerHighlightedItemId(null); }, [canCheckoutPullRequestIntoThread], ); const closePullRequestDialog = useCallback(() => { setPullRequestDialogState(null);