import { EDITORS, type EditorId, 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 { DEFAULT_MODEL, DEFAULT_REASONING, MODEL_OPTIONS, REASONING_OPTIONS, ReasoningEffort, resolveModelSlug,} from "../model-logic";import { countDiffStat, derivePendingApprovals, derivePhase, deriveTurnDiffSummaries, deriveTimelineEntries, inferCheckpointTurnCountByTurnId, type TurnDiffSummary, type PendingApproval, deriveWorkLogEntries, formatDuration, formatElapsed, formatTimestamp,} from "../session-logic";import { isScrollContainerNearBottom } from "../chat-scroll";import { useStore } from "../store";import type { ChatImageAttachment } from "../types";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 { ChevronDownIcon, CircleAlertIcon, 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 { 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 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 = []; 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;} 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); });} export default function ChatView() { const { state, dispatch } = useStore(); const api = useNativeApi(); 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 [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 messagesScrollRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null); const shouldAutoScrollRef = useRef(true); const textareaRef = useRef<HTMLTextAreaElement>(null); const composerImagesRef = useRef<ComposerImageAttachment[]>([]); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); const checkpointDiffRequestsRef = useRef(new Set<string>()); const checkpointHydrationSessionRequestRef = useRef(new Set<string>()); const activeThread = state.threads.find((t) => t.id === state.activeThreadId); const activeThreadId = activeThread?.id ?? null; const activeSessionId = activeThread?.session?.sessionId; 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 = useMemo( () => activeThread?.turnDiffSummaries.length ? activeThread.turnDiffSummaries : deriveTurnDiffSummaries(activeThread?.events ?? []), [activeThread?.events, activeThread?.turnDiffSummaries], ); 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 inferredCheckpointTurnCountByTurnId = useMemo( () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), [turnDiffSummaries], ); const revertTurnCountByUserMessageId = useMemo(() => { const byUserMessageId = new Map<string, 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]); useEffect(() => { if (!activeThreadId || turnDiffSummaries.length === 0) { return; } const inferredMissingTurnCounts = turnDiffSummaries.reduce<Record<string, number>>( (acc, summary) => { if (typeof summary.checkpointTurnCount === "number") { return acc; } const inferredTurnCount = inferredCheckpointTurnCountByTurnId[summary.turnId]; if (typeof inferredTurnCount !== "number") { return acc; } acc[summary.turnId] = inferredTurnCount; return acc; }, {}, ); if (Object.keys(inferredMissingTurnCounts).length === 0) { return; } dispatch({ type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", threadId: activeThreadId, checkpointTurnCountByTurnId: inferredMissingTurnCounts, }); }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); useEffect(() => { if (!api || !activeThreadId || !activeSessionId || turnDiffSummaries.length === 0) { return; } const turnSummariesNeedingDiff = turnDiffSummaries.filter( (summary) => !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId], ); const requestedSummaries = turnSummariesNeedingDiff.filter((summary) => { const requestKey = `${activeThreadId}:${summary.turnId}`; if (checkpointDiffRequestsRef.current.has(requestKey)) { return false; } checkpointDiffRequestsRef.current.add(requestKey); return true; }); if (requestedSummaries.length === 0) { return; } let cancelled = false; void Promise.all( requestedSummaries.map(async (summary) => { const turnCount = inferredCheckpointTurnCountByTurnId[summary.turnId] ?? 0; const result = await api.providers.getCheckpointDiff({ sessionId: activeSessionId, fromTurnCount: Math.max(0, turnCount - 1), toTurnCount: turnCount, }); return [summary.turnId, result.diff] as const; }), ) .then((entries) => { if (cancelled) return; dispatch({ type: "SET_THREAD_TURN_CHECKPOINT_DIFFS", threadId: activeThreadId, checkpointDiffByTurnId: Object.fromEntries(entries), }); }) .catch(() => { if (cancelled) return; }) .finally(() => { for (const summary of requestedSummaries) { checkpointDiffRequestsRef.current.delete(`${activeThreadId}:${summary.turnId}`); } }); return () => { cancelled = true; }; }, [ activeSessionId, activeThreadId, api, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries, ]); 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 runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; const runtimeSandboxMode = state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); const keybindingsQuery = useQuery({ ...serverConfigQueryOptions(api), select: (config) => config.keybindings, }); const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; const splitTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.split"), [keybindings], ); const newTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.new"), [keybindings], ); const closeTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.close"), [keybindings], ); const envLocked = Boolean( activeThread && (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { for (const image of images) { if (!image.previewUrl) continue; URL.revokeObjectURL(image.previewUrl); } }, []); const focusComposer = useCallback(() => { const textarea = textareaRef.current; if (!textarea) return; textarea.focus(); const cursor = textarea.value.length; textarea.setSelectionRange(cursor, cursor); }, []); const scheduleComposerFocus = useCallback(() => { window.requestAnimationFrame(() => { focusComposer(); }); }, [focusComposer]); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadId) return; dispatch({ type: "SET_THREAD_TERMINAL_OPEN", threadId: activeThreadId, open: !activeThread?.terminalOpen, }); }, [activeThread?.terminalOpen, activeThreadId, dispatch]); const splitTerminal = useCallback(() => { if (!activeThreadId) return; dispatch({ type: "SPLIT_THREAD_TERMINAL", threadId: activeThreadId, terminalId: `terminal-${crypto.randomUUID()}`, }); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, dispatch]); const createNewTerminal = useCallback(() => { if (!activeThreadId) return; dispatch({ type: "NEW_THREAD_TERMINAL", threadId: activeThreadId, terminalId: `terminal-${crypto.randomUUID()}`, }); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, dispatch]); const activateTerminal = useCallback( (terminalId: string) => { if (!activeThreadId) return; dispatch({ type: "SET_THREAD_ACTIVE_TERMINAL", threadId: activeThreadId, terminalId, }); setTerminalFocusRequestId((value) => value + 1); },Lines 1–500 of 2220. Load more
import { EDITORS, type EditorId, 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 { DEFAULT_MODEL, DEFAULT_REASONING, MODEL_OPTIONS, REASONING_OPTIONS, ReasoningEffort, resolveModelSlug, } from "../model-logic"; import { countDiffStat, derivePendingApprovals, derivePhase, deriveTurnDiffSummaries, deriveTimelineEntries, inferCheckpointTurnCountByTurnId, type TurnDiffSummary, type PendingApproval, deriveWorkLogEntries, formatDuration, formatElapsed, formatTimestamp, } from "../session-logic"; import { isScrollContainerNearBottom } from "../chat-scroll"; import { useStore } from "../store"; import type { ChatImageAttachment } from "../types"; 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 { ChevronDownIcon, CircleAlertIcon, 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 { 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 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 = []; 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; } 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); }); } export default function ChatView() { const { state, dispatch } = useStore(); const api = useNativeApi(); 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 [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 messagesScrollRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null); const shouldAutoScrollRef = useRef(true); const textareaRef = useRef<HTMLTextAreaElement>(null); const composerImagesRef = useRef<ComposerImageAttachment[]>([]); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); const checkpointDiffRequestsRef = useRef(new Set<string>()); const checkpointHydrationSessionRequestRef = useRef(new Set<string>()); const activeThread = state.threads.find((t) => t.id === state.activeThreadId); const activeThreadId = activeThread?.id ?? null; const activeSessionId = activeThread?.session?.sessionId; 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 = useMemo( () => activeThread?.turnDiffSummaries.length ? activeThread.turnDiffSummaries : deriveTurnDiffSummaries(activeThread?.events ?? []), [activeThread?.events, activeThread?.turnDiffSummaries], ); 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 inferredCheckpointTurnCountByTurnId = useMemo( () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), [turnDiffSummaries], ); const revertTurnCountByUserMessageId = useMemo(() => { const byUserMessageId = new Map<string, 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]); useEffect(() => { if (!activeThreadId || turnDiffSummaries.length === 0) { return; } const inferredMissingTurnCounts = turnDiffSummaries.reduce<Record<string, number>>( (acc, summary) => { if (typeof summary.checkpointTurnCount === "number") { return acc; } const inferredTurnCount = inferredCheckpointTurnCountByTurnId[summary.turnId]; if (typeof inferredTurnCount !== "number") { return acc; } acc[summary.turnId] = inferredTurnCount; return acc; }, {}, ); if (Object.keys(inferredMissingTurnCounts).length === 0) { return; } dispatch({ type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", threadId: activeThreadId, checkpointTurnCountByTurnId: inferredMissingTurnCounts, }); }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); useEffect(() => { if (!api || !activeThreadId || !activeSessionId || turnDiffSummaries.length === 0) { return; } const turnSummariesNeedingDiff = turnDiffSummaries.filter( (summary) => !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId], ); const requestedSummaries = turnSummariesNeedingDiff.filter((summary) => { const requestKey = `${activeThreadId}:${summary.turnId}`; if (checkpointDiffRequestsRef.current.has(requestKey)) { return false; } checkpointDiffRequestsRef.current.add(requestKey); return true; }); if (requestedSummaries.length === 0) { return; } let cancelled = false; void Promise.all( requestedSummaries.map(async (summary) => { const turnCount = inferredCheckpointTurnCountByTurnId[summary.turnId] ?? 0; const result = await api.providers.getCheckpointDiff({ sessionId: activeSessionId, fromTurnCount: Math.max(0, turnCount - 1), toTurnCount: turnCount, }); return [summary.turnId, result.diff] as const; }), ) .then((entries) => { if (cancelled) return; dispatch({ type: "SET_THREAD_TURN_CHECKPOINT_DIFFS", threadId: activeThreadId, checkpointDiffByTurnId: Object.fromEntries(entries), }); }) .catch(() => { if (cancelled) return; }) .finally(() => { for (const summary of requestedSummaries) { checkpointDiffRequestsRef.current.delete(`${activeThreadId}:${summary.turnId}`); } }); return () => { cancelled = true; }; }, [ activeSessionId, activeThreadId, api, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries, ]); 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 runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; const runtimeSandboxMode = state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); const keybindingsQuery = useQuery({ ...serverConfigQueryOptions(api), select: (config) => config.keybindings, }); const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; const splitTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.split"), [keybindings], ); const newTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.new"), [keybindings], ); const closeTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.close"), [keybindings], ); const envLocked = Boolean( activeThread && (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { for (const image of images) { if (!image.previewUrl) continue; URL.revokeObjectURL(image.previewUrl); } }, []); const focusComposer = useCallback(() => { const textarea = textareaRef.current; if (!textarea) return; textarea.focus(); const cursor = textarea.value.length; textarea.setSelectionRange(cursor, cursor); }, []); const scheduleComposerFocus = useCallback(() => { window.requestAnimationFrame(() => { focusComposer(); }); }, [focusComposer]); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadId) return; dispatch({ type: "SET_THREAD_TERMINAL_OPEN", threadId: activeThreadId, open: !activeThread?.terminalOpen, }); }, [activeThread?.terminalOpen, activeThreadId, dispatch]); const splitTerminal = useCallback(() => { if (!activeThreadId) return; dispatch({ type: "SPLIT_THREAD_TERMINAL", threadId: activeThreadId, terminalId: `terminal-${crypto.randomUUID()}`, }); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, dispatch]); const createNewTerminal = useCallback(() => { if (!activeThreadId) return; dispatch({ type: "NEW_THREAD_TERMINAL", threadId: activeThreadId, terminalId: `terminal-${crypto.randomUUID()}`, }); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, dispatch]); const activateTerminal = useCallback( (terminalId: string) => { if (!activeThreadId) return; dispatch({ type: "SET_THREAD_ACTIVE_TERMINAL", threadId: activeThreadId, terminalId, }); setTerminalFocusRequestId((value) => value + 1); },