From d3818e8c07724524d340dc811b11c1da1d21496d Mon Sep 17 00:00:00 2001 From: Christian Smith Date: Tue, 10 Mar 2026 16:36:45 -0500 Subject: [PATCH 1/4] feat: split out components from ChatView.tsx --- apps/web/src/components/ChangedFilesTree.tsx | 164 + apps/web/src/components/ChatHeader.tsx | 134 + apps/web/src/components/ChatView.logic.ts | 154 + apps/web/src/components/ChatView.tsx | 3496 ++++------------- apps/web/src/components/CodexTraitsPicker.tsx | 99 + .../CompactComposerControlsMenu.tsx | 153 + .../src/components/ComposerCommandMenu.tsx | 129 + .../ComposerPendingApprovalActions.tsx | 62 + .../ComposerPendingApprovalPanel.tsx | 37 + .../ComposerPendingUserInputPanel.tsx | 204 + .../components/ComposerPlanFollowUpBanner.tsx | 25 + apps/web/src/components/DiffStatLabel.tsx | 25 + .../src/components/ExpandedImagePreview.tsx | 36 + apps/web/src/components/MessageCopyButton.tsx | 33 + apps/web/src/components/MessagesTimeline.tsx | 747 ++++ apps/web/src/components/OpenInPicker.tsx | 163 + apps/web/src/components/ProposedPlanCard.tsx | 243 ++ .../src/components/ProviderHealthBanner.tsx | 38 + .../src/components/ProviderModelPicker.tsx | 239 ++ apps/web/src/components/ThreadErrorBanner.tsx | 35 + apps/web/src/components/VscodeEntryIcon.tsx | 41 + 21 files changed, 3612 insertions(+), 2645 deletions(-) create mode 100644 apps/web/src/components/ChangedFilesTree.tsx create mode 100644 apps/web/src/components/ChatHeader.tsx create mode 100644 apps/web/src/components/ChatView.logic.ts create mode 100644 apps/web/src/components/CodexTraitsPicker.tsx create mode 100644 apps/web/src/components/CompactComposerControlsMenu.tsx create mode 100644 apps/web/src/components/ComposerCommandMenu.tsx create mode 100644 apps/web/src/components/ComposerPendingApprovalActions.tsx create mode 100644 apps/web/src/components/ComposerPendingApprovalPanel.tsx create mode 100644 apps/web/src/components/ComposerPendingUserInputPanel.tsx create mode 100644 apps/web/src/components/ComposerPlanFollowUpBanner.tsx create mode 100644 apps/web/src/components/DiffStatLabel.tsx create mode 100644 apps/web/src/components/ExpandedImagePreview.tsx create mode 100644 apps/web/src/components/MessageCopyButton.tsx create mode 100644 apps/web/src/components/MessagesTimeline.tsx create mode 100644 apps/web/src/components/OpenInPicker.tsx create mode 100644 apps/web/src/components/ProposedPlanCard.tsx create mode 100644 apps/web/src/components/ProviderHealthBanner.tsx create mode 100644 apps/web/src/components/ProviderModelPicker.tsx create mode 100644 apps/web/src/components/ThreadErrorBanner.tsx create mode 100644 apps/web/src/components/VscodeEntryIcon.tsx diff --git a/apps/web/src/components/ChangedFilesTree.tsx b/apps/web/src/components/ChangedFilesTree.tsx new file mode 100644 index 0000000000..a6a58c7734 --- /dev/null +++ b/apps/web/src/components/ChangedFilesTree.tsx @@ -0,0 +1,164 @@ +import { type TurnId } from "@t3tools/contracts"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { type TurnDiffFileChange } from "../types"; +import { buildTurnDiffTree, type TurnDiffTreeNode } from "../lib/turnDiffTree"; +import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react"; +import { cn } from "~/lib/utils"; +import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; +import { VscodeEntryIcon } from "./VscodeEntryIcon"; + +export const ChangedFilesTree = memo(function ChangedFilesTree(props: { + turnId: TurnId; + files: ReadonlyArray; + allDirectoriesExpanded: boolean; + resolvedTheme: "light" | "dark"; + onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; +}) { + const { + files, + allDirectoriesExpanded, + onOpenTurnDiff, + resolvedTheme, + turnId, + } = props; + const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); + const directoryPathsKey = useMemo( + () => collectDirectoryPaths(treeNodes).join("\u0000"), + [treeNodes], + ); + const allDirectoryExpansionState = useMemo( + () => + buildDirectoryExpansionState( + directoryPathsKey ? directoryPathsKey.split("\u0000") : [], + allDirectoriesExpanded, + ), + [allDirectoriesExpanded, directoryPathsKey], + ); + const [expandedDirectories, setExpandedDirectories] = useState< + Record + >(() => + buildDirectoryExpansionState( + directoryPathsKey ? directoryPathsKey.split("\u0000") : [], + true, + ), + ); + useEffect(() => { + setExpandedDirectories(allDirectoryExpansionState); + }, [allDirectoryExpansionState]); + + const toggleDirectory = useCallback( + (pathValue: string, fallbackExpanded: boolean) => { + setExpandedDirectories((current) => ({ + ...current, + [pathValue]: !(current[pathValue] ?? fallbackExpanded), + })); + }, + [], + ); + + const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => { + const leftPadding = 8 + depth * 14; + if (node.kind === "directory") { + const isExpanded = expandedDirectories[node.path] ?? depth === 0; + return ( +
+ + {isExpanded && ( +
+ {node.children.map((childNode) => + renderTreeNode(childNode, depth + 1), + )} +
+ )} +
+ ); + } + + return ( + + ); + }; + + return ( +
+ {treeNodes.map((node) => renderTreeNode(node, 0))} +
+ ); +}); + +function collectDirectoryPaths( + nodes: ReadonlyArray, +): string[] { + const paths: string[] = []; + for (const node of nodes) { + if (node.kind !== "directory") continue; + paths.push(node.path); + paths.push(...collectDirectoryPaths(node.children)); + } + return paths; +} + +function buildDirectoryExpansionState( + directoryPaths: ReadonlyArray, + expanded: boolean, +): Record { + const expandedState: Record = {}; + for (const directoryPath of directoryPaths) { + expandedState[directoryPath] = expanded; + } + return expandedState; +} diff --git a/apps/web/src/components/ChatHeader.tsx b/apps/web/src/components/ChatHeader.tsx new file mode 100644 index 0000000000..fc734740be --- /dev/null +++ b/apps/web/src/components/ChatHeader.tsx @@ -0,0 +1,134 @@ +import { + type EditorId, + type ProjectScript, + type ResolvedKeybindingsConfig, + type ThreadId, +} from "@t3tools/contracts"; +import { memo } from "react"; +import GitActionsControl from "./GitActionsControl"; +import { DiffIcon } from "lucide-react"; +import { Badge } from "./ui/badge"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import ProjectScriptsControl, { + type NewProjectScriptInput, +} from "./ProjectScriptsControl"; +import { Toggle } from "./ui/toggle"; +import { SidebarTrigger } from "./ui/sidebar"; +import { OpenInPicker } from "./OpenInPicker"; + +interface ChatHeaderProps { + activeThreadId: ThreadId; + activeThreadTitle: string; + activeProjectName: string | undefined; + isGitRepo: boolean; + openInCwd: string | null; + activeProjectScripts: ProjectScript[] | undefined; + preferredScriptId: string | null; + keybindings: ResolvedKeybindingsConfig; + availableEditors: ReadonlyArray; + diffToggleShortcutLabel: string | null; + gitCwd: string | null; + diffOpen: boolean; + onRunProjectScript: (script: ProjectScript) => void; + onAddProjectScript: (input: NewProjectScriptInput) => Promise; + onUpdateProjectScript: ( + scriptId: string, + input: NewProjectScriptInput, + ) => Promise; + onDeleteProjectScript: (scriptId: string) => Promise; + onToggleDiff: () => void; +} + +export const ChatHeader = memo(function ChatHeader({ + activeThreadId, + activeThreadTitle, + activeProjectName, + isGitRepo, + openInCwd, + activeProjectScripts, + preferredScriptId, + keybindings, + availableEditors, + diffToggleShortcutLabel, + gitCwd, + diffOpen, + onRunProjectScript, + onAddProjectScript, + onUpdateProjectScript, + onDeleteProjectScript, + onToggleDiff, +}: ChatHeaderProps) { + return ( +
+
+ +

+ {activeThreadTitle} +

+ {activeProjectName && ( + + {activeProjectName} + + )} + {activeProjectName && !isGitRepo && ( + + No Git + + )} +
+
+ {activeProjectScripts && ( + + )} + {activeProjectName && ( + + )} + {activeProjectName && ( + + )} + + + + + } + /> + + {!isGitRepo + ? "Diff panel is unavailable because this project is not a git repository." + : diffToggleShortcutLabel + ? `Toggle diff panel (${diffToggleShortcutLabel})` + : "Toggle diff panel"} + + +
+
+ ); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts new file mode 100644 index 0000000000..a02d56d9c3 --- /dev/null +++ b/apps/web/src/components/ChatView.logic.ts @@ -0,0 +1,154 @@ +import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; +import { type ChatMessage, type Thread } from "../types"; +import { randomUUID } from "~/lib/utils"; +import { getAppModelOptions } from "../appSettings"; +import { + type ComposerImageAttachment, + type DraftThreadState, +} from "../composerDraftStore"; + +export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = + "t3code:last-invoked-script-by-project"; +const WORKTREE_BRANCH_PREFIX = "t3code"; + +export 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 {}; + } +} + +export function buildLocalDraftThread( + threadId: ThreadId, + draftThread: DraftThreadState, + fallbackModel: string, + error: string | null, +): Thread { + return { + id: threadId, + codexThreadId: null, + projectId: draftThread.projectId, + title: "New thread", + model: fallbackModel, + runtimeMode: draftThread.runtimeMode, + interactionMode: draftThread.interactionMode, + session: null, + messages: [], + error, + createdAt: draftThread.createdAt, + latestTurn: null, + lastVisitedAt: draftThread.createdAt, + branch: draftThread.branch, + worktreePath: draftThread.worktreePath, + turnDiffSummaries: [], + activities: [], + proposedPlans: [], + }; +} + +export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { + if ( + !previewUrl || + typeof URL === "undefined" || + !previewUrl.startsWith("blob:") + ) { + return; + } + URL.revokeObjectURL(previewUrl); +} + +export function revokeUserMessagePreviewUrls(message: ChatMessage): void { + if (message.role !== "user" || !message.attachments) { + return; + } + for (const attachment of message.attachments) { + if (attachment.type !== "image") { + continue; + } + revokeBlobPreviewUrl(attachment.previewUrl); + } +} + +export function collectUserMessageBlobPreviewUrls( + message: ChatMessage, +): string[] { + if (message.role !== "user" || !message.attachments) { + return []; + } + const previewUrls: string[] = []; + for (const attachment of message.attachments) { + if (attachment.type !== "image") continue; + if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) + continue; + previewUrls.push(attachment.previewUrl); + } + return previewUrls; +} + +export type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; + +export interface PullRequestDialogState { + initialReference: string | null; + key: number; +} + +export function readFileAsDataUrl(file: File): Promise { + 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 function buildTemporaryWorktreeBranchName(): string { + // Keep the 8-hex suffix shape for backend temporary-branch detection. + const token = randomUUID().slice(0, 8).toLowerCase(); + return `${WORKTREE_BRANCH_PREFIX}/${token}`; +} + +export function cloneComposerImageForRetry( + image: ComposerImageAttachment, +): ComposerImageAttachment { + if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) { + return image; + } + try { + return { + ...image, + previewUrl: URL.createObjectURL(image.file), + }; + } catch { + return image; + } +} + +export function getCustomModelOptionsByProvider(settings: { + customCodexModels: readonly string[]; +}): Record> { + return { + codex: getAppModelOptions("codex", settings.customCodexModels), + }; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c8a0a1529..7b7a38eda3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,7 +1,6 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, - EDITORS, type EditorId, type KeybindingCommand, type CodexReasoningEffort, @@ -30,33 +29,32 @@ import { resolveModelSlugForProvider, } from "@t3tools/shared/model"; import { - memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, - useId, } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { - measureElement as measureVirtualElement, - type VirtualItem, - useVirtualizer, -} from "@tanstack/react-virtual"; -import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; + gitBranchesQueryOptions, + gitCreateWorktreeMutationOptions, +} from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; -import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; - +import { + serverConfigQueryOptions, + serverQueryKeys, +} from "~/lib/serverReactQuery"; import { isElectron } from "../env"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { - type ComposerSlashCommand, + parseDiffRouteSearch, + stripDiffSearchParams, +} from "../diffRouteSearch"; +import { type ComposerTrigger, - type ComposerTriggerKind, detectComposerTrigger, expandCollapsedComposerCursor, parseStandaloneComposerSlashCommand, @@ -70,17 +68,12 @@ import { deriveActiveWorkStartedAt, deriveActivePlanState, findLatestProposedPlan, - type PendingApproval, - type PendingUserInput, - type ProviderPickerKind, - PROVIDER_OPTIONS, deriveWorkLogEntries, hasToolActivityForTurn, isLatestTurnSettled, formatElapsed, - formatTimestamp, } from "../session-logic"; -import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll"; +import { isScrollContainerNearBottom } from "../chat-scroll"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -89,15 +82,10 @@ import { } from "../pendingUserInput"; import { useStore } from "../store"; import { - buildCollapsedProposedPlanPreviewMarkdown, buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, - buildProposedPlanMarkdownFilename, - downloadPlanAsTextFile, - normalizePlanMarkdownForExport, proposedPlanTitle, resolvePlanFollowUpSubmission, - stripDisplayedPlanMarkdown, } from "../proposedPlan"; import { truncateTitle } from "../truncateTitle"; import { @@ -106,92 +94,37 @@ import { DEFAULT_THREAD_TERMINAL_ID, MAX_THREAD_TERMINAL_COUNT, type ChatMessage, - type Thread, - type TurnDiffFileChange, type TurnDiffSummary, } from "../types"; -import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; +import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { - buildTurnDiffTree, - summarizeTurnDiffStats, - type TurnDiffTreeNode, -} from "../lib/turnDiffTree"; import BranchToolbar from "./BranchToolbar"; -import GitActionsControl from "./GitActionsControl"; import { - isOpenFavoriteEditorShortcut, resolveShortcutCommand, shortcutLabelForCommand, } from "../keybindings"; -import ChatMarkdown from "./ChatMarkdown"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { BotIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, CircleAlertIcon, - FileIcon, - FolderIcon, - DiffIcon, - EllipsisIcon, - FolderClosedIcon, ListTodoIcon, LockIcon, LockOpenIcon, - Undo2Icon, XIcon, - CopyIcon, - CheckIcon, } from "lucide-react"; import { Button } from "./ui/button"; -import { Input } from "./ui/input"; import { Separator } from "./ui/separator"; -import { Group, GroupSeparator } from "./ui/group"; -import { - Menu, - MenuGroup, - MenuItem, - MenuPopup, - MenuRadioGroup, - MenuRadioItem, - MenuSeparator as MenuDivider, - MenuSub, - MenuSubPopup, - MenuSubTrigger, - MenuShortcut, - MenuTrigger, -} from "./ui/menu"; -import { - ClaudeAI, - CursorIcon, - Gemini, - Icon, - OpenAI, - OpenCodeIcon, - VisualStudioCode, - Zed, -} from "./Icons"; -import { cn, isMacPlatform, isWindowsPlatform, randomUUID } from "~/lib/utils"; -import { Badge } from "./ui/badge"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; +import { cn, randomUUID } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { Command, CommandItem, CommandList } from "./ui/command"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "./ui/dialog"; import { toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; -import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; +import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId, @@ -199,58 +132,64 @@ import { projectScriptIdFromCommand, setupProjectScript, } from "~/projectScripts"; -import { Toggle } from "./ui/toggle"; import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { getAppModelOptions, resolveAppModelSelection, useAppSettings } from "../appSettings"; +import { resolveAppModelSelection, useAppSettings } from "../appSettings"; import { type ComposerImageAttachment, type DraftThreadEnvMode, - type DraftThreadState, type PersistedComposerImageAttachment, useComposerDraftStore, useComposerThreadDraft, } from "../composerDraftStore"; import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; -import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; -import { clamp } from "effect/Number"; -import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; +import { + selectThreadTerminalState, + useTerminalStateStore, +} from "../terminalStateStore"; +import { + ComposerPromptEditor, + type ComposerPromptEditorHandle, +} from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; -import { estimateTimelineMessageHeight } from "./timelineHeight"; - -function formatMessageMeta(createdAt: string, duration: string | null): string { - if (!duration) return formatTimestamp(createdAt); - return `${formatTimestamp(createdAt)} • ${duration}`; -} - -function formatWorkingTimer(startIso: string, endIso: string): string | null { - const startedAtMs = Date.parse(startIso); - const endedAtMs = Date.parse(endIso); - if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) { - return null; - } - - const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000)); - if (elapsedSeconds < 60) { - return `${elapsedSeconds}s`; - } - - const hours = Math.floor(elapsedSeconds / 3600); - const minutes = Math.floor((elapsedSeconds % 3600) / 60); - const seconds = elapsedSeconds % 60; - - if (hours > 0) { - return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; - } - - return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; -} +import { MessagesTimeline } from "./MessagesTimeline"; +import { ChatHeader } from "./ChatHeader"; +import { + buildExpandedImagePreview, + ExpandedImagePreview, +} from "./ExpandedImagePreview"; +import { + AVAILABLE_PROVIDER_OPTIONS, + ProviderModelPicker, +} from "./ProviderModelPicker"; +import { + ComposerCommandItem, + ComposerCommandMenu, +} from "./ComposerCommandMenu"; +import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; +import { CodexTraitsPicker } from "./CodexTraitsPicker"; +import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; +import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; +import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; +import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; +import { ProviderHealthBanner } from "./ProviderHealthBanner"; +import { ThreadErrorBanner } from "./ThreadErrorBanner"; +import { + buildLocalDraftThread, + buildTemporaryWorktreeBranchName, + cloneComposerImageForRetry, + collectUserMessageBlobPreviewUrls, + getCustomModelOptionsByProvider, + LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, + PullRequestDialogState, + readFileAsDataUrl, + readLastInvokedScriptByProjectFromStorage, + revokeBlobPreviewUrl, + revokeUserMessagePreviewUrls, + SendPhase, +} from "./ChatView.logic"; -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 ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; const IMAGE_ONLY_BOOTSTRAP_PROMPT = @@ -260,315 +199,13 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; -const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const EMPTY_PENDING_USER_INPUT_ANSWERS: Record< + string, + PendingUserInputDraftAnswer +> = {}; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; -const WORKTREE_BRANCH_PREFIX = "t3code"; - -function readLastInvokedScriptByProjectFromStorage(): Record { - 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 ExpandedImageItem { - src: string; - name: string; -} - -interface ExpandedImagePreview { - images: ExpandedImageItem[]; - index: number; -} - -function buildExpandedImagePreview( - images: ReadonlyArray<{ id: string; name: string; previewUrl?: string }>, - selectedImageId: string, -): ExpandedImagePreview | null { - const previewableImages = images.flatMap((image) => - image.previewUrl ? [{ id: image.id, src: image.previewUrl, name: image.name }] : [], - ); - if (previewableImages.length === 0) { - return null; - } - const selectedIndex = previewableImages.findIndex((image) => image.id === selectedImageId); - if (selectedIndex < 0) { - return null; - } - return { - images: previewableImages.map((image) => ({ src: image.src, name: image.name })), - index: selectedIndex, - }; -} - -function buildLocalDraftThread( - threadId: ThreadId, - draftThread: DraftThreadState, - fallbackModel: string, - error: string | null, -): Thread { - return { - id: threadId, - codexThreadId: null, - projectId: draftThread.projectId, - title: "New thread", - model: fallbackModel, - runtimeMode: draftThread.runtimeMode, - interactionMode: draftThread.interactionMode, - session: null, - messages: [], - error, - createdAt: draftThread.createdAt, - latestTurn: null, - lastVisitedAt: draftThread.createdAt, - branch: draftThread.branch, - worktreePath: draftThread.worktreePath, - turnDiffSummaries: [], - activities: [], - proposedPlans: [], - }; -} - -function revokeBlobPreviewUrl(previewUrl: string | undefined): void { - if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { - return; - } - URL.revokeObjectURL(previewUrl); -} - -function revokeUserMessagePreviewUrls(message: ChatMessage): void { - if (message.role !== "user" || !message.attachments) { - return; - } - for (const attachment of message.attachments) { - if (attachment.type !== "image") { - continue; - } - revokeBlobPreviewUrl(attachment.previewUrl); - } -} - -function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] { - if (message.role !== "user" || !message.attachments) { - return []; - } - const previewUrls: string[] = []; - for (const attachment of message.attachments) { - if (attachment.type !== "image") continue; - if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue; - previewUrls.push(attachment.previewUrl); - } - return previewUrls; -} - -type ComposerCommandItem = - | { - id: string; - type: "path"; - path: string; - pathKind: ProjectEntry["kind"]; - label: string; - description: string; - } - | { - id: string; - type: "slash-command"; - command: ComposerSlashCommand; - label: string; - description: string; - } - | { - id: string; - type: "model"; - provider: ProviderKind; - model: ModelSlug; - label: string; - description: string; - }; - -type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; - -interface PullRequestDialogState { - initialReference: string | null; - key: number; -} - -function readFileAsDataUrl(file: File): Promise { - 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); - }); -} - -function buildTemporaryWorktreeBranchName(): string { - // Keep the 8-hex suffix shape for backend temporary-branch detection. - const token = randomUUID().slice(0, 8).toLowerCase(); - return `${WORKTREE_BRANCH_PREFIX}/${token}`; -} - -function cloneComposerImageForRetry(image: ComposerImageAttachment): ComposerImageAttachment { - if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) { - return image; - } - try { - return { - ...image, - previewUrl: URL.createObjectURL(image.file), - }; - } catch { - return image; - } -} - -const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { - pathValue: string; - kind: "file" | "directory"; - theme: "light" | "dark"; - className?: string; -}) { - const [failedIconUrl, setFailedIconUrl] = useState(null); - const iconUrl = useMemo( - () => getVscodeIconUrlForEntry(props.pathValue, props.kind, props.theme), - [props.kind, props.pathValue, props.theme], - ); - const failed = failedIconUrl === iconUrl; - - if (failed) { - return props.kind === "directory" ? ( - - ) : ( - - ); - } - - return ( - setFailedIconUrl(iconUrl)} - /> - ); -}); - -const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { - item: ComposerCommandItem; - resolvedTheme: "light" | "dark"; - isActive: boolean; - onSelect: (item: ComposerCommandItem) => void; -}) { - return ( - { - event.preventDefault(); - }} - onClick={() => { - props.onSelect(props.item); - }} - > - {props.item.type === "path" ? ( - - ) : null} - {props.item.type === "slash-command" ? ( - - ) : null} - {props.item.type === "model" ? ( - - model - - ) : null} - - {props.item.label} - - {props.item.description} - - ); -}); - -const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { - items: ComposerCommandItem[]; - resolvedTheme: "light" | "dark"; - isLoading: boolean; - triggerKind: ComposerTriggerKind | null; - activeItemId: string | null; - onHighlightedItemChange: (itemId: string | null) => void; - onSelect: (item: ComposerCommandItem) => void; -}) { - return ( - { - props.onHighlightedItemChange( - typeof highlightedValue === "string" ? highlightedValue : null, - ); - }} - > -
- - {props.items.map((item) => ( - - ))} - - {props.items.length === 0 && ( -

- {props.isLoading - ? "Searching workspace files..." - : props.triggerKind === "path" - ? "No matching files or folders." - : "No matching command."} -

- )} -
-
- ); -}); interface ChatViewProps { threadId: ThreadId; @@ -589,36 +226,62 @@ export default function ChatView({ threadId }: ChatViewProps) { }); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); - const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); + const createWorktreeMutation = useMutation( + gitCreateWorktreeMutationOptions({ queryClient }), + ); const composerDraft = useComposerThreadDraft(threadId); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; - const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); - const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider); - const setComposerDraftModel = useComposerDraftStore((store) => store.setModel); - const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); + const setComposerDraftPrompt = useComposerDraftStore( + (store) => store.setPrompt, + ); + const setComposerDraftProvider = useComposerDraftStore( + (store) => store.setProvider, + ); + const setComposerDraftModel = useComposerDraftStore( + (store) => store.setModel, + ); + const setComposerDraftRuntimeMode = useComposerDraftStore( + (store) => store.setRuntimeMode, + ); const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, ); - const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort); - const setComposerDraftCodexFastMode = useComposerDraftStore((store) => store.setCodexFastMode); - const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); - const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); - const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); + const setComposerDraftEffort = useComposerDraftStore( + (store) => store.setEffort, + ); + const setComposerDraftCodexFastMode = useComposerDraftStore( + (store) => store.setCodexFastMode, + ); + const addComposerDraftImage = useComposerDraftStore( + (store) => store.addImage, + ); + const addComposerDraftImages = useComposerDraftStore( + (store) => store.addImages, + ); + const removeComposerDraftImage = useComposerDraftStore( + (store) => store.removeImage, + ); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, ); - const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); - const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + const clearComposerDraftContent = useComposerDraftStore( + (store) => store.clearComposerContent, + ); + const setDraftThreadContext = useComposerDraftStore( + (store) => store.setDraftThreadContext, + ); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); - const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); + const setProjectDraftThreadId = useComposerDraftStore( + (store) => store.setProjectDraftThreadId, + ); const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); @@ -627,8 +290,11 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const promptRef = useRef(prompt); const [isDragOverComposer, setIsDragOverComposer] = useState(false); - const [expandedImage, setExpandedImage] = useState(null); - const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); + const [expandedImage, setExpandedImage] = + useState(null); + const [optimisticUserMessages, setOptimisticUserMessages] = useState< + ChatMessage[] + >([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< @@ -638,16 +304,22 @@ export default function ChatView({ threadId }: ChatViewProps) { const [sendStartedAt, setSendStartedAt] = useState(null); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); - const [respondingRequestIds, setRespondingRequestIds] = useState([]); - const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< + const [respondingRequestIds, setRespondingRequestIds] = useState< ApprovalRequestId[] >([]); - const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< - Record> + const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = + useState([]); + const [ + pendingUserInputAnswersByRequestId, + setPendingUserInputAnswersByRequestId, + ] = useState>>({}); + const [ + pendingUserInputQuestionIndexByRequestId, + setPendingUserInputQuestionIndexByRequestId, + ] = useState>({}); + const [expandedWorkGroups, setExpandedWorkGroups] = useState< + Record >({}); - const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = - useState>({}); - const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. @@ -657,21 +329,27 @@ export default function ChatView({ threadId }: ChatViewProps) { const planSidebarOpenOnNextThreadRef = useRef(false); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); - const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); + const [composerHighlightedItemId, setComposerHighlightedItemId] = useState< + string | null + >(null); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); - const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< - Record - >({}); + const [ + attachmentPreviewHandoffByMessageId, + setAttachmentPreviewHandoffByMessageId, + ] = useState>({}); const [composerCursor, setComposerCursor] = useState(() => prompt.length); - const [composerTrigger, setComposerTrigger] = useState(() => - detectComposerTrigger(prompt, prompt.length), - ); - const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< - Record - >(() => readLastInvokedScriptByProjectFromStorage()); + const [composerTrigger, setComposerTrigger] = + useState(() => + detectComposerTrigger(prompt, prompt.length), + ); + const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = + useState>(() => + readLastInvokedScriptByProjectFromStorage(), + ); const messagesScrollRef = useRef(null); - const [messagesScrollElement, setMessagesScrollElement] = useState(null); + const [messagesScrollElement, setMessagesScrollElement] = + useState(null); const shouldAutoScrollRef = useRef(true); const lastKnownScrollTopRef = useRef(0); const isPointerScrollActiveRef = useRef(false); @@ -691,24 +369,35 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef([]); const activeComposerMenuItemRef = useRef(null); - const attachmentPreviewHandoffByMessageIdRef = useRef>({}); - const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef>({}); + const attachmentPreviewHandoffByMessageIdRef = useRef< + Record + >({}); + const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef< + Record + >({}); const sendInFlightRef = useRef(false); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); - const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { - messagesScrollRef.current = element; - setMessagesScrollElement(element); - }, []); + const setMessagesScrollContainerRef = useCallback( + (element: HTMLDivElement | null) => { + messagesScrollRef.current = element; + setMessagesScrollElement(element); + }, + [], + ); const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadId, threadId), ); const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); - const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); + const storeSetTerminalHeight = useTerminalStateStore( + (s) => s.setTerminalHeight, + ); const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); - const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); + const storeSetActiveTerminal = useTerminalStateStore( + (s) => s.setActiveTerminal, + ); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); const setPrompt = useCallback( @@ -737,8 +426,12 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const serverThread = threads.find((t) => t.id === threadId); - const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); - const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); + const fallbackDraftProject = projects.find( + (project) => project.id === draftThread?.projectId, + ); + const localDraftError = serverThread + ? null + : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => draftThread @@ -753,16 +446,23 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const activeThread = serverThread ?? localDraftThread; const runtimeMode = - composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; + composerDraft.runtimeMode ?? + activeThread?.runtimeMode ?? + DEFAULT_RUNTIME_MODE; const interactionMode = - composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; + composerDraft.interactionMode ?? + activeThread?.interactionMode ?? + DEFAULT_INTERACTION_MODE; const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeLatestTurn = activeThread?.latestTurn ?? null; - const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); + const latestTurnSettled = isLatestTurnSettled( + activeLatestTurn, + activeThread?.session ?? null, + ); const activeProject = projects.find((p) => p.id === activeThread?.projectId); const openPullRequestDialog = useCallback( @@ -784,14 +484,24 @@ export default function ChatView({ threadId }: ChatViewProps) { }, []); const openOrReuseProjectDraftThread = useCallback( - async (input: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => { + async (input: { + branch: string; + worktreePath: string | null; + envMode: DraftThreadEnvMode; + }) => { if (!activeProject) { - throw new Error("No active project is available for this pull request."); + throw new Error( + "No active project is available for this pull request.", + ); } const storedDraftThread = getDraftThreadByProjectId(activeProject.id); if (storedDraftThread) { setDraftThreadContext(storedDraftThread.threadId, input); - setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input); + setProjectDraftThreadId( + activeProject.id, + storedDraftThread.threadId, + input, + ); if (storedDraftThread.threadId !== threadId) { await navigate({ to: "/$threadId", @@ -802,7 +512,10 @@ export default function ChatView({ threadId }: ChatViewProps) { } const activeDraftThread = getDraftThread(threadId); - if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { + if ( + !isServerThread && + activeDraftThread?.projectId === activeProject.id + ) { setDraftThreadContext(threadId, input); setProjectDraftThreadId(activeProject.id, threadId, input); return; @@ -851,8 +564,11 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activeLatestTurn?.completedAt) return; const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); if (Number.isNaN(turnCompletedAt)) return; - const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; - if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; + const lastVisitedAt = activeThread.lastVisitedAt + ? Date.parse(activeThread.lastVisitedAt) + : NaN; + if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) + return; markThreadVisited(activeThread.id); }, [ @@ -867,17 +583,20 @@ export default function ChatView({ threadId }: ChatViewProps) { const selectedProviderByThreadId = composerDraft.provider; const hasThreadStarted = Boolean( activeThread && - (activeThread.latestTurn !== null || - activeThread.messages.length > 0 || - activeThread.session !== null), + (activeThread.latestTurn !== null || + activeThread.messages.length > 0 || + activeThread.session !== null), ); const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? selectedProviderByThreadId ?? null) : null; - const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; + const selectedProvider: ProviderKind = + lockedProvider ?? selectedProviderByThreadId ?? "codex"; const baseThreadModel = resolveModelSlugForProvider( selectedProvider, - activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), + activeThread?.model ?? + activeProject?.model ?? + getDefaultModel(selectedProvider), ); const customModelsForSelectedProvider = settings.customCodexModels; const selectedModel = useMemo(() => { @@ -890,10 +609,16 @@ export default function ChatView({ threadId }: ChatViewProps) { customModelsForSelectedProvider, draftModel, ) as ModelSlug; - }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]); + }, [ + baseThreadModel, + composerDraft.model, + customModelsForSelectedProvider, + selectedProvider, + ]); const reasoningOptions = getReasoningEffortOptions(selectedProvider); const supportsReasoningEffort = reasoningOptions.length > 0; - const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); + const selectedEffort = + composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); const selectedCodexFastModeEnabled = selectedProvider === "codex" ? composerDraft.codexFastMode : false; const selectedModelOptionsForDispatch = useMemo(() => { @@ -901,18 +626,29 @@ export default function ChatView({ threadId }: ChatViewProps) { return undefined; } const codexOptions = { - ...(supportsReasoningEffort && selectedEffort ? { reasoningEffort: selectedEffort } : {}), + ...(supportsReasoningEffort && selectedEffort + ? { reasoningEffort: selectedEffort } + : {}), ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}), }; - return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; - }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]); + return Object.keys(codexOptions).length > 0 + ? { codex: codexOptions } + : undefined; + }, [ + selectedCodexFastModeEnabled, + selectedEffort, + selectedProvider, + supportsReasoningEffort, + ]); const providerOptionsForDispatch = useMemo(() => { if (!settings.codexBinaryPath && !settings.codexHomePath) { return undefined; } return { codex: { - ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), + ...(settings.codexBinaryPath + ? { binaryPath: settings.codexBinaryPath } + : {}), ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), }, }; @@ -924,9 +660,12 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const selectedModelForPickerWithCustomFallback = useMemo(() => { const currentOptions = modelOptionsByProvider[selectedProvider]; - return currentOptions.some((option) => option.slug === selectedModelForPicker) + return currentOptions.some( + (option) => option.slug === selectedModelForPicker, + ) ? selectedModelForPicker - : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); + : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? + selectedModelForPicker); }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); const searchableModelOptions = useMemo( () => @@ -948,7 +687,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; - const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const isWorking = + phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, @@ -957,7 +697,11 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( - () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), + () => + deriveWorkLogEntries( + threadActivities, + activeLatestTurn?.turnId ?? undefined, + ), [activeLatestTurn?.turnId, threadActivities], ); const latestTurnHasToolActivity = useMemo( @@ -976,13 +720,16 @@ export default function ChatView({ threadId }: ChatViewProps) { const activePendingDraftAnswers = useMemo( () => activePendingUserInput - ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ?? - EMPTY_PENDING_USER_INPUT_ANSWERS) + ? (pendingUserInputAnswersByRequestId[ + activePendingUserInput.requestId + ] ?? EMPTY_PENDING_USER_INPUT_ANSWERS) : EMPTY_PENDING_USER_INPUT_ANSWERS, [activePendingUserInput, pendingUserInputAnswersByRequestId], ); const activePendingQuestionIndex = activePendingUserInput - ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0) + ? (pendingUserInputQuestionIndexByRequestId[ + activePendingUserInput.requestId + ] ?? 0) : 0; const activePendingProgress = useMemo( () => @@ -993,12 +740,19 @@ export default function ChatView({ threadId }: ChatViewProps) { activePendingQuestionIndex, ) : null, - [activePendingDraftAnswers, activePendingQuestionIndex, activePendingUserInput], + [ + activePendingDraftAnswers, + activePendingQuestionIndex, + activePendingUserInput, + ], ); const activePendingResolvedAnswers = useMemo( () => activePendingUserInput - ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingDraftAnswers) + ? buildPendingUserInputAnswers( + activePendingUserInput.questions, + activePendingDraftAnswers, + ) : null, [activePendingDraftAnswers, activePendingUserInput], ); @@ -1013,9 +767,17 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThread?.proposedPlans ?? [], activeLatestTurn?.turnId ?? null, ); - }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); + }, [ + activeLatestTurn?.turnId, + activeThread?.proposedPlans, + latestTurnSettled, + ]); const activePlan = useMemo( - () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), + () => + deriveActivePlanState( + threadActivities, + activeLatestTurn?.turnId ?? undefined, + ), [activeLatestTurn?.turnId, threadActivities], ); const showPlanFollowUpPrompt = @@ -1029,7 +791,8 @@ export default function ChatView({ threadId }: ChatViewProps) { isComposerApprovalState || pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); - const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; + const composerFooterHasWideActions = + showPlanFollowUpPrompt || activePendingProgress !== null; useEffect(() => { if (!activePendingProgress) { return; @@ -1048,14 +811,19 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerHighlightedItemId(null); }, [activePendingProgress, activePendingUserInput?.requestId]); useEffect(() => { - attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; + attachmentPreviewHandoffByMessageIdRef.current = + attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); const clearAttachmentPreviewHandoffs = useCallback(() => { - for (const timeoutId of Object.values(attachmentPreviewHandoffTimeoutByMessageIdRef.current)) { + for (const timeoutId of Object.values( + attachmentPreviewHandoffTimeoutByMessageIdRef.current, + )) { window.clearTimeout(timeoutId); } attachmentPreviewHandoffTimeoutByMessageIdRef.current = {}; - for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { + for (const previewUrls of Object.values( + attachmentPreviewHandoffByMessageIdRef.current, + )) { for (const previewUrl of previewUrls) { revokeBlobPreviewUrl(previewUrl); } @@ -1071,45 +839,54 @@ export default function ChatView({ threadId }: ChatViewProps) { } }; }, [clearAttachmentPreviewHandoffs]); - const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { - if (previewUrls.length === 0) return; - - const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; - for (const previewUrl of previousPreviewUrls) { - if (!previewUrls.includes(previewUrl)) { - revokeBlobPreviewUrl(previewUrl); - } - } - setAttachmentPreviewHandoffByMessageId((existing) => { - const next = { - ...existing, - [messageId]: previewUrls, - }; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; - }); - - const existingTimeout = attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; - if (typeof existingTimeout === "number") { - window.clearTimeout(existingTimeout); - } - attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId] = window.setTimeout(() => { - const currentPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId]; - if (currentPreviewUrls) { - for (const previewUrl of currentPreviewUrls) { + const handoffAttachmentPreviews = useCallback( + (messageId: MessageId, previewUrls: string[]) => { + if (previewUrls.length === 0) return; + + const previousPreviewUrls = + attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; + for (const previewUrl of previousPreviewUrls) { + if (!previewUrls.includes(previewUrl)) { revokeBlobPreviewUrl(previewUrl); } } setAttachmentPreviewHandoffByMessageId((existing) => { - if (!(messageId in existing)) return existing; - const next = { ...existing }; - delete next[messageId]; + const next = { + ...existing, + [messageId]: previewUrls, + }; attachmentPreviewHandoffByMessageIdRef.current = next; return next; }); - delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; - }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS); - }, []); + + const existingTimeout = + attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; + if (typeof existingTimeout === "number") { + window.clearTimeout(existingTimeout); + } + attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId] = + window.setTimeout(() => { + const currentPreviewUrls = + attachmentPreviewHandoffByMessageIdRef.current[messageId]; + if (currentPreviewUrls) { + for (const previewUrl of currentPreviewUrls) { + revokeBlobPreviewUrl(previewUrl); + } + } + setAttachmentPreviewHandoffByMessageId((existing) => { + if (!(messageId in existing)) return existing; + const next = { ...existing }; + delete next[messageId]; + attachmentPreviewHandoffByMessageIdRef.current = next; + return next; + }); + delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[ + messageId + ]; + }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS); + }, + [], + ); const serverMessages = activeThread?.messages; const timelineMessages = useMemo(() => { const messages = serverMessages ?? []; @@ -1128,7 +905,8 @@ export default function ChatView({ threadId }: ChatViewProps) { ) { return message; } - const handoffPreviewUrls = attachmentPreviewHandoffByMessageId[message.id]; + const handoffPreviewUrls = + attachmentPreviewHandoffByMessageId[message.id]; if (!handoffPreviewUrls || handoffPreviewUrls.length === 0) { return message; } @@ -1141,7 +919,10 @@ export default function ChatView({ threadId }: ChatViewProps) { } const handoffPreviewUrl = handoffPreviewUrls[imageIndex]; imageIndex += 1; - if (!handoffPreviewUrl || attachment.previewUrl === handoffPreviewUrl) { + if ( + !handoffPreviewUrl || + attachment.previewUrl === handoffPreviewUrl + ) { return attachment; } changed = true; @@ -1157,16 +938,28 @@ export default function ChatView({ threadId }: ChatViewProps) { if (optimisticUserMessages.length === 0) { return serverMessagesWithPreviewHandoff; } - const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id)); - const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); + const serverIds = new Set( + serverMessagesWithPreviewHandoff.map((message) => message.id), + ); + const pendingMessages = optimisticUserMessages.filter( + (message) => !serverIds.has(message.id), + ); if (pendingMessages.length === 0) { return serverMessagesWithPreviewHandoff; } return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; - }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); + }, [ + serverMessages, + attachmentPreviewHandoffByMessageId, + optimisticUserMessages, + ]); const timelineEntries = useMemo( () => - deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), + deriveTimelineEntries( + timelineMessages, + activeThread?.proposedPlans ?? [], + workLogEntries, + ), [activeThread?.proposedPlans, timelineMessages, workLogEntries], ); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = @@ -1187,7 +980,11 @@ export default function ChatView({ threadId }: ChatViewProps) { continue; } - for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) { + for ( + let nextIndex = index + 1; + nextIndex < timelineEntries.length; + nextIndex += 1 + ) { const nextEntry = timelineEntries[nextIndex]; if (!nextEntry || nextEntry.kind !== "message") { continue; @@ -1195,12 +992,15 @@ export default function ChatView({ threadId }: ChatViewProps) { if (nextEntry.message.role === "user") { break; } - const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); + const summary = turnDiffSummaryByAssistantMessageId.get( + nextEntry.message.id, + ); if (!summary) { continue; } const turnCount = - summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; + summary.checkpointTurnCount ?? + inferredCheckpointTurnCountByTurnId[summary.turnId]; if (typeof turnCount !== "number") { break; } @@ -1210,7 +1010,11 @@ export default function ChatView({ threadId }: ChatViewProps) { } return byUserMessageId; - }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); + }, [ + inferredCheckpointTurnCountByTurnId, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + ]); const completionSummary = useMemo(() => { if (!latestTurnSettled) return null; @@ -1218,7 +1022,10 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activeLatestTurn.completedAt) return null; if (!latestTurnHasToolActivity) return null; - const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt); + const elapsed = formatElapsed( + activeLatestTurn.startedAt, + activeLatestTurn.completedAt, + ); return elapsed ? `Worked for ${elapsed}` : null; }, [ activeLatestTurn?.completedAt, @@ -1259,14 +1066,16 @@ export default function ChatView({ threadId }: ChatViewProps) { ]); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; const composerTriggerKind = composerTrigger?.kind ?? null; - const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; + const pathTriggerQuery = + composerTrigger?.kind === "path" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( pathTriggerQuery, { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, (debouncerState) => ({ isPending: debouncerState.isPending }), ); - const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; + const effectivePathQuery = + pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const workspaceEntriesQuery = useQuery( @@ -1277,7 +1086,8 @@ export default function ChatView({ threadId }: ChatViewProps) { limit: 80, }), ); - const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; + const workspaceEntries = + workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { @@ -1314,13 +1124,16 @@ export default function ChatView({ threadId }: ChatViewProps) { label: "/default", description: "Switch this thread back to normal chat mode", }, - ] satisfies ReadonlyArray>; + ] satisfies ReadonlyArray< + Extract + >; const query = composerTrigger.query.trim().toLowerCase(); if (!query) { return [...slashCommandItems]; } return slashCommandItems.filter( - (item) => item.command.includes(query) || item.label.slice(1).includes(query), + (item) => + item.command.includes(query) || item.label.slice(1).includes(query), ); } @@ -1329,7 +1142,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const query = composerTrigger.query.trim().toLowerCase(); if (!query) return true; return ( - searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query) + searchSlug.includes(query) || + searchName.includes(query) || + searchProvider.includes(query) ); }) .map(({ provider, providerLabel, slug, name }) => ({ @@ -1357,11 +1172,15 @@ export default function ChatView({ threadId }: ChatViewProps) { [nonPersistedComposerImageIds], ); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; - const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; - const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; + const availableEditors = + serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; + const providerStatuses = + serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; const activeProvider = activeThread?.session?.provider ?? "codex"; const activeProviderStatus = useMemo( - () => providerStatuses.find((status) => status.provider === activeProvider) ?? null, + () => + providerStatuses.find((status) => status.provider === activeProvider) ?? + null, [activeProvider, providerStatuses], ); const activeProjectCwd = activeProject?.cwd ?? null; @@ -1407,10 +1226,12 @@ export default function ChatView({ threadId }: ChatViewProps) { const envLocked = Boolean( activeThread && - (activeThread.messages.length > 0 || - (activeThread.session !== null && activeThread.session.status !== "closed")), + (activeThread.messages.length > 0 || + (activeThread.session !== null && + activeThread.session.status !== "closed")), ); - const hasReachedTerminalLimit = terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT; + const hasReachedTerminalLimit = + terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT; const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; @@ -1493,7 +1314,11 @@ export default function ChatView({ threadId }: ChatViewProps) { .clear({ threadId: activeThreadId, terminalId }) .catch(() => undefined); } - await api.terminal.close({ threadId: activeThreadId, terminalId, deleteHistory: true }); + await api.terminal.close({ + threadId: activeThreadId, + terminalId, + deleteHistory: true, + }); })().catch(() => fallbackExitWrite()); } else { void fallbackExitWrite(); @@ -1529,10 +1354,13 @@ export default function ChatView({ threadId }: ChatViewProps) { terminalState.activeTerminalId || terminalState.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; - const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId); - const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; + const isBaseTerminalBusy = + terminalState.runningTerminalIds.includes(baseTerminalId); + const wantsNewTerminal = + Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; const shouldCreateNewTerminal = - wantsNewTerminal && terminalState.terminalIds.length < MAX_THREAD_TERMINAL_COUNT; + wantsNewTerminal && + terminalState.terminalIds.length < MAX_THREAD_TERMINAL_COUNT; const targetTerminalId = shouldCreateNewTerminal ? `terminal-${randomUUID()}` : baseTerminalId; @@ -1549,24 +1377,26 @@ export default function ChatView({ threadId }: ChatViewProps) { project: { cwd: activeProject.cwd, }, - worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, + worktreePath: + options?.worktreePath ?? activeThread.worktreePath ?? null, ...(options?.env ? { extraEnv: options.env } : {}), }); - const openTerminalInput: Parameters[0] = shouldCreateNewTerminal - ? { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - env: runtimeEnv, - cols: SCRIPT_TERMINAL_COLS, - rows: SCRIPT_TERMINAL_ROWS, - } - : { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - env: runtimeEnv, - }; + const openTerminalInput: Parameters[0] = + shouldCreateNewTerminal + ? { + threadId: activeThreadId, + terminalId: targetTerminalId, + cwd: targetCwd, + env: runtimeEnv, + cols: SCRIPT_TERMINAL_COLS, + rows: SCRIPT_TERMINAL_ROWS, + } + : { + threadId: activeThreadId, + terminalId: targetTerminalId, + cwd: targetCwd, + env: runtimeEnv, + }; try { await api.terminal.open(openTerminalInput); @@ -1578,7 +1408,9 @@ export default function ChatView({ threadId }: ChatViewProps) { } catch (error) { setThreadError( activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, + error instanceof Error + ? error.message + : `Failed to run script "${script.name}".`, ); } }, @@ -1645,7 +1477,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextScripts = input.runOnWorktreeCreate ? [ ...activeProject.scripts.map((script) => - script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, + script.runOnWorktreeCreate + ? { ...script, runOnWorktreeCreate: false } + : script, ), nextScript, ] @@ -1665,7 +1499,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const updateProjectScript = useCallback( async (scriptId: string, input: NewProjectScriptInput) => { if (!activeProject) return; - const existingScript = activeProject.scripts.find((script) => script.id === scriptId); + const existingScript = activeProject.scripts.find( + (script) => script.id === scriptId, + ); if (!existingScript) { throw new Error("Script not found."); } @@ -1699,9 +1535,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const deleteProjectScript = useCallback( async (scriptId: string) => { if (!activeProject) return; - const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId); + const nextScripts = activeProject.scripts.filter( + (script) => script.id !== scriptId, + ); - const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name; + const deletedName = activeProject.scripts.find( + (s) => s.id === scriptId, + )?.name; try { await persistProjectScripts({ @@ -1720,7 +1560,10 @@ export default function ChatView({ threadId }: ChatViewProps) { toastManager.add({ type: "error", title: "Could not delete action", - description: error instanceof Error ? error.message : "An unexpected error occurred.", + description: + error instanceof Error + ? error.message + : "An unexpected error occurred.", }); } }, @@ -1765,7 +1608,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ], ); const toggleInteractionMode = useCallback(() => { - handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); + handleInteractionModeChange( + interactionMode === "plan" ? "default" : "plan", + ); }, [handleInteractionModeChange, interactionMode]); const toggleRuntimeMode = useCallback(() => { void handleRuntimeModeChange( @@ -1775,7 +1620,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const togglePlanSidebar = useCallback(() => { setPlanSidebarOpen((open) => { if (open) { - const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; + const turnKey = + activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; if (turnKey) { planSidebarDismissedForTurnRef.current = turnKey; } @@ -1851,13 +1697,16 @@ export default function ChatView({ threadId }: ChatViewProps) { // Auto-scroll on new messages const messageCount = timelineMessages.length; - const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); - lastKnownScrollTopRef.current = scrollContainer.scrollTop; - shouldAutoScrollRef.current = true; - }, []); + const scrollMessagesToBottom = useCallback( + (behavior: ScrollBehavior = "auto") => { + const scrollContainer = messagesScrollRef.current; + if (!scrollContainer) return; + scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); + lastKnownScrollTopRef.current = scrollContainer.scrollTop; + shouldAutoScrollRef.current = true; + }, + [], + ); const cancelPendingStickToBottom = useCallback(() => { const pendingFrame = pendingAutoScrollFrameRef.current; if (pendingFrame === null) return; @@ -1894,21 +1743,27 @@ export default function ChatView({ threadId }: ChatViewProps) { }; cancelPendingInteractionAnchorAdjustment(); - pendingInteractionAnchorFrameRef.current = window.requestAnimationFrame(() => { - pendingInteractionAnchorFrameRef.current = null; - const anchor = pendingInteractionAnchorRef.current; - pendingInteractionAnchorRef.current = null; - const activeScrollContainer = messagesScrollRef.current; - if (!anchor || !activeScrollContainer) return; - if (!anchor.element.isConnected || !activeScrollContainer.contains(anchor.element)) return; - - const nextTop = anchor.element.getBoundingClientRect().top; - const delta = nextTop - anchor.top; - if (Math.abs(delta) < 0.5) return; - - activeScrollContainer.scrollTop += delta; - lastKnownScrollTopRef.current = activeScrollContainer.scrollTop; - }); + pendingInteractionAnchorFrameRef.current = window.requestAnimationFrame( + () => { + pendingInteractionAnchorFrameRef.current = null; + const anchor = pendingInteractionAnchorRef.current; + pendingInteractionAnchorRef.current = null; + const activeScrollContainer = messagesScrollRef.current; + if (!anchor || !activeScrollContainer) return; + if ( + !anchor.element.isConnected || + !activeScrollContainer.contains(anchor.element) + ) + return; + + const nextTop = anchor.element.getBoundingClientRect().top; + const delta = nextTop - anchor.top; + if (Math.abs(delta) < 0.5) return; + + activeScrollContainer.scrollTop += delta; + lastKnownScrollTopRef.current = activeScrollContainer.scrollTop; + }, + ); }, [cancelPendingInteractionAnchorAdjustment], ); @@ -1916,7 +1771,11 @@ export default function ChatView({ threadId }: ChatViewProps) { cancelPendingStickToBottom(); scrollMessagesToBottom(); scheduleStickToBottom(); - }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]); + }, [ + cancelPendingStickToBottom, + scheduleStickToBottom, + scrollMessagesToBottom, + ]); const onMessagesScroll = useCallback(() => { const scrollContainer = messagesScrollRef.current; if (!scrollContainer) return; @@ -1926,13 +1785,19 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!shouldAutoScrollRef.current && isNearBottom) { shouldAutoScrollRef.current = true; pendingUserScrollUpIntentRef.current = false; - } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) { + } else if ( + shouldAutoScrollRef.current && + pendingUserScrollUpIntentRef.current + ) { const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; if (scrolledUp) { shouldAutoScrollRef.current = false; } pendingUserScrollUpIntentRef.current = false; - } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { + } else if ( + shouldAutoScrollRef.current && + isPointerScrollActiveRef.current + ) { const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; if (scrolledUp) { shouldAutoScrollRef.current = false; @@ -1947,37 +1812,58 @@ export default function ChatView({ threadId }: ChatViewProps) { lastKnownScrollTopRef.current = currentScrollTop; }, []); - const onMessagesWheel = useCallback((event: React.WheelEvent) => { - if (event.deltaY < 0) { - pendingUserScrollUpIntentRef.current = true; - } - }, []); - const onMessagesPointerDown = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = true; - }, []); - const onMessagesPointerUp = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = false; - }, []); - const onMessagesPointerCancel = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = false; - }, []); - const onMessagesTouchStart = useCallback((event: React.TouchEvent) => { - const touch = event.touches[0]; - if (!touch) return; - lastTouchClientYRef.current = touch.clientY; - }, []); - const onMessagesTouchMove = useCallback((event: React.TouchEvent) => { - const touch = event.touches[0]; - if (!touch) return; - const previousTouchY = lastTouchClientYRef.current; - if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { - pendingUserScrollUpIntentRef.current = true; - } - lastTouchClientYRef.current = touch.clientY; - }, []); - const onMessagesTouchEnd = useCallback((_event: React.TouchEvent) => { - lastTouchClientYRef.current = null; - }, []); + const onMessagesWheel = useCallback( + (event: React.WheelEvent) => { + if (event.deltaY < 0) { + pendingUserScrollUpIntentRef.current = true; + } + }, + [], + ); + const onMessagesPointerDown = useCallback( + (_event: React.PointerEvent) => { + isPointerScrollActiveRef.current = true; + }, + [], + ); + const onMessagesPointerUp = useCallback( + (_event: React.PointerEvent) => { + isPointerScrollActiveRef.current = false; + }, + [], + ); + const onMessagesPointerCancel = useCallback( + (_event: React.PointerEvent) => { + isPointerScrollActiveRef.current = false; + }, + [], + ); + const onMessagesTouchStart = useCallback( + (event: React.TouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + lastTouchClientYRef.current = touch.clientY; + }, + [], + ); + const onMessagesTouchMove = useCallback( + (event: React.TouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + const previousTouchY = lastTouchClientYRef.current; + if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { + pendingUserScrollUpIntentRef.current = true; + } + lastTouchClientYRef.current = touch.clientY; + }, + [], + ); + const onMessagesTouchEnd = useCallback( + (_event: React.TouchEvent) => { + lastTouchClientYRef.current = null; + }, + [], + ); useEffect(() => { return () => { cancelPendingStickToBottom(); @@ -2015,16 +1901,22 @@ export default function ChatView({ threadId }: ChatViewProps) { const [entry] = entries; if (!entry) return; - const nextCompact = shouldUseCompactComposerFooter(measureComposerFormWidth(), { - hasWideActions: composerFooterHasWideActions, - }); - setIsComposerFooterCompact((previous) => (previous === nextCompact ? previous : nextCompact)); - + const nextCompact = shouldUseCompactComposerFooter( + measureComposerFormWidth(), + { + hasWideActions: composerFooterHasWideActions, + }, + ); + setIsComposerFooterCompact((previous) => + previous === nextCompact ? previous : nextCompact, + ); + const nextHeight = entry.contentRect.height; const previousHeight = composerFormHeightRef.current; composerFormHeightRef.current = nextHeight; - if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return; + if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) + return; if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); }); @@ -2091,8 +1983,12 @@ export default function ChatView({ threadId }: ChatViewProps) { if (activeThread.messages.length === 0) { return; } - const serverIds = new Set(activeThread.messages.map((message) => message.id)); - const removedMessages = optimisticUserMessages.filter((message) => serverIds.has(message.id)); + const serverIds = new Set( + activeThread.messages.map((message) => message.id), + ); + const removedMessages = optimisticUserMessages.filter((message) => + serverIds.has(message.id), + ); if (removedMessages.length === 0) { return; } @@ -2112,11 +2008,18 @@ export default function ChatView({ threadId }: ChatViewProps) { return () => { window.clearTimeout(timer); }; - }, [activeThread?.id, activeThread?.messages, handoffAttachmentPreviews, optimisticUserMessages]); + }, [ + activeThread?.id, + activeThread?.messages, + handoffAttachmentPreviews, + optimisticUserMessages, + ]); useEffect(() => { promptRef.current = prompt; - setComposerCursor((existing) => Math.min(Math.max(0, existing), prompt.length)); + setComposerCursor((existing) => + Math.min(Math.max(0, existing), prompt.length), + ); }, [prompt]); useEffect(() => { @@ -2130,7 +2033,9 @@ export default function ChatView({ threadId }: ChatViewProps) { setSendStartedAt(null); setComposerHighlightedItemId(null); setComposerCursor(promptRef.current.length); - setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); + setComposerTrigger( + detectComposerTrigger(promptRef.current, promptRef.current.length), + ); dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); @@ -2144,13 +2049,20 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } const getPersistedAttachmentsForThread = () => - useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? []; + useComposerDraftStore.getState().draftsByThreadId[threadId] + ?.persistedAttachments ?? []; try { const currentPersistedAttachments = getPersistedAttachmentsForThread(); const existingPersistedById = new Map( - currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), + currentPersistedAttachments.map((attachment) => [ + attachment.id, + attachment, + ]), ); - const stagedAttachmentById = new Map(); + const stagedAttachmentById = new Map< + string, + PersistedComposerImageAttachment + >(); await Promise.all( composerImages.map(async (image) => { try { @@ -2177,14 +2089,16 @@ export default function ChatView({ threadId }: ChatViewProps) { // Stage attachments in persisted draft state first so persist middleware can write them. syncComposerDraftPersistedAttachments(threadId, serialized); } catch { - const currentImageIds = new Set(composerImages.map((image) => image.id)); + const currentImageIds = new Set( + composerImages.map((image) => image.id), + ); const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); const fallbackPersistedIds = fallbackPersistedAttachments .map((attachment) => attachment.id) .filter((id) => currentImageIds.has(id)); const fallbackPersistedIdSet = new Set(fallbackPersistedIds); - const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => - fallbackPersistedIdSet.has(attachment.id), + const fallbackAttachments = fallbackPersistedAttachments.filter( + (attachment) => fallbackPersistedIdSet.has(attachment.id), ); if (cancelled) { return; @@ -2211,7 +2125,8 @@ export default function ChatView({ threadId }: ChatViewProps) { return existing; } const nextIndex = - (existing.index + direction + existing.images.length) % existing.images.length; + (existing.index + direction + existing.images.length) % + existing.images.length; if (nextIndex === existing.index) { return existing; } @@ -2267,10 +2182,13 @@ export default function ChatView({ threadId }: ChatViewProps) { }; }, [phase]); - const beginSendPhase = useCallback((nextPhase: Exclude) => { - setSendStartedAt((current) => current ?? new Date().toISOString()); - setSendPhase(nextPhase); - }, []); + const beginSendPhase = useCallback( + (nextPhase: Exclude) => { + setSendStartedAt((current) => current ?? new Date().toISOString()); + setSendPhase(nextPhase); + }, + [], + ); const resetSendPhase = useCallback(() => { setSendPhase("idle"); @@ -2324,7 +2242,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const isTerminalFocused = (): boolean => { const activeElement = document.activeElement; if (!(activeElement instanceof HTMLElement)) return false; - if (activeElement.classList.contains("xterm-helper-textarea")) return true; + if (activeElement.classList.contains("xterm-helper-textarea")) + return true; return activeElement.closest(".thread-terminal-drawer .xterm") !== null; }; @@ -2335,7 +2254,9 @@ export default function ChatView({ threadId }: ChatViewProps) { terminalOpen: Boolean(terminalState.terminalOpen), }; - const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext }); + const command = resolveShortcutCommand(event, keybindings, { + context: shortcutContext, + }); if (!command) return; if (command === "terminal.toggle") { @@ -2382,7 +2303,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; - const script = activeProject.scripts.find((entry) => entry.id === scriptId); + const script = activeProject.scripts.find( + (entry) => entry.id === scriptId, + ); if (!script) return; event.preventDefault(); event.stopPropagation(); @@ -2487,7 +2410,10 @@ export default function ChatView({ threadId }: ChatViewProps) { } event.preventDefault(); const nextTarget = event.relatedTarget; - if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { + if ( + nextTarget instanceof Node && + event.currentTarget.contains(nextTarget) + ) { return; } dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); @@ -2514,7 +2440,10 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!api || !activeThread || isRevertingCheckpoint) return; if (phase === "running" || isSendBusy || isConnecting) { - setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); + setThreadError( + activeThread.id, + "Interrupt the current turn before reverting checkpoints.", + ); return; } const confirmed = await api.dialogs.confirm( @@ -2546,13 +2475,27 @@ export default function ChatView({ threadId }: ChatViewProps) { } setIsRevertingCheckpoint(false); }, - [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], + [ + activeThread, + isConnecting, + isRevertingCheckpoint, + isSendBusy, + phase, + setThreadError, + ], ); const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); const api = readNativeApi(); - if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; + if ( + !api || + !activeThread || + isSendBusy || + isConnecting || + sendInFlightRef.current + ) + return; if (activePendingProgress) { onAdvanceActivePendingUserInput(); return; @@ -2575,7 +2518,9 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } const standaloneSlashCommand = - composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; + composerImages.length === 0 + ? parseStandaloneComposerSlashCommand(trimmed) + : null; if (standaloneSlashCommand) { await handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; @@ -2588,7 +2533,8 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!trimmed && composerImages.length === 0) return; if (!activeProject) return; const threadIdForSend = activeThread.id; - const isFirstMessage = !isServerThread || activeThread.messages.length === 0; + const isFirstMessage = + !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = isFirstMessage && envMode === "worktree" && !activeThread.worktreePath ? activeThread.branch @@ -2607,7 +2553,9 @@ export default function ChatView({ threadId }: ChatViewProps) { } sendInFlightRef.current = true; - beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); + beginSendPhase( + baseBranchForWorktree ? "preparing-worktree" : "sending-turn", + ); const composerImagesSnapshot = [...composerImages]; const messageIdForSend = newMessageId(); @@ -2635,7 +2583,9 @@ export default function ChatView({ threadId }: ChatViewProps) { id: messageIdForSend, role: "user", text: trimmed, - ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), + ...(optimisticAttachments.length > 0 + ? { attachments: optimisticAttachments } + : {}), createdAt: messageCreatedAt, streaming: false, }, @@ -2677,7 +2627,11 @@ export default function ChatView({ threadId }: ChatViewProps) { }); // Keep local thread state in sync immediately so terminal drawer opens // with the worktree cwd/env instead of briefly using the project root. - setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); + setStoreThreadBranch( + threadIdForSend, + result.worktree.branch, + result.worktree.path, + ); } } @@ -2698,7 +2652,9 @@ export default function ChatView({ threadId }: ChatViewProps) { } const title = truncateTitle(titleSeed); let threadCreateModel: ModelSlug = - selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex; + selectedModel || + (activeProject.model as ModelSlug) || + DEFAULT_MODEL_BY_PROVIDER.codex; if (isLocalDraftThread) { await api.orchestration.dispatchCommand({ @@ -2779,9 +2735,13 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), + ...(providerOptionsForDispatch + ? { providerOptions: providerOptionsForDispatch } + : {}), provider: selectedProvider, - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", + assistantDeliveryMode: settings.enableAssistantStreaming + ? "streaming" + : "buffered", runtimeMode, interactionMode, createdAt: messageCreatedAt, @@ -2803,17 +2763,23 @@ export default function ChatView({ threadId }: ChatViewProps) { composerImagesRef.current.length === 0 ) { setOptimisticUserMessages((existing) => { - const removed = existing.filter((message) => message.id === messageIdForSend); + const removed = existing.filter( + (message) => message.id === messageIdForSend, + ); for (const message of removed) { revokeUserMessagePreviewUrls(message); } - const next = existing.filter((message) => message.id !== messageIdForSend); + const next = existing.filter( + (message) => message.id !== messageIdForSend, + ); return next.length === existing.length ? existing : next; }); promptRef.current = trimmed; setPrompt(trimmed); setComposerCursor(trimmed.length); - addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); + addComposerImagesToDraft( + composerImagesSnapshot.map(cloneComposerImageForRetry), + ); setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length)); } setThreadError( @@ -2839,7 +2805,10 @@ export default function ChatView({ threadId }: ChatViewProps) { }; const onRespondToApproval = useCallback( - async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { + async ( + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, + ) => { const api = readNativeApi(); if (!api || !activeThreadId) return; @@ -2858,10 +2827,14 @@ export default function ChatView({ threadId }: ChatViewProps) { .catch((err: unknown) => { setStoreThreadError( activeThreadId, - err instanceof Error ? err.message : "Failed to submit approval decision.", + err instanceof Error + ? err.message + : "Failed to submit approval decision.", ); }); - setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); + setRespondingRequestIds((existing) => + existing.filter((id) => id !== requestId), + ); }, [activeThreadId, setStoreThreadError], ); @@ -2889,7 +2862,9 @@ export default function ChatView({ threadId }: ChatViewProps) { err instanceof Error ? err.message : "Failed to submit user input.", ); }); - setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); + setRespondingUserInputRequestIds((existing) => + existing.filter((id) => id !== requestId), + ); }, [activeThreadId, setStoreThreadError], ); @@ -2930,7 +2905,12 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const onChangeActivePendingUserInputCustomAnswer = useCallback( - (questionId: string, value: string, nextCursor: number, cursorAdjacentToMention: boolean) => { + ( + questionId: string, + value: string, + nextCursor: number, + cursorAdjacentToMention: boolean, + ) => { if (!activePendingUserInput) { return; } @@ -2949,7 +2929,10 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerTrigger( cursorAdjacentToMention ? null - : detectComposerTrigger(value, expandCollapsedComposerCursor(value, nextCursor)), + : detectComposerTrigger( + value, + expandCollapsedComposerCursor(value, nextCursor), + ), ); }, [activePendingUserInput], @@ -2961,11 +2944,16 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (activePendingProgress.isLastQuestion) { if (activePendingResolvedAnswers) { - void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers); + void onRespondToUserInput( + activePendingUserInput.requestId, + activePendingResolvedAnswers, + ); } return; } - setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); + setActivePendingUserInputQuestionIndex( + activePendingProgress.questionIndex + 1, + ); }, [ activePendingProgress, activePendingResolvedAnswers, @@ -2978,7 +2966,9 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activePendingProgress) { return; } - setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0)); + setActivePendingUserInputQuestionIndex( + Math.max(activePendingProgress.questionIndex - 1, 0), + ); }, [activePendingProgress, setActivePendingUserInputQuestionIndex]); const onSubmitPlanFollowUp = useCallback( @@ -3054,8 +3044,12 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", + ...(providerOptionsForDispatch + ? { providerOptions: providerOptionsForDispatch } + : {}), + assistantDeliveryMode: settings.enableAssistantStreaming + ? "streaming" + : "buffered", runtimeMode, interactionMode: nextInteractionMode, createdAt: messageCreatedAt, @@ -3119,7 +3113,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextThreadId = newThreadId(); const planMarkdown = activeProposedPlan.planMarkdown; const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); - const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); + const nextThreadTitle = truncateTitle( + buildPlanImplementationThreadTitle(planMarkdown), + ); const nextThreadModel: ModelSlug = selectedModel || (activeThread.model as ModelSlug) || @@ -3163,8 +3159,12 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", + ...(providerOptionsForDispatch + ? { providerOptions: providerOptionsForDispatch } + : {}), + assistantDeliveryMode: settings.enableAssistantStreaming + ? "streaming" + : "buffered", runtimeMode, interactionMode: "default", createdAt, @@ -3198,7 +3198,9 @@ export default function ChatView({ threadId }: ChatViewProps) { type: "error", title: "Could not start implementation thread", description: - err instanceof Error ? err.message : "An error occurred while creating the new thread.", + err instanceof Error + ? err.message + : "An error occurred while creating the new thread.", }); }) .then(finish, finish); @@ -3265,7 +3267,12 @@ export default function ChatView({ threadId }: ChatViewProps) { } scheduleComposerFocus(); }, - [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId], + [ + isLocalDraftThread, + scheduleComposerFocus, + setDraftThreadContext, + threadId, + ], ); const applyPromptReplacement = useCallback( @@ -3277,14 +3284,22 @@ export default function ChatView({ threadId }: ChatViewProps) { ): boolean => { const currentText = promptRef.current; const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); - const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); + const safeEnd = Math.max( + safeStart, + Math.min(currentText.length, rangeEnd), + ); if ( options?.expectedText !== undefined && currentText.slice(safeStart, safeEnd) !== options.expectedText ) { return false; } - const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); + const next = replaceTextRange( + promptRef.current, + rangeStart, + rangeEnd, + replacement, + ); promptRef.current = next.text; const activePendingQuestion = activePendingProgress?.activeQuestion; if (activePendingQuestion && activePendingUserInput) { @@ -3293,7 +3308,9 @@ export default function ChatView({ threadId }: ChatViewProps) { [activePendingUserInput.requestId]: { ...existing[activePendingUserInput.requestId], [activePendingQuestion.id]: setPendingUserInputCustomAnswer( - existing[activePendingUserInput.requestId]?.[activePendingQuestion.id], + existing[activePendingUserInput.requestId]?.[ + activePendingQuestion.id + ], next.text, ), }, @@ -3311,7 +3328,10 @@ export default function ChatView({ threadId }: ChatViewProps) { [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt], ); - const readComposerSnapshot = useCallback((): { value: string; cursor: number } => { + const readComposerSnapshot = useCallback((): { + value: string; + cursor: number; + } => { const editorSnapshot = composerEditorRef.current?.readSnapshot(); if (editorSnapshot) { return editorSnapshot; @@ -3324,7 +3344,10 @@ export default function ChatView({ threadId }: ChatViewProps) { trigger: ComposerTrigger | null; } => { const snapshot = readComposerSnapshot(); - const expandedCursor = expandCollapsedComposerCursor(snapshot.value, snapshot.cursor); + const expandedCursor = expandCollapsedComposerCursor( + snapshot.value, + snapshot.cursor, + ); return { snapshot, trigger: detectComposerTrigger(snapshot.value, expandedCursor), @@ -3340,7 +3363,10 @@ export default function ChatView({ threadId }: ChatViewProps) { }); const { snapshot, trigger } = resolveActiveComposerTrigger(); if (!trigger) return; - const expectedToken = snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd); + const expectedToken = snapshot.value.slice( + trigger.rangeStart, + trigger.rangeEnd, + ); if (item.type === "path") { const applied = applyPromptReplacement( trigger.rangeStart, @@ -3355,27 +3381,44 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (item.type === "slash-command") { if (item.command === "model") { - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "/model ", { - expectedText: expectedToken, - }); + const applied = applyPromptReplacement( + trigger.rangeStart, + trigger.rangeEnd, + "/model ", + { + expectedText: expectedToken, + }, + ); if (applied) { setComposerHighlightedItemId(null); } return; } - void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: expectedToken, - }); + void handleInteractionModeChange( + item.command === "plan" ? "plan" : "default", + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + trigger.rangeEnd, + "", + { + expectedText: expectedToken, + }, + ); if (applied) { setComposerHighlightedItemId(null); } return; } onProviderModelSelect(item.provider, item.model); - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: expectedToken, - }); + const applied = applyPromptReplacement( + trigger.rangeStart, + trigger.rangeEnd, + "", + { + expectedText: expectedToken, + }, + ); if (applied) { setComposerHighlightedItemId(null); } @@ -3402,7 +3445,8 @@ export default function ChatView({ threadId }: ChatViewProps) { highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; const offset = key === "ArrowDown" ? 1 : -1; const nextIndex = - (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; + (normalizedIndex + offset + composerMenuItems.length) % + composerMenuItems.length; const nextItem = composerMenuItems[nextIndex]; setComposerHighlightedItemId(nextItem?.id ?? null); }, @@ -3410,12 +3454,17 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const isComposerMenuLoading = composerTriggerKind === "path" && - ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || + ((pathTriggerQuery.length > 0 && + composerPathQueryDebouncer.state.isPending) || workspaceEntriesQuery.isLoading || workspaceEntriesQuery.isFetching); const onPromptChange = useCallback( - (nextPrompt: string, nextCursor: number, cursorAdjacentToMention: boolean) => { + ( + nextPrompt: string, + nextCursor: number, + cursorAdjacentToMention: boolean, + ) => { if (activePendingProgress?.activeQuestion && activePendingUserInput) { onChangeActivePendingUserInputCustomAnswer( activePendingProgress.activeQuestion.id, @@ -3468,7 +3517,8 @@ export default function ChatView({ threadId }: ChatViewProps) { return true; } if (key === "Tab" || key === "Enter") { - const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; + const selectedItem = + activeComposerMenuItemRef.current ?? currentItems[0]; if (selectedItem) { onSelectComposerItem(selectedItem); return true; @@ -3491,7 +3541,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); - const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; + const expandedImageItem = expandedImage + ? expandedImage.images[expandedImage.index] + : null; const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { void navigate({ @@ -3523,18 +3575,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
- Threads + + Threads +
)} {isElectron && (
- No active thread + + No active thread +
)}
-

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

+

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

@@ -3547,7 +3605,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
{/* Input bar */} -
+
) : null} @@ -3741,8 +3815,8 @@ export default function ChatView({ threadId }: ChatViewProps) { side="top" className="max-w-64 whitespace-normal leading-tight" > - Draft attachment could not be saved locally and may be lost on - navigation. + Draft attachment could not be saved locally + and may be lost on navigation. )} @@ -3793,7 +3867,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
@@ -3827,13 +3903,17 @@ export default function ChatView({ threadId }: ChatViewProps) { {isComposerFooterCompact ? ( ) : ( <> - {selectedProvider === "codex" && selectedEffort != null ? ( + {selectedProvider === "codex" && + selectedEffort != null ? ( <> void handleRuntimeModeChange( - runtimeMode === "full-access" ? "approval-required" : "full-access", + runtimeMode === "full-access" + ? "approval-required" + : "full-access", ) } title={ @@ -3903,13 +3986,21 @@ export default function ChatView({ threadId }: ChatViewProps) { : "Approval required — click for full access" } > - {runtimeMode === "full-access" ? : } + {runtimeMode === "full-access" ? ( + + ) : ( + + )} - {runtimeMode === "full-access" ? "Full access" : "Supervised"} + {runtimeMode === "full-access" + ? "Full access" + : "Supervised"} - {activePlan || activeProposedPlan || planSidebarOpen ? ( + {activePlan || + activeProposedPlan || + planSidebarOpen ? ( <> - Plan + + Plan + ) : null} @@ -4004,7 +4101,9 @@ export default function ChatView({ threadId }: ChatViewProps) { className="h-9 rounded-full px-4 sm:h-8" disabled={isSendBusy || isConnecting} > - {isConnecting || isSendBusy ? "Sending..." : "Refine"} + {isConnecting || isSendBusy + ? "Sending..." + : "Refine"} ) : (
@@ -4014,7 +4113,9 @@ export default function ChatView({ threadId }: ChatViewProps) { className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8" disabled={isSendBusy || isConnecting} > - {isConnecting || isSendBusy ? "Sending..." : "Implement"} + {isConnecting || isSendBusy + ? "Sending..." + : "Implement"} void onImplementPlanInNewThread()} + onClick={() => + void onImplementPlanInNewThread() + } > Implement in new thread @@ -4144,7 +4247,8 @@ export default function ChatView({ threadId }: ChatViewProps) { onClose={() => { setPlanSidebarOpen(false); // Track that the user explicitly dismissed for this turn so auto-open won't fight them. - const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; + const turnKey = + activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; if (turnKey) { planSidebarDismissedForTurnRef.current = turnKey; } @@ -4252,1901 +4356,3 @@ export default function ChatView({ threadId }: ChatViewProps) {
); } - -interface ChatHeaderProps { - activeThreadId: ThreadId; - activeThreadTitle: string; - activeProjectName: string | undefined; - isGitRepo: boolean; - openInCwd: string | null; - activeProjectScripts: ProjectScript[] | undefined; - preferredScriptId: string | null; - keybindings: ResolvedKeybindingsConfig; - availableEditors: ReadonlyArray; - diffToggleShortcutLabel: string | null; - gitCwd: string | null; - diffOpen: boolean; - onRunProjectScript: (script: ProjectScript) => void; - onAddProjectScript: (input: NewProjectScriptInput) => Promise; - onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; - onDeleteProjectScript: (scriptId: string) => Promise; - onToggleDiff: () => void; -} - -const ChatHeader = memo(function ChatHeader({ - activeThreadId, - activeThreadTitle, - activeProjectName, - isGitRepo, - openInCwd, - activeProjectScripts, - preferredScriptId, - keybindings, - availableEditors, - diffToggleShortcutLabel, - gitCwd, - diffOpen, - onRunProjectScript, - onAddProjectScript, - onUpdateProjectScript, - onDeleteProjectScript, - onToggleDiff, -}: ChatHeaderProps) { - return ( -
-
- -

- {activeThreadTitle} -

- {activeProjectName && ( - - {activeProjectName} - - )} - {activeProjectName && !isGitRepo && ( - - No Git - - )} -
-
- {activeProjectScripts && ( - - )} - {activeProjectName && ( - - )} - {activeProjectName && } - - - - - } - /> - - {!isGitRepo - ? "Diff panel is unavailable because this project is not a git repository." - : diffToggleShortcutLabel - ? `Toggle diff panel (${diffToggleShortcutLabel})` - : "Toggle diff panel"} - - -
-
- ); -}); - -const ThreadErrorBanner = memo(function ThreadErrorBanner({ - error, - onDismiss, -}: { - error: string | null; - onDismiss?: () => void; -}) { - if (!error) return null; - return ( -
- - - - {error} - - {onDismiss && ( - - - - )} - -
- ); -}); - -const ProviderHealthBanner = memo(function ProviderHealthBanner({ - status, -}: { - status: ServerProviderStatus | null; -}) { - if (!status || status.status === "ready") { - return null; - } - - const defaultMessage = - status.status === "error" - ? `${status.provider} provider is unavailable.` - : `${status.provider} provider has limited availability.`; - - return ( -
- - - - {status.provider === "codex" ? "Codex provider status" : `${status.provider} status`} - - - {status.message ?? defaultMessage} - - -
- ); -}); - -interface ComposerPendingApprovalPanelProps { - approval: PendingApproval; - pendingCount: number; -} - -const ComposerPendingApprovalPanel = memo(function ComposerPendingApprovalPanel({ - approval, - pendingCount, -}: ComposerPendingApprovalPanelProps) { - const approvalSummary = - approval.requestKind === "command" - ? "Command approval requested" - : approval.requestKind === "file-read" - ? "File-read approval requested" - : "File-change approval requested"; - - return ( -
-
- PENDING APPROVAL - {approvalSummary} - {pendingCount > 1 ? ( - 1/{pendingCount} - ) : null} -
-
- ); -}); - -interface ComposerPendingApprovalActionsProps { - requestId: ApprovalRequestId; - isResponding: boolean; - onRespondToApproval: ( - requestId: ApprovalRequestId, - decision: ProviderApprovalDecision, - ) => Promise; -} - -const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({ - requestId, - isResponding, - onRespondToApproval, -}: ComposerPendingApprovalActionsProps) { - return ( - <> - - - - - - ); -}); - -interface PendingUserInputPanelProps { - pendingUserInputs: PendingUserInput[]; - respondingRequestIds: ApprovalRequestId[]; - answers: Record; - questionIndex: number; - onSelectOption: (questionId: string, optionLabel: string) => void; - onAdvance: () => void; -} - -const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ - pendingUserInputs, - respondingRequestIds, - answers, - questionIndex, - onSelectOption, - onAdvance, -}: PendingUserInputPanelProps) { - if (pendingUserInputs.length === 0) return null; - const activePrompt = pendingUserInputs[0]; - if (!activePrompt) return null; - - return ( - - ); -}); - -const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({ - prompt, - isResponding, - answers, - questionIndex, - onSelectOption, - onAdvance, -}: { - prompt: PendingUserInput; - isResponding: boolean; - answers: Record; - questionIndex: number; - onSelectOption: (questionId: string, optionLabel: string) => void; - onAdvance: () => void; -}) { - const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); - const activeQuestion = progress.activeQuestion; - const autoAdvanceTimerRef = useRef(null); - - // Clear auto-advance timer on unmount - useEffect(() => { - return () => { - if (autoAdvanceTimerRef.current !== null) { - window.clearTimeout(autoAdvanceTimerRef.current); - } - }; - }, []); - - const selectOptionAndAutoAdvance = useCallback( - (questionId: string, optionLabel: string) => { - onSelectOption(questionId, optionLabel); - if (autoAdvanceTimerRef.current !== null) { - window.clearTimeout(autoAdvanceTimerRef.current); - } - autoAdvanceTimerRef.current = window.setTimeout(() => { - autoAdvanceTimerRef.current = null; - onAdvance(); - }, 200); - }, - [onSelectOption, onAdvance], - ); - - // Keyboard shortcut: number keys 1-9 select corresponding option and auto-advance. - // Works even when the Lexical composer (contenteditable) has focus — the composer - // doubles as a custom-answer field during user input, and when it's empty the digit - // keys should pick options instead of typing into the editor. - useEffect(() => { - if (!activeQuestion || isResponding) return; - const handler = (event: globalThis.KeyboardEvent) => { - if (event.metaKey || event.ctrlKey || event.altKey) return; - const target = event.target; - if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { - return; - } - // If the user has started typing a custom answer in the contenteditable - // composer, let digit keys pass through so they can type numbers. - if (target instanceof HTMLElement && target.isContentEditable) { - const hasCustomText = progress.customAnswer.length > 0; - if (hasCustomText) return; - } - const digit = Number.parseInt(event.key, 10); - if (Number.isNaN(digit) || digit < 1 || digit > 9) return; - const optionIndex = digit - 1; - if (optionIndex >= activeQuestion.options.length) return; - const option = activeQuestion.options[optionIndex]; - if (!option) return; - event.preventDefault(); - selectOptionAndAutoAdvance(activeQuestion.id, option.label); - }; - document.addEventListener("keydown", handler); - return () => document.removeEventListener("keydown", handler); - }, [activeQuestion, isResponding, selectOptionAndAutoAdvance, progress.customAnswer.length]); - - if (!activeQuestion) { - return null; - } - - return ( -
-
-
- {prompt.questions.length > 1 ? ( - - {questionIndex + 1}/{prompt.questions.length} - - ) : null} - - {activeQuestion.header} - -
-
-

{activeQuestion.question}

-
- {activeQuestion.options.map((option, index) => { - const isSelected = progress.selectedOptionLabel === option.label; - const shortcutKey = index < 9 ? index + 1 : null; - return ( - - ); - })} -
-
- ); -}); - -const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({ - planTitle, -}: { - planTitle: string | null; -}) { - return ( -
-
- Plan ready - {planTitle ? ( - {planTitle} - ) : null} -
- {/*
- Review the plan -
*/} -
- ); -}); - -const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(() => { - void navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }, [text]); - - return ( - - ); -}); - -function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean { - return stat.additions > 0 || stat.deletions > 0; -} - -const DiffStatLabel = memo(function DiffStatLabel(props: { - additions: number; - deletions: number; - showParentheses?: boolean; -}) { - const { additions, deletions, showParentheses = false } = props; - return ( - <> - {showParentheses && (} - +{additions} - / - -{deletions} - {showParentheses && )} - - ); -}); - -function collectDirectoryPaths(nodes: ReadonlyArray): string[] { - const paths: string[] = []; - for (const node of nodes) { - if (node.kind !== "directory") continue; - paths.push(node.path); - paths.push(...collectDirectoryPaths(node.children)); - } - return paths; -} - -function buildDirectoryExpansionState( - directoryPaths: ReadonlyArray, - expanded: boolean, -): Record { - const expandedState: Record = {}; - for (const directoryPath of directoryPaths) { - expandedState[directoryPath] = expanded; - } - return expandedState; -} - -const ChangedFilesTree = memo(function ChangedFilesTree(props: { - turnId: TurnId; - files: ReadonlyArray; - allDirectoriesExpanded: boolean; - resolvedTheme: "light" | "dark"; - onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; -}) { - const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props; - const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); - const directoryPathsKey = useMemo( - () => collectDirectoryPaths(treeNodes).join("\u0000"), - [treeNodes], - ); - const allDirectoryExpansionState = useMemo( - () => - buildDirectoryExpansionState( - directoryPathsKey ? directoryPathsKey.split("\u0000") : [], - allDirectoriesExpanded, - ), - [allDirectoriesExpanded, directoryPathsKey], - ); - const [expandedDirectories, setExpandedDirectories] = useState>(() => - buildDirectoryExpansionState(directoryPathsKey ? directoryPathsKey.split("\u0000") : [], true), - ); - useEffect(() => { - setExpandedDirectories(allDirectoryExpansionState); - }, [allDirectoryExpansionState]); - - const toggleDirectory = useCallback((pathValue: string, fallbackExpanded: boolean) => { - setExpandedDirectories((current) => ({ - ...current, - [pathValue]: !(current[pathValue] ?? fallbackExpanded), - })); - }, []); - - const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => { - const leftPadding = 8 + depth * 14; - if (node.kind === "directory") { - const isExpanded = expandedDirectories[node.path] ?? depth === 0; - return ( -
- - {isExpanded && ( -
- {node.children.map((childNode) => renderTreeNode(childNode, depth + 1))} -
- )} -
- ); - } - - return ( - - ); - }; - - return
{treeNodes.map((node) => renderTreeNode(node, 0))}
; -}); - -const ProposedPlanCard = memo(function ProposedPlanCard({ - planMarkdown, - cwd, - workspaceRoot, -}: { - planMarkdown: string; - cwd: string | undefined; - workspaceRoot: string | undefined; -}) { - const [expanded, setExpanded] = useState(false); - const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); - const [savePath, setSavePath] = useState(""); - const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); - const savePathInputId = useId(); - const title = proposedPlanTitle(planMarkdown) ?? "Proposed plan"; - const lineCount = planMarkdown.split("\n").length; - const canCollapse = planMarkdown.length > 900 || lineCount > 20; - const displayedPlanMarkdown = stripDisplayedPlanMarkdown(planMarkdown); - const collapsedPreview = canCollapse - ? buildCollapsedProposedPlanPreviewMarkdown(planMarkdown, { maxLines: 10 }) - : null; - const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown); - const saveContents = normalizePlanMarkdownForExport(planMarkdown); - - const handleDownload = () => { - downloadPlanAsTextFile(downloadFilename, saveContents); - }; - - const openSaveDialog = () => { - if (!workspaceRoot) { - toastManager.add({ - type: "error", - title: "Workspace path is unavailable", - description: "This thread does not have a workspace path to save into.", - }); - return; - } - setSavePath((existing) => (existing.length > 0 ? existing : downloadFilename)); - setIsSaveDialogOpen(true); - }; - - const handleSaveToWorkspace = () => { - const api = readNativeApi(); - const relativePath = savePath.trim(); - if (!api || !workspaceRoot) { - return; - } - if (!relativePath) { - toastManager.add({ - type: "warning", - title: "Enter a workspace path", - }); - return; - } - - setIsSavingToWorkspace(true); - void api.projects - .writeFile({ - cwd: workspaceRoot, - relativePath, - contents: saveContents, - }) - .then((result) => { - setIsSaveDialogOpen(false); - toastManager.add({ - type: "success", - title: "Plan saved to workspace", - description: result.relativePath, - }); - }) - .catch((error) => { - toastManager.add({ - type: "error", - title: "Could not save plan", - description: error instanceof Error ? error.message : "An error occurred while saving.", - }); - }) - .then( - () => { - setIsSavingToWorkspace(false); - }, - () => { - setIsSavingToWorkspace(false); - }, - ); - }; - - return ( -
-
-
- Plan -

{title}

-
- - } - > - - - Download as markdown - - Save to workspace - - - -
-
-
- {canCollapse && !expanded ? ( - - ) : ( - - )} - {canCollapse && !expanded ? ( -
- ) : null} -
- {canCollapse ? ( -
- -
- ) : null} -
- - { - if (!isSavingToWorkspace) { - setIsSaveDialogOpen(open); - } - }} - > - - - Save plan to workspace - - Enter a path relative to {workspaceRoot ?? "the workspace"}. - - - - - - - - - - - -
- ); -}); - -interface MessagesTimelineProps { - hasMessages: boolean; - isWorking: boolean; - activeTurnInProgress: boolean; - activeTurnStartedAt: string | null; - scrollContainer: HTMLDivElement | null; - timelineEntries: ReturnType; - completionDividerBeforeEntryId: string | null; - completionSummary: string | null; - turnDiffSummaryByAssistantMessageId: Map; - nowIso: string; - expandedWorkGroups: Record; - onToggleWorkGroup: (groupId: string) => void; - onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; - revertTurnCountByUserMessageId: Map; - onRevertUserMessage: (messageId: MessageId) => void; - isRevertingCheckpoint: boolean; - onImageExpand: (preview: ExpandedImagePreview) => void; - markdownCwd: string | undefined; - resolvedTheme: "light" | "dark"; - workspaceRoot: string | undefined; -} - -type TimelineEntry = ReturnType[number]; -type TimelineMessage = Extract["message"]; -type TimelineProposedPlan = Extract["proposedPlan"]; -type TimelineWorkEntry = Extract["entry"]; -type TimelineRow = - | { - kind: "work"; - id: string; - createdAt: string; - groupedEntries: TimelineWorkEntry[]; - } - | { - kind: "message"; - id: string; - createdAt: string; - message: TimelineMessage; - showCompletionDivider: boolean; - } - | { - kind: "proposed-plan"; - id: string; - createdAt: string; - proposedPlan: TimelineProposedPlan; - } - | { kind: "working"; id: string; createdAt: string | null }; - -function estimateTimelineProposedPlanHeight(proposedPlan: TimelineProposedPlan): number { - const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72)); - return 120 + Math.min(estimatedLines * 22, 880); -} - -const MessagesTimeline = memo(function MessagesTimeline({ - hasMessages, - isWorking, - activeTurnInProgress, - activeTurnStartedAt, - scrollContainer, - timelineEntries, - completionDividerBeforeEntryId, - completionSummary, - turnDiffSummaryByAssistantMessageId, - nowIso, - expandedWorkGroups, - onToggleWorkGroup, - onOpenTurnDiff, - revertTurnCountByUserMessageId, - onRevertUserMessage, - isRevertingCheckpoint, - onImageExpand, - markdownCwd, - resolvedTheme, - workspaceRoot, -}: MessagesTimelineProps) { - const timelineRootRef = useRef(null); - const [timelineWidthPx, setTimelineWidthPx] = useState(null); - - useLayoutEffect(() => { - const timelineRoot = timelineRootRef.current; - if (!timelineRoot) return; - - const updateWidth = (nextWidth: number) => { - setTimelineWidthPx((previousWidth) => { - if (previousWidth !== null && Math.abs(previousWidth - nextWidth) < 0.5) { - return previousWidth; - } - return nextWidth; - }); - }; - - updateWidth(timelineRoot.getBoundingClientRect().width); - - if (typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(() => { - updateWidth(timelineRoot.getBoundingClientRect().width); - }); - observer.observe(timelineRoot); - return () => { - observer.disconnect(); - }; - }, [hasMessages, isWorking]); - - const rows = useMemo(() => { - const nextRows: TimelineRow[] = []; - - for (let index = 0; index < timelineEntries.length; index += 1) { - const timelineEntry = timelineEntries[index]; - if (!timelineEntry) { - continue; - } - - 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; - } - nextRows.push({ - kind: "work", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - groupedEntries, - }); - index = cursor - 1; - continue; - } - - if (timelineEntry.kind === "proposed-plan") { - nextRows.push({ - kind: "proposed-plan", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - proposedPlan: timelineEntry.proposedPlan, - }); - continue; - } - - nextRows.push({ - kind: "message", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - message: timelineEntry.message, - showCompletionDivider: - timelineEntry.message.role === "assistant" && - completionDividerBeforeEntryId === timelineEntry.id, - }); - } - - if (isWorking) { - nextRows.push({ - kind: "working", - id: "working-indicator-row", - createdAt: activeTurnStartedAt, - }); - } - - return nextRows; - }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]); - - const firstUnvirtualizedRowIndex = useMemo(() => { - const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); - if (!activeTurnInProgress) return firstTailRowIndex; - - const turnStartedAtMs = - typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN; - let firstCurrentTurnRowIndex = -1; - if (!Number.isNaN(turnStartedAtMs)) { - firstCurrentTurnRowIndex = rows.findIndex((row) => { - if (row.kind === "working") return true; - if (!row.createdAt) return false; - const rowCreatedAtMs = Date.parse(row.createdAt); - return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs; - }); - } - - if (firstCurrentTurnRowIndex < 0) { - firstCurrentTurnRowIndex = rows.findIndex( - (row) => row.kind === "message" && row.message.streaming, - ); - } - - if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex; - - for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) { - const previousRow = rows[index]; - if (!previousRow || previousRow.kind !== "message") continue; - if (previousRow.message.role === "user") { - return Math.min(index, firstTailRowIndex); - } - if (previousRow.message.role === "assistant" && !previousRow.message.streaming) { - break; - } - } - - return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex); - }, [activeTurnInProgress, activeTurnStartedAt, rows]); - - const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, { - minimum: 0, - maximum: rows.length, - }); - - const rowVirtualizer = useVirtualizer({ - count: virtualizedRowCount, - getScrollElement: () => scrollContainer, - // Use stable row ids so virtual measurements do not leak across thread switches. - getItemKey: (index: number) => rows[index]?.id ?? index, - estimateSize: (index: number) => { - const row = rows[index]; - if (!row) return 96; - if (row.kind === "work") return 112; - if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan); - if (row.kind === "working") return 40; - return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); - }, - measureElement: measureVirtualElement, - useAnimationFrameWithResizeObserver: true, - overscan: 8, - }); - useEffect(() => { - if (timelineWidthPx === null) return; - rowVirtualizer.measure(); - }, [rowVirtualizer, timelineWidthPx]); - useEffect(() => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { - const viewportHeight = instance.scrollRect?.height ?? 0; - const scrollOffset = instance.scrollOffset ?? 0; - const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); - return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; - }; - return () => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; - }; - }, [rowVirtualizer]); - const pendingMeasureFrameRef = useRef(null); - const onTimelineImageLoad = useCallback(() => { - if (pendingMeasureFrameRef.current !== null) return; - pendingMeasureFrameRef.current = window.requestAnimationFrame(() => { - pendingMeasureFrameRef.current = null; - rowVirtualizer.measure(); - }); - }, [rowVirtualizer]); - useEffect(() => { - return () => { - const frame = pendingMeasureFrameRef.current; - if (frame !== null) { - window.cancelAnimationFrame(frame); - } - }; - }, []); - - const virtualRows = rowVirtualizer.getVirtualItems(); - const nonVirtualizedRows = rows.slice(virtualizedRowCount); - const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState< - Record - >({}); - const onToggleAllDirectories = useCallback((turnId: TurnId) => { - setAllDirectoriesExpandedByTurnId((current) => ({ - ...current, - [turnId]: !(current[turnId] ?? true), - })); - }, []); - - const renderRowContent = (row: TimelineRow) => ( -
- {row.kind === "work" && - (() => { - const groupId = row.id; - const groupedEntries = row.groupedEntries; - 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.label} -

- {workEntry.command && ( -
-                          {workEntry.command}
-                        
- )} - {workEntry.changedFiles && workEntry.changedFiles.length > 0 && ( -
- {workEntry.changedFiles.slice(0, 6).map((filePath) => ( - - {filePath} - - ))} - {workEntry.changedFiles.length > 6 && ( - - +{workEntry.changedFiles.length - 6} more - - )} -
- )} - {workEntry.detail && - (!workEntry.command || workEntry.detail !== workEntry.command) && ( -

- {workEntry.detail} -

- )} -
-
- ))} -
-
- ); - })()} - - {row.kind === "message" && - row.message.role === "user" && - (() => { - const userImages = row.message.attachments ?? []; - const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); - return ( -
-
- {userImages.length > 0 && ( -
- {userImages.map( - (image: NonNullable[number]) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} -
- ), - )} -
- )} - {row.message.text && ( -
-                    {row.message.text}
-                  
- )} -
-
- {row.message.text && } - {canRevertAgentWork && ( - - )} -
-

- {formatTimestamp(row.message.createdAt)} -

-
-
-
- ); - })()} - - {row.kind === "message" && - row.message.role === "assistant" && - (() => { - const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); - return ( - <> - {row.showCompletionDivider && ( -
- - - {completionSummary ? `Response • ${completionSummary}` : "Response"} - - -
- )} -
- - {(() => { - const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); - if (!turnSummary) return null; - const checkpointFiles = turnSummary.files; - if (checkpointFiles.length === 0) return null; - const summaryStat = summarizeTurnDiffStats(checkpointFiles); - const changedFileCountLabel = String(checkpointFiles.length); - const allDirectoriesExpanded = - allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true; - return ( -
-
-

- Changed files ({changedFileCountLabel}) - {hasNonZeroStat(summaryStat) && ( - <> - - - - )} -

-
- - -
-
- -
- ); - })()} -

- {formatMessageMeta( - row.message.createdAt, - row.message.streaming - ? formatElapsed(row.message.createdAt, nowIso) - : formatElapsed(row.message.createdAt, row.message.completedAt), - )} -

-
- - ); - })()} - - {row.kind === "proposed-plan" && ( -
- -
- )} - - {row.kind === "working" && ( -
-
- - - - - - - {row.createdAt - ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}` - : "Working..."} - -
-
- )} -
- ); - - if (!hasMessages && !isWorking) { - return ( -
-

- Send a message to start the conversation. -

-
- ); - } - - return ( -
- {virtualizedRowCount > 0 && ( -
- {virtualRows.map((virtualRow: VirtualItem) => { - const row = rows[virtualRow.index]; - if (!row) return null; - - return ( -
- {renderRowContent(row)} -
- ); - })} -
- )} - - {nonVirtualizedRows.map((row) => ( -
{renderRowContent(row)}
- ))} -
- ); -}); - -function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { - value: ProviderKind; - label: string; - available: true; -} { - return option.available && option.value !== "claudeCode"; -} - -const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); -const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); -const COMING_SOON_PROVIDER_OPTIONS = [ - { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, - { id: "gemini", label: "Gemini", icon: Gemini }, -] as const; - -function getCustomModelOptionsByProvider(settings: { - customCodexModels: readonly string[]; -}): Record> { - return { - codex: getAppModelOptions("codex", settings.customCodexModels), - }; -} - -const PROVIDER_ICON_BY_PROVIDER: Record = { - codex: OpenAI, - claudeCode: ClaudeAI, - cursor: CursorIcon, -}; - -function resolveModelForProviderPicker( - provider: ProviderKind, - value: string, - options: ReadonlyArray<{ slug: string; name: string }>, -): ModelSlug | null { - const trimmedValue = value.trim(); - if (!trimmedValue) { - return null; - } - - const direct = options.find((option) => option.slug === trimmedValue); - if (direct) { - return direct.slug; - } - - const byName = options.find((option) => option.name.toLowerCase() === trimmedValue.toLowerCase()); - if (byName) { - return byName.slug; - } - - const normalized = normalizeModelSlug(trimmedValue, provider); - if (!normalized) { - return null; - } - - const resolved = options.find((option) => option.slug === normalized); - if (resolved) { - return resolved.slug; - } - - return null; -} - -const ProviderModelPicker = memo(function ProviderModelPicker(props: { - provider: ProviderKind; - model: ModelSlug; - lockedProvider: ProviderKind | null; - modelOptionsByProvider: Record>; - compact?: boolean; - disabled?: boolean; - onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; -}) { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const selectedProviderOptions = props.modelOptionsByProvider[props.provider]; - const selectedModelLabel = - selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; - const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; - - return ( - { - if (props.disabled) { - setIsMenuOpen(false); - return; - } - setIsMenuOpen(open); - }} - > - - } - > - - - - - {AVAILABLE_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; - const isDisabledByProviderLock = - props.lockedProvider !== null && props.lockedProvider !== option.value; - return ( - - - - - - { - if (props.disabled) return; - if (isDisabledByProviderLock) return; - if (!value) return; - const resolvedModel = resolveModelForProviderPicker( - option.value, - value, - props.modelOptionsByProvider[option.value], - ); - if (!resolvedModel) return; - props.onProviderModelChange(option.value, resolvedModel); - setIsMenuOpen(false); - }} - > - {props.modelOptionsByProvider[option.value].map((modelOption) => ( - setIsMenuOpen(false)} - > - {modelOption.name} - - ))} - - - - - ); - })} - {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && } - {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; - return ( - - - ); - })} - {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && } - {COMING_SOON_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = option.icon; - return ( - - - ); - })} - - - ); -}); - -const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { - activePlan: boolean; - interactionMode: ProviderInteractionMode; - planSidebarOpen: boolean; - runtimeMode: RuntimeMode; - selectedEffort: CodexReasoningEffort | null; - selectedProvider: ProviderKind; - selectedCodexFastModeEnabled: boolean; - reasoningOptions: ReadonlyArray; - onEffortSelect: (effort: CodexReasoningEffort) => void; - onCodexFastModeChange: (enabled: boolean) => void; - onToggleInteractionMode: () => void; - onTogglePlanSidebar: () => void; - onToggleRuntimeMode: () => void; -}) { - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningLabelByOption: Record = { - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", - }; - - return ( - - - } - > - - - {props.selectedProvider === "codex" && props.selectedEffort != null ? ( - <> - -
Reasoning
- { - if (!value) return; - const nextEffort = props.reasoningOptions.find((option) => option === value); - if (!nextEffort) return; - props.onEffortSelect(nextEffort); - }} - > - {props.reasoningOptions.map((effort) => ( - - {reasoningLabelByOption[effort]} - {effort === defaultReasoningEffort ? " (default)" : ""} - - ))} - -
- - -
Fast Mode
- { - props.onCodexFastModeChange(value === "on"); - }} - > - off - on - -
- - - ) : null} - -
Mode
- { - if (!value || value === props.interactionMode) return; - props.onToggleInteractionMode(); - }} - > - Chat - Plan - -
- - -
Access
- { - if (!value || value === props.runtimeMode) return; - props.onToggleRuntimeMode(); - }} - > - Supervised - Full access - -
- {props.activePlan ? ( - <> - - - - {props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"} - - - ) : null} -
-
- ); -}); - -const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { - effort: CodexReasoningEffort; - fastModeEnabled: boolean; - options: ReadonlyArray; - onEffortChange: (effort: CodexReasoningEffort) => void; - onFastModeChange: (enabled: boolean) => void; -}) { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningLabelByOption: Record = { - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", - }; - const triggerLabel = [ - reasoningLabelByOption[props.effort], - ...(props.fastModeEnabled ? ["Fast"] : []), - ] - .filter(Boolean) - .join(" · "); - - return ( - { - setIsMenuOpen(open); - }} - > - - } - > - {triggerLabel} - - - -
Reasoning
- { - if (!value) return; - const nextEffort = props.options.find((option) => option === value); - if (!nextEffort) return; - props.onEffortChange(nextEffort); - }} - > - {props.options.map((effort) => ( - - {reasoningLabelByOption[effort]} - {effort === defaultReasoningEffort ? " (default)" : ""} - - ))} - -
- - -
Fast Mode
- { - props.onFastModeChange(value === "on"); - }} - > - off - on - -
-
-
- ); -}); - -const OpenInPicker = memo(function OpenInPicker({ - keybindings, - availableEditors, - openInCwd, -}: { - keybindings: ResolvedKeybindingsConfig; - availableEditors: ReadonlyArray; - openInCwd: string | null; -}) { - 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 allOptions = useMemo>( - () => [ - { - label: "Cursor", - Icon: CursorIcon, - value: "cursor", - }, - { - label: "VS Code", - Icon: VisualStudioCode, - value: "vscode", - }, - { - label: "Zed", - Icon: Zed, - value: "zed", - }, - { - label: isMacPlatform(navigator.platform) - ? "Finder" - : isWindowsPlatform(navigator.platform) - ? "Explorer" - : "Files", - Icon: FolderClosedIcon, - value: "file-manager", - }, - ], - [], - ); - const options = useMemo( - () => allOptions.filter((option) => availableEditors.includes(option.value)), - [allOptions, availableEditors], - ); - - const effectiveEditor = options.some((option) => option.value === lastEditor) - ? lastEditor - : (options[0]?.value ?? null); - const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; - - const openInEditor = useCallback( - (editorId: EditorId | null) => { - const api = readNativeApi(); - if (!api || !openInCwd) return; - const editor = editorId ?? effectiveEditor; - if (!editor) return; - void api.shell.openInEditor(openInCwd, editor); - localStorage.setItem(LAST_EDITOR_KEY, editor); - setLastEditor(editor); - }, - [effectiveEditor, openInCwd, setLastEditor], - ); - - const openFavoriteEditorShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "editor.openFavorite"), - [keybindings], - ); - - useEffect(() => { - const handler = (e: globalThis.KeyboardEvent) => { - const api = readNativeApi(); - if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; - if (!api || !openInCwd) return; - if (!effectiveEditor) return; - - e.preventDefault(); - void api.shell.openInEditor(openInCwd, effectiveEditor); - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [effectiveEditor, keybindings, openInCwd]); - - return ( - - - - - }> - - - {options.length === 0 && No installed editors found} - {options.map(({ label, Icon, value }) => ( - openInEditor(value)}> - - ))} - - - - ); -}); diff --git a/apps/web/src/components/CodexTraitsPicker.tsx b/apps/web/src/components/CodexTraitsPicker.tsx new file mode 100644 index 0000000000..2dd8dbaaf4 --- /dev/null +++ b/apps/web/src/components/CodexTraitsPicker.tsx @@ -0,0 +1,99 @@ +import { type CodexReasoningEffort } from "@t3tools/contracts"; +import { getDefaultReasoningEffort } from "@t3tools/shared/model"; +import { memo, useState } from "react"; +import { ChevronDownIcon } from "lucide-react"; +import { Button } from "./ui/button"; +import { + Menu, + MenuGroup, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator as MenuDivider, + MenuTrigger, +} from "./ui/menu"; + +export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { + effort: CodexReasoningEffort; + fastModeEnabled: boolean; + options: ReadonlyArray; + onEffortChange: (effort: CodexReasoningEffort) => void; + onFastModeChange: (enabled: boolean) => void; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const defaultReasoningEffort = getDefaultReasoningEffort("codex"); + const reasoningLabelByOption: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + }; + const triggerLabel = [ + reasoningLabelByOption[props.effort], + ...(props.fastModeEnabled ? ["Fast"] : []), + ] + .filter(Boolean) + .join(" · "); + + return ( + { + setIsMenuOpen(open); + }} + > + + } + > + {triggerLabel} + + + +
+ Reasoning +
+ { + if (!value) return; + const nextEffort = props.options.find( + (option) => option === value, + ); + if (!nextEffort) return; + props.onEffortChange(nextEffort); + }} + > + {props.options.map((effort) => ( + + {reasoningLabelByOption[effort]} + {effort === defaultReasoningEffort ? " (default)" : ""} + + ))} + +
+ + +
+ Fast Mode +
+ { + props.onFastModeChange(value === "on"); + }} + > + off + on + +
+
+
+ ); +}); diff --git a/apps/web/src/components/CompactComposerControlsMenu.tsx b/apps/web/src/components/CompactComposerControlsMenu.tsx new file mode 100644 index 0000000000..c12a4912b9 --- /dev/null +++ b/apps/web/src/components/CompactComposerControlsMenu.tsx @@ -0,0 +1,153 @@ +import { + type CodexReasoningEffort, + type ProviderKind, + RuntimeMode, + ProviderInteractionMode, +} from "@t3tools/contracts"; +import { getDefaultReasoningEffort } from "@t3tools/shared/model"; +import { memo } from "react"; +import { EllipsisIcon, ListTodoIcon } from "lucide-react"; +import { Button } from "./ui/button"; +import { + Menu, + MenuGroup, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator as MenuDivider, + MenuTrigger, +} from "./ui/menu"; + +export const CompactComposerControlsMenu = memo( + function CompactComposerControlsMenu(props: { + activePlan: boolean; + interactionMode: ProviderInteractionMode; + planSidebarOpen: boolean; + runtimeMode: RuntimeMode; + selectedEffort: CodexReasoningEffort | null; + selectedProvider: ProviderKind; + selectedCodexFastModeEnabled: boolean; + reasoningOptions: ReadonlyArray; + onEffortSelect: (effort: CodexReasoningEffort) => void; + onCodexFastModeChange: (enabled: boolean) => void; + onToggleInteractionMode: () => void; + onTogglePlanSidebar: () => void; + onToggleRuntimeMode: () => void; + }) { + const defaultReasoningEffort = getDefaultReasoningEffort("codex"); + const reasoningLabelByOption: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + }; + + return ( + + + } + > + + + {props.selectedProvider === "codex" && + props.selectedEffort != null ? ( + <> + +
+ Reasoning +
+ { + if (!value) return; + const nextEffort = props.reasoningOptions.find( + (option) => option === value, + ); + if (!nextEffort) return; + props.onEffortSelect(nextEffort); + }} + > + {props.reasoningOptions.map((effort) => ( + + {reasoningLabelByOption[effort]} + {effort === defaultReasoningEffort ? " (default)" : ""} + + ))} + +
+ + +
+ Fast Mode +
+ { + props.onCodexFastModeChange(value === "on"); + }} + > + off + on + +
+ + + ) : null} + +
+ Mode +
+ { + if (!value || value === props.interactionMode) return; + props.onToggleInteractionMode(); + }} + > + Chat + Plan + +
+ + +
+ Access +
+ { + if (!value || value === props.runtimeMode) return; + props.onToggleRuntimeMode(); + }} + > + + Supervised + + Full access + +
+ {props.activePlan ? ( + <> + + + + {props.planSidebarOpen + ? "Hide plan sidebar" + : "Show plan sidebar"} + + + ) : null} +
+
+ ); + }, +); diff --git a/apps/web/src/components/ComposerCommandMenu.tsx b/apps/web/src/components/ComposerCommandMenu.tsx new file mode 100644 index 0000000000..15c915f286 --- /dev/null +++ b/apps/web/src/components/ComposerCommandMenu.tsx @@ -0,0 +1,129 @@ +import { + type ProjectEntry, + type ModelSlug, + type ProviderKind, +} from "@t3tools/contracts"; +import { memo } from "react"; +import { + type ComposerSlashCommand, + type ComposerTriggerKind, +} from "../composer-logic"; +import { BotIcon } from "lucide-react"; +import { cn } from "~/lib/utils"; +import { Badge } from "./ui/badge"; +import { Command, CommandItem, CommandList } from "./ui/command"; +import { VscodeEntryIcon } from "./VscodeEntryIcon"; + +export type ComposerCommandItem = + | { + id: string; + type: "path"; + path: string; + pathKind: ProjectEntry["kind"]; + label: string; + description: string; + } + | { + id: string; + type: "slash-command"; + command: ComposerSlashCommand; + label: string; + description: string; + } + | { + id: string; + type: "model"; + provider: ProviderKind; + model: ModelSlug; + label: string; + description: string; + }; + +export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { + items: ComposerCommandItem[]; + resolvedTheme: "light" | "dark"; + isLoading: boolean; + triggerKind: ComposerTriggerKind | null; + activeItemId: string | null; + onHighlightedItemChange: (itemId: string | null) => void; + onSelect: (item: ComposerCommandItem) => void; +}) { + return ( + { + props.onHighlightedItemChange( + typeof highlightedValue === "string" ? highlightedValue : null, + ); + }} + > +
+ + {props.items.map((item) => ( + + ))} + + {props.items.length === 0 && ( +

+ {props.isLoading + ? "Searching workspace files..." + : props.triggerKind === "path" + ? "No matching files or folders." + : "No matching command."} +

+ )} +
+
+ ); +}); + +const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { + item: ComposerCommandItem; + resolvedTheme: "light" | "dark"; + isActive: boolean; + onSelect: (item: ComposerCommandItem) => void; +}) { + return ( + { + event.preventDefault(); + }} + onClick={() => { + props.onSelect(props.item); + }} + > + {props.item.type === "path" ? ( + + ) : null} + {props.item.type === "slash-command" ? ( + + ) : null} + {props.item.type === "model" ? ( + + model + + ) : null} + + {props.item.label} + + + {props.item.description} + + + ); +}); diff --git a/apps/web/src/components/ComposerPendingApprovalActions.tsx b/apps/web/src/components/ComposerPendingApprovalActions.tsx new file mode 100644 index 0000000000..1aedbb8798 --- /dev/null +++ b/apps/web/src/components/ComposerPendingApprovalActions.tsx @@ -0,0 +1,62 @@ +import { + type ApprovalRequestId, + type ProviderApprovalDecision, +} from "@t3tools/contracts"; +import { memo } from "react"; +import { Button } from "./ui/button"; + +interface ComposerPendingApprovalActionsProps { + requestId: ApprovalRequestId; + isResponding: boolean; + onRespondToApproval: ( + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, + ) => Promise; +} + +export const ComposerPendingApprovalActions = memo( + function ComposerPendingApprovalActions({ + requestId, + isResponding, + onRespondToApproval, + }: ComposerPendingApprovalActionsProps) { + return ( + <> + + + + + + ); + }, +); diff --git a/apps/web/src/components/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/ComposerPendingApprovalPanel.tsx new file mode 100644 index 0000000000..854678be9b --- /dev/null +++ b/apps/web/src/components/ComposerPendingApprovalPanel.tsx @@ -0,0 +1,37 @@ +import { memo } from "react"; +import { type PendingApproval } from "../session-logic"; + +interface ComposerPendingApprovalPanelProps { + approval: PendingApproval; + pendingCount: number; +} + +export const ComposerPendingApprovalPanel = memo( + function ComposerPendingApprovalPanel({ + approval, + pendingCount, + }: ComposerPendingApprovalPanelProps) { + const approvalSummary = + approval.requestKind === "command" + ? "Command approval requested" + : approval.requestKind === "file-read" + ? "File-read approval requested" + : "File-change approval requested"; + + return ( +
+
+ + PENDING APPROVAL + + {approvalSummary} + {pendingCount > 1 ? ( + + 1/{pendingCount} + + ) : null} +
+
+ ); + }, +); diff --git a/apps/web/src/components/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/ComposerPendingUserInputPanel.tsx new file mode 100644 index 0000000000..45b16f6e6f --- /dev/null +++ b/apps/web/src/components/ComposerPendingUserInputPanel.tsx @@ -0,0 +1,204 @@ +import { type ApprovalRequestId } from "@t3tools/contracts"; +import { memo, useCallback, useEffect, useRef } from "react"; +import { type PendingUserInput } from "../session-logic"; +import { + derivePendingUserInputProgress, + type PendingUserInputDraftAnswer, +} from "../pendingUserInput"; +import { CheckIcon } from "lucide-react"; +import { cn } from "~/lib/utils"; + +interface PendingUserInputPanelProps { + pendingUserInputs: PendingUserInput[]; + respondingRequestIds: ApprovalRequestId[]; + answers: Record; + questionIndex: number; + onSelectOption: (questionId: string, optionLabel: string) => void; + onAdvance: () => void; +} + +export const ComposerPendingUserInputPanel = memo( + function ComposerPendingUserInputPanel({ + pendingUserInputs, + respondingRequestIds, + answers, + questionIndex, + onSelectOption, + onAdvance, + }: PendingUserInputPanelProps) { + if (pendingUserInputs.length === 0) return null; + const activePrompt = pendingUserInputs[0]; + if (!activePrompt) return null; + + return ( + + ); + }, +); + +const ComposerPendingUserInputCard = memo( + function ComposerPendingUserInputCard({ + prompt, + isResponding, + answers, + questionIndex, + onSelectOption, + onAdvance, + }: { + prompt: PendingUserInput; + isResponding: boolean; + answers: Record; + questionIndex: number; + onSelectOption: (questionId: string, optionLabel: string) => void; + onAdvance: () => void; + }) { + const progress = derivePendingUserInputProgress( + prompt.questions, + answers, + questionIndex, + ); + const activeQuestion = progress.activeQuestion; + const autoAdvanceTimerRef = useRef(null); + + // Clear auto-advance timer on unmount + useEffect(() => { + return () => { + if (autoAdvanceTimerRef.current !== null) { + window.clearTimeout(autoAdvanceTimerRef.current); + } + }; + }, []); + + const selectOptionAndAutoAdvance = useCallback( + (questionId: string, optionLabel: string) => { + onSelectOption(questionId, optionLabel); + if (autoAdvanceTimerRef.current !== null) { + window.clearTimeout(autoAdvanceTimerRef.current); + } + autoAdvanceTimerRef.current = window.setTimeout(() => { + autoAdvanceTimerRef.current = null; + onAdvance(); + }, 200); + }, + [onSelectOption, onAdvance], + ); + + // Keyboard shortcut: number keys 1-9 select corresponding option and auto-advance. + // Works even when the Lexical composer (contenteditable) has focus — the composer + // doubles as a custom-answer field during user input, and when it's empty the digit + // keys should pick options instead of typing into the editor. + useEffect(() => { + if (!activeQuestion || isResponding) return; + const handler = (event: globalThis.KeyboardEvent) => { + if (event.metaKey || event.ctrlKey || event.altKey) return; + const target = event.target; + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement + ) { + return; + } + // If the user has started typing a custom answer in the contenteditable + // composer, let digit keys pass through so they can type numbers. + if (target instanceof HTMLElement && target.isContentEditable) { + const hasCustomText = progress.customAnswer.length > 0; + if (hasCustomText) return; + } + const digit = Number.parseInt(event.key, 10); + if (Number.isNaN(digit) || digit < 1 || digit > 9) return; + const optionIndex = digit - 1; + if (optionIndex >= activeQuestion.options.length) return; + const option = activeQuestion.options[optionIndex]; + if (!option) return; + event.preventDefault(); + selectOptionAndAutoAdvance(activeQuestion.id, option.label); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [ + activeQuestion, + isResponding, + selectOptionAndAutoAdvance, + progress.customAnswer.length, + ]); + + if (!activeQuestion) { + return null; + } + + return ( +
+
+
+ {prompt.questions.length > 1 ? ( + + {questionIndex + 1}/{prompt.questions.length} + + ) : null} + + {activeQuestion.header} + +
+
+

+ {activeQuestion.question} +

+
+ {activeQuestion.options.map((option, index) => { + const isSelected = progress.selectedOptionLabel === option.label; + const shortcutKey = index < 9 ? index + 1 : null; + return ( + + ); + })} +
+
+ ); + }, +); diff --git a/apps/web/src/components/ComposerPlanFollowUpBanner.tsx b/apps/web/src/components/ComposerPlanFollowUpBanner.tsx new file mode 100644 index 0000000000..607fd8d20d --- /dev/null +++ b/apps/web/src/components/ComposerPlanFollowUpBanner.tsx @@ -0,0 +1,25 @@ +import { memo } from "react"; + +export const ComposerPlanFollowUpBanner = memo( + function ComposerPlanFollowUpBanner({ + planTitle, + }: { + planTitle: string | null; + }) { + return ( +
+
+ Plan ready + {planTitle ? ( + + {planTitle} + + ) : null} +
+ {/*
+ Review the plan +
*/} +
+ ); + }, +); diff --git a/apps/web/src/components/DiffStatLabel.tsx b/apps/web/src/components/DiffStatLabel.tsx new file mode 100644 index 0000000000..dab67662bf --- /dev/null +++ b/apps/web/src/components/DiffStatLabel.tsx @@ -0,0 +1,25 @@ +import { memo } from "react"; + +export function hasNonZeroStat(stat: { + additions: number; + deletions: number; +}): boolean { + return stat.additions > 0 || stat.deletions > 0; +} + +export const DiffStatLabel = memo(function DiffStatLabel(props: { + additions: number; + deletions: number; + showParentheses?: boolean; +}) { + const { additions, deletions, showParentheses = false } = props; + return ( + <> + {showParentheses && (} + +{additions} + / + -{deletions} + {showParentheses && )} + + ); +}); diff --git a/apps/web/src/components/ExpandedImagePreview.tsx b/apps/web/src/components/ExpandedImagePreview.tsx new file mode 100644 index 0000000000..9fc2aab815 --- /dev/null +++ b/apps/web/src/components/ExpandedImagePreview.tsx @@ -0,0 +1,36 @@ +export interface ExpandedImageItem { + src: string; + name: string; +} + +export interface ExpandedImagePreview { + images: ExpandedImageItem[]; + index: number; +} + +export function buildExpandedImagePreview( + images: ReadonlyArray<{ id: string; name: string; previewUrl?: string }>, + selectedImageId: string, +): ExpandedImagePreview | null { + const previewableImages = images.flatMap((image) => + image.previewUrl + ? [{ id: image.id, src: image.previewUrl, name: image.name }] + : [], + ); + if (previewableImages.length === 0) { + return null; + } + const selectedIndex = previewableImages.findIndex( + (image) => image.id === selectedImageId, + ); + if (selectedIndex < 0) { + return null; + } + return { + images: previewableImages.map((image) => ({ + src: image.src, + name: image.name, + })), + index: selectedIndex, + }; +} diff --git a/apps/web/src/components/MessageCopyButton.tsx b/apps/web/src/components/MessageCopyButton.tsx new file mode 100644 index 0000000000..f1d5d6dc1b --- /dev/null +++ b/apps/web/src/components/MessageCopyButton.tsx @@ -0,0 +1,33 @@ +import { memo, useCallback, useState } from "react"; +import { CopyIcon, CheckIcon } from "lucide-react"; +import { Button } from "./ui/button"; + +export const MessageCopyButton = memo(function MessageCopyButton({ + text, +}: { + text: string; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + void navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [text]); + + return ( + + ); +}); diff --git a/apps/web/src/components/MessagesTimeline.tsx b/apps/web/src/components/MessagesTimeline.tsx new file mode 100644 index 0000000000..431c508c38 --- /dev/null +++ b/apps/web/src/components/MessagesTimeline.tsx @@ -0,0 +1,747 @@ +import { type MessageId, type TurnId } from "@t3tools/contracts"; +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + measureElement as measureVirtualElement, + type VirtualItem, + useVirtualizer, +} from "@tanstack/react-virtual"; +import { + deriveTimelineEntries, + formatElapsed, + formatTimestamp, +} from "../session-logic"; +import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../chat-scroll"; +import { type TurnDiffSummary } from "../types"; +import { summarizeTurnDiffStats } from "../lib/turnDiffTree"; +import ChatMarkdown from "./ChatMarkdown"; +import { Undo2Icon } from "lucide-react"; +import { Button } from "./ui/button"; +import { clamp } from "effect/Number"; +import { estimateTimelineMessageHeight } from "./timelineHeight"; +import { + buildExpandedImagePreview, + ExpandedImagePreview, +} from "./ExpandedImagePreview"; +import { ProposedPlanCard } from "./ProposedPlanCard"; +import { ChangedFilesTree } from "./ChangedFilesTree"; +import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; +import { MessageCopyButton } from "./MessageCopyButton"; + +const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; +const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; + +interface MessagesTimelineProps { + hasMessages: boolean; + isWorking: boolean; + activeTurnInProgress: boolean; + activeTurnStartedAt: string | null; + scrollContainer: HTMLDivElement | null; + timelineEntries: ReturnType; + completionDividerBeforeEntryId: string | null; + completionSummary: string | null; + turnDiffSummaryByAssistantMessageId: Map; + nowIso: string; + expandedWorkGroups: Record; + onToggleWorkGroup: (groupId: string) => void; + onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; + revertTurnCountByUserMessageId: Map; + onRevertUserMessage: (messageId: MessageId) => void; + isRevertingCheckpoint: boolean; + onImageExpand: (preview: ExpandedImagePreview) => void; + markdownCwd: string | undefined; + resolvedTheme: "light" | "dark"; + workspaceRoot: string | undefined; +} + +export const MessagesTimeline = memo(function MessagesTimeline({ + hasMessages, + isWorking, + activeTurnInProgress, + activeTurnStartedAt, + scrollContainer, + timelineEntries, + completionDividerBeforeEntryId, + completionSummary, + turnDiffSummaryByAssistantMessageId, + nowIso, + expandedWorkGroups, + onToggleWorkGroup, + onOpenTurnDiff, + revertTurnCountByUserMessageId, + onRevertUserMessage, + isRevertingCheckpoint, + onImageExpand, + markdownCwd, + resolvedTheme, + workspaceRoot, +}: MessagesTimelineProps) { + const timelineRootRef = useRef(null); + const [timelineWidthPx, setTimelineWidthPx] = useState(null); + + useLayoutEffect(() => { + const timelineRoot = timelineRootRef.current; + if (!timelineRoot) return; + + const updateWidth = (nextWidth: number) => { + setTimelineWidthPx((previousWidth) => { + if ( + previousWidth !== null && + Math.abs(previousWidth - nextWidth) < 0.5 + ) { + return previousWidth; + } + return nextWidth; + }); + }; + + updateWidth(timelineRoot.getBoundingClientRect().width); + + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => { + updateWidth(timelineRoot.getBoundingClientRect().width); + }); + observer.observe(timelineRoot); + return () => { + observer.disconnect(); + }; + }, [hasMessages, isWorking]); + + const rows = useMemo(() => { + const nextRows: TimelineRow[] = []; + + for (let index = 0; index < timelineEntries.length; index += 1) { + const timelineEntry = timelineEntries[index]; + if (!timelineEntry) { + continue; + } + + 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; + } + nextRows.push({ + kind: "work", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + groupedEntries, + }); + index = cursor - 1; + continue; + } + + if (timelineEntry.kind === "proposed-plan") { + nextRows.push({ + kind: "proposed-plan", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + proposedPlan: timelineEntry.proposedPlan, + }); + continue; + } + + nextRows.push({ + kind: "message", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + message: timelineEntry.message, + showCompletionDivider: + timelineEntry.message.role === "assistant" && + completionDividerBeforeEntryId === timelineEntry.id, + }); + } + + if (isWorking) { + nextRows.push({ + kind: "working", + id: "working-indicator-row", + createdAt: activeTurnStartedAt, + }); + } + + return nextRows; + }, [ + timelineEntries, + completionDividerBeforeEntryId, + isWorking, + activeTurnStartedAt, + ]); + + const firstUnvirtualizedRowIndex = useMemo(() => { + const firstTailRowIndex = Math.max( + rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, + 0, + ); + if (!activeTurnInProgress) return firstTailRowIndex; + + const turnStartedAtMs = + typeof activeTurnStartedAt === "string" + ? Date.parse(activeTurnStartedAt) + : Number.NaN; + let firstCurrentTurnRowIndex = -1; + if (!Number.isNaN(turnStartedAtMs)) { + firstCurrentTurnRowIndex = rows.findIndex((row) => { + if (row.kind === "working") return true; + if (!row.createdAt) return false; + const rowCreatedAtMs = Date.parse(row.createdAt); + return ( + !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs + ); + }); + } + + if (firstCurrentTurnRowIndex < 0) { + firstCurrentTurnRowIndex = rows.findIndex( + (row) => row.kind === "message" && row.message.streaming, + ); + } + + if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex; + + for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) { + const previousRow = rows[index]; + if (!previousRow || previousRow.kind !== "message") continue; + if (previousRow.message.role === "user") { + return Math.min(index, firstTailRowIndex); + } + if ( + previousRow.message.role === "assistant" && + !previousRow.message.streaming + ) { + break; + } + } + + return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex); + }, [activeTurnInProgress, activeTurnStartedAt, rows]); + + const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, { + minimum: 0, + maximum: rows.length, + }); + + const rowVirtualizer = useVirtualizer({ + count: virtualizedRowCount, + getScrollElement: () => scrollContainer, + // Use stable row ids so virtual measurements do not leak across thread switches. + getItemKey: (index: number) => rows[index]?.id ?? index, + estimateSize: (index: number) => { + const row = rows[index]; + if (!row) return 96; + if (row.kind === "work") return 112; + if (row.kind === "proposed-plan") + return estimateTimelineProposedPlanHeight(row.proposedPlan); + if (row.kind === "working") return 40; + return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); + }, + measureElement: measureVirtualElement, + useAnimationFrameWithResizeObserver: true, + overscan: 8, + }); + useEffect(() => { + if (timelineWidthPx === null) return; + rowVirtualizer.measure(); + }, [rowVirtualizer, timelineWidthPx]); + useEffect(() => { + rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = ( + _item, + _delta, + instance, + ) => { + const viewportHeight = instance.scrollRect?.height ?? 0; + const scrollOffset = instance.scrollOffset ?? 0; + const remainingDistance = + instance.getTotalSize() - (scrollOffset + viewportHeight); + return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; + }; + return () => { + rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; + }; + }, [rowVirtualizer]); + const pendingMeasureFrameRef = useRef(null); + const onTimelineImageLoad = useCallback(() => { + if (pendingMeasureFrameRef.current !== null) return; + pendingMeasureFrameRef.current = window.requestAnimationFrame(() => { + pendingMeasureFrameRef.current = null; + rowVirtualizer.measure(); + }); + }, [rowVirtualizer]); + useEffect(() => { + return () => { + const frame = pendingMeasureFrameRef.current; + if (frame !== null) { + window.cancelAnimationFrame(frame); + } + }; + }, []); + + const virtualRows = rowVirtualizer.getVirtualItems(); + const nonVirtualizedRows = rows.slice(virtualizedRowCount); + const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = + useState>({}); + const onToggleAllDirectories = useCallback((turnId: TurnId) => { + setAllDirectoriesExpandedByTurnId((current) => ({ + ...current, + [turnId]: !(current[turnId] ?? true), + })); + }, []); + + const renderRowContent = (row: TimelineRow) => ( +
+ {row.kind === "work" && + (() => { + const groupId = row.id; + const groupedEntries = row.groupedEntries; + 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.label} +

+ {workEntry.command && ( +
+                          {workEntry.command}
+                        
+ )} + {workEntry.changedFiles && + workEntry.changedFiles.length > 0 && ( +
+ {workEntry.changedFiles + .slice(0, 6) + .map((filePath) => ( + + {filePath} + + ))} + {workEntry.changedFiles.length > 6 && ( + + +{workEntry.changedFiles.length - 6} more + + )} +
+ )} + {workEntry.detail && + (!workEntry.command || + workEntry.detail !== workEntry.command) && ( +

+ {workEntry.detail} +

+ )} +
+
+ ))} +
+
+ ); + })()} + + {row.kind === "message" && + row.message.role === "user" && + (() => { + const userImages = row.message.attachments ?? []; + const canRevertAgentWork = revertTurnCountByUserMessageId.has( + row.message.id, + ); + return ( +
+
+ {userImages.length > 0 && ( +
+ {userImages.map( + ( + image: NonNullable< + TimelineMessage["attachments"] + >[number], + ) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} +
+ ), + )} +
+ )} + {row.message.text && ( +
+                    {row.message.text}
+                  
+ )} +
+
+ {row.message.text && ( + + )} + {canRevertAgentWork && ( + + )} +
+

+ {formatTimestamp(row.message.createdAt)} +

+
+
+
+ ); + })()} + + {row.kind === "message" && + row.message.role === "assistant" && + (() => { + const messageText = + row.message.text || + (row.message.streaming ? "" : "(empty response)"); + return ( + <> + {row.showCompletionDivider && ( +
+ + + {completionSummary + ? `Response • ${completionSummary}` + : "Response"} + + +
+ )} +
+ + {(() => { + const turnSummary = turnDiffSummaryByAssistantMessageId.get( + row.message.id, + ); + if (!turnSummary) return null; + const checkpointFiles = turnSummary.files; + if (checkpointFiles.length === 0) return null; + const summaryStat = summarizeTurnDiffStats(checkpointFiles); + const changedFileCountLabel = String(checkpointFiles.length); + const allDirectoriesExpanded = + allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true; + return ( +
+
+

+ Changed files ({changedFileCountLabel}) + {hasNonZeroStat(summaryStat) && ( + <> + + + + )} +

+
+ + +
+
+ +
+ ); + })()} +

+ {formatMessageMeta( + row.message.createdAt, + row.message.streaming + ? formatElapsed(row.message.createdAt, nowIso) + : formatElapsed( + row.message.createdAt, + row.message.completedAt, + ), + )} +

+
+ + ); + })()} + + {row.kind === "proposed-plan" && ( +
+ +
+ )} + + {row.kind === "working" && ( +
+
+ + + + + + + {row.createdAt + ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}` + : "Working..."} + +
+
+ )} +
+ ); + + if (!hasMessages && !isWorking) { + return ( +
+

+ Send a message to start the conversation. +

+
+ ); + } + + return ( +
+ {virtualizedRowCount > 0 && ( +
+ {virtualRows.map((virtualRow: VirtualItem) => { + const row = rows[virtualRow.index]; + if (!row) return null; + + return ( +
+ {renderRowContent(row)} +
+ ); + })} +
+ )} + + {nonVirtualizedRows.map((row) => ( +
{renderRowContent(row)}
+ ))} +
+ ); +}); + +type TimelineEntry = ReturnType[number]; +type TimelineMessage = Extract["message"]; +type TimelineProposedPlan = Extract< + TimelineEntry, + { kind: "proposed-plan" } +>["proposedPlan"]; +type TimelineWorkEntry = Extract["entry"]; +type TimelineRow = + | { + kind: "work"; + id: string; + createdAt: string; + groupedEntries: TimelineWorkEntry[]; + } + | { + kind: "message"; + id: string; + createdAt: string; + message: TimelineMessage; + showCompletionDivider: boolean; + } + | { + kind: "proposed-plan"; + id: string; + createdAt: string; + proposedPlan: TimelineProposedPlan; + } + | { kind: "working"; id: string; createdAt: string | null }; + +function estimateTimelineProposedPlanHeight( + proposedPlan: TimelineProposedPlan, +): number { + const estimatedLines = Math.max( + 1, + Math.ceil(proposedPlan.planMarkdown.length / 72), + ); + return 120 + Math.min(estimatedLines * 22, 880); +} + +function formatWorkingTimer(startIso: string, endIso: string): string | null { + const startedAtMs = Date.parse(startIso); + const endedAtMs = Date.parse(endIso); + if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) { + return null; + } + + const elapsedSeconds = Math.max( + 0, + Math.floor((endedAtMs - startedAtMs) / 1000), + ); + if (elapsedSeconds < 60) { + return `${elapsedSeconds}s`; + } + + const hours = Math.floor(elapsedSeconds / 3600); + const minutes = Math.floor((elapsedSeconds % 3600) / 60); + const seconds = elapsedSeconds % 60; + + if (hours > 0) { + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + } + + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; +} + +function formatMessageMeta(createdAt: string, duration: string | null): string { + if (!duration) return formatTimestamp(createdAt); + return `${formatTimestamp(createdAt)} • ${duration}`; +} + +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"; +} diff --git a/apps/web/src/components/OpenInPicker.tsx b/apps/web/src/components/OpenInPicker.tsx new file mode 100644 index 0000000000..8d1a6051f2 --- /dev/null +++ b/apps/web/src/components/OpenInPicker.tsx @@ -0,0 +1,163 @@ +import { + EDITORS, + type EditorId, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { + isOpenFavoriteEditorShortcut, + shortcutLabelForCommand, +} from "../keybindings"; +import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; +import { Button } from "./ui/button"; +import { Group, GroupSeparator } from "./ui/group"; +import { + Menu, + MenuItem, + MenuPopup, + MenuShortcut, + MenuTrigger, +} from "./ui/menu"; +import { CursorIcon, Icon, VisualStudioCode, Zed } from "./Icons"; +import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; +import { readNativeApi } from "~/nativeApi"; + +const LAST_EDITOR_KEY = "t3code:last-editor"; + +export const OpenInPicker = memo(function OpenInPicker({ + keybindings, + availableEditors, + openInCwd, +}: { + keybindings: ResolvedKeybindingsConfig; + availableEditors: ReadonlyArray; + openInCwd: string | null; +}) { + 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 allOptions = useMemo< + Array<{ label: string; Icon: Icon; value: EditorId }> + >( + () => [ + { + label: "Cursor", + Icon: CursorIcon, + value: "cursor", + }, + { + label: "VS Code", + Icon: VisualStudioCode, + value: "vscode", + }, + { + label: "Zed", + Icon: Zed, + value: "zed", + }, + { + label: isMacPlatform(navigator.platform) + ? "Finder" + : isWindowsPlatform(navigator.platform) + ? "Explorer" + : "Files", + Icon: FolderClosedIcon, + value: "file-manager", + }, + ], + [], + ); + const options = useMemo( + () => + allOptions.filter((option) => availableEditors.includes(option.value)), + [allOptions, availableEditors], + ); + + const effectiveEditor = options.some((option) => option.value === lastEditor) + ? lastEditor + : (options[0]?.value ?? null); + const primaryOption = + options.find(({ value }) => value === effectiveEditor) ?? null; + + const openInEditor = useCallback( + (editorId: EditorId | null) => { + const api = readNativeApi(); + if (!api || !openInCwd) return; + const editor = editorId ?? effectiveEditor; + if (!editor) return; + void api.shell.openInEditor(openInCwd, editor); + localStorage.setItem(LAST_EDITOR_KEY, editor); + setLastEditor(editor); + }, + [effectiveEditor, openInCwd, setLastEditor], + ); + + const openFavoriteEditorShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "editor.openFavorite"), + [keybindings], + ); + + useEffect(() => { + const handler = (e: globalThis.KeyboardEvent) => { + const api = readNativeApi(); + if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; + if (!api || !openInCwd) return; + if (!effectiveEditor) return; + + e.preventDefault(); + void api.shell.openInEditor(openInCwd, effectiveEditor); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [effectiveEditor, keybindings, openInCwd]); + + return ( + + + + + + } + > + + + {options.length === 0 && ( + No installed editors found + )} + {options.map(({ label, Icon, value }) => ( + openInEditor(value)}> + + ))} + + + + ); +}); diff --git a/apps/web/src/components/ProposedPlanCard.tsx b/apps/web/src/components/ProposedPlanCard.tsx new file mode 100644 index 0000000000..248eee67d3 --- /dev/null +++ b/apps/web/src/components/ProposedPlanCard.tsx @@ -0,0 +1,243 @@ +import { memo, useState, useId } from "react"; +import { + buildCollapsedProposedPlanPreviewMarkdown, + buildProposedPlanMarkdownFilename, + downloadPlanAsTextFile, + normalizePlanMarkdownForExport, + proposedPlanTitle, + stripDisplayedPlanMarkdown, +} from "../proposedPlan"; +import ChatMarkdown from "./ChatMarkdown"; +import { EllipsisIcon } from "lucide-react"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; +import { cn } from "~/lib/utils"; +import { Badge } from "./ui/badge"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { toastManager } from "./ui/toast"; +import { readNativeApi } from "~/nativeApi"; + +export const ProposedPlanCard = memo(function ProposedPlanCard({ + planMarkdown, + cwd, + workspaceRoot, +}: { + planMarkdown: string; + cwd: string | undefined; + workspaceRoot: string | undefined; +}) { + const [expanded, setExpanded] = useState(false); + const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); + const [savePath, setSavePath] = useState(""); + const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); + const savePathInputId = useId(); + const title = proposedPlanTitle(planMarkdown) ?? "Proposed plan"; + const lineCount = planMarkdown.split("\n").length; + const canCollapse = planMarkdown.length > 900 || lineCount > 20; + const displayedPlanMarkdown = stripDisplayedPlanMarkdown(planMarkdown); + const collapsedPreview = canCollapse + ? buildCollapsedProposedPlanPreviewMarkdown(planMarkdown, { maxLines: 10 }) + : null; + const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown); + const saveContents = normalizePlanMarkdownForExport(planMarkdown); + + const handleDownload = () => { + downloadPlanAsTextFile(downloadFilename, saveContents); + }; + + const openSaveDialog = () => { + if (!workspaceRoot) { + toastManager.add({ + type: "error", + title: "Workspace path is unavailable", + description: "This thread does not have a workspace path to save into.", + }); + return; + } + setSavePath((existing) => + existing.length > 0 ? existing : downloadFilename, + ); + setIsSaveDialogOpen(true); + }; + + const handleSaveToWorkspace = () => { + const api = readNativeApi(); + const relativePath = savePath.trim(); + if (!api || !workspaceRoot) { + return; + } + if (!relativePath) { + toastManager.add({ + type: "warning", + title: "Enter a workspace path", + }); + return; + } + + setIsSavingToWorkspace(true); + void api.projects + .writeFile({ + cwd: workspaceRoot, + relativePath, + contents: saveContents, + }) + .then((result) => { + setIsSaveDialogOpen(false); + toastManager.add({ + type: "success", + title: "Plan saved to workspace", + description: result.relativePath, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not save plan", + description: + error instanceof Error + ? error.message + : "An error occurred while saving.", + }); + }) + .then( + () => { + setIsSavingToWorkspace(false); + }, + () => { + setIsSavingToWorkspace(false); + }, + ); + }; + + return ( +
+
+
+ Plan +

+ {title} +

+
+ + + } + > + + + Download as markdown + + Save to workspace + + + +
+
+
+ {canCollapse && !expanded ? ( + + ) : ( + + )} + {canCollapse && !expanded ? ( +
+ ) : null} +
+ {canCollapse ? ( +
+ +
+ ) : null} +
+ + { + if (!isSavingToWorkspace) { + setIsSaveDialogOpen(open); + } + }} + > + + + Save plan to workspace + + Enter a path relative to{" "} + {workspaceRoot ?? "the workspace"}. + + + + + + + + + + + +
+ ); +}); diff --git a/apps/web/src/components/ProviderHealthBanner.tsx b/apps/web/src/components/ProviderHealthBanner.tsx new file mode 100644 index 0000000000..249c2303f1 --- /dev/null +++ b/apps/web/src/components/ProviderHealthBanner.tsx @@ -0,0 +1,38 @@ +import { type ServerProviderStatus } from "@t3tools/contracts"; +import { memo } from "react"; +import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; +import { CircleAlertIcon } from "lucide-react"; + +export const ProviderHealthBanner = memo(function ProviderHealthBanner({ + status, +}: { + status: ServerProviderStatus | null; +}) { + if (!status || status.status === "ready") { + return null; + } + + const defaultMessage = + status.status === "error" + ? `${status.provider} provider is unavailable.` + : `${status.provider} provider has limited availability.`; + + return ( +
+ + + + {status.provider === "codex" + ? "Codex provider status" + : `${status.provider} status`} + + + {status.message ?? defaultMessage} + + +
+ ); +}); diff --git a/apps/web/src/components/ProviderModelPicker.tsx b/apps/web/src/components/ProviderModelPicker.tsx new file mode 100644 index 0000000000..b477ca2d26 --- /dev/null +++ b/apps/web/src/components/ProviderModelPicker.tsx @@ -0,0 +1,239 @@ +import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; +import { normalizeModelSlug } from "@t3tools/shared/model"; +import { memo, useState } from "react"; +import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../session-logic"; +import { ChevronDownIcon } from "lucide-react"; +import { Button } from "./ui/button"; +import { + Menu, + MenuGroup, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator as MenuDivider, + MenuSub, + MenuSubPopup, + MenuSubTrigger, + MenuTrigger, +} from "./ui/menu"; +import { + ClaudeAI, + CursorIcon, + Gemini, + Icon, + OpenAI, + OpenCodeIcon, +} from "./Icons"; +import { cn } from "~/lib/utils"; + +function isAvailableProviderOption( + option: (typeof PROVIDER_OPTIONS)[number], +): option is { + value: ProviderKind; + label: string; + available: true; +} { + return option.available && option.value !== "claudeCode"; +} + +function resolveModelForProviderPicker( + provider: ProviderKind, + value: string, + options: ReadonlyArray<{ slug: string; name: string }>, +): ModelSlug | null { + const trimmedValue = value.trim(); + if (!trimmedValue) { + return null; + } + + const direct = options.find((option) => option.slug === trimmedValue); + if (direct) { + return direct.slug; + } + + const byName = options.find( + (option) => option.name.toLowerCase() === trimmedValue.toLowerCase(), + ); + if (byName) { + return byName.slug; + } + + const normalized = normalizeModelSlug(trimmedValue, provider); + if (!normalized) { + return null; + } + + const resolved = options.find((option) => option.slug === normalized); + if (resolved) { + return resolved.slug; + } + + return null; +} + +const PROVIDER_ICON_BY_PROVIDER: Record = { + codex: OpenAI, + claudeCode: ClaudeAI, + cursor: CursorIcon, +}; + +export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter( + isAvailableProviderOption, +); +const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter( + (option) => !option.available, +); +const COMING_SOON_PROVIDER_OPTIONS = [ + { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, + { id: "gemini", label: "Gemini", icon: Gemini }, +] as const; + +export const ProviderModelPicker = memo(function ProviderModelPicker(props: { + provider: ProviderKind; + model: ModelSlug; + lockedProvider: ProviderKind | null; + modelOptionsByProvider: Record< + ProviderKind, + ReadonlyArray<{ slug: string; name: string }> + >; + compact?: boolean; + disabled?: boolean; + onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const selectedProviderOptions = props.modelOptionsByProvider[props.provider]; + const selectedModelLabel = + selectedProviderOptions.find((option) => option.slug === props.model) + ?.name ?? props.model; + const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; + + return ( + { + if (props.disabled) { + setIsMenuOpen(false); + return; + } + setIsMenuOpen(open); + }} + > + + } + > + + + + + {AVAILABLE_PROVIDER_OPTIONS.map((option) => { + const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; + const isDisabledByProviderLock = + props.lockedProvider !== null && + props.lockedProvider !== option.value; + return ( + + + + + + { + if (props.disabled) return; + if (isDisabledByProviderLock) return; + if (!value) return; + const resolvedModel = resolveModelForProviderPicker( + option.value, + value, + props.modelOptionsByProvider[option.value], + ); + if (!resolvedModel) return; + props.onProviderModelChange(option.value, resolvedModel); + setIsMenuOpen(false); + }} + > + {props.modelOptionsByProvider[option.value].map( + (modelOption) => ( + setIsMenuOpen(false)} + > + {modelOption.name} + + ), + )} + + + + + ); + })} + {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && } + {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { + const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; + return ( + + + ); + })} + {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && } + {COMING_SOON_PROVIDER_OPTIONS.map((option) => { + const OptionIcon = option.icon; + return ( + + + ); + })} + + + ); +}); diff --git a/apps/web/src/components/ThreadErrorBanner.tsx b/apps/web/src/components/ThreadErrorBanner.tsx new file mode 100644 index 0000000000..187294d791 --- /dev/null +++ b/apps/web/src/components/ThreadErrorBanner.tsx @@ -0,0 +1,35 @@ +import { memo } from "react"; +import { Alert, AlertAction, AlertDescription } from "./ui/alert"; +import { CircleAlertIcon, XIcon } from "lucide-react"; + +export const ThreadErrorBanner = memo(function ThreadErrorBanner({ + error, + onDismiss, +}: { + error: string | null; + onDismiss?: () => void; +}) { + if (!error) return null; + return ( +
+ + + + {error} + + {onDismiss && ( + + + + )} + +
+ ); +}); diff --git a/apps/web/src/components/VscodeEntryIcon.tsx b/apps/web/src/components/VscodeEntryIcon.tsx new file mode 100644 index 0000000000..4f77af7751 --- /dev/null +++ b/apps/web/src/components/VscodeEntryIcon.tsx @@ -0,0 +1,41 @@ +import { memo, useMemo, useState } from "react"; +import { getVscodeIconUrlForEntry } from "../vscode-icons"; +import { FileIcon, FolderIcon } from "lucide-react"; +import { cn } from "~/lib/utils"; + +export const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { + pathValue: string; + kind: "file" | "directory"; + theme: "light" | "dark"; + className?: string; +}) { + const [failedIconUrl, setFailedIconUrl] = useState(null); + const iconUrl = useMemo( + () => getVscodeIconUrlForEntry(props.pathValue, props.kind, props.theme), + [props.kind, props.pathValue, props.theme], + ); + const failed = failedIconUrl === iconUrl; + + if (failed) { + return props.kind === "directory" ? ( + + ) : ( + + ); + } + + return ( + setFailedIconUrl(iconUrl)} + /> + ); +}); From 598f0253c806a88c00cda398016bb6fc362306c8 Mon Sep 17 00:00:00 2001 From: Christian Smith Date: Tue, 10 Mar 2026 17:59:16 -0500 Subject: [PATCH 2/4] wip: relocate new component files into /chat for better organization. --- apps/web/src/components/ChatView.tsx | 24 +++++++++---------- .../{ => chat}/ChangedFilesTree.tsx | 7 ++++-- .../src/components/{ => chat}/ChatHeader.tsx | 12 +++++----- .../{ => chat}/CodexTraitsPicker.tsx | 4 ++-- .../CompactComposerControlsMenu.tsx | 4 ++-- .../{ => chat}/ComposerCommandMenu.tsx | 6 ++--- .../ComposerPendingApprovalActions.tsx | 2 +- .../ComposerPendingApprovalPanel.tsx | 2 +- .../ComposerPendingUserInputPanel.tsx | 4 ++-- .../{ => chat}/ComposerPlanFollowUpBanner.tsx | 0 .../components/{ => chat}/DiffStatLabel.tsx | 0 .../{ => chat}/MessageCopyButton.tsx | 2 +- .../{ => chat}/MessagesTimeline.tsx | 16 ++++++------- .../components/{ => chat}/OpenInPicker.tsx | 10 ++++---- .../{ => chat}/ProposedPlanCard.tsx | 16 ++++++------- .../{ => chat}/ProviderHealthBanner.tsx | 2 +- .../{ => chat}/ProviderModelPicker.tsx | 8 +++---- .../{ => chat}/ThreadErrorBanner.tsx | 2 +- .../components/{ => chat}/VscodeEntryIcon.tsx | 2 +- 19 files changed, 63 insertions(+), 60 deletions(-) rename apps/web/src/components/{ => chat}/ChangedFilesTree.tsx (97%) rename apps/web/src/components/{ => chat}/ChatHeader.tsx (93%) rename apps/web/src/components/{ => chat}/CodexTraitsPicker.tsx (98%) rename apps/web/src/components/{ => chat}/CompactComposerControlsMenu.tsx (98%) rename apps/web/src/components/{ => chat}/ComposerCommandMenu.tsx (96%) rename apps/web/src/components/{ => chat}/ComposerPendingApprovalActions.tsx (97%) rename apps/web/src/components/{ => chat}/ComposerPendingApprovalPanel.tsx (94%) rename apps/web/src/components/{ => chat}/ComposerPendingUserInputPanel.tsx (98%) rename apps/web/src/components/{ => chat}/ComposerPlanFollowUpBanner.tsx (100%) rename apps/web/src/components/{ => chat}/DiffStatLabel.tsx (100%) rename apps/web/src/components/{ => chat}/MessageCopyButton.tsx (94%) rename apps/web/src/components/{ => chat}/MessagesTimeline.tsx (98%) rename apps/web/src/components/{ => chat}/OpenInPicker.tsx (95%) rename apps/web/src/components/{ => chat}/ProposedPlanCard.tsx (95%) rename apps/web/src/components/{ => chat}/ProviderHealthBanner.tsx (94%) rename apps/web/src/components/{ => chat}/ProviderModelPicker.tsx (97%) rename apps/web/src/components/{ => chat}/ThreadErrorBanner.tsx (93%) rename apps/web/src/components/{ => chat}/VscodeEntryIcon.tsx (94%) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7b7a38eda3..491cb7856e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -153,8 +153,8 @@ import { type ComposerPromptEditorHandle, } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; -import { MessagesTimeline } from "./MessagesTimeline"; -import { ChatHeader } from "./ChatHeader"; +import { MessagesTimeline } from "./chat/MessagesTimeline"; +import { ChatHeader } from "./chat/ChatHeader"; import { buildExpandedImagePreview, ExpandedImagePreview, @@ -162,19 +162,19 @@ import { import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker, -} from "./ProviderModelPicker"; +} from "./chat/ProviderModelPicker"; import { ComposerCommandItem, ComposerCommandMenu, -} from "./ComposerCommandMenu"; -import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; -import { CodexTraitsPicker } from "./CodexTraitsPicker"; -import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; -import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; -import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; -import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; -import { ProviderHealthBanner } from "./ProviderHealthBanner"; -import { ThreadErrorBanner } from "./ThreadErrorBanner"; +} from "./chat/ComposerCommandMenu"; +import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; +import { CodexTraitsPicker } from "./chat/CodexTraitsPicker"; +import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; +import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; +import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; +import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; +import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; +import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { buildLocalDraftThread, buildTemporaryWorktreeBranchName, diff --git a/apps/web/src/components/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx similarity index 97% rename from apps/web/src/components/ChangedFilesTree.tsx rename to apps/web/src/components/chat/ChangedFilesTree.tsx index a6a58c7734..d04067d9a1 100644 --- a/apps/web/src/components/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -1,7 +1,10 @@ import { type TurnId } from "@t3tools/contracts"; import { memo, useCallback, useEffect, useMemo, useState } from "react"; -import { type TurnDiffFileChange } from "../types"; -import { buildTurnDiffTree, type TurnDiffTreeNode } from "../lib/turnDiffTree"; +import { type TurnDiffFileChange } from "../../types"; +import { + buildTurnDiffTree, + type TurnDiffTreeNode, +} from "../../lib/turnDiffTree"; import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; diff --git a/apps/web/src/components/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx similarity index 93% rename from apps/web/src/components/ChatHeader.tsx rename to apps/web/src/components/chat/ChatHeader.tsx index fc734740be..688520b9d5 100644 --- a/apps/web/src/components/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -5,15 +5,15 @@ import { type ThreadId, } from "@t3tools/contracts"; import { memo } from "react"; -import GitActionsControl from "./GitActionsControl"; +import GitActionsControl from "../GitActionsControl"; import { DiffIcon } from "lucide-react"; -import { Badge } from "./ui/badge"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { Badge } from "../ui/badge"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import ProjectScriptsControl, { type NewProjectScriptInput, -} from "./ProjectScriptsControl"; -import { Toggle } from "./ui/toggle"; -import { SidebarTrigger } from "./ui/sidebar"; +} from "../ProjectScriptsControl"; +import { Toggle } from "../ui/toggle"; +import { SidebarTrigger } from "../ui/sidebar"; import { OpenInPicker } from "./OpenInPicker"; interface ChatHeaderProps { diff --git a/apps/web/src/components/CodexTraitsPicker.tsx b/apps/web/src/components/chat/CodexTraitsPicker.tsx similarity index 98% rename from apps/web/src/components/CodexTraitsPicker.tsx rename to apps/web/src/components/chat/CodexTraitsPicker.tsx index 2dd8dbaaf4..6648e34743 100644 --- a/apps/web/src/components/CodexTraitsPicker.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.tsx @@ -2,7 +2,7 @@ import { type CodexReasoningEffort } from "@t3tools/contracts"; import { getDefaultReasoningEffort } from "@t3tools/shared/model"; import { memo, useState } from "react"; import { ChevronDownIcon } from "lucide-react"; -import { Button } from "./ui/button"; +import { Button } from "../ui/button"; import { Menu, MenuGroup, @@ -11,7 +11,7 @@ import { MenuRadioItem, MenuSeparator as MenuDivider, MenuTrigger, -} from "./ui/menu"; +} from "../ui/menu"; export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { effort: CodexReasoningEffort; diff --git a/apps/web/src/components/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx similarity index 98% rename from apps/web/src/components/CompactComposerControlsMenu.tsx rename to apps/web/src/components/chat/CompactComposerControlsMenu.tsx index c12a4912b9..9e6ede52ed 100644 --- a/apps/web/src/components/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -7,7 +7,7 @@ import { import { getDefaultReasoningEffort } from "@t3tools/shared/model"; import { memo } from "react"; import { EllipsisIcon, ListTodoIcon } from "lucide-react"; -import { Button } from "./ui/button"; +import { Button } from "../ui/button"; import { Menu, MenuGroup, @@ -17,7 +17,7 @@ import { MenuRadioItem, MenuSeparator as MenuDivider, MenuTrigger, -} from "./ui/menu"; +} from "../ui/menu"; export const CompactComposerControlsMenu = memo( function CompactComposerControlsMenu(props: { diff --git a/apps/web/src/components/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx similarity index 96% rename from apps/web/src/components/ComposerCommandMenu.tsx rename to apps/web/src/components/chat/ComposerCommandMenu.tsx index 15c915f286..38a1654025 100644 --- a/apps/web/src/components/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -7,11 +7,11 @@ import { memo } from "react"; import { type ComposerSlashCommand, type ComposerTriggerKind, -} from "../composer-logic"; +} from "../../composer-logic"; import { BotIcon } from "lucide-react"; import { cn } from "~/lib/utils"; -import { Badge } from "./ui/badge"; -import { Command, CommandItem, CommandList } from "./ui/command"; +import { Badge } from "../ui/badge"; +import { Command, CommandItem, CommandList } from "../ui/command"; import { VscodeEntryIcon } from "./VscodeEntryIcon"; export type ComposerCommandItem = diff --git a/apps/web/src/components/ComposerPendingApprovalActions.tsx b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx similarity index 97% rename from apps/web/src/components/ComposerPendingApprovalActions.tsx rename to apps/web/src/components/chat/ComposerPendingApprovalActions.tsx index 1aedbb8798..aa36228856 100644 --- a/apps/web/src/components/ComposerPendingApprovalActions.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx @@ -3,7 +3,7 @@ import { type ProviderApprovalDecision, } from "@t3tools/contracts"; import { memo } from "react"; -import { Button } from "./ui/button"; +import { Button } from "../ui/button"; interface ComposerPendingApprovalActionsProps { requestId: ApprovalRequestId; diff --git a/apps/web/src/components/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx similarity index 94% rename from apps/web/src/components/ComposerPendingApprovalPanel.tsx rename to apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx index 854678be9b..dc560c0eca 100644 --- a/apps/web/src/components/ComposerPendingApprovalPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx @@ -1,5 +1,5 @@ import { memo } from "react"; -import { type PendingApproval } from "../session-logic"; +import { type PendingApproval } from "../../session-logic"; interface ComposerPendingApprovalPanelProps { approval: PendingApproval; diff --git a/apps/web/src/components/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx similarity index 98% rename from apps/web/src/components/ComposerPendingUserInputPanel.tsx rename to apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 45b16f6e6f..c5533d650e 100644 --- a/apps/web/src/components/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -1,10 +1,10 @@ import { type ApprovalRequestId } from "@t3tools/contracts"; import { memo, useCallback, useEffect, useRef } from "react"; -import { type PendingUserInput } from "../session-logic"; +import { type PendingUserInput } from "../../session-logic"; import { derivePendingUserInputProgress, type PendingUserInputDraftAnswer, -} from "../pendingUserInput"; +} from "../../pendingUserInput"; import { CheckIcon } from "lucide-react"; import { cn } from "~/lib/utils"; diff --git a/apps/web/src/components/ComposerPlanFollowUpBanner.tsx b/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx similarity index 100% rename from apps/web/src/components/ComposerPlanFollowUpBanner.tsx rename to apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx diff --git a/apps/web/src/components/DiffStatLabel.tsx b/apps/web/src/components/chat/DiffStatLabel.tsx similarity index 100% rename from apps/web/src/components/DiffStatLabel.tsx rename to apps/web/src/components/chat/DiffStatLabel.tsx diff --git a/apps/web/src/components/MessageCopyButton.tsx b/apps/web/src/components/chat/MessageCopyButton.tsx similarity index 94% rename from apps/web/src/components/MessageCopyButton.tsx rename to apps/web/src/components/chat/MessageCopyButton.tsx index f1d5d6dc1b..93e8f9b46f 100644 --- a/apps/web/src/components/MessageCopyButton.tsx +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -1,6 +1,6 @@ import { memo, useCallback, useState } from "react"; import { CopyIcon, CheckIcon } from "lucide-react"; -import { Button } from "./ui/button"; +import { Button } from "../ui/button"; export const MessageCopyButton = memo(function MessageCopyButton({ text, diff --git a/apps/web/src/components/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx similarity index 98% rename from apps/web/src/components/MessagesTimeline.tsx rename to apps/web/src/components/chat/MessagesTimeline.tsx index 431c508c38..39108f2586 100644 --- a/apps/web/src/components/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -17,19 +17,19 @@ import { deriveTimelineEntries, formatElapsed, formatTimestamp, -} from "../session-logic"; -import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../chat-scroll"; -import { type TurnDiffSummary } from "../types"; -import { summarizeTurnDiffStats } from "../lib/turnDiffTree"; -import ChatMarkdown from "./ChatMarkdown"; +} from "../../session-logic"; +import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; +import { type TurnDiffSummary } from "../../types"; +import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; +import ChatMarkdown from "../ChatMarkdown"; import { Undo2Icon } from "lucide-react"; -import { Button } from "./ui/button"; +import { Button } from "../ui/button"; import { clamp } from "effect/Number"; -import { estimateTimelineMessageHeight } from "./timelineHeight"; +import { estimateTimelineMessageHeight } from "../timelineHeight"; import { buildExpandedImagePreview, ExpandedImagePreview, -} from "./ExpandedImagePreview"; +} from "../ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; diff --git a/apps/web/src/components/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx similarity index 95% rename from apps/web/src/components/OpenInPicker.tsx rename to apps/web/src/components/chat/OpenInPicker.tsx index 8d1a6051f2..4c1bccbffe 100644 --- a/apps/web/src/components/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -7,18 +7,18 @@ import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand, -} from "../keybindings"; +} from "../../keybindings"; import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; -import { Button } from "./ui/button"; -import { Group, GroupSeparator } from "./ui/group"; +import { Button } from "../ui/button"; +import { Group, GroupSeparator } from "../ui/group"; import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger, -} from "./ui/menu"; -import { CursorIcon, Icon, VisualStudioCode, Zed } from "./Icons"; +} from "../ui/menu"; +import { CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons"; import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; diff --git a/apps/web/src/components/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx similarity index 95% rename from apps/web/src/components/ProposedPlanCard.tsx rename to apps/web/src/components/chat/ProposedPlanCard.tsx index 248eee67d3..0206818fd9 100644 --- a/apps/web/src/components/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -6,14 +6,14 @@ import { normalizePlanMarkdownForExport, proposedPlanTitle, stripDisplayedPlanMarkdown, -} from "../proposedPlan"; -import ChatMarkdown from "./ChatMarkdown"; +} from "../../proposedPlan"; +import ChatMarkdown from "../ChatMarkdown"; import { EllipsisIcon } from "lucide-react"; -import { Button } from "./ui/button"; -import { Input } from "./ui/input"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { cn } from "~/lib/utils"; -import { Badge } from "./ui/badge"; +import { Badge } from "../ui/badge"; import { Dialog, DialogDescription, @@ -22,8 +22,8 @@ import { DialogPanel, DialogPopup, DialogTitle, -} from "./ui/dialog"; -import { toastManager } from "./ui/toast"; +} from "../ui/dialog"; +import { toastManager } from "../ui/toast"; import { readNativeApi } from "~/nativeApi"; export const ProposedPlanCard = memo(function ProposedPlanCard({ diff --git a/apps/web/src/components/ProviderHealthBanner.tsx b/apps/web/src/components/chat/ProviderHealthBanner.tsx similarity index 94% rename from apps/web/src/components/ProviderHealthBanner.tsx rename to apps/web/src/components/chat/ProviderHealthBanner.tsx index 249c2303f1..7f5adcc320 100644 --- a/apps/web/src/components/ProviderHealthBanner.tsx +++ b/apps/web/src/components/chat/ProviderHealthBanner.tsx @@ -1,6 +1,6 @@ import { type ServerProviderStatus } from "@t3tools/contracts"; import { memo } from "react"; -import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; +import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { CircleAlertIcon } from "lucide-react"; export const ProviderHealthBanner = memo(function ProviderHealthBanner({ diff --git a/apps/web/src/components/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx similarity index 97% rename from apps/web/src/components/ProviderModelPicker.tsx rename to apps/web/src/components/chat/ProviderModelPicker.tsx index b477ca2d26..0fc9e937d9 100644 --- a/apps/web/src/components/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,9 +1,9 @@ import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { memo, useState } from "react"; -import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../session-logic"; +import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; import { ChevronDownIcon } from "lucide-react"; -import { Button } from "./ui/button"; +import { Button } from "../ui/button"; import { Menu, MenuGroup, @@ -16,7 +16,7 @@ import { MenuSubPopup, MenuSubTrigger, MenuTrigger, -} from "./ui/menu"; +} from "../ui/menu"; import { ClaudeAI, CursorIcon, @@ -24,7 +24,7 @@ import { Icon, OpenAI, OpenCodeIcon, -} from "./Icons"; +} from "../Icons"; import { cn } from "~/lib/utils"; function isAvailableProviderOption( diff --git a/apps/web/src/components/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx similarity index 93% rename from apps/web/src/components/ThreadErrorBanner.tsx rename to apps/web/src/components/chat/ThreadErrorBanner.tsx index 187294d791..b48412453c 100644 --- a/apps/web/src/components/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -1,5 +1,5 @@ import { memo } from "react"; -import { Alert, AlertAction, AlertDescription } from "./ui/alert"; +import { Alert, AlertAction, AlertDescription } from "../ui/alert"; import { CircleAlertIcon, XIcon } from "lucide-react"; export const ThreadErrorBanner = memo(function ThreadErrorBanner({ diff --git a/apps/web/src/components/VscodeEntryIcon.tsx b/apps/web/src/components/chat/VscodeEntryIcon.tsx similarity index 94% rename from apps/web/src/components/VscodeEntryIcon.tsx rename to apps/web/src/components/chat/VscodeEntryIcon.tsx index 4f77af7751..21570633a7 100644 --- a/apps/web/src/components/VscodeEntryIcon.tsx +++ b/apps/web/src/components/chat/VscodeEntryIcon.tsx @@ -1,5 +1,5 @@ import { memo, useMemo, useState } from "react"; -import { getVscodeIconUrlForEntry } from "../vscode-icons"; +import { getVscodeIconUrlForEntry } from "../../vscode-icons"; import { FileIcon, FolderIcon } from "lucide-react"; import { cn } from "~/lib/utils"; From c3f017ea9660c051059c304786620848ec852075 Mon Sep 17 00:00:00 2001 From: Christian Smith Date: Tue, 10 Mar 2026 18:17:53 -0500 Subject: [PATCH 3/4] fix: forgot to move one of the files. --- apps/web/src/components/ChatView.tsx | 2 +- apps/web/src/components/{ => chat}/ExpandedImagePreview.tsx | 0 apps/web/src/components/chat/MessagesTimeline.tsx | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename apps/web/src/components/{ => chat}/ExpandedImagePreview.tsx (100%) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 491cb7856e..85d187aeef 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -158,7 +158,7 @@ import { ChatHeader } from "./chat/ChatHeader"; import { buildExpandedImagePreview, ExpandedImagePreview, -} from "./ExpandedImagePreview"; +} from "./chat/ExpandedImagePreview"; import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker, diff --git a/apps/web/src/components/ExpandedImagePreview.tsx b/apps/web/src/components/chat/ExpandedImagePreview.tsx similarity index 100% rename from apps/web/src/components/ExpandedImagePreview.tsx rename to apps/web/src/components/chat/ExpandedImagePreview.tsx diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 39108f2586..35bdb4651d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -29,7 +29,7 @@ import { estimateTimelineMessageHeight } from "../timelineHeight"; import { buildExpandedImagePreview, ExpandedImagePreview, -} from "../ExpandedImagePreview"; +} from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; From 24b93b35db03af1f224bf2e1882fe9bc693bf92f Mon Sep 17 00:00:00 2001 From: Christian Smith Date: Tue, 10 Mar 2026 20:59:56 -0500 Subject: [PATCH 4/4] fix: formatter --- apps/web/src/components/ChatView.logic.ts | 26 +- apps/web/src/components/ChatView.tsx | 1147 +++++------------ .../src/components/chat/ChangedFilesTree.tsx | 61 +- apps/web/src/components/chat/ChatHeader.tsx | 18 +- .../src/components/chat/CodexTraitsPicker.tsx | 12 +- .../chat/CompactComposerControlsMenu.tsx | 245 ++-- .../components/chat/ComposerCommandMenu.tsx | 15 +- .../chat/ComposerPendingApprovalActions.tsx | 93 +- .../chat/ComposerPendingApprovalPanel.tsx | 48 +- .../chat/ComposerPendingUserInputPanel.tsx | 326 +++-- .../chat/ComposerPlanFollowUpBanner.tsx | 38 +- .../web/src/components/chat/DiffStatLabel.tsx | 5 +- .../components/chat/ExpandedImagePreview.tsx | 8 +- .../src/components/chat/MessageCopyButton.tsx | 20 +- .../src/components/chat/MessagesTimeline.tsx | 199 +-- apps/web/src/components/chat/OpenInPicker.tsx | 51 +- .../src/components/chat/ProposedPlanCard.tsx | 52 +- .../components/chat/ProviderHealthBanner.tsx | 9 +- .../components/chat/ProviderModelPicker.tsx | 75 +- .../src/components/chat/VscodeEntryIcon.tsx | 8 +- 20 files changed, 809 insertions(+), 1647 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index a02d56d9c3..4cd64af9f1 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -2,19 +2,12 @@ import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; import { type ChatMessage, type Thread } from "../types"; import { randomUUID } from "~/lib/utils"; import { getAppModelOptions } from "../appSettings"; -import { - type ComposerImageAttachment, - type DraftThreadState, -} from "../composerDraftStore"; +import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; -export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = - "t3code:last-invoked-script-by-project"; +export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; const WORKTREE_BRANCH_PREFIX = "t3code"; -export function readLastInvokedScriptByProjectFromStorage(): Record< - string, - string -> { +export function readLastInvokedScriptByProjectFromStorage(): Record { const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); if (!stored) return {}; @@ -61,11 +54,7 @@ export function buildLocalDraftThread( } export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { - if ( - !previewUrl || - typeof URL === "undefined" || - !previewUrl.startsWith("blob:") - ) { + if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { return; } URL.revokeObjectURL(previewUrl); @@ -83,17 +72,14 @@ export function revokeUserMessagePreviewUrls(message: ChatMessage): void { } } -export function collectUserMessageBlobPreviewUrls( - message: ChatMessage, -): string[] { +export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] { if (message.role !== "user" || !message.attachments) { return []; } const previewUrls: string[] = []; for (const attachment of message.attachments) { if (attachment.type !== "image") continue; - if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) - continue; + if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue; previewUrls.push(attachment.previewUrl); } return previewUrls; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 85d187aeef..d81e024f3a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -28,31 +28,15 @@ import { normalizeModelSlug, resolveModelSlugForProvider, } from "@t3tools/shared/model"; -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; +import { 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 { - gitBranchesQueryOptions, - gitCreateWorktreeMutationOptions, -} from "~/lib/gitReactQuery"; +import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; -import { - serverConfigQueryOptions, - serverQueryKeys, -} from "~/lib/serverReactQuery"; +import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; -import { - parseDiffRouteSearch, - stripDiffSearchParams, -} from "../diffRouteSearch"; +import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { type ComposerTrigger, detectComposerTrigger, @@ -100,10 +84,7 @@ import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; -import { - resolveShortcutCommand, - shortcutLabelForCommand, -} from "../keybindings"; +import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { @@ -144,29 +125,14 @@ import { useComposerThreadDraft, } from "../composerDraftStore"; import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; -import { - selectThreadTerminalState, - useTerminalStateStore, -} from "../terminalStateStore"; -import { - ComposerPromptEditor, - type ComposerPromptEditorHandle, -} from "./ComposerPromptEditor"; +import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; -import { - buildExpandedImagePreview, - ExpandedImagePreview, -} from "./chat/ExpandedImagePreview"; -import { - AVAILABLE_PROVIDER_OPTIONS, - ProviderModelPicker, -} from "./chat/ProviderModelPicker"; -import { - ComposerCommandItem, - ComposerCommandMenu, -} from "./chat/ComposerCommandMenu"; +import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; +import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; +import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; import { CodexTraitsPicker } from "./chat/CodexTraitsPicker"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; @@ -199,10 +165,7 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; -const EMPTY_PENDING_USER_INPUT_ANSWERS: Record< - string, - PendingUserInputDraftAnswer -> = {}; +const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; @@ -226,62 +189,36 @@ export default function ChatView({ threadId }: ChatViewProps) { }); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); - const createWorktreeMutation = useMutation( - gitCreateWorktreeMutationOptions({ queryClient }), - ); + const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); const composerDraft = useComposerThreadDraft(threadId); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; - const setComposerDraftPrompt = useComposerDraftStore( - (store) => store.setPrompt, - ); - const setComposerDraftProvider = useComposerDraftStore( - (store) => store.setProvider, - ); - const setComposerDraftModel = useComposerDraftStore( - (store) => store.setModel, - ); - const setComposerDraftRuntimeMode = useComposerDraftStore( - (store) => store.setRuntimeMode, - ); + const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); + const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider); + const setComposerDraftModel = useComposerDraftStore((store) => store.setModel); + const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, ); - const setComposerDraftEffort = useComposerDraftStore( - (store) => store.setEffort, - ); - const setComposerDraftCodexFastMode = useComposerDraftStore( - (store) => store.setCodexFastMode, - ); - const addComposerDraftImage = useComposerDraftStore( - (store) => store.addImage, - ); - const addComposerDraftImages = useComposerDraftStore( - (store) => store.addImages, - ); - const removeComposerDraftImage = useComposerDraftStore( - (store) => store.removeImage, - ); + const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort); + const setComposerDraftCodexFastMode = useComposerDraftStore((store) => store.setCodexFastMode); + const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); + const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); + const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, ); - const clearComposerDraftContent = useComposerDraftStore( - (store) => store.clearComposerContent, - ); - const setDraftThreadContext = useComposerDraftStore( - (store) => store.setDraftThreadContext, - ); + const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); + const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); - const setProjectDraftThreadId = useComposerDraftStore( - (store) => store.setProjectDraftThreadId, - ); + const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); @@ -290,11 +227,8 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const promptRef = useRef(prompt); const [isDragOverComposer, setIsDragOverComposer] = useState(false); - const [expandedImage, setExpandedImage] = - useState(null); - const [optimisticUserMessages, setOptimisticUserMessages] = useState< - ChatMessage[] - >([]); + const [expandedImage, setExpandedImage] = useState(null); + const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< @@ -304,22 +238,16 @@ export default function ChatView({ threadId }: ChatViewProps) { const [sendStartedAt, setSendStartedAt] = useState(null); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); - const [respondingRequestIds, setRespondingRequestIds] = useState< + const [respondingRequestIds, setRespondingRequestIds] = useState([]); + const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] >([]); - const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = - useState([]); - const [ - pendingUserInputAnswersByRequestId, - setPendingUserInputAnswersByRequestId, - ] = useState>>({}); - const [ - pendingUserInputQuestionIndexByRequestId, - setPendingUserInputQuestionIndexByRequestId, - ] = useState>({}); - const [expandedWorkGroups, setExpandedWorkGroups] = useState< - Record + const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< + Record> >({}); + const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = + useState>({}); + const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. @@ -329,27 +257,21 @@ export default function ChatView({ threadId }: ChatViewProps) { const planSidebarOpenOnNextThreadRef = useRef(false); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); - const [composerHighlightedItemId, setComposerHighlightedItemId] = useState< - string | null - >(null); + const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); - const [ - attachmentPreviewHandoffByMessageId, - setAttachmentPreviewHandoffByMessageId, - ] = useState>({}); + const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< + Record + >({}); const [composerCursor, setComposerCursor] = useState(() => prompt.length); - const [composerTrigger, setComposerTrigger] = - useState(() => - detectComposerTrigger(prompt, prompt.length), - ); - const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = - useState>(() => - readLastInvokedScriptByProjectFromStorage(), - ); + const [composerTrigger, setComposerTrigger] = useState(() => + detectComposerTrigger(prompt, prompt.length), + ); + const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< + Record + >(() => readLastInvokedScriptByProjectFromStorage()); const messagesScrollRef = useRef(null); - const [messagesScrollElement, setMessagesScrollElement] = - useState(null); + const [messagesScrollElement, setMessagesScrollElement] = useState(null); const shouldAutoScrollRef = useRef(true); const lastKnownScrollTopRef = useRef(0); const isPointerScrollActiveRef = useRef(false); @@ -369,35 +291,24 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef([]); const activeComposerMenuItemRef = useRef(null); - const attachmentPreviewHandoffByMessageIdRef = useRef< - Record - >({}); - const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef< - Record - >({}); + const attachmentPreviewHandoffByMessageIdRef = useRef>({}); + const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); - const setMessagesScrollContainerRef = useCallback( - (element: HTMLDivElement | null) => { - messagesScrollRef.current = element; - setMessagesScrollElement(element); - }, - [], - ); + const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { + messagesScrollRef.current = element; + setMessagesScrollElement(element); + }, []); const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadId, threadId), ); const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); - const storeSetTerminalHeight = useTerminalStateStore( - (s) => s.setTerminalHeight, - ); + const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); - const storeSetActiveTerminal = useTerminalStateStore( - (s) => s.setActiveTerminal, - ); + const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); const setPrompt = useCallback( @@ -426,12 +337,8 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const serverThread = threads.find((t) => t.id === threadId); - const fallbackDraftProject = projects.find( - (project) => project.id === draftThread?.projectId, - ); - const localDraftError = serverThread - ? null - : (localDraftErrorsByThreadId[threadId] ?? null); + const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); + const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => draftThread @@ -446,23 +353,16 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const activeThread = serverThread ?? localDraftThread; const runtimeMode = - composerDraft.runtimeMode ?? - activeThread?.runtimeMode ?? - DEFAULT_RUNTIME_MODE; + composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = - composerDraft.interactionMode ?? - activeThread?.interactionMode ?? - DEFAULT_INTERACTION_MODE; + composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeLatestTurn = activeThread?.latestTurn ?? null; - const latestTurnSettled = isLatestTurnSettled( - activeLatestTurn, - activeThread?.session ?? null, - ); + const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProject = projects.find((p) => p.id === activeThread?.projectId); const openPullRequestDialog = useCallback( @@ -484,24 +384,14 @@ export default function ChatView({ threadId }: ChatViewProps) { }, []); const openOrReuseProjectDraftThread = useCallback( - async (input: { - branch: string; - worktreePath: string | null; - envMode: DraftThreadEnvMode; - }) => { + async (input: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => { if (!activeProject) { - throw new Error( - "No active project is available for this pull request.", - ); + throw new Error("No active project is available for this pull request."); } const storedDraftThread = getDraftThreadByProjectId(activeProject.id); if (storedDraftThread) { setDraftThreadContext(storedDraftThread.threadId, input); - setProjectDraftThreadId( - activeProject.id, - storedDraftThread.threadId, - input, - ); + setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input); if (storedDraftThread.threadId !== threadId) { await navigate({ to: "/$threadId", @@ -512,10 +402,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } const activeDraftThread = getDraftThread(threadId); - if ( - !isServerThread && - activeDraftThread?.projectId === activeProject.id - ) { + if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { setDraftThreadContext(threadId, input); setProjectDraftThreadId(activeProject.id, threadId, input); return; @@ -564,11 +451,8 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activeLatestTurn?.completedAt) return; const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); if (Number.isNaN(turnCompletedAt)) return; - const lastVisitedAt = activeThread.lastVisitedAt - ? Date.parse(activeThread.lastVisitedAt) - : NaN; - if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) - return; + const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; + if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; markThreadVisited(activeThread.id); }, [ @@ -583,20 +467,17 @@ export default function ChatView({ threadId }: ChatViewProps) { const selectedProviderByThreadId = composerDraft.provider; const hasThreadStarted = Boolean( activeThread && - (activeThread.latestTurn !== null || - activeThread.messages.length > 0 || - activeThread.session !== null), + (activeThread.latestTurn !== null || + activeThread.messages.length > 0 || + activeThread.session !== null), ); const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? selectedProviderByThreadId ?? null) : null; - const selectedProvider: ProviderKind = - lockedProvider ?? selectedProviderByThreadId ?? "codex"; + const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; const baseThreadModel = resolveModelSlugForProvider( selectedProvider, - activeThread?.model ?? - activeProject?.model ?? - getDefaultModel(selectedProvider), + activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), ); const customModelsForSelectedProvider = settings.customCodexModels; const selectedModel = useMemo(() => { @@ -609,16 +490,10 @@ export default function ChatView({ threadId }: ChatViewProps) { customModelsForSelectedProvider, draftModel, ) as ModelSlug; - }, [ - baseThreadModel, - composerDraft.model, - customModelsForSelectedProvider, - selectedProvider, - ]); + }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]); const reasoningOptions = getReasoningEffortOptions(selectedProvider); const supportsReasoningEffort = reasoningOptions.length > 0; - const selectedEffort = - composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); + const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); const selectedCodexFastModeEnabled = selectedProvider === "codex" ? composerDraft.codexFastMode : false; const selectedModelOptionsForDispatch = useMemo(() => { @@ -626,29 +501,18 @@ export default function ChatView({ threadId }: ChatViewProps) { return undefined; } const codexOptions = { - ...(supportsReasoningEffort && selectedEffort - ? { reasoningEffort: selectedEffort } - : {}), + ...(supportsReasoningEffort && selectedEffort ? { reasoningEffort: selectedEffort } : {}), ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}), }; - return Object.keys(codexOptions).length > 0 - ? { codex: codexOptions } - : undefined; - }, [ - selectedCodexFastModeEnabled, - selectedEffort, - selectedProvider, - supportsReasoningEffort, - ]); + return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; + }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]); const providerOptionsForDispatch = useMemo(() => { if (!settings.codexBinaryPath && !settings.codexHomePath) { return undefined; } return { codex: { - ...(settings.codexBinaryPath - ? { binaryPath: settings.codexBinaryPath } - : {}), + ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), }, }; @@ -660,12 +524,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const selectedModelForPickerWithCustomFallback = useMemo(() => { const currentOptions = modelOptionsByProvider[selectedProvider]; - return currentOptions.some( - (option) => option.slug === selectedModelForPicker, - ) + return currentOptions.some((option) => option.slug === selectedModelForPicker) ? selectedModelForPicker - : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? - selectedModelForPicker); + : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); const searchableModelOptions = useMemo( () => @@ -687,8 +548,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; - const isWorking = - phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, @@ -697,11 +557,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( - () => - deriveWorkLogEntries( - threadActivities, - activeLatestTurn?.turnId ?? undefined, - ), + () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); const latestTurnHasToolActivity = useMemo( @@ -720,16 +576,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const activePendingDraftAnswers = useMemo( () => activePendingUserInput - ? (pendingUserInputAnswersByRequestId[ - activePendingUserInput.requestId - ] ?? EMPTY_PENDING_USER_INPUT_ANSWERS) + ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ?? + EMPTY_PENDING_USER_INPUT_ANSWERS) : EMPTY_PENDING_USER_INPUT_ANSWERS, [activePendingUserInput, pendingUserInputAnswersByRequestId], ); const activePendingQuestionIndex = activePendingUserInput - ? (pendingUserInputQuestionIndexByRequestId[ - activePendingUserInput.requestId - ] ?? 0) + ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0) : 0; const activePendingProgress = useMemo( () => @@ -740,19 +593,12 @@ export default function ChatView({ threadId }: ChatViewProps) { activePendingQuestionIndex, ) : null, - [ - activePendingDraftAnswers, - activePendingQuestionIndex, - activePendingUserInput, - ], + [activePendingDraftAnswers, activePendingQuestionIndex, activePendingUserInput], ); const activePendingResolvedAnswers = useMemo( () => activePendingUserInput - ? buildPendingUserInputAnswers( - activePendingUserInput.questions, - activePendingDraftAnswers, - ) + ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingDraftAnswers) : null, [activePendingDraftAnswers, activePendingUserInput], ); @@ -767,17 +613,9 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThread?.proposedPlans ?? [], activeLatestTurn?.turnId ?? null, ); - }, [ - activeLatestTurn?.turnId, - activeThread?.proposedPlans, - latestTurnSettled, - ]); + }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); const activePlan = useMemo( - () => - deriveActivePlanState( - threadActivities, - activeLatestTurn?.turnId ?? undefined, - ), + () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); const showPlanFollowUpPrompt = @@ -791,8 +629,7 @@ export default function ChatView({ threadId }: ChatViewProps) { isComposerApprovalState || pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); - const composerFooterHasWideActions = - showPlanFollowUpPrompt || activePendingProgress !== null; + const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; useEffect(() => { if (!activePendingProgress) { return; @@ -811,19 +648,14 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerHighlightedItemId(null); }, [activePendingProgress, activePendingUserInput?.requestId]); useEffect(() => { - attachmentPreviewHandoffByMessageIdRef.current = - attachmentPreviewHandoffByMessageId; + attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); const clearAttachmentPreviewHandoffs = useCallback(() => { - for (const timeoutId of Object.values( - attachmentPreviewHandoffTimeoutByMessageIdRef.current, - )) { + for (const timeoutId of Object.values(attachmentPreviewHandoffTimeoutByMessageIdRef.current)) { window.clearTimeout(timeoutId); } attachmentPreviewHandoffTimeoutByMessageIdRef.current = {}; - for (const previewUrls of Object.values( - attachmentPreviewHandoffByMessageIdRef.current, - )) { + for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { for (const previewUrl of previewUrls) { revokeBlobPreviewUrl(previewUrl); } @@ -839,54 +671,45 @@ export default function ChatView({ threadId }: ChatViewProps) { } }; }, [clearAttachmentPreviewHandoffs]); - const handoffAttachmentPreviews = useCallback( - (messageId: MessageId, previewUrls: string[]) => { - if (previewUrls.length === 0) return; - - const previousPreviewUrls = - attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; - for (const previewUrl of previousPreviewUrls) { - if (!previewUrls.includes(previewUrl)) { + const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { + if (previewUrls.length === 0) return; + + const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; + for (const previewUrl of previousPreviewUrls) { + if (!previewUrls.includes(previewUrl)) { + revokeBlobPreviewUrl(previewUrl); + } + } + setAttachmentPreviewHandoffByMessageId((existing) => { + const next = { + ...existing, + [messageId]: previewUrls, + }; + attachmentPreviewHandoffByMessageIdRef.current = next; + return next; + }); + + const existingTimeout = attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; + if (typeof existingTimeout === "number") { + window.clearTimeout(existingTimeout); + } + attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId] = window.setTimeout(() => { + const currentPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId]; + if (currentPreviewUrls) { + for (const previewUrl of currentPreviewUrls) { revokeBlobPreviewUrl(previewUrl); } } setAttachmentPreviewHandoffByMessageId((existing) => { - const next = { - ...existing, - [messageId]: previewUrls, - }; + if (!(messageId in existing)) return existing; + const next = { ...existing }; + delete next[messageId]; attachmentPreviewHandoffByMessageIdRef.current = next; return next; }); - - const existingTimeout = - attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; - if (typeof existingTimeout === "number") { - window.clearTimeout(existingTimeout); - } - attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId] = - window.setTimeout(() => { - const currentPreviewUrls = - attachmentPreviewHandoffByMessageIdRef.current[messageId]; - if (currentPreviewUrls) { - for (const previewUrl of currentPreviewUrls) { - revokeBlobPreviewUrl(previewUrl); - } - } - setAttachmentPreviewHandoffByMessageId((existing) => { - if (!(messageId in existing)) return existing; - const next = { ...existing }; - delete next[messageId]; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; - }); - delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[ - messageId - ]; - }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS); - }, - [], - ); + delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; + }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS); + }, []); const serverMessages = activeThread?.messages; const timelineMessages = useMemo(() => { const messages = serverMessages ?? []; @@ -905,8 +728,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ) { return message; } - const handoffPreviewUrls = - attachmentPreviewHandoffByMessageId[message.id]; + const handoffPreviewUrls = attachmentPreviewHandoffByMessageId[message.id]; if (!handoffPreviewUrls || handoffPreviewUrls.length === 0) { return message; } @@ -919,10 +741,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } const handoffPreviewUrl = handoffPreviewUrls[imageIndex]; imageIndex += 1; - if ( - !handoffPreviewUrl || - attachment.previewUrl === handoffPreviewUrl - ) { + if (!handoffPreviewUrl || attachment.previewUrl === handoffPreviewUrl) { return attachment; } changed = true; @@ -938,28 +757,16 @@ export default function ChatView({ threadId }: ChatViewProps) { if (optimisticUserMessages.length === 0) { return serverMessagesWithPreviewHandoff; } - const serverIds = new Set( - serverMessagesWithPreviewHandoff.map((message) => message.id), - ); - const pendingMessages = optimisticUserMessages.filter( - (message) => !serverIds.has(message.id), - ); + const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id)); + const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); if (pendingMessages.length === 0) { return serverMessagesWithPreviewHandoff; } return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; - }, [ - serverMessages, - attachmentPreviewHandoffByMessageId, - optimisticUserMessages, - ]); + }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); const timelineEntries = useMemo( () => - deriveTimelineEntries( - timelineMessages, - activeThread?.proposedPlans ?? [], - workLogEntries, - ), + deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), [activeThread?.proposedPlans, timelineMessages, workLogEntries], ); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = @@ -980,11 +787,7 @@ export default function ChatView({ threadId }: ChatViewProps) { continue; } - for ( - let nextIndex = index + 1; - nextIndex < timelineEntries.length; - nextIndex += 1 - ) { + for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) { const nextEntry = timelineEntries[nextIndex]; if (!nextEntry || nextEntry.kind !== "message") { continue; @@ -992,15 +795,12 @@ export default function ChatView({ threadId }: ChatViewProps) { if (nextEntry.message.role === "user") { break; } - const summary = turnDiffSummaryByAssistantMessageId.get( - nextEntry.message.id, - ); + const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); if (!summary) { continue; } const turnCount = - summary.checkpointTurnCount ?? - inferredCheckpointTurnCountByTurnId[summary.turnId]; + summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; if (typeof turnCount !== "number") { break; } @@ -1010,11 +810,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } return byUserMessageId; - }, [ - inferredCheckpointTurnCountByTurnId, - timelineEntries, - turnDiffSummaryByAssistantMessageId, - ]); + }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); const completionSummary = useMemo(() => { if (!latestTurnSettled) return null; @@ -1022,10 +818,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activeLatestTurn.completedAt) return null; if (!latestTurnHasToolActivity) return null; - const elapsed = formatElapsed( - activeLatestTurn.startedAt, - activeLatestTurn.completedAt, - ); + const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt); return elapsed ? `Worked for ${elapsed}` : null; }, [ activeLatestTurn?.completedAt, @@ -1066,16 +859,14 @@ export default function ChatView({ threadId }: ChatViewProps) { ]); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; const composerTriggerKind = composerTrigger?.kind ?? null; - const pathTriggerQuery = - composerTrigger?.kind === "path" ? composerTrigger.query : ""; + const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( pathTriggerQuery, { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, (debouncerState) => ({ isPending: debouncerState.isPending }), ); - const effectivePathQuery = - pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; + const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const workspaceEntriesQuery = useQuery( @@ -1086,8 +877,7 @@ export default function ChatView({ threadId }: ChatViewProps) { limit: 80, }), ); - const workspaceEntries = - workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; + const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { @@ -1124,16 +914,13 @@ export default function ChatView({ threadId }: ChatViewProps) { label: "/default", description: "Switch this thread back to normal chat mode", }, - ] satisfies ReadonlyArray< - Extract - >; + ] satisfies ReadonlyArray>; const query = composerTrigger.query.trim().toLowerCase(); if (!query) { return [...slashCommandItems]; } return slashCommandItems.filter( - (item) => - item.command.includes(query) || item.label.slice(1).includes(query), + (item) => item.command.includes(query) || item.label.slice(1).includes(query), ); } @@ -1142,9 +929,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const query = composerTrigger.query.trim().toLowerCase(); if (!query) return true; return ( - searchSlug.includes(query) || - searchName.includes(query) || - searchProvider.includes(query) + searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query) ); }) .map(({ provider, providerLabel, slug, name }) => ({ @@ -1172,15 +957,11 @@ export default function ChatView({ threadId }: ChatViewProps) { [nonPersistedComposerImageIds], ); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; - const availableEditors = - serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; - const providerStatuses = - serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; + const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; + const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; const activeProvider = activeThread?.session?.provider ?? "codex"; const activeProviderStatus = useMemo( - () => - providerStatuses.find((status) => status.provider === activeProvider) ?? - null, + () => providerStatuses.find((status) => status.provider === activeProvider) ?? null, [activeProvider, providerStatuses], ); const activeProjectCwd = activeProject?.cwd ?? null; @@ -1226,12 +1007,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const envLocked = Boolean( activeThread && - (activeThread.messages.length > 0 || - (activeThread.session !== null && - activeThread.session.status !== "closed")), + (activeThread.messages.length > 0 || + (activeThread.session !== null && activeThread.session.status !== "closed")), ); - const hasReachedTerminalLimit = - terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT; + const hasReachedTerminalLimit = terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT; const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; @@ -1354,13 +1133,10 @@ export default function ChatView({ threadId }: ChatViewProps) { terminalState.activeTerminalId || terminalState.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; - const isBaseTerminalBusy = - terminalState.runningTerminalIds.includes(baseTerminalId); - const wantsNewTerminal = - Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; + const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId); + const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; const shouldCreateNewTerminal = - wantsNewTerminal && - terminalState.terminalIds.length < MAX_THREAD_TERMINAL_COUNT; + wantsNewTerminal && terminalState.terminalIds.length < MAX_THREAD_TERMINAL_COUNT; const targetTerminalId = shouldCreateNewTerminal ? `terminal-${randomUUID()}` : baseTerminalId; @@ -1377,26 +1153,24 @@ export default function ChatView({ threadId }: ChatViewProps) { project: { cwd: activeProject.cwd, }, - worktreePath: - options?.worktreePath ?? activeThread.worktreePath ?? null, + worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, ...(options?.env ? { extraEnv: options.env } : {}), }); - const openTerminalInput: Parameters[0] = - shouldCreateNewTerminal - ? { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - env: runtimeEnv, - cols: SCRIPT_TERMINAL_COLS, - rows: SCRIPT_TERMINAL_ROWS, - } - : { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - env: runtimeEnv, - }; + const openTerminalInput: Parameters[0] = shouldCreateNewTerminal + ? { + threadId: activeThreadId, + terminalId: targetTerminalId, + cwd: targetCwd, + env: runtimeEnv, + cols: SCRIPT_TERMINAL_COLS, + rows: SCRIPT_TERMINAL_ROWS, + } + : { + threadId: activeThreadId, + terminalId: targetTerminalId, + cwd: targetCwd, + env: runtimeEnv, + }; try { await api.terminal.open(openTerminalInput); @@ -1408,9 +1182,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } catch (error) { setThreadError( activeThreadId, - error instanceof Error - ? error.message - : `Failed to run script "${script.name}".`, + error instanceof Error ? error.message : `Failed to run script "${script.name}".`, ); } }, @@ -1477,9 +1249,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextScripts = input.runOnWorktreeCreate ? [ ...activeProject.scripts.map((script) => - script.runOnWorktreeCreate - ? { ...script, runOnWorktreeCreate: false } - : script, + script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, ), nextScript, ] @@ -1499,9 +1269,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const updateProjectScript = useCallback( async (scriptId: string, input: NewProjectScriptInput) => { if (!activeProject) return; - const existingScript = activeProject.scripts.find( - (script) => script.id === scriptId, - ); + const existingScript = activeProject.scripts.find((script) => script.id === scriptId); if (!existingScript) { throw new Error("Script not found."); } @@ -1535,13 +1303,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const deleteProjectScript = useCallback( async (scriptId: string) => { if (!activeProject) return; - const nextScripts = activeProject.scripts.filter( - (script) => script.id !== scriptId, - ); + const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId); - const deletedName = activeProject.scripts.find( - (s) => s.id === scriptId, - )?.name; + const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name; try { await persistProjectScripts({ @@ -1560,10 +1324,7 @@ export default function ChatView({ threadId }: ChatViewProps) { toastManager.add({ type: "error", title: "Could not delete action", - description: - error instanceof Error - ? error.message - : "An unexpected error occurred.", + description: error instanceof Error ? error.message : "An unexpected error occurred.", }); } }, @@ -1608,9 +1369,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ], ); const toggleInteractionMode = useCallback(() => { - handleInteractionModeChange( - interactionMode === "plan" ? "default" : "plan", - ); + handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); const toggleRuntimeMode = useCallback(() => { void handleRuntimeModeChange( @@ -1620,8 +1379,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const togglePlanSidebar = useCallback(() => { setPlanSidebarOpen((open) => { if (open) { - const turnKey = - activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; + const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; if (turnKey) { planSidebarDismissedForTurnRef.current = turnKey; } @@ -1697,16 +1455,13 @@ export default function ChatView({ threadId }: ChatViewProps) { // Auto-scroll on new messages const messageCount = timelineMessages.length; - const scrollMessagesToBottom = useCallback( - (behavior: ScrollBehavior = "auto") => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); - lastKnownScrollTopRef.current = scrollContainer.scrollTop; - shouldAutoScrollRef.current = true; - }, - [], - ); + const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { + const scrollContainer = messagesScrollRef.current; + if (!scrollContainer) return; + scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); + lastKnownScrollTopRef.current = scrollContainer.scrollTop; + shouldAutoScrollRef.current = true; + }, []); const cancelPendingStickToBottom = useCallback(() => { const pendingFrame = pendingAutoScrollFrameRef.current; if (pendingFrame === null) return; @@ -1743,27 +1498,21 @@ export default function ChatView({ threadId }: ChatViewProps) { }; cancelPendingInteractionAnchorAdjustment(); - pendingInteractionAnchorFrameRef.current = window.requestAnimationFrame( - () => { - pendingInteractionAnchorFrameRef.current = null; - const anchor = pendingInteractionAnchorRef.current; - pendingInteractionAnchorRef.current = null; - const activeScrollContainer = messagesScrollRef.current; - if (!anchor || !activeScrollContainer) return; - if ( - !anchor.element.isConnected || - !activeScrollContainer.contains(anchor.element) - ) - return; - - const nextTop = anchor.element.getBoundingClientRect().top; - const delta = nextTop - anchor.top; - if (Math.abs(delta) < 0.5) return; - - activeScrollContainer.scrollTop += delta; - lastKnownScrollTopRef.current = activeScrollContainer.scrollTop; - }, - ); + pendingInteractionAnchorFrameRef.current = window.requestAnimationFrame(() => { + pendingInteractionAnchorFrameRef.current = null; + const anchor = pendingInteractionAnchorRef.current; + pendingInteractionAnchorRef.current = null; + const activeScrollContainer = messagesScrollRef.current; + if (!anchor || !activeScrollContainer) return; + if (!anchor.element.isConnected || !activeScrollContainer.contains(anchor.element)) return; + + const nextTop = anchor.element.getBoundingClientRect().top; + const delta = nextTop - anchor.top; + if (Math.abs(delta) < 0.5) return; + + activeScrollContainer.scrollTop += delta; + lastKnownScrollTopRef.current = activeScrollContainer.scrollTop; + }); }, [cancelPendingInteractionAnchorAdjustment], ); @@ -1771,11 +1520,7 @@ export default function ChatView({ threadId }: ChatViewProps) { cancelPendingStickToBottom(); scrollMessagesToBottom(); scheduleStickToBottom(); - }, [ - cancelPendingStickToBottom, - scheduleStickToBottom, - scrollMessagesToBottom, - ]); + }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]); const onMessagesScroll = useCallback(() => { const scrollContainer = messagesScrollRef.current; if (!scrollContainer) return; @@ -1785,19 +1530,13 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!shouldAutoScrollRef.current && isNearBottom) { shouldAutoScrollRef.current = true; pendingUserScrollUpIntentRef.current = false; - } else if ( - shouldAutoScrollRef.current && - pendingUserScrollUpIntentRef.current - ) { + } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) { const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; if (scrolledUp) { shouldAutoScrollRef.current = false; } pendingUserScrollUpIntentRef.current = false; - } else if ( - shouldAutoScrollRef.current && - isPointerScrollActiveRef.current - ) { + } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; if (scrolledUp) { shouldAutoScrollRef.current = false; @@ -1812,58 +1551,37 @@ export default function ChatView({ threadId }: ChatViewProps) { lastKnownScrollTopRef.current = currentScrollTop; }, []); - const onMessagesWheel = useCallback( - (event: React.WheelEvent) => { - if (event.deltaY < 0) { - pendingUserScrollUpIntentRef.current = true; - } - }, - [], - ); - const onMessagesPointerDown = useCallback( - (_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = true; - }, - [], - ); - const onMessagesPointerUp = useCallback( - (_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = false; - }, - [], - ); - const onMessagesPointerCancel = useCallback( - (_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = false; - }, - [], - ); - const onMessagesTouchStart = useCallback( - (event: React.TouchEvent) => { - const touch = event.touches[0]; - if (!touch) return; - lastTouchClientYRef.current = touch.clientY; - }, - [], - ); - const onMessagesTouchMove = useCallback( - (event: React.TouchEvent) => { - const touch = event.touches[0]; - if (!touch) return; - const previousTouchY = lastTouchClientYRef.current; - if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { - pendingUserScrollUpIntentRef.current = true; - } - lastTouchClientYRef.current = touch.clientY; - }, - [], - ); - const onMessagesTouchEnd = useCallback( - (_event: React.TouchEvent) => { - lastTouchClientYRef.current = null; - }, - [], - ); + const onMessagesWheel = useCallback((event: React.WheelEvent) => { + if (event.deltaY < 0) { + pendingUserScrollUpIntentRef.current = true; + } + }, []); + const onMessagesPointerDown = useCallback((_event: React.PointerEvent) => { + isPointerScrollActiveRef.current = true; + }, []); + const onMessagesPointerUp = useCallback((_event: React.PointerEvent) => { + isPointerScrollActiveRef.current = false; + }, []); + const onMessagesPointerCancel = useCallback((_event: React.PointerEvent) => { + isPointerScrollActiveRef.current = false; + }, []); + const onMessagesTouchStart = useCallback((event: React.TouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + lastTouchClientYRef.current = touch.clientY; + }, []); + const onMessagesTouchMove = useCallback((event: React.TouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + const previousTouchY = lastTouchClientYRef.current; + if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { + pendingUserScrollUpIntentRef.current = true; + } + lastTouchClientYRef.current = touch.clientY; + }, []); + const onMessagesTouchEnd = useCallback((_event: React.TouchEvent) => { + lastTouchClientYRef.current = null; + }, []); useEffect(() => { return () => { cancelPendingStickToBottom(); @@ -1901,22 +1619,16 @@ export default function ChatView({ threadId }: ChatViewProps) { const [entry] = entries; if (!entry) return; - const nextCompact = shouldUseCompactComposerFooter( - measureComposerFormWidth(), - { - hasWideActions: composerFooterHasWideActions, - }, - ); - setIsComposerFooterCompact((previous) => - previous === nextCompact ? previous : nextCompact, - ); + const nextCompact = shouldUseCompactComposerFooter(measureComposerFormWidth(), { + hasWideActions: composerFooterHasWideActions, + }); + setIsComposerFooterCompact((previous) => (previous === nextCompact ? previous : nextCompact)); const nextHeight = entry.contentRect.height; const previousHeight = composerFormHeightRef.current; composerFormHeightRef.current = nextHeight; - if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) - return; + if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return; if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); }); @@ -1983,12 +1695,8 @@ export default function ChatView({ threadId }: ChatViewProps) { if (activeThread.messages.length === 0) { return; } - const serverIds = new Set( - activeThread.messages.map((message) => message.id), - ); - const removedMessages = optimisticUserMessages.filter((message) => - serverIds.has(message.id), - ); + const serverIds = new Set(activeThread.messages.map((message) => message.id)); + const removedMessages = optimisticUserMessages.filter((message) => serverIds.has(message.id)); if (removedMessages.length === 0) { return; } @@ -2008,18 +1716,11 @@ export default function ChatView({ threadId }: ChatViewProps) { return () => { window.clearTimeout(timer); }; - }, [ - activeThread?.id, - activeThread?.messages, - handoffAttachmentPreviews, - optimisticUserMessages, - ]); + }, [activeThread?.id, activeThread?.messages, handoffAttachmentPreviews, optimisticUserMessages]); useEffect(() => { promptRef.current = prompt; - setComposerCursor((existing) => - Math.min(Math.max(0, existing), prompt.length), - ); + setComposerCursor((existing) => Math.min(Math.max(0, existing), prompt.length)); }, [prompt]); useEffect(() => { @@ -2033,9 +1734,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setSendStartedAt(null); setComposerHighlightedItemId(null); setComposerCursor(promptRef.current.length); - setComposerTrigger( - detectComposerTrigger(promptRef.current, promptRef.current.length), - ); + setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); @@ -2049,20 +1748,13 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } const getPersistedAttachmentsForThread = () => - useComposerDraftStore.getState().draftsByThreadId[threadId] - ?.persistedAttachments ?? []; + useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? []; try { const currentPersistedAttachments = getPersistedAttachmentsForThread(); const existingPersistedById = new Map( - currentPersistedAttachments.map((attachment) => [ - attachment.id, - attachment, - ]), + currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), ); - const stagedAttachmentById = new Map< - string, - PersistedComposerImageAttachment - >(); + const stagedAttachmentById = new Map(); await Promise.all( composerImages.map(async (image) => { try { @@ -2089,16 +1781,14 @@ export default function ChatView({ threadId }: ChatViewProps) { // Stage attachments in persisted draft state first so persist middleware can write them. syncComposerDraftPersistedAttachments(threadId, serialized); } catch { - const currentImageIds = new Set( - composerImages.map((image) => image.id), - ); + const currentImageIds = new Set(composerImages.map((image) => image.id)); const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); const fallbackPersistedIds = fallbackPersistedAttachments .map((attachment) => attachment.id) .filter((id) => currentImageIds.has(id)); const fallbackPersistedIdSet = new Set(fallbackPersistedIds); - const fallbackAttachments = fallbackPersistedAttachments.filter( - (attachment) => fallbackPersistedIdSet.has(attachment.id), + const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => + fallbackPersistedIdSet.has(attachment.id), ); if (cancelled) { return; @@ -2125,8 +1815,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return existing; } const nextIndex = - (existing.index + direction + existing.images.length) % - existing.images.length; + (existing.index + direction + existing.images.length) % existing.images.length; if (nextIndex === existing.index) { return existing; } @@ -2182,13 +1871,10 @@ export default function ChatView({ threadId }: ChatViewProps) { }; }, [phase]); - const beginSendPhase = useCallback( - (nextPhase: Exclude) => { - setSendStartedAt((current) => current ?? new Date().toISOString()); - setSendPhase(nextPhase); - }, - [], - ); + const beginSendPhase = useCallback((nextPhase: Exclude) => { + setSendStartedAt((current) => current ?? new Date().toISOString()); + setSendPhase(nextPhase); + }, []); const resetSendPhase = useCallback(() => { setSendPhase("idle"); @@ -2242,8 +1928,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const isTerminalFocused = (): boolean => { const activeElement = document.activeElement; if (!(activeElement instanceof HTMLElement)) return false; - if (activeElement.classList.contains("xterm-helper-textarea")) - return true; + if (activeElement.classList.contains("xterm-helper-textarea")) return true; return activeElement.closest(".thread-terminal-drawer .xterm") !== null; }; @@ -2303,9 +1988,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; - const script = activeProject.scripts.find( - (entry) => entry.id === scriptId, - ); + const script = activeProject.scripts.find((entry) => entry.id === scriptId); if (!script) return; event.preventDefault(); event.stopPropagation(); @@ -2410,10 +2093,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } event.preventDefault(); const nextTarget = event.relatedTarget; - if ( - nextTarget instanceof Node && - event.currentTarget.contains(nextTarget) - ) { + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { return; } dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); @@ -2440,10 +2120,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!api || !activeThread || isRevertingCheckpoint) return; if (phase === "running" || isSendBusy || isConnecting) { - setThreadError( - activeThread.id, - "Interrupt the current turn before reverting checkpoints.", - ); + setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); return; } const confirmed = await api.dialogs.confirm( @@ -2475,27 +2152,13 @@ export default function ChatView({ threadId }: ChatViewProps) { } setIsRevertingCheckpoint(false); }, - [ - activeThread, - isConnecting, - isRevertingCheckpoint, - isSendBusy, - phase, - setThreadError, - ], + [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], ); const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); const api = readNativeApi(); - if ( - !api || - !activeThread || - isSendBusy || - isConnecting || - sendInFlightRef.current - ) - return; + if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; if (activePendingProgress) { onAdvanceActivePendingUserInput(); return; @@ -2518,9 +2181,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } const standaloneSlashCommand = - composerImages.length === 0 - ? parseStandaloneComposerSlashCommand(trimmed) - : null; + composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { await handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; @@ -2533,8 +2194,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!trimmed && composerImages.length === 0) return; if (!activeProject) return; const threadIdForSend = activeThread.id; - const isFirstMessage = - !isServerThread || activeThread.messages.length === 0; + const isFirstMessage = !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = isFirstMessage && envMode === "worktree" && !activeThread.worktreePath ? activeThread.branch @@ -2553,9 +2213,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } sendInFlightRef.current = true; - beginSendPhase( - baseBranchForWorktree ? "preparing-worktree" : "sending-turn", - ); + beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); const composerImagesSnapshot = [...composerImages]; const messageIdForSend = newMessageId(); @@ -2583,9 +2241,7 @@ export default function ChatView({ threadId }: ChatViewProps) { id: messageIdForSend, role: "user", text: trimmed, - ...(optimisticAttachments.length > 0 - ? { attachments: optimisticAttachments } - : {}), + ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), createdAt: messageCreatedAt, streaming: false, }, @@ -2627,11 +2283,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); // Keep local thread state in sync immediately so terminal drawer opens // with the worktree cwd/env instead of briefly using the project root. - setStoreThreadBranch( - threadIdForSend, - result.worktree.branch, - result.worktree.path, - ); + setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); } } @@ -2652,9 +2304,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } const title = truncateTitle(titleSeed); let threadCreateModel: ModelSlug = - selectedModel || - (activeProject.model as ModelSlug) || - DEFAULT_MODEL_BY_PROVIDER.codex; + selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex; if (isLocalDraftThread) { await api.orchestration.dispatchCommand({ @@ -2735,13 +2385,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - ...(providerOptionsForDispatch - ? { providerOptions: providerOptionsForDispatch } - : {}), + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), provider: selectedProvider, - assistantDeliveryMode: settings.enableAssistantStreaming - ? "streaming" - : "buffered", + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode, createdAt: messageCreatedAt, @@ -2763,23 +2409,17 @@ export default function ChatView({ threadId }: ChatViewProps) { composerImagesRef.current.length === 0 ) { setOptimisticUserMessages((existing) => { - const removed = existing.filter( - (message) => message.id === messageIdForSend, - ); + const removed = existing.filter((message) => message.id === messageIdForSend); for (const message of removed) { revokeUserMessagePreviewUrls(message); } - const next = existing.filter( - (message) => message.id !== messageIdForSend, - ); + const next = existing.filter((message) => message.id !== messageIdForSend); return next.length === existing.length ? existing : next; }); promptRef.current = trimmed; setPrompt(trimmed); setComposerCursor(trimmed.length); - addComposerImagesToDraft( - composerImagesSnapshot.map(cloneComposerImageForRetry), - ); + addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length)); } setThreadError( @@ -2805,10 +2445,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }; const onRespondToApproval = useCallback( - async ( - requestId: ApprovalRequestId, - decision: ProviderApprovalDecision, - ) => { + async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { const api = readNativeApi(); if (!api || !activeThreadId) return; @@ -2827,14 +2464,10 @@ export default function ChatView({ threadId }: ChatViewProps) { .catch((err: unknown) => { setStoreThreadError( activeThreadId, - err instanceof Error - ? err.message - : "Failed to submit approval decision.", + err instanceof Error ? err.message : "Failed to submit approval decision.", ); }); - setRespondingRequestIds((existing) => - existing.filter((id) => id !== requestId), - ); + setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); }, [activeThreadId, setStoreThreadError], ); @@ -2862,9 +2495,7 @@ export default function ChatView({ threadId }: ChatViewProps) { err instanceof Error ? err.message : "Failed to submit user input.", ); }); - setRespondingUserInputRequestIds((existing) => - existing.filter((id) => id !== requestId), - ); + setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); }, [activeThreadId, setStoreThreadError], ); @@ -2905,12 +2536,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const onChangeActivePendingUserInputCustomAnswer = useCallback( - ( - questionId: string, - value: string, - nextCursor: number, - cursorAdjacentToMention: boolean, - ) => { + (questionId: string, value: string, nextCursor: number, cursorAdjacentToMention: boolean) => { if (!activePendingUserInput) { return; } @@ -2929,10 +2555,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerTrigger( cursorAdjacentToMention ? null - : detectComposerTrigger( - value, - expandCollapsedComposerCursor(value, nextCursor), - ), + : detectComposerTrigger(value, expandCollapsedComposerCursor(value, nextCursor)), ); }, [activePendingUserInput], @@ -2944,16 +2567,11 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (activePendingProgress.isLastQuestion) { if (activePendingResolvedAnswers) { - void onRespondToUserInput( - activePendingUserInput.requestId, - activePendingResolvedAnswers, - ); + void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers); } return; } - setActivePendingUserInputQuestionIndex( - activePendingProgress.questionIndex + 1, - ); + setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); }, [ activePendingProgress, activePendingResolvedAnswers, @@ -2966,9 +2584,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activePendingProgress) { return; } - setActivePendingUserInputQuestionIndex( - Math.max(activePendingProgress.questionIndex - 1, 0), - ); + setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0)); }, [activePendingProgress, setActivePendingUserInputQuestionIndex]); const onSubmitPlanFollowUp = useCallback( @@ -3044,12 +2660,8 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - ...(providerOptionsForDispatch - ? { providerOptions: providerOptionsForDispatch } - : {}), - assistantDeliveryMode: settings.enableAssistantStreaming - ? "streaming" - : "buffered", + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, createdAt: messageCreatedAt, @@ -3113,9 +2725,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextThreadId = newThreadId(); const planMarkdown = activeProposedPlan.planMarkdown; const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); - const nextThreadTitle = truncateTitle( - buildPlanImplementationThreadTitle(planMarkdown), - ); + const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModel: ModelSlug = selectedModel || (activeThread.model as ModelSlug) || @@ -3159,12 +2769,8 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - ...(providerOptionsForDispatch - ? { providerOptions: providerOptionsForDispatch } - : {}), - assistantDeliveryMode: settings.enableAssistantStreaming - ? "streaming" - : "buffered", + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", createdAt, @@ -3198,9 +2804,7 @@ export default function ChatView({ threadId }: ChatViewProps) { type: "error", title: "Could not start implementation thread", description: - err instanceof Error - ? err.message - : "An error occurred while creating the new thread.", + err instanceof Error ? err.message : "An error occurred while creating the new thread.", }); }) .then(finish, finish); @@ -3267,12 +2871,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } scheduleComposerFocus(); }, - [ - isLocalDraftThread, - scheduleComposerFocus, - setDraftThreadContext, - threadId, - ], + [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId], ); const applyPromptReplacement = useCallback( @@ -3284,22 +2883,14 @@ export default function ChatView({ threadId }: ChatViewProps) { ): boolean => { const currentText = promptRef.current; const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); - const safeEnd = Math.max( - safeStart, - Math.min(currentText.length, rangeEnd), - ); + const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); if ( options?.expectedText !== undefined && currentText.slice(safeStart, safeEnd) !== options.expectedText ) { return false; } - const next = replaceTextRange( - promptRef.current, - rangeStart, - rangeEnd, - replacement, - ); + const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); promptRef.current = next.text; const activePendingQuestion = activePendingProgress?.activeQuestion; if (activePendingQuestion && activePendingUserInput) { @@ -3308,9 +2899,7 @@ export default function ChatView({ threadId }: ChatViewProps) { [activePendingUserInput.requestId]: { ...existing[activePendingUserInput.requestId], [activePendingQuestion.id]: setPendingUserInputCustomAnswer( - existing[activePendingUserInput.requestId]?.[ - activePendingQuestion.id - ], + existing[activePendingUserInput.requestId]?.[activePendingQuestion.id], next.text, ), }, @@ -3344,10 +2933,7 @@ export default function ChatView({ threadId }: ChatViewProps) { trigger: ComposerTrigger | null; } => { const snapshot = readComposerSnapshot(); - const expandedCursor = expandCollapsedComposerCursor( - snapshot.value, - snapshot.cursor, - ); + const expandedCursor = expandCollapsedComposerCursor(snapshot.value, snapshot.cursor); return { snapshot, trigger: detectComposerTrigger(snapshot.value, expandedCursor), @@ -3363,10 +2949,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); const { snapshot, trigger } = resolveActiveComposerTrigger(); if (!trigger) return; - const expectedToken = snapshot.value.slice( - trigger.rangeStart, - trigger.rangeEnd, - ); + const expectedToken = snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd); if (item.type === "path") { const applied = applyPromptReplacement( trigger.rangeStart, @@ -3381,44 +2964,27 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (item.type === "slash-command") { if (item.command === "model") { - const applied = applyPromptReplacement( - trigger.rangeStart, - trigger.rangeEnd, - "/model ", - { - expectedText: expectedToken, - }, - ); + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "/model ", { + expectedText: expectedToken, + }); if (applied) { setComposerHighlightedItemId(null); } return; } - void handleInteractionModeChange( - item.command === "plan" ? "plan" : "default", - ); - const applied = applyPromptReplacement( - trigger.rangeStart, - trigger.rangeEnd, - "", - { - expectedText: expectedToken, - }, - ); + void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: expectedToken, + }); if (applied) { setComposerHighlightedItemId(null); } return; } onProviderModelSelect(item.provider, item.model); - const applied = applyPromptReplacement( - trigger.rangeStart, - trigger.rangeEnd, - "", - { - expectedText: expectedToken, - }, - ); + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: expectedToken, + }); if (applied) { setComposerHighlightedItemId(null); } @@ -3445,8 +3011,7 @@ export default function ChatView({ threadId }: ChatViewProps) { highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; const offset = key === "ArrowDown" ? 1 : -1; const nextIndex = - (normalizedIndex + offset + composerMenuItems.length) % - composerMenuItems.length; + (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; const nextItem = composerMenuItems[nextIndex]; setComposerHighlightedItemId(nextItem?.id ?? null); }, @@ -3454,17 +3019,12 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const isComposerMenuLoading = composerTriggerKind === "path" && - ((pathTriggerQuery.length > 0 && - composerPathQueryDebouncer.state.isPending) || + ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || workspaceEntriesQuery.isLoading || workspaceEntriesQuery.isFetching); const onPromptChange = useCallback( - ( - nextPrompt: string, - nextCursor: number, - cursorAdjacentToMention: boolean, - ) => { + (nextPrompt: string, nextCursor: number, cursorAdjacentToMention: boolean) => { if (activePendingProgress?.activeQuestion && activePendingUserInput) { onChangeActivePendingUserInputCustomAnswer( activePendingProgress.activeQuestion.id, @@ -3517,8 +3077,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return true; } if (key === "Tab" || key === "Enter") { - const selectedItem = - activeComposerMenuItemRef.current ?? currentItems[0]; + const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; if (selectedItem) { onSelectComposerItem(selectedItem); return true; @@ -3541,9 +3100,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); - const expandedImageItem = expandedImage - ? expandedImage.images[expandedImage.index] - : null; + const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { void navigate({ @@ -3575,24 +3132,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
- - Threads - + Threads
)} {isElectron && (
- - No active thread - + No active thread
)}
-

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

+

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

@@ -3605,9 +3156,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
{/* Input bar */} -
+
) : null} @@ -3815,8 +3350,8 @@ export default function ChatView({ threadId }: ChatViewProps) { side="top" className="max-w-64 whitespace-normal leading-tight" > - Draft attachment could not be saved locally - and may be lost on navigation. + Draft attachment could not be saved locally and may be lost on + navigation. )} @@ -3867,9 +3402,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
@@ -3903,17 +3436,13 @@ export default function ChatView({ threadId }: ChatViewProps) { {isComposerFooterCompact ? ( ) : ( <> - {selectedProvider === "codex" && - selectedEffort != null ? ( + {selectedProvider === "codex" && selectedEffort != null ? ( <> void handleRuntimeModeChange( - runtimeMode === "full-access" - ? "approval-required" - : "full-access", + runtimeMode === "full-access" ? "approval-required" : "full-access", ) } title={ @@ -3986,21 +3512,13 @@ export default function ChatView({ threadId }: ChatViewProps) { : "Approval required — click for full access" } > - {runtimeMode === "full-access" ? ( - - ) : ( - - )} + {runtimeMode === "full-access" ? : } - {runtimeMode === "full-access" - ? "Full access" - : "Supervised"} + {runtimeMode === "full-access" ? "Full access" : "Supervised"} - {activePlan || - activeProposedPlan || - planSidebarOpen ? ( + {activePlan || activeProposedPlan || planSidebarOpen ? ( <> - - Plan - + Plan ) : null} @@ -4101,9 +3613,7 @@ export default function ChatView({ threadId }: ChatViewProps) { className="h-9 rounded-full px-4 sm:h-8" disabled={isSendBusy || isConnecting} > - {isConnecting || isSendBusy - ? "Sending..." - : "Refine"} + {isConnecting || isSendBusy ? "Sending..." : "Refine"} ) : (
@@ -4113,9 +3623,7 @@ export default function ChatView({ threadId }: ChatViewProps) { className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8" disabled={isSendBusy || isConnecting} > - {isConnecting || isSendBusy - ? "Sending..." - : "Implement"} + {isConnecting || isSendBusy ? "Sending..." : "Implement"} - void onImplementPlanInNewThread() - } + onClick={() => void onImplementPlanInNewThread()} > Implement in new thread @@ -4247,8 +3753,7 @@ export default function ChatView({ threadId }: ChatViewProps) { onClose={() => { setPlanSidebarOpen(false); // Track that the user explicitly dismissed for this turn so auto-open won't fight them. - const turnKey = - activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; + const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; if (turnKey) { planSidebarDismissedForTurnRef.current = turnKey; } diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index d04067d9a1..0174a77088 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -1,10 +1,7 @@ import { type TurnId } from "@t3tools/contracts"; import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { type TurnDiffFileChange } from "../../types"; -import { - buildTurnDiffTree, - type TurnDiffTreeNode, -} from "../../lib/turnDiffTree"; +import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; @@ -17,13 +14,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { - const { - files, - allDirectoriesExpanded, - onOpenTurnDiff, - resolvedTheme, - turnId, - } = props; + const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props; const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); const directoryPathsKey = useMemo( () => collectDirectoryPaths(treeNodes).join("\u0000"), @@ -37,27 +28,19 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { ), [allDirectoriesExpanded, directoryPathsKey], ); - const [expandedDirectories, setExpandedDirectories] = useState< - Record - >(() => - buildDirectoryExpansionState( - directoryPathsKey ? directoryPathsKey.split("\u0000") : [], - true, - ), + const [expandedDirectories, setExpandedDirectories] = useState>(() => + buildDirectoryExpansionState(directoryPathsKey ? directoryPathsKey.split("\u0000") : [], true), ); useEffect(() => { setExpandedDirectories(allDirectoryExpansionState); }, [allDirectoryExpansionState]); - const toggleDirectory = useCallback( - (pathValue: string, fallbackExpanded: boolean) => { - setExpandedDirectories((current) => ({ - ...current, - [pathValue]: !(current[pathValue] ?? fallbackExpanded), - })); - }, - [], - ); + const toggleDirectory = useCallback((pathValue: string, fallbackExpanded: boolean) => { + setExpandedDirectories((current) => ({ + ...current, + [pathValue]: !(current[pathValue] ?? fallbackExpanded), + })); + }, []); const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => { const leftPadding = 8 + depth * 14; @@ -88,18 +71,13 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { {hasNonZeroStat(node.stat) && ( - + )} {isExpanded && (
- {node.children.map((childNode) => - renderTreeNode(childNode, depth + 1), - )} + {node.children.map((childNode) => renderTreeNode(childNode, depth + 1))}
)}
@@ -126,26 +104,17 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { {node.stat && ( - + )} ); }; - return ( -
- {treeNodes.map((node) => renderTreeNode(node, 0))} -
- ); + return
{treeNodes.map((node) => renderTreeNode(node, 0))}
; }); -function collectDirectoryPaths( - nodes: ReadonlyArray, -): string[] { +function collectDirectoryPaths(nodes: ReadonlyArray): string[] { const paths: string[] = []; for (const node of nodes) { if (node.kind !== "directory") continue; diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 688520b9d5..ea7f911bec 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -9,9 +9,7 @@ import GitActionsControl from "../GitActionsControl"; import { DiffIcon } from "lucide-react"; import { Badge } from "../ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import ProjectScriptsControl, { - type NewProjectScriptInput, -} from "../ProjectScriptsControl"; +import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; import { Toggle } from "../ui/toggle"; import { SidebarTrigger } from "../ui/sidebar"; import { OpenInPicker } from "./OpenInPicker"; @@ -31,10 +29,7 @@ interface ChatHeaderProps { diffOpen: boolean; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; - onUpdateProjectScript: ( - scriptId: string, - input: NewProjectScriptInput, - ) => Promise; + onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; onDeleteProjectScript: (scriptId: string) => Promise; onToggleDiff: () => void; } @@ -74,10 +69,7 @@ export const ChatHeader = memo(function ChatHeader({ )} {activeProjectName && !isGitRepo && ( - + No Git )} @@ -101,9 +93,7 @@ export const ChatHeader = memo(function ChatHeader({ openInCwd={openInCwd} /> )} - {activeProjectName && ( - - )} + {activeProjectName && } -
- Reasoning -
+
Reasoning
{ if (!value) return; - const nextEffort = props.options.find( - (option) => option === value, - ); + const nextEffort = props.options.find((option) => option === value); if (!nextEffort) return; props.onEffortChange(nextEffort); }} @@ -80,9 +76,7 @@ export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {
-
- Fast Mode -
+
Fast Mode
{ diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 9e6ede52ed..0af50ff01e 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -19,135 +19,118 @@ import { MenuTrigger, } from "../ui/menu"; -export const CompactComposerControlsMenu = memo( - function CompactComposerControlsMenu(props: { - activePlan: boolean; - interactionMode: ProviderInteractionMode; - planSidebarOpen: boolean; - runtimeMode: RuntimeMode; - selectedEffort: CodexReasoningEffort | null; - selectedProvider: ProviderKind; - selectedCodexFastModeEnabled: boolean; - reasoningOptions: ReadonlyArray; - onEffortSelect: (effort: CodexReasoningEffort) => void; - onCodexFastModeChange: (enabled: boolean) => void; - onToggleInteractionMode: () => void; - onTogglePlanSidebar: () => void; - onToggleRuntimeMode: () => void; - }) { - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningLabelByOption: Record = { - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", - }; +export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { + activePlan: boolean; + interactionMode: ProviderInteractionMode; + planSidebarOpen: boolean; + runtimeMode: RuntimeMode; + selectedEffort: CodexReasoningEffort | null; + selectedProvider: ProviderKind; + selectedCodexFastModeEnabled: boolean; + reasoningOptions: ReadonlyArray; + onEffortSelect: (effort: CodexReasoningEffort) => void; + onCodexFastModeChange: (enabled: boolean) => void; + onToggleInteractionMode: () => void; + onTogglePlanSidebar: () => void; + onToggleRuntimeMode: () => void; +}) { + const defaultReasoningEffort = getDefaultReasoningEffort("codex"); + const reasoningLabelByOption: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + }; - return ( - - - } - > - - - {props.selectedProvider === "codex" && - props.selectedEffort != null ? ( - <> - -
- Reasoning -
- { - if (!value) return; - const nextEffort = props.reasoningOptions.find( - (option) => option === value, - ); - if (!nextEffort) return; - props.onEffortSelect(nextEffort); - }} - > - {props.reasoningOptions.map((effort) => ( - - {reasoningLabelByOption[effort]} - {effort === defaultReasoningEffort ? " (default)" : ""} - - ))} - -
- - -
- Fast Mode -
- { - props.onCodexFastModeChange(value === "on"); - }} - > - off - on - -
- - - ) : null} - -
- Mode -
- { - if (!value || value === props.interactionMode) return; - props.onToggleInteractionMode(); - }} - > - Chat - Plan - -
- - -
- Access -
- { - if (!value || value === props.runtimeMode) return; - props.onToggleRuntimeMode(); - }} - > - - Supervised - - Full access - -
- {props.activePlan ? ( - <> - - - - {props.planSidebarOpen - ? "Hide plan sidebar" - : "Show plan sidebar"} - - - ) : null} -
-
- ); - }, -); + return ( + + + } + > + + + {props.selectedProvider === "codex" && props.selectedEffort != null ? ( + <> + +
Reasoning
+ { + if (!value) return; + const nextEffort = props.reasoningOptions.find((option) => option === value); + if (!nextEffort) return; + props.onEffortSelect(nextEffort); + }} + > + {props.reasoningOptions.map((effort) => ( + + {reasoningLabelByOption[effort]} + {effort === defaultReasoningEffort ? " (default)" : ""} + + ))} + +
+ + +
Fast Mode
+ { + props.onCodexFastModeChange(value === "on"); + }} + > + off + on + +
+ + + ) : null} + +
Mode
+ { + if (!value || value === props.interactionMode) return; + props.onToggleInteractionMode(); + }} + > + Chat + Plan + +
+ + +
Access
+ { + if (!value || value === props.runtimeMode) return; + props.onToggleRuntimeMode(); + }} + > + Supervised + Full access + +
+ {props.activePlan ? ( + <> + + + + {props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"} + + + ) : null} +
+
+ ); +}); diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 38a1654025..818c3c20f8 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,13 +1,6 @@ -import { - type ProjectEntry, - type ModelSlug, - type ProviderKind, -} from "@t3tools/contracts"; +import { type ProjectEntry, type ModelSlug, type ProviderKind } from "@t3tools/contracts"; import { memo } from "react"; -import { - type ComposerSlashCommand, - type ComposerTriggerKind, -} from "../../composer-logic"; +import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; import { BotIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; @@ -121,9 +114,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { {props.item.label} - - {props.item.description} - + {props.item.description} ); }); diff --git a/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx index aa36228856..5786bab478 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx @@ -1,7 +1,4 @@ -import { - type ApprovalRequestId, - type ProviderApprovalDecision, -} from "@t3tools/contracts"; +import { type ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts"; import { memo } from "react"; import { Button } from "../ui/button"; @@ -14,49 +11,45 @@ interface ComposerPendingApprovalActionsProps { ) => Promise; } -export const ComposerPendingApprovalActions = memo( - function ComposerPendingApprovalActions({ - requestId, - isResponding, - onRespondToApproval, - }: ComposerPendingApprovalActionsProps) { - return ( - <> - - - - - - ); - }, -); +export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({ + requestId, + isResponding, + onRespondToApproval, +}: ComposerPendingApprovalActionsProps) { + return ( + <> + + + + + + ); +}); diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx index dc560c0eca..569fd108a4 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx @@ -6,32 +6,26 @@ interface ComposerPendingApprovalPanelProps { pendingCount: number; } -export const ComposerPendingApprovalPanel = memo( - function ComposerPendingApprovalPanel({ - approval, - pendingCount, - }: ComposerPendingApprovalPanelProps) { - const approvalSummary = - approval.requestKind === "command" - ? "Command approval requested" - : approval.requestKind === "file-read" - ? "File-read approval requested" - : "File-change approval requested"; +export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprovalPanel({ + approval, + pendingCount, +}: ComposerPendingApprovalPanelProps) { + const approvalSummary = + approval.requestKind === "command" + ? "Command approval requested" + : approval.requestKind === "file-read" + ? "File-read approval requested" + : "File-change approval requested"; - return ( -
-
- - PENDING APPROVAL - - {approvalSummary} - {pendingCount > 1 ? ( - - 1/{pendingCount} - - ) : null} -
+ return ( +
+
+ PENDING APPROVAL + {approvalSummary} + {pendingCount > 1 ? ( + 1/{pendingCount} + ) : null}
- ); - }, -); +
+ ); +}); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index c5533d650e..c8cad7bf36 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -17,188 +17,166 @@ interface PendingUserInputPanelProps { onAdvance: () => void; } -export const ComposerPendingUserInputPanel = memo( - function ComposerPendingUserInputPanel({ - pendingUserInputs, - respondingRequestIds, - answers, - questionIndex, - onSelectOption, - onAdvance, - }: PendingUserInputPanelProps) { - if (pendingUserInputs.length === 0) return null; - const activePrompt = pendingUserInputs[0]; - if (!activePrompt) return null; +export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ + pendingUserInputs, + respondingRequestIds, + answers, + questionIndex, + onSelectOption, + onAdvance, +}: PendingUserInputPanelProps) { + if (pendingUserInputs.length === 0) return null; + const activePrompt = pendingUserInputs[0]; + if (!activePrompt) return null; - return ( - - ); - }, -); + return ( + + ); +}); -const ComposerPendingUserInputCard = memo( - function ComposerPendingUserInputCard({ - prompt, - isResponding, - answers, - questionIndex, - onSelectOption, - onAdvance, - }: { - prompt: PendingUserInput; - isResponding: boolean; - answers: Record; - questionIndex: number; - onSelectOption: (questionId: string, optionLabel: string) => void; - onAdvance: () => void; - }) { - const progress = derivePendingUserInputProgress( - prompt.questions, - answers, - questionIndex, - ); - const activeQuestion = progress.activeQuestion; - const autoAdvanceTimerRef = useRef(null); +const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({ + prompt, + isResponding, + answers, + questionIndex, + onSelectOption, + onAdvance, +}: { + prompt: PendingUserInput; + isResponding: boolean; + answers: Record; + questionIndex: number; + onSelectOption: (questionId: string, optionLabel: string) => void; + onAdvance: () => void; +}) { + const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); + const activeQuestion = progress.activeQuestion; + const autoAdvanceTimerRef = useRef(null); - // Clear auto-advance timer on unmount - useEffect(() => { - return () => { - if (autoAdvanceTimerRef.current !== null) { - window.clearTimeout(autoAdvanceTimerRef.current); - } - }; - }, []); + // Clear auto-advance timer on unmount + useEffect(() => { + return () => { + if (autoAdvanceTimerRef.current !== null) { + window.clearTimeout(autoAdvanceTimerRef.current); + } + }; + }, []); - const selectOptionAndAutoAdvance = useCallback( - (questionId: string, optionLabel: string) => { - onSelectOption(questionId, optionLabel); - if (autoAdvanceTimerRef.current !== null) { - window.clearTimeout(autoAdvanceTimerRef.current); - } - autoAdvanceTimerRef.current = window.setTimeout(() => { - autoAdvanceTimerRef.current = null; - onAdvance(); - }, 200); - }, - [onSelectOption, onAdvance], - ); + const selectOptionAndAutoAdvance = useCallback( + (questionId: string, optionLabel: string) => { + onSelectOption(questionId, optionLabel); + if (autoAdvanceTimerRef.current !== null) { + window.clearTimeout(autoAdvanceTimerRef.current); + } + autoAdvanceTimerRef.current = window.setTimeout(() => { + autoAdvanceTimerRef.current = null; + onAdvance(); + }, 200); + }, + [onSelectOption, onAdvance], + ); - // Keyboard shortcut: number keys 1-9 select corresponding option and auto-advance. - // Works even when the Lexical composer (contenteditable) has focus — the composer - // doubles as a custom-answer field during user input, and when it's empty the digit - // keys should pick options instead of typing into the editor. - useEffect(() => { - if (!activeQuestion || isResponding) return; - const handler = (event: globalThis.KeyboardEvent) => { - if (event.metaKey || event.ctrlKey || event.altKey) return; - const target = event.target; - if ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement - ) { - return; - } - // If the user has started typing a custom answer in the contenteditable - // composer, let digit keys pass through so they can type numbers. - if (target instanceof HTMLElement && target.isContentEditable) { - const hasCustomText = progress.customAnswer.length > 0; - if (hasCustomText) return; - } - const digit = Number.parseInt(event.key, 10); - if (Number.isNaN(digit) || digit < 1 || digit > 9) return; - const optionIndex = digit - 1; - if (optionIndex >= activeQuestion.options.length) return; - const option = activeQuestion.options[optionIndex]; - if (!option) return; - event.preventDefault(); - selectOptionAndAutoAdvance(activeQuestion.id, option.label); - }; - document.addEventListener("keydown", handler); - return () => document.removeEventListener("keydown", handler); - }, [ - activeQuestion, - isResponding, - selectOptionAndAutoAdvance, - progress.customAnswer.length, - ]); + // Keyboard shortcut: number keys 1-9 select corresponding option and auto-advance. + // Works even when the Lexical composer (contenteditable) has focus — the composer + // doubles as a custom-answer field during user input, and when it's empty the digit + // keys should pick options instead of typing into the editor. + useEffect(() => { + if (!activeQuestion || isResponding) return; + const handler = (event: globalThis.KeyboardEvent) => { + if (event.metaKey || event.ctrlKey || event.altKey) return; + const target = event.target; + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + return; + } + // If the user has started typing a custom answer in the contenteditable + // composer, let digit keys pass through so they can type numbers. + if (target instanceof HTMLElement && target.isContentEditable) { + const hasCustomText = progress.customAnswer.length > 0; + if (hasCustomText) return; + } + const digit = Number.parseInt(event.key, 10); + if (Number.isNaN(digit) || digit < 1 || digit > 9) return; + const optionIndex = digit - 1; + if (optionIndex >= activeQuestion.options.length) return; + const option = activeQuestion.options[optionIndex]; + if (!option) return; + event.preventDefault(); + selectOptionAndAutoAdvance(activeQuestion.id, option.label); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [activeQuestion, isResponding, selectOptionAndAutoAdvance, progress.customAnswer.length]); - if (!activeQuestion) { - return null; - } + if (!activeQuestion) { + return null; + } - return ( -
-
-
- {prompt.questions.length > 1 ? ( - - {questionIndex + 1}/{prompt.questions.length} - - ) : null} - - {activeQuestion.header} + return ( +
+
+
+ {prompt.questions.length > 1 ? ( + + {questionIndex + 1}/{prompt.questions.length} -
+ ) : null} + + {activeQuestion.header} +
-

- {activeQuestion.question} -

-
- {activeQuestion.options.map((option, index) => { - const isSelected = progress.selectedOptionLabel === option.label; - const shortcutKey = index < 9 ? index + 1 : null; - return ( -
+

{activeQuestion.question}

+
+ {activeQuestion.options.map((option, index) => { + const isSelected = progress.selectedOptionLabel === option.label; + const shortcutKey = index < 9 ? index + 1 : null; + return ( + - ); - })} -
+
+ {isSelected ? : null} + + ); + })}
- ); - }, -); +
+ ); +}); diff --git a/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx b/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx index 607fd8d20d..49b03f7724 100644 --- a/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx +++ b/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx @@ -1,25 +1,21 @@ import { memo } from "react"; -export const ComposerPlanFollowUpBanner = memo( - function ComposerPlanFollowUpBanner({ - planTitle, - }: { - planTitle: string | null; - }) { - return ( -
-
- Plan ready - {planTitle ? ( - - {planTitle} - - ) : null} -
- {/*
+export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({ + planTitle, +}: { + planTitle: string | null; +}) { + return ( +
+
+ Plan ready + {planTitle ? ( + {planTitle} + ) : null} +
+ {/*
Review the plan
*/} -
- ); - }, -); +
+ ); +}); diff --git a/apps/web/src/components/chat/DiffStatLabel.tsx b/apps/web/src/components/chat/DiffStatLabel.tsx index dab67662bf..2dda06fd9d 100644 --- a/apps/web/src/components/chat/DiffStatLabel.tsx +++ b/apps/web/src/components/chat/DiffStatLabel.tsx @@ -1,9 +1,6 @@ import { memo } from "react"; -export function hasNonZeroStat(stat: { - additions: number; - deletions: number; -}): boolean { +export function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean { return stat.additions > 0 || stat.deletions > 0; } diff --git a/apps/web/src/components/chat/ExpandedImagePreview.tsx b/apps/web/src/components/chat/ExpandedImagePreview.tsx index 9fc2aab815..db5803d490 100644 --- a/apps/web/src/components/chat/ExpandedImagePreview.tsx +++ b/apps/web/src/components/chat/ExpandedImagePreview.tsx @@ -13,16 +13,12 @@ export function buildExpandedImagePreview( selectedImageId: string, ): ExpandedImagePreview | null { const previewableImages = images.flatMap((image) => - image.previewUrl - ? [{ id: image.id, src: image.previewUrl, name: image.name }] - : [], + image.previewUrl ? [{ id: image.id, src: image.previewUrl, name: image.name }] : [], ); if (previewableImages.length === 0) { return null; } - const selectedIndex = previewableImages.findIndex( - (image) => image.id === selectedImageId, - ); + const selectedIndex = previewableImages.findIndex((image) => image.id === selectedImageId); if (selectedIndex < 0) { return null; } diff --git a/apps/web/src/components/chat/MessageCopyButton.tsx b/apps/web/src/components/chat/MessageCopyButton.tsx index 93e8f9b46f..b3972d2530 100644 --- a/apps/web/src/components/chat/MessageCopyButton.tsx +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -2,11 +2,7 @@ import { memo, useCallback, useState } from "react"; import { CopyIcon, CheckIcon } from "lucide-react"; import { Button } from "../ui/button"; -export const MessageCopyButton = memo(function MessageCopyButton({ - text, -}: { - text: string; -}) { +export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); const handleCopy = useCallback(() => { @@ -16,18 +12,8 @@ export const MessageCopyButton = memo(function MessageCopyButton({ }, [text]); return ( - ); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 35bdb4651d..3bfef9d87c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,23 +1,11 @@ import { type MessageId, type TurnId } from "@t3tools/contracts"; -import { - memo, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { measureElement as measureVirtualElement, type VirtualItem, useVirtualizer, } from "@tanstack/react-virtual"; -import { - deriveTimelineEntries, - formatElapsed, - formatTimestamp, -} from "../../session-logic"; +import { deriveTimelineEntries, formatElapsed, formatTimestamp } from "../../session-logic"; import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; import { type TurnDiffSummary } from "../../types"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; @@ -26,10 +14,7 @@ import { Undo2Icon } from "lucide-react"; import { Button } from "../ui/button"; import { clamp } from "effect/Number"; import { estimateTimelineMessageHeight } from "../timelineHeight"; -import { - buildExpandedImagePreview, - ExpandedImagePreview, -} from "./ExpandedImagePreview"; +import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; @@ -92,10 +77,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const updateWidth = (nextWidth: number) => { setTimelineWidthPx((previousWidth) => { - if ( - previousWidth !== null && - Math.abs(previousWidth - nextWidth) < 0.5 - ) { + if (previousWidth !== null && Math.abs(previousWidth - nextWidth) < 0.5) { return previousWidth; } return nextWidth; @@ -172,33 +154,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } return nextRows; - }, [ - timelineEntries, - completionDividerBeforeEntryId, - isWorking, - activeTurnStartedAt, - ]); + }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]); const firstUnvirtualizedRowIndex = useMemo(() => { - const firstTailRowIndex = Math.max( - rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, - 0, - ); + const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); if (!activeTurnInProgress) return firstTailRowIndex; const turnStartedAtMs = - typeof activeTurnStartedAt === "string" - ? Date.parse(activeTurnStartedAt) - : Number.NaN; + typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN; let firstCurrentTurnRowIndex = -1; if (!Number.isNaN(turnStartedAtMs)) { firstCurrentTurnRowIndex = rows.findIndex((row) => { if (row.kind === "working") return true; if (!row.createdAt) return false; const rowCreatedAtMs = Date.parse(row.createdAt); - return ( - !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs - ); + return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs; }); } @@ -216,10 +186,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (previousRow.message.role === "user") { return Math.min(index, firstTailRowIndex); } - if ( - previousRow.message.role === "assistant" && - !previousRow.message.streaming - ) { + if (previousRow.message.role === "assistant" && !previousRow.message.streaming) { break; } } @@ -241,8 +208,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const row = rows[index]; if (!row) return 96; if (row.kind === "work") return 112; - if (row.kind === "proposed-plan") - return estimateTimelineProposedPlanHeight(row.proposedPlan); + if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan); if (row.kind === "working") return 40; return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); }, @@ -255,15 +221,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ rowVirtualizer.measure(); }, [rowVirtualizer, timelineWidthPx]); useEffect(() => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = ( - _item, - _delta, - instance, - ) => { + rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { const viewportHeight = instance.scrollRect?.height ?? 0; const scrollOffset = instance.scrollOffset ?? 0; - const remainingDistance = - instance.getTotalSize() - (scrollOffset + viewportHeight); + const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; }; return () => { @@ -289,8 +250,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const virtualRows = rowVirtualizer.getVirtualItems(); const nonVirtualizedRows = rows.slice(virtualizedRowCount); - const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = - useState>({}); + const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState< + Record + >({}); const onToggleAllDirectories = useCallback((turnId: TurnId) => { setAllDirectoriesExpandedByTurnId((current) => ({ ...current, @@ -310,16 +272,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const groupId = row.id; const groupedEntries = row.groupedEntries; const isExpanded = expandedWorkGroups[groupId] ?? false; - const hasOverflow = - groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + 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 onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); const groupLabel = onlyToolEntries ? groupedEntries.length === 1 ? "Tool call" @@ -346,15 +305,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
{visibleEntries.map((workEntry) => ( -
+
-

+

{workEntry.label}

{workEntry.command && ( @@ -362,30 +316,26 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {workEntry.command} )} - {workEntry.changedFiles && - workEntry.changedFiles.length > 0 && ( -
- {workEntry.changedFiles - .slice(0, 6) - .map((filePath) => ( - - {filePath} - - ))} - {workEntry.changedFiles.length > 6 && ( - - +{workEntry.changedFiles.length - 6} more - - )} -
- )} + {workEntry.changedFiles && workEntry.changedFiles.length > 0 && ( +
+ {workEntry.changedFiles.slice(0, 6).map((filePath) => ( + + {filePath} + + ))} + {workEntry.changedFiles.length > 6 && ( + + +{workEntry.changedFiles.length - 6} more + + )} +
+ )} {workEntry.detail && - (!workEntry.command || - workEntry.detail !== workEntry.command) && ( + (!workEntry.command || workEntry.detail !== workEntry.command) && (

{ const userImages = row.message.attachments ?? []; - const canRevertAgentWork = revertTurnCountByUserMessageId.has( - row.message.id, - ); + const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); return (

{userImages.length > 0 && (
{userImages.map( - ( - image: NonNullable< - TimelineMessage["attachments"] - >[number], - ) => ( + (image: NonNullable[number]) => (
{ - const preview = buildExpandedImagePreview( - userImages, - image.id, - ); + const preview = buildExpandedImagePreview(userImages, image.id); if (!preview) return; onImageExpand(preview); }} @@ -462,9 +403,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )}
- {row.message.text && ( - - )} + {row.message.text && } {canRevertAgentWork && (
@@ -639,10 +562,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden" > {virtualizedRowCount > 0 && ( -
+
{virtualRows.map((virtualRow: VirtualItem) => { const row = rows[virtualRow.index]; if (!row) return null; @@ -671,10 +591,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ type TimelineEntry = ReturnType[number]; type TimelineMessage = Extract["message"]; -type TimelineProposedPlan = Extract< - TimelineEntry, - { kind: "proposed-plan" } ->["proposedPlan"]; +type TimelineProposedPlan = Extract["proposedPlan"]; type TimelineWorkEntry = Extract["entry"]; type TimelineRow = | { @@ -698,13 +615,8 @@ type TimelineRow = } | { kind: "working"; id: string; createdAt: string | null }; -function estimateTimelineProposedPlanHeight( - proposedPlan: TimelineProposedPlan, -): number { - const estimatedLines = Math.max( - 1, - Math.ceil(proposedPlan.planMarkdown.length / 72), - ); +function estimateTimelineProposedPlanHeight(proposedPlan: TimelineProposedPlan): number { + const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72)); return 120 + Math.min(estimatedLines * 22, 880); } @@ -715,10 +627,7 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null { return null; } - const elapsedSeconds = Math.max( - 0, - Math.floor((endedAtMs - startedAtMs) / 1000), - ); + const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000)); if (elapsedSeconds < 60) { return `${elapsedSeconds}s`; } diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 4c1bccbffe..701204c1be 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,23 +1,10 @@ -import { - EDITORS, - type EditorId, - type ResolvedKeybindingsConfig, -} from "@t3tools/contracts"; +import { EDITORS, type EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { memo, useCallback, useEffect, useMemo, useState } from "react"; -import { - isOpenFavoriteEditorShortcut, - shortcutLabelForCommand, -} from "../../keybindings"; +import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; -import { - Menu, - MenuItem, - MenuPopup, - MenuShortcut, - MenuTrigger, -} from "../ui/menu"; +import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; import { CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons"; import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; @@ -35,14 +22,10 @@ export const OpenInPicker = memo(function OpenInPicker({ }) { const [lastEditor, setLastEditor] = useState(() => { const stored = localStorage.getItem(LAST_EDITOR_KEY); - return EDITORS.some((e) => e.id === stored) - ? (stored as EditorId) - : EDITORS[0].id; + return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; }); - const allOptions = useMemo< - Array<{ label: string; Icon: Icon; value: EditorId }> - >( + const allOptions = useMemo>( () => [ { label: "Cursor", @@ -72,16 +55,14 @@ export const OpenInPicker = memo(function OpenInPicker({ [], ); const options = useMemo( - () => - allOptions.filter((option) => availableEditors.includes(option.value)), + () => allOptions.filter((option) => availableEditors.includes(option.value)), [allOptions, availableEditors], ); const effectiveEditor = options.some((option) => option.value === lastEditor) ? lastEditor : (options[0]?.value ?? null); - const primaryOption = - options.find(({ value }) => value === effectiveEditor) ?? null; + const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; const openInEditor = useCallback( (editorId: EditorId | null) => { @@ -123,30 +104,18 @@ export const OpenInPicker = memo(function OpenInPicker({ disabled={!effectiveEditor || !openInCwd} onClick={() => openInEditor(effectiveEditor)} > - {primaryOption?.Icon && ( -