diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9f625762cf..347b17a733 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -691,7 +691,6 @@ export default function ChatView({ threadId }: ChatViewProps) { activePendingUserInput?.requestId, activePendingProgress?.activeQuestion?.id, ]); - useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); const clearAttachmentPreviewHandoffs = useCallback(() => { @@ -2567,9 +2566,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }, }, })); - promptRef.current = ""; - setComposerCursor(0); - setComposerTrigger(null); }, [activePendingUserInput], ); @@ -2585,7 +2581,6 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activePendingUserInput) { return; } - promptRef.current = value; setPendingUserInputAnswersByRequestId((existing) => ({ ...existing, [activePendingUserInput.requestId]: { @@ -2960,7 +2955,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); return true; }, - [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt], + [setPrompt], ); const readComposerSnapshot = useCallback((): { @@ -3111,12 +3106,7 @@ export default function ChatView({ threadId }: ChatViewProps) { cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), ); }, - [ - activePendingProgress?.activeQuestion, - activePendingUserInput, - onChangeActivePendingUserInputCustomAnswer, - setPrompt, - ], + [setPrompt], ); const onComposerCommandKey = ( @@ -3332,6 +3322,7 @@ export default function ChatView({ threadId }: ChatViewProps) { answers={activePendingDraftAnswers} questionIndex={activePendingQuestionIndex} onSelectOption={onSelectActivePendingUserInputOption} + onChangeCustomAnswer={onChangeActivePendingUserInputCustomAnswer} onAdvance={onAdvanceActivePendingUserInput} /> @@ -3436,13 +3427,7 @@ export default function ChatView({ threadId }: ChatViewProps) { )} ); } +<<<<<<< HEAD +======= + +interface ChatHeaderProps { + activeThreadId: ThreadId; + activeThreadTitle: string; + activeProjectName: string | undefined; + isGitRepo: boolean; + openInCwd: string | null; + activeProjectScripts: ProjectScript[] | undefined; + preferredScriptId: string | null; + keybindings: ResolvedKeybindingsConfig; + availableEditors: ReadonlyArray; + diffToggleShortcutLabel: string | null; + gitCwd: string | null; + diffOpen: boolean; + onRunProjectScript: (script: ProjectScript) => void; + onAddProjectScript: (input: NewProjectScriptInput) => Promise; + onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; + onDeleteProjectScript: (scriptId: string) => Promise; + onToggleDiff: () => void; +} + +const ChatHeader = memo(function ChatHeader({ + activeThreadId, + activeThreadTitle, + activeProjectName, + isGitRepo, + openInCwd, + activeProjectScripts, + preferredScriptId, + keybindings, + availableEditors, + diffToggleShortcutLabel, + gitCwd, + diffOpen, + onRunProjectScript, + onAddProjectScript, + onUpdateProjectScript, + onDeleteProjectScript, + onToggleDiff, +}: ChatHeaderProps) { + return ( +
+
+ +

+ {activeThreadTitle} +

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

{activeQuestion.question}

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

{title}

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

+ {groupLabel} +

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

+ {workEntry.label} +

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

+ {workEntry.detail} +

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

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

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

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

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

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

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

+ Send a message to start the conversation. +

+
+ ); + } + + return ( +
+ {virtualizedRowCount > 0 && ( +
+ {virtualRows.map((virtualRow: VirtualItem) => { + const row = rows[virtualRow.index]; + if (!row) return null; + + return ( +
+ {renderRowContent(row)} +
+ ); + })} +
+ )} + + {nonVirtualizedRows.map((row) => ( +
{renderRowContent(row)}
+ ))} +
+ ); +}); + +function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { + value: ProviderKind; + label: string; + available: true; +} { + return option.available && option.value !== "claudeCode"; +} + +const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); +const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); +const COMING_SOON_PROVIDER_OPTIONS = [ + { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, + { id: "gemini", label: "Gemini", icon: Gemini }, +] as const; + +function getCustomModelOptionsByProvider(settings: { + customCodexModels: readonly string[]; +}): Record> { + return { + codex: getAppModelOptions("codex", settings.customCodexModels), + }; +} + +const PROVIDER_ICON_BY_PROVIDER: Record = { + codex: OpenAI, + claudeCode: ClaudeAI, + cursor: CursorIcon, +}; + +function resolveModelForProviderPicker( + provider: ProviderKind, + value: string, + options: ReadonlyArray<{ slug: string; name: string }>, +): ModelSlug | null { + const trimmedValue = value.trim(); + if (!trimmedValue) { + return null; + } + + const direct = options.find((option) => option.slug === trimmedValue); + if (direct) { + return direct.slug; + } + + const byName = options.find((option) => option.name.toLowerCase() === trimmedValue.toLowerCase()); + if (byName) { + return byName.slug; + } + + const normalized = normalizeModelSlug(trimmedValue, provider); + if (!normalized) { + return null; + } + + const resolved = options.find((option) => option.slug === normalized); + if (resolved) { + return resolved.slug; + } + + return null; +} + +const ProviderModelPicker = memo(function ProviderModelPicker(props: { + provider: ProviderKind; + model: ModelSlug; + lockedProvider: ProviderKind | null; + modelOptionsByProvider: Record>; + compact?: boolean; + disabled?: boolean; + onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const selectedProviderOptions = props.modelOptionsByProvider[props.provider]; + const selectedModelLabel = + selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; + const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; + + return ( + { + if (props.disabled) { + setIsMenuOpen(false); + return; + } + setIsMenuOpen(open); + }} + > + + } + > + + + + + {AVAILABLE_PROVIDER_OPTIONS.map((option) => { + const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; + const isDisabledByProviderLock = + props.lockedProvider !== null && props.lockedProvider !== option.value; + return ( + + + + + + { + if (props.disabled) return; + if (isDisabledByProviderLock) return; + if (!value) return; + const resolvedModel = resolveModelForProviderPicker( + option.value, + value, + props.modelOptionsByProvider[option.value], + ); + if (!resolvedModel) return; + props.onProviderModelChange(option.value, resolvedModel); + setIsMenuOpen(false); + }} + > + {props.modelOptionsByProvider[option.value].map((modelOption) => ( + setIsMenuOpen(false)} + > + {modelOption.name} + + ))} + + + + + ); + })} + {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && } + {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { + const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; + return ( + + + ); + })} + {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && } + {COMING_SOON_PROVIDER_OPTIONS.map((option) => { + const OptionIcon = option.icon; + return ( + + + ); + })} + + + ); +}); + +const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { + activePlan: boolean; + interactionMode: ProviderInteractionMode; + planSidebarOpen: boolean; + runtimeMode: RuntimeMode; + selectedEffort: CodexReasoningEffort | null; + selectedProvider: ProviderKind; + selectedCodexFastModeEnabled: boolean; + reasoningOptions: ReadonlyArray; + onEffortSelect: (effort: CodexReasoningEffort) => void; + onCodexFastModeChange: (enabled: boolean) => void; + onToggleInteractionMode: () => void; + onTogglePlanSidebar: () => void; + onToggleRuntimeMode: () => void; +}) { + const defaultReasoningEffort = getDefaultReasoningEffort("codex"); + const reasoningLabelByOption: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + }; + + return ( + + + } + > + + + {props.selectedProvider === "codex" && props.selectedEffort != null ? ( + <> + +
Reasoning
+ { + if (!value) return; + const nextEffort = props.reasoningOptions.find((option) => option === value); + if (!nextEffort) return; + props.onEffortSelect(nextEffort); + }} + > + {props.reasoningOptions.map((effort) => ( + + {reasoningLabelByOption[effort]} + {effort === defaultReasoningEffort ? " (default)" : ""} + + ))} + +
+ + +
Fast Mode
+ { + props.onCodexFastModeChange(value === "on"); + }} + > + off + on + +
+ + + ) : null} + +
Mode
+ { + if (!value || value === props.interactionMode) return; + props.onToggleInteractionMode(); + }} + > + Chat + Plan + +
+ + +
Access
+ { + if (!value || value === props.runtimeMode) return; + props.onToggleRuntimeMode(); + }} + > + Supervised + Full access + +
+ {props.activePlan ? ( + <> + + + + {props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"} + + + ) : null} +
+
+ ); +}); + +const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { + effort: CodexReasoningEffort; + fastModeEnabled: boolean; + options: ReadonlyArray; + onEffortChange: (effort: CodexReasoningEffort) => void; + onFastModeChange: (enabled: boolean) => void; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const defaultReasoningEffort = getDefaultReasoningEffort("codex"); + const reasoningLabelByOption: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + }; + const triggerLabel = [ + reasoningLabelByOption[props.effort], + ...(props.fastModeEnabled ? ["Fast"] : []), + ] + .filter(Boolean) + .join(" · "); + + return ( + { + setIsMenuOpen(open); + }} + > + + } + > + {triggerLabel} + + + +
Reasoning
+ { + if (!value) return; + const nextEffort = props.options.find((option) => option === value); + if (!nextEffort) return; + props.onEffortChange(nextEffort); + }} + > + {props.options.map((effort) => ( + + {reasoningLabelByOption[effort]} + {effort === defaultReasoningEffort ? " (default)" : ""} + + ))} + +
+ + +
Fast Mode
+ { + props.onFastModeChange(value === "on"); + }} + > + off + on + +
+
+
+ ); +}); + +const OpenInPicker = memo(function OpenInPicker({ + keybindings, + availableEditors, + openInCwd, +}: { + keybindings: ResolvedKeybindingsConfig; + availableEditors: ReadonlyArray; + openInCwd: string | null; +}) { + const [lastEditor, setLastEditor] = useState(() => { + const stored = localStorage.getItem(LAST_EDITOR_KEY); + return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; + }); + + const allOptions = useMemo>( + () => [ + { + label: "Cursor", + Icon: CursorIcon, + value: "cursor", + }, + { + label: "VS Code", + Icon: VisualStudioCode, + value: "vscode", + }, + { + label: "Zed", + Icon: Zed, + value: "zed", + }, + { + label: isMacPlatform(navigator.platform) + ? "Finder" + : isWindowsPlatform(navigator.platform) + ? "Explorer" + : "Files", + Icon: FolderClosedIcon, + value: "file-manager", + }, + ], + [], + ); + const options = useMemo( + () => allOptions.filter((option) => availableEditors.includes(option.value)), + [allOptions, availableEditors], + ); + + const effectiveEditor = options.some((option) => option.value === lastEditor) + ? lastEditor + : (options[0]?.value ?? null); + const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; + + const openInEditor = useCallback( + (editorId: EditorId | null) => { + const api = readNativeApi(); + if (!api || !openInCwd) return; + const editor = editorId ?? effectiveEditor; + if (!editor) return; + void api.shell.openInEditor(openInCwd, editor); + localStorage.setItem(LAST_EDITOR_KEY, editor); + setLastEditor(editor); + }, + [effectiveEditor, openInCwd, setLastEditor], + ); + + const openFavoriteEditorShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "editor.openFavorite"), + [keybindings], + ); + + useEffect(() => { + const handler = (e: globalThis.KeyboardEvent) => { + const api = readNativeApi(); + if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; + if (!api || !openInCwd) return; + if (!effectiveEditor) return; + + e.preventDefault(); + void api.shell.openInEditor(openInCwd, effectiveEditor); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [effectiveEditor, keybindings, openInCwd]); + + return ( + + + + + }> + + + {options.length === 0 && No installed editors found} + {options.map(({ label, Icon, value }) => ( + openInEditor(value)}> + + ))} + + + + ); +}); +>>>>>>> 3a8dc31 (fix(web): preserve composer draft during planning prompts)