Skip to content
Source
Sign inSign up

ProposedPlanCard.tsx

85 lines3.4 KB

1import { useAtomValue } from "@effect/atom-react";
2import {
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
6import type { EnvironmentId } from "@t3tools/contracts";
7import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "@t3tools/shared/proposedPlanText";
8import { useRef, useState, type ReactNode } from "react";
9import { View } from "react-native";
10​
11import { AppText as Text } from "../../components/ui/AppText";
12import { Button } from "../../components/ui/Button";
13import * as Dialog from "../../components/ui/Dialog";
14import { projectEnvironment } from "../../state/projects";
15import { useAtomCommand } from "../../state/use-atom-command";
16import { CopyTextButton } from "./CopyTextButton";
17​
18export function ProposedPlanCard(props: {
19 readonly environmentId: EnvironmentId;
20 readonly workspaceRoot: string | null | undefined;
21 readonly markdown: string;
22 readonly renderMarkdown: (text: string) => ReactNode;
23}) {
24 const canWrite = useAtomValue(projectEnvironment.writeFile.permissionAtom(props.environmentId));
25 const writeFile = useAtomCommand(projectEnvironment.writeFile, { reportFailure: false });
26 const inFlight = useRef(false);
27 const [saving, setSaving] = useState(false);
28 const [lastPath, setLastPath] = useState("plan.md");
29 const contents = `${props.markdown.trimEnd()}\n`;
30​
31 const save = async (path: string) => {
32 const relativePath = path.trim();
33 if (!canWrite || !props.workspaceRoot || inFlight.current) return;
34 if (!relativePath) {
35 Dialog.alert("Enter a workspace path");
36 return;
37 }
38 inFlight.current = true;
39 setSaving(true);
40 setLastPath(relativePath);
41 const result = await writeFile({
42 environmentId: props.environmentId,
43 input: { cwd: props.workspaceRoot, relativePath, contents },
44 }).finally(() => {
45 inFlight.current = false;
46 setSaving(false);
47 });
48 if (result._tag === "Success") {
49 Dialog.alert("Plan saved to workspace", result.value.relativePath);
50 } else if (!isAtomCommandInterrupted(result)) {
51 const error = squashAtomCommandFailure(result);
52 Dialog.alert("Could not save plan", error instanceof Error ? error.message : String(error));
53 }
54 };
55​
56 return (
57 <View className="mb-3 gap-3 rounded-[20px] border border-border bg-card-alt p-4">
58 <View className="flex-row items-center gap-2">
59 <Text accessibilityRole="header" className="flex-1 font-t3-bold text-base text-foreground">
60 {proposedPlanTitle(props.markdown) ?? "Proposed plan"}
61 </Text>
62 <CopyTextButton accessibilityLabel="Copy plan" text={contents} />
63 </View>
64 {props.renderMarkdown(stripDisplayedPlanMarkdown(props.markdown))}
65 <View className="flex-row">
66 <Button
67 label={saving ? "Saving..." : "Save to workspace"}
68 tone="secondary"
69 disabled={!canWrite || !props.workspaceRoot || saving}
70 onPress={() =>
71 Dialog.prompt({
72 title: "Save plan to workspace",
73 // The write replaces an existing file, and the RPC has no create-only mode.
74 message:
75 "Enter a path relative to the thread's workspace. A file already at that path is replaced.",
76 initialValue: lastPath,
77 confirmText: "Save",
78 onConfirm: (path) => void save(path),
79 })
80 }
81 />
82 </View>
83 </View>
84 );