Skip to content
176 linesCodeBlameRaw
1import { FileText, LoaderCircle } from "lucide-react";
2import { useEffect, useState } from "react";
3
4import type { FoliosSidebar, Result } from "@g1t/contracts";
5
6import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
7import { Field, FieldError, FieldLabel } from "../ui/field";
8import { Input } from "../ui/input";
9import { SelectField } from "../ui/select";
10import { ORCHESTRATOR, type WritableSpace, type WriteUpAgent, type WriteUpWhere, defaultWhere, writableSpaces, writeUpMessage } from "../../lib/write-up";
11
12// "Write this up as an artifact" (docs/ARTIFACTS_MODE.md section 4.4):
13// where it goes, a title if you have one, and which agent writes it. The
14// kind is a doc for now. Sending posts the ask in the thread, as you,
15// where everyone sees it; the agent answers it as it answers any mention,
16// and links the artifact it made.
17
18type Spaces = { state: "loading" } | { state: "ready"; spaces: WritableSpace[] } | { state: "failed"; message: string };
19
20export function WriteUpDialog({
21 open,
22 onOpenChange,
23 slug,
24 agents,
25 link,
26 shared,
27 onSend,
28}: {
29 open: boolean;
30 onOpenChange: (open: boolean) => void;
31 slug: string;
32 /** Who can write it: @g1t, then the conversation's agents (lib/write-up.ts `writeUpAgents`). */
33 agents: WriteUpAgent[];
34 /** The thread's link, cited as the source. */
35 link: string;
36 /** A DM or a private channel: "Shared with this conversation" is offered, and chosen first. */
37 shared: boolean;
38 /** Posts the ask in the thread; answers with what went wrong, or null. */
39 onSend: (body: string) => Promise<string | null>;
40}) {
41 const [spaces, setSpaces] = useState<Spaces>({ state: "loading" });
42 const [where, setWhere] = useState<string>("private");
43 const [agent, setAgent] = useState(ORCHESTRATOR);
44 const [title, setTitle] = useState("");
45 const [busy, setBusy] = useState(false);
46 const [error, setError] = useState<string | null>(null);
47
48 // The spaces, read each time it opens: one may have been made meanwhile.
49 useEffect(() => {
50 if (!open) return;
51 let live = true;
52 setSpaces({ state: "loading" });
53 setError(null);
54 setTitle("");
55 setAgent(ORCHESTRATOR);
56 (async () => {
57 let list: WritableSpace[] = [];
58 try {
59 const response = await fetch(`/${slug}/-/artifacts/api`);
60 const result = (await response.json()) as Result<FoliosSidebar>;
61 if (!live) return;
62 if (!result.ok) setSpaces({ state: "failed", message: result.error.message });
63 else {
64 list = writableSpaces(result.value.spaces);
65 setSpaces({ state: "ready", spaces: list });
66 }
67 } catch {
68 if (live) setSpaces({ state: "failed", message: "Couldn't load your spaces. Private and this conversation still work." });
69 }
70 if (!live) return;
71 const first = defaultWhere(shared, list);
72 setWhere(first.kind === "space" ? first.id : first.kind);
73 })();
74 return () => {
75 live = false;
76 };
77 }, [open, slug, shared]);
78
79 const list = spaces.state === "ready" ? spaces.spaces : [];
80 const space = list.find((s) => s.id === where);
81 const chosen: WriteUpWhere | null = where === "private" ? { kind: "private" } : where === "conversation" ? { kind: "conversation" } : space ? { kind: "space", id: space.id, name: space.name } : null;
82 const writer = agents.find((a) => a.handle === agent) ?? agents[0] ?? { handle: ORCHESTRATOR, name: ORCHESTRATOR };
83 const body = chosen ? writeUpMessage({ agent: writer.handle, where: chosen, title, link }) : null;
84
85 const submit = async () => {
86 if (!body || busy) return;
87 setBusy(true);
88 setError(null);
89 const failed = await onSend(body);
90 setBusy(false);
91 if (failed) return setError(failed);
92 onOpenChange(false);
93 };
94
95 return (
96 <Dialog open={open} onOpenChange={onOpenChange}>
97 <DialogContent className="max-w-md">
98 <DialogHeader>
99 <DialogTitle>Write this up as an artifact</DialogTitle>
100 <DialogDescription>An agent turns this thread into a doc: what was decided, why, and what's next, with a link back here.</DialogDescription>
101 </DialogHeader>
102 <form
103 className="grid gap-4"
104 onSubmit={(event) => {
105 event.preventDefault();
106 void submit();
107 }}
108 >
109 <Field>
110 <FieldLabel htmlFor="write-up-where">Where</FieldLabel>
111 {spaces.state === "loading" ? (
112 <div className="flex h-9 items-center gap-2 rounded-md border border-line px-3 text-sm text-faint">
113 <LoaderCircle size={14} className="animate-spin" aria-hidden="true" />
114 Loading spaces…
115 </div>
116 ) : (
117 <SelectField
118 id="write-up-where"
119 value={where}
120 onValueChange={setWhere}
121 options={[
122 ...(shared ? [{ value: "conversation", label: "Shared with this conversation" }] : []),
123 { value: "private", label: "Private (just me)" },
124 ...list.map((s) => ({ value: s.id, label: s.name })),
125 ]}
126 />
127 )}
128 {spaces.state === "failed" && <FieldError>{spaces.message}</FieldError>}
129 </Field>
130 <Field>
131 <FieldLabel htmlFor="write-up-title">
132 Title <span className="font-normal text-faint">(optional)</span>
133 </FieldLabel>
134 <Input id="write-up-title" value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Left to the agent" maxLength={120} autoComplete="off" />
135 </Field>
136 <Field>
137 <FieldLabel htmlFor="write-up-agent">Written by</FieldLabel>
138 <SelectField
139 id="write-up-agent"
140 value={writer.handle}
141 onValueChange={setAgent}
142 options={agents.map((a) => ({ value: a.handle, label: a.name === a.handle ? `@${a.handle}` : `${a.name} (@${a.handle})` }))}
143 />
144 </Field>
145 {body && (
146 <div className="rounded-lg border border-line bg-bg/60 p-3">
147 <p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted">
148 <FileText size={13} aria-hidden="true" />
149 Posts in this thread, as you
150 </p>
151 <p className="text-[0.8125rem] leading-snug break-words text-fg-soft">{body}</p>
152 </div>
153 )}
154 <FieldError>{error}</FieldError>
155 <DialogFooter>
156 <button
157 type="button"
158 onClick={() => onOpenChange(false)}
159 className="inline-flex h-9 items-center rounded-md border border-line px-3 text-sm font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-surface"
160 >
161 Cancel
162 </button>
163 <button
164 type="submit"
165 disabled={!body || busy}
166 className="inline-flex h-9 items-center justify-center gap-1.5 rounded-md bg-accent px-3.5 text-sm font-medium text-bg transition-colors hover:bg-accent-hover disabled:opacity-50"
167 >
168 {busy && <LoaderCircle size={14} className="animate-spin" aria-hidden="true" />}
169 {busy ? "Asking…" : `Ask @${writer.handle}`}
170 </button>
171 </DialogFooter>
172 </form>
173 </DialogContent>
174 </Dialog>
175 );
176}