History

fix: plan mode ui overhaul (#596)

* new plan mode started * review comments * more fixes

Theo Browne committed 8 months ago

Commit 42234b81 parent 39f65703 files · +486 −80Browse files

Unified · Split

3 files
  1. apps/web/src/components/ChatView.tsx +203 −80
  2. apps/web/src/components/PlanSidebar.tsx +267 −0
  3. apps/web/src/proposedPlan.ts +16 −0
apps/web/src/components/ChatView.tsx+203−80
@@ -92,6 +92,8 @@ import { · 91 unmodified lines
9292 buildPlanImplementationThreadTitle,
9393 buildPlanImplementationPrompt,
9494 buildProposedPlanMarkdownFilename,
95+ downloadPlanAsTextFile,
96+ normalizePlanMarkdownForExport,
9597 proposedPlanTitle,
9698 resolvePlanFollowUpSubmission,
9799 } from "../proposedPlan";
@@ -122,6 +124,7 @@ import { · 24 unmodified lines
122124 shortcutLabelForCommand,
123125 } from "../keybindings";
124126 import ChatMarkdown from "./ChatMarkdown";
127+import PlanSidebar from "./PlanSidebar";
125128 import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
126129 import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
127130 import {
@@ -135,6 +138,7 @@ import { · 7 unmodified lines
135138 DiffIcon,
136139 EllipsisIcon,
137140 FolderClosedIcon,
141+ ListTodoIcon,
138142 LockIcon,
139143 LockOpenIcon,
140144 Undo2Icon,
@@ -291,21 +295,7 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { · 150 unmodified lines
291295 return "text-muted-foreground/40";
292296 }
293297
294−function normalizePlanMarkdownForExport(planMarkdown: string): string {
295− return `${planMarkdown.trimEnd()}\n`;
296−}
297298
298−function downloadTextFile(filename: string, contents: string): void {
299− const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" });
300− const url = URL.createObjectURL(blob);
301− const anchor = document.createElement("a");
302− anchor.href = url;
303− anchor.download = filename;
304− anchor.click();
305− window.setTimeout(() => {
306− URL.revokeObjectURL(url);
307− }, 0);
308−}
309299
310300 interface ExpandedImageItem {
311301 src: string;
@@ -656,6 +646,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 344 unmodified lines
656646 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
657647 useState<Record<string, number>>({});
658648 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
649+ const [planSidebarOpen, setPlanSidebarOpen] = useState(false);
650+ // Tracks whether the user explicitly dismissed the sidebar for the active turn.
651+ const planSidebarDismissedForTurnRef = useRef<string | null>(null);
652+ // When set, the thread-change reset effect will open the sidebar instead of closing it.
653+ // Used by "Implement in new thread" to carry the sidebar-open intent across navigation.
654+ const planSidebarOpenOnNextThreadRef = useRef(false);
659655 const [nowTick, setNowTick] = useState(() => Date.now());
660656 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
661657 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
@@ -1941,8 +1937,17 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1279 unmodified lines
19411937
19421938 useEffect(() => {
19431939 setExpandedWorkGroups({});
1940+ if (planSidebarOpenOnNextThreadRef.current) {
1941+ planSidebarOpenOnNextThreadRef.current = false;
1942+ setPlanSidebarOpen(true);
1943+ } else {
1944+ setPlanSidebarOpen(false);
1945+ }
1946+ planSidebarDismissedForTurnRef.current = null;
19441947 }, [activeThread?.id]);
19451948
1949+
1950+
19461951 useEffect(() => {
19471952 if (!composerMenuOpen) {
19481953 setComposerHighlightedItemId(null);
@@ -2955,6 +2960,13 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1006 unmodified lines
29552960 interactionMode: nextInteractionMode,
29562961 createdAt: messageCreatedAt,
29572962 });
2963+ // Optimistically open the plan sidebar when implementing (not refining).
2964+ // "default" mode here means the agent is executing the plan, which produces
2965+ // step-tracking activities that the sidebar will display.
2966+ if (nextInteractionMode === "default") {
2967+ planSidebarDismissedForTurnRef.current = null;
2968+ setPlanSidebarOpen(true);
2969+ }
29582970 sendInFlightRef.current = false;
29592971 } catch (err) {
29602972 setOptimisticUserMessages((existing) =>
@@ -3063,6 +3075,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 102 unmodified lines
30633075 .then(() => api.orchestration.getSnapshot())
30643076 .then((snapshot) => {
30653077 syncServerReadModel(snapshot);
3078+ // Signal that the plan sidebar should open on the new thread.
3079+ planSidebarOpenOnNextThreadRef.current = true;
30663080 return navigate({
30673081 to: "/$threadId",
30683082 params: { threadId: nextThreadId },
@@ -3469,7 +3483,10 @@ export default function ChatView({ threadId }: ChatViewProps) { · 400 unmodified lines
34693483 error={activeThread.error}
34703484 onDismiss={() => setThreadError(activeThread.id, null)}
34713485 />
3472− <PlanModePanel activePlan={activePlan} />
3486+ {/* Main content area with optional plan sidebar */}
3487+ <div className="flex min-h-0 flex-1">
3488+ {/* Chat column */}
3489+ <div className="flex min-h-0 min-w-0 flex-1 flex-col">
34733490
34743491 {/* Messages */}
34753492 <div
@@ -3543,6 +3560,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 67 unmodified lines
35433560 answers={activePendingDraftAnswers}
35443561 questionIndex={activePendingQuestionIndex}
35453562 onSelectOption={onSelectActivePendingUserInputOption}
3563+ onAdvance={onAdvanceActivePendingUserInput}
35463564 />
35473565 </div>
35483566 ) : showPlanFollowUpPrompt && activeProposedPlan ? (
@@ -3749,6 +3767,43 @@ export default function ChatView({ threadId }: ChatViewProps) { · 200 unmodified lines
37493767 {runtimeMode === "full-access" ? "Full access" : "Supervised"}
37503768 </span>
37513769 </Button>
3770+
3771+ {/* Plan sidebar toggle */}
3772+ {(activePlan || activeProposedPlan || planSidebarOpen) ? (
3773+ <>
3774+ <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
3775+ <Button
3776+ variant="ghost"
3777+ className={cn(
3778+ "shrink-0 whitespace-nowrap px-2 sm:px-3",
3779+ planSidebarOpen
3780+ ? "text-blue-400 hover:text-blue-300"
3781+ : "text-muted-foreground/70 hover:text-foreground/80",
3782+ )}
3783+ size="sm"
3784+ type="button"
3785+ onClick={() => {
3786+ setPlanSidebarOpen((open) => {
3787+ if (open) {
3788+ // Closing: track dismissal for current turn
3789+ const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null;
3790+ if (turnKey) {
3791+ planSidebarDismissedForTurnRef.current = turnKey;
3792+ }
3793+ } else {
3794+ // Re-opening: clear dismissal tracking
3795+ planSidebarDismissedForTurnRef.current = null;
3796+ }
3797+ return !open;
3798+ });
3799+ }}
3800+ title={planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"}
3801+ >
3802+ <ListTodoIcon />
3803+ <span className="sr-only sm:not-sr-only">Plan</span>
3804+ </Button>
3805+ </>
3806+ ) : null}
37523807 </div>
37533808
37543809 {/* Right side: send / stop button */}
@@ -3915,6 +3970,27 @@ export default function ChatView({ threadId }: ChatViewProps) { · 160 unmodified lines
39153970 </form>
39163971 </div>
39173972
3973+ </div>{/* end chat column */}
3974+
3975+ {/* Plan sidebar */}
3976+ {planSidebarOpen ? (
3977+ <PlanSidebar
3978+ activePlan={activePlan}
3979+ activeProposedPlan={activeProposedPlan}
3980+ markdownCwd={gitCwd ?? undefined}
3981+ workspaceRoot={activeProject?.cwd ?? undefined}
3982+ onClose={() => {
3983+ setPlanSidebarOpen(false);
3984+ // Track that the user explicitly dismissed for this turn so auto-open won't fight them.
3985+ const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null;
3986+ if (turnKey) {
3987+ planSidebarDismissedForTurnRef.current = turnKey;
3988+ }
3989+ }}
3990+ />
3991+ ) : null}
3992+ </div>{/* end horizontal flex container */}
3993+
39183994 {isGitRepo && (
39193995 <BranchToolbar
39203996 threadId={activeThread.id}
@@ -4274,61 +4350,13 @@ const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActi · 353 unmodified lines
42744350 );
42754351 });
42764352
4277−interface PlanModePanelProps {
4278− activePlan: ReturnType<typeof deriveActivePlanState>;
4279−}
4280−
4281−const PlanModePanel = memo(function PlanModePanel({ activePlan }: PlanModePanelProps) {
4282− if (!activePlan) return null;
4283−
4284− return (
4285− <div className="pt-3 mx-auto max-w-3xl">
4286− <div className="rounded-xl border border-border/70 bg-muted/30 p-4">
4287− <div className="flex items-center gap-2">
4288− <Badge variant="secondary">Plan</Badge>
4289− <span className="text-xs text-muted-foreground">
4290− Updated {formatTimestamp(activePlan.createdAt)}
4291− </span>
4292− </div>
4293− {activePlan.explanation ? (
4294− <p className="mt-2 text-sm text-muted-foreground">{activePlan.explanation}</p>
4295− ) : null}
4296− <div className="mt-3 space-y-2">
4297− {activePlan.steps.map((step) => (
4298− <div
4299− key={`${step.status}:${step.step}`}
4300− className="flex items-start gap-3 rounded-lg border border-border/60 bg-background/80 px-3 py-2"
4301− >
4302− <Badge
4303− variant={
4304− step.status === "completed"
4305− ? "default"
4306− : step.status === "inProgress"
4307− ? "secondary"
4308− : "outline"
4309− }
4310− >
4311− {step.status === "inProgress"
4312− ? "In progress"
4313− : step.status === "completed"
4314− ? "Done"
4315− : "Pending"}
4316− </Badge>
4317− <div className="min-w-0 flex-1 text-sm">{step.step}</div>
4318− </div>
4319− ))}
4320− </div>
4321− </div>
4322− </div>
4323− );
4324−});
4325−
43264353 interface PendingUserInputPanelProps {
43274354 pendingUserInputs: PendingUserInput[];
43284355 respondingRequestIds: ApprovalRequestId[];
43294356 answers: Record<string, PendingUserInputDraftAnswer>;
43304357 questionIndex: number;
43314358 onSelectOption: (questionId: string, optionLabel: string) => void;
4359+ onAdvance: () => void;
43324360 }
43334361
43344362 const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({
@@ -4337,6 +4365,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane · 2 unmodified lines
43374365 answers,
43384366 questionIndex,
43394367 onSelectOption,
4368+ onAdvance,
43404369 }: PendingUserInputPanelProps) {
43414370 if (pendingUserInputs.length === 0) return null;
43424371 const activePrompt = pendingUserInputs[0];
@@ -4350,6 +4379,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane · 7 unmodified lines
43504379 answers={answers}
43514380 questionIndex={questionIndex}
43524381 onSelectOption={onSelectOption}
4382+ onAdvance={onAdvance}
43534383 />
43544384 );
43554385 });
@@ -4360,42 +4390,135 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( · 4 unmodified lines
43604390 answers,
43614391 questionIndex,
43624392 onSelectOption,
4393+ onAdvance,
43634394 }: {
43644395 prompt: PendingUserInput;
43654396 isResponding: boolean;
43664397 answers: Record<string, PendingUserInputDraftAnswer>;
43674398 questionIndex: number;
43684399 onSelectOption: (questionId: string, optionLabel: string) => void;
4400+ onAdvance: () => void;
43694401 }) {
43704402 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
43714403 const activeQuestion = progress.activeQuestion;
4404+ const autoAdvanceTimerRef = useRef<number | null>(null);
4405+
4406+ // Clear auto-advance timer on unmount
4407+ useEffect(() => {
4408+ return () => {
4409+ if (autoAdvanceTimerRef.current !== null) {
4410+ window.clearTimeout(autoAdvanceTimerRef.current);
4411+ }
4412+ };
4413+ }, []);
4414+
4415+ const selectOptionAndAutoAdvance = useCallback(
4416+ (questionId: string, optionLabel: string) => {
4417+ onSelectOption(questionId, optionLabel);
4418+ if (autoAdvanceTimerRef.current !== null) {
4419+ window.clearTimeout(autoAdvanceTimerRef.current);
4420+ }
4421+ autoAdvanceTimerRef.current = window.setTimeout(() => {
4422+ autoAdvanceTimerRef.current = null;
4423+ onAdvance();
4424+ }, 200);
4425+ },
4426+ [onSelectOption, onAdvance],
4427+ );
4428+
4429+ // Keyboard shortcut: number keys 1-9 select corresponding option and auto-advance.
4430+ // Works even when the Lexical composer (contenteditable) has focus — the composer
4431+ // doubles as a custom-answer field during user input, and when it's empty the digit
4432+ // keys should pick options instead of typing into the editor.
4433+ useEffect(() => {
4434+ if (!activeQuestion || isResponding) return;
4435+ const handler = (event: globalThis.KeyboardEvent) => {
4436+ if (event.metaKey || event.ctrlKey || event.altKey) return;
4437+ const target = event.target;
4438+ if (
4439+ target instanceof HTMLInputElement ||
4440+ target instanceof HTMLTextAreaElement
4441+ ) {
4442+ return;
4443+ }
4444+ // If the user has started typing a custom answer in the contenteditable
4445+ // composer, let digit keys pass through so they can type numbers.
4446+ if (target instanceof HTMLElement && target.isContentEditable) {
4447+ const hasCustomText = progress.customAnswer.length > 0;
4448+ if (hasCustomText) return;
4449+ }
4450+ const digit = Number.parseInt(event.key, 10);
4451+ if (Number.isNaN(digit) || digit < 1 || digit > 9) return;
4452+ const optionIndex = digit - 1;
4453+ if (optionIndex >= activeQuestion.options.length) return;
4454+ const option = activeQuestion.options[optionIndex];
4455+ if (!option) return;
4456+ event.preventDefault();
4457+ selectOptionAndAutoAdvance(activeQuestion.id, option.label);
4458+ };
4459+ document.addEventListener("keydown", handler);
4460+ return () => document.removeEventListener("keydown", handler);
4461+ }, [activeQuestion, isResponding, selectOptionAndAutoAdvance, progress.customAnswer.length]);
43724462
43734463 if (!activeQuestion) {
43744464 return null;
43754465 }
43764466
43774467 return (
4378− <div className="px-4 py-4 sm:px-5">
4379− <div className="flex gap-2">
4380− <span className="uppercase text-sm tracking-[0.2em]">
4381− {questionIndex + 1}/{prompt.questions.length} {activeQuestion.header}
4382− </span>
4383− <div className="text-sm font-medium">{activeQuestion.question}</div>
4468+ <div className="px-4 py-3 sm:px-5">
4469+ <div className="flex items-center gap-3">
4470+ <div className="flex items-center gap-2">
4471+ {prompt.questions.length > 1 ? (
4472+ <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">
4473+ {questionIndex + 1}/{prompt.questions.length}
4474+ </span>
4475+ ) : null}
4476+ <span className="text-[11px] font-semibold tracking-widest text-muted-foreground/50 uppercase">
4477+ {activeQuestion.header}
4478+ </span>
4479+ </div>
43844480 </div>
4385− <div className="mt-3 flex flex-wrap gap-2">
4386− {activeQuestion.options.map((option) => {
4481+ <p className="mt-1.5 text-sm text-foreground/90">{activeQuestion.question}</p>
4482+ <div className="mt-3 space-y-1">
4483+ {activeQuestion.options.map((option, index) => {
43874484 const isSelected = progress.selectedOptionLabel === option.label;
4485+ const shortcutKey = index < 9 ? index + 1 : null;
43884486 return (
4389− <Button
4487+ <button
43904488 key={`${activeQuestion.id}:${option.label}`}
4391− size="sm"
4392− variant={isSelected ? "default" : "outline"}
4489+ type="button"
43934490 disabled={isResponding}
4394− onClick={() => onSelectOption(activeQuestion.id, option.label)}
4395− title={option.description}
4491+ onClick={() => selectOptionAndAutoAdvance(activeQuestion.id, option.label)}
4492+ className={cn(
4493+ "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-all duration-150",
4494+ isSelected
4495+ ? "border-blue-500/40 bg-blue-500/8 text-foreground"
4496+ : "border-transparent bg-muted/20 text-foreground/80 hover:bg-muted/40 hover:border-border/40",
4497+ isResponding && "opacity-50 cursor-not-allowed",
4498+ )}
43964499 >
4397− {option.label}
4398− </Button>
4500+ {shortcutKey !== null ? (
4501+ <kbd
4502+ className={cn(
4503+ "flex size-5 shrink-0 items-center justify-center rounded text-[11px] font-medium tabular-nums transition-colors duration-150",
4504+ isSelected
4505+ ? "bg-blue-500/20 text-blue-400"
4506+ : "bg-muted/40 text-muted-foreground/50 group-hover:bg-muted/60 group-hover:text-muted-foreground/70",
4507+ )}
4508+ >
4509+ {shortcutKey}
4510+ </kbd>
4511+ ) : null}
4512+ <div className="min-w-0 flex-1">
4513+ <span className="text-sm font-medium">{option.label}</span>
4514+ {option.description && option.description !== option.label ? (
4515+ <span className="ml-2 text-xs text-muted-foreground/50">{option.description}</span>
4516+ ) : null}
4517+ </div>
4518+ {isSelected ? (
4519+ <CheckIcon className="size-3.5 shrink-0 text-blue-400" />
4520+ ) : null}
4521+ </button>
43994522 );
44004523 })}
44014524 </div>
@@ -4609,7 +4732,7 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ · 207 unmodified lines
46094732 const saveContents = normalizePlanMarkdownForExport(planMarkdown);
46104733
46114734 const handleDownload = () => {
4612− downloadTextFile(downloadFilename, saveContents);
4735+ downloadPlanAsTextFile(downloadFilename, saveContents);
46134736 };
46144737
46154738 const openSaveDialog = () => {
apps/web/src/components/PlanSidebar.tsx+267−0
@@ -0,0 +1,267 @@
1+import { memo, useState, useCallback } from "react";
2+import { Badge } from "./ui/badge";
3+import { Button } from "./ui/button";
4+import { ScrollArea } from "./ui/scroll-area";
5+import ChatMarkdown from "./ChatMarkdown";
6+import {
7+ CheckIcon,
8+ ChevronDownIcon,
9+ ChevronRightIcon,
10+ EllipsisIcon,
11+ LoaderIcon,
12+ PanelRightCloseIcon,
13+} from "lucide-react";
14+import { cn } from "~/lib/utils";
15+import { formatTimestamp } from "../session-logic";
16+import type { ActivePlanState } from "../session-logic";
17+import type { LatestProposedPlanState } from "../session-logic";
18+import {
19+ proposedPlanTitle,
20+ buildProposedPlanMarkdownFilename,
21+ normalizePlanMarkdownForExport,
22+ downloadPlanAsTextFile,
23+} from "../proposedPlan";
24+import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
25+import { readNativeApi } from "~/nativeApi";
26+import { toastManager } from "./ui/toast";
27+
28+function stepStatusIcon(status: string): React.ReactNode {
29+ if (status === "completed") {
30+ return (
31+ <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-500">
32+ <CheckIcon className="size-3" />
33+ </span>
34+ );
35+ }
36+ if (status === "inProgress") {
37+ return (
38+ <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-blue-500/15 text-blue-400">
39+ <LoaderIcon className="size-3 animate-spin" />
40+ </span>
41+ );
42+ }
43+ return (
44+ <span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted/30">
45+ <span className="size-1.5 rounded-full bg-muted-foreground/30" />
46+ </span>
47+ );
48+}
49+
50+interface PlanSidebarProps {
51+ activePlan: ActivePlanState | null;
52+ activeProposedPlan: LatestProposedPlanState | null;
53+ markdownCwd: string | undefined;
54+ workspaceRoot: string | undefined;
55+ onClose: () => void;
56+}
57+
58+const PlanSidebar = memo(function PlanSidebar({
59+ activePlan,
60+ activeProposedPlan,
61+ markdownCwd,
62+ workspaceRoot,
63+ onClose,
64+}: PlanSidebarProps) {
65+ const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);
66+ const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);
67+ const [copied, setCopied] = useState(false);
68+
69+ const planMarkdown = activeProposedPlan?.planMarkdown ?? null;
70+ const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null;
71+
72+ const handleCopyPlan = useCallback(() => {
73+ if (!planMarkdown) return;
74+ void navigator.clipboard.writeText(planMarkdown);
75+ setCopied(true);
76+ setTimeout(() => setCopied(false), 2000);
77+ }, [planMarkdown]);
78+
79+ const handleDownload = useCallback(() => {
80+ if (!planMarkdown) return;
81+ const filename = buildProposedPlanMarkdownFilename(planMarkdown);
82+ downloadPlanAsTextFile(filename, normalizePlanMarkdownForExport(planMarkdown));
83+ }, [planMarkdown]);
84+
85+ const handleSaveToWorkspace = useCallback(() => {
86+ const api = readNativeApi();
87+ if (!api || !workspaceRoot || !planMarkdown) return;
88+ const filename = buildProposedPlanMarkdownFilename(planMarkdown);
89+ setIsSavingToWorkspace(true);
90+ void api.projects
91+ .writeFile({
92+ cwd: workspaceRoot,
93+ relativePath: filename,
94+ contents: normalizePlanMarkdownForExport(planMarkdown),
95+ })
96+ .then((result) => {
97+ toastManager.add({
98+ type: "success",
99+ title: "Plan saved",
100+ description: result.relativePath,
101+ });
102+ })
103+ .catch((error) => {
104+ toastManager.add({
105+ type: "error",
106+ title: "Could not save plan",
107+ description:
108+ error instanceof Error ? error.message : "An error occurred.",
109+ });
110+ })
111+ .then(
112+ () => setIsSavingToWorkspace(false),
113+ () => setIsSavingToWorkspace(false),
114+ );
115+ }, [planMarkdown, workspaceRoot]);
116+
117+ return (
118+ <div className="flex h-full w-[340px] shrink-0 flex-col border-l border-border/70 bg-card/50">
119+ {/* Header */}
120+ <div className="flex h-12 shrink-0 items-center justify-between border-b border-border/60 px-3">
121+ <div className="flex items-center gap-2">
122+ <Badge
123+ variant="secondary"
124+ className="rounded-md bg-blue-500/10 px-1.5 py-0 text-[10px] font-semibold tracking-wide text-blue-400 uppercase"
125+ >
126+ Plan
127+ </Badge>
128+ {activePlan ? (
129+ <span className="text-[11px] text-muted-foreground/60">
130+ {formatTimestamp(activePlan.createdAt)}
131+ </span>
132+ ) : null}
133+ </div>
134+ <div className="flex items-center gap-1">
135+ {planMarkdown ? (
136+ <Menu>
137+ <MenuTrigger
138+ render={
139+ <Button
140+ size="icon-xs"
141+ variant="ghost"
142+ className="text-muted-foreground/50 hover:text-foreground/70"
143+ aria-label="Plan actions"
144+ />
145+ }
146+ >
147+ <EllipsisIcon className="size-3.5" />
148+ </MenuTrigger>
149+ <MenuPopup align="end">
150+ <MenuItem onClick={handleCopyPlan}>
151+ {copied ? "Copied!" : "Copy to clipboard"}
152+ </MenuItem>
153+ <MenuItem onClick={handleDownload}>Download as markdown</MenuItem>
154+ <MenuItem
155+ onClick={handleSaveToWorkspace}
156+ disabled={!workspaceRoot || isSavingToWorkspace}
157+ >
158+ Save to workspace
159+ </MenuItem>
160+ </MenuPopup>
161+ </Menu>
162+ ) : null}
163+ <Button
164+ size="icon-xs"
165+ variant="ghost"
166+ onClick={onClose}
167+ aria-label="Close plan sidebar"
168+ className="text-muted-foreground/50 hover:text-foreground/70"
169+ >
170+ <PanelRightCloseIcon className="size-3.5" />
171+ </Button>
172+ </div>
173+ </div>
174+
175+ {/* Content */}
176+ <ScrollArea className="min-h-0 flex-1">
177+ <div className="p-3 space-y-4">
178+ {/* Explanation */}
179+ {activePlan?.explanation ? (
180+ <p className="text-[13px] leading-relaxed text-muted-foreground/80">
181+ {activePlan.explanation}
182+ </p>
183+ ) : null}
184+
185+ {/* Plan Steps */}
186+ {activePlan && activePlan.steps.length > 0 ? (
187+ <div className="space-y-1">
188+ <p className="mb-2 text-[10px] font-semibold tracking-widest text-muted-foreground/40 uppercase">
189+ Steps
190+ </p>
191+ {activePlan.steps.map((step) => (
192+ <div
193+ key={`${step.status}:${step.step}`}
194+ className={cn(
195+ "flex items-start gap-2.5 rounded-lg px-2.5 py-2 transition-colors duration-200",
196+ step.status === "inProgress" && "bg-blue-500/5",
197+ step.status === "completed" && "bg-emerald-500/5",
198+ )}
199+ >
200+ <div className="mt-0.5">
201+ {stepStatusIcon(step.status)}
202+ </div>
203+ <p
204+ className={cn(
205+ "text-[13px] leading-snug",
206+ step.status === "completed"
207+ ? "text-muted-foreground/50 line-through decoration-muted-foreground/20"
208+ : step.status === "inProgress"
209+ ? "text-foreground/90"
210+ : "text-muted-foreground/70",
211+ )}
212+ >
213+ {step.step}
214+ </p>
215+ </div>
216+ ))}
217+ </div>
218+ ) : null}
219+
220+ {/* Proposed Plan Markdown */}
221+ {planMarkdown ? (
222+ <div className="space-y-2">
223+ <button
224+ type="button"
225+ className="group flex w-full items-center gap-1.5 text-left"
226+ onClick={() => setProposedPlanExpanded((v) => !v)}
227+ >
228+ {proposedPlanExpanded ? (
229+ <ChevronDownIcon className="size-3 shrink-0 text-muted-foreground/40 transition-transform" />
230+ ) : (
231+ <ChevronRightIcon className="size-3 shrink-0 text-muted-foreground/40 transition-transform" />
232+ )}
233+ <span className="text-[10px] font-semibold tracking-widest text-muted-foreground/40 uppercase group-hover:text-muted-foreground/60">
234+ {planTitle ?? "Full Plan"}
235+ </span>
236+ </button>
237+ {proposedPlanExpanded ? (
238+ <div className="rounded-lg border border-border/50 bg-background/50 p-3">
239+ <ChatMarkdown
240+ text={planMarkdown}
241+ cwd={markdownCwd}
242+ isStreaming={false}
243+ />
244+ </div>
245+ ) : null}
246+ </div>
247+ ) : null}
248+
249+ {/* Empty state */}
250+ {!activePlan && !planMarkdown ? (
251+ <div className="flex flex-col items-center justify-center py-12 text-center">
252+ <p className="text-[13px] text-muted-foreground/40">
253+ No active plan yet.
254+ </p>
255+ <p className="mt-1 text-[11px] text-muted-foreground/30">
256+ Plans will appear here when generated.
257+ </p>
258+ </div>
259+ ) : null}
260+ </div>
261+ </ScrollArea>
262+ </div>
263+ );
264+});
265+
266+export default PlanSidebar;
267+export type { PlanSidebarProps };
apps/web/src/proposedPlan.ts+16−0
@@ -49,3 +49,19 @@ export function buildProposedPlanMarkdownFilename(planMarkdown: string): string · 48 unmodified lines
4949 const title = proposedPlanTitle(planMarkdown);
5050 return `${sanitizePlanFileSegment(title ?? "plan")}.md`;
5151 }
52+
53+export function normalizePlanMarkdownForExport(planMarkdown: string): string {
54+ return `${planMarkdown.trimEnd()}\n`;
55+}
56+
57+export function downloadPlanAsTextFile(filename: string, contents: string): void {
58+ const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" });
59+ const url = URL.createObjectURL(blob);
60+ const anchor = document.createElement("a");
61+ anchor.href = url;
62+ anchor.download = filename;
63+ anchor.click();
64+ window.setTimeout(() => {
65+ URL.revokeObjectURL(url);
66+ }, 0);
67+}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t