1import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "@t3tools/shared/proposedPlanText";
2import { memo, useCallback, useState, useId } from "react";
3import { useFindRevealRef } from "./markdownFindContext";
4import {
5 isAtomCommandInterrupted,
6 squashAtomCommandFailure,
7} from "@t3tools/client-runtime/state/runtime";
8import {
9 AuthFilesystemWriteScope,
10 type EnvironmentId,
11 type ScopedThreadRef,
12} from "@t3tools/contracts";
13import {
14 buildCollapsedProposedPlanPreviewMarkdown,
15 buildProposedPlanMarkdownFilename,
16 downloadPlanAsTextFile,
17 normalizePlanMarkdownForExport,
18} from "../../proposedPlan";
19import ChatMarkdown from "../ChatMarkdown";
20import { EllipsisIcon } from "lucide-react";
21import { Button } from "../ui/button";
22import { Input } from "../ui/input";
23import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
24import { cn } from "~/lib/utils";
25import { Badge } from "../ui/badge";
26import {
27 Dialog,
28 DialogDescription,
29 DialogFooter,
30 DialogHeader,
31 DialogPanel,
32 DialogPopup,
33 DialogTitle,
34} from "../ui/dialog";
35import { stackedThreadToast, toastManager } from "../ui/toast";
36import { projectEnvironment } from "~/state/projects";
37import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
38import { useAtomCommand } from "~/state/use-atom-command";
39import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
40
41export const ProposedPlanCard = memo(function ProposedPlanCard({
42 planMarkdown,
43 environmentId,
44 threadRef,
45 cwd,
46 workspaceRoot,
47 findActive = false,
48}: {
49 planMarkdown: string;
50 environmentId: EnvironmentId;
51 threadRef?: ScopedThreadRef | undefined;
52 cwd: string | undefined;
53 workspaceRoot: string | undefined;
54 findActive?: boolean;
55}) {
56 const [expanded, setExpanded] = useState(false);
57 const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope);
58 const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
59 const [savePath, setSavePath] = useState("");
60 const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);
61 const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, {
62 reportFailure: false,
63 });
64 const { copyToClipboard, isCopied } = useCopyToClipboard({
65 target: "plan",
66 onError: (error) => {
67 toastManager.add(
68 stackedThreadToast({
69 type: "error",
70 title: "Could not copy plan",
71 description: error instanceof Error ? error.message : "An error occurred while copying.",
72 }),
73 );
74 },
75 });
76 const savePathInputId = useId();
77 const title = proposedPlanTitle(planMarkdown) ?? "Proposed plan";
78 const lineCount = planMarkdown.split("\n").length;
79 const canCollapse = planMarkdown.length > 900 || lineCount > 20;
80 const displayedPlanMarkdown = stripDisplayedPlanMarkdown(planMarkdown);
81 const collapsedPreview = canCollapse
82 ? buildCollapsedProposedPlanPreviewMarkdown(planMarkdown, { maxLines: 10 })
83 : null;
84 const isCollapsed = canCollapse && !expanded;
85 // While finding, the full plan stays mounted but clipped, so a match past the
86 // preview can be counted and then opened only once it is selected.
87 const showPreview = isCollapsed && !findActive;
88 const revealForFind = useCallback(() => setExpanded(true), []);
89 const findRevealRef = useFindRevealRef(revealForFind);
90 const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown);
91 const saveContents = normalizePlanMarkdownForExport(planMarkdown);
92
93 const handleDownload = () => {
94 downloadPlanAsTextFile(downloadFilename, saveContents);
95 };
96
97 const handleCopyPlan = () => {
98 copyToClipboard(saveContents);
99 };
100
101 const openSaveDialog = () => {
102 if (!canWriteFiles) return;
103 if (!workspaceRoot) {
104 toastManager.add(
105 stackedThreadToast({
106 type: "error",
107 title: "Workspace path is unavailable",
108 description: "This thread does not have a workspace path to save into.",
109 }),
110 );
111 return;
112 }
113 setSavePath((existing) => (existing.length > 0 ? existing : downloadFilename));
114 setIsSaveDialogOpen(true);
115 };
116
117 const handleSaveToWorkspace = () => {
118 const relativePath = savePath.trim();
119 if (!workspaceRoot || !readEnvironmentScope(environmentId, AuthFilesystemWriteScope)) {
120 return;
121 }
122 if (!relativePath) {
123 toastManager.add({
124 type: "warning",
125 title: "Enter a workspace path",
126 });
127 return;
128 }
129
130 setIsSavingToWorkspace(true);
131 void (async () => {
132 const result = await writeProjectFile({
133 environmentId,
134 input: {
135 cwd: workspaceRoot,
136 relativePath,
137 contents: saveContents,
138 },
139 });
140 setIsSavingToWorkspace(false);
141 if (result._tag === "Success") {
142 setIsSaveDialogOpen(false);
143 toastManager.add({
144 type: "success",
145 title: "Plan saved to workspace",
146 description: result.value.relativePath,
147 });
148 return;
149 }
150 if (!isAtomCommandInterrupted(result)) {
151 const error = squashAtomCommandFailure(result);
152 toastManager.add(
153 stackedThreadToast({
154 type: "error",
155 title: "Could not save plan",
156 description: error instanceof Error ? error.message : "An error occurred while saving.",
157 }),
158 );
159 }
160 })();
161 };
162
163 return (
164 <div className="rounded-3xl border border-border/80 bg-card/70 p-4 sm:p-5">
165 <div className="flex flex-wrap items-center justify-between gap-3">
166 <div className="flex min-w-0 items-center gap-2">
167 <Badge variant="secondary">Plan</Badge>
168 {/* Same heading level as the message author headings in the timeline,
169 so a plan's own headings nest beneath it in the outline. */}
170 <h3 data-thread-find-text="true" className="truncate text-sm font-medium text-foreground">
171 {title}
172 </h3>
173 </div>
174 <Menu>
175 <MenuTrigger
176 render={<Button aria-label="Plan actions" size="icon-xs" variant="outline" />}
177 >
178 <EllipsisIcon aria-hidden="true" className="size-4" />
179 </MenuTrigger>
180 <MenuPopup align="end">
181 <MenuItem onClick={handleCopyPlan}>
182 {isCopied ? "Copied!" : "Copy to clipboard"}
183 </MenuItem>
184 <MenuItem onClick={handleDownload}>Download as markdown</MenuItem>
185 <MenuItem
186 onClick={openSaveDialog}
187 disabled={!canWriteFiles || !workspaceRoot || isSavingToWorkspace}
188 >
189 Save to workspace
190 </MenuItem>
191 </MenuPopup>
192 </Menu>
193 </div>
194 <div className="mt-4">
195 <div
196 ref={findRevealRef}
197 className={cn("relative", isCollapsed && "max-h-104 overflow-hidden")}
198 data-thread-find-text="true"
199 data-thread-find-fold={isCollapsed ? "" : undefined}
200 >
201 {showPreview ? (
202 <ChatMarkdown
203 text={collapsedPreview ?? ""}
204 cwd={cwd}
205 environmentId={environmentId}
206 threadRef={threadRef}
207 isStreaming={false}
208 headingLevelOffset={3}
209 />
210 ) : (
211 <ChatMarkdown
212 text={displayedPlanMarkdown}
213 cwd={cwd}
214 environmentId={environmentId}
215 threadRef={threadRef}
216 isStreaming={false}
217 headingLevelOffset={3}
218 />
219 )}
220 {isCollapsed ? (
221 <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" />
222 ) : null}
223 </div>
224 {canCollapse ? (
225 <div className="mt-4 flex justify-center">
226 <Button
227 size="sm"
228 variant="outline"
229 data-scroll-anchor-ignore
230 onClick={() => setExpanded((value) => !value)}
231 >
232 {expanded ? "Collapse plan" : "Expand plan"}
233 </Button>
234 </div>
235 ) : null}
236 </div>
237
238 <Dialog
239 open={isSaveDialogOpen}
240 onOpenChange={(open) => {
241 if (!isSavingToWorkspace) {
242 setIsSaveDialogOpen(open);
243 }
244 }}
245 >
246 <DialogPopup className="max-w-xl">
247 <DialogHeader>
248 <DialogTitle>Save plan to workspace</DialogTitle>
249 <DialogDescription>
250 Enter a path relative to <code>{workspaceRoot ?? "the workspace"}</code>.
251 </DialogDescription>
252 </DialogHeader>
253 <DialogPanel>
254 <label htmlFor={savePathInputId} className="grid gap-1.5">
255 <span className="text-xs font-medium text-foreground">Workspace path</span>
256 <Input
257 id={savePathInputId}
258 value={savePath}
259 onChange={(event) => setSavePath(event.target.value)}
260 placeholder={downloadFilename}
261 spellCheck={false}
262 disabled={isSavingToWorkspace}
263 />
264 </label>
265 </DialogPanel>
266 <DialogFooter>
267 <Button
268 variant="outline"
269 size="sm"
270 onClick={() => setIsSaveDialogOpen(false)}
271 disabled={isSavingToWorkspace}
272 >
273 Cancel
274 </Button>
275 <Button
276 size="sm"
277 onClick={() => void handleSaveToWorkspace()}
278 disabled={!canWriteFiles || isSavingToWorkspace}
279 >
280 {isSavingToWorkspace ? "Saving..." : "Save"}
281 </Button>
282 </DialogFooter>
283 </DialogPopup>
284 </Dialog>
285 </div>
286 );
287});