import { EDITORS, type EditorId, type NativeApi, type ProjectEntry, type ProjectScript, ModelSlug, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ResolvedKeybindingsConfig, type ProviderApprovalDecision, type ProviderSendTurnAttachmentInput,} from "@t3tools/contracts";import { type ClipboardEvent, type DragEvent, type FormEvent, type KeyboardEvent, memo, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,} from "react";import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";import { useDebouncedValue } from "@tanstack/react-pacer";import { useNavigate, useSearch } from "@tanstack/react-router";import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual";import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env";import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";import { useAppSettings } from "../appSettings";import { buildBootstrapInput } from "../historyBootstrap";import { type ComposerTriggerKind, detectComposerTrigger, replaceTextRange,} from "../composer-logic";import { DEFAULT_MODEL, DEFAULT_REASONING, MODEL_OPTIONS, REASONING_OPTIONS, ReasoningEffort, resolveModelSlug,} from "../model-logic";import { derivePendingApprovals, derivePhase, deriveTurnDiffFilesFromUnifiedDiff, deriveTimelineEntries, type TurnDiffSummary, type PendingApproval, deriveWorkLogEntries, formatDuration, formatElapsed, formatTimestamp,} from "../session-logic";import { isScrollContainerNearBottom } from "../chat-scroll";import { useStore } from "../store";import { truncateTitle } from "../truncateTitle";import { DEFAULT_THREAD_TERMINAL_ID, MAX_THREAD_TERMINAL_COUNT, type ChatImageAttachment, type TurnDiffFileChange,} from "../types";import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";import { useTheme } from "../hooks/useTheme";import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";import BranchToolbar from "./BranchToolbar";import GitActionsControl from "./GitActionsControl";import { isOpenFavoriteEditorShortcut, resolveShortcutCommand, shortcutLabelForCommand,} from "../keybindings";import ChatMarkdown from "./ChatMarkdown";import ThreadTerminalDrawer from "./ThreadTerminalDrawer";import { useNativeApi } from "../hooks/useNativeApi";import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";import { BotIcon, ChevronDownIcon, CircleAlertIcon, FileIcon, FolderIcon, DiffIcon, FolderClosedIcon, InfoIcon, LockIcon, LockOpenIcon, Undo2Icon, XIcon,} from "lucide-react";import { Button } from "./ui/button";import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";import { Separator } from "./ui/separator";import { Group, GroupSeparator } from "./ui/group";import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";import { CursorIcon, Icon } from "./Icons";import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";import { Badge } from "./ui/badge";import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";import { commandForProjectScript, nextProjectScriptId, projectScriptRuntimeEnv, projectScriptIdFromCommand, setupProjectScript,} from "~/projectScripts";import { Toggle } from "./ui/toggle"; function formatMessageMeta(createdAt: string, duration: string | null): string { if (!duration) return formatTimestamp(createdAt); return `${formatTimestamp(createdAt)} • ${duration}`;} const LAST_EDITOR_KEY = "t3code:last-editor";const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({ slug, name, searchSlug: slug.toLowerCase(), searchName: name.toLowerCase(),}));const SCRIPT_TERMINAL_COLS = 120;const SCRIPT_TERMINAL_ROWS = 30; function readLastInvokedScriptByProjectFromStorage(): Record<string, string> { const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); if (!stored) return {}; try { const parsed: unknown = JSON.parse(stored); if (!parsed || typeof parsed !== "object") return {}; return Object.fromEntries( Object.entries(parsed).filter( (entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string", ), ); } catch { return {}; }} function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; if (tone === "tool") return "text-muted-foreground/70"; if (tone === "thinking") return "text-muted-foreground/50"; return "text-muted-foreground/40";} type SessionContinuityState = "resumed" | "new" | "fallback_new"; interface EnsuredSessionInfo { sessionId: string; resolvedThreadId: string | null; continuityState: SessionContinuityState;} interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { previewUrl: string; file: File;} interface ExpandedImagePreview { src: string; name: string;} type TurnCheckpointFilesState = | { status: "pending" | "loading"; files: TurnDiffFileChange[]; errorMessage?: undefined; } | { status: "error"; files: TurnDiffFileChange[]; errorMessage: string; } | { status: "ready"; files: TurnDiffFileChange[]; errorMessage?: undefined; }; function checkpointErrorMessage(error: unknown): string { if (error instanceof Error && error.message.trim().length > 0) { return error.message; } if (typeof error === "string" && error.trim().length > 0) { return error; } return "Checkpoint diff is unavailable.";} type ComposerCommandItem = | { id: string; type: "path"; path: string; pathKind: ProjectEntry["kind"]; label: string; description: string; } | { id: string; type: "slash-command"; label: string; description: string; } | { id: string; type: "model"; model: ModelSlug; label: string; description: string; }; function readFileAsDataUrl(file: File): Promise<string> { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.addEventListener("load", () => { if (typeof reader.result === "string") { resolve(reader.result); return; } reject(new Error("Could not read image data.")); }); reader.addEventListener("error", () => { reject(reader.error ?? new Error("Failed to read image.")); }); reader.readAsDataURL(file); });} const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { pathValue: string; kind: "file" | "directory"; theme: "light" | "dark";}) { const [failed, setFailed] = useState(false); const iconUrl = useMemo( () => getVscodeIconUrlForEntry(props.pathValue, props.kind, props.theme), [props.kind, props.pathValue, props.theme], ); useEffect(() => { setFailed(false); }, [iconUrl]); if (failed) { return props.kind === "directory" ? ( <FolderIcon className="size-4 text-muted-foreground/80" /> ) : ( <FileIcon className="size-4 text-muted-foreground/80" /> ); } return ( <img src={iconUrl} alt="" aria-hidden="true" className="size-4 shrink-0" loading="lazy" onError={() => setFailed(true)} /> );}); const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { item: ComposerCommandItem; resolvedTheme: "light" | "dark"; onSelect: (item: ComposerCommandItem) => void;}) { return ( <CommandItem value={props.item.id} className="cursor-pointer gap-2" onMouseDown={(event) => { event.preventDefault(); }} onClick={() => { props.onSelect(props.item); }} > {props.item.type === "path" ? ( <VscodeEntryIcon pathValue={props.item.path} kind={props.item.pathKind} theme={props.resolvedTheme} /> ) : null} {props.item.type === "slash-command" ? ( <BotIcon className="size-4 text-muted-foreground/80" /> ) : null} {props.item.type === "model" ? ( <Badge variant="outline" className="px-1.5 py-0 text-[10px]"> model </Badge> ) : null} <span className="truncate">{props.item.label}</span> <span className="truncate text-muted-foreground/70 text-xs">{props.item.description}</span> </CommandItem> );}); const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { items: ComposerCommandItem[]; resolvedTheme: "light" | "dark"; isLoading: boolean; triggerKind: ComposerTriggerKind | null; onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; commandInputRef: RefObject<HTMLInputElement | null>;}) { return ( <Command mode="none" onItemHighlighted={(highlightedValue) => { props.onHighlightedItemChange( typeof highlightedValue === "string" ? highlightedValue : null, ); }} > <div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"> <div className="pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0"> <CommandInput autoFocus={false} ref={props.commandInputRef} /> </div> <CommandList className="max-h-64"> {props.items.map((item) => ( <ComposerCommandMenuItem key={item.id} item={item} resolvedTheme={props.resolvedTheme} onSelect={props.onSelect} /> ))} </CommandList> {props.items.length === 0 && ( <p className="px-3 py-2 text-muted-foreground/70 text-xs"> {props.isLoading ? "Searching workspace files..." : props.triggerKind === "path" ? "No matching files or folders." : "No matching command."} </p> )} </div> </Command> );}); interface ChatViewProps { threadId: string;} export default function ChatView({ threadId }: ChatViewProps) { const { state, dispatch } = useStore(); const navigate = useNavigate(); const rawSearch = useSearch({ strict: false }); const api = useNativeApi(); const { resolvedTheme } = useTheme(); const { settings: appSettings } = useAppSettings(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation( gitCreateWorktreeMutationOptions({ api, queryClient }), ); const [prompt, setPrompt] = useState(""); const promptRef = useRef(prompt); const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); const [isSending, setIsSending] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>([]); const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [composerCursor, setComposerCursor] = useState(0); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< Record<string, string> >(() => readLastInvokedScriptByProjectFromStorage()); const messagesScrollRef = useRef<HTMLDivElement>(null); const shouldAutoScrollRef = useRef(true); const textareaRef = useRef<HTMLTextAreaElement>(null); const composerCommandInputRef = useRef<HTMLInputElement>(null); const composerImagesRef = useRef<ComposerImageAttachment[]>([]); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); const checkpointHydrationSessionRequestRef = useRef(new Set<string>()); const checkpointTurnCountSyncFingerprintRef = useRef(new Map<string, string>()); const terminalStateSyncFingerprintRef = useRef(new Map<string, string>()); const activeThread = state.threads.find((t) => t.id === threadId); const diffSearch = useMemo( () => parseDiffRouteSearch(rawSearch as Record<string, unknown>), [rawSearch], ); const diffOpen = diffSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeSessionId = activeThread?.session?.sessionId ?? null; const activeThreadRuntimeId = activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); const selectedModel = resolveModelSlug( activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, ); const phase = derivePhase(activeThread?.session ?? null); const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const workLogEntries = useMemo( () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), [activeThread?.events], ); const latestTurnWorkEntries = useMemo( () => deriveWorkLogEntries(activeThread?.events ?? [], activeThread?.latestTurnId), [activeThread?.events, activeThread?.latestTurnId], ); const pendingApprovals = useMemo( () => derivePendingApprovals(activeThread?.events ?? []), [activeThread?.events], ); const assistantCompletionByItemId = useMemo(() => { const map = new Map<string, string>(); const ordered = [...(activeThread?.events ?? [])].toReversed(); for (const event of ordered) { if (event.method !== "item/completed") continue; if (!event.itemId) continue; map.set(event.itemId, event.createdAt); } return map; }, [activeThread?.events]); const timelineEntries = useMemo( () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), [activeThread?.messages, workLogEntries], ); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const turnDiffSummaryByAssistantMessageId = useMemo(() => { const byMessageId = new Map<string, TurnDiffSummary>(); for (const summary of turnDiffSummaries) { if (!summary.assistantMessageId) continue; byMessageId.set(summary.assistantMessageId, summary); } return byMessageId; }, [turnDiffSummaries]); const checkpointDiffInputsByTurn = useMemo( () => turnDiffSummaries .filter((summary) => summary.assistantMessageId) .map((summary) => { const checkpointTurnCount = summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; return { turnId: summary.turnId, checkpointTurnCount: typeof checkpointTurnCount === "number" ? checkpointTurnCount : null, }; }), [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], ); const checkpointDiffQueriesByTurn = useQueries({ queries: checkpointDiffInputsByTurn.map((input) => checkpointDiffQueryOptions(api, { sessionId: activeSessionId, threadRuntimeId: activeThreadRuntimeId, fromTurnCount: typeof input.checkpointTurnCount === "number" ? Math.max(0, input.checkpointTurnCount - 1) : null, toTurnCount: input.checkpointTurnCount, cacheScope: `turn:${input.turnId}`, }), ),Lines 1–500 of 3205. Load more
import { EDITORS, type EditorId, type NativeApi, type ProjectEntry, type ProjectScript, ModelSlug, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ResolvedKeybindingsConfig, type ProviderApprovalDecision, type ProviderSendTurnAttachmentInput, } from "@t3tools/contracts"; import { type ClipboardEvent, type DragEvent, type FormEvent, type KeyboardEvent, memo, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { useAppSettings } from "../appSettings"; import { buildBootstrapInput } from "../historyBootstrap"; import { type ComposerTriggerKind, detectComposerTrigger, replaceTextRange, } from "../composer-logic"; import { DEFAULT_MODEL, DEFAULT_REASONING, MODEL_OPTIONS, REASONING_OPTIONS, ReasoningEffort, resolveModelSlug, } from "../model-logic"; import { derivePendingApprovals, derivePhase, deriveTurnDiffFilesFromUnifiedDiff, deriveTimelineEntries, type TurnDiffSummary, type PendingApproval, deriveWorkLogEntries, formatDuration, formatElapsed, formatTimestamp, } from "../session-logic"; import { isScrollContainerNearBottom } from "../chat-scroll"; import { useStore } from "../store"; import { truncateTitle } from "../truncateTitle"; import { DEFAULT_THREAD_TERMINAL_ID, MAX_THREAD_TERMINAL_COUNT, type ChatImageAttachment, type TurnDiffFileChange, } from "../types"; import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { isOpenFavoriteEditorShortcut, resolveShortcutCommand, shortcutLabelForCommand, } from "../keybindings"; import ChatMarkdown from "./ChatMarkdown"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { useNativeApi } from "../hooks/useNativeApi"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { BotIcon, ChevronDownIcon, CircleAlertIcon, FileIcon, FolderIcon, DiffIcon, FolderClosedIcon, InfoIcon, LockIcon, LockOpenIcon, Undo2Icon, XIcon, } from "lucide-react"; import { Button } from "./ui/button"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { Separator } from "./ui/separator"; import { Group, GroupSeparator } from "./ui/group"; import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"; import { CursorIcon, Icon } from "./Icons"; import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { Badge } from "./ui/badge"; import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId, projectScriptRuntimeEnv, projectScriptIdFromCommand, setupProjectScript, } from "~/projectScripts"; import { Toggle } from "./ui/toggle"; function formatMessageMeta(createdAt: string, duration: string | null): string { if (!duration) return formatTimestamp(createdAt); return `${formatTimestamp(createdAt)} • ${duration}`; } const LAST_EDITOR_KEY = "t3code:last-editor"; const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({ slug, name, searchSlug: slug.toLowerCase(), searchName: name.toLowerCase(), })); const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; function readLastInvokedScriptByProjectFromStorage(): Record<string, string> { const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); if (!stored) return {}; try { const parsed: unknown = JSON.parse(stored); if (!parsed || typeof parsed !== "object") return {}; return Object.fromEntries( Object.entries(parsed).filter( (entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string", ), ); } catch { return {}; } } function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; if (tone === "tool") return "text-muted-foreground/70"; if (tone === "thinking") return "text-muted-foreground/50"; return "text-muted-foreground/40"; } type SessionContinuityState = "resumed" | "new" | "fallback_new"; interface EnsuredSessionInfo { sessionId: string; resolvedThreadId: string | null; continuityState: SessionContinuityState; } interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { previewUrl: string; file: File; } interface ExpandedImagePreview { src: string; name: string; } type TurnCheckpointFilesState = | { status: "pending" | "loading"; files: TurnDiffFileChange[]; errorMessage?: undefined; } | { status: "error"; files: TurnDiffFileChange[]; errorMessage: string; } | { status: "ready"; files: TurnDiffFileChange[]; errorMessage?: undefined; }; function checkpointErrorMessage(error: unknown): string { if (error instanceof Error && error.message.trim().length > 0) { return error.message; } if (typeof error === "string" && error.trim().length > 0) { return error; } return "Checkpoint diff is unavailable."; } type ComposerCommandItem = | { id: string; type: "path"; path: string; pathKind: ProjectEntry["kind"]; label: string; description: string; } | { id: string; type: "slash-command"; label: string; description: string; } | { id: string; type: "model"; model: ModelSlug; label: string; description: string; }; function readFileAsDataUrl(file: File): Promise<string> { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.addEventListener("load", () => { if (typeof reader.result === "string") { resolve(reader.result); return; } reject(new Error("Could not read image data.")); }); reader.addEventListener("error", () => { reject(reader.error ?? new Error("Failed to read image.")); }); reader.readAsDataURL(file); }); } const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { pathValue: string; kind: "file" | "directory"; theme: "light" | "dark"; }) { const [failed, setFailed] = useState(false); const iconUrl = useMemo( () => getVscodeIconUrlForEntry(props.pathValue, props.kind, props.theme), [props.kind, props.pathValue, props.theme], ); useEffect(() => { setFailed(false); }, [iconUrl]); if (failed) { return props.kind === "directory" ? ( <FolderIcon className="size-4 text-muted-foreground/80" /> ) : ( <FileIcon className="size-4 text-muted-foreground/80" /> ); } return ( <img src={iconUrl} alt="" aria-hidden="true" className="size-4 shrink-0" loading="lazy" onError={() => setFailed(true)} /> ); }); const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { item: ComposerCommandItem; resolvedTheme: "light" | "dark"; onSelect: (item: ComposerCommandItem) => void; }) { return ( <CommandItem value={props.item.id} className="cursor-pointer gap-2" onMouseDown={(event) => { event.preventDefault(); }} onClick={() => { props.onSelect(props.item); }} > {props.item.type === "path" ? ( <VscodeEntryIcon pathValue={props.item.path} kind={props.item.pathKind} theme={props.resolvedTheme} /> ) : null} {props.item.type === "slash-command" ? ( <BotIcon className="size-4 text-muted-foreground/80" /> ) : null} {props.item.type === "model" ? ( <Badge variant="outline" className="px-1.5 py-0 text-[10px]"> model </Badge> ) : null} <span className="truncate">{props.item.label}</span> <span className="truncate text-muted-foreground/70 text-xs">{props.item.description}</span> </CommandItem> ); }); const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { items: ComposerCommandItem[]; resolvedTheme: "light" | "dark"; isLoading: boolean; triggerKind: ComposerTriggerKind | null; onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; commandInputRef: RefObject<HTMLInputElement | null>; }) { return ( <Command mode="none" onItemHighlighted={(highlightedValue) => { props.onHighlightedItemChange( typeof highlightedValue === "string" ? highlightedValue : null, ); }} > <div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"> <div className="pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0"> <CommandInput autoFocus={false} ref={props.commandInputRef} /> </div> <CommandList className="max-h-64"> {props.items.map((item) => ( <ComposerCommandMenuItem key={item.id} item={item} resolvedTheme={props.resolvedTheme} onSelect={props.onSelect} /> ))} </CommandList> {props.items.length === 0 && ( <p className="px-3 py-2 text-muted-foreground/70 text-xs"> {props.isLoading ? "Searching workspace files..." : props.triggerKind === "path" ? "No matching files or folders." : "No matching command."} </p> )} </div> </Command> ); }); interface ChatViewProps { threadId: string; } export default function ChatView({ threadId }: ChatViewProps) { const { state, dispatch } = useStore(); const navigate = useNavigate(); const rawSearch = useSearch({ strict: false }); const api = useNativeApi(); const { resolvedTheme } = useTheme(); const { settings: appSettings } = useAppSettings(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation( gitCreateWorktreeMutationOptions({ api, queryClient }), ); const [prompt, setPrompt] = useState(""); const promptRef = useRef(prompt); const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); const [isSending, setIsSending] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>([]); const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [composerCursor, setComposerCursor] = useState(0); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< Record<string, string> >(() => readLastInvokedScriptByProjectFromStorage()); const messagesScrollRef = useRef<HTMLDivElement>(null); const shouldAutoScrollRef = useRef(true); const textareaRef = useRef<HTMLTextAreaElement>(null); const composerCommandInputRef = useRef<HTMLInputElement>(null); const composerImagesRef = useRef<ComposerImageAttachment[]>([]); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); const checkpointHydrationSessionRequestRef = useRef(new Set<string>()); const checkpointTurnCountSyncFingerprintRef = useRef(new Map<string, string>()); const terminalStateSyncFingerprintRef = useRef(new Map<string, string>()); const activeThread = state.threads.find((t) => t.id === threadId); const diffSearch = useMemo( () => parseDiffRouteSearch(rawSearch as Record<string, unknown>), [rawSearch], ); const diffOpen = diffSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeSessionId = activeThread?.session?.sessionId ?? null; const activeThreadRuntimeId = activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); const selectedModel = resolveModelSlug( activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, ); const phase = derivePhase(activeThread?.session ?? null); const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const workLogEntries = useMemo( () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), [activeThread?.events], ); const latestTurnWorkEntries = useMemo( () => deriveWorkLogEntries(activeThread?.events ?? [], activeThread?.latestTurnId), [activeThread?.events, activeThread?.latestTurnId], ); const pendingApprovals = useMemo( () => derivePendingApprovals(activeThread?.events ?? []), [activeThread?.events], ); const assistantCompletionByItemId = useMemo(() => { const map = new Map<string, string>(); const ordered = [...(activeThread?.events ?? [])].toReversed(); for (const event of ordered) { if (event.method !== "item/completed") continue; if (!event.itemId) continue; map.set(event.itemId, event.createdAt); } return map; }, [activeThread?.events]); const timelineEntries = useMemo( () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), [activeThread?.messages, workLogEntries], ); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const turnDiffSummaryByAssistantMessageId = useMemo(() => { const byMessageId = new Map<string, TurnDiffSummary>(); for (const summary of turnDiffSummaries) { if (!summary.assistantMessageId) continue; byMessageId.set(summary.assistantMessageId, summary); } return byMessageId; }, [turnDiffSummaries]); const checkpointDiffInputsByTurn = useMemo( () => turnDiffSummaries .filter((summary) => summary.assistantMessageId) .map((summary) => { const checkpointTurnCount = summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; return { turnId: summary.turnId, checkpointTurnCount: typeof checkpointTurnCount === "number" ? checkpointTurnCount : null, }; }), [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], ); const checkpointDiffQueriesByTurn = useQueries({ queries: checkpointDiffInputsByTurn.map((input) => checkpointDiffQueryOptions(api, { sessionId: activeSessionId, threadRuntimeId: activeThreadRuntimeId, fromTurnCount: typeof input.checkpointTurnCount === "number" ? Math.max(0, input.checkpointTurnCount - 1) : null, toTurnCount: input.checkpointTurnCount, cacheScope: `turn:${input.turnId}`, }), ),