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 {...@@ -92,6 +92,8 @@ import {
92 buildPlanImplementationThreadTitle,92 buildPlanImplementationThreadTitle,
93 buildPlanImplementationPrompt,93 buildPlanImplementationPrompt,
94 buildProposedPlanMarkdownFilename,94 buildProposedPlanMarkdownFilename,
95 downloadPlanAsTextFile,
96 normalizePlanMarkdownForExport,
95 proposedPlanTitle,97 proposedPlanTitle,
96 resolvePlanFollowUpSubmission,98 resolvePlanFollowUpSubmission,
97} from "../proposedPlan";99} from "../proposedPlan";
...@@ -122,6 +124,7 @@ import {...@@ -122,6 +124,7 @@ import {
122 shortcutLabelForCommand,124 shortcutLabelForCommand,
123} from "../keybindings";125} from "../keybindings";
124import ChatMarkdown from "./ChatMarkdown";126import ChatMarkdown from "./ChatMarkdown";
127import PlanSidebar from "./PlanSidebar";
125import ThreadTerminalDrawer from "./ThreadTerminalDrawer";128import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
126import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";129import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
127import {130import {
...@@ -135,6 +138,7 @@ import {...@@ -135,6 +138,7 @@ import {
135 DiffIcon,138 DiffIcon,
136 EllipsisIcon,139 EllipsisIcon,
137 FolderClosedIcon,140 FolderClosedIcon,
141 ListTodoIcon,
138 LockIcon,142 LockIcon,
139 LockOpenIcon,143 LockOpenIcon,
140 Undo2Icon,144 Undo2Icon,
...@@ -291,21 +295,7 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {...@@ -291,21 +295,7 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
291 return "text-muted-foreground/40";295 return "text-muted-foreground/40";
292}296}
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
310interface ExpandedImageItem {300interface ExpandedImageItem {
311 src: string;301 src: string;
...@@ -656,6 +646,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -656,6 +646,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
656 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =646 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
657 useState<Record<string, number>>({});647 useState<Record<string, number>>({});
658 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});648 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);
659 const [nowTick, setNowTick] = useState(() => Date.now());655 const [nowTick, setNowTick] = useState(() => Date.now());
660 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);656 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
661 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);657 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
...@@ -1941,8 +1937,17 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1941,8 +1937,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
19411937
1942 useEffect(() => {1938 useEffect(() => {
1943 setExpandedWorkGroups({});1939 setExpandedWorkGroups({});
1940 if (planSidebarOpenOnNextThreadRef.current) {
1941 planSidebarOpenOnNextThreadRef.current = false;
1942 setPlanSidebarOpen(true);
1943 } else {
1944 setPlanSidebarOpen(false);
1945 }
1946 planSidebarDismissedForTurnRef.current = null;
1944 }, [activeThread?.id]);1947 }, [activeThread?.id]);
19451948
1949
1950
1946 useEffect(() => {1951 useEffect(() => {
1947 if (!composerMenuOpen) {1952 if (!composerMenuOpen) {
1948 setComposerHighlightedItemId(null);1953 setComposerHighlightedItemId(null);
...@@ -2955,6 +2960,13 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2955,6 +2960,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
2955 interactionMode: nextInteractionMode,2960 interactionMode: nextInteractionMode,
2956 createdAt: messageCreatedAt,2961 createdAt: messageCreatedAt,
2957 });2962 });
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 }
2958 sendInFlightRef.current = false;2970 sendInFlightRef.current = false;
2959 } catch (err) {2971 } catch (err) {
2960 setOptimisticUserMessages((existing) =>2972 setOptimisticUserMessages((existing) =>
...@@ -3063,6 +3075,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3063,6 +3075,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
3063 .then(() => api.orchestration.getSnapshot())3075 .then(() => api.orchestration.getSnapshot())
3064 .then((snapshot) => {3076 .then((snapshot) => {
3065 syncServerReadModel(snapshot);3077 syncServerReadModel(snapshot);
3078 // Signal that the plan sidebar should open on the new thread.
3079 planSidebarOpenOnNextThreadRef.current = true;
3066 return navigate({3080 return navigate({
3067 to: "/$threadId",3081 to: "/$threadId",
3068 params: { threadId: nextThreadId },3082 params: { threadId: nextThreadId },
...@@ -3469,7 +3483,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3469,7 +3483,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
3469 error={activeThread.error}3483 error={activeThread.error}
3470 onDismiss={() => setThreadError(activeThread.id, null)}3484 onDismiss={() => setThreadError(activeThread.id, null)}
3471 />3485 />
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
3474 {/* Messages */}3491 {/* Messages */}
3475 <div3492 <div
...@@ -3543,6 +3560,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3543,6 +3560,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3543 answers={activePendingDraftAnswers}3560 answers={activePendingDraftAnswers}
3544 questionIndex={activePendingQuestionIndex}3561 questionIndex={activePendingQuestionIndex}
3545 onSelectOption={onSelectActivePendingUserInputOption}3562 onSelectOption={onSelectActivePendingUserInputOption}
3563 onAdvance={onAdvanceActivePendingUserInput}
3546 />3564 />
3547 </div>3565 </div>
3548 ) : showPlanFollowUpPrompt && activeProposedPlan ? (3566 ) : showPlanFollowUpPrompt && activeProposedPlan ? (
...@@ -3749,6 +3767,43 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3749,6 +3767,43 @@ export default function ChatView({ threadId }: ChatViewProps) {
3749 {runtimeMode === "full-access" ? "Full access" : "Supervised"}3767 {runtimeMode === "full-access" ? "Full access" : "Supervised"}
3750 </span>3768 </span>
3751 </Button>3769 </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}
3752 </div>3807 </div>
37533808
3754 {/* Right side: send / stop button */}3809 {/* Right side: send / stop button */}
...@@ -3915,6 +3970,27 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3915,6 +3970,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
3915 </form>3970 </form>
3916 </div>3971 </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
3918 {isGitRepo && (3994 {isGitRepo && (
3919 <BranchToolbar3995 <BranchToolbar
3920 threadId={activeThread.id}3996 threadId={activeThread.id}
...@@ -4274,61 +4350,13 @@ const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActi...@@ -4274,61 +4350,13 @@ const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActi
4274 );4350 );
4275});4351});
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
4326interface PendingUserInputPanelProps {4353interface PendingUserInputPanelProps {
4327 pendingUserInputs: PendingUserInput[];4354 pendingUserInputs: PendingUserInput[];
4328 respondingRequestIds: ApprovalRequestId[];4355 respondingRequestIds: ApprovalRequestId[];
4329 answers: Record<string, PendingUserInputDraftAnswer>;4356 answers: Record<string, PendingUserInputDraftAnswer>;
4330 questionIndex: number;4357 questionIndex: number;
4331 onSelectOption: (questionId: string, optionLabel: string) => void;4358 onSelectOption: (questionId: string, optionLabel: string) => void;
4359 onAdvance: () => void;
4332}4360}
43334361
4334const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({4362const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({
...@@ -4337,6 +4365,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane...@@ -4337,6 +4365,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane
4337 answers,4365 answers,
4338 questionIndex,4366 questionIndex,
4339 onSelectOption,4367 onSelectOption,
4368 onAdvance,
4340}: PendingUserInputPanelProps) {4369}: PendingUserInputPanelProps) {
4341 if (pendingUserInputs.length === 0) return null;4370 if (pendingUserInputs.length === 0) return null;
4342 const activePrompt = pendingUserInputs[0];4371 const activePrompt = pendingUserInputs[0];
...@@ -4350,6 +4379,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane...@@ -4350,6 +4379,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane
4350 answers={answers}4379 answers={answers}
4351 questionIndex={questionIndex}4380 questionIndex={questionIndex}
4352 onSelectOption={onSelectOption}4381 onSelectOption={onSelectOption}
4382 onAdvance={onAdvance}
4353 />4383 />
4354 );4384 );
4355});4385});
...@@ -4360,42 +4390,135 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -4360,42 +4390,135 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
4360 answers,4390 answers,
4361 questionIndex,4391 questionIndex,
4362 onSelectOption,4392 onSelectOption,
4393 onAdvance,
4363}: {4394}: {
4364 prompt: PendingUserInput;4395 prompt: PendingUserInput;
4365 isResponding: boolean;4396 isResponding: boolean;
4366 answers: Record<string, PendingUserInputDraftAnswer>;4397 answers: Record<string, PendingUserInputDraftAnswer>;
4367 questionIndex: number;4398 questionIndex: number;
4368 onSelectOption: (questionId: string, optionLabel: string) => void;4399 onSelectOption: (questionId: string, optionLabel: string) => void;
4400 onAdvance: () => void;
4369}) {4401}) {
4370 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);4402 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
4371 const activeQuestion = progress.activeQuestion;4403 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
4373 if (!activeQuestion) {4463 if (!activeQuestion) {
4374 return null;4464 return null;
4375 }4465 }
43764466
4377 return (4467 return (
4378 <div className="px-4 py-4 sm:px-5">4468 <div className="px-4 py-3 sm:px-5">
4379 <div className="flex gap-2">4469 <div className="flex items-center gap-3">
4380 <span className="uppercase text-sm tracking-[0.2em]">4470 <div className="flex items-center gap-2">
4381 {questionIndex + 1}/{prompt.questions.length} {activeQuestion.header}4471 {prompt.questions.length > 1 ? (
4382 </span>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">
4383 <div className="text-sm font-medium">{activeQuestion.question}</div>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>
4384 </div>4480 </div>
4385 <div className="mt-3 flex flex-wrap gap-2">4481 <p className="mt-1.5 text-sm text-foreground/90">{activeQuestion.question}</p>
4386 {activeQuestion.options.map((option) => {4482 <div className="mt-3 space-y-1">
4483 {activeQuestion.options.map((option, index) => {
4387 const isSelected = progress.selectedOptionLabel === option.label;4484 const isSelected = progress.selectedOptionLabel === option.label;
4485 const shortcutKey = index < 9 ? index + 1 : null;
4388 return (4486 return (
4389 <Button4487 <button
4390 key={`${activeQuestion.id}:${option.label}`}4488 key={`${activeQuestion.id}:${option.label}`}
4391 size="sm"4489 type="button"
4392 variant={isSelected ? "default" : "outline"}
4393 disabled={isResponding}4490 disabled={isResponding}
4394 onClick={() => onSelectOption(activeQuestion.id, option.label)}4491 onClick={() => selectOptionAndAutoAdvance(activeQuestion.id, option.label)}
4395 title={option.description}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 )}
4396 >4499 >
4397 {option.label}4500 {shortcutKey !== null ? (
4398 </Button>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>
4399 );4522 );
4400 })}4523 })}
4401 </div>4524 </div>
...@@ -4609,7 +4732,7 @@ const ProposedPlanCard = memo(function ProposedPlanCard({...@@ -4609,7 +4732,7 @@ const ProposedPlanCard = memo(function ProposedPlanCard({
4609 const saveContents = normalizePlanMarkdownForExport(planMarkdown);4732 const saveContents = normalizePlanMarkdownForExport(planMarkdown);
46104733
4611 const handleDownload = () => {4734 const handleDownload = () => {
4612 downloadTextFile(downloadFilename, saveContents);4735 downloadPlanAsTextFile(downloadFilename, saveContents);
4613 };4736 };
46144737
4615 const openSaveDialog = () => {4738 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...@@ -49,3 +49,19 @@ export function buildProposedPlanMarkdownFilename(planMarkdown: string): string
49 const title = proposedPlanTitle(planMarkdown);49 const title = proposedPlanTitle(planMarkdown);
50 return `${sanitizePlanFileSegment(title ?? "plan")}.md`;50 return `${sanitizePlanFileSegment(title ?? "plan")}.md`;
51}51}
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}