| author | |
| committer | |
| log | e3d46b687ce2537b4761ab9ae1c50db6708d3948 |
| tree | 4a9167be1f47b88524c757c5ab679104f9156a36 |
| parent | 8636ea0ec4163714a445cf4b11d53bbf88fd9ee7 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Christian Smith <chris@Christians-MacBook-Air.local>21 files changed, 2468 insertions(+), 2336 deletions(-)
apps/web/src/components/ChatView.logic.ts created+140| ... | ... | @@ -0,0 +1,140 @@ |
| 1 | import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; | |
| 2 | import { type ChatMessage, type Thread } from "../types"; | |
| 3 | import { randomUUID } from "~/lib/utils"; | |
| 4 | import { getAppModelOptions } from "../appSettings"; | |
| 5 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; | |
| 6 | ||
| 7 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; | |
| 8 | const WORKTREE_BRANCH_PREFIX = "t3code"; | |
| 9 | ||
| 10 | export function readLastInvokedScriptByProjectFromStorage(): Record<string, string> { | |
| 11 | const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); | |
| 12 | if (!stored) return {}; | |
| 13 | ||
| 14 | try { | |
| 15 | const parsed: unknown = JSON.parse(stored); | |
| 16 | if (!parsed || typeof parsed !== "object") return {}; | |
| 17 | return Object.fromEntries( | |
| 18 | Object.entries(parsed).filter( | |
| 19 | (entry): entry is [string, string] => | |
| 20 | typeof entry[0] === "string" && typeof entry[1] === "string", | |
| 21 | ), | |
| 22 | ); | |
| 23 | } catch { | |
| 24 | return {}; | |
| 25 | } | |
| 26 | } | |
| 27 | ||
| 28 | export function buildLocalDraftThread( | |
| 29 | threadId: ThreadId, | |
| 30 | draftThread: DraftThreadState, | |
| 31 | fallbackModel: string, | |
| 32 | error: string | null, | |
| 33 | ): Thread { | |
| 34 | return { | |
| 35 | id: threadId, | |
| 36 | codexThreadId: null, | |
| 37 | projectId: draftThread.projectId, | |
| 38 | title: "New thread", | |
| 39 | model: fallbackModel, | |
| 40 | runtimeMode: draftThread.runtimeMode, | |
| 41 | interactionMode: draftThread.interactionMode, | |
| 42 | session: null, | |
| 43 | messages: [], | |
| 44 | error, | |
| 45 | createdAt: draftThread.createdAt, | |
| 46 | latestTurn: null, | |
| 47 | lastVisitedAt: draftThread.createdAt, | |
| 48 | branch: draftThread.branch, | |
| 49 | worktreePath: draftThread.worktreePath, | |
| 50 | turnDiffSummaries: [], | |
| 51 | activities: [], | |
| 52 | proposedPlans: [], | |
| 53 | }; | |
| 54 | } | |
| 55 | ||
| 56 | export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { | |
| 57 | if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { | |
| 58 | return; | |
| 59 | } | |
| 60 | URL.revokeObjectURL(previewUrl); | |
| 61 | } | |
| 62 | ||
| 63 | export function revokeUserMessagePreviewUrls(message: ChatMessage): void { | |
| 64 | if (message.role !== "user" || !message.attachments) { | |
| 65 | return; | |
| 66 | } | |
| 67 | for (const attachment of message.attachments) { | |
| 68 | if (attachment.type !== "image") { | |
| 69 | continue; | |
| 70 | } | |
| 71 | revokeBlobPreviewUrl(attachment.previewUrl); | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] { | |
| 76 | if (message.role !== "user" || !message.attachments) { | |
| 77 | return []; | |
| 78 | } | |
| 79 | const previewUrls: string[] = []; | |
| 80 | for (const attachment of message.attachments) { | |
| 81 | if (attachment.type !== "image") continue; | |
| 82 | if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue; | |
| 83 | previewUrls.push(attachment.previewUrl); | |
| 84 | } | |
| 85 | return previewUrls; | |
| 86 | } | |
| 87 | ||
| 88 | export type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; | |
| 89 | ||
| 90 | export interface PullRequestDialogState { | |
| 91 | initialReference: string | null; | |
| 92 | key: number; | |
| 93 | } | |
| 94 | ||
| 95 | export function readFileAsDataUrl(file: File): Promise<string> { | |
| 96 | return new Promise((resolve, reject) => { | |
| 97 | const reader = new FileReader(); | |
| 98 | reader.addEventListener("load", () => { | |
| 99 | if (typeof reader.result === "string") { | |
| 100 | resolve(reader.result); | |
| 101 | return; | |
| 102 | } | |
| 103 | reject(new Error("Could not read image data.")); | |
| 104 | }); | |
| 105 | reader.addEventListener("error", () => { | |
| 106 | reject(reader.error ?? new Error("Failed to read image.")); | |
| 107 | }); | |
| 108 | reader.readAsDataURL(file); | |
| 109 | }); | |
| 110 | } | |
| 111 | ||
| 112 | export function buildTemporaryWorktreeBranchName(): string { | |
| 113 | // Keep the 8-hex suffix shape for backend temporary-branch detection. | |
| 114 | const token = randomUUID().slice(0, 8).toLowerCase(); | |
| 115 | return `${WORKTREE_BRANCH_PREFIX}/${token}`; | |
| 116 | } | |
| 117 | ||
| 118 | export function cloneComposerImageForRetry( | |
| 119 | image: ComposerImageAttachment, | |
| 120 | ): ComposerImageAttachment { | |
| 121 | if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) { | |
| 122 | return image; | |
| 123 | } | |
| 124 | try { | |
| 125 | return { | |
| 126 | ...image, | |
| 127 | previewUrl: URL.createObjectURL(image.file), | |
| 128 | }; | |
| 129 | } catch { | |
| 130 | return image; | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 134 | export function getCustomModelOptionsByProvider(settings: { | |
| 135 | customCodexModels: readonly string[]; | |
| 136 | }): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> { | |
| 137 | return { | |
| 138 | codex: getAppModelOptions("codex", settings.customCodexModels), | |
| 139 | }; | |
| 140 | } |
apps/web/src/components/ChatView.tsx+47-2336| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | import { |
| 2 | 2 | type ApprovalRequestId, |
| 3 | 3 | DEFAULT_MODEL_BY_PROVIDER, |
| 4 | EDITORS, | |
| 5 | 4 | type EditorId, |
| 6 | 5 | type KeybindingCommand, |
| 7 | 6 | type CodexReasoningEffort, |
| ... | ... | @@ -29,34 +28,17 @@ import { |
| 29 | 28 | normalizeModelSlug, |
| 30 | 29 | resolveModelSlugForProvider, |
| 31 | 30 | } from "@t3tools/shared/model"; |
| 32 | import { | |
| 33 | memo, | |
| 34 | useCallback, | |
| 35 | useEffect, | |
| 36 | useLayoutEffect, | |
| 37 | useMemo, | |
| 38 | useRef, | |
| 39 | useState, | |
| 40 | useId, | |
| 41 | } from "react"; | |
| 31 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 42 | 32 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 43 | 33 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 44 | 34 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 45 | import { | |
| 46 | measureElement as measureVirtualElement, | |
| 47 | type VirtualItem, | |
| 48 | useVirtualizer, | |
| 49 | } from "@tanstack/react-virtual"; | |
| 50 | 35 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; |
| 51 | 36 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 52 | 37 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; |
| 53 | ||
| 54 | 38 | import { isElectron } from "../env"; |
| 55 | 39 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 56 | 40 | import { |
| 57 | type ComposerSlashCommand, | |
| 58 | 41 | type ComposerTrigger, |
| 59 | type ComposerTriggerKind, | |
| 60 | 42 | detectComposerTrigger, |
| 61 | 43 | expandCollapsedComposerCursor, |
| 62 | 44 | parseStandaloneComposerSlashCommand, |
| ... | ... | @@ -70,17 +52,12 @@ import { |
| 70 | 52 | deriveActiveWorkStartedAt, |
| 71 | 53 | deriveActivePlanState, |
| 72 | 54 | findLatestProposedPlan, |
| 73 | type PendingApproval, | |
| 74 | type PendingUserInput, | |
| 75 | type ProviderPickerKind, | |
| 76 | PROVIDER_OPTIONS, | |
| 77 | 55 | deriveWorkLogEntries, |
| 78 | 56 | hasToolActivityForTurn, |
| 79 | 57 | isLatestTurnSettled, |
| 80 | 58 | formatElapsed, |
| 81 | formatTimestamp, | |
| 82 | 59 | } from "../session-logic"; |
| 83 | import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll"; | |
| 60 | import { isScrollContainerNearBottom } from "../chat-scroll"; | |
| 84 | 61 | import { |
| 85 | 62 | buildPendingUserInputAnswers, |
| 86 | 63 | derivePendingUserInputProgress, |
| ... | ... | @@ -89,15 +66,10 @@ import { |
| 89 | 66 | } from "../pendingUserInput"; |
| 90 | 67 | import { useStore } from "../store"; |
| 91 | 68 | import { |
| 92 | buildCollapsedProposedPlanPreviewMarkdown, | |
| 93 | 69 | buildPlanImplementationThreadTitle, |
| 94 | 70 | buildPlanImplementationPrompt, |
| 95 | buildProposedPlanMarkdownFilename, | |
| 96 | downloadPlanAsTextFile, | |
| 97 | normalizePlanMarkdownForExport, | |
| 98 | 71 | proposedPlanTitle, |
| 99 | 72 | resolvePlanFollowUpSubmission, |
| 100 | stripDisplayedPlanMarkdown, | |
| 101 | 73 | } from "../proposedPlan"; |
| 102 | 74 | import { truncateTitle } from "../truncateTitle"; |
| 103 | 75 | import { |
| ... | ... | @@ -106,92 +78,34 @@ import { |
| 106 | 78 | DEFAULT_THREAD_TERMINAL_ID, |
| 107 | 79 | MAX_THREAD_TERMINAL_COUNT, |
| 108 | 80 | type ChatMessage, |
| 109 | type Thread, | |
| 110 | type TurnDiffFileChange, | |
| 111 | 81 | type TurnDiffSummary, |
| 112 | 82 | } from "../types"; |
| 113 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; | |
| 83 | import { basenameOfPath } from "../vscode-icons"; | |
| 114 | 84 | import { useTheme } from "../hooks/useTheme"; |
| 115 | 85 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 116 | import { | |
| 117 | buildTurnDiffTree, | |
| 118 | summarizeTurnDiffStats, | |
| 119 | type TurnDiffTreeNode, | |
| 120 | } from "../lib/turnDiffTree"; | |
| 121 | 86 | import BranchToolbar from "./BranchToolbar"; |
| 122 | import GitActionsControl from "./GitActionsControl"; | |
| 123 | import { | |
| 124 | isOpenFavoriteEditorShortcut, | |
| 125 | resolveShortcutCommand, | |
| 126 | shortcutLabelForCommand, | |
| 127 | } from "../keybindings"; | |
| 128 | import ChatMarkdown from "./ChatMarkdown"; | |
| 87 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | |
| 129 | 88 | import PlanSidebar from "./PlanSidebar"; |
| 130 | 89 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 131 | import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; | |
| 132 | 90 | import { |
| 133 | 91 | BotIcon, |
| 134 | 92 | ChevronDownIcon, |
| 135 | 93 | ChevronLeftIcon, |
| 136 | 94 | ChevronRightIcon, |
| 137 | 95 | CircleAlertIcon, |
| 138 | FileIcon, | |
| 139 | FolderIcon, | |
| 140 | DiffIcon, | |
| 141 | EllipsisIcon, | |
| 142 | FolderClosedIcon, | |
| 143 | 96 | ListTodoIcon, |
| 144 | 97 | LockIcon, |
| 145 | 98 | LockOpenIcon, |
| 146 | Undo2Icon, | |
| 147 | 99 | XIcon, |
| 148 | CopyIcon, | |
| 149 | CheckIcon, | |
| 150 | 100 | } from "lucide-react"; |
| 151 | 101 | import { Button } from "./ui/button"; |
| 152 | import { Input } from "./ui/input"; | |
| 153 | 102 | import { Separator } from "./ui/separator"; |
| 154 | import { Group, GroupSeparator } from "./ui/group"; | |
| 155 | import { | |
| 156 | Menu, | |
| 157 | MenuGroup, | |
| 158 | MenuItem, | |
| 159 | MenuPopup, | |
| 160 | MenuRadioGroup, | |
| 161 | MenuRadioItem, | |
| 162 | MenuSeparator as MenuDivider, | |
| 163 | MenuSub, | |
| 164 | MenuSubPopup, | |
| 165 | MenuSubTrigger, | |
| 166 | MenuShortcut, | |
| 167 | MenuTrigger, | |
| 168 | } from "./ui/menu"; | |
| 169 | import { | |
| 170 | ClaudeAI, | |
| 171 | CursorIcon, | |
| 172 | Gemini, | |
| 173 | Icon, | |
| 174 | OpenAI, | |
| 175 | OpenCodeIcon, | |
| 176 | VisualStudioCode, | |
| 177 | Zed, | |
| 178 | } from "./Icons"; | |
| 179 | import { cn, isMacPlatform, isWindowsPlatform, randomUUID } from "~/lib/utils"; | |
| 180 | import { Badge } from "./ui/badge"; | |
| 103 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; | |
| 104 | import { cn, randomUUID } from "~/lib/utils"; | |
| 181 | 105 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 182 | import { Command, CommandItem, CommandList } from "./ui/command"; | |
| 183 | import { | |
| 184 | Dialog, | |
| 185 | DialogDescription, | |
| 186 | DialogFooter, | |
| 187 | DialogHeader, | |
| 188 | DialogPanel, | |
| 189 | DialogPopup, | |
| 190 | DialogTitle, | |
| 191 | } from "./ui/dialog"; | |
| 192 | 106 | import { toastManager } from "./ui/toast"; |
| 193 | 107 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| 194 | import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; | |
| 108 | import { type NewProjectScriptInput } from "./ProjectScriptsControl"; | |
| 195 | 109 | import { |
| 196 | 110 | commandForProjectScript, |
| 197 | 111 | nextProjectScriptId, |
| ... | ... | @@ -199,58 +113,49 @@ import { |
| 199 | 113 | projectScriptIdFromCommand, |
| 200 | 114 | setupProjectScript, |
| 201 | 115 | } from "~/projectScripts"; |
| 202 | import { Toggle } from "./ui/toggle"; | |
| 203 | 116 | import { SidebarTrigger } from "./ui/sidebar"; |
| 204 | 117 | import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; |
| 205 | 118 | import { readNativeApi } from "~/nativeApi"; |
| 206 | import { getAppModelOptions, resolveAppModelSelection, useAppSettings } from "../appSettings"; | |
| 119 | import { resolveAppModelSelection, useAppSettings } from "../appSettings"; | |
| 207 | 120 | import { |
| 208 | 121 | type ComposerImageAttachment, |
| 209 | 122 | type DraftThreadEnvMode, |
| 210 | type DraftThreadState, | |
| 211 | 123 | type PersistedComposerImageAttachment, |
| 212 | 124 | useComposerDraftStore, |
| 213 | 125 | useComposerThreadDraft, |
| 214 | 126 | } from "../composerDraftStore"; |
| 215 | 127 | import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; |
| 216 | 128 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 217 | import { clamp } from "effect/Number"; | |
| 218 | 129 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; |
| 219 | 130 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| 220 | import { estimateTimelineMessageHeight } from "./timelineHeight"; | |
| 221 | ||
| 222 | function formatMessageMeta(createdAt: string, duration: string | null): string { | |
| 223 | if (!duration) return formatTimestamp(createdAt); | |
| 224 | return `${formatTimestamp(createdAt)} • ${duration}`; | |
| 225 | } | |
| 226 | ||
| 227 | function formatWorkingTimer(startIso: string, endIso: string): string | null { | |
| 228 | const startedAtMs = Date.parse(startIso); | |
| 229 | const endedAtMs = Date.parse(endIso); | |
| 230 | if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) { | |
| 231 | return null; | |
| 232 | } | |
| 233 | ||
| 234 | const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000)); | |
| 235 | if (elapsedSeconds < 60) { | |
| 236 | return `${elapsedSeconds}s`; | |
| 237 | } | |
| 238 | ||
| 239 | const hours = Math.floor(elapsedSeconds / 3600); | |
| 240 | const minutes = Math.floor((elapsedSeconds % 3600) / 60); | |
| 241 | const seconds = elapsedSeconds % 60; | |
| 242 | ||
| 243 | if (hours > 0) { | |
| 244 | return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; | |
| 245 | } | |
| 246 | ||
| 247 | return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; | |
| 248 | } | |
| 131 | import { MessagesTimeline } from "./chat/MessagesTimeline"; | |
| 132 | import { ChatHeader } from "./chat/ChatHeader"; | |
| 133 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | |
| 134 | import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; | |
| 135 | import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; | |
| 136 | import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; | |
| 137 | import { CodexTraitsPicker } from "./chat/CodexTraitsPicker"; | |
| 138 | import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; | |
| 139 | import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; | |
| 140 | import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; | |
| 141 | import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; | |
| 142 | import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; | |
| 143 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; | |
| 144 | import { | |
| 145 | buildLocalDraftThread, | |
| 146 | buildTemporaryWorktreeBranchName, | |
| 147 | cloneComposerImageForRetry, | |
| 148 | collectUserMessageBlobPreviewUrls, | |
| 149 | getCustomModelOptionsByProvider, | |
| 150 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, | |
| 151 | PullRequestDialogState, | |
| 152 | readFileAsDataUrl, | |
| 153 | readLastInvokedScriptByProjectFromStorage, | |
| 154 | revokeBlobPreviewUrl, | |
| 155 | revokeUserMessagePreviewUrls, | |
| 156 | SendPhase, | |
| 157 | } from "./ChatView.logic"; | |
| 249 | 158 | |
| 250 | const LAST_EDITOR_KEY = "t3code:last-editor"; | |
| 251 | const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; | |
| 252 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | |
| 253 | const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; | |
| 254 | 159 | const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; |
| 255 | 160 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; |
| 256 | 161 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| ... | ... | @@ -264,311 +169,6 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnsw |
| 264 | 169 | const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; |
| 265 | 170 | const SCRIPT_TERMINAL_COLS = 120; |
| 266 | 171 | const SCRIPT_TERMINAL_ROWS = 30; |
| 267 | const WORKTREE_BRANCH_PREFIX = "t3code"; | |
| 268 | ||
| 269 | function readLastInvokedScriptByProjectFromStorage(): Record<string, string> { | |
| 270 | const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); | |
| 271 | if (!stored) return {}; | |
| 272 | ||
| 273 | try { | |
| 274 | const parsed: unknown = JSON.parse(stored); | |
| 275 | if (!parsed || typeof parsed !== "object") return {}; | |
| 276 | return Object.fromEntries( | |
| 277 | Object.entries(parsed).filter( | |
| 278 | (entry): entry is [string, string] => | |
| 279 | typeof entry[0] === "string" && typeof entry[1] === "string", | |
| 280 | ), | |
| 281 | ); | |
| 282 | } catch { | |
| 283 | return {}; | |
| 284 | } | |
| 285 | } | |
| 286 | ||
| 287 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | |
| 288 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; | |
| 289 | if (tone === "tool") return "text-muted-foreground/70"; | |
| 290 | if (tone === "thinking") return "text-muted-foreground/50"; | |
| 291 | return "text-muted-foreground/40"; | |
| 292 | } | |
| 293 | ||
| 294 | interface ExpandedImageItem { | |
| 295 | src: string; | |
| 296 | name: string; | |
| 297 | } | |
| 298 | ||
| 299 | interface ExpandedImagePreview { | |
| 300 | images: ExpandedImageItem[]; | |
| 301 | index: number; | |
| 302 | } | |
| 303 | ||
| 304 | function buildExpandedImagePreview( | |
| 305 | images: ReadonlyArray<{ id: string; name: string; previewUrl?: string }>, | |
| 306 | selectedImageId: string, | |
| 307 | ): ExpandedImagePreview | null { | |
| 308 | const previewableImages = images.flatMap((image) => | |
| 309 | image.previewUrl ? [{ id: image.id, src: image.previewUrl, name: image.name }] : [], | |
| 310 | ); | |
| 311 | if (previewableImages.length === 0) { | |
| 312 | return null; | |
| 313 | } | |
| 314 | const selectedIndex = previewableImages.findIndex((image) => image.id === selectedImageId); | |
| 315 | if (selectedIndex < 0) { | |
| 316 | return null; | |
| 317 | } | |
| 318 | return { | |
| 319 | images: previewableImages.map((image) => ({ src: image.src, name: image.name })), | |
| 320 | index: selectedIndex, | |
| 321 | }; | |
| 322 | } | |
| 323 | ||
| 324 | function buildLocalDraftThread( | |
| 325 | threadId: ThreadId, | |
| 326 | draftThread: DraftThreadState, | |
| 327 | fallbackModel: string, | |
| 328 | error: string | null, | |
| 329 | ): Thread { | |
| 330 | return { | |
| 331 | id: threadId, | |
| 332 | codexThreadId: null, | |
| 333 | projectId: draftThread.projectId, | |
| 334 | title: "New thread", | |
| 335 | model: fallbackModel, | |
| 336 | runtimeMode: draftThread.runtimeMode, | |
| 337 | interactionMode: draftThread.interactionMode, | |
| 338 | session: null, | |
| 339 | messages: [], | |
| 340 | error, | |
| 341 | createdAt: draftThread.createdAt, | |
| 342 | latestTurn: null, | |
| 343 | lastVisitedAt: draftThread.createdAt, | |
| 344 | branch: draftThread.branch, | |
| 345 | worktreePath: draftThread.worktreePath, | |
| 346 | turnDiffSummaries: [], | |
| 347 | activities: [], | |
| 348 | proposedPlans: [], | |
| 349 | }; | |
| 350 | } | |
| 351 | ||
| 352 | function revokeBlobPreviewUrl(previewUrl: string | undefined): void { | |
| 353 | if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { | |
| 354 | return; | |
| 355 | } | |
| 356 | URL.revokeObjectURL(previewUrl); | |
| 357 | } | |
| 358 | ||
| 359 | function revokeUserMessagePreviewUrls(message: ChatMessage): void { | |
| 360 | if (message.role !== "user" || !message.attachments) { | |
| 361 | return; | |
| 362 | } | |
| 363 | for (const attachment of message.attachments) { | |
| 364 | if (attachment.type !== "image") { | |
| 365 | continue; | |
| 366 | } | |
| 367 | revokeBlobPreviewUrl(attachment.previewUrl); | |
| 368 | } | |
| 369 | } | |
| 370 | ||
| 371 | function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] { | |
| 372 | if (message.role !== "user" || !message.attachments) { | |
| 373 | return []; | |
| 374 | } | |
| 375 | const previewUrls: string[] = []; | |
| 376 | for (const attachment of message.attachments) { | |
| 377 | if (attachment.type !== "image") continue; | |
| 378 | if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue; | |
| 379 | previewUrls.push(attachment.previewUrl); | |
| 380 | } | |
| 381 | return previewUrls; | |
| 382 | } | |
| 383 | ||
| 384 | type ComposerCommandItem = | |
| 385 | | { | |
| 386 | id: string; | |
| 387 | type: "path"; | |
| 388 | path: string; | |
| 389 | pathKind: ProjectEntry["kind"]; | |
| 390 | label: string; | |
| 391 | description: string; | |
| 392 | } | |
| 393 | | { | |
| 394 | id: string; | |
| 395 | type: "slash-command"; | |
| 396 | command: ComposerSlashCommand; | |
| 397 | label: string; | |
| 398 | description: string; | |
| 399 | } | |
| 400 | | { | |
| 401 | id: string; | |
| 402 | type: "model"; | |
| 403 | provider: ProviderKind; | |
| 404 | model: ModelSlug; | |
| 405 | label: string; | |
| 406 | description: string; | |
| 407 | }; | |
| 408 | ||
| 409 | type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; | |
| 410 | ||
| 411 | interface PullRequestDialogState { | |
| 412 | initialReference: string | null; | |
| 413 | key: number; | |
| 414 | } | |
| 415 | ||
| 416 | function readFileAsDataUrl(file: File): Promise<string> { | |
| 417 | return new Promise((resolve, reject) => { | |
| 418 | const reader = new FileReader(); | |
| 419 | reader.addEventListener("load", () => { | |
| 420 | if (typeof reader.result === "string") { | |
| 421 | resolve(reader.result); | |
| 422 | return; | |
| 423 | } | |
| 424 | reject(new Error("Could not read image data.")); | |
| 425 | }); | |
| 426 | reader.addEventListener("error", () => { | |
| 427 | reject(reader.error ?? new Error("Failed to read image.")); | |
| 428 | }); | |
| 429 | reader.readAsDataURL(file); | |
| 430 | }); | |
| 431 | } | |
| 432 | ||
| 433 | function buildTemporaryWorktreeBranchName(): string { | |
| 434 | // Keep the 8-hex suffix shape for backend temporary-branch detection. | |
| 435 | const token = randomUUID().slice(0, 8).toLowerCase(); | |
| 436 | return `${WORKTREE_BRANCH_PREFIX}/${token}`; | |
| 437 | } | |
| 438 | ||
| 439 | function cloneComposerImageForRetry(image: ComposerImageAttachment): ComposerImageAttachment { | |
| 440 | if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) { | |
| 441 | return image; | |
| 442 | } | |
| 443 | try { | |
| 444 | return { | |
| 445 | ...image, | |
| 446 | previewUrl: URL.createObjectURL(image.file), | |
| 447 | }; | |
| 448 | } catch { | |
| 449 | return image; | |
| 450 | } | |
| 451 | } | |
| 452 | ||
| 453 | const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { | |
| 454 | pathValue: string; | |
| 455 | kind: "file" | "directory"; | |
| 456 | theme: "light" | "dark"; | |
| 457 | className?: string; | |
| 458 | }) { | |
| 459 | const [failedIconUrl, setFailedIconUrl] = useState<string | null>(null); | |
| 460 | const iconUrl = useMemo( | |
| 461 | () => getVscodeIconUrlForEntry(props.pathValue, props.kind, props.theme), | |
| 462 | [props.kind, props.pathValue, props.theme], | |
| 463 | ); | |
| 464 | const failed = failedIconUrl === iconUrl; | |
| 465 | ||
| 466 | if (failed) { | |
| 467 | return props.kind === "directory" ? ( | |
| 468 | <FolderIcon className={cn("size-4 text-muted-foreground/80", props.className)} /> | |
| 469 | ) : ( | |
| 470 | <FileIcon className={cn("size-4 text-muted-foreground/80", props.className)} /> | |
| 471 | ); | |
| 472 | } | |
| 473 | ||
| 474 | return ( | |
| 475 | <img | |
| 476 | src={iconUrl} | |
| 477 | alt="" | |
| 478 | aria-hidden="true" | |
| 479 | className={cn("size-4 shrink-0", props.className)} | |
| 480 | loading="lazy" | |
| 481 | onError={() => setFailedIconUrl(iconUrl)} | |
| 482 | /> | |
| 483 | ); | |
| 484 | }); | |
| 485 | ||
| 486 | const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { | |
| 487 | item: ComposerCommandItem; | |
| 488 | resolvedTheme: "light" | "dark"; | |
| 489 | isActive: boolean; | |
| 490 | onSelect: (item: ComposerCommandItem) => void; | |
| 491 | }) { | |
| 492 | return ( | |
| 493 | <CommandItem | |
| 494 | value={props.item.id} | |
| 495 | className={cn( | |
| 496 | "cursor-pointer select-none gap-2", | |
| 497 | props.isActive && "bg-accent text-accent-foreground", | |
| 498 | )} | |
| 499 | onMouseDown={(event) => { | |
| 500 | event.preventDefault(); | |
| 501 | }} | |
| 502 | onClick={() => { | |
| 503 | props.onSelect(props.item); | |
| 504 | }} | |
| 505 | > | |
| 506 | {props.item.type === "path" ? ( | |
| 507 | <VscodeEntryIcon | |
| 508 | pathValue={props.item.path} | |
| 509 | kind={props.item.pathKind} | |
| 510 | theme={props.resolvedTheme} | |
| 511 | /> | |
| 512 | ) : null} | |
| 513 | {props.item.type === "slash-command" ? ( | |
| 514 | <BotIcon className="size-4 text-muted-foreground/80" /> | |
| 515 | ) : null} | |
| 516 | {props.item.type === "model" ? ( | |
| 517 | <Badge variant="outline" className="px-1.5 py-0 text-[10px]"> | |
| 518 | model | |
| 519 | </Badge> | |
| 520 | ) : null} | |
| 521 | <span className="flex min-w-0 items-center gap-1.5 truncate"> | |
| 522 | <span className="truncate">{props.item.label}</span> | |
| 523 | </span> | |
| 524 | <span className="truncate text-muted-foreground/70 text-xs">{props.item.description}</span> | |
| 525 | </CommandItem> | |
| 526 | ); | |
| 527 | }); | |
| 528 | ||
| 529 | const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { | |
| 530 | items: ComposerCommandItem[]; | |
| 531 | resolvedTheme: "light" | "dark"; | |
| 532 | isLoading: boolean; | |
| 533 | triggerKind: ComposerTriggerKind | null; | |
| 534 | activeItemId: string | null; | |
| 535 | onHighlightedItemChange: (itemId: string | null) => void; | |
| 536 | onSelect: (item: ComposerCommandItem) => void; | |
| 537 | }) { | |
| 538 | return ( | |
| 539 | <Command | |
| 540 | mode="none" | |
| 541 | onItemHighlighted={(highlightedValue) => { | |
| 542 | props.onHighlightedItemChange( | |
| 543 | typeof highlightedValue === "string" ? highlightedValue : null, | |
| 544 | ); | |
| 545 | }} | |
| 546 | > | |
| 547 | <div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"> | |
| 548 | <CommandList className="max-h-64"> | |
| 549 | {props.items.map((item) => ( | |
| 550 | <ComposerCommandMenuItem | |
| 551 | key={item.id} | |
| 552 | item={item} | |
| 553 | resolvedTheme={props.resolvedTheme} | |
| 554 | isActive={props.activeItemId === item.id} | |
| 555 | onSelect={props.onSelect} | |
| 556 | /> | |
| 557 | ))} | |
| 558 | </CommandList> | |
| 559 | {props.items.length === 0 && ( | |
| 560 | <p className="px-3 py-2 text-muted-foreground/70 text-xs"> | |
| 561 | {props.isLoading | |
| 562 | ? "Searching workspace files..." | |
| 563 | : props.triggerKind === "path" | |
| 564 | ? "No matching files or folders." | |
| 565 | : "No matching command."} | |
| 566 | </p> | |
| 567 | )} | |
| 568 | </div> | |
| 569 | </Command> | |
| 570 | ); | |
| 571 | }); | |
| 572 | 172 | |
| 573 | 173 | interface ChatViewProps { |
| 574 | 174 | threadId: ThreadId; |
| ... | ... | @@ -1493,7 +1093,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1493 | 1093 | .clear({ threadId: activeThreadId, terminalId }) |
| 1494 | 1094 | .catch(() => undefined); |
| 1495 | 1095 | } |
| 1496 | await api.terminal.close({ threadId: activeThreadId, terminalId, deleteHistory: true }); | |
| 1096 | await api.terminal.close({ | |
| 1097 | threadId: activeThreadId, | |
| 1098 | terminalId, | |
| 1099 | deleteHistory: true, | |
| 1100 | }); | |
| 1497 | 1101 | })().catch(() => fallbackExitWrite()); |
| 1498 | 1102 | } else { |
| 1499 | 1103 | void fallbackExitWrite(); |
| ... | ... | @@ -2335,7 +1939,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2335 | 1939 | terminalOpen: Boolean(terminalState.terminalOpen), |
| 2336 | 1940 | }; |
| 2337 | 1941 | |
| 2338 | const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext }); | |
| 1942 | const command = resolveShortcutCommand(event, keybindings, { | |
| 1943 | context: shortcutContext, | |
| 1944 | }); | |
| 2339 | 1945 | if (!command) return; |
| 2340 | 1946 | |
| 2341 | 1947 | if (command === "terminal.toggle") { |
| ... | ... | @@ -3311,7 +2917,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3311 | 2917 | [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt], |
| 3312 | 2918 | ); |
| 3313 | 2919 | |
| 3314 | const readComposerSnapshot = useCallback((): { value: string; cursor: number } => { | |
| 2920 | const readComposerSnapshot = useCallback((): { | |
| 2921 | value: string; | |
| 2922 | cursor: number; | |
| 2923 | } => { | |
| 3315 | 2924 | const editorSnapshot = composerEditorRef.current?.readSnapshot(); |
| 3316 | 2925 | if (editorSnapshot) { |
| 3317 | 2926 | return editorSnapshot; |
| ... | ... | @@ -4252,1901 +3861,3 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 4252 | 3861 | </div> |
| 4253 | 3862 | ); |
| 4254 | 3863 | } |
| 4255 | ||
| 4256 | interface ChatHeaderProps { | |
| 4257 | activeThreadId: ThreadId; | |
| 4258 | activeThreadTitle: string; | |
| 4259 | activeProjectName: string | undefined; | |
| 4260 | isGitRepo: boolean; | |
| 4261 | openInCwd: string | null; | |
| 4262 | activeProjectScripts: ProjectScript[] | undefined; | |
| 4263 | preferredScriptId: string | null; | |
| 4264 | keybindings: ResolvedKeybindingsConfig; | |
| 4265 | availableEditors: ReadonlyArray<EditorId>; | |
| 4266 | diffToggleShortcutLabel: string | null; | |
| 4267 | gitCwd: string | null; | |
| 4268 | diffOpen: boolean; | |
| 4269 | onRunProjectScript: (script: ProjectScript) => void; | |
| 4270 | onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; | |
| 4271 | onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; | |
| 4272 | onDeleteProjectScript: (scriptId: string) => Promise<void>; | |
| 4273 | onToggleDiff: () => void; | |
| 4274 | } | |
| 4275 | ||
| 4276 | const ChatHeader = memo(function ChatHeader({ | |
| 4277 | activeThreadId, | |
| 4278 | activeThreadTitle, | |
| 4279 | activeProjectName, | |
| 4280 | isGitRepo, | |
| 4281 | openInCwd, | |
| 4282 | activeProjectScripts, | |
| 4283 | preferredScriptId, | |
| 4284 | keybindings, | |
| 4285 | availableEditors, | |
| 4286 | diffToggleShortcutLabel, | |
| 4287 | gitCwd, | |
| 4288 | diffOpen, | |
| 4289 | onRunProjectScript, | |
| 4290 | onAddProjectScript, | |
| 4291 | onUpdateProjectScript, | |
| 4292 | onDeleteProjectScript, | |
| 4293 | onToggleDiff, | |
| 4294 | }: ChatHeaderProps) { | |
| 4295 | return ( | |
| 4296 | <div className="flex min-w-0 flex-1 items-center gap-2"> | |
| 4297 | <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3"> | |
| 4298 | <SidebarTrigger className="size-7 shrink-0 md:hidden" /> | |
| 4299 | <h2 | |
| 4300 | className="min-w-0 shrink truncate text-sm font-medium text-foreground" | |
| 4301 | title={activeThreadTitle} | |
| 4302 | > | |
| 4303 | {activeThreadTitle} | |
| 4304 | </h2> | |
| 4305 | {activeProjectName && ( | |
| 4306 | <Badge variant="outline" className="min-w-0 shrink truncate"> | |
| 4307 | {activeProjectName} | |
| 4308 | </Badge> | |
| 4309 | )} | |
| 4310 | {activeProjectName && !isGitRepo && ( | |
| 4311 | <Badge variant="outline" className="shrink-0 text-[10px] text-amber-700"> | |
| 4312 | No Git | |
| 4313 | </Badge> | |
| 4314 | )} | |
| 4315 | </div> | |
| 4316 | <div className="@container/header-actions flex min-w-0 flex-1 items-center justify-end gap-2 @sm/header-actions:gap-3"> | |
| 4317 | {activeProjectScripts && ( | |
| 4318 | <ProjectScriptsControl | |
| 4319 | scripts={activeProjectScripts} | |
| 4320 | keybindings={keybindings} | |
| 4321 | preferredScriptId={preferredScriptId} | |
| 4322 | onRunScript={onRunProjectScript} | |
| 4323 | onAddScript={onAddProjectScript} | |
| 4324 | onUpdateScript={onUpdateProjectScript} | |
| 4325 | onDeleteScript={onDeleteProjectScript} | |
| 4326 | /> | |
| 4327 | )} | |
| 4328 | {activeProjectName && ( | |
| 4329 | <OpenInPicker | |
| 4330 | keybindings={keybindings} | |
| 4331 | availableEditors={availableEditors} | |
| 4332 | openInCwd={openInCwd} | |
| 4333 | /> | |
| 4334 | )} | |
| 4335 | {activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />} | |
| 4336 | <Tooltip> | |
| 4337 | <TooltipTrigger | |
| 4338 | render={ | |
| 4339 | <Toggle | |
| 4340 | className="shrink-0" | |
| 4341 | pressed={diffOpen} | |
| 4342 | onPressedChange={onToggleDiff} | |
| 4343 | aria-label="Toggle diff panel" | |
| 4344 | variant="outline" | |
| 4345 | size="xs" | |
| 4346 | disabled={!isGitRepo} | |
| 4347 | > | |
| 4348 | <DiffIcon className="size-3" /> | |
| 4349 | </Toggle> | |
| 4350 | } | |
| 4351 | /> | |
| 4352 | <TooltipPopup side="bottom"> | |
| 4353 | {!isGitRepo | |
| 4354 | ? "Diff panel is unavailable because this project is not a git repository." | |
| 4355 | : diffToggleShortcutLabel | |
| 4356 | ? `Toggle diff panel (${diffToggleShortcutLabel})` | |
| 4357 | : "Toggle diff panel"} | |
| 4358 | </TooltipPopup> | |
| 4359 | </Tooltip> | |
| 4360 | </div> | |
| 4361 | </div> | |
| 4362 | ); | |
| 4363 | }); | |
| 4364 | ||
| 4365 | const ThreadErrorBanner = memo(function ThreadErrorBanner({ | |
| 4366 | error, | |
| 4367 | onDismiss, | |
| 4368 | }: { | |
| 4369 | error: string | null; | |
| 4370 | onDismiss?: () => void; | |
| 4371 | }) { | |
| 4372 | if (!error) return null; | |
| 4373 | return ( | |
| 4374 | <div className="pt-3 mx-auto max-w-3xl"> | |
| 4375 | <Alert variant="error"> | |
| 4376 | <CircleAlertIcon /> | |
| 4377 | <AlertDescription className="line-clamp-3" title={error}> | |
| 4378 | {error} | |
| 4379 | </AlertDescription> | |
| 4380 | {onDismiss && ( | |
| 4381 | <AlertAction> | |
| 4382 | <button | |
| 4383 | type="button" | |
| 4384 | aria-label="Dismiss error" | |
| 4385 | className="inline-flex size-6 items-center justify-center rounded-md text-destructive/60 transition-colors hover:text-destructive" | |
| 4386 | onClick={onDismiss} | |
| 4387 | > | |
| 4388 | <XIcon className="size-3.5" /> | |
| 4389 | </button> | |
| 4390 | </AlertAction> | |
| 4391 | )} | |
| 4392 | </Alert> | |
| 4393 | </div> | |
| 4394 | ); | |
| 4395 | }); | |
| 4396 | ||
| 4397 | const ProviderHealthBanner = memo(function ProviderHealthBanner({ | |
| 4398 | status, | |
| 4399 | }: { | |
| 4400 | status: ServerProviderStatus | null; | |
| 4401 | }) { | |
| 4402 | if (!status || status.status === "ready") { | |
| 4403 | return null; | |
| 4404 | } | |
| 4405 | ||
| 4406 | const defaultMessage = | |
| 4407 | status.status === "error" | |
| 4408 | ? `${status.provider} provider is unavailable.` | |
| 4409 | : `${status.provider} provider has limited availability.`; | |
| 4410 | ||
| 4411 | return ( | |
| 4412 | <div className="pt-3 mx-auto max-w-3xl"> | |
| 4413 | <Alert variant={status.status === "error" ? "error" : "warning"}> | |
| 4414 | <CircleAlertIcon /> | |
| 4415 | <AlertTitle> | |
| 4416 | {status.provider === "codex" ? "Codex provider status" : `${status.provider} status`} | |
| 4417 | </AlertTitle> | |
| 4418 | <AlertDescription className="line-clamp-3" title={status.message ?? defaultMessage}> | |
| 4419 | {status.message ?? defaultMessage} | |
| 4420 | </AlertDescription> | |
| 4421 | </Alert> | |
| 4422 | </div> | |
| 4423 | ); | |
| 4424 | }); | |
| 4425 | ||
| 4426 | interface ComposerPendingApprovalPanelProps { | |
| 4427 | approval: PendingApproval; | |
| 4428 | pendingCount: number; | |
| 4429 | } | |
| 4430 | ||
| 4431 | const ComposerPendingApprovalPanel = memo(function ComposerPendingApprovalPanel({ | |
| 4432 | approval, | |
| 4433 | pendingCount, | |
| 4434 | }: ComposerPendingApprovalPanelProps) { | |
| 4435 | const approvalSummary = | |
| 4436 | approval.requestKind === "command" | |
| 4437 | ? "Command approval requested" | |
| 4438 | : approval.requestKind === "file-read" | |
| 4439 | ? "File-read approval requested" | |
| 4440 | : "File-change approval requested"; | |
| 4441 | ||
| 4442 | return ( | |
| 4443 | <div className="px-4 py-3.5 sm:px-5 sm:py-4"> | |
| 4444 | <div className="flex flex-wrap items-center gap-2"> | |
| 4445 | <span className="uppercase text-sm tracking-[0.2em]">PENDING APPROVAL</span> | |
| 4446 | <span className="text-sm font-medium">{approvalSummary}</span> | |
| 4447 | {pendingCount > 1 ? ( | |
| 4448 | <span className="text-xs text-muted-foreground">1/{pendingCount}</span> | |
| 4449 | ) : null} | |
| 4450 | </div> | |
| 4451 | </div> | |
| 4452 | ); | |
| 4453 | }); | |
| 4454 | ||
| 4455 | interface ComposerPendingApprovalActionsProps { | |
| 4456 | requestId: ApprovalRequestId; | |
| 4457 | isResponding: boolean; | |
| 4458 | onRespondToApproval: ( | |
| 4459 | requestId: ApprovalRequestId, | |
| 4460 | decision: ProviderApprovalDecision, | |
| 4461 | ) => Promise<void>; | |
| 4462 | } | |
| 4463 | ||
| 4464 | const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({ | |
| 4465 | requestId, | |
| 4466 | isResponding, | |
| 4467 | onRespondToApproval, | |
| 4468 | }: ComposerPendingApprovalActionsProps) { | |
| 4469 | return ( | |
| 4470 | <> | |
| 4471 | <Button | |
| 4472 | size="sm" | |
| 4473 | variant="ghost" | |
| 4474 | disabled={isResponding} | |
| 4475 | onClick={() => void onRespondToApproval(requestId, "cancel")} | |
| 4476 | > | |
| 4477 | Cancel turn | |
| 4478 | </Button> | |
| 4479 | <Button | |
| 4480 | size="sm" | |
| 4481 | variant="destructive-outline" | |
| 4482 | disabled={isResponding} | |
| 4483 | onClick={() => void onRespondToApproval(requestId, "decline")} | |
| 4484 | > | |
| 4485 | Decline | |
| 4486 | </Button> | |
| 4487 | <Button | |
| 4488 | size="sm" | |
| 4489 | variant="outline" | |
| 4490 | disabled={isResponding} | |
| 4491 | onClick={() => void onRespondToApproval(requestId, "acceptForSession")} | |
| 4492 | > | |
| 4493 | Always allow this session | |
| 4494 | </Button> | |
| 4495 | <Button | |
| 4496 | size="sm" | |
| 4497 | variant="default" | |
| 4498 | disabled={isResponding} | |
| 4499 | onClick={() => void onRespondToApproval(requestId, "accept")} | |
| 4500 | > | |
| 4501 | Approve once | |
| 4502 | </Button> | |
| 4503 | </> | |
| 4504 | ); | |
| 4505 | }); | |
| 4506 | ||
| 4507 | interface PendingUserInputPanelProps { | |
| 4508 | pendingUserInputs: PendingUserInput[]; | |
| 4509 | respondingRequestIds: ApprovalRequestId[]; | |
| 4510 | answers: Record<string, PendingUserInputDraftAnswer>; | |
| 4511 | questionIndex: number; | |
| 4512 | onSelectOption: (questionId: string, optionLabel: string) => void; | |
| 4513 | onAdvance: () => void; | |
| 4514 | } | |
| 4515 | ||
| 4516 | const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ | |
| 4517 | pendingUserInputs, | |
| 4518 | respondingRequestIds, | |
| 4519 | answers, | |
| 4520 | questionIndex, | |
| 4521 | onSelectOption, | |
| 4522 | onAdvance, | |
| 4523 | }: PendingUserInputPanelProps) { | |
| 4524 | if (pendingUserInputs.length === 0) return null; | |
| 4525 | const activePrompt = pendingUserInputs[0]; | |
| 4526 | if (!activePrompt) return null; | |
| 4527 | ||
| 4528 | return ( | |
| 4529 | <ComposerPendingUserInputCard | |
| 4530 | key={activePrompt.requestId} | |
| 4531 | prompt={activePrompt} | |
| 4532 | isResponding={respondingRequestIds.includes(activePrompt.requestId)} | |
| 4533 | answers={answers} | |
| 4534 | questionIndex={questionIndex} | |
| 4535 | onSelectOption={onSelectOption} | |
| 4536 | onAdvance={onAdvance} | |
| 4537 | /> | |
| 4538 | ); | |
| 4539 | }); | |
| 4540 | ||
| 4541 | const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({ | |
| 4542 | prompt, | |
| 4543 | isResponding, | |
| 4544 | answers, | |
| 4545 | questionIndex, | |
| 4546 | onSelectOption, | |
| 4547 | onAdvance, | |
| 4548 | }: { | |
| 4549 | prompt: PendingUserInput; | |
| 4550 | isResponding: boolean; | |
| 4551 | answers: Record<string, PendingUserInputDraftAnswer>; | |
| 4552 | questionIndex: number; | |
| 4553 | onSelectOption: (questionId: string, optionLabel: string) => void; | |
| 4554 | onAdvance: () => void; | |
| 4555 | }) { | |
| 4556 | const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); | |
| 4557 | const activeQuestion = progress.activeQuestion; | |
| 4558 | const autoAdvanceTimerRef = useRef<number | null>(null); | |
| 4559 | ||
| 4560 | // Clear auto-advance timer on unmount | |
| 4561 | useEffect(() => { | |
| 4562 | return () => { | |
| 4563 | if (autoAdvanceTimerRef.current !== null) { | |
| 4564 | window.clearTimeout(autoAdvanceTimerRef.current); | |
| 4565 | } | |
| 4566 | }; | |
| 4567 | }, []); | |
| 4568 | ||
| 4569 | const selectOptionAndAutoAdvance = useCallback( | |
| 4570 | (questionId: string, optionLabel: string) => { | |
| 4571 | onSelectOption(questionId, optionLabel); | |
| 4572 | if (autoAdvanceTimerRef.current !== null) { | |
| 4573 | window.clearTimeout(autoAdvanceTimerRef.current); | |
| 4574 | } | |
| 4575 | autoAdvanceTimerRef.current = window.setTimeout(() => { | |
| 4576 | autoAdvanceTimerRef.current = null; | |
| 4577 | onAdvance(); | |
| 4578 | }, 200); | |
| 4579 | }, | |
| 4580 | [onSelectOption, onAdvance], | |
| 4581 | ); | |
| 4582 | ||
| 4583 | // Keyboard shortcut: number keys 1-9 select corresponding option and auto-advance. | |
| 4584 | // Works even when the Lexical composer (contenteditable) has focus — the composer | |
| 4585 | // doubles as a custom-answer field during user input, and when it's empty the digit | |
| 4586 | // keys should pick options instead of typing into the editor. | |
| 4587 | useEffect(() => { | |
| 4588 | if (!activeQuestion || isResponding) return; | |
| 4589 | const handler = (event: globalThis.KeyboardEvent) => { | |
| 4590 | if (event.metaKey || event.ctrlKey || event.altKey) return; | |
| 4591 | const target = event.target; | |
| 4592 | if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { | |
| 4593 | return; | |
| 4594 | } | |
| 4595 | // If the user has started typing a custom answer in the contenteditable | |
| 4596 | // composer, let digit keys pass through so they can type numbers. | |
| 4597 | if (target instanceof HTMLElement && target.isContentEditable) { | |
| 4598 | const hasCustomText = progress.customAnswer.length > 0; | |
| 4599 | if (hasCustomText) return; | |
| 4600 | } | |
| 4601 | const digit = Number.parseInt(event.key, 10); | |
| 4602 | if (Number.isNaN(digit) || digit < 1 || digit > 9) return; | |
| 4603 | const optionIndex = digit - 1; | |
| 4604 | if (optionIndex >= activeQuestion.options.length) return; | |
| 4605 | const option = activeQuestion.options[optionIndex]; | |
| 4606 | if (!option) return; | |
| 4607 | event.preventDefault(); | |
| 4608 | selectOptionAndAutoAdvance(activeQuestion.id, option.label); | |
| 4609 | }; | |
| 4610 | document.addEventListener("keydown", handler); | |
| 4611 | return () => document.removeEventListener("keydown", handler); | |
| 4612 | }, [activeQuestion, isResponding, selectOptionAndAutoAdvance, progress.customAnswer.length]); | |
| 4613 | ||
| 4614 | if (!activeQuestion) { | |
| 4615 | return null; | |
| 4616 | } | |
| 4617 | ||
| 4618 | return ( | |
| 4619 | <div className="px-4 py-3 sm:px-5"> | |
| 4620 | <div className="flex items-center gap-3"> | |
| 4621 | <div className="flex items-center gap-2"> | |
| 4622 | {prompt.questions.length > 1 ? ( | |
| 4623 | <span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-[10px] font-medium tabular-nums text-muted-foreground/60"> | |
| 4624 | {questionIndex + 1}/{prompt.questions.length} | |
| 4625 | </span> | |
| 4626 | ) : null} | |
| 4627 | <span className="text-[11px] font-semibold tracking-widest text-muted-foreground/50 uppercase"> | |
| 4628 | {activeQuestion.header} | |
| 4629 | </span> | |
| 4630 | </div> | |
| 4631 | </div> | |
| 4632 | <p className="mt-1.5 text-sm text-foreground/90">{activeQuestion.question}</p> | |
| 4633 | <div className="mt-3 space-y-1"> | |
| 4634 | {activeQuestion.options.map((option, index) => { | |
| 4635 | const isSelected = progress.selectedOptionLabel === option.label; | |
| 4636 | const shortcutKey = index < 9 ? index + 1 : null; | |
| 4637 | return ( | |
| 4638 | <button | |
| 4639 | key={`${activeQuestion.id}:${option.label}`} | |
| 4640 | type="button" | |
| 4641 | disabled={isResponding} | |
| 4642 | onClick={() => selectOptionAndAutoAdvance(activeQuestion.id, option.label)} | |
| 4643 | className={cn( | |
| 4644 | "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-all duration-150", | |
| 4645 | isSelected | |
| 4646 | ? "border-blue-500/40 bg-blue-500/8 text-foreground" | |
| 4647 | : "border-transparent bg-muted/20 text-foreground/80 hover:bg-muted/40 hover:border-border/40", | |
| 4648 | isResponding && "opacity-50 cursor-not-allowed", | |
| 4649 | )} | |
| 4650 | > | |
| 4651 | {shortcutKey !== null ? ( | |
| 4652 | <kbd | |
| 4653 | className={cn( | |
| 4654 | "flex size-5 shrink-0 items-center justify-center rounded text-[11px] font-medium tabular-nums transition-colors duration-150", | |
| 4655 | isSelected | |
| 4656 | ? "bg-blue-500/20 text-blue-400" | |
| 4657 | : "bg-muted/40 text-muted-foreground/50 group-hover:bg-muted/60 group-hover:text-muted-foreground/70", | |
| 4658 | )} | |
| 4659 | > | |
| 4660 | {shortcutKey} | |
| 4661 | </kbd> | |
| 4662 | ) : null} | |
| 4663 | <div className="min-w-0 flex-1"> | |
| 4664 | <span className="text-sm font-medium">{option.label}</span> | |
| 4665 | {option.description && option.description !== option.label ? ( | |
| 4666 | <span className="ml-2 text-xs text-muted-foreground/50"> | |
| 4667 | {option.description} | |
| 4668 | </span> | |
| 4669 | ) : null} | |
| 4670 | </div> | |
| 4671 | {isSelected ? <CheckIcon className="size-3.5 shrink-0 text-blue-400" /> : null} | |
| 4672 | </button> | |
| 4673 | ); | |
| 4674 | })} | |
| 4675 | </div> | |
| 4676 | </div> | |
| 4677 | ); | |
| 4678 | }); | |
| 4679 | ||
| 4680 | const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({ | |
| 4681 | planTitle, | |
| 4682 | }: { | |
| 4683 | planTitle: string | null; | |
| 4684 | }) { | |
| 4685 | return ( | |
| 4686 | <div className="px-4 py-3.5 sm:px-5 sm:py-4"> | |
| 4687 | <div className="flex flex-wrap items-center gap-2"> | |
| 4688 | <span className="uppercase text-sm tracking-[0.2em]">Plan ready</span> | |
| 4689 | {planTitle ? ( | |
| 4690 | <span className="min-w-0 flex-1 truncate text-sm font-medium">{planTitle}</span> | |
| 4691 | ) : null} | |
| 4692 | </div> | |
| 4693 | {/* <div className="mt-2 text-xs text-muted-foreground"> | |
| 4694 | Review the plan | |
| 4695 | </div> */} | |
| 4696 | </div> | |
| 4697 | ); | |
| 4698 | }); | |
| 4699 | ||
| 4700 | const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) { | |
| 4701 | const [copied, setCopied] = useState(false); | |
| 4702 | ||
| 4703 | const handleCopy = useCallback(() => { | |
| 4704 | void navigator.clipboard.writeText(text); | |
| 4705 | setCopied(true); | |
| 4706 | setTimeout(() => setCopied(false), 2000); | |
| 4707 | }, [text]); | |
| 4708 | ||
| 4709 | return ( | |
| 4710 | <Button type="button" size="xs" variant="outline" onClick={handleCopy} title="Copy message"> | |
| 4711 | {copied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />} | |
| 4712 | </Button> | |
| 4713 | ); | |
| 4714 | }); | |
| 4715 | ||
| 4716 | function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean { | |
| 4717 | return stat.additions > 0 || stat.deletions > 0; | |
| 4718 | } | |
| 4719 | ||
| 4720 | const DiffStatLabel = memo(function DiffStatLabel(props: { | |
| 4721 | additions: number; | |
| 4722 | deletions: number; | |
| 4723 | showParentheses?: boolean; | |
| 4724 | }) { | |
| 4725 | const { additions, deletions, showParentheses = false } = props; | |
| 4726 | return ( | |
| 4727 | <> | |
| 4728 | {showParentheses && <span className="text-muted-foreground/70">(</span>} | |
| 4729 | <span className="text-success">+{additions}</span> | |
| 4730 | <span className="mx-0.5 text-muted-foreground/70">/</span> | |
| 4731 | <span className="text-destructive">-{deletions}</span> | |
| 4732 | {showParentheses && <span className="text-muted-foreground/70">)</span>} | |
| 4733 | </> | |
| 4734 | ); | |
| 4735 | }); | |
| 4736 | ||
| 4737 | function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): string[] { | |
| 4738 | const paths: string[] = []; | |
| 4739 | for (const node of nodes) { | |
| 4740 | if (node.kind !== "directory") continue; | |
| 4741 | paths.push(node.path); | |
| 4742 | paths.push(...collectDirectoryPaths(node.children)); | |
| 4743 | } | |
| 4744 | return paths; | |
| 4745 | } | |
| 4746 | ||
| 4747 | function buildDirectoryExpansionState( | |
| 4748 | directoryPaths: ReadonlyArray<string>, | |
| 4749 | expanded: boolean, | |
| 4750 | ): Record<string, boolean> { | |
| 4751 | const expandedState: Record<string, boolean> = {}; | |
| 4752 | for (const directoryPath of directoryPaths) { | |
| 4753 | expandedState[directoryPath] = expanded; | |
| 4754 | } | |
| 4755 | return expandedState; | |
| 4756 | } | |
| 4757 | ||
| 4758 | const ChangedFilesTree = memo(function ChangedFilesTree(props: { | |
| 4759 | turnId: TurnId; | |
| 4760 | files: ReadonlyArray<TurnDiffFileChange>; | |
| 4761 | allDirectoriesExpanded: boolean; | |
| 4762 | resolvedTheme: "light" | "dark"; | |
| 4763 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | |
| 4764 | }) { | |
| 4765 | const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props; | |
| 4766 | const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); | |
| 4767 | const directoryPathsKey = useMemo( | |
| 4768 | () => collectDirectoryPaths(treeNodes).join("\u0000"), | |
| 4769 | [treeNodes], | |
| 4770 | ); | |
| 4771 | const allDirectoryExpansionState = useMemo( | |
| 4772 | () => | |
| 4773 | buildDirectoryExpansionState( | |
| 4774 | directoryPathsKey ? directoryPathsKey.split("\u0000") : [], | |
| 4775 | allDirectoriesExpanded, | |
| 4776 | ), | |
| 4777 | [allDirectoriesExpanded, directoryPathsKey], | |
| 4778 | ); | |
| 4779 | const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>(() => | |
| 4780 | buildDirectoryExpansionState(directoryPathsKey ? directoryPathsKey.split("\u0000") : [], true), | |
| 4781 | ); | |
| 4782 | useEffect(() => { | |
| 4783 | setExpandedDirectories(allDirectoryExpansionState); | |
| 4784 | }, [allDirectoryExpansionState]); | |
| 4785 | ||
| 4786 | const toggleDirectory = useCallback((pathValue: string, fallbackExpanded: boolean) => { | |
| 4787 | setExpandedDirectories((current) => ({ | |
| 4788 | ...current, | |
| 4789 | [pathValue]: !(current[pathValue] ?? fallbackExpanded), | |
| 4790 | })); | |
| 4791 | }, []); | |
| 4792 | ||
| 4793 | const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => { | |
| 4794 | const leftPadding = 8 + depth * 14; | |
| 4795 | if (node.kind === "directory") { | |
| 4796 | const isExpanded = expandedDirectories[node.path] ?? depth === 0; | |
| 4797 | return ( | |
| 4798 | <div key={`dir:${node.path}`}> | |
| 4799 | <button | |
| 4800 | type="button" | |
| 4801 | className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80" | |
| 4802 | style={{ paddingLeft: `${leftPadding}px` }} | |
| 4803 | onClick={() => toggleDirectory(node.path, depth === 0)} | |
| 4804 | > | |
| 4805 | <ChevronRightIcon | |
| 4806 | aria-hidden="true" | |
| 4807 | className={cn( | |
| 4808 | "size-3.5 shrink-0 text-muted-foreground/70 transition-transform group-hover:text-foreground/80", | |
| 4809 | isExpanded && "rotate-90", | |
| 4810 | )} | |
| 4811 | /> | |
| 4812 | {isExpanded ? ( | |
| 4813 | <FolderIcon className="size-3.5 shrink-0 text-muted-foreground/75" /> | |
| 4814 | ) : ( | |
| 4815 | <FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" /> | |
| 4816 | )} | |
| 4817 | <span className="truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90"> | |
| 4818 | {node.name} | |
| 4819 | </span> | |
| 4820 | {hasNonZeroStat(node.stat) && ( | |
| 4821 | <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> | |
| 4822 | <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> | |
| 4823 | </span> | |
| 4824 | )} | |
| 4825 | </button> | |
| 4826 | {isExpanded && ( | |
| 4827 | <div className="space-y-0.5"> | |
| 4828 | {node.children.map((childNode) => renderTreeNode(childNode, depth + 1))} | |
| 4829 | </div> | |
| 4830 | )} | |
| 4831 | </div> | |
| 4832 | ); | |
| 4833 | } | |
| 4834 | ||
| 4835 | return ( | |
| 4836 | <button | |
| 4837 | key={`file:${node.path}`} | |
| 4838 | type="button" | |
| 4839 | className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80" | |
| 4840 | style={{ paddingLeft: `${leftPadding}px` }} | |
| 4841 | onClick={() => onOpenTurnDiff(turnId, node.path)} | |
| 4842 | > | |
| 4843 | <span aria-hidden="true" className="size-3.5 shrink-0" /> | |
| 4844 | <VscodeEntryIcon | |
| 4845 | pathValue={node.path} | |
| 4846 | kind="file" | |
| 4847 | theme={resolvedTheme} | |
| 4848 | className="size-3.5 text-muted-foreground/70" | |
| 4849 | /> | |
| 4850 | <span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90"> | |
| 4851 | {node.name} | |
| 4852 | </span> | |
| 4853 | {node.stat && ( | |
| 4854 | <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> | |
| 4855 | <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> | |
| 4856 | </span> | |
| 4857 | )} | |
| 4858 | </button> | |
| 4859 | ); | |
| 4860 | }; | |
| 4861 | ||
| 4862 | return <div className="space-y-0.5">{treeNodes.map((node) => renderTreeNode(node, 0))}</div>; | |
| 4863 | }); | |
| 4864 | ||
| 4865 | const ProposedPlanCard = memo(function ProposedPlanCard({ | |
| 4866 | planMarkdown, | |
| 4867 | cwd, | |
| 4868 | workspaceRoot, | |
| 4869 | }: { | |
| 4870 | planMarkdown: string; | |
| 4871 | cwd: string | undefined; | |
| 4872 | workspaceRoot: string | undefined; | |
| 4873 | }) { | |
| 4874 | const [expanded, setExpanded] = useState(false); | |
| 4875 | const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); | |
| 4876 | const [savePath, setSavePath] = useState(""); | |
| 4877 | const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); | |
| 4878 | const savePathInputId = useId(); | |
| 4879 | const title = proposedPlanTitle(planMarkdown) ?? "Proposed plan"; | |
| 4880 | const lineCount = planMarkdown.split("\n").length; | |
| 4881 | const canCollapse = planMarkdown.length > 900 || lineCount > 20; | |
| 4882 | const displayedPlanMarkdown = stripDisplayedPlanMarkdown(planMarkdown); | |
| 4883 | const collapsedPreview = canCollapse | |
| 4884 | ? buildCollapsedProposedPlanPreviewMarkdown(planMarkdown, { maxLines: 10 }) | |
| 4885 | : null; | |
| 4886 | const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown); | |
| 4887 | const saveContents = normalizePlanMarkdownForExport(planMarkdown); | |
| 4888 | ||
| 4889 | const handleDownload = () => { | |
| 4890 | downloadPlanAsTextFile(downloadFilename, saveContents); | |
| 4891 | }; | |
| 4892 | ||
| 4893 | const openSaveDialog = () => { | |
| 4894 | if (!workspaceRoot) { | |
| 4895 | toastManager.add({ | |
| 4896 | type: "error", | |
| 4897 | title: "Workspace path is unavailable", | |
| 4898 | description: "This thread does not have a workspace path to save into.", | |
| 4899 | }); | |
| 4900 | return; | |
| 4901 | } | |
| 4902 | setSavePath((existing) => (existing.length > 0 ? existing : downloadFilename)); | |
| 4903 | setIsSaveDialogOpen(true); | |
| 4904 | }; | |
| 4905 | ||
| 4906 | const handleSaveToWorkspace = () => { | |
| 4907 | const api = readNativeApi(); | |
| 4908 | const relativePath = savePath.trim(); | |
| 4909 | if (!api || !workspaceRoot) { | |
| 4910 | return; | |
| 4911 | } | |
| 4912 | if (!relativePath) { | |
| 4913 | toastManager.add({ | |
| 4914 | type: "warning", | |
| 4915 | title: "Enter a workspace path", | |
| 4916 | }); | |
| 4917 | return; | |
| 4918 | } | |
| 4919 | ||
| 4920 | setIsSavingToWorkspace(true); | |
| 4921 | void api.projects | |
| 4922 | .writeFile({ | |
| 4923 | cwd: workspaceRoot, | |
| 4924 | relativePath, | |
| 4925 | contents: saveContents, | |
| 4926 | }) | |
| 4927 | .then((result) => { | |
| 4928 | setIsSaveDialogOpen(false); | |
| 4929 | toastManager.add({ | |
| 4930 | type: "success", | |
| 4931 | title: "Plan saved to workspace", | |
| 4932 | description: result.relativePath, | |
| 4933 | }); | |
| 4934 | }) | |
| 4935 | .catch((error) => { | |
| 4936 | toastManager.add({ | |
| 4937 | type: "error", | |
| 4938 | title: "Could not save plan", | |
| 4939 | description: error instanceof Error ? error.message : "An error occurred while saving.", | |
| 4940 | }); | |
| 4941 | }) | |
| 4942 | .then( | |
| 4943 | () => { | |
| 4944 | setIsSavingToWorkspace(false); | |
| 4945 | }, | |
| 4946 | () => { | |
| 4947 | setIsSavingToWorkspace(false); | |
| 4948 | }, | |
| 4949 | ); | |
| 4950 | }; | |
| 4951 | ||
| 4952 | return ( | |
| 4953 | <div className="rounded-[24px] border border-border/80 bg-card/70 p-4 sm:p-5"> | |
| 4954 | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 4955 | <div className="flex min-w-0 items-center gap-2"> | |
| 4956 | <Badge variant="secondary">Plan</Badge> | |
| 4957 | <p className="truncate text-sm font-medium text-foreground">{title}</p> | |
| 4958 | </div> | |
| 4959 | <Menu> | |
| 4960 | <MenuTrigger | |
| 4961 | render={<Button aria-label="Plan actions" size="icon-xs" variant="outline" />} | |
| 4962 | > | |
| 4963 | <EllipsisIcon aria-hidden="true" className="size-4" /> | |
| 4964 | </MenuTrigger> | |
| 4965 | <MenuPopup align="end"> | |
| 4966 | <MenuItem onClick={handleDownload}>Download as markdown</MenuItem> | |
| 4967 | <MenuItem onClick={openSaveDialog} disabled={!workspaceRoot || isSavingToWorkspace}> | |
| 4968 | Save to workspace | |
| 4969 | </MenuItem> | |
| 4970 | </MenuPopup> | |
| 4971 | </Menu> | |
| 4972 | </div> | |
| 4973 | <div className="mt-4"> | |
| 4974 | <div className={cn("relative", canCollapse && !expanded && "max-h-104 overflow-hidden")}> | |
| 4975 | {canCollapse && !expanded ? ( | |
| 4976 | <ChatMarkdown text={collapsedPreview ?? ""} cwd={cwd} isStreaming={false} /> | |
| 4977 | ) : ( | |
| 4978 | <ChatMarkdown text={displayedPlanMarkdown} cwd={cwd} isStreaming={false} /> | |
| 4979 | )} | |
| 4980 | {canCollapse && !expanded ? ( | |
| 4981 | <div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-linear-to-t from-card/95 via-card/80 to-transparent" /> | |
| 4982 | ) : null} | |
| 4983 | </div> | |
| 4984 | {canCollapse ? ( | |
| 4985 | <div className="mt-4 flex justify-center"> | |
| 4986 | <Button | |
| 4987 | size="sm" | |
| 4988 | variant="outline" | |
| 4989 | data-scroll-anchor-ignore | |
| 4990 | onClick={() => setExpanded((value) => !value)} | |
| 4991 | > | |
| 4992 | {expanded ? "Collapse plan" : "Expand plan"} | |
| 4993 | </Button> | |
| 4994 | </div> | |
| 4995 | ) : null} | |
| 4996 | </div> | |
| 4997 | ||
| 4998 | <Dialog | |
| 4999 | open={isSaveDialogOpen} | |
| 5000 | onOpenChange={(open) => { | |
| 5001 | if (!isSavingToWorkspace) { | |
| 5002 | setIsSaveDialogOpen(open); | |
| 5003 | } | |
| 5004 | }} | |
| 5005 | > | |
| 5006 | <DialogPopup className="max-w-xl"> | |
| 5007 | <DialogHeader> | |
| 5008 | <DialogTitle>Save plan to workspace</DialogTitle> | |
| 5009 | <DialogDescription> | |
| 5010 | Enter a path relative to <code>{workspaceRoot ?? "the workspace"}</code>. | |
| 5011 | </DialogDescription> | |
| 5012 | </DialogHeader> | |
| 5013 | <DialogPanel className="space-y-3"> | |
| 5014 | <label htmlFor={savePathInputId} className="grid gap-1.5"> | |
| 5015 | <span className="text-xs font-medium text-foreground">Workspace path</span> | |
| 5016 | <Input | |
| 5017 | id={savePathInputId} | |
| 5018 | value={savePath} | |
| 5019 | onChange={(event) => setSavePath(event.target.value)} | |
| 5020 | placeholder={downloadFilename} | |
| 5021 | spellCheck={false} | |
| 5022 | disabled={isSavingToWorkspace} | |
| 5023 | /> | |
| 5024 | </label> | |
| 5025 | </DialogPanel> | |
| 5026 | <DialogFooter> | |
| 5027 | <Button | |
| 5028 | variant="outline" | |
| 5029 | size="sm" | |
| 5030 | onClick={() => setIsSaveDialogOpen(false)} | |
| 5031 | disabled={isSavingToWorkspace} | |
| 5032 | > | |
| 5033 | Cancel | |
| 5034 | </Button> | |
| 5035 | <Button | |
| 5036 | size="sm" | |
| 5037 | onClick={() => void handleSaveToWorkspace()} | |
| 5038 | disabled={isSavingToWorkspace} | |
| 5039 | > | |
| 5040 | {isSavingToWorkspace ? "Saving..." : "Save"} | |
| 5041 | </Button> | |
| 5042 | </DialogFooter> | |
| 5043 | </DialogPopup> | |
| 5044 | </Dialog> | |
| 5045 | </div> | |
| 5046 | ); | |
| 5047 | }); | |
| 5048 | ||
| 5049 | interface MessagesTimelineProps { | |
| 5050 | hasMessages: boolean; | |
| 5051 | isWorking: boolean; | |
| 5052 | activeTurnInProgress: boolean; | |
| 5053 | activeTurnStartedAt: string | null; | |
| 5054 | scrollContainer: HTMLDivElement | null; | |
| 5055 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; | |
| 5056 | completionDividerBeforeEntryId: string | null; | |
| 5057 | completionSummary: string | null; | |
| 5058 | turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>; | |
| 5059 | nowIso: string; | |
| 5060 | expandedWorkGroups: Record<string, boolean>; | |
| 5061 | onToggleWorkGroup: (groupId: string) => void; | |
| 5062 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | |
| 5063 | revertTurnCountByUserMessageId: Map<MessageId, number>; | |
| 5064 | onRevertUserMessage: (messageId: MessageId) => void; | |
| 5065 | isRevertingCheckpoint: boolean; | |
| 5066 | onImageExpand: (preview: ExpandedImagePreview) => void; | |
| 5067 | markdownCwd: string | undefined; | |
| 5068 | resolvedTheme: "light" | "dark"; | |
| 5069 | workspaceRoot: string | undefined; | |
| 5070 | } | |
| 5071 | ||
| 5072 | type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number]; | |
| 5073 | type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"]; | |
| 5074 | type TimelineProposedPlan = Extract<TimelineEntry, { kind: "proposed-plan" }>["proposedPlan"]; | |
| 5075 | type TimelineWorkEntry = Extract<TimelineEntry, { kind: "work" }>["entry"]; | |
| 5076 | type TimelineRow = | |
| 5077 | | { | |
| 5078 | kind: "work"; | |
| 5079 | id: string; | |
| 5080 | createdAt: string; | |
| 5081 | groupedEntries: TimelineWorkEntry[]; | |
| 5082 | } | |
| 5083 | | { | |
| 5084 | kind: "message"; | |
| 5085 | id: string; | |
| 5086 | createdAt: string; | |
| 5087 | message: TimelineMessage; | |
| 5088 | showCompletionDivider: boolean; | |
| 5089 | } | |
| 5090 | | { | |
| 5091 | kind: "proposed-plan"; | |
| 5092 | id: string; | |
| 5093 | createdAt: string; | |
| 5094 | proposedPlan: TimelineProposedPlan; | |
| 5095 | } | |
| 5096 | | { kind: "working"; id: string; createdAt: string | null }; | |
| 5097 | ||
| 5098 | function estimateTimelineProposedPlanHeight(proposedPlan: TimelineProposedPlan): number { | |
| 5099 | const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72)); | |
| 5100 | return 120 + Math.min(estimatedLines * 22, 880); | |
| 5101 | } | |
| 5102 | ||
| 5103 | const MessagesTimeline = memo(function MessagesTimeline({ | |
| 5104 | hasMessages, | |
| 5105 | isWorking, | |
| 5106 | activeTurnInProgress, | |
| 5107 | activeTurnStartedAt, | |
| 5108 | scrollContainer, | |
| 5109 | timelineEntries, | |
| 5110 | completionDividerBeforeEntryId, | |
| 5111 | completionSummary, | |
| 5112 | turnDiffSummaryByAssistantMessageId, | |
| 5113 | nowIso, | |
| 5114 | expandedWorkGroups, | |
| 5115 | onToggleWorkGroup, | |
| 5116 | onOpenTurnDiff, | |
| 5117 | revertTurnCountByUserMessageId, | |
| 5118 | onRevertUserMessage, | |
| 5119 | isRevertingCheckpoint, | |
| 5120 | onImageExpand, | |
| 5121 | markdownCwd, | |
| 5122 | resolvedTheme, | |
| 5123 | workspaceRoot, | |
| 5124 | }: MessagesTimelineProps) { | |
| 5125 | const timelineRootRef = useRef<HTMLDivElement | null>(null); | |
| 5126 | const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null); | |
| 5127 | ||
| 5128 | useLayoutEffect(() => { | |
| 5129 | const timelineRoot = timelineRootRef.current; | |
| 5130 | if (!timelineRoot) return; | |
| 5131 | ||
| 5132 | const updateWidth = (nextWidth: number) => { | |
| 5133 | setTimelineWidthPx((previousWidth) => { | |
| 5134 | if (previousWidth !== null && Math.abs(previousWidth - nextWidth) < 0.5) { | |
| 5135 | return previousWidth; | |
| 5136 | } | |
| 5137 | return nextWidth; | |
| 5138 | }); | |
| 5139 | }; | |
| 5140 | ||
| 5141 | updateWidth(timelineRoot.getBoundingClientRect().width); | |
| 5142 | ||
| 5143 | if (typeof ResizeObserver === "undefined") return; | |
| 5144 | const observer = new ResizeObserver(() => { | |
| 5145 | updateWidth(timelineRoot.getBoundingClientRect().width); | |
| 5146 | }); | |
| 5147 | observer.observe(timelineRoot); | |
| 5148 | return () => { | |
| 5149 | observer.disconnect(); | |
| 5150 | }; | |
| 5151 | }, [hasMessages, isWorking]); | |
| 5152 | ||
| 5153 | const rows = useMemo<TimelineRow[]>(() => { | |
| 5154 | const nextRows: TimelineRow[] = []; | |
| 5155 | ||
| 5156 | for (let index = 0; index < timelineEntries.length; index += 1) { | |
| 5157 | const timelineEntry = timelineEntries[index]; | |
| 5158 | if (!timelineEntry) { | |
| 5159 | continue; | |
| 5160 | } | |
| 5161 | ||
| 5162 | if (timelineEntry.kind === "work") { | |
| 5163 | const groupedEntries = [timelineEntry.entry]; | |
| 5164 | let cursor = index + 1; | |
| 5165 | while (cursor < timelineEntries.length) { | |
| 5166 | const nextEntry = timelineEntries[cursor]; | |
| 5167 | if (!nextEntry || nextEntry.kind !== "work") break; | |
| 5168 | groupedEntries.push(nextEntry.entry); | |
| 5169 | cursor += 1; | |
| 5170 | } | |
| 5171 | nextRows.push({ | |
| 5172 | kind: "work", | |
| 5173 | id: timelineEntry.id, | |
| 5174 | createdAt: timelineEntry.createdAt, | |
| 5175 | groupedEntries, | |
| 5176 | }); | |
| 5177 | index = cursor - 1; | |
| 5178 | continue; | |
| 5179 | } | |
| 5180 | ||
| 5181 | if (timelineEntry.kind === "proposed-plan") { | |
| 5182 | nextRows.push({ | |
| 5183 | kind: "proposed-plan", | |
| 5184 | id: timelineEntry.id, | |
| 5185 | createdAt: timelineEntry.createdAt, | |
| 5186 | proposedPlan: timelineEntry.proposedPlan, | |
| 5187 | }); | |
| 5188 | continue; | |
| 5189 | } | |
| 5190 | ||
| 5191 | nextRows.push({ | |
| 5192 | kind: "message", | |
| 5193 | id: timelineEntry.id, | |
| 5194 | createdAt: timelineEntry.createdAt, | |
| 5195 | message: timelineEntry.message, | |
| 5196 | showCompletionDivider: | |
| 5197 | timelineEntry.message.role === "assistant" && | |
| 5198 | completionDividerBeforeEntryId === timelineEntry.id, | |
| 5199 | }); | |
| 5200 | } | |
| 5201 | ||
| 5202 | if (isWorking) { | |
| 5203 | nextRows.push({ | |
| 5204 | kind: "working", | |
| 5205 | id: "working-indicator-row", | |
| 5206 | createdAt: activeTurnStartedAt, | |
| 5207 | }); | |
| 5208 | } | |
| 5209 | ||
| 5210 | return nextRows; | |
| 5211 | }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]); | |
| 5212 | ||
| 5213 | const firstUnvirtualizedRowIndex = useMemo(() => { | |
| 5214 | const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); | |
| 5215 | if (!activeTurnInProgress) return firstTailRowIndex; | |
| 5216 | ||
| 5217 | const turnStartedAtMs = | |
| 5218 | typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN; | |
| 5219 | let firstCurrentTurnRowIndex = -1; | |
| 5220 | if (!Number.isNaN(turnStartedAtMs)) { | |
| 5221 | firstCurrentTurnRowIndex = rows.findIndex((row) => { | |
| 5222 | if (row.kind === "working") return true; | |
| 5223 | if (!row.createdAt) return false; | |
| 5224 | const rowCreatedAtMs = Date.parse(row.createdAt); | |
| 5225 | return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs; | |
| 5226 | }); | |
| 5227 | } | |
| 5228 | ||
| 5229 | if (firstCurrentTurnRowIndex < 0) { | |
| 5230 | firstCurrentTurnRowIndex = rows.findIndex( | |
| 5231 | (row) => row.kind === "message" && row.message.streaming, | |
| 5232 | ); | |
| 5233 | } | |
| 5234 | ||
| 5235 | if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex; | |
| 5236 | ||
| 5237 | for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) { | |
| 5238 | const previousRow = rows[index]; | |
| 5239 | if (!previousRow || previousRow.kind !== "message") continue; | |
| 5240 | if (previousRow.message.role === "user") { | |
| 5241 | return Math.min(index, firstTailRowIndex); | |
| 5242 | } | |
| 5243 | if (previousRow.message.role === "assistant" && !previousRow.message.streaming) { | |
| 5244 | break; | |
| 5245 | } | |
| 5246 | } | |
| 5247 | ||
| 5248 | return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex); | |
| 5249 | }, [activeTurnInProgress, activeTurnStartedAt, rows]); | |
| 5250 | ||
| 5251 | const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, { | |
| 5252 | minimum: 0, | |
| 5253 | maximum: rows.length, | |
| 5254 | }); | |
| 5255 | ||
| 5256 | const rowVirtualizer = useVirtualizer({ | |
| 5257 | count: virtualizedRowCount, | |
| 5258 | getScrollElement: () => scrollContainer, | |
| 5259 | // Use stable row ids so virtual measurements do not leak across thread switches. | |
| 5260 | getItemKey: (index: number) => rows[index]?.id ?? index, | |
| 5261 | estimateSize: (index: number) => { | |
| 5262 | const row = rows[index]; | |
| 5263 | if (!row) return 96; | |
| 5264 | if (row.kind === "work") return 112; | |
| 5265 | if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan); | |
| 5266 | if (row.kind === "working") return 40; | |
| 5267 | return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); | |
| 5268 | }, | |
| 5269 | measureElement: measureVirtualElement, | |
| 5270 | useAnimationFrameWithResizeObserver: true, | |
| 5271 | overscan: 8, | |
| 5272 | }); | |
| 5273 | useEffect(() => { | |
| 5274 | if (timelineWidthPx === null) return; | |
| 5275 | rowVirtualizer.measure(); | |
| 5276 | }, [rowVirtualizer, timelineWidthPx]); | |
| 5277 | useEffect(() => { | |
| 5278 | rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { | |
| 5279 | const viewportHeight = instance.scrollRect?.height ?? 0; | |
| 5280 | const scrollOffset = instance.scrollOffset ?? 0; | |
| 5281 | const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); | |
| 5282 | return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; | |
| 5283 | }; | |
| 5284 | return () => { | |
| 5285 | rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; | |
| 5286 | }; | |
| 5287 | }, [rowVirtualizer]); | |
| 5288 | const pendingMeasureFrameRef = useRef<number | null>(null); | |
| 5289 | const onTimelineImageLoad = useCallback(() => { | |
| 5290 | if (pendingMeasureFrameRef.current !== null) return; | |
| 5291 | pendingMeasureFrameRef.current = window.requestAnimationFrame(() => { | |
| 5292 | pendingMeasureFrameRef.current = null; | |
| 5293 | rowVirtualizer.measure(); | |
| 5294 | }); | |
| 5295 | }, [rowVirtualizer]); | |
| 5296 | useEffect(() => { | |
| 5297 | return () => { | |
| 5298 | const frame = pendingMeasureFrameRef.current; | |
| 5299 | if (frame !== null) { | |
| 5300 | window.cancelAnimationFrame(frame); | |
| 5301 | } | |
| 5302 | }; | |
| 5303 | }, []); | |
| 5304 | ||
| 5305 | const virtualRows = rowVirtualizer.getVirtualItems(); | |
| 5306 | const nonVirtualizedRows = rows.slice(virtualizedRowCount); | |
| 5307 | const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState< | |
| 5308 | Record<string, boolean> | |
| 5309 | >({}); | |
| 5310 | const onToggleAllDirectories = useCallback((turnId: TurnId) => { | |
| 5311 | setAllDirectoriesExpandedByTurnId((current) => ({ | |
| 5312 | ...current, | |
| 5313 | [turnId]: !(current[turnId] ?? true), | |
| 5314 | })); | |
| 5315 | }, []); | |
| 5316 | ||
| 5317 | const renderRowContent = (row: TimelineRow) => ( | |
| 5318 | <div | |
| 5319 | className="pb-4" | |
| 5320 | data-timeline-row-kind={row.kind} | |
| 5321 | data-message-id={row.kind === "message" ? row.message.id : undefined} | |
| 5322 | data-message-role={row.kind === "message" ? row.message.role : undefined} | |
| 5323 | > | |
| 5324 | {row.kind === "work" && | |
| 5325 | (() => { | |
| 5326 | const groupId = row.id; | |
| 5327 | const groupedEntries = row.groupedEntries; | |
| 5328 | const isExpanded = expandedWorkGroups[groupId] ?? false; | |
| 5329 | const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | |
| 5330 | const visibleEntries = | |
| 5331 | hasOverflow && !isExpanded | |
| 5332 | ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) | |
| 5333 | : groupedEntries; | |
| 5334 | const hiddenCount = groupedEntries.length - visibleEntries.length; | |
| 5335 | const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); | |
| 5336 | const groupLabel = onlyToolEntries | |
| 5337 | ? groupedEntries.length === 1 | |
| 5338 | ? "Tool call" | |
| 5339 | : `Tool calls (${groupedEntries.length})` | |
| 5340 | : groupedEntries.length === 1 | |
| 5341 | ? "Work event" | |
| 5342 | : `Work log (${groupedEntries.length})`; | |
| 5343 | ||
| 5344 | return ( | |
| 5345 | <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2"> | |
| 5346 | <div className="mb-1.5 flex items-center justify-between gap-3"> | |
| 5347 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | |
| 5348 | {groupLabel} | |
| 5349 | </p> | |
| 5350 | {hasOverflow && ( | |
| 5351 | <button | |
| 5352 | type="button" | |
| 5353 | className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80" | |
| 5354 | onClick={() => onToggleWorkGroup(groupId)} | |
| 5355 | > | |
| 5356 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} | |
| 5357 | </button> | |
| 5358 | )} | |
| 5359 | </div> | |
| 5360 | <div className="space-y-1"> | |
| 5361 | {visibleEntries.map((workEntry) => ( | |
| 5362 | <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5"> | |
| 5363 | <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | |
| 5364 | <div className="min-w-0 flex-1 py-[2px]"> | |
| 5365 | <p className={`text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}> | |
| 5366 | {workEntry.label} | |
| 5367 | </p> | |
| 5368 | {workEntry.command && ( | |
| 5369 | <pre className="mt-1 overflow-x-auto rounded-md border border-border/70 bg-background/80 px-2 py-1 font-mono text-[11px] leading-relaxed text-foreground/80"> | |
| 5370 | {workEntry.command} | |
| 5371 | </pre> | |
| 5372 | )} | |
| 5373 | {workEntry.changedFiles && workEntry.changedFiles.length > 0 && ( | |
| 5374 | <div className="mt-1 flex flex-wrap gap-1"> | |
| 5375 | {workEntry.changedFiles.slice(0, 6).map((filePath) => ( | |
| 5376 | <span | |
| 5377 | key={`${workEntry.id}:${filePath}`} | |
| 5378 | className="rounded-md border border-border/70 bg-background/65 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground/85" | |
| 5379 | title={filePath} | |
| 5380 | > | |
| 5381 | {filePath} | |
| 5382 | </span> | |
| 5383 | ))} | |
| 5384 | {workEntry.changedFiles.length > 6 && ( | |
| 5385 | <span className="px-1 text-[10px] text-muted-foreground/65"> | |
| 5386 | +{workEntry.changedFiles.length - 6} more | |
| 5387 | </span> | |
| 5388 | )} | |
| 5389 | </div> | |
| 5390 | )} | |
| 5391 | {workEntry.detail && | |
| 5392 | (!workEntry.command || workEntry.detail !== workEntry.command) && ( | |
| 5393 | <p | |
| 5394 | className="mt-1 text-[11px] leading-relaxed text-muted-foreground/75" | |
| 5395 | title={workEntry.detail} | |
| 5396 | > | |
| 5397 | {workEntry.detail} | |
| 5398 | </p> | |
| 5399 | )} | |
| 5400 | </div> | |
| 5401 | </div> | |
| 5402 | ))} | |
| 5403 | </div> | |
| 5404 | </div> | |
| 5405 | ); | |
| 5406 | })()} | |
| 5407 | ||
| 5408 | {row.kind === "message" && | |
| 5409 | row.message.role === "user" && | |
| 5410 | (() => { | |
| 5411 | const userImages = row.message.attachments ?? []; | |
| 5412 | const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); | |
| 5413 | return ( | |
| 5414 | <div className="flex justify-end"> | |
| 5415 | <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"> | |
| 5416 | {userImages.length > 0 && ( | |
| 5417 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> | |
| 5418 | {userImages.map( | |
| 5419 | (image: NonNullable<TimelineMessage["attachments"]>[number]) => ( | |
| 5420 | <div | |
| 5421 | key={image.id} | |
| 5422 | className="overflow-hidden rounded-lg border border-border/80 bg-background/70" | |
| 5423 | > | |
| 5424 | {image.previewUrl ? ( | |
| 5425 | <button | |
| 5426 | type="button" | |
| 5427 | className="h-full w-full cursor-zoom-in" | |
| 5428 | aria-label={`Preview ${image.name}`} | |
| 5429 | onClick={() => { | |
| 5430 | const preview = buildExpandedImagePreview(userImages, image.id); | |
| 5431 | if (!preview) return; | |
| 5432 | onImageExpand(preview); | |
| 5433 | }} | |
| 5434 | > | |
| 5435 | <img | |
| 5436 | src={image.previewUrl} | |
| 5437 | alt={image.name} | |
| 5438 | className="h-full max-h-[220px] w-full object-cover" | |
| 5439 | onLoad={onTimelineImageLoad} | |
| 5440 | onError={onTimelineImageLoad} | |
| 5441 | /> | |
| 5442 | </button> | |
| 5443 | ) : ( | |
| 5444 | <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70"> | |
| 5445 | {image.name} | |
| 5446 | </div> | |
| 5447 | )} | |
| 5448 | </div> | |
| 5449 | ), | |
| 5450 | )} | |
| 5451 | </div> | |
| 5452 | )} | |
| 5453 | {row.message.text && ( | |
| 5454 | <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground"> | |
| 5455 | {row.message.text} | |
| 5456 | </pre> | |
| 5457 | )} | |
| 5458 | <div className="mt-1.5 flex items-center justify-end gap-2"> | |
| 5459 | <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100"> | |
| 5460 | {row.message.text && <MessageCopyButton text={row.message.text} />} | |
| 5461 | {canRevertAgentWork && ( | |
| 5462 | <Button | |
| 5463 | type="button" | |
| 5464 | size="xs" | |
| 5465 | variant="outline" | |
| 5466 | disabled={isRevertingCheckpoint || isWorking} | |
| 5467 | onClick={() => onRevertUserMessage(row.message.id)} | |
| 5468 | title="Revert to this message" | |
| 5469 | > | |
| 5470 | <Undo2Icon className="size-3" /> | |
| 5471 | </Button> | |
| 5472 | )} | |
| 5473 | </div> | |
| 5474 | <p className="text-right text-[10px] text-muted-foreground/30"> | |
| 5475 | {formatTimestamp(row.message.createdAt)} | |
| 5476 | </p> | |
| 5477 | </div> | |
| 5478 | </div> | |
| 5479 | </div> | |
| 5480 | ); | |
| 5481 | })()} | |
| 5482 | ||
| 5483 | {row.kind === "message" && | |
| 5484 | row.message.role === "assistant" && | |
| 5485 | (() => { | |
| 5486 | const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); | |
| 5487 | return ( | |
| 5488 | <> | |
| 5489 | {row.showCompletionDivider && ( | |
| 5490 | <div className="my-3 flex items-center gap-3"> | |
| 5491 | <span className="h-px flex-1 bg-border" /> | |
| 5492 | <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80"> | |
| 5493 | {completionSummary ? `Response • ${completionSummary}` : "Response"} | |
| 5494 | </span> | |
| 5495 | <span className="h-px flex-1 bg-border" /> | |
| 5496 | </div> | |
| 5497 | )} | |
| 5498 | <div className="min-w-0 px-1 py-0.5"> | |
| 5499 | <ChatMarkdown | |
| 5500 | text={messageText} | |
| 5501 | cwd={markdownCwd} | |
| 5502 | isStreaming={Boolean(row.message.streaming)} | |
| 5503 | /> | |
| 5504 | {(() => { | |
| 5505 | const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); | |
| 5506 | if (!turnSummary) return null; | |
| 5507 | const checkpointFiles = turnSummary.files; | |
| 5508 | if (checkpointFiles.length === 0) return null; | |
| 5509 | const summaryStat = summarizeTurnDiffStats(checkpointFiles); | |
| 5510 | const changedFileCountLabel = String(checkpointFiles.length); | |
| 5511 | const allDirectoriesExpanded = | |
| 5512 | allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true; | |
| 5513 | return ( | |
| 5514 | <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> | |
| 5515 | <div className="mb-1.5 flex items-center justify-between gap-2"> | |
| 5516 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | |
| 5517 | <span>Changed files ({changedFileCountLabel})</span> | |
| 5518 | {hasNonZeroStat(summaryStat) && ( | |
| 5519 | <> | |
| 5520 | <span className="mx-1">•</span> | |
| 5521 | <DiffStatLabel | |
| 5522 | additions={summaryStat.additions} | |
| 5523 | deletions={summaryStat.deletions} | |
| 5524 | /> | |
| 5525 | </> | |
| 5526 | )} | |
| 5527 | </p> | |
| 5528 | <div className="flex items-center gap-1.5"> | |
| 5529 | <Button | |
| 5530 | type="button" | |
| 5531 | size="xs" | |
| 5532 | variant="outline" | |
| 5533 | onClick={() => onToggleAllDirectories(turnSummary.turnId)} | |
| 5534 | > | |
| 5535 | {allDirectoriesExpanded ? "Collapse all" : "Expand all"} | |
| 5536 | </Button> | |
| 5537 | <Button | |
| 5538 | type="button" | |
| 5539 | size="xs" | |
| 5540 | variant="outline" | |
| 5541 | onClick={() => | |
| 5542 | onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path) | |
| 5543 | } | |
| 5544 | > | |
| 5545 | View diff | |
| 5546 | </Button> | |
| 5547 | </div> | |
| 5548 | </div> | |
| 5549 | <ChangedFilesTree | |
| 5550 | key={`changed-files-tree:${turnSummary.turnId}`} | |
| 5551 | turnId={turnSummary.turnId} | |
| 5552 | files={checkpointFiles} | |
| 5553 | allDirectoriesExpanded={allDirectoriesExpanded} | |
| 5554 | resolvedTheme={resolvedTheme} | |
| 5555 | onOpenTurnDiff={onOpenTurnDiff} | |
| 5556 | /> | |
| 5557 | </div> | |
| 5558 | ); | |
| 5559 | })()} | |
| 5560 | <p className="mt-1.5 text-[10px] text-muted-foreground/30"> | |
| 5561 | {formatMessageMeta( | |
| 5562 | row.message.createdAt, | |
| 5563 | row.message.streaming | |
| 5564 | ? formatElapsed(row.message.createdAt, nowIso) | |
| 5565 | : formatElapsed(row.message.createdAt, row.message.completedAt), | |
| 5566 | )} | |
| 5567 | </p> | |
| 5568 | </div> | |
| 5569 | </> | |
| 5570 | ); | |
| 5571 | })()} | |
| 5572 | ||
| 5573 | {row.kind === "proposed-plan" && ( | |
| 5574 | <div className="min-w-0 px-1 py-0.5"> | |
| 5575 | <ProposedPlanCard | |
| 5576 | planMarkdown={row.proposedPlan.planMarkdown} | |
| 5577 | cwd={markdownCwd} | |
| 5578 | workspaceRoot={workspaceRoot} | |
| 5579 | /> | |
| 5580 | </div> | |
| 5581 | )} | |
| 5582 | ||
| 5583 | {row.kind === "working" && ( | |
| 5584 | <div className="py-0.5 pl-1.5"> | |
| 5585 | <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70"> | |
| 5586 | <span className="inline-flex items-center gap-[3px]"> | |
| 5587 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" /> | |
| 5588 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" /> | |
| 5589 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> | |
| 5590 | </span> | |
| 5591 | <span> | |
| 5592 | {row.createdAt | |
| 5593 | ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}` | |
| 5594 | : "Working..."} | |
| 5595 | </span> | |
| 5596 | </div> | |
| 5597 | </div> | |
| 5598 | )} | |
| 5599 | </div> | |
| 5600 | ); | |
| 5601 | ||
| 5602 | if (!hasMessages && !isWorking) { | |
| 5603 | return ( | |
| 5604 | <div className="flex h-full items-center justify-center"> | |
| 5605 | <p className="text-sm text-muted-foreground/30"> | |
| 5606 | Send a message to start the conversation. | |
| 5607 | </p> | |
| 5608 | </div> | |
| 5609 | ); | |
| 5610 | } | |
| 5611 | ||
| 5612 | return ( | |
| 5613 | <div | |
| 5614 | ref={timelineRootRef} | |
| 5615 | data-timeline-root="true" | |
| 5616 | className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden" | |
| 5617 | > | |
| 5618 | {virtualizedRowCount > 0 && ( | |
| 5619 | <div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}> | |
| 5620 | {virtualRows.map((virtualRow: VirtualItem) => { | |
| 5621 | const row = rows[virtualRow.index]; | |
| 5622 | if (!row) return null; | |
| 5623 | ||
| 5624 | return ( | |
| 5625 | <div | |
| 5626 | key={`virtual-row:${row.id}`} | |
| 5627 | data-index={virtualRow.index} | |
| 5628 | ref={rowVirtualizer.measureElement} | |
| 5629 | className="absolute left-0 top-0 w-full" | |
| 5630 | style={{ transform: `translateY(${virtualRow.start}px)` }} | |
| 5631 | > | |
| 5632 | {renderRowContent(row)} | |
| 5633 | </div> | |
| 5634 | ); | |
| 5635 | })} | |
| 5636 | </div> | |
| 5637 | )} | |
| 5638 | ||
| 5639 | {nonVirtualizedRows.map((row) => ( | |
| 5640 | <div key={`non-virtual-row:${row.id}`}>{renderRowContent(row)}</div> | |
| 5641 | ))} | |
| 5642 | </div> | |
| 5643 | ); | |
| 5644 | }); | |
| 5645 | ||
| 5646 | function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { | |
| 5647 | value: ProviderKind; | |
| 5648 | label: string; | |
| 5649 | available: true; | |
| 5650 | } { | |
| 5651 | return option.available && option.value !== "claudeCode"; | |
| 5652 | } | |
| 5653 | ||
| 5654 | const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); | |
| 5655 | const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); | |
| 5656 | const COMING_SOON_PROVIDER_OPTIONS = [ | |
| 5657 | { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, | |
| 5658 | { id: "gemini", label: "Gemini", icon: Gemini }, | |
| 5659 | ] as const; | |
| 5660 | ||
| 5661 | function getCustomModelOptionsByProvider(settings: { | |
| 5662 | customCodexModels: readonly string[]; | |
| 5663 | }): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> { | |
| 5664 | return { | |
| 5665 | codex: getAppModelOptions("codex", settings.customCodexModels), | |
| 5666 | }; | |
| 5667 | } | |
| 5668 | ||
| 5669 | const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = { | |
| 5670 | codex: OpenAI, | |
| 5671 | claudeCode: ClaudeAI, | |
| 5672 | cursor: CursorIcon, | |
| 5673 | }; | |
| 5674 | ||
| 5675 | function resolveModelForProviderPicker( | |
| 5676 | provider: ProviderKind, | |
| 5677 | value: string, | |
| 5678 | options: ReadonlyArray<{ slug: string; name: string }>, | |
| 5679 | ): ModelSlug | null { | |
| 5680 | const trimmedValue = value.trim(); | |
| 5681 | if (!trimmedValue) { | |
| 5682 | return null; | |
| 5683 | } | |
| 5684 | ||
| 5685 | const direct = options.find((option) => option.slug === trimmedValue); | |
| 5686 | if (direct) { | |
| 5687 | return direct.slug; | |
| 5688 | } | |
| 5689 | ||
| 5690 | const byName = options.find((option) => option.name.toLowerCase() === trimmedValue.toLowerCase()); | |
| 5691 | if (byName) { | |
| 5692 | return byName.slug; | |
| 5693 | } | |
| 5694 | ||
| 5695 | const normalized = normalizeModelSlug(trimmedValue, provider); | |
| 5696 | if (!normalized) { | |
| 5697 | return null; | |
| 5698 | } | |
| 5699 | ||
| 5700 | const resolved = options.find((option) => option.slug === normalized); | |
| 5701 | if (resolved) { | |
| 5702 | return resolved.slug; | |
| 5703 | } | |
| 5704 | ||
| 5705 | return null; | |
| 5706 | } | |
| 5707 | ||
| 5708 | const ProviderModelPicker = memo(function ProviderModelPicker(props: { | |
| 5709 | provider: ProviderKind; | |
| 5710 | model: ModelSlug; | |
| 5711 | lockedProvider: ProviderKind | null; | |
| 5712 | modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>; | |
| 5713 | compact?: boolean; | |
| 5714 | disabled?: boolean; | |
| 5715 | onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; | |
| 5716 | }) { | |
| 5717 | const [isMenuOpen, setIsMenuOpen] = useState(false); | |
| 5718 | const selectedProviderOptions = props.modelOptionsByProvider[props.provider]; | |
| 5719 | const selectedModelLabel = | |
| 5720 | selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; | |
| 5721 | const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; | |
| 5722 | ||
| 5723 | return ( | |
| 5724 | <Menu | |
| 5725 | open={isMenuOpen} | |
| 5726 | onOpenChange={(open) => { | |
| 5727 | if (props.disabled) { | |
| 5728 | setIsMenuOpen(false); | |
| 5729 | return; | |
| 5730 | } | |
| 5731 | setIsMenuOpen(open); | |
| 5732 | }} | |
| 5733 | > | |
| 5734 | <MenuTrigger | |
| 5735 | render={ | |
| 5736 | <Button | |
| 5737 | size="sm" | |
| 5738 | variant="ghost" | |
| 5739 | className={cn( | |
| 5740 | "min-w-0 shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80", | |
| 5741 | props.compact ? "max-w-42" : "sm:px-3", | |
| 5742 | )} | |
| 5743 | disabled={props.disabled} | |
| 5744 | /> | |
| 5745 | } | |
| 5746 | > | |
| 5747 | <span | |
| 5748 | className={cn("flex min-w-0 items-center gap-2", props.compact ? "max-w-36" : undefined)} | |
| 5749 | > | |
| 5750 | <ProviderIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/70" /> | |
| 5751 | <span className="truncate">{selectedModelLabel}</span> | |
| 5752 | <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" /> | |
| 5753 | </span> | |
| 5754 | </MenuTrigger> | |
| 5755 | <MenuPopup align="start"> | |
| 5756 | {AVAILABLE_PROVIDER_OPTIONS.map((option) => { | |
| 5757 | const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; | |
| 5758 | const isDisabledByProviderLock = | |
| 5759 | props.lockedProvider !== null && props.lockedProvider !== option.value; | |
| 5760 | return ( | |
| 5761 | <MenuSub key={option.value}> | |
| 5762 | <MenuSubTrigger disabled={isDisabledByProviderLock}> | |
| 5763 | <OptionIcon | |
| 5764 | aria-hidden="true" | |
| 5765 | className="size-4 shrink-0 text-muted-foreground/85" | |
| 5766 | /> | |
| 5767 | {option.label} | |
| 5768 | </MenuSubTrigger> | |
| 5769 | <MenuSubPopup className="[--available-height:min(24rem,70vh)]"> | |
| 5770 | <MenuGroup> | |
| 5771 | <MenuRadioGroup | |
| 5772 | value={props.provider === option.value ? props.model : ""} | |
| 5773 | onValueChange={(value) => { | |
| 5774 | if (props.disabled) return; | |
| 5775 | if (isDisabledByProviderLock) return; | |
| 5776 | if (!value) return; | |
| 5777 | const resolvedModel = resolveModelForProviderPicker( | |
| 5778 | option.value, | |
| 5779 | value, | |
| 5780 | props.modelOptionsByProvider[option.value], | |
| 5781 | ); | |
| 5782 | if (!resolvedModel) return; | |
| 5783 | props.onProviderModelChange(option.value, resolvedModel); | |
| 5784 | setIsMenuOpen(false); | |
| 5785 | }} | |
| 5786 | > | |
| 5787 | {props.modelOptionsByProvider[option.value].map((modelOption) => ( | |
| 5788 | <MenuRadioItem | |
| 5789 | key={`${option.value}:${modelOption.slug}`} | |
| 5790 | value={modelOption.slug} | |
| 5791 | onClick={() => setIsMenuOpen(false)} | |
| 5792 | > | |
| 5793 | {modelOption.name} | |
| 5794 | </MenuRadioItem> | |
| 5795 | ))} | |
| 5796 | </MenuRadioGroup> | |
| 5797 | </MenuGroup> | |
| 5798 | </MenuSubPopup> | |
| 5799 | </MenuSub> | |
| 5800 | ); | |
| 5801 | })} | |
| 5802 | {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && <MenuDivider />} | |
| 5803 | {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { | |
| 5804 | const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; | |
| 5805 | return ( | |
| 5806 | <MenuItem key={option.value} disabled> | |
| 5807 | <OptionIcon | |
| 5808 | aria-hidden="true" | |
| 5809 | className={cn( | |
| 5810 | "size-4 shrink-0 opacity-80", | |
| 5811 | option.value === "claudeCode" ? "" : "text-muted-foreground/85", | |
| 5812 | )} | |
| 5813 | /> | |
| 5814 | <span>{option.label}</span> | |
| 5815 | <span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]"> | |
| 5816 | Coming soon | |
| 5817 | </span> | |
| 5818 | </MenuItem> | |
| 5819 | ); | |
| 5820 | })} | |
| 5821 | {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && <MenuDivider />} | |
| 5822 | {COMING_SOON_PROVIDER_OPTIONS.map((option) => { | |
| 5823 | const OptionIcon = option.icon; | |
| 5824 | return ( | |
| 5825 | <MenuItem key={option.id} disabled> | |
| 5826 | <OptionIcon aria-hidden="true" className="size-4 shrink-0 opacity-80" /> | |
| 5827 | <span>{option.label}</span> | |
| 5828 | <span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]"> | |
| 5829 | Coming soon | |
| 5830 | </span> | |
| 5831 | </MenuItem> | |
| 5832 | ); | |
| 5833 | })} | |
| 5834 | </MenuPopup> | |
| 5835 | </Menu> | |
| 5836 | ); | |
| 5837 | }); | |
| 5838 | ||
| 5839 | const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { | |
| 5840 | activePlan: boolean; | |
| 5841 | interactionMode: ProviderInteractionMode; | |
| 5842 | planSidebarOpen: boolean; | |
| 5843 | runtimeMode: RuntimeMode; | |
| 5844 | selectedEffort: CodexReasoningEffort | null; | |
| 5845 | selectedProvider: ProviderKind; | |
| 5846 | selectedCodexFastModeEnabled: boolean; | |
| 5847 | reasoningOptions: ReadonlyArray<CodexReasoningEffort>; | |
| 5848 | onEffortSelect: (effort: CodexReasoningEffort) => void; | |
| 5849 | onCodexFastModeChange: (enabled: boolean) => void; | |
| 5850 | onToggleInteractionMode: () => void; | |
| 5851 | onTogglePlanSidebar: () => void; | |
| 5852 | onToggleRuntimeMode: () => void; | |
| 5853 | }) { | |
| 5854 | const defaultReasoningEffort = getDefaultReasoningEffort("codex"); | |
| 5855 | const reasoningLabelByOption: Record<CodexReasoningEffort, string> = { | |
| 5856 | low: "Low", | |
| 5857 | medium: "Medium", | |
| 5858 | high: "High", | |
| 5859 | xhigh: "Extra High", | |
| 5860 | }; | |
| 5861 | ||
| 5862 | return ( | |
| 5863 | <Menu> | |
| 5864 | <MenuTrigger | |
| 5865 | render={ | |
| 5866 | <Button | |
| 5867 | size="sm" | |
| 5868 | variant="ghost" | |
| 5869 | className="shrink-0 px-2 text-muted-foreground/70 hover:text-foreground/80" | |
| 5870 | aria-label="More composer controls" | |
| 5871 | /> | |
| 5872 | } | |
| 5873 | > | |
| 5874 | <EllipsisIcon aria-hidden="true" className="size-4" /> | |
| 5875 | </MenuTrigger> | |
| 5876 | <MenuPopup align="start"> | |
| 5877 | {props.selectedProvider === "codex" && props.selectedEffort != null ? ( | |
| 5878 | <> | |
| 5879 | <MenuGroup> | |
| 5880 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Reasoning</div> | |
| 5881 | <MenuRadioGroup | |
| 5882 | value={props.selectedEffort} | |
| 5883 | onValueChange={(value) => { | |
| 5884 | if (!value) return; | |
| 5885 | const nextEffort = props.reasoningOptions.find((option) => option === value); | |
| 5886 | if (!nextEffort) return; | |
| 5887 | props.onEffortSelect(nextEffort); | |
| 5888 | }} | |
| 5889 | > | |
| 5890 | {props.reasoningOptions.map((effort) => ( | |
| 5891 | <MenuRadioItem key={effort} value={effort}> | |
| 5892 | {reasoningLabelByOption[effort]} | |
| 5893 | {effort === defaultReasoningEffort ? " (default)" : ""} | |
| 5894 | </MenuRadioItem> | |
| 5895 | ))} | |
| 5896 | </MenuRadioGroup> | |
| 5897 | </MenuGroup> | |
| 5898 | <MenuDivider /> | |
| 5899 | <MenuGroup> | |
| 5900 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div> | |
| 5901 | <MenuRadioGroup | |
| 5902 | value={props.selectedCodexFastModeEnabled ? "on" : "off"} | |
| 5903 | onValueChange={(value) => { | |
| 5904 | props.onCodexFastModeChange(value === "on"); | |
| 5905 | }} | |
| 5906 | > | |
| 5907 | <MenuRadioItem value="off">off</MenuRadioItem> | |
| 5908 | <MenuRadioItem value="on">on</MenuRadioItem> | |
| 5909 | </MenuRadioGroup> | |
| 5910 | </MenuGroup> | |
| 5911 | <MenuDivider /> | |
| 5912 | </> | |
| 5913 | ) : null} | |
| 5914 | <MenuGroup> | |
| 5915 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Mode</div> | |
| 5916 | <MenuRadioGroup | |
| 5917 | value={props.interactionMode} | |
| 5918 | onValueChange={(value) => { | |
| 5919 | if (!value || value === props.interactionMode) return; | |
| 5920 | props.onToggleInteractionMode(); | |
| 5921 | }} | |
| 5922 | > | |
| 5923 | <MenuRadioItem value="default">Chat</MenuRadioItem> | |
| 5924 | <MenuRadioItem value="plan">Plan</MenuRadioItem> | |
| 5925 | </MenuRadioGroup> | |
| 5926 | </MenuGroup> | |
| 5927 | <MenuDivider /> | |
| 5928 | <MenuGroup> | |
| 5929 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Access</div> | |
| 5930 | <MenuRadioGroup | |
| 5931 | value={props.runtimeMode} | |
| 5932 | onValueChange={(value) => { | |
| 5933 | if (!value || value === props.runtimeMode) return; | |
| 5934 | props.onToggleRuntimeMode(); | |
| 5935 | }} | |
| 5936 | > | |
| 5937 | <MenuRadioItem value="approval-required">Supervised</MenuRadioItem> | |
| 5938 | <MenuRadioItem value="full-access">Full access</MenuRadioItem> | |
| 5939 | </MenuRadioGroup> | |
| 5940 | </MenuGroup> | |
| 5941 | {props.activePlan ? ( | |
| 5942 | <> | |
| 5943 | <MenuDivider /> | |
| 5944 | <MenuItem onClick={props.onTogglePlanSidebar}> | |
| 5945 | <ListTodoIcon className="size-4 shrink-0" /> | |
| 5946 | {props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"} | |
| 5947 | </MenuItem> | |
| 5948 | </> | |
| 5949 | ) : null} | |
| 5950 | </MenuPopup> | |
| 5951 | </Menu> | |
| 5952 | ); | |
| 5953 | }); | |
| 5954 | ||
| 5955 | const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { | |
| 5956 | effort: CodexReasoningEffort; | |
| 5957 | fastModeEnabled: boolean; | |
| 5958 | options: ReadonlyArray<CodexReasoningEffort>; | |
| 5959 | onEffortChange: (effort: CodexReasoningEffort) => void; | |
| 5960 | onFastModeChange: (enabled: boolean) => void; | |
| 5961 | }) { | |
| 5962 | const [isMenuOpen, setIsMenuOpen] = useState(false); | |
| 5963 | const defaultReasoningEffort = getDefaultReasoningEffort("codex"); | |
| 5964 | const reasoningLabelByOption: Record<CodexReasoningEffort, string> = { | |
| 5965 | low: "Low", | |
| 5966 | medium: "Medium", | |
| 5967 | high: "High", | |
| 5968 | xhigh: "Extra High", | |
| 5969 | }; | |
| 5970 | const triggerLabel = [ | |
| 5971 | reasoningLabelByOption[props.effort], | |
| 5972 | ...(props.fastModeEnabled ? ["Fast"] : []), | |
| 5973 | ] | |
| 5974 | .filter(Boolean) | |
| 5975 | .join(" · "); | |
| 5976 | ||
| 5977 | return ( | |
| 5978 | <Menu | |
| 5979 | open={isMenuOpen} | |
| 5980 | onOpenChange={(open) => { | |
| 5981 | setIsMenuOpen(open); | |
| 5982 | }} | |
| 5983 | > | |
| 5984 | <MenuTrigger | |
| 5985 | render={ | |
| 5986 | <Button | |
| 5987 | size="sm" | |
| 5988 | variant="ghost" | |
| 5989 | className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" | |
| 5990 | /> | |
| 5991 | } | |
| 5992 | > | |
| 5993 | <span>{triggerLabel}</span> | |
| 5994 | <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" /> | |
| 5995 | </MenuTrigger> | |
| 5996 | <MenuPopup align="start"> | |
| 5997 | <MenuGroup> | |
| 5998 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Reasoning</div> | |
| 5999 | <MenuRadioGroup | |
| 6000 | value={props.effort} | |
| 6001 | onValueChange={(value) => { | |
| 6002 | if (!value) return; | |
| 6003 | const nextEffort = props.options.find((option) => option === value); | |
| 6004 | if (!nextEffort) return; | |
| 6005 | props.onEffortChange(nextEffort); | |
| 6006 | }} | |
| 6007 | > | |
| 6008 | {props.options.map((effort) => ( | |
| 6009 | <MenuRadioItem key={effort} value={effort}> | |
| 6010 | {reasoningLabelByOption[effort]} | |
| 6011 | {effort === defaultReasoningEffort ? " (default)" : ""} | |
| 6012 | </MenuRadioItem> | |
| 6013 | ))} | |
| 6014 | </MenuRadioGroup> | |
| 6015 | </MenuGroup> | |
| 6016 | <MenuDivider /> | |
| 6017 | <MenuGroup> | |
| 6018 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div> | |
| 6019 | <MenuRadioGroup | |
| 6020 | value={props.fastModeEnabled ? "on" : "off"} | |
| 6021 | onValueChange={(value) => { | |
| 6022 | props.onFastModeChange(value === "on"); | |
| 6023 | }} | |
| 6024 | > | |
| 6025 | <MenuRadioItem value="off">off</MenuRadioItem> | |
| 6026 | <MenuRadioItem value="on">on</MenuRadioItem> | |
| 6027 | </MenuRadioGroup> | |
| 6028 | </MenuGroup> | |
| 6029 | </MenuPopup> | |
| 6030 | </Menu> | |
| 6031 | ); | |
| 6032 | }); | |
| 6033 | ||
| 6034 | const OpenInPicker = memo(function OpenInPicker({ | |
| 6035 | keybindings, | |
| 6036 | availableEditors, | |
| 6037 | openInCwd, | |
| 6038 | }: { | |
| 6039 | keybindings: ResolvedKeybindingsConfig; | |
| 6040 | availableEditors: ReadonlyArray<EditorId>; | |
| 6041 | openInCwd: string | null; | |
| 6042 | }) { | |
| 6043 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { | |
| 6044 | const stored = localStorage.getItem(LAST_EDITOR_KEY); | |
| 6045 | return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; | |
| 6046 | }); | |
| 6047 | ||
| 6048 | const allOptions = useMemo<Array<{ label: string; Icon: Icon; value: EditorId }>>( | |
| 6049 | () => [ | |
| 6050 | { | |
| 6051 | label: "Cursor", | |
| 6052 | Icon: CursorIcon, | |
| 6053 | value: "cursor", | |
| 6054 | }, | |
| 6055 | { | |
| 6056 | label: "VS Code", | |
| 6057 | Icon: VisualStudioCode, | |
| 6058 | value: "vscode", | |
| 6059 | }, | |
| 6060 | { | |
| 6061 | label: "Zed", | |
| 6062 | Icon: Zed, | |
| 6063 | value: "zed", | |
| 6064 | }, | |
| 6065 | { | |
| 6066 | label: isMacPlatform(navigator.platform) | |
| 6067 | ? "Finder" | |
| 6068 | : isWindowsPlatform(navigator.platform) | |
| 6069 | ? "Explorer" | |
| 6070 | : "Files", | |
| 6071 | Icon: FolderClosedIcon, | |
| 6072 | value: "file-manager", | |
| 6073 | }, | |
| 6074 | ], | |
| 6075 | [], | |
| 6076 | ); | |
| 6077 | const options = useMemo( | |
| 6078 | () => allOptions.filter((option) => availableEditors.includes(option.value)), | |
| 6079 | [allOptions, availableEditors], | |
| 6080 | ); | |
| 6081 | ||
| 6082 | const effectiveEditor = options.some((option) => option.value === lastEditor) | |
| 6083 | ? lastEditor | |
| 6084 | : (options[0]?.value ?? null); | |
| 6085 | const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; | |
| 6086 | ||
| 6087 | const openInEditor = useCallback( | |
| 6088 | (editorId: EditorId | null) => { | |
| 6089 | const api = readNativeApi(); | |
| 6090 | if (!api || !openInCwd) return; | |
| 6091 | const editor = editorId ?? effectiveEditor; | |
| 6092 | if (!editor) return; | |
| 6093 | void api.shell.openInEditor(openInCwd, editor); | |
| 6094 | localStorage.setItem(LAST_EDITOR_KEY, editor); | |
| 6095 | setLastEditor(editor); | |
| 6096 | }, | |
| 6097 | [effectiveEditor, openInCwd, setLastEditor], | |
| 6098 | ); | |
| 6099 | ||
| 6100 | const openFavoriteEditorShortcutLabel = useMemo( | |
| 6101 | () => shortcutLabelForCommand(keybindings, "editor.openFavorite"), | |
| 6102 | [keybindings], | |
| 6103 | ); | |
| 6104 | ||
| 6105 | useEffect(() => { | |
| 6106 | const handler = (e: globalThis.KeyboardEvent) => { | |
| 6107 | const api = readNativeApi(); | |
| 6108 | if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; | |
| 6109 | if (!api || !openInCwd) return; | |
| 6110 | if (!effectiveEditor) return; | |
| 6111 | ||
| 6112 | e.preventDefault(); | |
| 6113 | void api.shell.openInEditor(openInCwd, effectiveEditor); | |
| 6114 | }; | |
| 6115 | window.addEventListener("keydown", handler); | |
| 6116 | return () => window.removeEventListener("keydown", handler); | |
| 6117 | }, [effectiveEditor, keybindings, openInCwd]); | |
| 6118 | ||
| 6119 | return ( | |
| 6120 | <Group aria-label="Subscription actions"> | |
| 6121 | <Button | |
| 6122 | size="xs" | |
| 6123 | variant="outline" | |
| 6124 | disabled={!effectiveEditor || !openInCwd} | |
| 6125 | onClick={() => openInEditor(effectiveEditor)} | |
| 6126 | > | |
| 6127 | {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3.5" />} | |
| 6128 | <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5"> | |
| 6129 | Open | |
| 6130 | </span> | |
| 6131 | </Button> | |
| 6132 | <GroupSeparator className="hidden @sm/header-actions:block" /> | |
| 6133 | <Menu> | |
| 6134 | <MenuTrigger render={<Button aria-label="Copy options" size="icon-xs" variant="outline" />}> | |
| 6135 | <ChevronDownIcon aria-hidden="true" className="size-4" /> | |
| 6136 | </MenuTrigger> | |
| 6137 | <MenuPopup align="end"> | |
| 6138 | {options.length === 0 && <MenuItem disabled>No installed editors found</MenuItem>} | |
| 6139 | {options.map(({ label, Icon, value }) => ( | |
| 6140 | <MenuItem key={value} onClick={() => openInEditor(value)}> | |
| 6141 | <Icon aria-hidden="true" className="text-muted-foreground" /> | |
| 6142 | {label} | |
| 6143 | {value === effectiveEditor && openFavoriteEditorShortcutLabel && ( | |
| 6144 | <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut> | |
| 6145 | )} | |
| 6146 | </MenuItem> | |
| 6147 | ))} | |
| 6148 | </MenuPopup> | |
| 6149 | </Menu> | |
| 6150 | </Group> | |
| 6151 | ); | |
| 6152 | }); |
apps/web/src/components/chat/ChangedFilesTree.tsx created+136| ... | ... | @@ -0,0 +1,136 @@ |
| 1 | import { type TurnId } from "@t3tools/contracts"; | |
| 2 | import { memo, useCallback, useEffect, useMemo, useState } from "react"; | |
| 3 | import { type TurnDiffFileChange } from "../../types"; | |
| 4 | import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; | |
| 5 | import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react"; | |
| 6 | import { cn } from "~/lib/utils"; | |
| 7 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; | |
| 8 | import { VscodeEntryIcon } from "./VscodeEntryIcon"; | |
| 9 | ||
| 10 | export const ChangedFilesTree = memo(function ChangedFilesTree(props: { | |
| 11 | turnId: TurnId; | |
| 12 | files: ReadonlyArray<TurnDiffFileChange>; | |
| 13 | allDirectoriesExpanded: boolean; | |
| 14 | resolvedTheme: "light" | "dark"; | |
| 15 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | |
| 16 | }) { | |
| 17 | const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props; | |
| 18 | const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); | |
| 19 | const directoryPathsKey = useMemo( | |
| 20 | () => collectDirectoryPaths(treeNodes).join("\u0000"), | |
| 21 | [treeNodes], | |
| 22 | ); | |
| 23 | const allDirectoryExpansionState = useMemo( | |
| 24 | () => | |
| 25 | buildDirectoryExpansionState( | |
| 26 | directoryPathsKey ? directoryPathsKey.split("\u0000") : [], | |
| 27 | allDirectoriesExpanded, | |
| 28 | ), | |
| 29 | [allDirectoriesExpanded, directoryPathsKey], | |
| 30 | ); | |
| 31 | const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>(() => | |
| 32 | buildDirectoryExpansionState(directoryPathsKey ? directoryPathsKey.split("\u0000") : [], true), | |
| 33 | ); | |
| 34 | useEffect(() => { | |
| 35 | setExpandedDirectories(allDirectoryExpansionState); | |
| 36 | }, [allDirectoryExpansionState]); | |
| 37 | ||
| 38 | const toggleDirectory = useCallback((pathValue: string, fallbackExpanded: boolean) => { | |
| 39 | setExpandedDirectories((current) => ({ | |
| 40 | ...current, | |
| 41 | [pathValue]: !(current[pathValue] ?? fallbackExpanded), | |
| 42 | })); | |
| 43 | }, []); | |
| 44 | ||
| 45 | const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => { | |
| 46 | const leftPadding = 8 + depth * 14; | |
| 47 | if (node.kind === "directory") { | |
| 48 | const isExpanded = expandedDirectories[node.path] ?? depth === 0; | |
| 49 | return ( | |
| 50 | <div key={`dir:${node.path}`}> | |
| 51 | <button | |
| 52 | type="button" | |
| 53 | className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80" | |
| 54 | style={{ paddingLeft: `${leftPadding}px` }} | |
| 55 | onClick={() => toggleDirectory(node.path, depth === 0)} | |
| 56 | > | |
| 57 | <ChevronRightIcon | |
| 58 | aria-hidden="true" | |
| 59 | className={cn( | |
| 60 | "size-3.5 shrink-0 text-muted-foreground/70 transition-transform group-hover:text-foreground/80", | |
| 61 | isExpanded && "rotate-90", | |
| 62 | )} | |
| 63 | /> | |
| 64 | {isExpanded ? ( | |
| 65 | <FolderIcon className="size-3.5 shrink-0 text-muted-foreground/75" /> | |
| 66 | ) : ( | |
| 67 | <FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" /> | |
| 68 | )} | |
| 69 | <span className="truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90"> | |
| 70 | {node.name} | |
| 71 | </span> | |
| 72 | {hasNonZeroStat(node.stat) && ( | |
| 73 | <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> | |
| 74 | <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> | |
| 75 | </span> | |
| 76 | )} | |
| 77 | </button> | |
| 78 | {isExpanded && ( | |
| 79 | <div className="space-y-0.5"> | |
| 80 | {node.children.map((childNode) => renderTreeNode(childNode, depth + 1))} | |
| 81 | </div> | |
| 82 | )} | |
| 83 | </div> | |
| 84 | ); | |
| 85 | } | |
| 86 | ||
| 87 | return ( | |
| 88 | <button | |
| 89 | key={`file:${node.path}`} | |
| 90 | type="button" | |
| 91 | className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80" | |
| 92 | style={{ paddingLeft: `${leftPadding}px` }} | |
| 93 | onClick={() => onOpenTurnDiff(turnId, node.path)} | |
| 94 | > | |
| 95 | <span aria-hidden="true" className="size-3.5 shrink-0" /> | |
| 96 | <VscodeEntryIcon | |
| 97 | pathValue={node.path} | |
| 98 | kind="file" | |
| 99 | theme={resolvedTheme} | |
| 100 | className="size-3.5 text-muted-foreground/70" | |
| 101 | /> | |
| 102 | <span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90"> | |
| 103 | {node.name} | |
| 104 | </span> | |
| 105 | {node.stat && ( | |
| 106 | <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums"> | |
| 107 | <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} /> | |
| 108 | </span> | |
| 109 | )} | |
| 110 | </button> | |
| 111 | ); | |
| 112 | }; | |
| 113 | ||
| 114 | return <div className="space-y-0.5">{treeNodes.map((node) => renderTreeNode(node, 0))}</div>; | |
| 115 | }); | |
| 116 | ||
| 117 | function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): string[] { | |
| 118 | const paths: string[] = []; | |
| 119 | for (const node of nodes) { | |
| 120 | if (node.kind !== "directory") continue; | |
| 121 | paths.push(node.path); | |
| 122 | paths.push(...collectDirectoryPaths(node.children)); | |
| 123 | } | |
| 124 | return paths; | |
| 125 | } | |
| 126 | ||
| 127 | function buildDirectoryExpansionState( | |
| 128 | directoryPaths: ReadonlyArray<string>, | |
| 129 | expanded: boolean, | |
| 130 | ): Record<string, boolean> { | |
| 131 | const expandedState: Record<string, boolean> = {}; | |
| 132 | for (const directoryPath of directoryPaths) { | |
| 133 | expandedState[directoryPath] = expanded; | |
| 134 | } | |
| 135 | return expandedState; | |
| 136 | } |
apps/web/src/components/chat/ChatHeader.tsx created+124| ... | ... | @@ -0,0 +1,124 @@ |
| 1 | import { | |
| 2 | type EditorId, | |
| 3 | type ProjectScript, | |
| 4 | type ResolvedKeybindingsConfig, | |
| 5 | type ThreadId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { memo } from "react"; | |
| 8 | import GitActionsControl from "../GitActionsControl"; | |
| 9 | import { DiffIcon } from "lucide-react"; | |
| 10 | import { Badge } from "../ui/badge"; | |
| 11 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 12 | import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; | |
| 13 | import { Toggle } from "../ui/toggle"; | |
| 14 | import { SidebarTrigger } from "../ui/sidebar"; | |
| 15 | import { OpenInPicker } from "./OpenInPicker"; | |
| 16 | ||
| 17 | interface ChatHeaderProps { | |
| 18 | activeThreadId: ThreadId; | |
| 19 | activeThreadTitle: string; | |
| 20 | activeProjectName: string | undefined; | |
| 21 | isGitRepo: boolean; | |
| 22 | openInCwd: string | null; | |
| 23 | activeProjectScripts: ProjectScript[] | undefined; | |
| 24 | preferredScriptId: string | null; | |
| 25 | keybindings: ResolvedKeybindingsConfig; | |
| 26 | availableEditors: ReadonlyArray<EditorId>; | |
| 27 | diffToggleShortcutLabel: string | null; | |
| 28 | gitCwd: string | null; | |
| 29 | diffOpen: boolean; | |
| 30 | onRunProjectScript: (script: ProjectScript) => void; | |
| 31 | onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; | |
| 32 | onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; | |
| 33 | onDeleteProjectScript: (scriptId: string) => Promise<void>; | |
| 34 | onToggleDiff: () => void; | |
| 35 | } | |
| 36 | ||
| 37 | export const ChatHeader = memo(function ChatHeader({ | |
| 38 | activeThreadId, | |
| 39 | activeThreadTitle, | |
| 40 | activeProjectName, | |
| 41 | isGitRepo, | |
| 42 | openInCwd, | |
| 43 | activeProjectScripts, | |
| 44 | preferredScriptId, | |
| 45 | keybindings, | |
| 46 | availableEditors, | |
| 47 | diffToggleShortcutLabel, | |
| 48 | gitCwd, | |
| 49 | diffOpen, | |
| 50 | onRunProjectScript, | |
| 51 | onAddProjectScript, | |
| 52 | onUpdateProjectScript, | |
| 53 | onDeleteProjectScript, | |
| 54 | onToggleDiff, | |
| 55 | }: ChatHeaderProps) { | |
| 56 | return ( | |
| 57 | <div className="flex min-w-0 flex-1 items-center gap-2"> | |
| 58 | <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3"> | |
| 59 | <SidebarTrigger className="size-7 shrink-0 md:hidden" /> | |
| 60 | <h2 | |
| 61 | className="min-w-0 shrink truncate text-sm font-medium text-foreground" | |
| 62 | title={activeThreadTitle} | |
| 63 | > | |
| 64 | {activeThreadTitle} | |
| 65 | </h2> | |
| 66 | {activeProjectName && ( | |
| 67 | <Badge variant="outline" className="min-w-0 shrink truncate"> | |
| 68 | {activeProjectName} | |
| 69 | </Badge> | |
| 70 | )} | |
| 71 | {activeProjectName && !isGitRepo && ( | |
| 72 | <Badge variant="outline" className="shrink-0 text-[10px] text-amber-700"> | |
| 73 | No Git | |
| 74 | </Badge> | |
| 75 | )} | |
| 76 | </div> | |
| 77 | <div className="@container/header-actions flex min-w-0 flex-1 items-center justify-end gap-2 @sm/header-actions:gap-3"> | |
| 78 | {activeProjectScripts && ( | |
| 79 | <ProjectScriptsControl | |
| 80 | scripts={activeProjectScripts} | |
| 81 | keybindings={keybindings} | |
| 82 | preferredScriptId={preferredScriptId} | |
| 83 | onRunScript={onRunProjectScript} | |
| 84 | onAddScript={onAddProjectScript} | |
| 85 | onUpdateScript={onUpdateProjectScript} | |
| 86 | onDeleteScript={onDeleteProjectScript} | |
| 87 | /> | |
| 88 | )} | |
| 89 | {activeProjectName && ( | |
| 90 | <OpenInPicker | |
| 91 | keybindings={keybindings} | |
| 92 | availableEditors={availableEditors} | |
| 93 | openInCwd={openInCwd} | |
| 94 | /> | |
| 95 | )} | |
| 96 | {activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />} | |
| 97 | <Tooltip> | |
| 98 | <TooltipTrigger | |
| 99 | render={ | |
| 100 | <Toggle | |
| 101 | className="shrink-0" | |
| 102 | pressed={diffOpen} | |
| 103 | onPressedChange={onToggleDiff} | |
| 104 | aria-label="Toggle diff panel" | |
| 105 | variant="outline" | |
| 106 | size="xs" | |
| 107 | disabled={!isGitRepo} | |
| 108 | > | |
| 109 | <DiffIcon className="size-3" /> | |
| 110 | </Toggle> | |
| 111 | } | |
| 112 | /> | |
| 113 | <TooltipPopup side="bottom"> | |
| 114 | {!isGitRepo | |
| 115 | ? "Diff panel is unavailable because this project is not a git repository." | |
| 116 | : diffToggleShortcutLabel | |
| 117 | ? `Toggle diff panel (${diffToggleShortcutLabel})` | |
| 118 | : "Toggle diff panel"} | |
| 119 | </TooltipPopup> | |
| 120 | </Tooltip> | |
| 121 | </div> | |
| 122 | </div> | |
| 123 | ); | |
| 124 | }); |
apps/web/src/components/chat/CodexTraitsPicker.tsx created+93| ... | ... | @@ -0,0 +1,93 @@ |
| 1 | import { type CodexReasoningEffort } from "@t3tools/contracts"; | |
| 2 | import { getDefaultReasoningEffort } from "@t3tools/shared/model"; | |
| 3 | import { memo, useState } from "react"; | |
| 4 | import { ChevronDownIcon } from "lucide-react"; | |
| 5 | import { Button } from "../ui/button"; | |
| 6 | import { | |
| 7 | Menu, | |
| 8 | MenuGroup, | |
| 9 | MenuPopup, | |
| 10 | MenuRadioGroup, | |
| 11 | MenuRadioItem, | |
| 12 | MenuSeparator as MenuDivider, | |
| 13 | MenuTrigger, | |
| 14 | } from "../ui/menu"; | |
| 15 | ||
| 16 | export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { | |
| 17 | effort: CodexReasoningEffort; | |
| 18 | fastModeEnabled: boolean; | |
| 19 | options: ReadonlyArray<CodexReasoningEffort>; | |
| 20 | onEffortChange: (effort: CodexReasoningEffort) => void; | |
| 21 | onFastModeChange: (enabled: boolean) => void; | |
| 22 | }) { | |
| 23 | const [isMenuOpen, setIsMenuOpen] = useState(false); | |
| 24 | const defaultReasoningEffort = getDefaultReasoningEffort("codex"); | |
| 25 | const reasoningLabelByOption: Record<CodexReasoningEffort, string> = { | |
| 26 | low: "Low", | |
| 27 | medium: "Medium", | |
| 28 | high: "High", | |
| 29 | xhigh: "Extra High", | |
| 30 | }; | |
| 31 | const triggerLabel = [ | |
| 32 | reasoningLabelByOption[props.effort], | |
| 33 | ...(props.fastModeEnabled ? ["Fast"] : []), | |
| 34 | ] | |
| 35 | .filter(Boolean) | |
| 36 | .join(" · "); | |
| 37 | ||
| 38 | return ( | |
| 39 | <Menu | |
| 40 | open={isMenuOpen} | |
| 41 | onOpenChange={(open) => { | |
| 42 | setIsMenuOpen(open); | |
| 43 | }} | |
| 44 | > | |
| 45 | <MenuTrigger | |
| 46 | render={ | |
| 47 | <Button | |
| 48 | size="sm" | |
| 49 | variant="ghost" | |
| 50 | className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" | |
| 51 | /> | |
| 52 | } | |
| 53 | > | |
| 54 | <span>{triggerLabel}</span> | |
| 55 | <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" /> | |
| 56 | </MenuTrigger> | |
| 57 | <MenuPopup align="start"> | |
| 58 | <MenuGroup> | |
| 59 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Reasoning</div> | |
| 60 | <MenuRadioGroup | |
| 61 | value={props.effort} | |
| 62 | onValueChange={(value) => { | |
| 63 | if (!value) return; | |
| 64 | const nextEffort = props.options.find((option) => option === value); | |
| 65 | if (!nextEffort) return; | |
| 66 | props.onEffortChange(nextEffort); | |
| 67 | }} | |
| 68 | > | |
| 69 | {props.options.map((effort) => ( | |
| 70 | <MenuRadioItem key={effort} value={effort}> | |
| 71 | {reasoningLabelByOption[effort]} | |
| 72 | {effort === defaultReasoningEffort ? " (default)" : ""} | |
| 73 | </MenuRadioItem> | |
| 74 | ))} | |
| 75 | </MenuRadioGroup> | |
| 76 | </MenuGroup> | |
| 77 | <MenuDivider /> | |
| 78 | <MenuGroup> | |
| 79 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div> | |
| 80 | <MenuRadioGroup | |
| 81 | value={props.fastModeEnabled ? "on" : "off"} | |
| 82 | onValueChange={(value) => { | |
| 83 | props.onFastModeChange(value === "on"); | |
| 84 | }} | |
| 85 | > | |
| 86 | <MenuRadioItem value="off">off</MenuRadioItem> | |
| 87 | <MenuRadioItem value="on">on</MenuRadioItem> | |
| 88 | </MenuRadioGroup> | |
| 89 | </MenuGroup> | |
| 90 | </MenuPopup> | |
| 91 | </Menu> | |
| 92 | ); | |
| 93 | }); |
apps/web/src/components/chat/CompactComposerControlsMenu.tsx created+136| ... | ... | @@ -0,0 +1,136 @@ |
| 1 | import { | |
| 2 | type CodexReasoningEffort, | |
| 3 | type ProviderKind, | |
| 4 | RuntimeMode, | |
| 5 | ProviderInteractionMode, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { getDefaultReasoningEffort } from "@t3tools/shared/model"; | |
| 8 | import { memo } from "react"; | |
| 9 | import { EllipsisIcon, ListTodoIcon } from "lucide-react"; | |
| 10 | import { Button } from "../ui/button"; | |
| 11 | import { | |
| 12 | Menu, | |
| 13 | MenuGroup, | |
| 14 | MenuItem, | |
| 15 | MenuPopup, | |
| 16 | MenuRadioGroup, | |
| 17 | MenuRadioItem, | |
| 18 | MenuSeparator as MenuDivider, | |
| 19 | MenuTrigger, | |
| 20 | } from "../ui/menu"; | |
| 21 | ||
| 22 | export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { | |
| 23 | activePlan: boolean; | |
| 24 | interactionMode: ProviderInteractionMode; | |
| 25 | planSidebarOpen: boolean; | |
| 26 | runtimeMode: RuntimeMode; | |
| 27 | selectedEffort: CodexReasoningEffort | null; | |
| 28 | selectedProvider: ProviderKind; | |
| 29 | selectedCodexFastModeEnabled: boolean; | |
| 30 | reasoningOptions: ReadonlyArray<CodexReasoningEffort>; | |
| 31 | onEffortSelect: (effort: CodexReasoningEffort) => void; | |
| 32 | onCodexFastModeChange: (enabled: boolean) => void; | |
| 33 | onToggleInteractionMode: () => void; | |
| 34 | onTogglePlanSidebar: () => void; | |
| 35 | onToggleRuntimeMode: () => void; | |
| 36 | }) { | |
| 37 | const defaultReasoningEffort = getDefaultReasoningEffort("codex"); | |
| 38 | const reasoningLabelByOption: Record<CodexReasoningEffort, string> = { | |
| 39 | low: "Low", | |
| 40 | medium: "Medium", | |
| 41 | high: "High", | |
| 42 | xhigh: "Extra High", | |
| 43 | }; | |
| 44 | ||
| 45 | return ( | |
| 46 | <Menu> | |
| 47 | <MenuTrigger | |
| 48 | render={ | |
| 49 | <Button | |
| 50 | size="sm" | |
| 51 | variant="ghost" | |
| 52 | className="shrink-0 px-2 text-muted-foreground/70 hover:text-foreground/80" | |
| 53 | aria-label="More composer controls" | |
| 54 | /> | |
| 55 | } | |
| 56 | > | |
| 57 | <EllipsisIcon aria-hidden="true" className="size-4" /> | |
| 58 | </MenuTrigger> | |
| 59 | <MenuPopup align="start"> | |
| 60 | {props.selectedProvider === "codex" && props.selectedEffort != null ? ( | |
| 61 | <> | |
| 62 | <MenuGroup> | |
| 63 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Reasoning</div> | |
| 64 | <MenuRadioGroup | |
| 65 | value={props.selectedEffort} | |
| 66 | onValueChange={(value) => { | |
| 67 | if (!value) return; | |
| 68 | const nextEffort = props.reasoningOptions.find((option) => option === value); | |
| 69 | if (!nextEffort) return; | |
| 70 | props.onEffortSelect(nextEffort); | |
| 71 | }} | |
| 72 | > | |
| 73 | {props.reasoningOptions.map((effort) => ( | |
| 74 | <MenuRadioItem key={effort} value={effort}> | |
| 75 | {reasoningLabelByOption[effort]} | |
| 76 | {effort === defaultReasoningEffort ? " (default)" : ""} | |
| 77 | </MenuRadioItem> | |
| 78 | ))} | |
| 79 | </MenuRadioGroup> | |
| 80 | </MenuGroup> | |
| 81 | <MenuDivider /> | |
| 82 | <MenuGroup> | |
| 83 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div> | |
| 84 | <MenuRadioGroup | |
| 85 | value={props.selectedCodexFastModeEnabled ? "on" : "off"} | |
| 86 | onValueChange={(value) => { | |
| 87 | props.onCodexFastModeChange(value === "on"); | |
| 88 | }} | |
| 89 | > | |
| 90 | <MenuRadioItem value="off">off</MenuRadioItem> | |
| 91 | <MenuRadioItem value="on">on</MenuRadioItem> | |
| 92 | </MenuRadioGroup> | |
| 93 | </MenuGroup> | |
| 94 | <MenuDivider /> | |
| 95 | </> | |
| 96 | ) : null} | |
| 97 | <MenuGroup> | |
| 98 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Mode</div> | |
| 99 | <MenuRadioGroup | |
| 100 | value={props.interactionMode} | |
| 101 | onValueChange={(value) => { | |
| 102 | if (!value || value === props.interactionMode) return; | |
| 103 | props.onToggleInteractionMode(); | |
| 104 | }} | |
| 105 | > | |
| 106 | <MenuRadioItem value="default">Chat</MenuRadioItem> | |
| 107 | <MenuRadioItem value="plan">Plan</MenuRadioItem> | |
| 108 | </MenuRadioGroup> | |
| 109 | </MenuGroup> | |
| 110 | <MenuDivider /> | |
| 111 | <MenuGroup> | |
| 112 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Access</div> | |
| 113 | <MenuRadioGroup | |
| 114 | value={props.runtimeMode} | |
| 115 | onValueChange={(value) => { | |
| 116 | if (!value || value === props.runtimeMode) return; | |
| 117 | props.onToggleRuntimeMode(); | |
| 118 | }} | |
| 119 | > | |
| 120 | <MenuRadioItem value="approval-required">Supervised</MenuRadioItem> | |
| 121 | <MenuRadioItem value="full-access">Full access</MenuRadioItem> | |
| 122 | </MenuRadioGroup> | |
| 123 | </MenuGroup> | |
| 124 | {props.activePlan ? ( | |
| 125 | <> | |
| 126 | <MenuDivider /> | |
| 127 | <MenuItem onClick={props.onTogglePlanSidebar}> | |
| 128 | <ListTodoIcon className="size-4 shrink-0" /> | |
| 129 | {props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"} | |
| 130 | </MenuItem> | |
| 131 | </> | |
| 132 | ) : null} | |
| 133 | </MenuPopup> | |
| 134 | </Menu> | |
| 135 | ); | |
| 136 | }); |
apps/web/src/components/chat/ComposerCommandMenu.tsx created+120| ... | ... | @@ -0,0 +1,120 @@ |
| 1 | import { type ProjectEntry, type ModelSlug, type ProviderKind } from "@t3tools/contracts"; | |
| 2 | import { memo } from "react"; | |
| 3 | import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; | |
| 4 | import { BotIcon } from "lucide-react"; | |
| 5 | import { cn } from "~/lib/utils"; | |
| 6 | import { Badge } from "../ui/badge"; | |
| 7 | import { Command, CommandItem, CommandList } from "../ui/command"; | |
| 8 | import { VscodeEntryIcon } from "./VscodeEntryIcon"; | |
| 9 | ||
| 10 | export type ComposerCommandItem = | |
| 11 | | { | |
| 12 | id: string; | |
| 13 | type: "path"; | |
| 14 | path: string; | |
| 15 | pathKind: ProjectEntry["kind"]; | |
| 16 | label: string; | |
| 17 | description: string; | |
| 18 | } | |
| 19 | | { | |
| 20 | id: string; | |
| 21 | type: "slash-command"; | |
| 22 | command: ComposerSlashCommand; | |
| 23 | label: string; | |
| 24 | description: string; | |
| 25 | } | |
| 26 | | { | |
| 27 | id: string; | |
| 28 | type: "model"; | |
| 29 | provider: ProviderKind; | |
| 30 | model: ModelSlug; | |
| 31 | label: string; | |
| 32 | description: string; | |
| 33 | }; | |
| 34 | ||
| 35 | export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { | |
| 36 | items: ComposerCommandItem[]; | |
| 37 | resolvedTheme: "light" | "dark"; | |
| 38 | isLoading: boolean; | |
| 39 | triggerKind: ComposerTriggerKind | null; | |
| 40 | activeItemId: string | null; | |
| 41 | onHighlightedItemChange: (itemId: string | null) => void; | |
| 42 | onSelect: (item: ComposerCommandItem) => void; | |
| 43 | }) { | |
| 44 | return ( | |
| 45 | <Command | |
| 46 | mode="none" | |
| 47 | onItemHighlighted={(highlightedValue) => { | |
| 48 | props.onHighlightedItemChange( | |
| 49 | typeof highlightedValue === "string" ? highlightedValue : null, | |
| 50 | ); | |
| 51 | }} | |
| 52 | > | |
| 53 | <div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"> | |
| 54 | <CommandList className="max-h-64"> | |
| 55 | {props.items.map((item) => ( | |
| 56 | <ComposerCommandMenuItem | |
| 57 | key={item.id} | |
| 58 | item={item} | |
| 59 | resolvedTheme={props.resolvedTheme} | |
| 60 | isActive={props.activeItemId === item.id} | |
| 61 | onSelect={props.onSelect} | |
| 62 | /> | |
| 63 | ))} | |
| 64 | </CommandList> | |
| 65 | {props.items.length === 0 && ( | |
| 66 | <p className="px-3 py-2 text-muted-foreground/70 text-xs"> | |
| 67 | {props.isLoading | |
| 68 | ? "Searching workspace files..." | |
| 69 | : props.triggerKind === "path" | |
| 70 | ? "No matching files or folders." | |
| 71 | : "No matching command."} | |
| 72 | </p> | |
| 73 | )} | |
| 74 | </div> | |
| 75 | </Command> | |
| 76 | ); | |
| 77 | }); | |
| 78 | ||
| 79 | const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { | |
| 80 | item: ComposerCommandItem; | |
| 81 | resolvedTheme: "light" | "dark"; | |
| 82 | isActive: boolean; | |
| 83 | onSelect: (item: ComposerCommandItem) => void; | |
| 84 | }) { | |
| 85 | return ( | |
| 86 | <CommandItem | |
| 87 | value={props.item.id} | |
| 88 | className={cn( | |
| 89 | "cursor-pointer select-none gap-2", | |
| 90 | props.isActive && "bg-accent text-accent-foreground", | |
| 91 | )} | |
| 92 | onMouseDown={(event) => { | |
| 93 | event.preventDefault(); | |
| 94 | }} | |
| 95 | onClick={() => { | |
| 96 | props.onSelect(props.item); | |
| 97 | }} | |
| 98 | > | |
| 99 | {props.item.type === "path" ? ( | |
| 100 | <VscodeEntryIcon | |
| 101 | pathValue={props.item.path} | |
| 102 | kind={props.item.pathKind} | |
| 103 | theme={props.resolvedTheme} | |
| 104 | /> | |
| 105 | ) : null} | |
| 106 | {props.item.type === "slash-command" ? ( | |
| 107 | <BotIcon className="size-4 text-muted-foreground/80" /> | |
| 108 | ) : null} | |
| 109 | {props.item.type === "model" ? ( | |
| 110 | <Badge variant="outline" className="px-1.5 py-0 text-[10px]"> | |
| 111 | model | |
| 112 | </Badge> | |
| 113 | ) : null} | |
| 114 | <span className="flex min-w-0 items-center gap-1.5 truncate"> | |
| 115 | <span className="truncate">{props.item.label}</span> | |
| 116 | </span> | |
| 117 | <span className="truncate text-muted-foreground/70 text-xs">{props.item.description}</span> | |
| 118 | </CommandItem> | |
| 119 | ); | |
| 120 | }); |
apps/web/src/components/chat/ComposerPendingApprovalActions.tsx created+55| ... | ... | @@ -0,0 +1,55 @@ |
| 1 | import { type ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts"; | |
| 2 | import { memo } from "react"; | |
| 3 | import { Button } from "../ui/button"; | |
| 4 | ||
| 5 | interface ComposerPendingApprovalActionsProps { | |
| 6 | requestId: ApprovalRequestId; | |
| 7 | isResponding: boolean; | |
| 8 | onRespondToApproval: ( | |
| 9 | requestId: ApprovalRequestId, | |
| 10 | decision: ProviderApprovalDecision, | |
| 11 | ) => Promise<void>; | |
| 12 | } | |
| 13 | ||
| 14 | export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({ | |
| 15 | requestId, | |
| 16 | isResponding, | |
| 17 | onRespondToApproval, | |
| 18 | }: ComposerPendingApprovalActionsProps) { | |
| 19 | return ( | |
| 20 | <> | |
| 21 | <Button | |
| 22 | size="sm" | |
| 23 | variant="ghost" | |
| 24 | disabled={isResponding} | |
| 25 | onClick={() => void onRespondToApproval(requestId, "cancel")} | |
| 26 | > | |
| 27 | Cancel turn | |
| 28 | </Button> | |
| 29 | <Button | |
| 30 | size="sm" | |
| 31 | variant="destructive-outline" | |
| 32 | disabled={isResponding} | |
| 33 | onClick={() => void onRespondToApproval(requestId, "decline")} | |
| 34 | > | |
| 35 | Decline | |
| 36 | </Button> | |
| 37 | <Button | |
| 38 | size="sm" | |
| 39 | variant="outline" | |
| 40 | disabled={isResponding} | |
| 41 | onClick={() => void onRespondToApproval(requestId, "acceptForSession")} | |
| 42 | > | |
| 43 | Always allow this session | |
| 44 | </Button> | |
| 45 | <Button | |
| 46 | size="sm" | |
| 47 | variant="default" | |
| 48 | disabled={isResponding} | |
| 49 | onClick={() => void onRespondToApproval(requestId, "accept")} | |
| 50 | > | |
| 51 | Approve once | |
| 52 | </Button> | |
| 53 | </> | |
| 54 | ); | |
| 55 | }); |
apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx created+31| ... | ... | @@ -0,0 +1,31 @@ |
| 1 | import { memo } from "react"; | |
| 2 | import { type PendingApproval } from "../../session-logic"; | |
| 3 | ||
| 4 | interface ComposerPendingApprovalPanelProps { | |
| 5 | approval: PendingApproval; | |
| 6 | pendingCount: number; | |
| 7 | } | |
| 8 | ||
| 9 | export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprovalPanel({ | |
| 10 | approval, | |
| 11 | pendingCount, | |
| 12 | }: ComposerPendingApprovalPanelProps) { | |
| 13 | const approvalSummary = | |
| 14 | approval.requestKind === "command" | |
| 15 | ? "Command approval requested" | |
| 16 | : approval.requestKind === "file-read" | |
| 17 | ? "File-read approval requested" | |
| 18 | : "File-change approval requested"; | |
| 19 | ||
| 20 | return ( | |
| 21 | <div className="px-4 py-3.5 sm:px-5 sm:py-4"> | |
| 22 | <div className="flex flex-wrap items-center gap-2"> | |
| 23 | <span className="uppercase text-sm tracking-[0.2em]">PENDING APPROVAL</span> | |
| 24 | <span className="text-sm font-medium">{approvalSummary}</span> | |
| 25 | {pendingCount > 1 ? ( | |
| 26 | <span className="text-xs text-muted-foreground">1/{pendingCount}</span> | |
| 27 | ) : null} | |
| 28 | </div> | |
| 29 | </div> | |
| 30 | ); | |
| 31 | }); |
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx created+182| ... | ... | @@ -0,0 +1,182 @@ |
| 1 | import { type ApprovalRequestId } from "@t3tools/contracts"; | |
| 2 | import { memo, useCallback, useEffect, useRef } from "react"; | |
| 3 | import { type PendingUserInput } from "../../session-logic"; | |
| 4 | import { | |
| 5 | derivePendingUserInputProgress, | |
| 6 | type PendingUserInputDraftAnswer, | |
| 7 | } from "../../pendingUserInput"; | |
| 8 | import { CheckIcon } from "lucide-react"; | |
| 9 | import { cn } from "~/lib/utils"; | |
| 10 | ||
| 11 | interface PendingUserInputPanelProps { | |
| 12 | pendingUserInputs: PendingUserInput[]; | |
| 13 | respondingRequestIds: ApprovalRequestId[]; | |
| 14 | answers: Record<string, PendingUserInputDraftAnswer>; | |
| 15 | questionIndex: number; | |
| 16 | onSelectOption: (questionId: string, optionLabel: string) => void; | |
| 17 | onAdvance: () => void; | |
| 18 | } | |
| 19 | ||
| 20 | export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ | |
| 21 | pendingUserInputs, | |
| 22 | respondingRequestIds, | |
| 23 | answers, | |
| 24 | questionIndex, | |
| 25 | onSelectOption, | |
| 26 | onAdvance, | |
| 27 | }: PendingUserInputPanelProps) { | |
| 28 | if (pendingUserInputs.length === 0) return null; | |
| 29 | const activePrompt = pendingUserInputs[0]; | |
| 30 | if (!activePrompt) return null; | |
| 31 | ||
| 32 | return ( | |
| 33 | <ComposerPendingUserInputCard | |
| 34 | key={activePrompt.requestId} | |
| 35 | prompt={activePrompt} | |
| 36 | isResponding={respondingRequestIds.includes(activePrompt.requestId)} | |
| 37 | answers={answers} | |
| 38 | questionIndex={questionIndex} | |
| 39 | onSelectOption={onSelectOption} | |
| 40 | onAdvance={onAdvance} | |
| 41 | /> | |
| 42 | ); | |
| 43 | }); | |
| 44 | ||
| 45 | const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({ | |
| 46 | prompt, | |
| 47 | isResponding, | |
| 48 | answers, | |
| 49 | questionIndex, | |
| 50 | onSelectOption, | |
| 51 | onAdvance, | |
| 52 | }: { | |
| 53 | prompt: PendingUserInput; | |
| 54 | isResponding: boolean; | |
| 55 | answers: Record<string, PendingUserInputDraftAnswer>; | |
| 56 | questionIndex: number; | |
| 57 | onSelectOption: (questionId: string, optionLabel: string) => void; | |
| 58 | onAdvance: () => void; | |
| 59 | }) { | |
| 60 | const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); | |
| 61 | const activeQuestion = progress.activeQuestion; | |
| 62 | const autoAdvanceTimerRef = useRef<number | null>(null); | |
| 63 | ||
| 64 | // Clear auto-advance timer on unmount | |
| 65 | useEffect(() => { | |
| 66 | return () => { | |
| 67 | if (autoAdvanceTimerRef.current !== null) { | |
| 68 | window.clearTimeout(autoAdvanceTimerRef.current); | |
| 69 | } | |
| 70 | }; | |
| 71 | }, []); | |
| 72 | ||
| 73 | const selectOptionAndAutoAdvance = useCallback( | |
| 74 | (questionId: string, optionLabel: string) => { | |
| 75 | onSelectOption(questionId, optionLabel); | |
| 76 | if (autoAdvanceTimerRef.current !== null) { | |
| 77 | window.clearTimeout(autoAdvanceTimerRef.current); | |
| 78 | } | |
| 79 | autoAdvanceTimerRef.current = window.setTimeout(() => { | |
| 80 | autoAdvanceTimerRef.current = null; | |
| 81 | onAdvance(); | |
| 82 | }, 200); | |
| 83 | }, | |
| 84 | [onSelectOption, onAdvance], | |
| 85 | ); | |
| 86 | ||
| 87 | // Keyboard shortcut: number keys 1-9 select corresponding option and auto-advance. | |
| 88 | // Works even when the Lexical composer (contenteditable) has focus — the composer | |
| 89 | // doubles as a custom-answer field during user input, and when it's empty the digit | |
| 90 | // keys should pick options instead of typing into the editor. | |
| 91 | useEffect(() => { | |
| 92 | if (!activeQuestion || isResponding) return; | |
| 93 | const handler = (event: globalThis.KeyboardEvent) => { | |
| 94 | if (event.metaKey || event.ctrlKey || event.altKey) return; | |
| 95 | const target = event.target; | |
| 96 | if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { | |
| 97 | return; | |
| 98 | } | |
| 99 | // If the user has started typing a custom answer in the contenteditable | |
| 100 | // composer, let digit keys pass through so they can type numbers. | |
| 101 | if (target instanceof HTMLElement && target.isContentEditable) { | |
| 102 | const hasCustomText = progress.customAnswer.length > 0; | |
| 103 | if (hasCustomText) return; | |
| 104 | } | |
| 105 | const digit = Number.parseInt(event.key, 10); | |
| 106 | if (Number.isNaN(digit) || digit < 1 || digit > 9) return; | |
| 107 | const optionIndex = digit - 1; | |
| 108 | if (optionIndex >= activeQuestion.options.length) return; | |
| 109 | const option = activeQuestion.options[optionIndex]; | |
| 110 | if (!option) return; | |
| 111 | event.preventDefault(); | |
| 112 | selectOptionAndAutoAdvance(activeQuestion.id, option.label); | |
| 113 | }; | |
| 114 | document.addEventListener("keydown", handler); | |
| 115 | return () => document.removeEventListener("keydown", handler); | |
| 116 | }, [activeQuestion, isResponding, selectOptionAndAutoAdvance, progress.customAnswer.length]); | |
| 117 | ||
| 118 | if (!activeQuestion) { | |
| 119 | return null; | |
| 120 | } | |
| 121 | ||
| 122 | return ( | |
| 123 | <div className="px-4 py-3 sm:px-5"> | |
| 124 | <div className="flex items-center gap-3"> | |
| 125 | <div className="flex items-center gap-2"> | |
| 126 | {prompt.questions.length > 1 ? ( | |
| 127 | <span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-[10px] font-medium tabular-nums text-muted-foreground/60"> | |
| 128 | {questionIndex + 1}/{prompt.questions.length} | |
| 129 | </span> | |
| 130 | ) : null} | |
| 131 | <span className="text-[11px] font-semibold tracking-widest text-muted-foreground/50 uppercase"> | |
| 132 | {activeQuestion.header} | |
| 133 | </span> | |
| 134 | </div> | |
| 135 | </div> | |
| 136 | <p className="mt-1.5 text-sm text-foreground/90">{activeQuestion.question}</p> | |
| 137 | <div className="mt-3 space-y-1"> | |
| 138 | {activeQuestion.options.map((option, index) => { | |
| 139 | const isSelected = progress.selectedOptionLabel === option.label; | |
| 140 | const shortcutKey = index < 9 ? index + 1 : null; | |
| 141 | return ( | |
| 142 | <button | |
| 143 | key={`${activeQuestion.id}:${option.label}`} | |
| 144 | type="button" | |
| 145 | disabled={isResponding} | |
| 146 | onClick={() => selectOptionAndAutoAdvance(activeQuestion.id, option.label)} | |
| 147 | className={cn( | |
| 148 | "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-all duration-150", | |
| 149 | isSelected | |
| 150 | ? "border-blue-500/40 bg-blue-500/8 text-foreground" | |
| 151 | : "border-transparent bg-muted/20 text-foreground/80 hover:bg-muted/40 hover:border-border/40", | |
| 152 | isResponding && "opacity-50 cursor-not-allowed", | |
| 153 | )} | |
| 154 | > | |
| 155 | {shortcutKey !== null ? ( | |
| 156 | <kbd | |
| 157 | className={cn( | |
| 158 | "flex size-5 shrink-0 items-center justify-center rounded text-[11px] font-medium tabular-nums transition-colors duration-150", | |
| 159 | isSelected | |
| 160 | ? "bg-blue-500/20 text-blue-400" | |
| 161 | : "bg-muted/40 text-muted-foreground/50 group-hover:bg-muted/60 group-hover:text-muted-foreground/70", | |
| 162 | )} | |
| 163 | > | |
| 164 | {shortcutKey} | |
| 165 | </kbd> | |
| 166 | ) : null} | |
| 167 | <div className="min-w-0 flex-1"> | |
| 168 | <span className="text-sm font-medium">{option.label}</span> | |
| 169 | {option.description && option.description !== option.label ? ( | |
| 170 | <span className="ml-2 text-xs text-muted-foreground/50"> | |
| 171 | {option.description} | |
| 172 | </span> | |
| 173 | ) : null} | |
| 174 | </div> | |
| 175 | {isSelected ? <CheckIcon className="size-3.5 shrink-0 text-blue-400" /> : null} | |
| 176 | </button> | |
| 177 | ); | |
| 178 | })} | |
| 179 | </div> | |
| 180 | </div> | |
| 181 | ); | |
| 182 | }); |
apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | import { memo } from "react"; | |
| 2 | ||
| 3 | export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({ | |
| 4 | planTitle, | |
| 5 | }: { | |
| 6 | planTitle: string | null; | |
| 7 | }) { | |
| 8 | return ( | |
| 9 | <div className="px-4 py-3.5 sm:px-5 sm:py-4"> | |
| 10 | <div className="flex flex-wrap items-center gap-2"> | |
| 11 | <span className="uppercase text-sm tracking-[0.2em]">Plan ready</span> | |
| 12 | {planTitle ? ( | |
| 13 | <span className="min-w-0 flex-1 truncate text-sm font-medium">{planTitle}</span> | |
| 14 | ) : null} | |
| 15 | </div> | |
| 16 | {/* <div className="mt-2 text-xs text-muted-foreground"> | |
| 17 | Review the plan | |
| 18 | </div> */} | |
| 19 | </div> | |
| 20 | ); | |
| 21 | }); |
apps/web/src/components/chat/DiffStatLabel.tsx created+22| ... | ... | @@ -0,0 +1,22 @@ |
| 1 | import { memo } from "react"; | |
| 2 | ||
| 3 | export function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean { | |
| 4 | return stat.additions > 0 || stat.deletions > 0; | |
| 5 | } | |
| 6 | ||
| 7 | export const DiffStatLabel = memo(function DiffStatLabel(props: { | |
| 8 | additions: number; | |
| 9 | deletions: number; | |
| 10 | showParentheses?: boolean; | |
| 11 | }) { | |
| 12 | const { additions, deletions, showParentheses = false } = props; | |
| 13 | return ( | |
| 14 | <> | |
| 15 | {showParentheses && <span className="text-muted-foreground/70">(</span>} | |
| 16 | <span className="text-success">+{additions}</span> | |
| 17 | <span className="mx-0.5 text-muted-foreground/70">/</span> | |
| 18 | <span className="text-destructive">-{deletions}</span> | |
| 19 | {showParentheses && <span className="text-muted-foreground/70">)</span>} | |
| 20 | </> | |
| 21 | ); | |
| 22 | }); |
apps/web/src/components/chat/ExpandedImagePreview.tsx created+32| ... | ... | @@ -0,0 +1,32 @@ |
| 1 | export interface ExpandedImageItem { | |
| 2 | src: string; | |
| 3 | name: string; | |
| 4 | } | |
| 5 | ||
| 6 | export interface ExpandedImagePreview { | |
| 7 | images: ExpandedImageItem[]; | |
| 8 | index: number; | |
| 9 | } | |
| 10 | ||
| 11 | export function buildExpandedImagePreview( | |
| 12 | images: ReadonlyArray<{ id: string; name: string; previewUrl?: string }>, | |
| 13 | selectedImageId: string, | |
| 14 | ): ExpandedImagePreview | null { | |
| 15 | const previewableImages = images.flatMap((image) => | |
| 16 | image.previewUrl ? [{ id: image.id, src: image.previewUrl, name: image.name }] : [], | |
| 17 | ); | |
| 18 | if (previewableImages.length === 0) { | |
| 19 | return null; | |
| 20 | } | |
| 21 | const selectedIndex = previewableImages.findIndex((image) => image.id === selectedImageId); | |
| 22 | if (selectedIndex < 0) { | |
| 23 | return null; | |
| 24 | } | |
| 25 | return { | |
| 26 | images: previewableImages.map((image) => ({ | |
| 27 | src: image.src, | |
| 28 | name: image.name, | |
| 29 | })), | |
| 30 | index: selectedIndex, | |
| 31 | }; | |
| 32 | } |
apps/web/src/components/chat/MessageCopyButton.tsx created+19| ... | ... | @@ -0,0 +1,19 @@ |
| 1 | import { memo, useCallback, useState } from "react"; | |
| 2 | import { CopyIcon, CheckIcon } from "lucide-react"; | |
| 3 | import { Button } from "../ui/button"; | |
| 4 | ||
| 5 | export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) { | |
| 6 | const [copied, setCopied] = useState(false); | |
| 7 | ||
| 8 | const handleCopy = useCallback(() => { | |
| 9 | void navigator.clipboard.writeText(text); | |
| 10 | setCopied(true); | |
| 11 | setTimeout(() => setCopied(false), 2000); | |
| 12 | }, [text]); | |
| 13 | ||
| 14 | return ( | |
| 15 | <Button type="button" size="xs" variant="outline" onClick={handleCopy} title="Copy message"> | |
| 16 | {copied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />} | |
| 17 | </Button> | |
| 18 | ); | |
| 19 | }); |
apps/web/src/components/chat/MessagesTimeline.tsx created+656| ... | ... | @@ -0,0 +1,656 @@ |
| 1 | import { type MessageId, type TurnId } from "@t3tools/contracts"; | |
| 2 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 3 | import { | |
| 4 | measureElement as measureVirtualElement, | |
| 5 | type VirtualItem, | |
| 6 | useVirtualizer, | |
| 7 | } from "@tanstack/react-virtual"; | |
| 8 | import { deriveTimelineEntries, formatElapsed, formatTimestamp } from "../../session-logic"; | |
| 9 | import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; | |
| 10 | import { type TurnDiffSummary } from "../../types"; | |
| 11 | import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; | |
| 12 | import ChatMarkdown from "../ChatMarkdown"; | |
| 13 | import { Undo2Icon } from "lucide-react"; | |
| 14 | import { Button } from "../ui/button"; | |
| 15 | import { clamp } from "effect/Number"; | |
| 16 | import { estimateTimelineMessageHeight } from "../timelineHeight"; | |
| 17 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; | |
| 18 | import { ProposedPlanCard } from "./ProposedPlanCard"; | |
| 19 | import { ChangedFilesTree } from "./ChangedFilesTree"; | |
| 20 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; | |
| 21 | import { MessageCopyButton } from "./MessageCopyButton"; | |
| 22 | ||
| 23 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | |
| 24 | const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; | |
| 25 | ||
| 26 | interface MessagesTimelineProps { | |
| 27 | hasMessages: boolean; | |
| 28 | isWorking: boolean; | |
| 29 | activeTurnInProgress: boolean; | |
| 30 | activeTurnStartedAt: string | null; | |
| 31 | scrollContainer: HTMLDivElement | null; | |
| 32 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; | |
| 33 | completionDividerBeforeEntryId: string | null; | |
| 34 | completionSummary: string | null; | |
| 35 | turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>; | |
| 36 | nowIso: string; | |
| 37 | expandedWorkGroups: Record<string, boolean>; | |
| 38 | onToggleWorkGroup: (groupId: string) => void; | |
| 39 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | |
| 40 | revertTurnCountByUserMessageId: Map<MessageId, number>; | |
| 41 | onRevertUserMessage: (messageId: MessageId) => void; | |
| 42 | isRevertingCheckpoint: boolean; | |
| 43 | onImageExpand: (preview: ExpandedImagePreview) => void; | |
| 44 | markdownCwd: string | undefined; | |
| 45 | resolvedTheme: "light" | "dark"; | |
| 46 | workspaceRoot: string | undefined; | |
| 47 | } | |
| 48 | ||
| 49 | export const MessagesTimeline = memo(function MessagesTimeline({ | |
| 50 | hasMessages, | |
| 51 | isWorking, | |
| 52 | activeTurnInProgress, | |
| 53 | activeTurnStartedAt, | |
| 54 | scrollContainer, | |
| 55 | timelineEntries, | |
| 56 | completionDividerBeforeEntryId, | |
| 57 | completionSummary, | |
| 58 | turnDiffSummaryByAssistantMessageId, | |
| 59 | nowIso, | |
| 60 | expandedWorkGroups, | |
| 61 | onToggleWorkGroup, | |
| 62 | onOpenTurnDiff, | |
| 63 | revertTurnCountByUserMessageId, | |
| 64 | onRevertUserMessage, | |
| 65 | isRevertingCheckpoint, | |
| 66 | onImageExpand, | |
| 67 | markdownCwd, | |
| 68 | resolvedTheme, | |
| 69 | workspaceRoot, | |
| 70 | }: MessagesTimelineProps) { | |
| 71 | const timelineRootRef = useRef<HTMLDivElement | null>(null); | |
| 72 | const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null); | |
| 73 | ||
| 74 | useLayoutEffect(() => { | |
| 75 | const timelineRoot = timelineRootRef.current; | |
| 76 | if (!timelineRoot) return; | |
| 77 | ||
| 78 | const updateWidth = (nextWidth: number) => { | |
| 79 | setTimelineWidthPx((previousWidth) => { | |
| 80 | if (previousWidth !== null && Math.abs(previousWidth - nextWidth) < 0.5) { | |
| 81 | return previousWidth; | |
| 82 | } | |
| 83 | return nextWidth; | |
| 84 | }); | |
| 85 | }; | |
| 86 | ||
| 87 | updateWidth(timelineRoot.getBoundingClientRect().width); | |
| 88 | ||
| 89 | if (typeof ResizeObserver === "undefined") return; | |
| 90 | const observer = new ResizeObserver(() => { | |
| 91 | updateWidth(timelineRoot.getBoundingClientRect().width); | |
| 92 | }); | |
| 93 | observer.observe(timelineRoot); | |
| 94 | return () => { | |
| 95 | observer.disconnect(); | |
| 96 | }; | |
| 97 | }, [hasMessages, isWorking]); | |
| 98 | ||
| 99 | const rows = useMemo<TimelineRow[]>(() => { | |
| 100 | const nextRows: TimelineRow[] = []; | |
| 101 | ||
| 102 | for (let index = 0; index < timelineEntries.length; index += 1) { | |
| 103 | const timelineEntry = timelineEntries[index]; | |
| 104 | if (!timelineEntry) { | |
| 105 | continue; | |
| 106 | } | |
| 107 | ||
| 108 | if (timelineEntry.kind === "work") { | |
| 109 | const groupedEntries = [timelineEntry.entry]; | |
| 110 | let cursor = index + 1; | |
| 111 | while (cursor < timelineEntries.length) { | |
| 112 | const nextEntry = timelineEntries[cursor]; | |
| 113 | if (!nextEntry || nextEntry.kind !== "work") break; | |
| 114 | groupedEntries.push(nextEntry.entry); | |
| 115 | cursor += 1; | |
| 116 | } | |
| 117 | nextRows.push({ | |
| 118 | kind: "work", | |
| 119 | id: timelineEntry.id, | |
| 120 | createdAt: timelineEntry.createdAt, | |
| 121 | groupedEntries, | |
| 122 | }); | |
| 123 | index = cursor - 1; | |
| 124 | continue; | |
| 125 | } | |
| 126 | ||
| 127 | if (timelineEntry.kind === "proposed-plan") { | |
| 128 | nextRows.push({ | |
| 129 | kind: "proposed-plan", | |
| 130 | id: timelineEntry.id, | |
| 131 | createdAt: timelineEntry.createdAt, | |
| 132 | proposedPlan: timelineEntry.proposedPlan, | |
| 133 | }); | |
| 134 | continue; | |
| 135 | } | |
| 136 | ||
| 137 | nextRows.push({ | |
| 138 | kind: "message", | |
| 139 | id: timelineEntry.id, | |
| 140 | createdAt: timelineEntry.createdAt, | |
| 141 | message: timelineEntry.message, | |
| 142 | showCompletionDivider: | |
| 143 | timelineEntry.message.role === "assistant" && | |
| 144 | completionDividerBeforeEntryId === timelineEntry.id, | |
| 145 | }); | |
| 146 | } | |
| 147 | ||
| 148 | if (isWorking) { | |
| 149 | nextRows.push({ | |
| 150 | kind: "working", | |
| 151 | id: "working-indicator-row", | |
| 152 | createdAt: activeTurnStartedAt, | |
| 153 | }); | |
| 154 | } | |
| 155 | ||
| 156 | return nextRows; | |
| 157 | }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]); | |
| 158 | ||
| 159 | const firstUnvirtualizedRowIndex = useMemo(() => { | |
| 160 | const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); | |
| 161 | if (!activeTurnInProgress) return firstTailRowIndex; | |
| 162 | ||
| 163 | const turnStartedAtMs = | |
| 164 | typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN; | |
| 165 | let firstCurrentTurnRowIndex = -1; | |
| 166 | if (!Number.isNaN(turnStartedAtMs)) { | |
| 167 | firstCurrentTurnRowIndex = rows.findIndex((row) => { | |
| 168 | if (row.kind === "working") return true; | |
| 169 | if (!row.createdAt) return false; | |
| 170 | const rowCreatedAtMs = Date.parse(row.createdAt); | |
| 171 | return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs; | |
| 172 | }); | |
| 173 | } | |
| 174 | ||
| 175 | if (firstCurrentTurnRowIndex < 0) { | |
| 176 | firstCurrentTurnRowIndex = rows.findIndex( | |
| 177 | (row) => row.kind === "message" && row.message.streaming, | |
| 178 | ); | |
| 179 | } | |
| 180 | ||
| 181 | if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex; | |
| 182 | ||
| 183 | for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) { | |
| 184 | const previousRow = rows[index]; | |
| 185 | if (!previousRow || previousRow.kind !== "message") continue; | |
| 186 | if (previousRow.message.role === "user") { | |
| 187 | return Math.min(index, firstTailRowIndex); | |
| 188 | } | |
| 189 | if (previousRow.message.role === "assistant" && !previousRow.message.streaming) { | |
| 190 | break; | |
| 191 | } | |
| 192 | } | |
| 193 | ||
| 194 | return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex); | |
| 195 | }, [activeTurnInProgress, activeTurnStartedAt, rows]); | |
| 196 | ||
| 197 | const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, { | |
| 198 | minimum: 0, | |
| 199 | maximum: rows.length, | |
| 200 | }); | |
| 201 | ||
| 202 | const rowVirtualizer = useVirtualizer({ | |
| 203 | count: virtualizedRowCount, | |
| 204 | getScrollElement: () => scrollContainer, | |
| 205 | // Use stable row ids so virtual measurements do not leak across thread switches. | |
| 206 | getItemKey: (index: number) => rows[index]?.id ?? index, | |
| 207 | estimateSize: (index: number) => { | |
| 208 | const row = rows[index]; | |
| 209 | if (!row) return 96; | |
| 210 | if (row.kind === "work") return 112; | |
| 211 | if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan); | |
| 212 | if (row.kind === "working") return 40; | |
| 213 | return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); | |
| 214 | }, | |
| 215 | measureElement: measureVirtualElement, | |
| 216 | useAnimationFrameWithResizeObserver: true, | |
| 217 | overscan: 8, | |
| 218 | }); | |
| 219 | useEffect(() => { | |
| 220 | if (timelineWidthPx === null) return; | |
| 221 | rowVirtualizer.measure(); | |
| 222 | }, [rowVirtualizer, timelineWidthPx]); | |
| 223 | useEffect(() => { | |
| 224 | rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { | |
| 225 | const viewportHeight = instance.scrollRect?.height ?? 0; | |
| 226 | const scrollOffset = instance.scrollOffset ?? 0; | |
| 227 | const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); | |
| 228 | return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; | |
| 229 | }; | |
| 230 | return () => { | |
| 231 | rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; | |
| 232 | }; | |
| 233 | }, [rowVirtualizer]); | |
| 234 | const pendingMeasureFrameRef = useRef<number | null>(null); | |
| 235 | const onTimelineImageLoad = useCallback(() => { | |
| 236 | if (pendingMeasureFrameRef.current !== null) return; | |
| 237 | pendingMeasureFrameRef.current = window.requestAnimationFrame(() => { | |
| 238 | pendingMeasureFrameRef.current = null; | |
| 239 | rowVirtualizer.measure(); | |
| 240 | }); | |
| 241 | }, [rowVirtualizer]); | |
| 242 | useEffect(() => { | |
| 243 | return () => { | |
| 244 | const frame = pendingMeasureFrameRef.current; | |
| 245 | if (frame !== null) { | |
| 246 | window.cancelAnimationFrame(frame); | |
| 247 | } | |
| 248 | }; | |
| 249 | }, []); | |
| 250 | ||
| 251 | const virtualRows = rowVirtualizer.getVirtualItems(); | |
| 252 | const nonVirtualizedRows = rows.slice(virtualizedRowCount); | |
| 253 | const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState< | |
| 254 | Record<string, boolean> | |
| 255 | >({}); | |
| 256 | const onToggleAllDirectories = useCallback((turnId: TurnId) => { | |
| 257 | setAllDirectoriesExpandedByTurnId((current) => ({ | |
| 258 | ...current, | |
| 259 | [turnId]: !(current[turnId] ?? true), | |
| 260 | })); | |
| 261 | }, []); | |
| 262 | ||
| 263 | const renderRowContent = (row: TimelineRow) => ( | |
| 264 | <div | |
| 265 | className="pb-4" | |
| 266 | data-timeline-row-kind={row.kind} | |
| 267 | data-message-id={row.kind === "message" ? row.message.id : undefined} | |
| 268 | data-message-role={row.kind === "message" ? row.message.role : undefined} | |
| 269 | > | |
| 270 | {row.kind === "work" && | |
| 271 | (() => { | |
| 272 | const groupId = row.id; | |
| 273 | const groupedEntries = row.groupedEntries; | |
| 274 | const isExpanded = expandedWorkGroups[groupId] ?? false; | |
| 275 | const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | |
| 276 | const visibleEntries = | |
| 277 | hasOverflow && !isExpanded | |
| 278 | ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) | |
| 279 | : groupedEntries; | |
| 280 | const hiddenCount = groupedEntries.length - visibleEntries.length; | |
| 281 | const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); | |
| 282 | const groupLabel = onlyToolEntries | |
| 283 | ? groupedEntries.length === 1 | |
| 284 | ? "Tool call" | |
| 285 | : `Tool calls (${groupedEntries.length})` | |
| 286 | : groupedEntries.length === 1 | |
| 287 | ? "Work event" | |
| 288 | : `Work log (${groupedEntries.length})`; | |
| 289 | ||
| 290 | return ( | |
| 291 | <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2"> | |
| 292 | <div className="mb-1.5 flex items-center justify-between gap-3"> | |
| 293 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | |
| 294 | {groupLabel} | |
| 295 | </p> | |
| 296 | {hasOverflow && ( | |
| 297 | <button | |
| 298 | type="button" | |
| 299 | className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80" | |
| 300 | onClick={() => onToggleWorkGroup(groupId)} | |
| 301 | > | |
| 302 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} | |
| 303 | </button> | |
| 304 | )} | |
| 305 | </div> | |
| 306 | <div className="space-y-1"> | |
| 307 | {visibleEntries.map((workEntry) => ( | |
| 308 | <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5"> | |
| 309 | <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | |
| 310 | <div className="min-w-0 flex-1 py-[2px]"> | |
| 311 | <p className={`text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}> | |
| 312 | {workEntry.label} | |
| 313 | </p> | |
| 314 | {workEntry.command && ( | |
| 315 | <pre className="mt-1 overflow-x-auto rounded-md border border-border/70 bg-background/80 px-2 py-1 font-mono text-[11px] leading-relaxed text-foreground/80"> | |
| 316 | {workEntry.command} | |
| 317 | </pre> | |
| 318 | )} | |
| 319 | {workEntry.changedFiles && workEntry.changedFiles.length > 0 && ( | |
| 320 | <div className="mt-1 flex flex-wrap gap-1"> | |
| 321 | {workEntry.changedFiles.slice(0, 6).map((filePath) => ( | |
| 322 | <span | |
| 323 | key={`${workEntry.id}:${filePath}`} | |
| 324 | className="rounded-md border border-border/70 bg-background/65 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground/85" | |
| 325 | title={filePath} | |
| 326 | > | |
| 327 | {filePath} | |
| 328 | </span> | |
| 329 | ))} | |
| 330 | {workEntry.changedFiles.length > 6 && ( | |
| 331 | <span className="px-1 text-[10px] text-muted-foreground/65"> | |
| 332 | +{workEntry.changedFiles.length - 6} more | |
| 333 | </span> | |
| 334 | )} | |
| 335 | </div> | |
| 336 | )} | |
| 337 | {workEntry.detail && | |
| 338 | (!workEntry.command || workEntry.detail !== workEntry.command) && ( | |
| 339 | <p | |
| 340 | className="mt-1 text-[11px] leading-relaxed text-muted-foreground/75" | |
| 341 | title={workEntry.detail} | |
| 342 | > | |
| 343 | {workEntry.detail} | |
| 344 | </p> | |
| 345 | )} | |
| 346 | </div> | |
| 347 | </div> | |
| 348 | ))} | |
| 349 | </div> | |
| 350 | </div> | |
| 351 | ); | |
| 352 | })()} | |
| 353 | ||
| 354 | {row.kind === "message" && | |
| 355 | row.message.role === "user" && | |
| 356 | (() => { | |
| 357 | const userImages = row.message.attachments ?? []; | |
| 358 | const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); | |
| 359 | return ( | |
| 360 | <div className="flex justify-end"> | |
| 361 | <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"> | |
| 362 | {userImages.length > 0 && ( | |
| 363 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> | |
| 364 | {userImages.map( | |
| 365 | (image: NonNullable<TimelineMessage["attachments"]>[number]) => ( | |
| 366 | <div | |
| 367 | key={image.id} | |
| 368 | className="overflow-hidden rounded-lg border border-border/80 bg-background/70" | |
| 369 | > | |
| 370 | {image.previewUrl ? ( | |
| 371 | <button | |
| 372 | type="button" | |
| 373 | className="h-full w-full cursor-zoom-in" | |
| 374 | aria-label={`Preview ${image.name}`} | |
| 375 | onClick={() => { | |
| 376 | const preview = buildExpandedImagePreview(userImages, image.id); | |
| 377 | if (!preview) return; | |
| 378 | onImageExpand(preview); | |
| 379 | }} | |
| 380 | > | |
| 381 | <img | |
| 382 | src={image.previewUrl} | |
| 383 | alt={image.name} | |
| 384 | className="h-full max-h-[220px] w-full object-cover" | |
| 385 | onLoad={onTimelineImageLoad} | |
| 386 | onError={onTimelineImageLoad} | |
| 387 | /> | |
| 388 | </button> | |
| 389 | ) : ( | |
| 390 | <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70"> | |
| 391 | {image.name} | |
| 392 | </div> | |
| 393 | )} | |
| 394 | </div> | |
| 395 | ), | |
| 396 | )} | |
| 397 | </div> | |
| 398 | )} | |
| 399 | {row.message.text && ( | |
| 400 | <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground"> | |
| 401 | {row.message.text} | |
| 402 | </pre> | |
| 403 | )} | |
| 404 | <div className="mt-1.5 flex items-center justify-end gap-2"> | |
| 405 | <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100"> | |
| 406 | {row.message.text && <MessageCopyButton text={row.message.text} />} | |
| 407 | {canRevertAgentWork && ( | |
| 408 | <Button | |
| 409 | type="button" | |
| 410 | size="xs" | |
| 411 | variant="outline" | |
| 412 | disabled={isRevertingCheckpoint || isWorking} | |
| 413 | onClick={() => onRevertUserMessage(row.message.id)} | |
| 414 | title="Revert to this message" | |
| 415 | > | |
| 416 | <Undo2Icon className="size-3" /> | |
| 417 | </Button> | |
| 418 | )} | |
| 419 | </div> | |
| 420 | <p className="text-right text-[10px] text-muted-foreground/30"> | |
| 421 | {formatTimestamp(row.message.createdAt)} | |
| 422 | </p> | |
| 423 | </div> | |
| 424 | </div> | |
| 425 | </div> | |
| 426 | ); | |
| 427 | })()} | |
| 428 | ||
| 429 | {row.kind === "message" && | |
| 430 | row.message.role === "assistant" && | |
| 431 | (() => { | |
| 432 | const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); | |
| 433 | return ( | |
| 434 | <> | |
| 435 | {row.showCompletionDivider && ( | |
| 436 | <div className="my-3 flex items-center gap-3"> | |
| 437 | <span className="h-px flex-1 bg-border" /> | |
| 438 | <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80"> | |
| 439 | {completionSummary ? `Response • ${completionSummary}` : "Response"} | |
| 440 | </span> | |
| 441 | <span className="h-px flex-1 bg-border" /> | |
| 442 | </div> | |
| 443 | )} | |
| 444 | <div className="min-w-0 px-1 py-0.5"> | |
| 445 | <ChatMarkdown | |
| 446 | text={messageText} | |
| 447 | cwd={markdownCwd} | |
| 448 | isStreaming={Boolean(row.message.streaming)} | |
| 449 | /> | |
| 450 | {(() => { | |
| 451 | const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); | |
| 452 | if (!turnSummary) return null; | |
| 453 | const checkpointFiles = turnSummary.files; | |
| 454 | if (checkpointFiles.length === 0) return null; | |
| 455 | const summaryStat = summarizeTurnDiffStats(checkpointFiles); | |
| 456 | const changedFileCountLabel = String(checkpointFiles.length); | |
| 457 | const allDirectoriesExpanded = | |
| 458 | allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true; | |
| 459 | return ( | |
| 460 | <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> | |
| 461 | <div className="mb-1.5 flex items-center justify-between gap-2"> | |
| 462 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | |
| 463 | <span>Changed files ({changedFileCountLabel})</span> | |
| 464 | {hasNonZeroStat(summaryStat) && ( | |
| 465 | <> | |
| 466 | <span className="mx-1">•</span> | |
| 467 | <DiffStatLabel | |
| 468 | additions={summaryStat.additions} | |
| 469 | deletions={summaryStat.deletions} | |
| 470 | /> | |
| 471 | </> | |
| 472 | )} | |
| 473 | </p> | |
| 474 | <div className="flex items-center gap-1.5"> | |
| 475 | <Button | |
| 476 | type="button" | |
| 477 | size="xs" | |
| 478 | variant="outline" | |
| 479 | onClick={() => onToggleAllDirectories(turnSummary.turnId)} | |
| 480 | > | |
| 481 | {allDirectoriesExpanded ? "Collapse all" : "Expand all"} | |
| 482 | </Button> | |
| 483 | <Button | |
| 484 | type="button" | |
| 485 | size="xs" | |
| 486 | variant="outline" | |
| 487 | onClick={() => | |
| 488 | onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path) | |
| 489 | } | |
| 490 | > | |
| 491 | View diff | |
| 492 | </Button> | |
| 493 | </div> | |
| 494 | </div> | |
| 495 | <ChangedFilesTree | |
| 496 | key={`changed-files-tree:${turnSummary.turnId}`} | |
| 497 | turnId={turnSummary.turnId} | |
| 498 | files={checkpointFiles} | |
| 499 | allDirectoriesExpanded={allDirectoriesExpanded} | |
| 500 | resolvedTheme={resolvedTheme} | |
| 501 | onOpenTurnDiff={onOpenTurnDiff} | |
| 502 | /> | |
| 503 | </div> | |
| 504 | ); | |
| 505 | })()} | |
| 506 | <p className="mt-1.5 text-[10px] text-muted-foreground/30"> | |
| 507 | {formatMessageMeta( | |
| 508 | row.message.createdAt, | |
| 509 | row.message.streaming | |
| 510 | ? formatElapsed(row.message.createdAt, nowIso) | |
| 511 | : formatElapsed(row.message.createdAt, row.message.completedAt), | |
| 512 | )} | |
| 513 | </p> | |
| 514 | </div> | |
| 515 | </> | |
| 516 | ); | |
| 517 | })()} | |
| 518 | ||
| 519 | {row.kind === "proposed-plan" && ( | |
| 520 | <div className="min-w-0 px-1 py-0.5"> | |
| 521 | <ProposedPlanCard | |
| 522 | planMarkdown={row.proposedPlan.planMarkdown} | |
| 523 | cwd={markdownCwd} | |
| 524 | workspaceRoot={workspaceRoot} | |
| 525 | /> | |
| 526 | </div> | |
| 527 | )} | |
| 528 | ||
| 529 | {row.kind === "working" && ( | |
| 530 | <div className="py-0.5 pl-1.5"> | |
| 531 | <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70"> | |
| 532 | <span className="inline-flex items-center gap-[3px]"> | |
| 533 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" /> | |
| 534 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" /> | |
| 535 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> | |
| 536 | </span> | |
| 537 | <span> | |
| 538 | {row.createdAt | |
| 539 | ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}` | |
| 540 | : "Working..."} | |
| 541 | </span> | |
| 542 | </div> | |
| 543 | </div> | |
| 544 | )} | |
| 545 | </div> | |
| 546 | ); | |
| 547 | ||
| 548 | if (!hasMessages && !isWorking) { | |
| 549 | return ( | |
| 550 | <div className="flex h-full items-center justify-center"> | |
| 551 | <p className="text-sm text-muted-foreground/30"> | |
| 552 | Send a message to start the conversation. | |
| 553 | </p> | |
| 554 | </div> | |
| 555 | ); | |
| 556 | } | |
| 557 | ||
| 558 | return ( | |
| 559 | <div | |
| 560 | ref={timelineRootRef} | |
| 561 | data-timeline-root="true" | |
| 562 | className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden" | |
| 563 | > | |
| 564 | {virtualizedRowCount > 0 && ( | |
| 565 | <div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}> | |
| 566 | {virtualRows.map((virtualRow: VirtualItem) => { | |
| 567 | const row = rows[virtualRow.index]; | |
| 568 | if (!row) return null; | |
| 569 | ||
| 570 | return ( | |
| 571 | <div | |
| 572 | key={`virtual-row:${row.id}`} | |
| 573 | data-index={virtualRow.index} | |
| 574 | ref={rowVirtualizer.measureElement} | |
| 575 | className="absolute left-0 top-0 w-full" | |
| 576 | style={{ transform: `translateY(${virtualRow.start}px)` }} | |
| 577 | > | |
| 578 | {renderRowContent(row)} | |
| 579 | </div> | |
| 580 | ); | |
| 581 | })} | |
| 582 | </div> | |
| 583 | )} | |
| 584 | ||
| 585 | {nonVirtualizedRows.map((row) => ( | |
| 586 | <div key={`non-virtual-row:${row.id}`}>{renderRowContent(row)}</div> | |
| 587 | ))} | |
| 588 | </div> | |
| 589 | ); | |
| 590 | }); | |
| 591 | ||
| 592 | type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number]; | |
| 593 | type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"]; | |
| 594 | type TimelineProposedPlan = Extract<TimelineEntry, { kind: "proposed-plan" }>["proposedPlan"]; | |
| 595 | type TimelineWorkEntry = Extract<TimelineEntry, { kind: "work" }>["entry"]; | |
| 596 | type TimelineRow = | |
| 597 | | { | |
| 598 | kind: "work"; | |
| 599 | id: string; | |
| 600 | createdAt: string; | |
| 601 | groupedEntries: TimelineWorkEntry[]; | |
| 602 | } | |
| 603 | | { | |
| 604 | kind: "message"; | |
| 605 | id: string; | |
| 606 | createdAt: string; | |
| 607 | message: TimelineMessage; | |
| 608 | showCompletionDivider: boolean; | |
| 609 | } | |
| 610 | | { | |
| 611 | kind: "proposed-plan"; | |
| 612 | id: string; | |
| 613 | createdAt: string; | |
| 614 | proposedPlan: TimelineProposedPlan; | |
| 615 | } | |
| 616 | | { kind: "working"; id: string; createdAt: string | null }; | |
| 617 | ||
| 618 | function estimateTimelineProposedPlanHeight(proposedPlan: TimelineProposedPlan): number { | |
| 619 | const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72)); | |
| 620 | return 120 + Math.min(estimatedLines * 22, 880); | |
| 621 | } | |
| 622 | ||
| 623 | function formatWorkingTimer(startIso: string, endIso: string): string | null { | |
| 624 | const startedAtMs = Date.parse(startIso); | |
| 625 | const endedAtMs = Date.parse(endIso); | |
| 626 | if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) { | |
| 627 | return null; | |
| 628 | } | |
| 629 | ||
| 630 | const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000)); | |
| 631 | if (elapsedSeconds < 60) { | |
| 632 | return `${elapsedSeconds}s`; | |
| 633 | } | |
| 634 | ||
| 635 | const hours = Math.floor(elapsedSeconds / 3600); | |
| 636 | const minutes = Math.floor((elapsedSeconds % 3600) / 60); | |
| 637 | const seconds = elapsedSeconds % 60; | |
| 638 | ||
| 639 | if (hours > 0) { | |
| 640 | return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; | |
| 641 | } | |
| 642 | ||
| 643 | return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; | |
| 644 | } | |
| 645 | ||
| 646 | function formatMessageMeta(createdAt: string, duration: string | null): string { | |
| 647 | if (!duration) return formatTimestamp(createdAt); | |
| 648 | return `${formatTimestamp(createdAt)} • ${duration}`; | |
| 649 | } | |
| 650 | ||
| 651 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | |
| 652 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; | |
| 653 | if (tone === "tool") return "text-muted-foreground/70"; | |
| 654 | if (tone === "thinking") return "text-muted-foreground/50"; | |
| 655 | return "text-muted-foreground/40"; | |
| 656 | } |
apps/web/src/components/chat/OpenInPicker.tsx created+132| ... | ... | @@ -0,0 +1,132 @@ |
| 1 | import { EDITORS, type EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; | |
| 2 | import { memo, useCallback, useEffect, useMemo, useState } from "react"; | |
| 3 | import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; | |
| 4 | import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; | |
| 5 | import { Button } from "../ui/button"; | |
| 6 | import { Group, GroupSeparator } from "../ui/group"; | |
| 7 | import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; | |
| 8 | import { CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons"; | |
| 9 | import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; | |
| 10 | import { readNativeApi } from "~/nativeApi"; | |
| 11 | ||
| 12 | const LAST_EDITOR_KEY = "t3code:last-editor"; | |
| 13 | ||
| 14 | export const OpenInPicker = memo(function OpenInPicker({ | |
| 15 | keybindings, | |
| 16 | availableEditors, | |
| 17 | openInCwd, | |
| 18 | }: { | |
| 19 | keybindings: ResolvedKeybindingsConfig; | |
| 20 | availableEditors: ReadonlyArray<EditorId>; | |
| 21 | openInCwd: string | null; | |
| 22 | }) { | |
| 23 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { | |
| 24 | const stored = localStorage.getItem(LAST_EDITOR_KEY); | |
| 25 | return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; | |
| 26 | }); | |
| 27 | ||
| 28 | const allOptions = useMemo<Array<{ label: string; Icon: Icon; value: EditorId }>>( | |
| 29 | () => [ | |
| 30 | { | |
| 31 | label: "Cursor", | |
| 32 | Icon: CursorIcon, | |
| 33 | value: "cursor", | |
| 34 | }, | |
| 35 | { | |
| 36 | label: "VS Code", | |
| 37 | Icon: VisualStudioCode, | |
| 38 | value: "vscode", | |
| 39 | }, | |
| 40 | { | |
| 41 | label: "Zed", | |
| 42 | Icon: Zed, | |
| 43 | value: "zed", | |
| 44 | }, | |
| 45 | { | |
| 46 | label: isMacPlatform(navigator.platform) | |
| 47 | ? "Finder" | |
| 48 | : isWindowsPlatform(navigator.platform) | |
| 49 | ? "Explorer" | |
| 50 | : "Files", | |
| 51 | Icon: FolderClosedIcon, | |
| 52 | value: "file-manager", | |
| 53 | }, | |
| 54 | ], | |
| 55 | [], | |
| 56 | ); | |
| 57 | const options = useMemo( | |
| 58 | () => allOptions.filter((option) => availableEditors.includes(option.value)), | |
| 59 | [allOptions, availableEditors], | |
| 60 | ); | |
| 61 | ||
| 62 | const effectiveEditor = options.some((option) => option.value === lastEditor) | |
| 63 | ? lastEditor | |
| 64 | : (options[0]?.value ?? null); | |
| 65 | const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; | |
| 66 | ||
| 67 | const openInEditor = useCallback( | |
| 68 | (editorId: EditorId | null) => { | |
| 69 | const api = readNativeApi(); | |
| 70 | if (!api || !openInCwd) return; | |
| 71 | const editor = editorId ?? effectiveEditor; | |
| 72 | if (!editor) return; | |
| 73 | void api.shell.openInEditor(openInCwd, editor); | |
| 74 | localStorage.setItem(LAST_EDITOR_KEY, editor); | |
| 75 | setLastEditor(editor); | |
| 76 | }, | |
| 77 | [effectiveEditor, openInCwd, setLastEditor], | |
| 78 | ); | |
| 79 | ||
| 80 | const openFavoriteEditorShortcutLabel = useMemo( | |
| 81 | () => shortcutLabelForCommand(keybindings, "editor.openFavorite"), | |
| 82 | [keybindings], | |
| 83 | ); | |
| 84 | ||
| 85 | useEffect(() => { | |
| 86 | const handler = (e: globalThis.KeyboardEvent) => { | |
| 87 | const api = readNativeApi(); | |
| 88 | if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; | |
| 89 | if (!api || !openInCwd) return; | |
| 90 | if (!effectiveEditor) return; | |
| 91 | ||
| 92 | e.preventDefault(); | |
| 93 | void api.shell.openInEditor(openInCwd, effectiveEditor); | |
| 94 | }; | |
| 95 | window.addEventListener("keydown", handler); | |
| 96 | return () => window.removeEventListener("keydown", handler); | |
| 97 | }, [effectiveEditor, keybindings, openInCwd]); | |
| 98 | ||
| 99 | return ( | |
| 100 | <Group aria-label="Subscription actions"> | |
| 101 | <Button | |
| 102 | size="xs" | |
| 103 | variant="outline" | |
| 104 | disabled={!effectiveEditor || !openInCwd} | |
| 105 | onClick={() => openInEditor(effectiveEditor)} | |
| 106 | > | |
| 107 | {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3.5" />} | |
| 108 | <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5"> | |
| 109 | Open | |
| 110 | </span> | |
| 111 | </Button> | |
| 112 | <GroupSeparator className="hidden @sm/header-actions:block" /> | |
| 113 | <Menu> | |
| 114 | <MenuTrigger render={<Button aria-label="Copy options" size="icon-xs" variant="outline" />}> | |
| 115 | <ChevronDownIcon aria-hidden="true" className="size-4" /> | |
| 116 | </MenuTrigger> | |
| 117 | <MenuPopup align="end"> | |
| 118 | {options.length === 0 && <MenuItem disabled>No installed editors found</MenuItem>} | |
| 119 | {options.map(({ label, Icon, value }) => ( | |
| 120 | <MenuItem key={value} onClick={() => openInEditor(value)}> | |
| 121 | <Icon aria-hidden="true" className="text-muted-foreground" /> | |
| 122 | {label} | |
| 123 | {value === effectiveEditor && openFavoriteEditorShortcutLabel && ( | |
| 124 | <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut> | |
| 125 | )} | |
| 126 | </MenuItem> | |
| 127 | ))} | |
| 128 | </MenuPopup> | |
| 129 | </Menu> | |
| 130 | </Group> | |
| 131 | ); | |
| 132 | }); |
apps/web/src/components/chat/ProposedPlanCard.tsx created+211| ... | ... | @@ -0,0 +1,211 @@ |
| 1 | import { memo, useState, useId } from "react"; | |
| 2 | import { | |
| 3 | buildCollapsedProposedPlanPreviewMarkdown, | |
| 4 | buildProposedPlanMarkdownFilename, | |
| 5 | downloadPlanAsTextFile, | |
| 6 | normalizePlanMarkdownForExport, | |
| 7 | proposedPlanTitle, | |
| 8 | stripDisplayedPlanMarkdown, | |
| 9 | } from "../../proposedPlan"; | |
| 10 | import ChatMarkdown from "../ChatMarkdown"; | |
| 11 | import { EllipsisIcon } from "lucide-react"; | |
| 12 | import { Button } from "../ui/button"; | |
| 13 | import { Input } from "../ui/input"; | |
| 14 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; | |
| 15 | import { cn } from "~/lib/utils"; | |
| 16 | import { Badge } from "../ui/badge"; | |
| 17 | import { | |
| 18 | Dialog, | |
| 19 | DialogDescription, | |
| 20 | DialogFooter, | |
| 21 | DialogHeader, | |
| 22 | DialogPanel, | |
| 23 | DialogPopup, | |
| 24 | DialogTitle, | |
| 25 | } from "../ui/dialog"; | |
| 26 | import { toastManager } from "../ui/toast"; | |
| 27 | import { readNativeApi } from "~/nativeApi"; | |
| 28 | ||
| 29 | export const ProposedPlanCard = memo(function ProposedPlanCard({ | |
| 30 | planMarkdown, | |
| 31 | cwd, | |
| 32 | workspaceRoot, | |
| 33 | }: { | |
| 34 | planMarkdown: string; | |
| 35 | cwd: string | undefined; | |
| 36 | workspaceRoot: string | undefined; | |
| 37 | }) { | |
| 38 | const [expanded, setExpanded] = useState(false); | |
| 39 | const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); | |
| 40 | const [savePath, setSavePath] = useState(""); | |
| 41 | const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); | |
| 42 | const savePathInputId = useId(); | |
| 43 | const title = proposedPlanTitle(planMarkdown) ?? "Proposed plan"; | |
| 44 | const lineCount = planMarkdown.split("\n").length; | |
| 45 | const canCollapse = planMarkdown.length > 900 || lineCount > 20; | |
| 46 | const displayedPlanMarkdown = stripDisplayedPlanMarkdown(planMarkdown); | |
| 47 | const collapsedPreview = canCollapse | |
| 48 | ? buildCollapsedProposedPlanPreviewMarkdown(planMarkdown, { maxLines: 10 }) | |
| 49 | : null; | |
| 50 | const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown); | |
| 51 | const saveContents = normalizePlanMarkdownForExport(planMarkdown); | |
| 52 | ||
| 53 | const handleDownload = () => { | |
| 54 | downloadPlanAsTextFile(downloadFilename, saveContents); | |
| 55 | }; | |
| 56 | ||
| 57 | const openSaveDialog = () => { | |
| 58 | if (!workspaceRoot) { | |
| 59 | toastManager.add({ | |
| 60 | type: "error", | |
| 61 | title: "Workspace path is unavailable", | |
| 62 | description: "This thread does not have a workspace path to save into.", | |
| 63 | }); | |
| 64 | return; | |
| 65 | } | |
| 66 | setSavePath((existing) => (existing.length > 0 ? existing : downloadFilename)); | |
| 67 | setIsSaveDialogOpen(true); | |
| 68 | }; | |
| 69 | ||
| 70 | const handleSaveToWorkspace = () => { | |
| 71 | const api = readNativeApi(); | |
| 72 | const relativePath = savePath.trim(); | |
| 73 | if (!api || !workspaceRoot) { | |
| 74 | return; | |
| 75 | } | |
| 76 | if (!relativePath) { | |
| 77 | toastManager.add({ | |
| 78 | type: "warning", | |
| 79 | title: "Enter a workspace path", | |
| 80 | }); | |
| 81 | return; | |
| 82 | } | |
| 83 | ||
| 84 | setIsSavingToWorkspace(true); | |
| 85 | void api.projects | |
| 86 | .writeFile({ | |
| 87 | cwd: workspaceRoot, | |
| 88 | relativePath, | |
| 89 | contents: saveContents, | |
| 90 | }) | |
| 91 | .then((result) => { | |
| 92 | setIsSaveDialogOpen(false); | |
| 93 | toastManager.add({ | |
| 94 | type: "success", | |
| 95 | title: "Plan saved to workspace", | |
| 96 | description: result.relativePath, | |
| 97 | }); | |
| 98 | }) | |
| 99 | .catch((error) => { | |
| 100 | toastManager.add({ | |
| 101 | type: "error", | |
| 102 | title: "Could not save plan", | |
| 103 | description: error instanceof Error ? error.message : "An error occurred while saving.", | |
| 104 | }); | |
| 105 | }) | |
| 106 | .then( | |
| 107 | () => { | |
| 108 | setIsSavingToWorkspace(false); | |
| 109 | }, | |
| 110 | () => { | |
| 111 | setIsSavingToWorkspace(false); | |
| 112 | }, | |
| 113 | ); | |
| 114 | }; | |
| 115 | ||
| 116 | return ( | |
| 117 | <div className="rounded-[24px] border border-border/80 bg-card/70 p-4 sm:p-5"> | |
| 118 | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 119 | <div className="flex min-w-0 items-center gap-2"> | |
| 120 | <Badge variant="secondary">Plan</Badge> | |
| 121 | <p className="truncate text-sm font-medium text-foreground">{title}</p> | |
| 122 | </div> | |
| 123 | <Menu> | |
| 124 | <MenuTrigger | |
| 125 | render={<Button aria-label="Plan actions" size="icon-xs" variant="outline" />} | |
| 126 | > | |
| 127 | <EllipsisIcon aria-hidden="true" className="size-4" /> | |
| 128 | </MenuTrigger> | |
| 129 | <MenuPopup align="end"> | |
| 130 | <MenuItem onClick={handleDownload}>Download as markdown</MenuItem> | |
| 131 | <MenuItem onClick={openSaveDialog} disabled={!workspaceRoot || isSavingToWorkspace}> | |
| 132 | Save to workspace | |
| 133 | </MenuItem> | |
| 134 | </MenuPopup> | |
| 135 | </Menu> | |
| 136 | </div> | |
| 137 | <div className="mt-4"> | |
| 138 | <div className={cn("relative", canCollapse && !expanded && "max-h-104 overflow-hidden")}> | |
| 139 | {canCollapse && !expanded ? ( | |
| 140 | <ChatMarkdown text={collapsedPreview ?? ""} cwd={cwd} isStreaming={false} /> | |
| 141 | ) : ( | |
| 142 | <ChatMarkdown text={displayedPlanMarkdown} cwd={cwd} isStreaming={false} /> | |
| 143 | )} | |
| 144 | {canCollapse && !expanded ? ( | |
| 145 | <div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-linear-to-t from-card/95 via-card/80 to-transparent" /> | |
| 146 | ) : null} | |
| 147 | </div> | |
| 148 | {canCollapse ? ( | |
| 149 | <div className="mt-4 flex justify-center"> | |
| 150 | <Button | |
| 151 | size="sm" | |
| 152 | variant="outline" | |
| 153 | data-scroll-anchor-ignore | |
| 154 | onClick={() => setExpanded((value) => !value)} | |
| 155 | > | |
| 156 | {expanded ? "Collapse plan" : "Expand plan"} | |
| 157 | </Button> | |
| 158 | </div> | |
| 159 | ) : null} | |
| 160 | </div> | |
| 161 | ||
| 162 | <Dialog | |
| 163 | open={isSaveDialogOpen} | |
| 164 | onOpenChange={(open) => { | |
| 165 | if (!isSavingToWorkspace) { | |
| 166 | setIsSaveDialogOpen(open); | |
| 167 | } | |
| 168 | }} | |
| 169 | > | |
| 170 | <DialogPopup className="max-w-xl"> | |
| 171 | <DialogHeader> | |
| 172 | <DialogTitle>Save plan to workspace</DialogTitle> | |
| 173 | <DialogDescription> | |
| 174 | Enter a path relative to <code>{workspaceRoot ?? "the workspace"}</code>. | |
| 175 | </DialogDescription> | |
| 176 | </DialogHeader> | |
| 177 | <DialogPanel className="space-y-3"> | |
| 178 | <label htmlFor={savePathInputId} className="grid gap-1.5"> | |
| 179 | <span className="text-xs font-medium text-foreground">Workspace path</span> | |
| 180 | <Input | |
| 181 | id={savePathInputId} | |
| 182 | value={savePath} | |
| 183 | onChange={(event) => setSavePath(event.target.value)} | |
| 184 | placeholder={downloadFilename} | |
| 185 | spellCheck={false} | |
| 186 | disabled={isSavingToWorkspace} | |
| 187 | /> | |
| 188 | </label> | |
| 189 | </DialogPanel> | |
| 190 | <DialogFooter> | |
| 191 | <Button | |
| 192 | variant="outline" | |
| 193 | size="sm" | |
| 194 | onClick={() => setIsSaveDialogOpen(false)} | |
| 195 | disabled={isSavingToWorkspace} | |
| 196 | > | |
| 197 | Cancel | |
| 198 | </Button> | |
| 199 | <Button | |
| 200 | size="sm" | |
| 201 | onClick={() => void handleSaveToWorkspace()} | |
| 202 | disabled={isSavingToWorkspace} | |
| 203 | > | |
| 204 | {isSavingToWorkspace ? "Saving..." : "Save"} | |
| 205 | </Button> | |
| 206 | </DialogFooter> | |
| 207 | </DialogPopup> | |
| 208 | </Dialog> | |
| 209 | </div> | |
| 210 | ); | |
| 211 | }); |
apps/web/src/components/chat/ProviderHealthBanner.tsx created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | import { type ServerProviderStatus } from "@t3tools/contracts"; | |
| 2 | import { memo } from "react"; | |
| 3 | import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; | |
| 4 | import { CircleAlertIcon } from "lucide-react"; | |
| 5 | ||
| 6 | export const ProviderHealthBanner = memo(function ProviderHealthBanner({ | |
| 7 | status, | |
| 8 | }: { | |
| 9 | status: ServerProviderStatus | null; | |
| 10 | }) { | |
| 11 | if (!status || status.status === "ready") { | |
| 12 | return null; | |
| 13 | } | |
| 14 | ||
| 15 | const defaultMessage = | |
| 16 | status.status === "error" | |
| 17 | ? `${status.provider} provider is unavailable.` | |
| 18 | : `${status.provider} provider has limited availability.`; | |
| 19 | ||
| 20 | return ( | |
| 21 | <div className="pt-3 mx-auto max-w-3xl"> | |
| 22 | <Alert variant={status.status === "error" ? "error" : "warning"}> | |
| 23 | <CircleAlertIcon /> | |
| 24 | <AlertTitle> | |
| 25 | {status.provider === "codex" ? "Codex provider status" : `${status.provider} status`} | |
| 26 | </AlertTitle> | |
| 27 | <AlertDescription className="line-clamp-3" title={status.message ?? defaultMessage}> | |
| 28 | {status.message ?? defaultMessage} | |
| 29 | </AlertDescription> | |
| 30 | </Alert> | |
| 31 | </div> | |
| 32 | ); | |
| 33 | }); |
apps/web/src/components/chat/ProviderModelPicker.tsx created+206| ... | ... | @@ -0,0 +1,206 @@ |
| 1 | import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; | |
| 2 | import { normalizeModelSlug } from "@t3tools/shared/model"; | |
| 3 | import { memo, useState } from "react"; | |
| 4 | import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; | |
| 5 | import { ChevronDownIcon } from "lucide-react"; | |
| 6 | import { Button } from "../ui/button"; | |
| 7 | import { | |
| 8 | Menu, | |
| 9 | MenuGroup, | |
| 10 | MenuItem, | |
| 11 | MenuPopup, | |
| 12 | MenuRadioGroup, | |
| 13 | MenuRadioItem, | |
| 14 | MenuSeparator as MenuDivider, | |
| 15 | MenuSub, | |
| 16 | MenuSubPopup, | |
| 17 | MenuSubTrigger, | |
| 18 | MenuTrigger, | |
| 19 | } from "../ui/menu"; | |
| 20 | import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; | |
| 21 | import { cn } from "~/lib/utils"; | |
| 22 | ||
| 23 | function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { | |
| 24 | value: ProviderKind; | |
| 25 | label: string; | |
| 26 | available: true; | |
| 27 | } { | |
| 28 | return option.available && option.value !== "claudeCode"; | |
| 29 | } | |
| 30 | ||
| 31 | function resolveModelForProviderPicker( | |
| 32 | provider: ProviderKind, | |
| 33 | value: string, | |
| 34 | options: ReadonlyArray<{ slug: string; name: string }>, | |
| 35 | ): ModelSlug | null { | |
| 36 | const trimmedValue = value.trim(); | |
| 37 | if (!trimmedValue) { | |
| 38 | return null; | |
| 39 | } | |
| 40 | ||
| 41 | const direct = options.find((option) => option.slug === trimmedValue); | |
| 42 | if (direct) { | |
| 43 | return direct.slug; | |
| 44 | } | |
| 45 | ||
| 46 | const byName = options.find((option) => option.name.toLowerCase() === trimmedValue.toLowerCase()); | |
| 47 | if (byName) { | |
| 48 | return byName.slug; | |
| 49 | } | |
| 50 | ||
| 51 | const normalized = normalizeModelSlug(trimmedValue, provider); | |
| 52 | if (!normalized) { | |
| 53 | return null; | |
| 54 | } | |
| 55 | ||
| 56 | const resolved = options.find((option) => option.slug === normalized); | |
| 57 | if (resolved) { | |
| 58 | return resolved.slug; | |
| 59 | } | |
| 60 | ||
| 61 | return null; | |
| 62 | } | |
| 63 | ||
| 64 | const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = { | |
| 65 | codex: OpenAI, | |
| 66 | claudeCode: ClaudeAI, | |
| 67 | cursor: CursorIcon, | |
| 68 | }; | |
| 69 | ||
| 70 | export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); | |
| 71 | const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); | |
| 72 | const COMING_SOON_PROVIDER_OPTIONS = [ | |
| 73 | { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, | |
| 74 | { id: "gemini", label: "Gemini", icon: Gemini }, | |
| 75 | ] as const; | |
| 76 | ||
| 77 | export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | |
| 78 | provider: ProviderKind; | |
| 79 | model: ModelSlug; | |
| 80 | lockedProvider: ProviderKind | null; | |
| 81 | modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>; | |
| 82 | compact?: boolean; | |
| 83 | disabled?: boolean; | |
| 84 | onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; | |
| 85 | }) { | |
| 86 | const [isMenuOpen, setIsMenuOpen] = useState(false); | |
| 87 | const selectedProviderOptions = props.modelOptionsByProvider[props.provider]; | |
| 88 | const selectedModelLabel = | |
| 89 | selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; | |
| 90 | const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; | |
| 91 | ||
| 92 | return ( | |
| 93 | <Menu | |
| 94 | open={isMenuOpen} | |
| 95 | onOpenChange={(open) => { | |
| 96 | if (props.disabled) { | |
| 97 | setIsMenuOpen(false); | |
| 98 | return; | |
| 99 | } | |
| 100 | setIsMenuOpen(open); | |
| 101 | }} | |
| 102 | > | |
| 103 | <MenuTrigger | |
| 104 | render={ | |
| 105 | <Button | |
| 106 | size="sm" | |
| 107 | variant="ghost" | |
| 108 | className={cn( | |
| 109 | "min-w-0 shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80", | |
| 110 | props.compact ? "max-w-42" : "sm:px-3", | |
| 111 | )} | |
| 112 | disabled={props.disabled} | |
| 113 | /> | |
| 114 | } | |
| 115 | > | |
| 116 | <span | |
| 117 | className={cn("flex min-w-0 items-center gap-2", props.compact ? "max-w-36" : undefined)} | |
| 118 | > | |
| 119 | <ProviderIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/70" /> | |
| 120 | <span className="truncate">{selectedModelLabel}</span> | |
| 121 | <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" /> | |
| 122 | </span> | |
| 123 | </MenuTrigger> | |
| 124 | <MenuPopup align="start"> | |
| 125 | {AVAILABLE_PROVIDER_OPTIONS.map((option) => { | |
| 126 | const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; | |
| 127 | const isDisabledByProviderLock = | |
| 128 | props.lockedProvider !== null && props.lockedProvider !== option.value; | |
| 129 | return ( | |
| 130 | <MenuSub key={option.value}> | |
| 131 | <MenuSubTrigger disabled={isDisabledByProviderLock}> | |
| 132 | <OptionIcon | |
| 133 | aria-hidden="true" | |
| 134 | className="size-4 shrink-0 text-muted-foreground/85" | |
| 135 | /> | |
| 136 | {option.label} | |
| 137 | </MenuSubTrigger> | |
| 138 | <MenuSubPopup className="[--available-height:min(24rem,70vh)]"> | |
| 139 | <MenuGroup> | |
| 140 | <MenuRadioGroup | |
| 141 | value={props.provider === option.value ? props.model : ""} | |
| 142 | onValueChange={(value) => { | |
| 143 | if (props.disabled) return; | |
| 144 | if (isDisabledByProviderLock) return; | |
| 145 | if (!value) return; | |
| 146 | const resolvedModel = resolveModelForProviderPicker( | |
| 147 | option.value, | |
| 148 | value, | |
| 149 | props.modelOptionsByProvider[option.value], | |
| 150 | ); | |
| 151 | if (!resolvedModel) return; | |
| 152 | props.onProviderModelChange(option.value, resolvedModel); | |
| 153 | setIsMenuOpen(false); | |
| 154 | }} | |
| 155 | > | |
| 156 | {props.modelOptionsByProvider[option.value].map((modelOption) => ( | |
| 157 | <MenuRadioItem | |
| 158 | key={`${option.value}:${modelOption.slug}`} | |
| 159 | value={modelOption.slug} | |
| 160 | onClick={() => setIsMenuOpen(false)} | |
| 161 | > | |
| 162 | {modelOption.name} | |
| 163 | </MenuRadioItem> | |
| 164 | ))} | |
| 165 | </MenuRadioGroup> | |
| 166 | </MenuGroup> | |
| 167 | </MenuSubPopup> | |
| 168 | </MenuSub> | |
| 169 | ); | |
| 170 | })} | |
| 171 | {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && <MenuDivider />} | |
| 172 | {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { | |
| 173 | const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; | |
| 174 | return ( | |
| 175 | <MenuItem key={option.value} disabled> | |
| 176 | <OptionIcon | |
| 177 | aria-hidden="true" | |
| 178 | className={cn( | |
| 179 | "size-4 shrink-0 opacity-80", | |
| 180 | option.value === "claudeCode" ? "" : "text-muted-foreground/85", | |
| 181 | )} | |
| 182 | /> | |
| 183 | <span>{option.label}</span> | |
| 184 | <span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]"> | |
| 185 | Coming soon | |
| 186 | </span> | |
| 187 | </MenuItem> | |
| 188 | ); | |
| 189 | })} | |
| 190 | {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && <MenuDivider />} | |
| 191 | {COMING_SOON_PROVIDER_OPTIONS.map((option) => { | |
| 192 | const OptionIcon = option.icon; | |
| 193 | return ( | |
| 194 | <MenuItem key={option.id} disabled> | |
| 195 | <OptionIcon aria-hidden="true" className="size-4 shrink-0 opacity-80" /> | |
| 196 | <span>{option.label}</span> | |
| 197 | <span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]"> | |
| 198 | Coming soon | |
| 199 | </span> | |
| 200 | </MenuItem> | |
| 201 | ); | |
| 202 | })} | |
| 203 | </MenuPopup> | |
| 204 | </Menu> | |
| 205 | ); | |
| 206 | }); |
apps/web/src/components/chat/ThreadErrorBanner.tsx created+35| ... | ... | @@ -0,0 +1,35 @@ |
| 1 | import { memo } from "react"; | |
| 2 | import { Alert, AlertAction, AlertDescription } from "../ui/alert"; | |
| 3 | import { CircleAlertIcon, XIcon } from "lucide-react"; | |
| 4 | ||
| 5 | export const ThreadErrorBanner = memo(function ThreadErrorBanner({ | |
| 6 | error, | |
| 7 | onDismiss, | |
| 8 | }: { | |
| 9 | error: string | null; | |
| 10 | onDismiss?: () => void; | |
| 11 | }) { | |
| 12 | if (!error) return null; | |
| 13 | return ( | |
| 14 | <div className="pt-3 mx-auto max-w-3xl"> | |
| 15 | <Alert variant="error"> | |
| 16 | <CircleAlertIcon /> | |
| 17 | <AlertDescription className="line-clamp-3" title={error}> | |
| 18 | {error} | |
| 19 | </AlertDescription> | |
| 20 | {onDismiss && ( | |
| 21 | <AlertAction> | |
| 22 | <button | |
| 23 | type="button" | |
| 24 | aria-label="Dismiss error" | |
| 25 | className="inline-flex size-6 items-center justify-center rounded-md text-destructive/60 transition-colors hover:text-destructive" | |
| 26 | onClick={onDismiss} | |
| 27 | > | |
| 28 | <XIcon className="size-3.5" /> | |
| 29 | </button> | |
| 30 | </AlertAction> | |
| 31 | )} | |
| 32 | </Alert> | |
| 33 | </div> | |
| 34 | ); | |
| 35 | }); |
apps/web/src/components/chat/VscodeEntryIcon.tsx created+37| ... | ... | @@ -0,0 +1,37 @@ |
| 1 | import { memo, useMemo, useState } from "react"; | |
| 2 | import { getVscodeIconUrlForEntry } from "../../vscode-icons"; | |
| 3 | import { FileIcon, FolderIcon } from "lucide-react"; | |
| 4 | import { cn } from "~/lib/utils"; | |
| 5 | ||
| 6 | export const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { | |
| 7 | pathValue: string; | |
| 8 | kind: "file" | "directory"; | |
| 9 | theme: "light" | "dark"; | |
| 10 | className?: string; | |
| 11 | }) { | |
| 12 | const [failedIconUrl, setFailedIconUrl] = useState<string | null>(null); | |
| 13 | const iconUrl = useMemo( | |
| 14 | () => getVscodeIconUrlForEntry(props.pathValue, props.kind, props.theme), | |
| 15 | [props.kind, props.pathValue, props.theme], | |
| 16 | ); | |
| 17 | const failed = failedIconUrl === iconUrl; | |
| 18 | ||
| 19 | if (failed) { | |
| 20 | return props.kind === "directory" ? ( | |
| 21 | <FolderIcon className={cn("size-4 text-muted-foreground/80", props.className)} /> | |
| 22 | ) : ( | |
| 23 | <FileIcon className={cn("size-4 text-muted-foreground/80", props.className)} /> | |
| 24 | ); | |
| 25 | } | |
| 26 | ||
| 27 | return ( | |
| 28 | <img | |
| 29 | src={iconUrl} | |
| 30 | alt="" | |
| 31 | aria-hidden="true" | |
| 32 | className={cn("size-4 shrink-0", props.className)} | |
| 33 | loading="lazy" | |
| 34 | onError={() => setFailedIconUrl(iconUrl)} | |
| 35 | /> | |
| 36 | ); | |
| 37 | }); |