import { memo, useState, useId } from "react";import { buildCollapsedProposedPlanPreviewMarkdown, buildProposedPlanMarkdownFilename, downloadPlanAsTextFile, normalizePlanMarkdownForExport, proposedPlanTitle, stripDisplayedPlanMarkdown,} from "../../proposedPlan";import ChatMarkdown from "../ChatMarkdown";import { EllipsisIcon } from "lucide-react";import { Button } from "../ui/button";import { Input } from "../ui/input";import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";import { cn } from "~/lib/utils";import { Badge } from "../ui/badge";import { Dialog, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle,} from "../ui/dialog";import { toastManager } from "../ui/toast";import { readNativeApi } from "~/nativeApi"; export const ProposedPlanCard = memo(function ProposedPlanCard({ planMarkdown, cwd, workspaceRoot,}: { planMarkdown: string; cwd: string | undefined; workspaceRoot: string | undefined;}) { const [expanded, setExpanded] = useState(false); const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); const [savePath, setSavePath] = useState(""); const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); const savePathInputId = useId(); const title = proposedPlanTitle(planMarkdown) ?? "Proposed plan"; const lineCount = planMarkdown.split("\n").length; const canCollapse = planMarkdown.length > 900 || lineCount > 20; const displayedPlanMarkdown = stripDisplayedPlanMarkdown(planMarkdown); const collapsedPreview = canCollapse ? buildCollapsedProposedPlanPreviewMarkdown(planMarkdown, { maxLines: 10 }) : null; const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown); const saveContents = normalizePlanMarkdownForExport(planMarkdown); const handleDownload = () => { downloadPlanAsTextFile(downloadFilename, saveContents); }; const openSaveDialog = () => { if (!workspaceRoot) { toastManager.add({ type: "error", title: "Workspace path is unavailable", description: "This thread does not have a workspace path to save into.", }); return; } setSavePath((existing) => (existing.length > 0 ? existing : downloadFilename)); setIsSaveDialogOpen(true); }; const handleSaveToWorkspace = () => { const api = readNativeApi(); const relativePath = savePath.trim(); if (!api || !workspaceRoot) { return; } if (!relativePath) { toastManager.add({ type: "warning", title: "Enter a workspace path", }); return; } setIsSavingToWorkspace(true); void api.projects .writeFile({ cwd: workspaceRoot, relativePath, contents: saveContents, }) .then((result) => { setIsSaveDialogOpen(false); toastManager.add({ type: "success", title: "Plan saved to workspace", description: result.relativePath, }); }) .catch((error) => { toastManager.add({ type: "error", title: "Could not save plan", description: error instanceof Error ? error.message : "An error occurred while saving.", }); }) .then( () => { setIsSavingToWorkspace(false); }, () => { setIsSavingToWorkspace(false); }, ); }; return ( <div className="rounded-[24px] border border-border/80 bg-card/70 p-4 sm:p-5"> <div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex min-w-0 items-center gap-2"> <Badge variant="secondary">Plan</Badge> <p className="truncate text-sm font-medium text-foreground">{title}</p> </div> <Menu> <MenuTrigger render={<Button aria-label="Plan actions" size="icon-xs" variant="outline" />} > <EllipsisIcon aria-hidden="true" className="size-4" /> </MenuTrigger> <MenuPopup align="end"> <MenuItem onClick={handleDownload}>Download as markdown</MenuItem> <MenuItem onClick={openSaveDialog} disabled={!workspaceRoot || isSavingToWorkspace}> Save to workspace </MenuItem> </MenuPopup> </Menu> </div> <div className="mt-4"> <div className={cn("relative", canCollapse && !expanded && "max-h-104 overflow-hidden")}> {canCollapse && !expanded ? ( <ChatMarkdown text={collapsedPreview ?? ""} cwd={cwd} isStreaming={false} /> ) : ( <ChatMarkdown text={displayedPlanMarkdown} cwd={cwd} isStreaming={false} /> )} {canCollapse && !expanded ? ( <div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-linear-to-t from-card/95 via-card/80 to-transparent" /> ) : null} </div> {canCollapse ? ( <div className="mt-4 flex justify-center"> <Button size="sm" variant="outline" data-scroll-anchor-ignore onClick={() => setExpanded((value) => !value)} > {expanded ? "Collapse plan" : "Expand plan"} </Button> </div> ) : null} </div> <Dialog open={isSaveDialogOpen} onOpenChange={(open) => { if (!isSavingToWorkspace) { setIsSaveDialogOpen(open); } }} > <DialogPopup className="max-w-xl"> <DialogHeader> <DialogTitle>Save plan to workspace</DialogTitle> <DialogDescription> Enter a path relative to <code>{workspaceRoot ?? "the workspace"}</code>. </DialogDescription> </DialogHeader> <DialogPanel className="space-y-3"> <label htmlFor={savePathInputId} className="grid gap-1.5"> <span className="text-xs font-medium text-foreground">Workspace path</span> <Input id={savePathInputId} value={savePath} onChange={(event) => setSavePath(event.target.value)} placeholder={downloadFilename} spellCheck={false} disabled={isSavingToWorkspace} /> </label> </DialogPanel> <DialogFooter> <Button variant="outline" size="sm" onClick={() => setIsSaveDialogOpen(false)} disabled={isSavingToWorkspace} > Cancel </Button> <Button size="sm" onClick={() => void handleSaveToWorkspace()} disabled={isSavingToWorkspace} > {isSavingToWorkspace ? "Saving..." : "Save"} </Button> </DialogFooter> </DialogPopup> </Dialog> </div> );});
import { memo, useState, useId } from "react";
import {
buildCollapsedProposedPlanPreviewMarkdown,
buildProposedPlanMarkdownFilename,
downloadPlanAsTextFile,
normalizePlanMarkdownForExport,
proposedPlanTitle,
stripDisplayedPlanMarkdown,
} from "../../proposedPlan";
import ChatMarkdown from "../ChatMarkdown";
import { EllipsisIcon } from "lucide-react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
import { cn } from "~/lib/utils";
import { Badge } from "../ui/badge";
import {
Dialog,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "../ui/dialog";
import { toastManager } from "../ui/toast";
import { readNativeApi } from "~/nativeApi";
export const ProposedPlanCard = memo(function ProposedPlanCard({
planMarkdown,
cwd,
workspaceRoot,
}: {
planMarkdown: string;
cwd: string | undefined;
workspaceRoot: string | undefined;
}) {
const [expanded, setExpanded] = useState(false);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [savePath, setSavePath] = useState("");
const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);
const savePathInputId = useId();
const title = proposedPlanTitle(planMarkdown) ?? "Proposed plan";
const lineCount = planMarkdown.split("\n").length;
const canCollapse = planMarkdown.length > 900 || lineCount > 20;
const displayedPlanMarkdown = stripDisplayedPlanMarkdown(planMarkdown);
const collapsedPreview = canCollapse
? buildCollapsedProposedPlanPreviewMarkdown(planMarkdown, { maxLines: 10 })
: null;
const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown);
const saveContents = normalizePlanMarkdownForExport(planMarkdown);
const handleDownload = () => {
downloadPlanAsTextFile(downloadFilename, saveContents);
};
const openSaveDialog = () => {
if (!workspaceRoot) {
toastManager.add({
type: "error",
title: "Workspace path is unavailable",
description: "This thread does not have a workspace path to save into.",
});
return;
}
setSavePath((existing) => (existing.length > 0 ? existing : downloadFilename));
setIsSaveDialogOpen(true);
};
const handleSaveToWorkspace = () => {
const api = readNativeApi();
const relativePath = savePath.trim();
if (!api || !workspaceRoot) {
return;
}
if (!relativePath) {
toastManager.add({
type: "warning",
title: "Enter a workspace path",
});
return;
}
setIsSavingToWorkspace(true);
void api.projects
.writeFile({
cwd: workspaceRoot,
relativePath,
contents: saveContents,
})
.then((result) => {
setIsSaveDialogOpen(false);
toastManager.add({
type: "success",
title: "Plan saved to workspace",
description: result.relativePath,
});
})
.catch((error) => {
toastManager.add({
type: "error",
title: "Could not save plan",
description: error instanceof Error ? error.message : "An error occurred while saving.",
});
})
.then(
() => {
setIsSavingToWorkspace(false);
},
() => {
setIsSavingToWorkspace(false);
},
);
};
return (
<div className="rounded-[24px] border border-border/80 bg-card/70 p-4 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="secondary">Plan</Badge>
<p className="truncate text-sm font-medium text-foreground">{title}</p>
</div>
<Menu>
<MenuTrigger
render={<Button aria-label="Plan actions" size="icon-xs" variant="outline" />}
>
<EllipsisIcon aria-hidden="true" className="size-4" />
</MenuTrigger>
<MenuPopup align="end">
<MenuItem onClick={handleDownload}>Download as markdown</MenuItem>
<MenuItem onClick={openSaveDialog} disabled={!workspaceRoot || isSavingToWorkspace}>
Save to workspace
</MenuItem>
</MenuPopup>
</Menu>
</div>
<div className="mt-4">
<div className={cn("relative", canCollapse && !expanded && "max-h-104 overflow-hidden")}>
{canCollapse && !expanded ? (
<ChatMarkdown text={collapsedPreview ?? ""} cwd={cwd} isStreaming={false} />
) : (
<ChatMarkdown text={displayedPlanMarkdown} cwd={cwd} isStreaming={false} />
)}
{canCollapse && !expanded ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-linear-to-t from-card/95 via-card/80 to-transparent" />
) : null}
</div>
{canCollapse ? (
<div className="mt-4 flex justify-center">
<Button
size="sm"
variant="outline"
data-scroll-anchor-ignore
onClick={() => setExpanded((value) => !value)}
>
{expanded ? "Collapse plan" : "Expand plan"}
</Button>
</div>
) : null}
</div>
<Dialog
open={isSaveDialogOpen}
onOpenChange={(open) => {
if (!isSavingToWorkspace) {
setIsSaveDialogOpen(open);
}
}}
>
<DialogPopup className="max-w-xl">
<DialogHeader>
<DialogTitle>Save plan to workspace</DialogTitle>
<DialogDescription>
Enter a path relative to <code>{workspaceRoot ?? "the workspace"}</code>.
</DialogDescription>
</DialogHeader>
<DialogPanel className="space-y-3">
<label htmlFor={savePathInputId} className="grid gap-1.5">
<span className="text-xs font-medium text-foreground">Workspace path</span>
<Input
id={savePathInputId}
value={savePath}
onChange={(event) => setSavePath(event.target.value)}
placeholder={downloadFilename}
spellCheck={false}
disabled={isSavingToWorkspace}
/>
</label>
</DialogPanel>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setIsSaveDialogOpen(false)}
disabled={isSavingToWorkspace}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => void handleSaveToWorkspace()}
disabled={isSavingToWorkspace}
>
{isSavingToWorkspace ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogPopup>
</Dialog>
</div>
);
});