authorgravatar for christianryansmith@gmail.comYmit24 <christianryansmith@gmail.com> 2026-03-11 21:21:22-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-11 19:21:22-07:00
loge3d46b687ce2537b4761ab9ae1c50db6708d3948
tree4a9167be1f47b88524c757c5ab679104f9156a36
parent8636ea0ec4163714a445cf4b11d53bbf88fd9ee7
signature Signed by PGP key B5690EEEBB952194

feat: split out components from ChatView.tsx (#860)

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 @@
1import { type ProviderKind, type ThreadId } from "@t3tools/contracts";
2import { type ChatMessage, type Thread } from "../types";
3import { randomUUID } from "~/lib/utils";
4import { getAppModelOptions } from "../appSettings";
5import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
6
7export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
8const WORKTREE_BRANCH_PREFIX = "t3code";
9
10export 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
28export 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
56export function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
57 if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) {
58 return;
59 }
60 URL.revokeObjectURL(previewUrl);
61}
62
63export 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
75export 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
88export type SendPhase = "idle" | "preparing-worktree" | "sending-turn";
89
90export interface PullRequestDialogState {
91 initialReference: string | null;
92 key: number;
93}
94
95export 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
112export 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
118export 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
134export 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 @@
11import {
22 type ApprovalRequestId,
33 DEFAULT_MODEL_BY_PROVIDER,
4 EDITORS,
54 type EditorId,
65 type KeybindingCommand,
76 type CodexReasoningEffort,
......@@ -29,34 +28,17 @@ import {
2928 normalizeModelSlug,
3029 resolveModelSlugForProvider,
3130} from "@t3tools/shared/model";
32import {
33 memo,
34 useCallback,
35 useEffect,
36 useLayoutEffect,
37 useMemo,
38 useRef,
39 useState,
40 useId,
41} from "react";
31import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4232import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4333import { useDebouncedValue } from "@tanstack/react-pacer";
4434import { useNavigate, useSearch } from "@tanstack/react-router";
45import {
46 measureElement as measureVirtualElement,
47 type VirtualItem,
48 useVirtualizer,
49} from "@tanstack/react-virtual";
5035import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
5136import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
5237import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
53
5438import { isElectron } from "../env";
5539import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
5640import {
57 type ComposerSlashCommand,
5841 type ComposerTrigger,
59 type ComposerTriggerKind,
6042 detectComposerTrigger,
6143 expandCollapsedComposerCursor,
6244 parseStandaloneComposerSlashCommand,
......@@ -70,17 +52,12 @@ import {
7052 deriveActiveWorkStartedAt,
7153 deriveActivePlanState,
7254 findLatestProposedPlan,
73 type PendingApproval,
74 type PendingUserInput,
75 type ProviderPickerKind,
76 PROVIDER_OPTIONS,
7755 deriveWorkLogEntries,
7856 hasToolActivityForTurn,
7957 isLatestTurnSettled,
8058 formatElapsed,
81 formatTimestamp,
8259} from "../session-logic";
83import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll";
60import { isScrollContainerNearBottom } from "../chat-scroll";
8461import {
8562 buildPendingUserInputAnswers,
8663 derivePendingUserInputProgress,
......@@ -89,15 +66,10 @@ import {
8966} from "../pendingUserInput";
9067import { useStore } from "../store";
9168import {
92 buildCollapsedProposedPlanPreviewMarkdown,
9369 buildPlanImplementationThreadTitle,
9470 buildPlanImplementationPrompt,
95 buildProposedPlanMarkdownFilename,
96 downloadPlanAsTextFile,
97 normalizePlanMarkdownForExport,
9871 proposedPlanTitle,
9972 resolvePlanFollowUpSubmission,
100 stripDisplayedPlanMarkdown,
10173} from "../proposedPlan";
10274import { truncateTitle } from "../truncateTitle";
10375import {
......@@ -106,92 +78,34 @@ import {
10678 DEFAULT_THREAD_TERMINAL_ID,
10779 MAX_THREAD_TERMINAL_COUNT,
10880 type ChatMessage,
109 type Thread,
110 type TurnDiffFileChange,
11181 type TurnDiffSummary,
11282} from "../types";
113import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
83import { basenameOfPath } from "../vscode-icons";
11484import { useTheme } from "../hooks/useTheme";
11585import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
116import {
117 buildTurnDiffTree,
118 summarizeTurnDiffStats,
119 type TurnDiffTreeNode,
120} from "../lib/turnDiffTree";
12186import BranchToolbar from "./BranchToolbar";
122import GitActionsControl from "./GitActionsControl";
123import {
124 isOpenFavoriteEditorShortcut,
125 resolveShortcutCommand,
126 shortcutLabelForCommand,
127} from "../keybindings";
128import ChatMarkdown from "./ChatMarkdown";
87import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
12988import PlanSidebar from "./PlanSidebar";
13089import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
131import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
13290import {
13391 BotIcon,
13492 ChevronDownIcon,
13593 ChevronLeftIcon,
13694 ChevronRightIcon,
13795 CircleAlertIcon,
138 FileIcon,
139 FolderIcon,
140 DiffIcon,
141 EllipsisIcon,
142 FolderClosedIcon,
14396 ListTodoIcon,
14497 LockIcon,
14598 LockOpenIcon,
146 Undo2Icon,
14799 XIcon,
148 CopyIcon,
149 CheckIcon,
150100} from "lucide-react";
151101import { Button } from "./ui/button";
152import { Input } from "./ui/input";
153102import { Separator } from "./ui/separator";
154import { Group, GroupSeparator } from "./ui/group";
155import {
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";
169import {
170 ClaudeAI,
171 CursorIcon,
172 Gemini,
173 Icon,
174 OpenAI,
175 OpenCodeIcon,
176 VisualStudioCode,
177 Zed,
178} from "./Icons";
179import { cn, isMacPlatform, isWindowsPlatform, randomUUID } from "~/lib/utils";
180import { Badge } from "./ui/badge";
103import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
104import { cn, randomUUID } from "~/lib/utils";
181105import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
182import { Command, CommandItem, CommandList } from "./ui/command";
183import {
184 Dialog,
185 DialogDescription,
186 DialogFooter,
187 DialogHeader,
188 DialogPanel,
189 DialogPopup,
190 DialogTitle,
191} from "./ui/dialog";
192106import { toastManager } from "./ui/toast";
193107import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
194import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
108import { type NewProjectScriptInput } from "./ProjectScriptsControl";
195109import {
196110 commandForProjectScript,
197111 nextProjectScriptId,
......@@ -199,58 +113,49 @@ import {
199113 projectScriptIdFromCommand,
200114 setupProjectScript,
201115} from "~/projectScripts";
202import { Toggle } from "./ui/toggle";
203116import { SidebarTrigger } from "./ui/sidebar";
204117import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
205118import { readNativeApi } from "~/nativeApi";
206import { getAppModelOptions, resolveAppModelSelection, useAppSettings } from "../appSettings";
119import { resolveAppModelSelection, useAppSettings } from "../appSettings";
207120import {
208121 type ComposerImageAttachment,
209122 type DraftThreadEnvMode,
210 type DraftThreadState,
211123 type PersistedComposerImageAttachment,
212124 useComposerDraftStore,
213125 useComposerThreadDraft,
214126} from "../composerDraftStore";
215127import { shouldUseCompactComposerFooter } from "./composerFooterLayout";
216128import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
217import { clamp } from "effect/Number";
218129import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
219130import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
220import { estimateTimelineMessageHeight } from "./timelineHeight";
221
222function formatMessageMeta(createdAt: string, duration: string | null): string {
223 if (!duration) return formatTimestamp(createdAt);
224 return `${formatTimestamp(createdAt)} • ${duration}`;
225}
226
227function 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}
131import { MessagesTimeline } from "./chat/MessagesTimeline";
132import { ChatHeader } from "./chat/ChatHeader";
133import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview";
134import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker";
135import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu";
136import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions";
137import { CodexTraitsPicker } from "./chat/CodexTraitsPicker";
138import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu";
139import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel";
140import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel";
141import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner";
142import { ProviderHealthBanner } from "./chat/ProviderHealthBanner";
143import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
144import {
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";
249158
250const LAST_EDITOR_KEY = "t3code:last-editor";
251const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
252const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
253const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;
254159const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;
255160const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
256161const IMAGE_ONLY_BOOTSTRAP_PROMPT =
......@@ -264,311 +169,6 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnsw
264169const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
265170const SCRIPT_TERMINAL_COLS = 120;
266171const SCRIPT_TERMINAL_ROWS = 30;
267const WORKTREE_BRANCH_PREFIX = "t3code";
268
269function 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
287function 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
294interface ExpandedImageItem {
295 src: string;
296 name: string;
297}
298
299interface ExpandedImagePreview {
300 images: ExpandedImageItem[];
301 index: number;
302}
303
304function 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
324function 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
352function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
353 if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) {
354 return;
355 }
356 URL.revokeObjectURL(previewUrl);
357}
358
359function 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
371function 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
384type 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
409type SendPhase = "idle" | "preparing-worktree" | "sending-turn";
410
411interface PullRequestDialogState {
412 initialReference: string | null;
413 key: number;
414}
415
416function 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
433function 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
439function 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
453const 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
486const 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
529const 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});
572172
573173interface ChatViewProps {
574174 threadId: ThreadId;
......@@ -1493,7 +1093,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
14931093 .clear({ threadId: activeThreadId, terminalId })
14941094 .catch(() => undefined);
14951095 }
1496 await api.terminal.close({ threadId: activeThreadId, terminalId, deleteHistory: true });
1096 await api.terminal.close({
1097 threadId: activeThreadId,
1098 terminalId,
1099 deleteHistory: true,
1100 });
14971101 })().catch(() => fallbackExitWrite());
14981102 } else {
14991103 void fallbackExitWrite();
......@@ -2335,7 +1939,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
23351939 terminalOpen: Boolean(terminalState.terminalOpen),
23361940 };
23371941
2338 const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext });
1942 const command = resolveShortcutCommand(event, keybindings, {
1943 context: shortcutContext,
1944 });
23391945 if (!command) return;
23401946
23411947 if (command === "terminal.toggle") {
......@@ -3311,7 +2917,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
33112917 [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt],
33122918 );
33132919
3314 const readComposerSnapshot = useCallback((): { value: string; cursor: number } => {
2920 const readComposerSnapshot = useCallback((): {
2921 value: string;
2922 cursor: number;
2923 } => {
33152924 const editorSnapshot = composerEditorRef.current?.readSnapshot();
33162925 if (editorSnapshot) {
33172926 return editorSnapshot;
......@@ -4252,1901 +3861,3 @@ export default function ChatView({ threadId }: ChatViewProps) {
42523861 </div>
42533862 );
42543863}
4255
4256interface 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
4276const 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
4365const 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
4397const 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
4426interface ComposerPendingApprovalPanelProps {
4427 approval: PendingApproval;
4428 pendingCount: number;
4429}
4430
4431const 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
4455interface ComposerPendingApprovalActionsProps {
4456 requestId: ApprovalRequestId;
4457 isResponding: boolean;
4458 onRespondToApproval: (
4459 requestId: ApprovalRequestId,
4460 decision: ProviderApprovalDecision,
4461 ) => Promise<void>;
4462}
4463
4464const 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
4507interface 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
4516const 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
4541const 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
4680const 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
4700const 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
4716function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean {
4717 return stat.additions > 0 || stat.deletions > 0;
4718}
4719
4720const 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
4737function 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
4747function 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
4758const 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
4865const 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
5049interface 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
5072type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
5073type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];
5074type TimelineProposedPlan = Extract<TimelineEntry, { kind: "proposed-plan" }>["proposedPlan"];
5075type TimelineWorkEntry = Extract<TimelineEntry, { kind: "work" }>["entry"];
5076type 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
5098function 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
5103const 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
5646function 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
5654const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption);
5655const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available);
5656const COMING_SOON_PROVIDER_OPTIONS = [
5657 { id: "opencode", label: "OpenCode", icon: OpenCodeIcon },
5658 { id: "gemini", label: "Gemini", icon: Gemini },
5659] as const;
5660
5661function 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
5669const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = {
5670 codex: OpenAI,
5671 claudeCode: ClaudeAI,
5672 cursor: CursorIcon,
5673};
5674
5675function 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
5708const 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
5839const 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
5955const 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
6034const 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 @@
1import { type TurnId } from "@t3tools/contracts";
2import { memo, useCallback, useEffect, useMemo, useState } from "react";
3import { type TurnDiffFileChange } from "../../types";
4import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree";
5import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react";
6import { cn } from "~/lib/utils";
7import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
8import { VscodeEntryIcon } from "./VscodeEntryIcon";
9
10export 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
117function 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
127function 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 @@
1import {
2 type EditorId,
3 type ProjectScript,
4 type ResolvedKeybindingsConfig,
5 type ThreadId,
6} from "@t3tools/contracts";
7import { memo } from "react";
8import GitActionsControl from "../GitActionsControl";
9import { DiffIcon } from "lucide-react";
10import { Badge } from "../ui/badge";
11import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
12import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl";
13import { Toggle } from "../ui/toggle";
14import { SidebarTrigger } from "../ui/sidebar";
15import { OpenInPicker } from "./OpenInPicker";
16
17interface 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
37export 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 @@
1import { type CodexReasoningEffort } from "@t3tools/contracts";
2import { getDefaultReasoningEffort } from "@t3tools/shared/model";
3import { memo, useState } from "react";
4import { ChevronDownIcon } from "lucide-react";
5import { Button } from "../ui/button";
6import {
7 Menu,
8 MenuGroup,
9 MenuPopup,
10 MenuRadioGroup,
11 MenuRadioItem,
12 MenuSeparator as MenuDivider,
13 MenuTrigger,
14} from "../ui/menu";
15
16export 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 @@
1import {
2 type CodexReasoningEffort,
3 type ProviderKind,
4 RuntimeMode,
5 ProviderInteractionMode,
6} from "@t3tools/contracts";
7import { getDefaultReasoningEffort } from "@t3tools/shared/model";
8import { memo } from "react";
9import { EllipsisIcon, ListTodoIcon } from "lucide-react";
10import { Button } from "../ui/button";
11import {
12 Menu,
13 MenuGroup,
14 MenuItem,
15 MenuPopup,
16 MenuRadioGroup,
17 MenuRadioItem,
18 MenuSeparator as MenuDivider,
19 MenuTrigger,
20} from "../ui/menu";
21
22export 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 @@
1import { type ProjectEntry, type ModelSlug, type ProviderKind } from "@t3tools/contracts";
2import { memo } from "react";
3import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";
4import { BotIcon } from "lucide-react";
5import { cn } from "~/lib/utils";
6import { Badge } from "../ui/badge";
7import { Command, CommandItem, CommandList } from "../ui/command";
8import { VscodeEntryIcon } from "./VscodeEntryIcon";
9
10export 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
35export 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
79const 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 @@
1import { type ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts";
2import { memo } from "react";
3import { Button } from "../ui/button";
4
5interface ComposerPendingApprovalActionsProps {
6 requestId: ApprovalRequestId;
7 isResponding: boolean;
8 onRespondToApproval: (
9 requestId: ApprovalRequestId,
10 decision: ProviderApprovalDecision,
11 ) => Promise<void>;
12}
13
14export 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 @@
1import { memo } from "react";
2import { type PendingApproval } from "../../session-logic";
3
4interface ComposerPendingApprovalPanelProps {
5 approval: PendingApproval;
6 pendingCount: number;
7}
8
9export 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 @@
1import { type ApprovalRequestId } from "@t3tools/contracts";
2import { memo, useCallback, useEffect, useRef } from "react";
3import { type PendingUserInput } from "../../session-logic";
4import {
5 derivePendingUserInputProgress,
6 type PendingUserInputDraftAnswer,
7} from "../../pendingUserInput";
8import { CheckIcon } from "lucide-react";
9import { cn } from "~/lib/utils";
10
11interface 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
20export 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
45const 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 @@
1import { memo } from "react";
2
3export 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 @@
1import { memo } from "react";
2
3export function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean {
4 return stat.additions > 0 || stat.deletions > 0;
5}
6
7export 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 @@
1export interface ExpandedImageItem {
2 src: string;
3 name: string;
4}
5
6export interface ExpandedImagePreview {
7 images: ExpandedImageItem[];
8 index: number;
9}
10
11export 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 @@
1import { memo, useCallback, useState } from "react";
2import { CopyIcon, CheckIcon } from "lucide-react";
3import { Button } from "../ui/button";
4
5export 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 @@
1import { type MessageId, type TurnId } from "@t3tools/contracts";
2import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3import {
4 measureElement as measureVirtualElement,
5 type VirtualItem,
6 useVirtualizer,
7} from "@tanstack/react-virtual";
8import { deriveTimelineEntries, formatElapsed, formatTimestamp } from "../../session-logic";
9import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll";
10import { type TurnDiffSummary } from "../../types";
11import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
12import ChatMarkdown from "../ChatMarkdown";
13import { Undo2Icon } from "lucide-react";
14import { Button } from "../ui/button";
15import { clamp } from "effect/Number";
16import { estimateTimelineMessageHeight } from "../timelineHeight";
17import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
18import { ProposedPlanCard } from "./ProposedPlanCard";
19import { ChangedFilesTree } from "./ChangedFilesTree";
20import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
21import { MessageCopyButton } from "./MessageCopyButton";
22
23const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
24const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;
25
26interface 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
49export 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
592type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
593type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];
594type TimelineProposedPlan = Extract<TimelineEntry, { kind: "proposed-plan" }>["proposedPlan"];
595type TimelineWorkEntry = Extract<TimelineEntry, { kind: "work" }>["entry"];
596type 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
618function 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
623function 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
646function formatMessageMeta(createdAt: string, duration: string | null): string {
647 if (!duration) return formatTimestamp(createdAt);
648 return `${formatTimestamp(createdAt)} • ${duration}`;
649}
650
651function 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 @@
1import { EDITORS, type EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts";
2import { memo, useCallback, useEffect, useMemo, useState } from "react";
3import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings";
4import { ChevronDownIcon, FolderClosedIcon } from "lucide-react";
5import { Button } from "../ui/button";
6import { Group, GroupSeparator } from "../ui/group";
7import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu";
8import { CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons";
9import { isMacPlatform, isWindowsPlatform } from "~/lib/utils";
10import { readNativeApi } from "~/nativeApi";
11
12const LAST_EDITOR_KEY = "t3code:last-editor";
13
14export 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 @@
1import { memo, useState, useId } from "react";
2import {
3 buildCollapsedProposedPlanPreviewMarkdown,
4 buildProposedPlanMarkdownFilename,
5 downloadPlanAsTextFile,
6 normalizePlanMarkdownForExport,
7 proposedPlanTitle,
8 stripDisplayedPlanMarkdown,
9} from "../../proposedPlan";
10import ChatMarkdown from "../ChatMarkdown";
11import { EllipsisIcon } from "lucide-react";
12import { Button } from "../ui/button";
13import { Input } from "../ui/input";
14import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
15import { cn } from "~/lib/utils";
16import { Badge } from "../ui/badge";
17import {
18 Dialog,
19 DialogDescription,
20 DialogFooter,
21 DialogHeader,
22 DialogPanel,
23 DialogPopup,
24 DialogTitle,
25} from "../ui/dialog";
26import { toastManager } from "../ui/toast";
27import { readNativeApi } from "~/nativeApi";
28
29export 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 @@
1import { type ServerProviderStatus } from "@t3tools/contracts";
2import { memo } from "react";
3import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
4import { CircleAlertIcon } from "lucide-react";
5
6export 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 @@
1import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";
2import { normalizeModelSlug } from "@t3tools/shared/model";
3import { memo, useState } from "react";
4import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic";
5import { ChevronDownIcon } from "lucide-react";
6import { Button } from "../ui/button";
7import {
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";
20import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons";
21import { cn } from "~/lib/utils";
22
23function 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
31function 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
64const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = {
65 codex: OpenAI,
66 claudeCode: ClaudeAI,
67 cursor: CursorIcon,
68};
69
70export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption);
71const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available);
72const COMING_SOON_PROVIDER_OPTIONS = [
73 { id: "opencode", label: "OpenCode", icon: OpenCodeIcon },
74 { id: "gemini", label: "Gemini", icon: Gemini },
75] as const;
76
77export 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 @@
1import { memo } from "react";
2import { Alert, AlertAction, AlertDescription } from "../ui/alert";
3import { CircleAlertIcon, XIcon } from "lucide-react";
4
5export 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 @@
1import { memo, useMemo, useState } from "react";
2import { getVscodeIconUrlForEntry } from "../../vscode-icons";
3import { FileIcon, FolderIcon } from "lucide-react";
4import { cn } from "~/lib/utils";
5
6export 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});