Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

370 linesCodeBlameRaw
1/**
2 * A doc's page under the shared header: its icon and title, who
3 * edited it and where it came from, agents' suggestions, the live editor
4 * (or its saved Markdown until the editor loads, and for a reader without
5 * script), what is inside it and what links to it, and comments on the
6 * whole doc. The editor and everything it brings load only in the
7 * browser, after the page shows.
8 */
9import type { FolioSuggestion, FoliosLiveEvent, MemberProfile } from "@g1t/contracts";
10import { AlertTriangle, Check, MessageSquareQuote, SmilePlus, Sparkles } from "lucide-react";
11import { type ComponentType, Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
12import { Link, useRevalidator, useRouteLoaderData } from "react-router";
13import { usePageNavigation } from "../../../lib/use-page-navigation";
14
15import { usercontentFrom } from "../../../lib/addresses";
16import { canDo, readingTime } from "../../../lib/folios";
17import { Markdown } from "../../markdown";
18import { TimeAgo } from "../../ui";
19import { Button } from "../../ui/button";
20import { foliosRequest, useFoliosData } from "../actions";
21import { FolioGlyph, type FolioBodyProps } from "../kinds";
22import { Face } from "../parts";
23import { type Who, whoAre } from "../who";
24import type { DocEditorProps } from "./editor";
25import { EditorSkeleton } from "./editor-skeleton";
26import { Discussion, IconPicker, SuggestionCard, type PageThread } from "./page-parts";
27
28/** The BlockNote editor, loaded only in the browser: the server renders the doc's Markdown instead. */
29const DocEditor = lazy(
30 (): Promise<{ default: ComponentType<DocEditorProps> }> => (import.meta.env.SSR ? Promise.resolve({ default: () => null }) : import("./editor")),
31);
32
33function useHydrated() {
34 const [hydrated, setHydrated] = useState(false);
35 useEffect(() => setHydrated(true), []);
36 return hydrated;
37}
38
39export default function DocBody({ slug, page, folio, live, role, showComments, onPresence, onFolio, onRole, onError }: FolioBodyProps) {
40 const layout = useFoliosData();
41 const root = useRouteLoaderData("root");
42 const usercontent = usercontentFrom(root);
43 const { navigate } = usePageNavigation();
44 const revalidator = useRevalidator();
45 const hydrated = useHydrated();
46 const editable = canDo(role, "edit");
47 const send = useCallback(
48 async (intent: string, body: Record<string, unknown>, reload = true) => {
49 const done = await foliosRequest(slug, intent, { folio_id: folio.id, ...body });
50 if (!done.ok) onError(done.error.message);
51 else if (reload) void revalidator.revalidate();
52 return done;
53 },
54 [slug, folio.id, onError, revalidator],
55 );
56
57 // The title follows the folio, and anyone renaming it live.
58 const [title, setTitle] = useState(folio.title);
59 const titleFocused = useRef(false);
60 useEffect(() => {
61 if (!titleFocused.current) setTitle(folio.title);
62 }, [folio.title]);
63 const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
64 const saveTitle = (next: string) => {
65 setTitle(next);
66 onFolio({ ...folio, title: next });
67 if (saveTimer.current) clearTimeout(saveTimer.current);
68 saveTimer.current = setTimeout(() => void send("update", { change: { title: next } }, false), 700);
69 };
70
71 const [suggestions, setSuggestions] = useState<FolioSuggestion[]>(page.suggestions);
72 useEffect(() => setSuggestions(page.suggestions), [page.suggestions]);
73 const [threads, setThreads] = useState<PageThread[]>([]);
74 const [names, setNames] = useState<Map<string, Who>>(new Map());
75 useEffect(() => {
76 const keys = threads.flatMap((t) => t.comments.map((c) => c.author));
77 if (keys.some((k) => !names.has(k))) void whoAre(slug, keys).then((found) => setNames((was) => new Map([...was, ...found])));
78 }, [threads, slug, names]);
79
80 const onEvent = useCallback(
81 (event: FoliosLiveEvent) => {
82 if (event.type === "folio.updated") onFolio(event.folio);
83 else if (event.type === "folio.trashed") void revalidator.revalidate();
84 else if (event.type === "access") {
85 onRole(event.role);
86 if (!event.role) navigate(`/${slug}/-/artifacts`);
87 } else if (event.type === "suggestion.created") setSuggestions((was) => [...was.filter((s) => s.id !== event.suggestion.id), event.suggestion]);
88 else if (event.type === "suggestion.updated")
89 setSuggestions((was) =>
90 event.suggestion.status === "open"
91 ? was.map((s) => (s.id === event.suggestion.id ? event.suggestion : s))
92 : was.filter((s) => s.id !== event.suggestion.id),
93 );
94 else if (event.type === "version.created" || event.type === "folio.staleness" || event.type === "folio.access") void revalidator.revalidate();
95 },
96 [navigate, slug, revalidator, onFolio, onRole],
97 );
98 // The room's notices (a rename, a suggestion, a change of access), from the page's connection.
99 useEffect(() => live?.onEvent(onEvent), [live, onEvent]);
100
101 const decide = async (id: string, decision: "accept" | "reject") => {
102 setSuggestions((was) => was.filter((s) => s.id !== id));
103 const done = await send("decide", { suggestion_id: id, decision }, false);
104 if (!done.ok) void revalidator.revalidate();
105 };
106 const acceptAll = async () => {
107 const all = suggestions;
108 setSuggestions([]);
109 for (const s of all) await send("decide", { suggestion_id: s.id, decision: "accept" }, false);
110 void revalidator.revalidate();
111 };
112
113 const people = useCallback(
114 (key: string): Pick<MemberProfile, "kind" | "id" | "name" | "display_name" | "avatar"> => {
115 const at = key.indexOf(":");
116 const kind = key.slice(0, at) === "agent" ? "agent" : "user";
117 const id = key.slice(at + 1);
118 if (layout?.me.key === key) return { kind: "user", id, name: layout.me.name, display_name: layout.me.display_name, avatar: layout.me.avatar };
119 const found = names.get(key);
120 return {
121 kind,
122 id,
123 name: found?.name ?? "someone",
124 display_name: found?.display_name ?? (kind === "agent" ? "A deleted agent" : "Someone"),
125 avatar: found?.avatar ?? null,
126 };
127 },
128 [layout, names],
129 );
130
131 const threadRequest = async (path: string, method: string, body: unknown) => {
132 try {
133 const r = await fetch(`/${slug}/-/artifacts/threads/${folio.id}${path}`, {
134 method,
135 headers: { "content-type": "application/json" },
136 body: JSON.stringify(body),
137 });
138 if (!r.ok) {
139 const result = (await r.json().catch(() => null)) as { error?: { message?: string } } | null;
140 onError(result?.error?.message ?? "That comment didn't go through.");
141 return false;
142 }
143 return true;
144 } catch {
145 onError("That comment didn't go through.");
146 return false;
147 }
148 };
149 /** A comment's text as the editor's comments hold it: blocks, with @mentions as mentions. */
150 const commentBody = (text: string) => {
151 const parts: unknown[] = [];
152 for (const piece of text.split(/(@[a-z0-9][a-z0-9-]*)/i)) {
153 const who = piece.startsWith("@") ? layout?.mentionables.find((m) => m.name.toLowerCase() === piece.slice(1).toLowerCase()) : null;
154 if (who) parts.push({ type: "mention", props: { kind: who.kind, id: who.id, name: who.name, href: "" } });
155 else if (piece) parts.push({ type: "text", text: piece, styles: {} });
156 }
157 return [{ type: "paragraph", content: parts, children: [] }];
158 };
159
160 const reading = useMemo(() => readingTime(page.text), [page.text]);
161 const trashed = !!folio.trashed_at;
162
163 return (
164 <div className="-mt-6">
165 <article className={`mx-auto pt-10 ${showComments ? "max-w-6xl" : "max-w-3xl"}`}>
166 <div className={showComments ? "xl:max-w-3xl" : ""}>
167 {trashed && (
168 <p className="mb-6 rounded-lg border border-warn/30 bg-warn/10 px-3 py-2 text-sm text-warn">
169 This is in the trash.{" "}
170 <Link to={`/${slug}/-/artifacts/trash`} className="underline">
171 Open the trash
172 </Link>{" "}
173 to restore it.
174 </p>
175 )}
176 {/* Icon and title. */}
177 <div className="group">
178 {folio.icon && (
179 <IconPicker value={folio.icon} onChange={(icon) => editable && send("update", { change: { icon } })}>
180 <Button
181 type="button"
182 disabled={!editable}
183 aria-label="Change the icon"
184 variant="ghost"
185 size="inline"
186 className="-ml-1 mb-2 rounded-lg p-1 text-5xl leading-none disabled:hover:bg-transparent"
187 >
188 {folio.icon}
189 </Button>
190 </IconPicker>
191 )}
192 {editable && !trashed && (
193 <div className="mb-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100">
194 {!folio.icon && (
195 <IconPicker value={null} onChange={(icon) => send("update", { change: { icon } })}>
196 <Button type="button" variant="ghost" size="xs" className="text-faint font-normal">
197 <SmilePlus size={13} /> Add icon
198 </Button>
199 </IconPicker>
200 )}
201 </div>
202 )}
203 {editable && !trashed ? (
204 <textarea
205 value={title}
206 rows={1}
207 placeholder="Untitled"
208 aria-label="Title"
209 onFocus={() => (titleFocused.current = true)}
210 onBlur={() => (titleFocused.current = false)}
211 onChange={(e) => saveTitle(e.target.value.replace(/\n/g, " "))}
212 onKeyDown={(e) => {
213 if (e.key === "Enter") {
214 e.preventDefault();
215 (document.querySelector(".g1t-editor .bn-editor") as HTMLElement | null)?.focus();
216 }
217 }}
218 className="field-sizing-content w-full resize-none bg-transparent font-display text-4xl leading-tight font-semibold tracking-tight text-fg outline-none placeholder:text-faint/60"
219 />
220 ) : (
221 <h1 className="font-display text-4xl leading-tight font-semibold tracking-tight">{folio.title || "Untitled"}</h1>
222 )}
223 </div>
224 {/* Who wrote it, who owns it, where it came from. */}
225 <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-faint">
226 {folio.edited_by && (
227 <span className="flex items-center gap-1.5">
228 <Face who={folio.edited_by} size={16} />
229 Edited by <span className="text-muted">{folio.edited_by.display_name}</span> · <TimeAgo at={folio.edited_at} />
230 </span>
231 )}
232 <span className="flex items-center gap-1.5">
233 Owner <Face who={folio.owner} size={16} /> <span className="text-muted">{folio.owner.display_name}</span>
234 </span>
235 {folio.source && (
236 <Link to={folio.source.href} className="inline-flex items-center gap-1 hover:text-fg">
237 <MessageSquareQuote size={13} /> From a conversation
238 </Link>
239 )}
240 <span>
241 {reading.words.toLocaleString()} words · {reading.minutes} min read
242 </span>
243 {!editable && <span className="rounded-full bg-raised px-2 py-0.5">{role === "comment" ? "Can comment" : "Can view"}</span>}
244 </div>
245 {folio.stale && (
246 <div className="mt-5 flex flex-wrap items-center gap-3 rounded-lg border border-warn/30 bg-warn/8 px-3 py-2.5 text-sm">
247 <AlertTriangle size={15} className="shrink-0 text-warn" />
248 <span className="min-w-0 grow text-fg-soft">Possibly out of date: code this doc cites changed since it was last brought up to date.</span>
249 {editable && (
250 <Button type="button" onClick={() => send("mark_current", {})} variant="link" size="inline" className="text-xs text-warn">
251 It&apos;s current
252 </Button>
253 )}
254 </div>
255 )}
256
257 {/* Agents' suggestions, all together on a narrower screen (beside their blocks on a wide one). */}
258 {suggestions.length > 0 && (
259 <div className="mt-6 rounded-xl border border-accent/30 bg-accent/5 p-3 2xl:hidden">
260 <div className="mb-2 flex items-center gap-2">
261 <Sparkles size={14} className="text-accent" />
262 <span className="grow text-sm font-medium">
263 {suggestions.length} {suggestions.length === 1 ? "suggestion" : "suggestions"} waiting
264 </span>
265 {editable && suggestions.length > 1 && (
266 <Button type="button" onClick={acceptAll} variant="link" size="inline" className="text-xs">
267 Accept all
268 </Button>
269 )}
270 </div>
271 <div className="space-y-2">
272 {suggestions.map((s) => (
273 <SuggestionCard key={s.id} suggestion={s} canDecide={editable} onDecide={decide} compact />
274 ))}
275 </div>
276 </div>
277 )}
278 {suggestions.length > 1 && editable && (
279 <div className="mt-4 hidden justify-end 2xl:flex">
280 <Button type="button" onClick={acceptAll} variant="link" size="sm" className="bg-accent/15 px-3 text-xs hover:bg-accent/25">
281 <Check size={13} /> Accept all {suggestions.length} suggestions
282 </Button>
283 </div>
284 )}
285 </div>
286
287 {/* The doc itself: live once the editor loads; its saved Markdown until then. */}
288 <div className="mt-6">
289 {hydrated && live && !trashed ? (
290 <Suspense fallback={<ReadView markdown={page.text} />}>
291 <DocEditor
292 key={folio.id}
293 slug={slug}
294 folioId={folio.id}
295 provider={live}
296 role={role}
297 me={layout?.me ?? { key: "", name: "", display_name: "", avatar: null }}
298 mentionables={layout?.mentionables ?? []}
299 usercontent={usercontent}
300 suggestions={suggestions}
301 showComments={showComments}
302 onPresence={onPresence}
303 onPageThreads={setThreads}
304 renderSuggestion={(s) => <SuggestionCard suggestion={s} canDecide={editable} onDecide={decide} />}
305 projects={[]}
306 />
307 </Suspense>
308 ) : (
309 <ReadView markdown={page.text} />
310 )}
311 </div>
312
313 <div className={showComments ? "xl:max-w-3xl" : ""}>
314 {(page.children.length > 0 || page.backlinks.length > 0) && (
315 <div className="mt-14 grid gap-6 border-t border-line pt-8 sm:grid-cols-2">
316 {page.children.length > 0 && (
317 <section>
318 <h2 className="mb-2 text-xs font-medium tracking-wide text-faint uppercase">Inside</h2>
319 <ul className="space-y-1">
320 {page.children.map((c) => (
321 <li key={c.id}>
322 <Link to={c.path} className="flex items-center gap-2 rounded-md px-2 py-1 text-sm hover:bg-raised">
323 <FolioGlyph folio={c} /> {c.title || "Untitled"}
324 </Link>
325 </li>
326 ))}
327 </ul>
328 </section>
329 )}
330 {page.backlinks.length > 0 && (
331 <section>
332 <h2 className="mb-2 text-xs font-medium tracking-wide text-faint uppercase">Linked from</h2>
333 <ul className="space-y-1">
334 {page.backlinks.map((c) => (
335 <li key={c.id}>
336 <Link to={c.path} className="flex items-center gap-2 rounded-md px-2 py-1 text-sm hover:bg-raised">
337 <FolioGlyph folio={c} /> {c.title || "Untitled"}
338 </Link>
339 </li>
340 ))}
341 </ul>
342 </section>
343 )}
344 </div>
345 )}
346 {hydrated && (
347 <Discussion
348 threads={threads}
349 people={people}
350 canComment={canDo(role, "comment")}
351 onPost={(text) => threadRequest("", "POST", { initialComment: { body: commentBody(text) }, page_level: true })}
352 onReply={(thread, text) => threadRequest(`/${encodeURIComponent(thread)}/comments`, "POST", { comment: { body: commentBody(text) } })}
353 onResolve={(thread, resolved) => void threadRequest(`/${encodeURIComponent(thread)}/${resolved ? "resolve" : "unresolve"}`, "POST", {})}
354 />
355 )}
356 </div>
357 </article>
358 </div>
359 );
360}
361
362/** The doc as Markdown: what shows before the editor loads, and what a reader without script sees. */
363function ReadView({ markdown }: { markdown: string }) {
364 if (!markdown.trim()) return <EditorSkeleton />;
365 return (
366 <div className="docs-read">
367 <Markdown source={markdown} />
368 </div>
369 );
370}