import { EDITORS, type EditorId, type ProjectEntry, ModelSlug, type NativeApi, 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, Fragment, type KeyboardEvent, memo, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,} from "react";import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; import { isElectron } from "../env";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, deriveTimelineEntries, type PendingApproval, deriveWorkLogEntries, formatDuration, formatElapsed, formatTimestamp,} from "../session-logic";import { isScrollContainerNearBottom } from "../chat-scroll";import { useStore } from "../store";import type { ChatImageAttachment } from "../types";import { getVscodeIconUrlForEntry } from "../vscode-icons";import { useTheme } from "../hooks/useTheme";import BranchToolbar from "./BranchToolbar";import GitActionsControl from "./GitActionsControl";import { isOpenFavoriteEditorShortcut, isTerminalCloseShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, isTerminalToggleShortcut, 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, FolderClosedIcon, InfoIcon, LockIcon, LockOpenIcon, 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, CommandItem, CommandList } from "./ui/command"; 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 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(),})); 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";} function basenameOfPath(pathValue: string): string { const slashIndex = pathValue.lastIndexOf("/"); if (slashIndex === -1) return pathValue; return pathValue.slice(slashIndex + 1);} 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 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; index: number; isHighlighted: boolean; keyboardHighlight: boolean; resolvedTheme: "light" | "dark"; onHighlight: (index: number) => void; onSelect: (item: ComposerCommandItem) => void;}) { return ( <CommandItem value={props.item.id} data-composer-menu-index={props.index} className={cn( "cursor-pointer gap-2", props.keyboardHighlight && !props.isHighlighted && "data-highlighted:bg-transparent data-highlighted:text-inherit", props.isHighlighted && "bg-accent text-accent-foreground", )} onMouseEnter={() => { props.onHighlight(props.index); }} 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[]; selectedIndex: number; keyboardHighlight: boolean; resolvedTheme: "light" | "dark"; isLoading: boolean; triggerKind: ComposerTriggerKind | null; onHighlight: (index: number) => void; onSelect: (item: ComposerCommandItem) => void; menuRef: RefObject<HTMLDivElement | null>;}) { return ( <Command> <div ref={props.menuRef} className="overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs" > <CommandList className="max-h-64"> {props.items.map((item, index) => ( <ComposerCommandMenuItem key={item.id} item={item} index={index} isHighlighted={index === props.selectedIndex} keyboardHighlight={props.keyboardHighlight} resolvedTheme={props.resolvedTheme} onHighlight={props.onHighlight} 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> );}); export default function ChatView() { const { state, dispatch } = useStore(); const api = useNativeApi(); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation( gitCreateWorktreeMutationOptions({ api, queryClient }), ); const [prompt, setPrompt] = useState(""); 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 [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 [debouncedPathQuery, setDebouncedPathQuery] = useState(""); const [composerMenuIndex, setComposerMenuIndex] = useState(0); const [composerKeyboardHighlight, setComposerKeyboardHighlight] = useState(false); const messagesScrollRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null); const shouldAutoScrollRef = useRef(true); const textareaRef = useRef<HTMLTextAreaElement>(null); const composerMenuRef = useRef<HTMLDivElement>(null); const previousComposerMenuIndexRef = useRef(0); const composerImagesRef = useRef<ComposerImageAttachment[]>([]); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); const activeThread = state.threads.find((t) => t.id === state.activeThreadId); const activeThreadId = activeThread?.id ?? null; const activeSessionId = activeThread?.session?.sessionId; 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; 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 completionSummary = useMemo(() => { if (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; if (!latestTurnWorkEntries.some((entry) => entry.tone === "tool")) { return null; } if ( typeof activeThread.latestTurnDurationMs === "number" && Number.isFinite(activeThread.latestTurnDurationMs) && activeThread.latestTurnDurationMs >= 0 ) { return `Worked for ${formatDuration(activeThread.latestTurnDurationMs)}`; } const elapsed = formatElapsed( activeThread.latestTurnStartedAt, activeThread.latestTurnCompletedAt, ); return elapsed ? `Worked for ${elapsed}` : null; }, [ activeThread?.latestTurnStartedAt, activeThread?.latestTurnCompletedAt, activeThread?.latestTurnDurationMs, latestTurnWorkEntries, ]); const completionDividerBeforeEntryId = useMemo(() => { if (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; if (!completionSummary) return null; const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt); const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt); if (Number.isNaN(turnStartedAt)) return null; if (Number.isNaN(turnCompletedAt)) return null; let inRangeMatch: string | null = null; let fallbackMatch: string | null = null; for (const timelineEntry of timelineEntries) { if (timelineEntry.kind !== "message") continue; if (timelineEntry.message.role !== "assistant") continue; const messageAt = Date.parse(timelineEntry.message.createdAt); if (Number.isNaN(messageAt) || messageAt < turnStartedAt) continue; fallbackMatch = timelineEntry.id; if (messageAt <= turnCompletedAt) { inRangeMatch = timelineEntry.id; } } return inRangeMatch ?? fallbackMatch; }, [ activeThread?.latestTurnCompletedAt, activeThread?.latestTurnStartedAt, completionSummary, timelineEntries, ]); const runtimeSessionConfig = state.runtimeMode === "full-access" ? ({ approvalPolicy: "never", sandboxMode: "danger-full-access", } as const) : ({ approvalPolicy: "on-request", sandboxMode: "workspace-write", } as const); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; const composerTrigger = useMemo( () => detectComposerTrigger(prompt, composerCursor), [prompt, composerCursor], ); const composerTriggerKind = composerTrigger?.kind ?? null; const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); const keybindingsQuery = useQuery({ ...serverConfigQueryOptions(api), select: (config) => config.keybindings, }); useEffect(() => { if (!isPathTrigger) { setDebouncedPathQuery(""); return; } if (pathTriggerQuery.length === 0) { setDebouncedPathQuery(""); return; } const timer = window.setTimeout(() => { setDebouncedPathQuery(pathTriggerQuery); }, COMPOSER_PATH_QUERY_DEBOUNCE_MS); return () => { window.clearTimeout(timer); }; }, [isPathTrigger, pathTriggerQuery]); const workspaceEntriesQuery = useQuery({ queryKey: [ "composer-workspace-entries", gitCwd, debouncedPathQuery, ], enabled: Boolean(api && gitCwd && isPathTrigger), staleTime: 15_000, placeholderData: (previous) => previous, queryFn: async () => { if (!api || !gitCwd || !isPathTrigger) { return { entries: [], truncated: false }; } return api.projects.searchEntries({ cwd: gitCwd,Lines 1–500 of 2187. Load more
import { EDITORS, type EditorId, type ProjectEntry, ModelSlug, type NativeApi, 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, Fragment, type KeyboardEvent, memo, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; 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, deriveTimelineEntries, type PendingApproval, deriveWorkLogEntries, formatDuration, formatElapsed, formatTimestamp, } from "../session-logic"; import { isScrollContainerNearBottom } from "../chat-scroll"; import { useStore } from "../store"; import type { ChatImageAttachment } from "../types"; import { getVscodeIconUrlForEntry } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { isOpenFavoriteEditorShortcut, isTerminalCloseShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, isTerminalToggleShortcut, 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, FolderClosedIcon, InfoIcon, LockIcon, LockOpenIcon, 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, CommandItem, CommandList } from "./ui/command"; 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 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(), })); 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"; } function basenameOfPath(pathValue: string): string { const slashIndex = pathValue.lastIndexOf("/"); if (slashIndex === -1) return pathValue; return pathValue.slice(slashIndex + 1); } 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 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; index: number; isHighlighted: boolean; keyboardHighlight: boolean; resolvedTheme: "light" | "dark"; onHighlight: (index: number) => void; onSelect: (item: ComposerCommandItem) => void; }) { return ( <CommandItem value={props.item.id} data-composer-menu-index={props.index} className={cn( "cursor-pointer gap-2", props.keyboardHighlight && !props.isHighlighted && "data-highlighted:bg-transparent data-highlighted:text-inherit", props.isHighlighted && "bg-accent text-accent-foreground", )} onMouseEnter={() => { props.onHighlight(props.index); }} 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[]; selectedIndex: number; keyboardHighlight: boolean; resolvedTheme: "light" | "dark"; isLoading: boolean; triggerKind: ComposerTriggerKind | null; onHighlight: (index: number) => void; onSelect: (item: ComposerCommandItem) => void; menuRef: RefObject<HTMLDivElement | null>; }) { return ( <Command> <div ref={props.menuRef} className="overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs" > <CommandList className="max-h-64"> {props.items.map((item, index) => ( <ComposerCommandMenuItem key={item.id} item={item} index={index} isHighlighted={index === props.selectedIndex} keyboardHighlight={props.keyboardHighlight} resolvedTheme={props.resolvedTheme} onHighlight={props.onHighlight} 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> ); }); export default function ChatView() { const { state, dispatch } = useStore(); const api = useNativeApi(); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation( gitCreateWorktreeMutationOptions({ api, queryClient }), ); const [prompt, setPrompt] = useState(""); 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 [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 [debouncedPathQuery, setDebouncedPathQuery] = useState(""); const [composerMenuIndex, setComposerMenuIndex] = useState(0); const [composerKeyboardHighlight, setComposerKeyboardHighlight] = useState(false); const messagesScrollRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null); const shouldAutoScrollRef = useRef(true); const textareaRef = useRef<HTMLTextAreaElement>(null); const composerMenuRef = useRef<HTMLDivElement>(null); const previousComposerMenuIndexRef = useRef(0); const composerImagesRef = useRef<ComposerImageAttachment[]>([]); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); const activeThread = state.threads.find((t) => t.id === state.activeThreadId); const activeThreadId = activeThread?.id ?? null; const activeSessionId = activeThread?.session?.sessionId; 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; 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 completionSummary = useMemo(() => { if (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; if (!latestTurnWorkEntries.some((entry) => entry.tone === "tool")) { return null; } if ( typeof activeThread.latestTurnDurationMs === "number" && Number.isFinite(activeThread.latestTurnDurationMs) && activeThread.latestTurnDurationMs >= 0 ) { return `Worked for ${formatDuration(activeThread.latestTurnDurationMs)}`; } const elapsed = formatElapsed( activeThread.latestTurnStartedAt, activeThread.latestTurnCompletedAt, ); return elapsed ? `Worked for ${elapsed}` : null; }, [ activeThread?.latestTurnStartedAt, activeThread?.latestTurnCompletedAt, activeThread?.latestTurnDurationMs, latestTurnWorkEntries, ]); const completionDividerBeforeEntryId = useMemo(() => { if (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; if (!completionSummary) return null; const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt); const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt); if (Number.isNaN(turnStartedAt)) return null; if (Number.isNaN(turnCompletedAt)) return null; let inRangeMatch: string | null = null; let fallbackMatch: string | null = null; for (const timelineEntry of timelineEntries) { if (timelineEntry.kind !== "message") continue; if (timelineEntry.message.role !== "assistant") continue; const messageAt = Date.parse(timelineEntry.message.createdAt); if (Number.isNaN(messageAt) || messageAt < turnStartedAt) continue; fallbackMatch = timelineEntry.id; if (messageAt <= turnCompletedAt) { inRangeMatch = timelineEntry.id; } } return inRangeMatch ?? fallbackMatch; }, [ activeThread?.latestTurnCompletedAt, activeThread?.latestTurnStartedAt, completionSummary, timelineEntries, ]); const runtimeSessionConfig = state.runtimeMode === "full-access" ? ({ approvalPolicy: "never", sandboxMode: "danger-full-access", } as const) : ({ approvalPolicy: "on-request", sandboxMode: "workspace-write", } as const); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; const composerTrigger = useMemo( () => detectComposerTrigger(prompt, composerCursor), [prompt, composerCursor], ); const composerTriggerKind = composerTrigger?.kind ?? null; const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); const keybindingsQuery = useQuery({ ...serverConfigQueryOptions(api), select: (config) => config.keybindings, }); useEffect(() => { if (!isPathTrigger) { setDebouncedPathQuery(""); return; } if (pathTriggerQuery.length === 0) { setDebouncedPathQuery(""); return; } const timer = window.setTimeout(() => { setDebouncedPathQuery(pathTriggerQuery); }, COMPOSER_PATH_QUERY_DEBOUNCE_MS); return () => { window.clearTimeout(timer); }; }, [isPathTrigger, pathTriggerQuery]); const workspaceEntriesQuery = useQuery({ queryKey: [ "composer-workspace-entries", gitCwd, debouncedPathQuery, ], enabled: Boolean(api && gitCwd && isPathTrigger), staleTime: 15_000, placeholderData: (previous) => previous, queryFn: async () => { if (!api || !gitCwd || !isPathTrigger) { return { entries: [], truncated: false }; } return api.projects.searchEntries({ cwd: gitCwd,