diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 670bcc10c21a..10630fd67a41 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -20,6 +20,7 @@ github:hwanseoc github:jamesx0416 github:jasonLaster github:JoeEverest +github:maria-rcks github:nmggithub github:Noojuno github:notkainoa diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1524675138ac..904b2ac06d4d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,6 +125,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Align package versions to release version + run: bun run scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + - name: Build desktop artifact shell: bash env: @@ -244,6 +247,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Align package versions to release version + run: bun run scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + - name: Build CLI package run: bun run build --filter=@t3tools/web --filter=t3 @@ -322,7 +328,7 @@ jobs: name: Update version strings env: RELEASE_VERSION: ${{ needs.preflight.outputs.version }} - run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output + run: bun run scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output - name: Format package.json files if: steps.update_versions.outputs.changed == 'true' diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8aded92b613f..0754c0d1c8f9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.9", + "version": "0.0.10", "private": true, "main": "dist-electron/main.js", "scripts": { diff --git a/apps/server/package.json b/apps/server/package.json index 2e7b3b37a430..b49e42b27aea 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.9", + "version": "0.0.10", "repository": { "type": "git", "url": "https://github.com/pingdotgg/t3code", diff --git a/apps/web/package.json b/apps/web/package.json index 6e155e0c5de6..91939741c3f7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.9", + "version": "0.0.10", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts new file mode 100644 index 000000000000..bbc7bd500e5e --- /dev/null +++ b/apps/web/src/components/ChatView.logic.ts @@ -0,0 +1,136 @@ +import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; +import { type ChatMessage, type Thread } from "../types"; +import { randomUUID } from "~/lib/utils"; +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 { + 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, + defaults: { + readonly provider: ProviderKind; + readonly model: string; + }, + error: string | null, +): Thread { + return { + id: threadId, + codexThreadId: null, + projectId: draftThread.projectId, + title: "New thread", + provider: defaults.provider, + model: defaults.model, + 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; + } +} + diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 78305c250956..17a3d71b3691 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2,8 +2,6 @@ import { type ApprovalRequestId, type ClaudeCodeEffort, DEFAULT_MODEL_BY_PROVIDER, - CURSOR_REASONING_OPTIONS, - EDITORS, type EditorId, type KeybindingCommand, type CodexReasoningEffort, @@ -31,44 +29,25 @@ import { getDefaultModel, getDefaultReasoningEffort, getCursorModelCapabilities, - getCursorModelFamilyOptions, getReasoningEffortOptions, normalizeModelSlug, parseCursorModelSelection, - resolveCursorPickerModelSlug, resolveCursorModelFromSelection, resolveModelSlugForProvider, } from "@t3tools/shared/model"; -import { - memo, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - useId, -} 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 { - measureElement as measureVirtualElement, - type VirtualItem, - useVirtualizer, -} from "@tanstack/react-virtual"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { providerListModelsQueryOptions } from "~/lib/providerReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; - import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { resolveDraftThreadDefaults } from "../lib/threadDraftDefaults"; import { - type ComposerSlashCommand, type ComposerTrigger, - type ComposerTriggerKind, detectComposerTrigger, expandCollapsedComposerCursor, parseStandaloneComposerSlashCommand, @@ -82,18 +61,14 @@ import { deriveActiveWorkStartedAt, deriveActivePlanState, findLatestProposedPlan, - type PendingApproval, - type PendingUserInput, PROVIDER_OPTIONS, deriveWorkLogEntries, hasToolActivityForTurn, hasToolActivitySince, isLatestTurnSettled, formatElapsed, - formatTimestamp, - type WorkLogEntry, } from "../session-logic"; -import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll"; +import { isScrollContainerNearBottom } from "../chat-scroll"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -102,15 +77,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 { @@ -119,114 +89,34 @@ 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 CommandPalette from "./CommandPalette"; +import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import GhosttyTerminalSplitView from "./GhosttyTerminalSplitView"; -import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { BotIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, CircleAlertIcon, - DatabaseIcon, - EyeIcon, - FileIcon, - FolderIcon, - DiffIcon, - EllipsisIcon, - FolderClosedIcon, - HammerIcon, ListTodoIcon, LockIcon, LockOpenIcon, - type LucideIcon, - SearchIcon, - SquarePenIcon, - TargetIcon, - TerminalIcon, - Undo2Icon, - WrenchIcon, XIcon, - ZapIcon, - 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, - FleetIcon, - Gemini, - GhosttyIcon, - GitHubIcon, - Icon, - IntelliJIcon, - OpenAI, - OpenCodeIcon, - AmpIcon, - KiloIcon, - PositronIcon, - SublimeTextIcon, - VisualStudioCode, - WebStormIcon, - WindsurfIcon, - 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, @@ -234,57 +124,54 @@ 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 { 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 "./chat/MessagesTimeline"; +import { ChatHeader } from "./chat/ChatHeader"; +import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; +import { + getCustomModelOptionsByProvider, + mergeDiscoveredModels, + ProviderModelPicker, +} from "./chat/ProviderModelPicker"; +import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; +import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; +import { CodexTraitsPicker } from "./chat/CodexTraitsPicker"; +import { ClaudeCodeTraitsPicker } from "./chat/ClaudeCodeTraitsPicker"; +import { CursorTraitsPicker } from "./chat/CursorTraitsPicker"; +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, + cloneComposerImageForRetry, + collectUserMessageBlobPreviewUrls, + 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 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 = @@ -298,416 +185,6 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: 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"; -} - -function workToneIcon(tone: "thinking" | "tool" | "info" | "error") { - if (tone === "error") { - return { - icon: CircleAlertIcon, - className: "text-black/85 dark:text-white/90", - }; - } - if (tone === "thinking") { - return { - icon: BotIcon, - className: "text-black/85 dark:text-white/90", - }; - } - if (tone === "info") { - return { - icon: CheckIcon, - className: "text-black/85 dark:text-white/90", - }; - } - return { - icon: ZapIcon, - className: "text-black/85 dark:text-white/90", - }; -} - -function workEntryPreview(workEntry: { - detail?: string; - command?: string; - changedFiles?: ReadonlyArray; -}): string | null { - if (workEntry.command) return workEntry.command; - if (workEntry.detail) return workEntry.detail; - if ((workEntry.changedFiles?.length ?? 0) > 0) { - const [firstPath] = workEntry.changedFiles ?? []; - if (!firstPath) return null; - return workEntry.changedFiles!.length === 1 - ? firstPath - : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; - } - return null; -} - -function workEntryIcon(workEntry: WorkLogEntry): LucideIcon { - if (workEntry.requestKind === "command") return TerminalIcon; - if (workEntry.requestKind === "file-read") return EyeIcon; - if (workEntry.requestKind === "file-change") return SquarePenIcon; - - const haystack = [workEntry.label, workEntry.detail, workEntry.command] - .filter((value): value is string => typeof value === "string" && value.length > 0) - .join(" ") - .toLowerCase(); - - if (haystack.includes("report_intent") || haystack.includes("intent logged")) { - return TargetIcon; - } - if ( - haystack.includes("bash") || - haystack.includes("read_bash") || - haystack.includes("write_bash") || - haystack.includes("stop_bash") || - haystack.includes("list_bash") - ) { - return TerminalIcon; - } - if (haystack.includes("sql")) return DatabaseIcon; - if (haystack.includes("view")) return EyeIcon; - if (haystack.includes("apply_patch")) return SquarePenIcon; - if (haystack.includes("rg") || haystack.includes("glob") || haystack.includes("search")) { - return SearchIcon; - } - if (haystack.includes("skill")) return ZapIcon; - if (haystack.includes("ask_user") || haystack.includes("approval")) return BotIcon; - if (haystack.includes("store_memory")) return FolderIcon; - if (haystack.includes("edit") || haystack.includes("patch")) return WrenchIcon; - if (haystack.includes("file")) return FileIcon; - - switch (workEntry.itemType) { - case "command_execution": - return TerminalIcon; - case "file_change": - return SquarePenIcon; - case "mcp_tool_call": - return WrenchIcon; - case "dynamic_tool_call": - case "collab_agent_tool_call": - return HammerIcon; - case "web_search": - return SearchIcon; - case "image_view": - return EyeIcon; - } - if (haystack.includes("task")) return HammerIcon; - - if (workEntry.activityKind === "turn.plan.updated") return ListTodoIcon; - if (workEntry.activityKind === "task.progress") return HammerIcon; - if (workEntry.activityKind === "approval.requested") return BotIcon; - if (workEntry.activityKind === "approval.resolved") return CheckIcon; - - return workToneIcon(workEntry.tone).icon; -} - -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, - defaults: { - readonly provider: ProviderKind; - readonly model: string; - }, - error: string | null, -): Thread { - return { - id: threadId, - codexThreadId: null, - projectId: draftThread.projectId, - title: "New thread", - provider: defaults.provider, - model: defaults.model, - 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; @@ -730,7 +207,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const queryClient = useQueryClient(); const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); const composerDraft = useComposerThreadDraft(threadId); - const persistedPrompt = composerDraft.prompt; + const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); @@ -767,10 +244,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const draftThread = useComposerDraftStore( (store) => store.draftThreadsByThreadId[threadId] ?? null, ); - const [prompt, setPromptState] = useState(() => persistedPrompt); - const [hasPromptText, setHasPromptText] = useState(() => persistedPrompt.trim().length > 0); const promptRef = useRef(prompt); - const promptSyncPendingRef = useRef(false); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); @@ -809,7 +283,6 @@ export default function ChatView({ threadId }: ChatViewProps) { Record >({}); const [composerCursor, setComposerCursor] = useState(() => prompt.length); - const composerCursorRef = useRef(prompt.length); const [composerTrigger, setComposerTrigger] = useState(() => detectComposerTrigger(prompt, prompt.length), ); @@ -842,7 +315,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const sendInFlightRef = useRef(false); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); - const [ghosttySplitOpen, setGhosttySplitOpen] = useState(false); const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { messagesScrollRef.current = element; setMessagesScrollElement(element); @@ -860,12 +332,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const setPrompt = useCallback( (nextPrompt: string) => { - promptRef.current = nextPrompt; - promptSyncPendingRef.current = true; - setPromptState(nextPrompt); - setHasPromptText(nextPrompt.trim().length > 0); + setComposerDraftPrompt(threadId, nextPrompt); }, - [], + [setComposerDraftPrompt, threadId], ); const addComposerImage = useCallback( (image: ComposerImageAttachment) => { @@ -1035,10 +504,6 @@ export default function ChatView({ threadId }: ChatViewProps) { : null; const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? activeThread?.provider ?? "codex"; - const assistantDeliveryMode = - settings.enableAssistantStreaming || selectedProvider === "cursor" - ? "streaming" - : "buffered"; const customModelsByProvider = useMemo( () => ({ codex: settings.customCodexModels, @@ -1061,10 +526,6 @@ export default function ChatView({ threadId }: ChatViewProps) { settings.customKiloModels, ], ); - const cursorModelSelectionLockedReason = - hasThreadStarted && selectedProvider === "cursor" - ? "Cursor currently does not support changing models after the first message in a thread." - : null; const baseThreadModel = resolveModelSlugForProvider( selectedProvider, activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), @@ -1479,43 +940,10 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!latestTurnHasToolActivity) return null; const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt); - const parts: string[] = []; - if (elapsed) parts.push(`Worked for ${elapsed}`); - - const usage = activeLatestTurn.usage as - | { input_tokens?: number; output_tokens?: number; cached_tokens?: number } - | undefined; - if (usage) { - const tokenParts: string[] = []; - if (typeof usage.input_tokens === "number") { - const formatted = - usage.input_tokens >= 1000 - ? `${(usage.input_tokens / 1000).toFixed(1)}k` - : String(usage.input_tokens); - tokenParts.push(`${formatted} in`); - } - if (typeof usage.output_tokens === "number") { - const formatted = - usage.output_tokens >= 1000 - ? `${(usage.output_tokens / 1000).toFixed(1)}k` - : String(usage.output_tokens); - tokenParts.push(`${formatted} out`); - } - if (typeof usage.cached_tokens === "number" && usage.cached_tokens > 0) { - const formatted = - usage.cached_tokens >= 1000 - ? `${(usage.cached_tokens / 1000).toFixed(1)}k` - : String(usage.cached_tokens); - tokenParts.push(`${formatted} cached`); - } - if (tokenParts.length > 0) parts.push(tokenParts.join(" · ")); - } - - return parts.length > 0 ? parts.join(" · ") : null; + return elapsed ? `Worked for ${elapsed}` : null; }, [ activeLatestTurn?.completedAt, activeLatestTurn?.startedAt, - activeLatestTurn?.usage, latestTurnHasToolActivity, latestTurnSettled, ]); @@ -1630,7 +1058,7 @@ export default function ChatView({ threadId }: ChatViewProps) { type: "model", provider, model: slug, - label: pricingTier ? `${name} ${formatPricingTier(pricingTier)}` : name, + label: pricingTier ? `${name} ${pricingTier}` : name, description: `${providerLabel} · ${slug}`, })); }, [composerTrigger, searchableModelOptions, workspaceEntries]); @@ -1786,7 +1214,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(); @@ -1917,12 +1349,6 @@ export default function ChatView({ threadId }: ChatViewProps) { if (isElectron && keybindingRule) { await api.server.upsertKeybinding(keybindingRule); await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); - } else if (isElectron && input.keybinding === null) { - // Explicitly null keybinding means the script (and its shortcut) is - // being deleted. Remove any persisted keybinding for this command so - // stale accelerators don't linger. - await api.server.removeKeybinding({ command: input.keybindingCommand }); - await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); } }, [queryClient], @@ -2368,15 +1794,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [composerMenuItems, composerMenuOpen]); useEffect(() => { - setExpandedWorkGroups({}); setIsRevertingCheckpoint(false); - if (planSidebarOpenOnNextThreadRef.current) { - planSidebarOpenOnNextThreadRef.current = false; - setPlanSidebarOpen(true); - } else { - setPlanSidebarOpen(false); - } - planSidebarDismissedForTurnRef.current = null; }, [activeThread?.id]); useEffect(() => { @@ -2423,40 +1841,9 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { promptRef.current = prompt; - setHasPromptText(prompt.trim().length > 0); setComposerCursor((existing) => Math.min(Math.max(0, existing), prompt.length)); }, [prompt]); - useEffect(() => { - composerCursorRef.current = composerCursor; - }, [composerCursor]); - - useEffect(() => { - // Always reset pending sync when the thread changes so stale debounce - // timeouts from the previous thread cannot overwrite the new thread's draft. - promptSyncPendingRef.current = false; - - if (persistedPrompt === promptRef.current) { - return; - } - promptRef.current = persistedPrompt; - setPromptState(persistedPrompt); - setHasPromptText(persistedPrompt.trim().length > 0); - }, [persistedPrompt, threadId]); - - useEffect(() => { - if (prompt === persistedPrompt) { - promptSyncPendingRef.current = false; - return; - } - const timeout = window.setTimeout(() => { - setComposerDraftPrompt(threadId, prompt); - }, 180); - return () => { - window.clearTimeout(timeout); - }; - }, [persistedPrompt, prompt, setComposerDraftPrompt, threadId]); - useEffect(() => { setOptimisticUserMessages((existing) => { for (const message of existing) { @@ -2673,7 +2060,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") { @@ -2824,6 +2213,10 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } event.preventDefault(); + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { + return; + } dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) { setIsDragOverComposer(false); @@ -2891,15 +2284,13 @@ export default function ChatView({ threadId }: ChatViewProps) { onAdvanceActivePendingUserInput(); return; } - const trimmed = promptRef.current.trim(); + const trimmed = prompt.trim(); if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, planMarkdown: activeProposedPlan.planMarkdown, }); promptRef.current = ""; - promptSyncPendingRef.current = false; - setPromptState(""); clearComposerDraftContent(activeThread.id); setComposerHighlightedItemId(null); setComposerCursor(0); @@ -2915,8 +2306,6 @@ export default function ChatView({ threadId }: ChatViewProps) { if (standaloneSlashCommand) { await handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; - promptSyncPendingRef.current = false; - setPromptState(""); clearComposerDraftContent(activeThread.id); setComposerHighlightedItemId(null); setComposerCursor(0); @@ -2984,8 +2373,6 @@ export default function ChatView({ threadId }: ChatViewProps) { setThreadError(threadIdForSend, null); promptRef.current = ""; - promptSyncPendingRef.current = false; - setPromptState(""); clearComposerDraftContent(threadIdForSend); setComposerHighlightedItemId(null); setComposerCursor(0); @@ -3121,7 +2508,7 @@ export default function ChatView({ threadId }: ChatViewProps) { : {}), ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), provider: selectedProvider, - assistantDeliveryMode, + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode, createdAt: messageCreatedAt, @@ -3167,23 +2554,16 @@ export default function ChatView({ threadId }: ChatViewProps) { } }; - const onInterrupt = useCallback(async () => { + const onInterrupt = async () => { const api = readNativeApi(); if (!api || !activeThread) return; - try { - await api.orchestration.dispatchCommand({ - type: "thread.turn.interrupt", - commandId: newCommandId(), - threadId: activeThread.id, - createdAt: new Date().toISOString(), - }); - } catch (err) { - setThreadError( - activeThread.id, - err instanceof Error ? err.message : "Failed to stop generation.", - ); - } - }, [activeThread, setThreadError]); + await api.orchestration.dispatchCommand({ + type: "thread.turn.interrupt", + commandId: newCommandId(), + threadId: activeThread.id, + createdAt: new Date().toISOString(), + }); + }; const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { @@ -3270,8 +2650,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }, })); promptRef.current = ""; - promptSyncPendingRef.current = false; - setPromptState(""); setComposerCursor(0); setComposerTrigger(null); }, @@ -3400,14 +2778,11 @@ export default function ChatView({ threadId }: ChatViewProps) { }, provider: selectedProvider, model: selectedModel || undefined, - ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - ...(providerOptionsForDispatch - ? { providerOptions: providerOptionsForDispatch } - : {}), - assistantDeliveryMode, + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, createdAt: messageCreatedAt, @@ -3448,7 +2823,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider, setComposerDraftInteractionMode, setThreadError, - assistantDeliveryMode, + settings.enableAssistantStreaming, ], ); @@ -3485,8 +2860,6 @@ export default function ChatView({ threadId }: ChatViewProps) { resetSendPhase(); }; - let serverStarted = false; - await api.orchestration .dispatchCommand({ type: "thread.create", @@ -3514,30 +2887,18 @@ export default function ChatView({ threadId }: ChatViewProps) { }, provider: selectedProvider, model: selectedModel || undefined, - ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - ...(providerOptionsForDispatch - ? { providerOptions: providerOptionsForDispatch } - : {}), - assistantDeliveryMode, + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", createdAt, }); }) - .then(() => { - serverStarted = true; - return api.orchestration.getSnapshot(); - }) + .then(() => api.orchestration.getSnapshot()) .then((snapshot) => { - // Snapshot sync is a safety net for the navigation/thread creation - // flow: the newly created thread must exist in the client-side read - // model before we navigate to it. The WebSocket push channel - // (`orchestration.domainEvent`) is the primary update path and will - // usually deliver the event first, but a snapshot fetch here - // guarantees correctness when the push hasn't arrived yet. syncServerReadModel(snapshot); // Signal that the plan sidebar should open on the new thread. planSidebarOpenOnNextThreadRef.current = true; @@ -3547,23 +2908,19 @@ export default function ChatView({ threadId }: ChatViewProps) { }); }) .catch(async (err) => { - if (!serverStarted) { - await api.orchestration - .dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: nextThreadId, - }) - .catch(() => undefined); - // Re-sync after rollback so the deleted thread is removed from - // the client read model even if the WebSocket push is delayed. - await api.orchestration - .getSnapshot() - .then((snapshot) => { - syncServerReadModel(snapshot); - }) - .catch(() => undefined); - } + await api.orchestration + .dispatchCommand({ + type: "thread.delete", + commandId: newCommandId(), + threadId: nextThreadId, + }) + .catch(() => undefined); + await api.orchestration + .getSnapshot() + .then((snapshot) => { + syncServerReadModel(snapshot); + }) + .catch(() => undefined); toastManager.add({ type: "error", title: "Could not start implementation thread", @@ -3587,34 +2944,26 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModelOptionsForDispatch, providerOptionsForDispatch, selectedProvider, - assistantDeliveryMode, + settings.enableAssistantStreaming, syncServerReadModel, ]); const onProviderModelSelect = useCallback( (provider: ProviderKind, model: ModelSlug) => { if (!activeThread) return; - if (cursorModelSelectionLockedReason !== null && provider === "cursor") { - scheduleComposerFocus(); - return; - } if (lockedProvider !== null && provider !== lockedProvider) { scheduleComposerFocus(); return; } - const parsedCursorSelection = - provider === "cursor" ? parseCursorModelSelection(model) : null; - const resolvedModel = - provider === "cursor" && parsedCursorSelection?.family === model - ? resolveCursorModelFromSelection({ family: parsedCursorSelection.family }) - : resolveAppModelSelection(provider, customModelsByProvider[provider], model); setComposerDraftProvider(activeThread.id, provider); - setComposerDraftModel(activeThread.id, resolvedModel); + setComposerDraftModel( + activeThread.id, + resolveAppModelSelection(provider, customModelsByProvider[provider], model), + ); scheduleComposerFocus(); }, [ activeThread, - cursorModelSelectionLockedReason, customModelsByProvider, lockedProvider, scheduleComposerFocus, @@ -3622,6 +2971,13 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftProvider, ], ); + const onEffortSelect = useCallback( + (effort: CodexReasoningEffort) => { + setComposerDraftEffort(threadId, effort); + scheduleComposerFocus(); + }, + [scheduleComposerFocus, setComposerDraftEffort, threadId], + ); const onCursorReasoningSelect = useCallback( (reasoning: CursorReasoningOption) => { if (selectedProvider !== "cursor") return; @@ -3664,12 +3020,12 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [onProviderModelSelect, selectedModel, selectedProvider], ); - const onEffortSelect = useCallback( - (effort: CodexReasoningEffort) => { - setComposerDraftEffort(threadId, effort); + const onCodexFastModeChange = useCallback( + (enabled: boolean) => { + setComposerDraftCodexFastMode(threadId, enabled); scheduleComposerFocus(); }, - [scheduleComposerFocus, setComposerDraftEffort, threadId], + [scheduleComposerFocus, setComposerDraftCodexFastMode, threadId], ); const onClaudeCodeEffortSelect = useCallback( (effort: ClaudeCodeEffort) => { @@ -3678,13 +3034,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [scheduleComposerFocus, setComposerDraftClaudeCodeEffort, threadId], ); - const onCodexFastModeChange = useCallback( - (enabled: boolean) => { - setComposerDraftCodexFastMode(threadId, enabled); - scheduleComposerFocus(); - }, - [scheduleComposerFocus, setComposerDraftCodexFastMode, threadId], - ); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (isLocalDraftThread) { @@ -3738,13 +3087,16 @@ 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; } - return { value: promptRef.current, cursor: composerCursorRef.current }; - }, []); + return { value: promptRef.current, cursor: composerCursor }; + }, [composerCursor]); const resolveActiveComposerTrigger = useCallback((): { snapshot: { value: string; cursor: number }; @@ -3853,13 +3205,8 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } promptRef.current = nextPrompt; - promptSyncPendingRef.current = true; - setPromptState((current) => (current === nextPrompt ? current : nextPrompt)); - composerCursorRef.current = nextCursor; - setHasPromptText((current) => { - const next = nextPrompt.trim().length > 0; - return current === next ? current : next; - }); + setPrompt(nextPrompt); + setComposerCursor(nextCursor); setComposerTrigger( cursorAdjacentToMention ? null @@ -3873,6 +3220,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activePendingProgress?.activeQuestion, activePendingUserInput, onChangeActivePendingUserInputCustomAnswer, + setPrompt, ], ); @@ -3974,35 +3322,6 @@ export default function ChatView({ threadId }: ChatViewProps) { return (
- - void handleRuntimeModeChange( - runtimeMode === "full-access" ? "approval-required" : "full-access", - ) - } - onInterrupt={onInterrupt} - onRunProjectScript={(script) => runProjectScript(script)} - ghosttySplitOpen={ghosttySplitOpen} - onToggleGhosttySplit={() => setGhosttySplitOpen((v) => !v)} - /> - {/* Top bar */}
- )} - {expandedImage && expandedImageItem && (
); } - -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 === "copilot" - ? "GitHub Copilot 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") { - if (timelineEntry.entry.tone === "tool") { - const groupedEntries = [timelineEntry.entry]; - let cursor = index + 1; - while (cursor < timelineEntries.length) { - const nextEntry = timelineEntries[cursor]; - if (!nextEntry || nextEntry.kind !== "work" || nextEntry.entry.tone !== "tool") { - break; - } - groupedEntries.push(nextEntry.entry); - cursor += 1; - } - nextRows.push({ - kind: "work", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - groupedEntries, - }); - index = cursor - 1; - continue; - } - - nextRows.push({ - kind: "work", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - groupedEntries: [timelineEntry.entry], - }); - 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 groupedEntries = row.groupedEntries; - const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); - const groupLabel = onlyToolEntries - ? groupedEntries.length === 1 - ? "Tool call" - : `Tool calls (${groupedEntries.length})` - : "Work event"; - - return ( -
-
-

- {groupLabel} -

-
-
- {groupedEntries.map((workEntry) => { - const iconConfig = workToneIcon(workEntry.tone); - const EntryIcon = workEntryIcon(workEntry); - const preview = workEntryPreview(workEntry); - return ( -
- - - -
-

- {workEntry.label} -

- {preview && preview !== workEntry.label && ( -

- {preview} -

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

- {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 turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); - if (!row.message.text && !row.message.streaming && !turnSummary) return null; - const messageText = row.message.text || ""; - return ( - <> - {row.showCompletionDivider && ( -
- - - {completionSummary ? `Response • ${completionSummary}` : "Response"} - - -
- )} -
- - {(() => { - 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)}
- ))} -
- ); -}); - -const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => option.available); -const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); -const COMING_SOON_PROVIDER_OPTIONS: ReadonlyArray<{ id: string; label: string; icon: Icon }> = []; - -function getCustomModelOptionsByProvider(settings: { - customCodexModels: readonly string[]; - customCopilotModels: readonly string[]; - customClaudeModels: readonly string[]; - customCursorModels: readonly string[]; - customOpencodeModels: readonly string[]; - customGeminiCliModels: readonly string[]; - customAmpModels: readonly string[]; - customKiloModels: readonly string[]; -}): Record> { - const cursorFamilyOptions = getCursorModelFamilyOptions(); - return { - codex: getAppModelOptions("codex", settings.customCodexModels), - copilot: getAppModelOptions("copilot", settings.customCopilotModels), - claudeCode: getAppModelOptions("claudeCode", settings.customClaudeModels), - cursor: [ - ...cursorFamilyOptions, - ...getAppModelOptions("cursor", settings.customCursorModels).filter( - (option) => - option.isCustom && !cursorFamilyOptions.some((family) => family.slug === option.slug), - ), - ], - opencode: getAppModelOptions("opencode", settings.customOpencodeModels), - geminiCli: getAppModelOptions("geminiCli", settings.customGeminiCliModels), - amp: getAppModelOptions("amp", settings.customAmpModels), - kilo: getAppModelOptions("kilo", settings.customKiloModels), - }; -} - -type ModelOptionEntry = { slug: string; name: string; pricingTier?: string; isCustom?: boolean }; - -function mergeDiscoveredModels( - base: Record>, - discovered: Partial | undefined>>, -): Record> { - const result = { ...base }; - for (const [provider, models] of Object.entries(discovered) as Array< - [ProviderKind, ReadonlyArray | undefined] - >) { - if (!models || models.length === 0) continue; - const normalizedModels = - provider === "cursor" - ? models.filter((model) => resolveCursorPickerModelSlug(model.slug) === model.slug) - : models; - const dedupedModels = Array.from(new Map(normalizedModels.map((m) => [m.slug, m])).values()); - const existing = new Set(base[provider]?.map((m) => m.slug)); - // For copilot, discovered models replace the static list but inherit - // pricingTier from the static entries when the SDK doesn't provide it. - if (provider === "copilot") { - const baseTiers = new Map( - (base[provider] ?? []).map((m) => [m.slug, m.pricingTier]), - ); - const enriched = dedupedModels.map((m) => { - if (m.pricingTier) return m; - const tier = baseTiers.get(m.slug); - return tier ? { ...m, pricingTier: tier } : m; - }); - const customOnly = (base[provider] ?? []).filter( - (m) => m.isCustom && !dedupedModels.some((d) => d.slug === m.slug), - ); - result[provider] = [...enriched, ...customOnly]; - continue; - } - // Build a lookup of discovered models by slug so we can merge metadata - // (e.g. pricingTier) into base entries and also add truly-new models. - const discoveredBySlug = new Map(dedupedModels.map((m) => [m.slug, m])); - const merged = (base[provider] ?? []).map((m) => { - const discovered = discoveredBySlug.get(m.slug); - return discovered ? { ...m, ...discovered } : m; - }); - // Append any discovered models that weren't already in the base list. - const additions = dedupedModels.filter((m) => !existing.has(m.slug)); - result[provider] = [...additions, ...merged]; - } - return result; -} - -type GroupedModelEntry = { - readonly subProvider: string; - readonly models: ReadonlyArray; -}; - -function groupModelsBySubProvider( - models: ReadonlyArray, -): ReadonlyArray { - const groupOrder: string[] = []; - const groupMap = new Map(); - const ungrouped: ModelOptionEntry[] = []; - - for (const model of models) { - const slashIndex = model.slug.indexOf("/"); - if (slashIndex > 0) { - const subProviderId = model.slug.slice(0, slashIndex); - const nameSlashIndex = model.name.indexOf(" / "); - const subProviderName = nameSlashIndex > 0 ? model.name.slice(0, nameSlashIndex) : subProviderId; - const modelName = nameSlashIndex > 0 ? model.name.slice(nameSlashIndex + 3) : model.name; - - let group = groupMap.get(subProviderId); - if (!group) { - group = { displayName: subProviderName, models: [] }; - groupMap.set(subProviderId, group); - groupOrder.push(subProviderId); - } - group.models.push({ - slug: model.slug, - name: modelName, - ...(model.pricingTier != null && { pricingTier: model.pricingTier }), - ...(model.isCustom != null && { isCustom: model.isCustom }), - }); - } else { - ungrouped.push(model); - } - } - - const result: GroupedModelEntry[] = groupOrder.map((id) => { - const group = groupMap.get(id)!; - return { subProvider: group.displayName, models: group.models }; - }); - if (ungrouped.length > 0) { - result.push({ subProvider: "Other", models: ungrouped }); - } - return result; -} - -const PROVIDER_ICON_BY_PROVIDER: Record = { - codex: OpenAI, - copilot: GitHubIcon, - claudeCode: ClaudeAI, - cursor: CursorIcon, - opencode: OpenCodeIcon, - geminiCli: Gemini, - amp: AmpIcon, - kilo: KiloIcon, -}; - -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; - } - - if (provider === "cursor") { - return parseCursorModelSelection(normalized).family; - } - - return null; -} - -function formatPricingTier(tier: string): string { - // Normalize to uppercase X suffix: "1x" → "1X", "0.3x" → "0.3X" - return tier.replace(/x$/i, "X"); -} - - -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 selectedModelOption = selectedProviderOptions.find((option) => option.slug === props.model); - const selectedModelLabel = selectedModelOption?.name ?? props.model; - const selectedPricingTier = selectedModelOption?.pricingTier; - 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; - const providerModels = props.modelOptionsByProvider[option.value]; - const onModelSelect = (value: string) => { - if (props.disabled) return; - if (isDisabledByProviderLock) return; - if (!value) return; - const resolvedModel = resolveModelForProviderPicker(option.value, value, providerModels); - if (!resolvedModel) return; - props.onProviderModelChange(option.value, resolvedModel); - setIsMenuOpen(false); - }; - - // OpenCode / Kilo: two-tiered picker grouped by sub-provider - if (option.value === "opencode" || option.value === "kilo") { - const groups = groupModelsBySubProvider(providerModels); - return ( - - - - - {groups.length === 0 ? ( - - No models discovered - - ) : ( - groups.map((group) => ( - - {group.subProvider} - - - - {group.models.map((modelOption) => ( - setIsMenuOpen(false)} - > - - {modelOption.name} - {modelOption.pricingTier ? ( - - {formatPricingTier(modelOption.pricingTier)} - - ) : null} - - - ))} - - - - - )) - )} - - - ); - } - - return ( - - - - - - - {providerModels.map((modelOption) => ( - setIsMenuOpen(false)} - > - - {modelOption.name} - {modelOption.pricingTier ? ( - - {formatPricingTier(modelOption.pricingTier)} - - ) : null} - - - ))} - - - - - ); - })} - {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 CLAUDE_CODE_EFFORT_LABEL: Record = { - low: "Low", - medium: "Medium", - high: "High", - max: "Max", -}; - -const ClaudeCodeTraitsPicker = memo(function ClaudeCodeTraitsPicker(props: { - effort: ClaudeCodeEffort; - options: ReadonlyArray; - onEffortChange: (effort: ClaudeCodeEffort) => void; -}) { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const defaultEffort = getDefaultClaudeCodeEffort("claudeCode"); - - return ( - { - setIsMenuOpen(open); - }} - > - - } - > - {CLAUDE_CODE_EFFORT_LABEL[props.effort]} - - - -
Effort
- { - if (!value) return; - const nextEffort = props.options.find((option) => option === value); - if (!nextEffort) return; - props.onEffortChange(nextEffort); - }} - > - {props.options.map((effort) => ( - - {CLAUDE_CODE_EFFORT_LABEL[effort]} - {effort === defaultEffort ? " (default)" : ""} - - ))} - -
-
-
- ); -}); - -const CursorTraitsPicker = memo(function CursorTraitsPicker(props: { - selection: ReturnType; - capabilities: ReturnType; - disabled?: boolean; - onReasoningChange: (reasoning: CursorReasoningOption) => void; - onFastModeChange: (enabled: boolean) => void; - onThinkingModeChange: (enabled: boolean) => void; -}) { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const reasoningLabelByOption: Record = { - low: "Low", - normal: "Normal", - high: "High", - xhigh: "Extra High", - }; - const traitSummary = [ - ...(props.capabilities.supportsReasoning - ? [reasoningLabelByOption[props.selection.reasoning]] - : []), - ...(props.capabilities.supportsFast && props.selection.fast ? ["Fast"] : []), - ...(props.capabilities.supportsThinking && props.selection.thinking ? ["Thinking"] : []), - ]; - const triggerLabel = traitSummary.length > 0 ? traitSummary.join(" · ") : "Traits"; - - return ( - { - if (props.disabled) { - setIsMenuOpen(false); - return; - } - setIsMenuOpen(open); - }} - > - - } - > - {triggerLabel} - - - {props.capabilities.supportsReasoning && ( - -
Reasoning
- { - if (props.disabled) return; - if (!value) return; - const nextReasoning = CURSOR_REASONING_OPTIONS.find((option) => option === value); - if (!nextReasoning) return; - props.onReasoningChange(nextReasoning); - }} - > - {CURSOR_REASONING_OPTIONS.map((reasoning) => ( - - {reasoning} - {reasoning === "normal" ? " (default)" : ""} - - ))} - -
- )} - {props.capabilities.supportsReasoning && - (props.capabilities.supportsFast || props.capabilities.supportsThinking) && ( - - )} - {props.capabilities.supportsFast && ( - -
Fast Mode
- { - if (props.disabled) return; - props.onFastModeChange(value === "on"); - }} - > - off - on - -
- )} - {props.capabilities.supportsFast && props.capabilities.supportsThinking && } - {props.capabilities.supportsThinking && ( - -
Thinking
- { - if (props.disabled) return; - props.onThinkingModeChange(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: "Windsurf", - Icon: WindsurfIcon, - value: "windsurf", - }, - { - label: "VS Code", - Icon: VisualStudioCode, - value: "vscode", - }, - { - label: "Zed", - Icon: Zed, - value: "zed", - }, - { - label: "Positron", - Icon: PositronIcon, - value: "positron", - }, - { - label: "Sublime Text", - Icon: SublimeTextIcon, - value: "sublime", - }, - { - label: "WebStorm", - Icon: WebStormIcon, - value: "webstorm", - }, - { - label: "IntelliJ IDEA", - Icon: IntelliJIcon, - value: "intellij", - }, - { - label: "Fleet", - Icon: FleetIcon, - value: "fleet", - }, - { - label: "Ghostty", - Icon: GhosttyIcon, - value: "ghostty", - }, - { - 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 [copiedPath, setCopiedPath] = useState(false); - const copiedPathTimeoutRef = useRef | null>(null); - - useEffect(() => { - return () => { - if (copiedPathTimeoutRef.current !== null) { - clearTimeout(copiedPathTimeoutRef.current); - } - }; - }, []); - - const copyPath = useCallback(() => { - if (!openInCwd) return; - void navigator.clipboard.writeText(openInCwd).then(() => { - setCopiedPath(true); - if (copiedPathTimeoutRef.current !== null) { - clearTimeout(copiedPathTimeoutRef.current); - } - copiedPathTimeoutRef.current = setTimeout(() => { - setCopiedPath(false); - copiedPathTimeoutRef.current = null; - }, 2000); - }).catch(() => { - // Clipboard write failed — don't show success indicator. - }); - }, [openInCwd]); - - 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)}> - - ))} - {openInCwd && ( - <> - - - {copiedPath ? ( - - - )} - - - - ); -}); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index fd50b4229f47..2da1450cb3e4 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -754,20 +754,6 @@ export default function Sidebar() { const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const shouldBrowseForProjectImmediately = isElectron; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; - const pendingApprovalByThreadId = useMemo(() => { - const map = new Map(); - for (const thread of threads) { - map.set(thread.id, derivePendingApprovals(thread.activities).length > 0); - } - return map; - }, [threads]); - const pendingUserInputByThreadId = useMemo(() => { - const map = new Map(); - for (const thread of threads) { - map.set(thread.id, derivePendingUserInputs(thread.activities).length > 0); - } - return map; - }, [threads]); const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], @@ -2034,9 +2020,9 @@ export default function Sidebar() { const threadStatus = resolveThreadStatusPill({ thread, hasPendingApprovals: - pendingApprovalByThreadId.get(thread.id) === true, + derivePendingApprovals(thread.activities).length > 0, hasPendingUserInput: - pendingUserInputByThreadId.get(thread.id) === true, + derivePendingUserInputs(thread.activities).length > 0, }); const prStatus = prStatusIndicator( prByThreadId.get(thread.id) ?? null, diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx new file mode 100644 index 000000000000..fed905eee57d --- /dev/null +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -0,0 +1,135 @@ +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>(allDirectoryExpansionState); + 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/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx new file mode 100644 index 000000000000..ea7f911bec4b --- /dev/null +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -0,0 +1,124 @@ +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/chat/ClaudeCodeTraitsPicker.tsx b/apps/web/src/components/chat/ClaudeCodeTraitsPicker.tsx new file mode 100644 index 000000000000..899d735d48cb --- /dev/null +++ b/apps/web/src/components/chat/ClaudeCodeTraitsPicker.tsx @@ -0,0 +1,72 @@ +import { type ClaudeCodeEffort } from "@t3tools/contracts"; +import { getDefaultClaudeCodeEffort } 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, + MenuTrigger, +} from "../ui/menu"; + +export const CLAUDE_CODE_EFFORT_LABEL: Record = { + low: "Low", + medium: "Medium", + high: "High", + max: "Max", +}; + +export const ClaudeCodeTraitsPicker = memo(function ClaudeCodeTraitsPicker(props: { + effort: ClaudeCodeEffort; + options: ReadonlyArray; + onEffortChange: (effort: ClaudeCodeEffort) => void; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const defaultEffort = getDefaultClaudeCodeEffort("claudeCode"); + + return ( + { + setIsMenuOpen(open); + }} + > + + } + > + {CLAUDE_CODE_EFFORT_LABEL[props.effort]} + + + +
Effort
+ { + if (!value) return; + const nextEffort = props.options.find((option) => option === value); + if (!nextEffort) return; + props.onEffortChange(nextEffort); + }} + > + {props.options.map((effort) => ( + + {CLAUDE_CODE_EFFORT_LABEL[effort]} + {effort === defaultEffort ? " (default)" : ""} + + ))} + +
+
+
+ ); +}); diff --git a/apps/web/src/components/chat/CodexTraitsPicker.tsx b/apps/web/src/components/chat/CodexTraitsPicker.tsx new file mode 100644 index 000000000000..6c72f497ba9f --- /dev/null +++ b/apps/web/src/components/chat/CodexTraitsPicker.tsx @@ -0,0 +1,93 @@ +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/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx new file mode 100644 index 000000000000..0af50ff01ee5 --- /dev/null +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -0,0 +1,136 @@ +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/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx new file mode 100644 index 000000000000..a43702774f55 --- /dev/null +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -0,0 +1,122 @@ +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." + : props.triggerKind === "slash-model" + ? "No matching models." + : "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/chat/ComposerPendingApprovalActions.tsx b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx new file mode 100644 index 000000000000..5786bab478be --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx @@ -0,0 +1,55 @@ +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/chat/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx new file mode 100644 index 000000000000..569fd108a4a8 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx @@ -0,0 +1,31 @@ +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/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx new file mode 100644 index 000000000000..c8cad7bf36f4 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -0,0 +1,182 @@ +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/chat/ComposerPlanFollowUpBanner.tsx b/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx new file mode 100644 index 000000000000..7d08f9a80c6f --- /dev/null +++ b/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx @@ -0,0 +1,18 @@ +import { memo } from "react"; + +export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({ + planTitle, +}: { + planTitle: string | null; +}) { + return ( +
+
+ Plan ready + {planTitle ? ( + {planTitle} + ) : null} +
+
+ ); +}); diff --git a/apps/web/src/components/chat/CursorTraitsPicker.tsx b/apps/web/src/components/chat/CursorTraitsPicker.tsx new file mode 100644 index 000000000000..717f8ac398f7 --- /dev/null +++ b/apps/web/src/components/chat/CursorTraitsPicker.tsx @@ -0,0 +1,131 @@ +import { + CURSOR_REASONING_OPTIONS, + type CursorReasoningOption, +} from "@t3tools/contracts"; +import { + getCursorModelCapabilities, + parseCursorModelSelection, +} 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 CursorTraitsPicker = memo(function CursorTraitsPicker(props: { + selection: ReturnType; + capabilities: ReturnType; + disabled?: boolean; + onReasoningChange: (reasoning: CursorReasoningOption) => void; + onFastModeChange: (enabled: boolean) => void; + onThinkingModeChange: (enabled: boolean) => void; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const reasoningLabelByOption: Record = { + low: "Low", + normal: "Normal", + high: "High", + xhigh: "Extra High", + }; + const traitSummary = [ + ...(props.capabilities.supportsReasoning + ? [reasoningLabelByOption[props.selection.reasoning]] + : []), + ...(props.capabilities.supportsFast && props.selection.fast ? ["Fast"] : []), + ...(props.capabilities.supportsThinking && props.selection.thinking ? ["Thinking"] : []), + ]; + const triggerLabel = traitSummary.length > 0 ? traitSummary.join(" · ") : "Traits"; + + return ( + { + if (props.disabled) { + setIsMenuOpen(false); + return; + } + setIsMenuOpen(open); + }} + > + + } + > + {triggerLabel} + + + {props.capabilities.supportsReasoning && ( + +
Reasoning
+ { + if (props.disabled) return; + if (!value) return; + const nextReasoning = CURSOR_REASONING_OPTIONS.find((option) => option === value); + if (!nextReasoning) return; + props.onReasoningChange(nextReasoning); + }} + > + {CURSOR_REASONING_OPTIONS.map((reasoning) => ( + + {reasoningLabelByOption[reasoning]} + {reasoning === "normal" ? " (default)" : ""} + + ))} + +
+ )} + {props.capabilities.supportsReasoning && + (props.capabilities.supportsFast || props.capabilities.supportsThinking) && ( + + )} + {props.capabilities.supportsFast && ( + +
Fast Mode
+ { + if (props.disabled) return; + props.onFastModeChange(value === "on"); + }} + > + off + on + +
+ )} + {props.capabilities.supportsFast && props.capabilities.supportsThinking && } + {props.capabilities.supportsThinking && ( + +
Thinking
+ { + if (props.disabled) return; + props.onThinkingModeChange(value === "on"); + }} + > + off + on + +
+ )} +
+
+ ); +}); diff --git a/apps/web/src/components/chat/DiffStatLabel.tsx b/apps/web/src/components/chat/DiffStatLabel.tsx new file mode 100644 index 000000000000..2dda06fd9dfc --- /dev/null +++ b/apps/web/src/components/chat/DiffStatLabel.tsx @@ -0,0 +1,22 @@ +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/chat/ExpandedImagePreview.tsx b/apps/web/src/components/chat/ExpandedImagePreview.tsx new file mode 100644 index 000000000000..db5803d49082 --- /dev/null +++ b/apps/web/src/components/chat/ExpandedImagePreview.tsx @@ -0,0 +1,32 @@ +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/chat/MessageCopyButton.tsx b/apps/web/src/components/chat/MessageCopyButton.tsx new file mode 100644 index 000000000000..5f4ac4f1e127 --- /dev/null +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -0,0 +1,38 @@ +import { memo, useCallback, useEffect, useRef, 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 resetTimerRef = useRef(null); + + useEffect(() => { + return () => { + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + }; + }, []); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + resetTimerRef.current = window.setTimeout(() => { + resetTimerRef.current = null; + setCopied(false); + }, 2000); + } catch { + setCopied(false); + } + }, [text]); + + return ( + + ); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx new file mode 100644 index 000000000000..3bfef9d87cb4 --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -0,0 +1,656 @@ +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< + 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)}
+ ))} +
+ ); +}); + +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); +} + +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/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx new file mode 100644 index 000000000000..086732728e05 --- /dev/null +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -0,0 +1,134 @@ +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(() => { + if (typeof window === "undefined") return EDITORS[0].id; + const stored = localStorage.getItem(LAST_EDITOR_KEY); + return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; + }); + + const platform = typeof navigator !== "undefined" ? navigator.platform : ""; + const allOptions = useMemo>( + () => [ + { + label: "Cursor", + Icon: CursorIcon, + value: "cursor", + }, + { + label: "VS Code", + Icon: VisualStudioCode, + value: "vscode", + }, + { + label: "Zed", + Icon: Zed, + value: "zed", + }, + { + label: isMacPlatform(platform) + ? "Finder" + : isWindowsPlatform(platform) + ? "Explorer" + : "Files", + Icon: FolderClosedIcon, + value: "file-manager", + }, + ], + [platform], + ); + 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/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx new file mode 100644 index 000000000000..2aaaa168060f --- /dev/null +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -0,0 +1,219 @@ +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 canSaveToWorkspace = () => Boolean(workspaceRoot && readNativeApi()); + + const openSaveDialog = () => { + if (!canSaveToWorkspace()) { + toastManager.add({ + type: "error", + title: !workspaceRoot ? "Workspace path is unavailable" : "Native API unavailable", + description: !workspaceRoot + ? "This thread does not have a workspace path to save into." + : "Saving to workspace requires the native desktop app.", + }); + 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; + } + const hasTraversalSegment = relativePath.split("/").some((segment) => segment === ".."); + if (relativePath.startsWith("/") || hasTraversalSegment) { + toastManager.add({ + type: "warning", + title: "Invalid path", + description: "Path must be relative and cannot contain '..' segments.", + }); + 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.", + }); + }) + .finally(() => { + 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/chat/ProviderHealthBanner.tsx b/apps/web/src/components/chat/ProviderHealthBanner.tsx new file mode 100644 index 000000000000..12c7f60544f3 --- /dev/null +++ b/apps/web/src/components/chat/ProviderHealthBanner.tsx @@ -0,0 +1,33 @@ +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/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx new file mode 100644 index 000000000000..3d013859aad7 --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -0,0 +1,428 @@ +import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; +import { + normalizeModelSlug, + parseCursorModelSelection, + resolveCursorPickerModelSlug, +} from "@t3tools/shared/model"; +import { memo, useState } from "react"; +import { 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 { + AmpIcon, + ClaudeAI, + CursorIcon, + Gemini, + GitHubIcon, + Icon, + KiloIcon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; +import { cn } from "~/lib/utils"; +import { getAppModelOptions } from "../../appSettings"; +import { + getCursorModelFamilyOptions, +} from "@t3tools/shared/model"; + +export type ModelOptionEntry = { + slug: string; + name: string; + pricingTier?: string; + isCustom?: boolean; +}; + +type GroupedModelEntry = { + readonly subProvider: string; + readonly models: ReadonlyArray; +}; + +export function getCustomModelOptionsByProvider(settings: { + customCodexModels: readonly string[]; + customCopilotModels: readonly string[]; + customClaudeModels: readonly string[]; + customCursorModels: readonly string[]; + customOpencodeModels: readonly string[]; + customGeminiCliModels: readonly string[]; + customAmpModels: readonly string[]; + customKiloModels: readonly string[]; +}): Record> { + const cursorFamilyOptions = getCursorModelFamilyOptions(); + return { + codex: getAppModelOptions("codex", settings.customCodexModels), + copilot: getAppModelOptions("copilot", settings.customCopilotModels), + claudeCode: getAppModelOptions("claudeCode", settings.customClaudeModels), + cursor: [ + ...cursorFamilyOptions, + ...getAppModelOptions("cursor", settings.customCursorModels).filter( + (option) => + option.isCustom && !cursorFamilyOptions.some((family) => family.slug === option.slug), + ), + ], + opencode: getAppModelOptions("opencode", settings.customOpencodeModels), + geminiCli: getAppModelOptions("geminiCli", settings.customGeminiCliModels), + amp: getAppModelOptions("amp", settings.customAmpModels), + kilo: getAppModelOptions("kilo", settings.customKiloModels), + }; +} + +export function mergeDiscoveredModels( + base: Record>, + discovered: Partial | undefined>>, +): Record> { + const result = { ...base }; + for (const [provider, models] of Object.entries(discovered) as Array< + [ProviderKind, ReadonlyArray | undefined] + >) { + if (!models || models.length === 0) continue; + const normalizedModels = + provider === "cursor" + ? models.filter((model) => resolveCursorPickerModelSlug(model.slug) === model.slug) + : models; + const dedupedModels = Array.from(new Map(normalizedModels.map((m) => [m.slug, m])).values()); + const existing = new Set(base[provider]?.map((m) => m.slug)); + // For copilot, discovered models replace the static list but inherit + // pricingTier from the static entries when the SDK doesn't provide it. + if (provider === "copilot") { + const baseTiers = new Map( + (base[provider] ?? []).map((m) => [m.slug, m.pricingTier]), + ); + const enriched = dedupedModels.map((m) => { + if (m.pricingTier) return m; + const tier = baseTiers.get(m.slug); + return tier ? { ...m, pricingTier: tier } : m; + }); + const customOnly = (base[provider] ?? []).filter( + (m) => m.isCustom && !dedupedModels.some((d) => d.slug === m.slug), + ); + result[provider] = [...enriched, ...customOnly]; + continue; + } + // Build a lookup of discovered models by slug so we can merge metadata + // (e.g. pricingTier) into base entries and also add truly-new models. + const discoveredBySlug = new Map(dedupedModels.map((m) => [m.slug, m])); + const merged = (base[provider] ?? []).map((m) => { + const disc = discoveredBySlug.get(m.slug); + return disc ? { ...m, ...disc } : m; + }); + // Append any discovered models that weren't already in the base list. + const additions = dedupedModels.filter((m) => !existing.has(m.slug)); + result[provider] = [...additions, ...merged]; + } + return result; +} + +function groupModelsBySubProvider( + models: ReadonlyArray, +): ReadonlyArray { + const groupOrder: string[] = []; + const groupMap = new Map(); + const ungrouped: ModelOptionEntry[] = []; + + for (const model of models) { + const slashIndex = model.slug.indexOf("/"); + if (slashIndex > 0) { + const subProviderId = model.slug.slice(0, slashIndex); + const nameSlashIndex = model.name.indexOf(" / "); + const subProviderName = nameSlashIndex > 0 ? model.name.slice(0, nameSlashIndex) : subProviderId; + const modelName = nameSlashIndex > 0 ? model.name.slice(nameSlashIndex + 3) : model.name; + + let group = groupMap.get(subProviderId); + if (!group) { + group = { displayName: subProviderName, models: [] }; + groupMap.set(subProviderId, group); + groupOrder.push(subProviderId); + } + group.models.push({ + slug: model.slug, + name: modelName, + ...(model.pricingTier != null && { pricingTier: model.pricingTier }), + ...(model.isCustom != null && { isCustom: model.isCustom }), + }); + } else { + ungrouped.push(model); + } + } + + const result: GroupedModelEntry[] = groupOrder.map((id) => { + const group = groupMap.get(id)!; + return { subProvider: group.displayName, models: group.models }; + }); + if (ungrouped.length > 0) { + result.push({ subProvider: "Other", models: ungrouped }); + } + return result; +} + +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; + } + + if (provider === "cursor") { + return parseCursorModelSelection(normalized).family; + } + + return null; +} + +export function formatPricingTier(tier: string): string { + // Normalize to uppercase X suffix: "1x" -> "1X", "0.3x" -> "0.3X" + return tier.replace(/x$/i, "X"); +} + +const PROVIDER_ICON_BY_PROVIDER: Record = { + codex: OpenAI, + copilot: GitHubIcon, + claudeCode: ClaudeAI, + cursor: CursorIcon, + opencode: OpenCodeIcon, + geminiCli: Gemini, + amp: AmpIcon, + kilo: KiloIcon, +}; + +export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => option.available); +const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); +const COMING_SOON_PROVIDER_OPTIONS: ReadonlyArray<{ id: string; label: string; icon: Icon }> = []; + +export 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 selectedModelOption = selectedProviderOptions.find((option) => option.slug === props.model); + const selectedModelLabel = selectedModelOption?.name ?? props.model; + const selectedPricingTier = selectedModelOption?.pricingTier; + 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; + const providerModels = props.modelOptionsByProvider[option.value]; + const onModelSelect = (value: string) => { + if (props.disabled) return; + if (isDisabledByProviderLock) return; + if (!value) return; + const resolvedModel = resolveModelForProviderPicker(option.value, value, providerModels); + if (!resolvedModel) return; + props.onProviderModelChange(option.value, resolvedModel); + setIsMenuOpen(false); + }; + + // OpenCode / Kilo: two-tiered picker grouped by sub-provider + if (option.value === "opencode" || option.value === "kilo") { + const groups = groupModelsBySubProvider(providerModels); + return ( + + + + + {groups.length === 0 ? ( + + No models discovered + + ) : ( + groups.map((group) => ( + + {group.subProvider} + + + + {group.models.map((modelOption) => ( + setIsMenuOpen(false)} + > + + {modelOption.name} + {modelOption.pricingTier ? ( + + {formatPricingTier(modelOption.pricingTier)} + + ) : null} + + + ))} + + + + + )) + )} + + + ); + } + + return ( + + + + + + + {providerModels.map((modelOption) => ( + setIsMenuOpen(false)} + > + + {modelOption.name} + {modelOption.pricingTier ? ( + + {formatPricingTier(modelOption.pricingTier)} + + ) : null} + + + ))} + + + + + ); + })} + {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/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx new file mode 100644 index 000000000000..b48412453ce2 --- /dev/null +++ b/apps/web/src/components/chat/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/chat/VscodeEntryIcon.tsx b/apps/web/src/components/chat/VscodeEntryIcon.tsx new file mode 100644 index 000000000000..bf110e2bb8ff --- /dev/null +++ b/apps/web/src/components/chat/VscodeEntryIcon.tsx @@ -0,0 +1,37 @@ +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)} + /> + ); +}); diff --git a/bun.lock b/bun.lock index 2e6ca5ff4243..6a2c3b24970a 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ }, "apps/desktop": { "name": "@t3tools/desktop", - "version": "0.0.9", + "version": "0.0.10", "dependencies": { "effect": "catalog:", "electron": "40.6.0", @@ -43,7 +43,7 @@ }, "apps/server": { "name": "t3", - "version": "0.0.9", + "version": "0.0.10", "bin": { "t3": "./dist/index.mjs", }, @@ -76,7 +76,7 @@ }, "apps/web": { "name": "@t3tools/web", - "version": "0.0.9", + "version": "0.0.10", "dependencies": { "@base-ui/react": "^1.2.0", "@dnd-kit/core": "^6.3.1", @@ -125,7 +125,7 @@ }, "packages/contracts": { "name": "@t3tools/contracts", - "version": "0.0.9", + "version": "0.0.10", "dependencies": { "effect": "catalog:", }, diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 56d903b111a5..286a1b9b04f2 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.9", + "version": "0.0.10", "private": true, "files": [ "dist"