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