authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-03-08 20:12:31-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-08 20:12:31-07:00
log42234b8f7cbe4465370fa0511f3fe26df2a2ae2e
tree84083e9f2b671f15518ffea7c65125c52dabf679
parent39f65703e7bd97df787ba45373ed75dddae9bb2f
signature Signed by PGP key B5690EEEBB952194

fix: plan mode ui overhaul (#596)

* new plan mode started * review comments * more fixes

3 files changed, 486 insertions(+), 80 deletions(-)

apps/web/src/components/ChatView.tsx+203-80
......@@ -92,6 +92,8 @@ import {
9292 buildPlanImplementationThreadTitle,
9393 buildPlanImplementationPrompt,
9494 buildProposedPlanMarkdownFilename,
95 downloadPlanAsTextFile,
96 normalizePlanMarkdownForExport,
9597 proposedPlanTitle,
9698 resolvePlanFollowUpSubmission,
9799} from "../proposedPlan";
......@@ -122,6 +124,7 @@ import {
122124 shortcutLabelForCommand,
123125} from "../keybindings";
124126import ChatMarkdown from "./ChatMarkdown";
127import PlanSidebar from "./PlanSidebar";
125128import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
126129import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
127130import {
......@@ -135,6 +138,7 @@ import {
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 {
291295 return "text-muted-foreground/40";
292296}
293297
294function normalizePlanMarkdownForExport(planMarkdown: string): string {
295 return `${planMarkdown.trimEnd()}\n`;
296}
297298
298function 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
310300interface ExpandedImageItem {
311301 src: string;
......@@ -656,6 +646,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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
42744350 );
42754351});
42764352
4277interface PlanModePanelProps {
4278 activePlan: ReturnType<typeof deriveActivePlanState>;
4279}
4280
4281const 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
43264353interface 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
43344362const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({
......@@ -4337,6 +4365,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane
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
43504379 answers={answers}
43514380 questionIndex={questionIndex}
43524381 onSelectOption={onSelectOption}
4382 onAdvance={onAdvance}
43534383 />
43544384 );
43554385});
......@@ -4360,42 +4390,135 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
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({
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 created+267
......@@ -0,0 +1,267 @@
1import { memo, useState, useCallback } from "react";
2import { Badge } from "./ui/badge";
3import { Button } from "./ui/button";
4import { ScrollArea } from "./ui/scroll-area";
5import ChatMarkdown from "./ChatMarkdown";
6import {
7 CheckIcon,
8 ChevronDownIcon,
9 ChevronRightIcon,
10 EllipsisIcon,
11 LoaderIcon,
12 PanelRightCloseIcon,
13} from "lucide-react";
14import { cn } from "~/lib/utils";
15import { formatTimestamp } from "../session-logic";
16import type { ActivePlanState } from "../session-logic";
17import type { LatestProposedPlanState } from "../session-logic";
18import {
19 proposedPlanTitle,
20 buildProposedPlanMarkdownFilename,
21 normalizePlanMarkdownForExport,
22 downloadPlanAsTextFile,
23} from "../proposedPlan";
24import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
25import { readNativeApi } from "~/nativeApi";
26import { toastManager } from "./ui/toast";
27
28function 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
50interface PlanSidebarProps {
51 activePlan: ActivePlanState | null;
52 activeProposedPlan: LatestProposedPlanState | null;
53 markdownCwd: string | undefined;
54 workspaceRoot: string | undefined;
55 onClose: () => void;
56}
57
58const 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
266export default PlanSidebar;
267export type { PlanSidebarProps };
apps/web/src/proposedPlan.ts+16
......@@ -49,3 +49,19 @@ export function buildProposedPlanMarkdownFilename(planMarkdown: string): string
4949 const title = proposedPlanTitle(planMarkdown);
5050 return `${sanitizePlanFileSegment(title ?? "plan")}.md`;
5151}
52
53export function normalizePlanMarkdownForExport(planMarkdown: string): string {
54 return `${planMarkdown.trimEnd()}\n`;
55}
56
57export 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}