diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts new file mode 100644 index 000000000..4cd64af9f --- /dev/null +++ b/apps/web/src/components/ChatView.logic.ts @@ -0,0 +1,140 @@ +import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; +import { type ChatMessage, type Thread } from "../types"; +import { randomUUID } from "~/lib/utils"; +import { getAppModelOptions } from "../appSettings"; +import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; + +export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; +const WORKTREE_BRANCH_PREFIX = "t3code"; + +export function readLastInvokedScriptByProjectFromStorage(): Record { + const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); + if (!stored) return {}; + + try { + const parsed: unknown = JSON.parse(stored); + if (!parsed || typeof parsed !== "object") return {}; + return Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [string, string] => + typeof entry[0] === "string" && typeof entry[1] === "string", + ), + ); + } catch { + return {}; + } +} + +export function buildLocalDraftThread( + threadId: ThreadId, + draftThread: DraftThreadState, + fallbackModel: string, + error: string | null, +): Thread { + return { + id: threadId, + codexThreadId: null, + projectId: draftThread.projectId, + title: "New thread", + model: fallbackModel, + runtimeMode: draftThread.runtimeMode, + interactionMode: draftThread.interactionMode, + session: null, + messages: [], + error, + createdAt: draftThread.createdAt, + latestTurn: null, + lastVisitedAt: draftThread.createdAt, + branch: draftThread.branch, + worktreePath: draftThread.worktreePath, + turnDiffSummaries: [], + activities: [], + proposedPlans: [], + }; +} + +export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { + if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { + return; + } + URL.revokeObjectURL(previewUrl); +} + +export function revokeUserMessagePreviewUrls(message: ChatMessage): void { + if (message.role !== "user" || !message.attachments) { + return; + } + for (const attachment of message.attachments) { + if (attachment.type !== "image") { + continue; + } + revokeBlobPreviewUrl(attachment.previewUrl); + } +} + +export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] { + if (message.role !== "user" || !message.attachments) { + return []; + } + const previewUrls: string[] = []; + for (const attachment of message.attachments) { + if (attachment.type !== "image") continue; + if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue; + previewUrls.push(attachment.previewUrl); + } + return previewUrls; +} + +export type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; + +export interface PullRequestDialogState { + initialReference: string | null; + key: number; +} + +export function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("load", () => { + if (typeof reader.result === "string") { + resolve(reader.result); + return; + } + reject(new Error("Could not read image data.")); + }); + reader.addEventListener("error", () => { + reject(reader.error ?? new Error("Failed to read image.")); + }); + reader.readAsDataURL(file); + }); +} + +export function buildTemporaryWorktreeBranchName(): string { + // Keep the 8-hex suffix shape for backend temporary-branch detection. + const token = randomUUID().slice(0, 8).toLowerCase(); + return `${WORKTREE_BRANCH_PREFIX}/${token}`; +} + +export function cloneComposerImageForRetry( + image: ComposerImageAttachment, +): ComposerImageAttachment { + if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) { + return image; + } + try { + return { + ...image, + previewUrl: URL.createObjectURL(image.file), + }; + } catch { + return image; + } +} + +export function getCustomModelOptionsByProvider(settings: { + customCodexModels: readonly string[]; +}): Record> { + return { + codex: getAppModelOptions("codex", settings.customCodexModels), + }; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c8a0a152..d81e024f3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,7 +1,6 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, - EDITORS, type EditorId, type KeybindingCommand, type CodexReasoningEffort, @@ -29,34 +28,17 @@ import { normalizeModelSlug, 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 { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; - import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { - type ComposerSlashCommand, type ComposerTrigger, - type ComposerTriggerKind, detectComposerTrigger, expandCollapsedComposerCursor, parseStandaloneComposerSlashCommand, @@ -70,17 +52,12 @@ import { deriveActiveWorkStartedAt, deriveActivePlanState, findLatestProposedPlan, - type PendingApproval, - type PendingUserInput, - type ProviderPickerKind, - PROVIDER_OPTIONS, deriveWorkLogEntries, hasToolActivityForTurn, isLatestTurnSettled, formatElapsed, - formatTimestamp, } from "../session-logic"; -import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll"; +import { isScrollContainerNearBottom } from "../chat-scroll"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -89,15 +66,10 @@ import { } from "../pendingUserInput"; import { useStore } from "../store"; import { - buildCollapsedProposedPlanPreviewMarkdown, buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, - buildProposedPlanMarkdownFilename, - downloadPlanAsTextFile, - normalizePlanMarkdownForExport, proposedPlanTitle, resolvePlanFollowUpSubmission, - stripDisplayedPlanMarkdown, } from "../proposedPlan"; import { truncateTitle } from "../truncateTitle"; import { @@ -106,92 +78,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 { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { BotIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, CircleAlertIcon, - FileIcon, - FolderIcon, - DiffIcon, - EllipsisIcon, - FolderClosedIcon, ListTodoIcon, LockIcon, LockOpenIcon, - Undo2Icon, XIcon, - CopyIcon, - CheckIcon, } from "lucide-react"; import { Button } from "./ui/button"; -import { Input } from "./ui/input"; import { Separator } from "./ui/separator"; -import { Group, GroupSeparator } from "./ui/group"; -import { - Menu, - MenuGroup, - MenuItem, - MenuPopup, - MenuRadioGroup, - MenuRadioItem, - MenuSeparator as MenuDivider, - MenuSub, - MenuSubPopup, - MenuSubTrigger, - MenuShortcut, - MenuTrigger, -} from "./ui/menu"; -import { - ClaudeAI, - CursorIcon, - Gemini, - Icon, - OpenAI, - OpenCodeIcon, - VisualStudioCode, - Zed, -} from "./Icons"; -import { cn, isMacPlatform, isWindowsPlatform, randomUUID } from "~/lib/utils"; -import { Badge } from "./ui/badge"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; +import { cn, randomUUID } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { Command, CommandItem, CommandList } from "./ui/command"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "./ui/dialog"; import { toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; -import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; +import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId, @@ -199,58 +113,49 @@ 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 { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; +import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; +import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; +import { CodexTraitsPicker } from "./chat/CodexTraitsPicker"; +import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; +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, + getCustomModelOptionsByProvider, + LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, + PullRequestDialogState, + readFileAsDataUrl, + readLastInvokedScriptByProjectFromStorage, + revokeBlobPreviewUrl, + revokeUserMessagePreviewUrls, + SendPhase, +} from "./ChatView.logic"; -const LAST_EDITOR_KEY = "t3code:last-editor"; -const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; -const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; -const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; const IMAGE_ONLY_BOOTSTRAP_PROMPT = @@ -264,311 +169,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"; -} - -interface ExpandedImageItem { - src: string; - name: string; -} - -interface ExpandedImagePreview { - images: ExpandedImageItem[]; - index: number; -} - -function buildExpandedImagePreview( - images: ReadonlyArray<{ id: string; name: string; previewUrl?: string }>, - selectedImageId: string, -): ExpandedImagePreview | null { - const previewableImages = images.flatMap((image) => - image.previewUrl ? [{ id: image.id, src: image.previewUrl, name: image.name }] : [], - ); - if (previewableImages.length === 0) { - return null; - } - const selectedIndex = previewableImages.findIndex((image) => image.id === selectedImageId); - if (selectedIndex < 0) { - return null; - } - return { - images: previewableImages.map((image) => ({ src: image.src, name: image.name })), - index: selectedIndex, - }; -} - -function buildLocalDraftThread( - threadId: ThreadId, - draftThread: DraftThreadState, - fallbackModel: string, - error: string | null, -): Thread { - return { - id: threadId, - codexThreadId: null, - projectId: draftThread.projectId, - title: "New thread", - model: fallbackModel, - runtimeMode: draftThread.runtimeMode, - interactionMode: draftThread.interactionMode, - session: null, - messages: [], - error, - createdAt: draftThread.createdAt, - latestTurn: null, - lastVisitedAt: draftThread.createdAt, - branch: draftThread.branch, - worktreePath: draftThread.worktreePath, - turnDiffSummaries: [], - activities: [], - proposedPlans: [], - }; -} - -function revokeBlobPreviewUrl(previewUrl: string | undefined): void { - if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { - return; - } - URL.revokeObjectURL(previewUrl); -} - -function revokeUserMessagePreviewUrls(message: ChatMessage): void { - if (message.role !== "user" || !message.attachments) { - return; - } - for (const attachment of message.attachments) { - if (attachment.type !== "image") { - continue; - } - revokeBlobPreviewUrl(attachment.previewUrl); - } -} - -function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] { - if (message.role !== "user" || !message.attachments) { - return []; - } - const previewUrls: string[] = []; - for (const attachment of message.attachments) { - if (attachment.type !== "image") continue; - if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue; - previewUrls.push(attachment.previewUrl); - } - return previewUrls; -} - -type ComposerCommandItem = - | { - id: string; - type: "path"; - path: string; - pathKind: ProjectEntry["kind"]; - label: string; - description: string; - } - | { - id: string; - type: "slash-command"; - command: ComposerSlashCommand; - label: string; - description: string; - } - | { - id: string; - type: "model"; - provider: ProviderKind; - model: ModelSlug; - label: string; - description: string; - }; - -type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; - -interface PullRequestDialogState { - initialReference: string | null; - key: number; -} - -function readFileAsDataUrl(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.addEventListener("load", () => { - if (typeof reader.result === "string") { - resolve(reader.result); - return; - } - reject(new Error("Could not read image data.")); - }); - reader.addEventListener("error", () => { - reject(reader.error ?? new Error("Failed to read image.")); - }); - reader.readAsDataURL(file); - }); -} - -function buildTemporaryWorktreeBranchName(): string { - // Keep the 8-hex suffix shape for backend temporary-branch detection. - const token = randomUUID().slice(0, 8).toLowerCase(); - return `${WORKTREE_BRANCH_PREFIX}/${token}`; -} - -function cloneComposerImageForRetry(image: ComposerImageAttachment): ComposerImageAttachment { - if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) { - return image; - } - try { - return { - ...image, - previewUrl: URL.createObjectURL(image.file), - }; - } catch { - return image; - } -} - -const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { - pathValue: string; - kind: "file" | "directory"; - theme: "light" | "dark"; - className?: string; -}) { - const [failedIconUrl, setFailedIconUrl] = useState(null); - const iconUrl = useMemo( - () => getVscodeIconUrlForEntry(props.pathValue, props.kind, props.theme), - [props.kind, props.pathValue, props.theme], - ); - const failed = failedIconUrl === iconUrl; - - if (failed) { - return props.kind === "directory" ? ( - - ) : ( - - ); - } - - return ( - setFailedIconUrl(iconUrl)} - /> - ); -}); - -const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { - item: ComposerCommandItem; - resolvedTheme: "light" | "dark"; - isActive: boolean; - onSelect: (item: ComposerCommandItem) => void; -}) { - return ( - { - event.preventDefault(); - }} - onClick={() => { - props.onSelect(props.item); - }} - > - {props.item.type === "path" ? ( - - ) : null} - {props.item.type === "slash-command" ? ( - - ) : null} - {props.item.type === "model" ? ( - - model - - ) : null} - - {props.item.label} - - {props.item.description} - - ); -}); - -const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { - items: ComposerCommandItem[]; - resolvedTheme: "light" | "dark"; - isLoading: boolean; - triggerKind: ComposerTriggerKind | null; - activeItemId: string | null; - onHighlightedItemChange: (itemId: string | null) => void; - onSelect: (item: ComposerCommandItem) => void; -}) { - return ( - { - props.onHighlightedItemChange( - typeof highlightedValue === "string" ? highlightedValue : null, - ); - }} - > -
- - {props.items.map((item) => ( - - ))} - - {props.items.length === 0 && ( -

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

- )} -
-
- ); -}); interface ChatViewProps { threadId: ThreadId; @@ -1493,7 +1093,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(); @@ -2335,7 +1939,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") { @@ -3311,7 +2917,10 @@ export default function ChatView({ threadId }: ChatViewProps) { [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt], ); - const readComposerSnapshot = useCallback((): { value: string; cursor: number } => { + const readComposerSnapshot = useCallback((): { + value: string; + cursor: number; + } => { const editorSnapshot = composerEditorRef.current?.readSnapshot(); if (editorSnapshot) { return editorSnapshot; @@ -4252,1901 +3861,3 @@ export default function ChatView({ threadId }: ChatViewProps) { ); } - -interface ChatHeaderProps { - activeThreadId: ThreadId; - activeThreadTitle: string; - activeProjectName: string | undefined; - isGitRepo: boolean; - openInCwd: string | null; - activeProjectScripts: ProjectScript[] | undefined; - preferredScriptId: string | null; - keybindings: ResolvedKeybindingsConfig; - availableEditors: ReadonlyArray; - diffToggleShortcutLabel: string | null; - gitCwd: string | null; - diffOpen: boolean; - onRunProjectScript: (script: ProjectScript) => void; - onAddProjectScript: (input: NewProjectScriptInput) => Promise; - onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; - onDeleteProjectScript: (scriptId: string) => Promise; - onToggleDiff: () => void; -} - -const ChatHeader = memo(function ChatHeader({ - activeThreadId, - activeThreadTitle, - activeProjectName, - isGitRepo, - openInCwd, - activeProjectScripts, - preferredScriptId, - keybindings, - availableEditors, - diffToggleShortcutLabel, - gitCwd, - diffOpen, - onRunProjectScript, - onAddProjectScript, - onUpdateProjectScript, - onDeleteProjectScript, - onToggleDiff, -}: ChatHeaderProps) { - return ( -
-
- -

- {activeThreadTitle} -

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

{activeQuestion.question}

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

{title}

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

- {groupLabel} -

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

- {workEntry.label} -

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

- {workEntry.detail} -

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

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

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

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

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

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

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

- Send a message to start the conversation. -

-
- ); - } - - return ( -
- {virtualizedRowCount > 0 && ( -
- {virtualRows.map((virtualRow: VirtualItem) => { - const row = rows[virtualRow.index]; - if (!row) return null; - - return ( -
- {renderRowContent(row)} -
- ); - })} -
- )} - - {nonVirtualizedRows.map((row) => ( -
{renderRowContent(row)}
- ))} -
- ); -}); - -function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { - value: ProviderKind; - label: string; - available: true; -} { - return option.available && option.value !== "claudeCode"; -} - -const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); -const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); -const COMING_SOON_PROVIDER_OPTIONS = [ - { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, - { id: "gemini", label: "Gemini", icon: Gemini }, -] as const; - -function getCustomModelOptionsByProvider(settings: { - customCodexModels: readonly string[]; -}): Record> { - return { - codex: getAppModelOptions("codex", settings.customCodexModels), - }; -} - -const PROVIDER_ICON_BY_PROVIDER: Record = { - codex: OpenAI, - claudeCode: ClaudeAI, - cursor: CursorIcon, -}; - -function resolveModelForProviderPicker( - provider: ProviderKind, - value: string, - options: ReadonlyArray<{ slug: string; name: string }>, -): ModelSlug | null { - const trimmedValue = value.trim(); - if (!trimmedValue) { - return null; - } - - const direct = options.find((option) => option.slug === trimmedValue); - if (direct) { - return direct.slug; - } - - const byName = options.find((option) => option.name.toLowerCase() === trimmedValue.toLowerCase()); - if (byName) { - return byName.slug; - } - - const normalized = normalizeModelSlug(trimmedValue, provider); - if (!normalized) { - return null; - } - - const resolved = options.find((option) => option.slug === normalized); - if (resolved) { - return resolved.slug; - } - - return null; -} - -const ProviderModelPicker = memo(function ProviderModelPicker(props: { - provider: ProviderKind; - model: ModelSlug; - lockedProvider: ProviderKind | null; - modelOptionsByProvider: Record>; - compact?: boolean; - disabled?: boolean; - onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; -}) { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const selectedProviderOptions = props.modelOptionsByProvider[props.provider]; - const selectedModelLabel = - selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; - const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; - - return ( - { - if (props.disabled) { - setIsMenuOpen(false); - return; - } - setIsMenuOpen(open); - }} - > - - } - > - - - - - {AVAILABLE_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; - const isDisabledByProviderLock = - props.lockedProvider !== null && props.lockedProvider !== option.value; - return ( - - - - - - { - if (props.disabled) return; - if (isDisabledByProviderLock) return; - if (!value) return; - const resolvedModel = resolveModelForProviderPicker( - option.value, - value, - props.modelOptionsByProvider[option.value], - ); - if (!resolvedModel) return; - props.onProviderModelChange(option.value, resolvedModel); - setIsMenuOpen(false); - }} - > - {props.modelOptionsByProvider[option.value].map((modelOption) => ( - setIsMenuOpen(false)} - > - {modelOption.name} - - ))} - - - - - ); - })} - {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && } - {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; - return ( - - - ); - })} - {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && } - {COMING_SOON_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = option.icon; - return ( - - - ); - })} - - - ); -}); - -const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { - activePlan: boolean; - interactionMode: ProviderInteractionMode; - planSidebarOpen: boolean; - runtimeMode: RuntimeMode; - selectedEffort: CodexReasoningEffort | null; - selectedProvider: ProviderKind; - selectedCodexFastModeEnabled: boolean; - reasoningOptions: ReadonlyArray; - onEffortSelect: (effort: CodexReasoningEffort) => void; - onCodexFastModeChange: (enabled: boolean) => void; - onToggleInteractionMode: () => void; - onTogglePlanSidebar: () => void; - onToggleRuntimeMode: () => void; -}) { - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningLabelByOption: Record = { - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", - }; - - return ( - - - } - > - - - {props.selectedProvider === "codex" && props.selectedEffort != null ? ( - <> - -
Reasoning
- { - if (!value) return; - const nextEffort = props.reasoningOptions.find((option) => option === value); - if (!nextEffort) return; - props.onEffortSelect(nextEffort); - }} - > - {props.reasoningOptions.map((effort) => ( - - {reasoningLabelByOption[effort]} - {effort === defaultReasoningEffort ? " (default)" : ""} - - ))} - -
- - -
Fast Mode
- { - props.onCodexFastModeChange(value === "on"); - }} - > - off - on - -
- - - ) : null} - -
Mode
- { - if (!value || value === props.interactionMode) return; - props.onToggleInteractionMode(); - }} - > - Chat - Plan - -
- - -
Access
- { - if (!value || value === props.runtimeMode) return; - props.onToggleRuntimeMode(); - }} - > - Supervised - Full access - -
- {props.activePlan ? ( - <> - - - - {props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"} - - - ) : null} -
-
- ); -}); - -const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { - effort: CodexReasoningEffort; - fastModeEnabled: boolean; - options: ReadonlyArray; - onEffortChange: (effort: CodexReasoningEffort) => void; - onFastModeChange: (enabled: boolean) => void; -}) { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningLabelByOption: Record = { - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", - }; - const triggerLabel = [ - reasoningLabelByOption[props.effort], - ...(props.fastModeEnabled ? ["Fast"] : []), - ] - .filter(Boolean) - .join(" · "); - - return ( - { - setIsMenuOpen(open); - }} - > - - } - > - {triggerLabel} - - - -
Reasoning
- { - if (!value) return; - const nextEffort = props.options.find((option) => option === value); - if (!nextEffort) return; - props.onEffortChange(nextEffort); - }} - > - {props.options.map((effort) => ( - - {reasoningLabelByOption[effort]} - {effort === defaultReasoningEffort ? " (default)" : ""} - - ))} - -
- - -
Fast Mode
- { - props.onFastModeChange(value === "on"); - }} - > - off - on - -
-
-
- ); -}); - -const OpenInPicker = memo(function OpenInPicker({ - keybindings, - availableEditors, - openInCwd, -}: { - keybindings: ResolvedKeybindingsConfig; - availableEditors: ReadonlyArray; - openInCwd: string | null; -}) { - const [lastEditor, setLastEditor] = useState(() => { - const stored = localStorage.getItem(LAST_EDITOR_KEY); - return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; - }); - - const allOptions = useMemo>( - () => [ - { - label: "Cursor", - Icon: CursorIcon, - value: "cursor", - }, - { - label: "VS Code", - Icon: VisualStudioCode, - value: "vscode", - }, - { - label: "Zed", - Icon: Zed, - value: "zed", - }, - { - label: isMacPlatform(navigator.platform) - ? "Finder" - : isWindowsPlatform(navigator.platform) - ? "Explorer" - : "Files", - Icon: FolderClosedIcon, - value: "file-manager", - }, - ], - [], - ); - const options = useMemo( - () => allOptions.filter((option) => availableEditors.includes(option.value)), - [allOptions, availableEditors], - ); - - const effectiveEditor = options.some((option) => option.value === lastEditor) - ? lastEditor - : (options[0]?.value ?? null); - const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; - - const openInEditor = useCallback( - (editorId: EditorId | null) => { - const api = readNativeApi(); - if (!api || !openInCwd) return; - const editor = editorId ?? effectiveEditor; - if (!editor) return; - void api.shell.openInEditor(openInCwd, editor); - localStorage.setItem(LAST_EDITOR_KEY, editor); - setLastEditor(editor); - }, - [effectiveEditor, openInCwd, setLastEditor], - ); - - const openFavoriteEditorShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "editor.openFavorite"), - [keybindings], - ); - - useEffect(() => { - const handler = (e: globalThis.KeyboardEvent) => { - const api = readNativeApi(); - if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; - if (!api || !openInCwd) return; - if (!effectiveEditor) return; - - e.preventDefault(); - void api.shell.openInEditor(openInCwd, effectiveEditor); - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [effectiveEditor, keybindings, openInCwd]); - - return ( - - - - - }> - - - {options.length === 0 && No installed editors found} - {options.map(({ label, Icon, value }) => ( - openInEditor(value)}> - - ))} - - - - ); -}); diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx new file mode 100644 index 000000000..0174a7708 --- /dev/null +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -0,0 +1,136 @@ +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>(() => + buildDirectoryExpansionState(directoryPathsKey ? directoryPathsKey.split("\u0000") : [], true), + ); + useEffect(() => { + setExpandedDirectories(allDirectoryExpansionState); + }, [allDirectoryExpansionState]); + + const toggleDirectory = useCallback((pathValue: string, fallbackExpanded: boolean) => { + setExpandedDirectories((current) => ({ + ...current, + [pathValue]: !(current[pathValue] ?? fallbackExpanded), + })); + }, []); + + const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => { + const leftPadding = 8 + depth * 14; + if (node.kind === "directory") { + const isExpanded = expandedDirectories[node.path] ?? depth === 0; + return ( +
+ + {isExpanded && ( +
+ {node.children.map((childNode) => renderTreeNode(childNode, depth + 1))} +
+ )} +
+ ); + } + + return ( + + ); + }; + + return
{treeNodes.map((node) => renderTreeNode(node, 0))}
; +}); + +function collectDirectoryPaths(nodes: ReadonlyArray): string[] { + const paths: string[] = []; + for (const node of nodes) { + if (node.kind !== "directory") continue; + paths.push(node.path); + paths.push(...collectDirectoryPaths(node.children)); + } + return paths; +} + +function buildDirectoryExpansionState( + directoryPaths: ReadonlyArray, + expanded: boolean, +): Record { + const expandedState: Record = {}; + for (const directoryPath of directoryPaths) { + expandedState[directoryPath] = expanded; + } + return expandedState; +} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx new file mode 100644 index 000000000..ea7f911be --- /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/CodexTraitsPicker.tsx b/apps/web/src/components/chat/CodexTraitsPicker.tsx new file mode 100644 index 000000000..6c72f497b --- /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 000000000..0af50ff01 --- /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 000000000..818c3c20f --- /dev/null +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -0,0 +1,120 @@ +import { type ProjectEntry, type ModelSlug, type ProviderKind } from "@t3tools/contracts"; +import { memo } from "react"; +import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; +import { BotIcon } from "lucide-react"; +import { cn } from "~/lib/utils"; +import { Badge } from "../ui/badge"; +import { Command, CommandItem, CommandList } from "../ui/command"; +import { VscodeEntryIcon } from "./VscodeEntryIcon"; + +export type ComposerCommandItem = + | { + id: string; + type: "path"; + path: string; + pathKind: ProjectEntry["kind"]; + label: string; + description: string; + } + | { + id: string; + type: "slash-command"; + command: ComposerSlashCommand; + label: string; + description: string; + } + | { + id: string; + type: "model"; + provider: ProviderKind; + model: ModelSlug; + label: string; + description: string; + }; + +export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { + items: ComposerCommandItem[]; + resolvedTheme: "light" | "dark"; + isLoading: boolean; + triggerKind: ComposerTriggerKind | null; + activeItemId: string | null; + onHighlightedItemChange: (itemId: string | null) => void; + onSelect: (item: ComposerCommandItem) => void; +}) { + return ( + { + props.onHighlightedItemChange( + typeof highlightedValue === "string" ? highlightedValue : null, + ); + }} + > +
+ + {props.items.map((item) => ( + + ))} + + {props.items.length === 0 && ( +

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

+ )} +
+
+ ); +}); + +const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { + item: ComposerCommandItem; + resolvedTheme: "light" | "dark"; + isActive: boolean; + onSelect: (item: ComposerCommandItem) => void; +}) { + return ( + { + event.preventDefault(); + }} + onClick={() => { + props.onSelect(props.item); + }} + > + {props.item.type === "path" ? ( + + ) : null} + {props.item.type === "slash-command" ? ( + + ) : null} + {props.item.type === "model" ? ( + + model + + ) : null} + + {props.item.label} + + {props.item.description} + + ); +}); diff --git a/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx new file mode 100644 index 000000000..5786bab47 --- /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 000000000..569fd108a --- /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 000000000..c8cad7bf3 --- /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 000000000..49b03f772 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx @@ -0,0 +1,21 @@ +import { memo } from "react"; + +export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({ + planTitle, +}: { + planTitle: string | null; +}) { + return ( +
+
+ Plan ready + {planTitle ? ( + {planTitle} + ) : null} +
+ {/*
+ Review the plan +
*/} +
+ ); +}); diff --git a/apps/web/src/components/chat/DiffStatLabel.tsx b/apps/web/src/components/chat/DiffStatLabel.tsx new file mode 100644 index 000000000..2dda06fd9 --- /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 000000000..db5803d49 --- /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 000000000..b3972d253 --- /dev/null +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -0,0 +1,19 @@ +import { memo, useCallback, useState } from "react"; +import { CopyIcon, CheckIcon } from "lucide-react"; +import { Button } from "../ui/button"; + +export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + void navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [text]); + + return ( + + ); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx new file mode 100644 index 000000000..3bfef9d87 --- /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 000000000..701204c1b --- /dev/null +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -0,0 +1,132 @@ +import { EDITORS, type EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; +import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import { Group, GroupSeparator } from "../ui/group"; +import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; +import { CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons"; +import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; +import { readNativeApi } from "~/nativeApi"; + +const LAST_EDITOR_KEY = "t3code:last-editor"; + +export const OpenInPicker = memo(function OpenInPicker({ + keybindings, + availableEditors, + openInCwd, +}: { + keybindings: ResolvedKeybindingsConfig; + availableEditors: ReadonlyArray; + openInCwd: string | null; +}) { + const [lastEditor, setLastEditor] = useState(() => { + const stored = localStorage.getItem(LAST_EDITOR_KEY); + return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; + }); + + const allOptions = useMemo>( + () => [ + { + label: "Cursor", + Icon: CursorIcon, + value: "cursor", + }, + { + label: "VS Code", + Icon: VisualStudioCode, + value: "vscode", + }, + { + label: "Zed", + Icon: Zed, + value: "zed", + }, + { + label: isMacPlatform(navigator.platform) + ? "Finder" + : isWindowsPlatform(navigator.platform) + ? "Explorer" + : "Files", + Icon: FolderClosedIcon, + value: "file-manager", + }, + ], + [], + ); + const options = useMemo( + () => allOptions.filter((option) => availableEditors.includes(option.value)), + [allOptions, availableEditors], + ); + + const effectiveEditor = options.some((option) => option.value === lastEditor) + ? lastEditor + : (options[0]?.value ?? null); + const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; + + const openInEditor = useCallback( + (editorId: EditorId | null) => { + const api = readNativeApi(); + if (!api || !openInCwd) return; + const editor = editorId ?? effectiveEditor; + if (!editor) return; + void api.shell.openInEditor(openInCwd, editor); + localStorage.setItem(LAST_EDITOR_KEY, editor); + setLastEditor(editor); + }, + [effectiveEditor, openInCwd, setLastEditor], + ); + + const openFavoriteEditorShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "editor.openFavorite"), + [keybindings], + ); + + useEffect(() => { + const handler = (e: globalThis.KeyboardEvent) => { + const api = readNativeApi(); + if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; + if (!api || !openInCwd) return; + if (!effectiveEditor) return; + + e.preventDefault(); + void api.shell.openInEditor(openInCwd, effectiveEditor); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [effectiveEditor, keybindings, openInCwd]); + + return ( + + + + + }> + + + {options.length === 0 && No installed editors found} + {options.map(({ label, Icon, value }) => ( + openInEditor(value)}> + + ))} + + + + ); +}); diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx new file mode 100644 index 000000000..c8956b9cf --- /dev/null +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -0,0 +1,211 @@ +import { memo, useState, useId } from "react"; +import { + buildCollapsedProposedPlanPreviewMarkdown, + buildProposedPlanMarkdownFilename, + downloadPlanAsTextFile, + normalizePlanMarkdownForExport, + proposedPlanTitle, + stripDisplayedPlanMarkdown, +} from "../../proposedPlan"; +import ChatMarkdown from "../ChatMarkdown"; +import { EllipsisIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { cn } from "~/lib/utils"; +import { Badge } from "../ui/badge"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { toastManager } from "../ui/toast"; +import { readNativeApi } from "~/nativeApi"; + +export const ProposedPlanCard = memo(function ProposedPlanCard({ + planMarkdown, + cwd, + workspaceRoot, +}: { + planMarkdown: string; + cwd: string | undefined; + workspaceRoot: string | undefined; +}) { + const [expanded, setExpanded] = useState(false); + const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); + const [savePath, setSavePath] = useState(""); + const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); + const savePathInputId = useId(); + const title = proposedPlanTitle(planMarkdown) ?? "Proposed plan"; + const lineCount = planMarkdown.split("\n").length; + const canCollapse = planMarkdown.length > 900 || lineCount > 20; + const displayedPlanMarkdown = stripDisplayedPlanMarkdown(planMarkdown); + const collapsedPreview = canCollapse + ? buildCollapsedProposedPlanPreviewMarkdown(planMarkdown, { maxLines: 10 }) + : null; + const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown); + const saveContents = normalizePlanMarkdownForExport(planMarkdown); + + const handleDownload = () => { + downloadPlanAsTextFile(downloadFilename, saveContents); + }; + + const openSaveDialog = () => { + if (!workspaceRoot) { + toastManager.add({ + type: "error", + title: "Workspace path is unavailable", + description: "This thread does not have a workspace path to save into.", + }); + return; + } + setSavePath((existing) => (existing.length > 0 ? existing : downloadFilename)); + setIsSaveDialogOpen(true); + }; + + const handleSaveToWorkspace = () => { + const api = readNativeApi(); + const relativePath = savePath.trim(); + if (!api || !workspaceRoot) { + return; + } + if (!relativePath) { + toastManager.add({ + type: "warning", + title: "Enter a workspace path", + }); + return; + } + + setIsSavingToWorkspace(true); + void api.projects + .writeFile({ + cwd: workspaceRoot, + relativePath, + contents: saveContents, + }) + .then((result) => { + setIsSaveDialogOpen(false); + toastManager.add({ + type: "success", + title: "Plan saved to workspace", + description: result.relativePath, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not save plan", + description: error instanceof Error ? error.message : "An error occurred while saving.", + }); + }) + .then( + () => { + setIsSavingToWorkspace(false); + }, + () => { + setIsSavingToWorkspace(false); + }, + ); + }; + + return ( +
+
+
+ Plan +

{title}

+
+ + } + > + + + Download as markdown + + Save to workspace + + + +
+
+
+ {canCollapse && !expanded ? ( + + ) : ( + + )} + {canCollapse && !expanded ? ( +
+ ) : null} +
+ {canCollapse ? ( +
+ +
+ ) : null} +
+ + { + if (!isSavingToWorkspace) { + setIsSaveDialogOpen(open); + } + }} + > + + + Save plan to workspace + + Enter a path relative to {workspaceRoot ?? "the workspace"}. + + + + + + + + + + + +
+ ); +}); diff --git a/apps/web/src/components/chat/ProviderHealthBanner.tsx b/apps/web/src/components/chat/ProviderHealthBanner.tsx new file mode 100644 index 000000000..12c7f6054 --- /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 000000000..9bc034991 --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -0,0 +1,206 @@ +import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; +import { normalizeModelSlug } from "@t3tools/shared/model"; +import { memo, useState } from "react"; +import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; +import { ChevronDownIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import { + Menu, + MenuGroup, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator as MenuDivider, + MenuSub, + MenuSubPopup, + MenuSubTrigger, + MenuTrigger, +} from "../ui/menu"; +import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { cn } from "~/lib/utils"; + +function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { + value: ProviderKind; + label: string; + available: true; +} { + return option.available && option.value !== "claudeCode"; +} + +function resolveModelForProviderPicker( + provider: ProviderKind, + value: string, + options: ReadonlyArray<{ slug: string; name: string }>, +): ModelSlug | null { + const trimmedValue = value.trim(); + if (!trimmedValue) { + return null; + } + + const direct = options.find((option) => option.slug === trimmedValue); + if (direct) { + return direct.slug; + } + + const byName = options.find((option) => option.name.toLowerCase() === trimmedValue.toLowerCase()); + if (byName) { + return byName.slug; + } + + const normalized = normalizeModelSlug(trimmedValue, provider); + if (!normalized) { + return null; + } + + const resolved = options.find((option) => option.slug === normalized); + if (resolved) { + return resolved.slug; + } + + return null; +} + +const PROVIDER_ICON_BY_PROVIDER: Record = { + codex: OpenAI, + claudeCode: ClaudeAI, + cursor: CursorIcon, +}; + +export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); +const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); +const COMING_SOON_PROVIDER_OPTIONS = [ + { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, + { id: "gemini", label: "Gemini", icon: Gemini }, +] as const; + +export const ProviderModelPicker = memo(function ProviderModelPicker(props: { + provider: ProviderKind; + model: ModelSlug; + lockedProvider: ProviderKind | null; + modelOptionsByProvider: Record>; + compact?: boolean; + disabled?: boolean; + onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const selectedProviderOptions = props.modelOptionsByProvider[props.provider]; + const selectedModelLabel = + selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; + const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; + + return ( + { + if (props.disabled) { + setIsMenuOpen(false); + return; + } + setIsMenuOpen(open); + }} + > + + } + > + + + + + {AVAILABLE_PROVIDER_OPTIONS.map((option) => { + const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; + const isDisabledByProviderLock = + props.lockedProvider !== null && props.lockedProvider !== option.value; + return ( + + + + + + { + if (props.disabled) return; + if (isDisabledByProviderLock) return; + if (!value) return; + const resolvedModel = resolveModelForProviderPicker( + option.value, + value, + props.modelOptionsByProvider[option.value], + ); + if (!resolvedModel) return; + props.onProviderModelChange(option.value, resolvedModel); + setIsMenuOpen(false); + }} + > + {props.modelOptionsByProvider[option.value].map((modelOption) => ( + setIsMenuOpen(false)} + > + {modelOption.name} + + ))} + + + + + ); + })} + {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && } + {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { + const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; + return ( + + + ); + })} + {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && } + {COMING_SOON_PROVIDER_OPTIONS.map((option) => { + const OptionIcon = option.icon; + return ( + + + ); + })} + + + ); +}); diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx new file mode 100644 index 000000000..b48412453 --- /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 000000000..bf110e2bb --- /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)} + /> + ); +});