import { type ApprovalRequestId, DEFAULT_MODEL, DEFAULT_REASONING, EDITORS, type EditorId, type KeybindingCommand, type MessageId, MODEL_OPTIONS, type ProjectId, type ProjectEntry, type ProjectScript, ModelSlug, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, REASONING_OPTIONS, type ReasoningEffort, type ResolvedKeybindingsConfig, type ProviderApprovalDecision, type ThreadId, type TurnId, resolveModelSlug,} from "@t3tools/contracts";import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";import { useDebouncedValue } from "@tanstack/react-pacer";import { useNavigate, useSearch } from "@tanstack/react-router";import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual";import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env";import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";import { type ComposerTriggerKind, detectComposerTrigger, replaceTextRange,} from "../composer-logic";import { derivePendingApprovals, derivePhase, deriveTimelineEntries, 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 ChatMessage, type ChatImageAttachment, type TurnDiffSummary,} from "../types";import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";import { useTheme } from "../hooks/useTheme";import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";import BranchToolbar from "./BranchToolbar";import GitActionsControl from "./GitActionsControl";import { isOpenFavoriteEditorShortcut, resolveShortcutCommand, shortcutLabelForCommand,} from "../keybindings";import ChatMarkdown from "./ChatMarkdown";import ThreadTerminalDrawer from "./ThreadTerminalDrawer";import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";import { BotIcon, ChevronDownIcon, 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 { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";import { commandForProjectScript, nextProjectScriptId, projectScriptRuntimeEnv, projectScriptIdFromCommand, setupProjectScript,} from "~/projectScripts";import { Toggle } from "./ui/toggle";import { SidebarTrigger } from "./ui/sidebar";import { newCommandId, newMessageId } from "~/lib/utils";import { readNativeApi } from "~/nativeApi";import { useAppSettings } from "../appSettings"; 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";} interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { previewUrl: string; file: File;} interface ExpandedImagePreview { src: string; name: string;} 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; }; type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; 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: React.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: ThreadId;} export default function ChatView({ threadId }: ChatViewProps) { const { state, dispatch } = useStore(); const { settings } = useAppSettings(); const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, select: (params) => parseDiffRouteSearch(params), }); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); const [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 [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); 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<ApprovalRequestId[]>([]); 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 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 activeProject = state.projects.find((p) => p.id === activeThread?.projectId); useEffect(() => { if (!activeThread?.id) return; if (!activeThread.latestTurnCompletedAt) return; const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt); if (Number.isNaN(turnCompletedAt)) return; const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; dispatch({ type: "MARK_THREAD_VISITED", threadId: activeThread.id, }); }, [ activeThread?.id, activeThread?.lastVisitedAt, activeThread?.latestTurnCompletedAt, dispatch, ]); const selectedModel = resolveModelSlug( activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, ); const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const threadActivities = activeThread?.activities ?? []; const workLogEntries = useMemo( () => deriveWorkLogEntries(threadActivities, undefined), [threadActivities], ); const latestTurnHasToolActivity = useMemo(() => { const latestTurnId = activeThread?.latestTurnId; if (!latestTurnId) { return false; } return threadActivities.some( (activity) => activity.turnId === latestTurnId && activity.tone === "tool", ); }, [activeThread?.latestTurnId, threadActivities]); const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), [threadActivities], ); const timelineMessages = useMemo(() => { const serverMessages = activeThread?.messages ?? []; if (optimisticUserMessages.length === 0) { return serverMessages; } const serverIds = new Set(serverMessages.map((message) => message.id)); const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); if (pendingMessages.length === 0) { return serverMessages; } return [...serverMessages, ...pendingMessages]; }, [activeThread?.messages, optimisticUserMessages]); const timelineEntries = useMemo( () => deriveTimelineEntries(timelineMessages, workLogEntries), [timelineMessages, workLogEntries], ); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const turnDiffSummaryByAssistantMessageId = useMemo(() => { const byMessageId = new Map<MessageId, TurnDiffSummary>(); for (const summary of turnDiffSummaries) { if (!summary.assistantMessageId) continue; byMessageId.set(summary.assistantMessageId, summary); } return byMessageId; }, [turnDiffSummaries]); const revertTurnCountByUserMessageId = useMemo(() => { const byUserMessageId = new Map<MessageId, number>(); 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 (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; if (!latestTurnHasToolActivity) return null; if ( typeof activeThread.latestTurnDurationMs === "number" && Number.isFinite(activeThread.latestTurnDurationMs) && activeThread.latestTurnDurationMs >= 0 ) { return `Worked for ${formatDuration(activeThread.latestTurnDurationMs)}`; } const elapsed = formatElapsed(Lines 1–500 of 2855. Load more
import { type ApprovalRequestId, DEFAULT_MODEL, DEFAULT_REASONING, EDITORS, type EditorId, type KeybindingCommand, type MessageId, MODEL_OPTIONS, type ProjectId, type ProjectEntry, type ProjectScript, ModelSlug, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, REASONING_OPTIONS, type ReasoningEffort, type ResolvedKeybindingsConfig, type ProviderApprovalDecision, type ThreadId, type TurnId, resolveModelSlug, } from "@t3tools/contracts"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { type ComposerTriggerKind, detectComposerTrigger, replaceTextRange, } from "../composer-logic"; import { derivePendingApprovals, derivePhase, deriveTimelineEntries, 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 ChatMessage, type ChatImageAttachment, type TurnDiffSummary, } from "../types"; import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { isOpenFavoriteEditorShortcut, resolveShortcutCommand, shortcutLabelForCommand, } from "../keybindings"; import ChatMarkdown from "./ChatMarkdown"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { BotIcon, ChevronDownIcon, 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 { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId, projectScriptRuntimeEnv, projectScriptIdFromCommand, setupProjectScript, } from "~/projectScripts"; import { Toggle } from "./ui/toggle"; import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { useAppSettings } from "../appSettings"; 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"; } interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { previewUrl: string; file: File; } interface ExpandedImagePreview { src: string; name: string; } 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; }; type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; 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: React.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: ThreadId; } export default function ChatView({ threadId }: ChatViewProps) { const { state, dispatch } = useStore(); const { settings } = useAppSettings(); const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, select: (params) => parseDiffRouteSearch(params), }); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); const [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 [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); 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<ApprovalRequestId[]>([]); 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 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 activeProject = state.projects.find((p) => p.id === activeThread?.projectId); useEffect(() => { if (!activeThread?.id) return; if (!activeThread.latestTurnCompletedAt) return; const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt); if (Number.isNaN(turnCompletedAt)) return; const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; dispatch({ type: "MARK_THREAD_VISITED", threadId: activeThread.id, }); }, [ activeThread?.id, activeThread?.lastVisitedAt, activeThread?.latestTurnCompletedAt, dispatch, ]); const selectedModel = resolveModelSlug( activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, ); const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const threadActivities = activeThread?.activities ?? []; const workLogEntries = useMemo( () => deriveWorkLogEntries(threadActivities, undefined), [threadActivities], ); const latestTurnHasToolActivity = useMemo(() => { const latestTurnId = activeThread?.latestTurnId; if (!latestTurnId) { return false; } return threadActivities.some( (activity) => activity.turnId === latestTurnId && activity.tone === "tool", ); }, [activeThread?.latestTurnId, threadActivities]); const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), [threadActivities], ); const timelineMessages = useMemo(() => { const serverMessages = activeThread?.messages ?? []; if (optimisticUserMessages.length === 0) { return serverMessages; } const serverIds = new Set(serverMessages.map((message) => message.id)); const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); if (pendingMessages.length === 0) { return serverMessages; } return [...serverMessages, ...pendingMessages]; }, [activeThread?.messages, optimisticUserMessages]); const timelineEntries = useMemo( () => deriveTimelineEntries(timelineMessages, workLogEntries), [timelineMessages, workLogEntries], ); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const turnDiffSummaryByAssistantMessageId = useMemo(() => { const byMessageId = new Map<MessageId, TurnDiffSummary>(); for (const summary of turnDiffSummaries) { if (!summary.assistantMessageId) continue; byMessageId.set(summary.assistantMessageId, summary); } return byMessageId; }, [turnDiffSummaries]); const revertTurnCountByUserMessageId = useMemo(() => { const byUserMessageId = new Map<MessageId, number>(); 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 (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; if (!latestTurnHasToolActivity) return null; if ( typeof activeThread.latestTurnDurationMs === "number" && Number.isFinite(activeThread.latestTurnDurationMs) && activeThread.latestTurnDurationMs >= 0 ) { return `Worked for ${formatDuration(activeThread.latestTurnDurationMs)}`; } const elapsed = formatElapsed(