Skip to content
177 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread1import { FileText, LoaderCircle } from "lucide-react";
2import { useEffect, useState } from "react";
3import { Link } from "react-router";
4
5import type { DocsSidebar, Result } from "@g1t/contracts";
6
7import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
8import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field";
9import { Input } from "../ui/input";
10import { SelectField } from "../ui/select";
11import { ORCHESTRATOR, type WritableSpace, type WriteUpAgent, writableSpaces, writeUpMessage } from "../../lib/write-up";
12
13// "Write this up in Docs" (docs/WORKSPACE.md, "Docs"): which space, a
14// title if you have one, and which agent writes it. Sending posts the ask
15// in the thread, as you, where everyone sees it; the agent answers it as
16// it answers any mention, and links the page 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 onSend,
27}: {
28 open: boolean;
29 onOpenChange: (open: boolean) => void;
30 slug: string;
31 /** Who can write it: @g1t, then the conversation's agents (lib/write-up.ts `writeUpAgents`). */
32 agents: WriteUpAgent[];
33 /** The thread's link, cited as the source. */
34 link: string;
35 /** Posts the ask in the thread; answers with what went wrong, or null. */
36 onSend: (body: string) => Promise<string | null>;
37}) {
38 const [spaces, setSpaces] = useState<Spaces>({ state: "loading" });
39 const [space, setSpace] = useState("");
40 const [agent, setAgent] = useState(ORCHESTRATOR);
41 const [title, setTitle] = useState("");
42 const [busy, setBusy] = useState(false);
43 const [error, setError] = useState<string | null>(null);
44
45 // The spaces, read each time it opens: one may have been made meanwhile.
46 useEffect(() => {
47 if (!open) return;
48 let live = true;
49 setSpaces({ state: "loading" });
50 setError(null);
51 setTitle("");
52 setAgent(ORCHESTRATOR);
53 (async () => {
54 try {
55 const response = await fetch(`/${slug}/-/docs/api`);
56 const result = (await response.json()) as Result<DocsSidebar>;
57 if (!live) return;
58 if (!result.ok) return setSpaces({ state: "failed", message: result.error.message });
59 const list = writableSpaces(result.value.spaces);
60 setSpaces({ state: "ready", spaces: list });
61 setSpace((now) => (list.some((s) => s.id === now) ? now : (list[0]?.id ?? "")));
62 } catch {
63 if (live) setSpaces({ state: "failed", message: "Couldn't load your Docs spaces. Try again in a moment." });
64 }
65 })();
66 return () => {
67 live = false;
68 };
69 }, [open, slug]);
70
71 const chosen = spaces.state === "ready" ? spaces.spaces.find((s) => s.id === space) ?? null : null;
72 const writer = agents.find((a) => a.handle === agent) ?? agents[0] ?? { handle: ORCHESTRATOR, name: ORCHESTRATOR };
73 const body = chosen ? writeUpMessage({ agent: writer.handle, space: chosen.name, title, link }) : null;
74
75 const submit = async () => {
76 if (!body || busy) return;
77 setBusy(true);
78 setError(null);
79 const failed = await onSend(body);
80 setBusy(false);
81 if (failed) return setError(failed);
82 onOpenChange(false);
83 };
84
85 return (
86 <Dialog open={open} onOpenChange={onOpenChange}>
87 <DialogContent className="max-w-md">
88 <DialogHeader>
89 <DialogTitle>Write this up in Docs</DialogTitle>
90 <DialogDescription>An agent turns this thread into a page: what was decided, why, and what's next, with a link back here.</DialogDescription>
91 </DialogHeader>
92 <form
93 className="grid gap-4"
94 onSubmit={(event) => {
95 event.preventDefault();
96 void submit();
97 }}
98 >
99 <Field>
100 <FieldLabel htmlFor="write-up-space">Space</FieldLabel>
101 {spaces.state === "loading" ? (
102 <div className="flex h-9 items-center gap-2 rounded-md border border-line px-3 text-sm text-faint">
103 <LoaderCircle size={14} className="animate-spin" aria-hidden="true" />
104 Loading spaces…
105 </div>
106 ) : spaces.state === "failed" ? (
107 <FieldError>{spaces.message}</FieldError>
108 ) : spaces.spaces.length === 0 ? (
109 <FieldDescription>
110 There's no Docs space you can write in yet.{" "}
111 <Link to={`/${slug}/-/docs/new`} className="font-medium text-accent hover:underline" onClick={() => onOpenChange(false)}>
112 Create one
113 </Link>
114 </FieldDescription>
115 ) : (
116 <SelectField
117 id="write-up-space"
118 value={space}
119 onValueChange={setSpace}
120 options={spaces.spaces.map((s) => ({ value: s.id, label: s.name }))}
121 />
122 )}
123 </Field>
124 <Field>
125 <FieldLabel htmlFor="write-up-title">
126 Title <span className="font-normal text-faint">(optional)</span>
127 </FieldLabel>
128 <Input
129 id="write-up-title"
130 value={title}
131 onChange={(event) => setTitle(event.target.value)}
132 placeholder="Left to the agent"
133 maxLength={120}
134 autoComplete="off"
135 />
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}