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