import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS, type ProviderApprovalDecision, type ProviderEvent, } from "@t3tools/contracts"; import { type FormEvent, Fragment, type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { EDITORS, type EditorId } from "@t3tools/contracts"; import { isElectron } from "../env"; import { buildBootstrapInput } from "../historyBootstrap"; import { DEFAULT_MODEL, DEFAULT_REASONING, MODEL_OPTIONS, REASONING_OPTIONS, resolveModelSlug, } from "../model-logic"; import { derivePhase, deriveTimelineEntries, deriveWorkLogEntries, formatDuration, formatElapsed, formatTimestamp, readNativeApi, } from "../session-logic"; import { useStore } from "../store"; import BranchToolbar from "./BranchToolbar"; import { isTerminalToggleShortcut } from "../terminal-shortcuts"; import ChatMarkdown from "./ChatMarkdown"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; function formatMessageMeta(createdAt: string, duration: string | null): string { if (!duration) return formatTimestamp(createdAt); return `${formatTimestamp(createdAt)} • ${duration}`; } const FILE_MANAGER_LABEL = navigator.platform.includes("Mac") ? "Finder" : navigator.platform.includes("Win") ? "Explorer" : "Files"; function editorLabel(editor: (typeof EDITORS)[number]): string { return editor.command ? editor.label : FILE_MANAGER_LABEL; } const LAST_EDITOR_KEY = "t3code:last-editor"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; 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 PendingApprovalCard { requestId: string; requestKind: "command" | "file-change"; createdAt: string; detail?: string; } type SessionContinuityState = "resumed" | "new" | "fallback_new"; interface EnsuredSessionInfo { sessionId: string; resolvedThreadId: string | null; continuityState: SessionContinuityState; } function asRecord(value: unknown): Record | undefined { if (!value || typeof value !== "object") return undefined; return value as Record; } function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } function approvalDetail(event: ProviderEvent): string | undefined { const payload = asRecord(event.payload); const command = asString(payload?.command); if (command) return command; return asString(payload?.reason); } function derivePendingApprovals(events: ProviderEvent[]): PendingApprovalCard[] { const pending = new Map(); const ordered = [...events].toReversed(); for (const event of ordered) { if (event.method === "session/closed" || event.method === "session/exited") { pending.clear(); continue; } const requestId = event.requestId ?? asString(asRecord(event.payload)?.requestId); if (!requestId) continue; if ( event.kind === "request" && (event.requestKind === "command" || event.requestKind === "file-change") ) { const detail = approvalDetail(event); pending.set(requestId, { requestId, requestKind: event.requestKind, createdAt: event.createdAt, ...(detail ? { detail } : {}), }); continue; } if (event.method === "item/requestApproval/decision") { pending.delete(requestId); } } return Array.from(pending.values()); } export default function ChatView() { const { state, dispatch } = useStore(); const api = useMemo(() => readNativeApi(), []); const [prompt, setPrompt] = useState(""); const [isSending, setIsSending] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false); const [lastEditor, setLastEditor] = useState(() => { const stored = localStorage.getItem(LAST_EDITOR_KEY); return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; }); const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const messagesEndRef = useRef(null); const textareaRef = useRef(null); const modelMenuRef = useRef(null); const editorMenuRef = useRef(null); const terminalOpenByThreadRef = useRef>({}); const activeThread = state.threads.find((t) => t.id === state.activeThreadId); const activeThreadId = activeThread?.id ?? 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; const nowIso = new Date(nowTick).toISOString(); const modelOptions = MODEL_OPTIONS; 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(); 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 envLocked = Boolean( activeThread && (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); const terminalShortcutHint = navigator.platform.includes("Mac") ? "\u2318J" : "Ctrl+J"; const focusComposer = useCallback(() => { const textarea = textareaRef.current; if (!textarea) return; textarea.focus(); const cursor = textarea.value.length; textarea.setSelectionRange(cursor, cursor); }, []); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadId) return; const isOpen = Boolean(activeThread?.terminalOpen); dispatch({ type: "SET_THREAD_TERMINAL_OPEN", threadId: activeThreadId, open: !isOpen, }); }, [activeThread?.terminalOpen, activeThreadId, dispatch]); const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { if (mode === state.runtimeMode) return; dispatch({ type: "SET_RUNTIME_MODE", mode }); if (!api) return; const sessionIds = state.threads .map((t) => t.session) .filter((s): s is NonNullable => s !== null && s.status !== "closed") .map((s) => s.sessionId); if (sessionIds.length === 0) return; setIsSwitchingRuntimeMode(true); try { await Promise.all( sessionIds.map((id) => api.providers.stopSession({ sessionId: id }).catch(() => undefined)), ); } finally { setIsSwitchingRuntimeMode(false); } }; // Auto-scroll on new messages const messageCount = activeThread?.messages.length ?? 0; const workLogCount = workLogEntries.length; useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messageCount]); useEffect(() => { if (phase !== "running") return; messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [phase, workLogCount]); useEffect(() => { setExpandedWorkGroups({}); }, [activeThread?.id]); const activeWorktreePath = activeThread?.worktreePath; useEffect(() => { if (!activeThread?.id) return; setEnvMode(activeWorktreePath ? "worktree" : "local"); }, [activeThread?.id, activeWorktreePath]); // Auto-resize textarea useEffect(() => { const ta = textareaRef.current; if (!ta) return; ta.style.height = "auto"; ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; }, [prompt]); useEffect(() => { if (phase !== "running") return; const timer = window.setInterval(() => { setNowTick(Date.now()); }, 250); return () => { window.clearInterval(timer); }; }, [phase]); useEffect(() => { if (!isModelMenuOpen) return; const handleClickOutside = (event: MouseEvent) => { if (!modelMenuRef.current) return; if (event.target instanceof Node && !modelMenuRef.current.contains(event.target)) { setIsModelMenuOpen(false); } }; window.addEventListener("mousedown", handleClickOutside); return () => { window.removeEventListener("mousedown", handleClickOutside); }; }, [isModelMenuOpen]); useEffect(() => { if (!isEditorMenuOpen) return; const handleClickOutside = (event: MouseEvent) => { if (!editorMenuRef.current) return; if (event.target instanceof Node && !editorMenuRef.current.contains(event.target)) { setIsEditorMenuOpen(false); } }; window.addEventListener("mousedown", handleClickOutside); return () => { window.removeEventListener("mousedown", handleClickOutside); }; }, [isEditorMenuOpen]); // Cmd+O / Ctrl+O to open in last-used editor useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) { if (api && activeProject) { e.preventDefault(); const cwd = activeThread?.worktreePath ?? activeProject.cwd; void api.shell.openInEditor(cwd, lastEditor); } } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [api, activeProject, activeThread, lastEditor]); useEffect(() => { if (!activeThreadId) return; const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; const current = Boolean(activeThread?.terminalOpen); if (!previous && current) { setTerminalFocusRequestId((value) => value + 1); } else if (previous && !current) { terminalOpenByThreadRef.current[activeThreadId] = current; const frame = window.requestAnimationFrame(() => { focusComposer(); }); return () => { window.cancelAnimationFrame(frame); }; } terminalOpenByThreadRef.current[activeThreadId] = current; }, [activeThread?.terminalOpen, activeThreadId, focusComposer]); useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { if (!activeThreadId) return; if (!isTerminalToggleShortcut(event)) return; event.preventDefault(); toggleTerminalVisibility(); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [activeThreadId, toggleTerminalVisibility]); const openInEditor = (editorId: EditorId) => { if (!api || !activeProject) return; const cwd = activeThread?.worktreePath ?? activeProject.cwd; void api.shell.openInEditor(cwd, editorId); setLastEditor(editorId); localStorage.setItem(LAST_EDITOR_KEY, editorId); setIsEditorMenuOpen(false); }; const ensureSession = async (cwdOverride?: string): Promise => { if (!api || !activeThread || !activeProject) return null; if (activeThread.session && activeThread.session.status !== "closed") { const sessionThreadId = activeThread.session.threadId ?? null; const continuityState: SessionContinuityState = activeThread.codexThreadId === null ? "new" : sessionThreadId === activeThread.codexThreadId ? "resumed" : "fallback_new"; return { sessionId: activeThread.session.sessionId, resolvedThreadId: sessionThreadId, continuityState, } satisfies EnsuredSessionInfo; } const priorCodexThreadId = activeThread.codexThreadId; setIsConnecting(true); try { const session = await api.providers.startSession({ provider: "codex", cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd, model: selectedModel || undefined, resumeThreadId: priorCodexThreadId ?? undefined, approvalPolicy: runtimeSessionConfig.approvalPolicy, sandboxMode: runtimeSessionConfig.sandboxMode, }); dispatch({ type: "UPDATE_SESSION", threadId: activeThread.id, session, }); const resolvedThreadId = session.threadId ?? null; const continuityState: SessionContinuityState = priorCodexThreadId === null ? "new" : resolvedThreadId === priorCodexThreadId ? "resumed" : "fallback_new"; return { sessionId: session.sessionId, resolvedThreadId, continuityState, }; } catch (err) { dispatch({ type: "SET_ERROR", threadId: activeThread.id, error: err instanceof Error ? err.message : "Failed to connect.", }); return null; } finally { setIsConnecting(false); } }; const onSend = async (e: FormEvent) => { e.preventDefault(); if (!api || !activeThread || isSending || isConnecting) return; const trimmed = prompt.trim(); if (!trimmed) return; if (!activeProject) return; // On first message: lock in branch + create worktree if needed. let sessionCwd: string | undefined; if ( activeThread.messages.length === 0 && activeThread.branch && envMode === "worktree" && !activeThread.worktreePath ) { try { const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`; const result = await api.git.createWorktree({ cwd: activeProject.cwd, branch: activeThread.branch, newBranch, }); sessionCwd = result.worktree.path; dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThread.id, branch: result.worktree.branch, worktreePath: result.worktree.path, }); } catch (err) { dispatch({ type: "SET_ERROR", threadId: activeThread.id, error: err instanceof Error ? err.message : "Failed to create worktree", }); return; } } // Auto-title from first message if (activeThread.messages.length === 0) { const title = trimmed.length > 50 ? `${trimmed.slice(0, 50)}...` : trimmed; dispatch({ type: "SET_THREAD_TITLE", threadId: activeThread.id, title, }); } dispatch({ type: "SET_ERROR", threadId: activeThread.id, error: null, }); dispatch({ type: "PUSH_USER_MESSAGE", threadId: activeThread.id, id: crypto.randomUUID(), text: trimmed, }); const previousMessages = activeThread.messages; setPrompt(""); const sessionInfo = await ensureSession(sessionCwd); if (!sessionInfo) return; setIsSending(true); try { const shouldBootstrap = previousMessages.length > 0 && (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new"); const input = shouldBootstrap ? buildBootstrapInput(previousMessages, trimmed, PROVIDER_SEND_TURN_MAX_INPUT_CHARS).text : trimmed; await api.providers.sendTurn({ sessionId: sessionInfo.sessionId, input, model: selectedModel || undefined, effort: selectedEffort || undefined, }); } catch (err) { dispatch({ type: "SET_ERROR", threadId: activeThread.id, error: err instanceof Error ? err.message : "Failed to send message.", }); } finally { setIsSending(false); } }; const onInterrupt = async () => { if (!api || !activeThread?.session) return; await api.providers.interruptTurn({ sessionId: activeThread.session.sessionId, turnId: activeThread.session.activeTurnId, }); }; const onRespondToApproval = async (requestId: string, decision: ProviderApprovalDecision) => { if (!api || !activeThread?.session) return; setRespondingRequestIds((existing) => existing.includes(requestId) ? existing : [...existing, requestId], ); try { await api.providers.respondToRequest({ sessionId: activeThread.session.sessionId, requestId, decision, }); } catch (err) { dispatch({ type: "SET_ERROR", threadId: activeThread.id, error: err instanceof Error ? err.message : "Failed to submit approval decision.", }); } finally { setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); } }; const onModelSelect = (model: string) => { if (!activeThread) return; dispatch({ type: "SET_THREAD_MODEL", threadId: activeThread.id, model: resolveModelSlug(model), }); setIsModelMenuOpen(false); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void onSend(e as unknown as FormEvent); } }; // Empty state: no active thread if (!activeThread) { return (
{isElectron &&
}

Select a thread or create a new one to get started.

); } return (
{/* Top bar */}

{activeThread.title}

{activeProject && ( {activeProject.name} )} {activeThread.branch && ( {activeThread.branch} {activeThread.worktreePath ? " (worktree)" : ""} )}
{/* Open in editor */} {activeProject && (
{isEditorMenuOpen && (
{EDITORS.map((editor) => ( ))}
)}
)} {/* Diff toggle */}
{/* Error banner */} {activeThread.error && (
{activeThread.error}
)} {pendingApprovals.length > 0 && (
{pendingApprovals.map((approval) => { const isResponding = respondingRequestIds.includes(approval.requestId); return (

{approval.requestKind === "command" ? "Command approval requested" : "File-change approval requested"}

{approval.detail && (

{approval.detail}

)}
); })}
)} {/* Messages */}
{activeThread.messages.length === 0 && !isWorking ? (

Send a message to start the conversation.

) : (
{timelineEntries.map((timelineEntry, index) => { if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") { return null; } const showCompletionDivider = timelineEntry.kind === "message" && timelineEntry.message.role === "assistant" && completionDividerBeforeEntryId === timelineEntry.id; if (timelineEntry.kind === "work") { const groupedEntries = [timelineEntry.entry]; let cursor = index + 1; while (cursor < timelineEntries.length) { const nextEntry = timelineEntries[cursor]; if (!nextEntry || nextEntry.kind !== "work") break; groupedEntries.push(nextEntry.entry); cursor += 1; } const groupId = timelineEntry.id; const isExpanded = expandedWorkGroups[groupId] ?? false; const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; const visibleEntries = hasOverflow && !isExpanded ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : groupedEntries; const hiddenCount = groupedEntries.length - visibleEntries.length; const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); const groupLabel = onlyToolEntries ? groupedEntries.length === 1 ? "Tool call" : `Tool calls (${groupedEntries.length})` : groupedEntries.length === 1 ? "Work event" : `Work log (${groupedEntries.length})`; return (

{groupLabel}

{hasOverflow && ( )}
{visibleEntries.map((workEntry) => (

{workEntry.detail ? ( <> {workEntry.label} {workEntry.detail} ) : ( workEntry.label )}

))}
); } if (timelineEntry.message.role === "user") { return (
                          {timelineEntry.message.text}
                        

{formatTimestamp(timelineEntry.message.createdAt)}

); } return ( {showCompletionDivider && (
{completionSummary ? `Response • ${completionSummary}` : "Response"}
)}

{formatMessageMeta( timelineEntry.message.createdAt, timelineEntry.message.streaming ? formatElapsed(timelineEntry.message.createdAt, nowIso) : formatElapsed( timelineEntry.message.createdAt, assistantCompletionByItemId.get(timelineEntry.message.id), ), )}

); })} {isWorking && (
)}
)}
{/* Input bar */}
{/* Textarea area */}