diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a96b17bdb7..259f055ef0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -92,6 +92,8 @@ import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, buildProposedPlanMarkdownFilename, + downloadPlanAsTextFile, + normalizePlanMarkdownForExport, proposedPlanTitle, resolvePlanFollowUpSubmission, } from "../proposedPlan"; @@ -122,6 +124,7 @@ import { shortcutLabelForCommand, } from "../keybindings"; import ChatMarkdown from "./ChatMarkdown"; +import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { @@ -135,6 +138,7 @@ import { DiffIcon, EllipsisIcon, FolderClosedIcon, + ListTodoIcon, LockIcon, LockOpenIcon, Undo2Icon, @@ -291,21 +295,7 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { return "text-muted-foreground/40"; } -function normalizePlanMarkdownForExport(planMarkdown: string): string { - return `${planMarkdown.trimEnd()}\n`; -} -function downloadTextFile(filename: string, contents: string): void { - const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = filename; - anchor.click(); - window.setTimeout(() => { - URL.revokeObjectURL(url); - }, 0); -} interface ExpandedImageItem { src: string; @@ -656,6 +646,12 @@ export default function ChatView({ threadId }: ChatViewProps) { const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); + const [planSidebarOpen, setPlanSidebarOpen] = useState(false); + // Tracks whether the user explicitly dismissed the sidebar for the active turn. + const planSidebarDismissedForTurnRef = useRef(null); + // When set, the thread-change reset effect will open the sidebar instead of closing it. + // Used by "Implement in new thread" to carry the sidebar-open intent across navigation. + const planSidebarOpenOnNextThreadRef = useRef(false); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); @@ -1941,8 +1937,17 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { setExpandedWorkGroups({}); + if (planSidebarOpenOnNextThreadRef.current) { + planSidebarOpenOnNextThreadRef.current = false; + setPlanSidebarOpen(true); + } else { + setPlanSidebarOpen(false); + } + planSidebarDismissedForTurnRef.current = null; }, [activeThread?.id]); + + useEffect(() => { if (!composerMenuOpen) { setComposerHighlightedItemId(null); @@ -2955,6 +2960,13 @@ export default function ChatView({ threadId }: ChatViewProps) { interactionMode: nextInteractionMode, createdAt: messageCreatedAt, }); + // Optimistically open the plan sidebar when implementing (not refining). + // "default" mode here means the agent is executing the plan, which produces + // step-tracking activities that the sidebar will display. + if (nextInteractionMode === "default") { + planSidebarDismissedForTurnRef.current = null; + setPlanSidebarOpen(true); + } sendInFlightRef.current = false; } catch (err) { setOptimisticUserMessages((existing) => @@ -3063,6 +3075,8 @@ export default function ChatView({ threadId }: ChatViewProps) { .then(() => api.orchestration.getSnapshot()) .then((snapshot) => { syncServerReadModel(snapshot); + // Signal that the plan sidebar should open on the new thread. + planSidebarOpenOnNextThreadRef.current = true; return navigate({ to: "/$threadId", params: { threadId: nextThreadId }, @@ -3469,7 +3483,10 @@ export default function ChatView({ threadId }: ChatViewProps) { error={activeThread.error} onDismiss={() => setThreadError(activeThread.id, null)} /> - + {/* Main content area with optional plan sidebar */} +
+ {/* Chat column */} +
{/* Messages */}
) : showPlanFollowUpPrompt && activeProposedPlan ? ( @@ -3749,6 +3767,43 @@ export default function ChatView({ threadId }: ChatViewProps) { {runtimeMode === "full-access" ? "Full access" : "Supervised"} + + {/* Plan sidebar toggle */} + {(activePlan || activeProposedPlan || planSidebarOpen) ? ( + <> + + + + ) : null}
{/* Right side: send / stop button */} @@ -3915,6 +3970,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
+ {/* end chat column */} + + {/* Plan sidebar */} + {planSidebarOpen ? ( + { + setPlanSidebarOpen(false); + // Track that the user explicitly dismissed for this turn so auto-open won't fight them. + const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; + if (turnKey) { + planSidebarDismissedForTurnRef.current = turnKey; + } + }} + /> + ) : null} + {/* end horizontal flex container */} + {isGitRepo && ( ; -} - -const PlanModePanel = memo(function PlanModePanel({ activePlan }: PlanModePanelProps) { - if (!activePlan) return null; - - return ( -
-
-
- Plan - - Updated {formatTimestamp(activePlan.createdAt)} - -
- {activePlan.explanation ? ( -

{activePlan.explanation}

- ) : null} -
- {activePlan.steps.map((step) => ( -
- - {step.status === "inProgress" - ? "In progress" - : step.status === "completed" - ? "Done" - : "Pending"} - -
{step.step}
-
- ))} -
-
-
- ); -}); - interface PendingUserInputPanelProps { pendingUserInputs: PendingUserInput[]; respondingRequestIds: ApprovalRequestId[]; answers: Record; questionIndex: number; onSelectOption: (questionId: string, optionLabel: string) => void; + onAdvance: () => void; } const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ @@ -4337,6 +4365,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane answers, questionIndex, onSelectOption, + onAdvance, }: PendingUserInputPanelProps) { if (pendingUserInputs.length === 0) return null; const activePrompt = pendingUserInputs[0]; @@ -4350,6 +4379,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane answers={answers} questionIndex={questionIndex} onSelectOption={onSelectOption} + onAdvance={onAdvance} /> ); }); @@ -4360,42 +4390,135 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( answers, questionIndex, onSelectOption, + onAdvance, }: { prompt: PendingUserInput; isResponding: boolean; answers: Record; questionIndex: number; onSelectOption: (questionId: string, optionLabel: string) => void; + onAdvance: () => void; }) { const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); const activeQuestion = progress.activeQuestion; + const autoAdvanceTimerRef = useRef(null); + + // Clear auto-advance timer on unmount + useEffect(() => { + return () => { + if (autoAdvanceTimerRef.current !== null) { + window.clearTimeout(autoAdvanceTimerRef.current); + } + }; + }, []); + + const selectOptionAndAutoAdvance = useCallback( + (questionId: string, optionLabel: string) => { + onSelectOption(questionId, optionLabel); + if (autoAdvanceTimerRef.current !== null) { + window.clearTimeout(autoAdvanceTimerRef.current); + } + autoAdvanceTimerRef.current = window.setTimeout(() => { + autoAdvanceTimerRef.current = null; + onAdvance(); + }, 200); + }, + [onSelectOption, onAdvance], + ); + + // Keyboard shortcut: number keys 1-9 select corresponding option and auto-advance. + // Works even when the Lexical composer (contenteditable) has focus — the composer + // doubles as a custom-answer field during user input, and when it's empty the digit + // keys should pick options instead of typing into the editor. + useEffect(() => { + if (!activeQuestion || isResponding) return; + const handler = (event: globalThis.KeyboardEvent) => { + if (event.metaKey || event.ctrlKey || event.altKey) return; + const target = event.target; + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement + ) { + return; + } + // If the user has started typing a custom answer in the contenteditable + // composer, let digit keys pass through so they can type numbers. + if (target instanceof HTMLElement && target.isContentEditable) { + const hasCustomText = progress.customAnswer.length > 0; + if (hasCustomText) return; + } + const digit = Number.parseInt(event.key, 10); + if (Number.isNaN(digit) || digit < 1 || digit > 9) return; + const optionIndex = digit - 1; + if (optionIndex >= activeQuestion.options.length) return; + const option = activeQuestion.options[optionIndex]; + if (!option) return; + event.preventDefault(); + selectOptionAndAutoAdvance(activeQuestion.id, option.label); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [activeQuestion, isResponding, selectOptionAndAutoAdvance, progress.customAnswer.length]); if (!activeQuestion) { return null; } return ( -
-
- - {questionIndex + 1}/{prompt.questions.length} {activeQuestion.header} - -
{activeQuestion.question}
+
+
+
+ {prompt.questions.length > 1 ? ( + + {questionIndex + 1}/{prompt.questions.length} + + ) : null} + + {activeQuestion.header} + +
-
- {activeQuestion.options.map((option) => { +

{activeQuestion.question}

+
+ {activeQuestion.options.map((option, index) => { const isSelected = progress.selectedOptionLabel === option.label; + const shortcutKey = index < 9 ? index + 1 : null; return ( - + {shortcutKey !== null ? ( + + {shortcutKey} + + ) : null} +
+ {option.label} + {option.description && option.description !== option.label ? ( + {option.description} + ) : null} +
+ {isSelected ? ( + + ) : null} + ); })}
@@ -4609,7 +4732,7 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ const saveContents = normalizePlanMarkdownForExport(planMarkdown); const handleDownload = () => { - downloadTextFile(downloadFilename, saveContents); + downloadPlanAsTextFile(downloadFilename, saveContents); }; const openSaveDialog = () => { diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx new file mode 100644 index 0000000000..5ab2f5e32c --- /dev/null +++ b/apps/web/src/components/PlanSidebar.tsx @@ -0,0 +1,267 @@ +import { memo, useState, useCallback } from "react"; +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; +import { ScrollArea } from "./ui/scroll-area"; +import ChatMarkdown from "./ChatMarkdown"; +import { + CheckIcon, + ChevronDownIcon, + ChevronRightIcon, + EllipsisIcon, + LoaderIcon, + PanelRightCloseIcon, +} from "lucide-react"; +import { cn } from "~/lib/utils"; +import { formatTimestamp } from "../session-logic"; +import type { ActivePlanState } from "../session-logic"; +import type { LatestProposedPlanState } from "../session-logic"; +import { + proposedPlanTitle, + buildProposedPlanMarkdownFilename, + normalizePlanMarkdownForExport, + downloadPlanAsTextFile, +} from "../proposedPlan"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; +import { readNativeApi } from "~/nativeApi"; +import { toastManager } from "./ui/toast"; + +function stepStatusIcon(status: string): React.ReactNode { + if (status === "completed") { + return ( + + + + ); + } + if (status === "inProgress") { + return ( + + + + ); + } + return ( + + + + ); +} + +interface PlanSidebarProps { + activePlan: ActivePlanState | null; + activeProposedPlan: LatestProposedPlanState | null; + markdownCwd: string | undefined; + workspaceRoot: string | undefined; + onClose: () => void; +} + +const PlanSidebar = memo(function PlanSidebar({ + activePlan, + activeProposedPlan, + markdownCwd, + workspaceRoot, + onClose, +}: PlanSidebarProps) { + const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); + const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); + const [copied, setCopied] = useState(false); + + const planMarkdown = activeProposedPlan?.planMarkdown ?? null; + const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null; + + const handleCopyPlan = useCallback(() => { + if (!planMarkdown) return; + void navigator.clipboard.writeText(planMarkdown); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [planMarkdown]); + + const handleDownload = useCallback(() => { + if (!planMarkdown) return; + const filename = buildProposedPlanMarkdownFilename(planMarkdown); + downloadPlanAsTextFile(filename, normalizePlanMarkdownForExport(planMarkdown)); + }, [planMarkdown]); + + const handleSaveToWorkspace = useCallback(() => { + const api = readNativeApi(); + if (!api || !workspaceRoot || !planMarkdown) return; + const filename = buildProposedPlanMarkdownFilename(planMarkdown); + setIsSavingToWorkspace(true); + void api.projects + .writeFile({ + cwd: workspaceRoot, + relativePath: filename, + contents: normalizePlanMarkdownForExport(planMarkdown), + }) + .then((result) => { + toastManager.add({ + type: "success", + title: "Plan saved", + description: result.relativePath, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not save plan", + description: + error instanceof Error ? error.message : "An error occurred.", + }); + }) + .then( + () => setIsSavingToWorkspace(false), + () => setIsSavingToWorkspace(false), + ); + }, [planMarkdown, workspaceRoot]); + + return ( +
+ {/* Header */} +
+
+ + Plan + + {activePlan ? ( + + {formatTimestamp(activePlan.createdAt)} + + ) : null} +
+
+ {planMarkdown ? ( + + + } + > + + + + + {copied ? "Copied!" : "Copy to clipboard"} + + Download as markdown + + Save to workspace + + + + ) : null} + +
+
+ + {/* Content */} + +
+ {/* Explanation */} + {activePlan?.explanation ? ( +

+ {activePlan.explanation} +

+ ) : null} + + {/* Plan Steps */} + {activePlan && activePlan.steps.length > 0 ? ( +
+

+ Steps +

+ {activePlan.steps.map((step) => ( +
+
+ {stepStatusIcon(step.status)} +
+

+ {step.step} +

+
+ ))} +
+ ) : null} + + {/* Proposed Plan Markdown */} + {planMarkdown ? ( +
+ + {proposedPlanExpanded ? ( +
+ +
+ ) : null} +
+ ) : null} + + {/* Empty state */} + {!activePlan && !planMarkdown ? ( +
+

+ No active plan yet. +

+

+ Plans will appear here when generated. +

+
+ ) : null} +
+
+
+ ); +}); + +export default PlanSidebar; +export type { PlanSidebarProps }; diff --git a/apps/web/src/proposedPlan.ts b/apps/web/src/proposedPlan.ts index 1550eb7de1..3bd4f62e60 100644 --- a/apps/web/src/proposedPlan.ts +++ b/apps/web/src/proposedPlan.ts @@ -49,3 +49,19 @@ export function buildProposedPlanMarkdownFilename(planMarkdown: string): string const title = proposedPlanTitle(planMarkdown); return `${sanitizePlanFileSegment(title ?? "plan")}.md`; } + +export function normalizePlanMarkdownForExport(planMarkdown: string): string { + return `${planMarkdown.trimEnd()}\n`; +} + +export function downloadPlanAsTextFile(filename: string, contents: string): void { + const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + window.setTimeout(() => { + URL.revokeObjectURL(url); + }, 0); +}