Skip to content
571 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.

Docs: a workspace knowledge base people and agents write together1import { DOC_AGENT_MODE_LABELS, DOC_ROLE_LABELS, type DocPage, type DocPageDetail, type DocSuggestion, type DocsLiveEvent, type MemberProfile } from "@g1t/contracts";
2import {
3 ArrowRightLeft,
4 Check,
5 ClipboardCopy,
6 Copy,
7 Download,
8 Ellipsis,
9 History,
10 ImagePlus,
11 LayoutTemplate,
12 Link2,
13 MessageSquare,
14 Share2,
15 SmilePlus,
16 Sparkles,
17 Star,
18 Trash2,
19 WifiOff,
20} from "lucide-react";
21import { type ComponentType, Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
22import { Link, data, redirect, useNavigate, useRevalidator, useRouteLoaderData } from "react-router";
23
24import type { Route } from "./+types/page";
25import { docsRequest, useDocsAction, useDocsData } from "../../../components/docs/actions";
26import type { DocEditorProps, Presence } from "../../../components/docs/editor";
27import { EditorSkeleton } from "../../../components/docs/editor-skeleton";
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store28import { Describes, StaleBanner } from "../../../components/docs/code";
Docs: a workspace knowledge base people and agents write together29import { CoverPicker, Discussion, HistoryDialog, IconPicker, MoveDialog, SuggestionCard, TemplateDialog, type PageThread } from "../../../components/docs/page-parts";
30import { Crumbs, Face, Faces } from "../../../components/docs/parts";
31import type { LiveStatus } from "../../../components/docs/provider";
32import { PageIcon } from "../../../components/docs/sidebar";
33import { type Who, whoAre } from "../../../components/docs/who";
34import { Markdown } from "../../../components/markdown";
35import { ErrorText, TimeAgo } from "../../../components/ui";
36import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../../../components/ui/dropdown-menu";
37import { Hint } from "../../../components/ui/hint";
38import { Popover, PopoverContent, PopoverTrigger } from "../../../components/ui/popover";
39import { usercontentFrom } from "../../../lib/addresses";
40import { canDo, coverStyle, pageIdOf, pagePath, readingTime } from "../../../lib/docs";
41import { page as pageMeta } from "../../../lib/meta";
42import { docs } from "../../../lib/services.server";
43import { requireUser, roleIn } from "../../../lib/session.server";
44
45/**
46 * The editor and everything it brings, loaded only in the browser, after
47 * the page shows. The server's bundle never includes it: the server
48 * renders the page's Markdown instead.
49 */
50const DocEditor = lazy(
51 (): Promise<{ default: ComponentType<DocEditorProps> }> => (import.meta.env.SSR ? Promise.resolve({ default: () => null }) : import("../../../components/docs/editor")),
52);
53
54export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
55 const p = loaded?.detail.page;
56 return pageMeta(args, { title: `${p ? `${p.icon ? `${p.icon} ` : ""}${p.title || "Untitled"}` : "Page"} · ${params.owner} · g1t`, description: p?.excerpt, type: "article" });
57}
58
59export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ detail: DocPageDetail }> {
60 const viewer = requireUser(context, request);
61 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
62 const id = pageIdOf(params.page);
63 if (!id) throw data(null, { status: 404 });
64 const found = await docs.page(params.owner.toLowerCase(), id, viewer);
65 if (!found.ok) throw data(null, { status: found.error.code === "not_found" ? 404 : 403 });
66 const detail = found.value;
67 // An old address (renamed page, moved space) goes to the current one.
68 const url = new URL(request.url);
69 if (url.pathname !== detail.page.path) throw redirect(`${detail.page.path}${url.search}`);
70 return { detail };
71}
72
73function useHydrated() {
74 const [hydrated, setHydrated] = useState(false);
75 useEffect(() => setHydrated(true), []);
76 return hydrated;
77}
78
79/** One page: its header, the live editor (or its Markdown until the editor loads), suggestions, comments, links. */
80export default function DocPageView({ loaderData, params }: Route.ComponentProps) {
81 const slug = params.owner.toLowerCase();
82 const { detail } = loaderData;
83 const { page, space } = detail;
84 const layout = useDocsData();
85 const root = useRouteLoaderData("root");
86 const usercontent = usercontentFrom(root);
87 const navigate = useNavigate();
88 const revalidator = useRevalidator();
89 const hydrated = useHydrated();
90 const { send, error, setError } = useDocsAction(slug);
91
92 const [role, setRole] = useState(detail.role);
93 useEffect(() => setRole(detail.role), [detail.role, page.id]);
94 const editable = canDo(role, "edit");
95
96 // The header follows the page, and anyone renaming it live.
97 const [live, setLive] = useState<DocPage>(page);
98 useEffect(() => setLive(page), [page]);
99 const [title, setTitle] = useState(page.title);
100 const titleFocused = useRef(false);
101 useEffect(() => {
102 if (!titleFocused.current) setTitle(live.title);
103 }, [live.title]);
104 const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
105 const saveTitle = (next: string) => {
106 setTitle(next);
107 if (saveTimer.current) clearTimeout(saveTimer.current);
108 saveTimer.current = setTimeout(() => void send("update_page", { page_id: page.id, change: { title: next } }), 700);
109 };
110
111 const [suggestions, setSuggestions] = useState<DocSuggestion[]>(detail.suggestions);
112 useEffect(() => setSuggestions(detail.suggestions), [detail.suggestions]);
113 const [presence, setPresence] = useState<Presence[]>([]);
114 const [status, setStatus] = useState<LiveStatus>("connecting");
115 const [archived, setArchived] = useState(!!page.archived_at);
116 useEffect(() => setArchived(!!page.archived_at), [page.archived_at]);
117 const [showComments, setShowComments] = useState(false);
118 const [history, setHistory] = useState(false);
119 const [moving, setMoving] = useState(false);
120 const [templating, setTemplating] = useState(false);
121 const [favorite, setFavorite] = useState(detail.favorite);
122 useEffect(() => setFavorite(detail.favorite), [detail.favorite]);
123 const [copied, setCopied] = useState<string | null>(null);
124 const [threads, setThreads] = useState<PageThread[]>([]);
125 const [names, setNames] = useState<Map<string, Who>>(new Map());
126 useEffect(() => {
127 const keys = threads.flatMap((t) => t.comments.map((c) => c.author));
128 if (keys.some((k) => !names.has(k))) void whoAre(slug, keys).then((found) => setNames((was) => new Map([...was, ...found])));
129 }, [threads, slug, names]);
130
131 const onEvent = useCallback(
132 (event: DocsLiveEvent) => {
133 if (event.type === "page.updated") setLive(event.page);
134 else if (event.type === "page.archived") setArchived(true);
135 else if (event.type === "access") {
136 if (event.role) setRole(event.role);
137 else navigate(`/${slug}/-/docs`);
138 } else if (event.type === "suggestion.created") setSuggestions((was) => [...was.filter((s) => s.id !== event.suggestion.id), event.suggestion]);
139 else if (event.type === "suggestion.updated") setSuggestions((was) => (event.suggestion.status === "open" ? was.map((s) => (s.id === event.suggestion.id ? event.suggestion : s)) : was.filter((s) => s.id !== event.suggestion.id)));
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store140 else if (event.type === "version.created" || event.type === "page.staleness") void revalidator.revalidate();
Docs: a workspace knowledge base people and agents write together141 },
142 [navigate, slug, revalidator],
143 );
144
145 const decide = async (id: string, decision: "accept" | "reject") => {
146 setSuggestions((was) => was.filter((s) => s.id !== id));
147 const done = await send("decide", { suggestion_id: id, decision }, { reload: false });
148 if (!done.ok) void revalidator.revalidate();
149 };
150
151 const people = useCallback(
152 (key: string): Pick<MemberProfile, "kind" | "id" | "name" | "display_name" | "avatar" | "avatar_seed"> => {
153 const at = key.indexOf(":");
154 const kind = key.slice(0, at) === "agent" ? "agent" : "user";
155 const id = key.slice(at + 1);
156 if (layout?.me.key === key) return { kind: "user", id, name: layout.me.name, display_name: layout.me.display_name, avatar: layout.me.avatar, avatar_seed: null };
157 const found = names.get(key);
158 return { kind, id, name: found?.name ?? "someone", display_name: found?.display_name ?? (kind === "agent" ? "An agent" : "Someone"), avatar: found?.avatar ?? null, avatar_seed: found?.avatar_seed ?? null };
159 },
160 [layout, names],
161 );
162
163 const threadRequest = async (path: string, method: string, body: unknown) => {
164 try {
165 const r = await fetch(`/${slug}/-/docs/threads/${page.id}${path}`, { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
166 if (!r.ok) {
167 const result = (await r.json().catch(() => null)) as { error?: { message?: string } } | null;
168 setError(result?.error?.message ?? "That comment didn't go through.");
169 return false;
170 }
171 return true;
172 } catch {
173 setError("That comment didn't go through.");
174 return false;
175 }
176 };
177 /** A comment's text as the editor's comments hold it: blocks, with @mentions as mentions. */
178 const commentBody = (text: string) => {
179 const parts: unknown[] = [];
180 for (const piece of text.split(/(@[a-z0-9][a-z0-9-]*)/i)) {
181 const who = piece.startsWith("@") ? layout?.mentionables.find((m) => m.name.toLowerCase() === piece.slice(1).toLowerCase()) : null;
182 if (who) parts.push({ type: "mention", props: { kind: who.kind, id: who.id, name: who.name, href: "" } });
183 else if (piece) parts.push({ type: "text", text: piece, styles: {} });
184 }
185 return [{ type: "paragraph", content: parts, children: [] }];
186 };
187
188 const copy = async (what: "link" | "markdown") => {
189 try {
190 if (what === "link") await navigator.clipboard.writeText(`${window.location.origin}${pagePath(slug, space.slug, live.title, page.id)}`);
191 else {
192 const r = await fetch(`/${slug}/-/docs/export?page=${encodeURIComponent(page.id)}`);
193 await navigator.clipboard.writeText(await r.text());
194 }
195 setCopied(what);
196 setTimeout(() => setCopied(null), 1600);
197 } catch {
198 setError("Your browser didn't allow copying.");
199 }
200 };
201
202 const cover = coverStyle(live.cover);
203 const reading = useMemo(() => readingTime(detail.markdown), [detail.markdown]);
204 const others = presence.filter((p) => !p.me);
205 const crumbs = [
206 { label: "Docs", to: `/${slug}/-/docs` },
207 { label: space.name, to: `/${slug}/-/docs/${space.slug}` },
208 ...detail.breadcrumbs.map((b) => ({ label: `${b.icon ? `${b.icon} ` : ""}${b.title || "Untitled"}`, to: b.path })),
209 ];
210
211 return (
212 <div className="-mt-6">
213 {cover && (
214 <div className="group relative -mx-4 h-44 sm:-mx-6 lg:-mx-8" style={{ background: cover }}>
215 {editable && (
216 <CoverPicker onChange={(c) => send("update_page", { page_id: page.id, change: { cover: c } })}>
Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar217 <button type="button" className="absolute right-4 bottom-3 rounded-md bg-bg/70 px-2.5 py-1 text-xs text-fg opacity-0 backdrop-blur group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100">
Docs: a workspace knowledge base people and agents write together218 Change cover
219 </button>
220 </CoverPicker>
221 )}
222 </div>
223 )}
224 {/* The bar above the page: where it is, who is here, and what can be done. */}
Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar225 <div className="sticky top-(--topbar-h) z-20 -mx-4 flex h-12 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
Docs: a workspace knowledge base people and agents write together226 <div className="min-w-0 grow">
227 <Crumbs items={crumbs} />
228 </div>
229 {status === "offline" && (
230 <Hint label="Your changes are kept and sync when the connection is back.">
231 <span className="flex items-center gap-1 text-xs text-warn">
232 <WifiOff size={13} /> Offline
233 </span>
234 </Hint>
235 )}
236 {others.length > 0 && (
237 <span className="flex items-center -space-x-1.5" aria-label={`${others.length} others here`}>
238 {others.slice(0, 5).map((p) => (
239 <Hint key={p.key} label={`${p.name}${p.kind === "agent" ? " (agent)" : ""}`}>
240 <span className="rounded-full ring-2" style={{ ["--tw-ring-color" as string]: p.color }}>
241 <Face who={{ kind: p.kind, id: p.key.slice(p.key.indexOf(":") + 1), name: p.name, avatar: p.avatar, avatar_seed: null }} size={24} />
242 </span>
243 </Hint>
244 ))}
245 {others.length > 5 && <span className="pl-3 text-xs text-faint">+{others.length - 5}</span>}
246 </span>
247 )}
248 <Hint label={showComments ? "Hide comments" : "Comments"}>
Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar249 <button type="button" onClick={() => setShowComments(!showComments)} aria-pressed={showComments} aria-label="Comments" className={`flex size-8 items-center justify-center rounded-md max-md:size-10 ${showComments ? "bg-raised text-fg" : "text-faint hover:bg-raised hover:text-fg"}`}>
Docs: a workspace knowledge base people and agents write together250 <MessageSquare size={16} />
251 </button>
252 </Hint>
253 <Hint label={favorite ? "Remove from favorites" : "Add to favorites"}>
254 <button
255 type="button"
256 aria-label={favorite ? "Remove from favorites" : "Add to favorites"}
257 aria-pressed={favorite}
258 onClick={async () => {
259 setFavorite(!favorite);
260 await send("favorite", { page_id: page.id, on: !favorite });
261 }}
262 className="flex size-8 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg"
263 >
264 <Star size={16} className={favorite ? "fill-current text-warn" : ""} />
265 </button>
266 </Hint>
267 <Popover>
268 <PopoverTrigger asChild>
269 <button type="button" className="hidden h-8 items-center gap-1.5 rounded-md border border-line px-2.5 text-xs text-fg/85 hover:bg-raised sm:inline-flex">
270 <Share2 size={13} /> Share
271 </button>
272 </PopoverTrigger>
273 <PopoverContent align="end" className="w-80 p-4 text-sm">
274 <p className="font-medium">Who can see this page</p>
275 <p className="mt-1 text-xs leading-relaxed text-muted">
276 Everyone with access to <span className="text-fg">{space.name}</span>:{" "}
277 {space.kind === "workspace" ? `the whole workspace (${space.default_role ? DOC_ROLE_LABELS[space.default_role].toLowerCase() : "listed members only"})` : space.kind === "team" ? `the ${space.team} team, and members added to the space` : "only the space's members"}. Agents here: {DOC_AGENT_MODE_LABELS[space.agent_mode].toLowerCase()}.
278 </p>
279 <p className="mt-2 text-xs text-muted">Your access: {DOC_ROLE_LABELS[role].toLowerCase()}.</p>
280 <div className="mt-3 flex gap-2">
281 <button type="button" onClick={() => copy("link")} className="inline-flex h-8 items-center gap-1.5 rounded-md bg-raised px-2.5 text-xs hover:bg-line">
282 {copied === "link" ? <Check size={13} /> : <Link2 size={13} />} {copied === "link" ? "Copied" : "Copy link"}
283 </button>
284 {canDo(space.viewer_role, "manage") && (
285 <Link to={`/${slug}/-/docs/${space.slug}/settings`} className="inline-flex h-8 items-center rounded-md px-2.5 text-xs text-muted hover:bg-raised hover:text-fg">
286 Space access
287 </Link>
288 )}
289 </div>
290 </PopoverContent>
291 </Popover>
292 <DropdownMenu>
293 <DropdownMenuTrigger asChild>
294 <button type="button" aria-label="More" className="flex size-8 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg">
295 <Ellipsis size={16} />
296 </button>
297 </DropdownMenuTrigger>
298 <DropdownMenuContent align="end" className="w-56">
299 <DropdownMenuItem onSelect={() => setHistory(true)}>
300 <History size={14} /> History
301 </DropdownMenuItem>
302 <DropdownMenuItem onSelect={() => copy("markdown")}>
303 <ClipboardCopy size={14} /> Copy as Markdown
304 </DropdownMenuItem>
305 <DropdownMenuItem asChild>
306 <a href={`/${slug}/-/docs/export?page=${encodeURIComponent(page.id)}`}>
307 <Download size={14} /> Export Markdown
308 </a>
309 </DropdownMenuItem>
310 <DropdownMenuItem onSelect={() => copy("link")}>
311 <Link2 size={14} /> Copy link
312 </DropdownMenuItem>
313 {editable && (
314 <>
315 <DropdownMenuSeparator />
316 <DropdownMenuItem onSelect={() => setMoving(true)}>
317 <ArrowRightLeft size={14} /> Move
318 </DropdownMenuItem>
319 <DropdownMenuItem
320 onSelect={async () => {
321 const made = await docsRequest<DocPage>(slug, "duplicate_page", { page_id: page.id });
322 if (made.ok) navigate(made.value.path);
323 else setError(made.error.message);
324 }}
325 >
326 <Copy size={14} /> Duplicate
327 </DropdownMenuItem>
328 </>
329 )}
330 <DropdownMenuItem onSelect={() => setTemplating(true)}>
331 <LayoutTemplate size={14} /> Save as template
332 </DropdownMenuItem>
333 {editable && (
334 <>
335 <DropdownMenuSeparator />
336 <DropdownMenuItem
337 onSelect={async () => {
338 const done = await send("archive_page", { page_id: page.id });
339 if (done.ok) navigate(`/${slug}/-/docs/${space.slug}`);
340 }}
341 className="text-danger focus:text-danger"
342 >
343 <Trash2 size={14} /> Move to trash
344 </DropdownMenuItem>
345 </>
346 )}
347 </DropdownMenuContent>
348 </DropdownMenu>
349 </div>
350
351 <article className={`mx-auto pt-10 ${showComments ? "max-w-6xl" : "max-w-3xl"}`}>
352 <div className={showComments ? "xl:max-w-3xl" : ""}>
353 {archived && (
354 <p className="mb-6 rounded-lg border border-warn/30 bg-warn/10 px-3 py-2 text-sm text-warn">
355 This page was moved to the trash.{" "}
356 <Link to={`/${slug}/-/docs/trash`} className="underline">
357 Open the trash
358 </Link>
359 </p>
360 )}
361 {/* Icon, cover, title. */}
362 <div className="group">
363 {live.icon && (
364 <IconPicker value={live.icon} onChange={(icon) => editable && send("update_page", { page_id: page.id, change: { icon } })}>
365 <button type="button" disabled={!editable} aria-label="Change the icon" className="-ml-1 mb-2 rounded-lg p-1 text-5xl leading-none hover:bg-raised disabled:hover:bg-transparent">
366 {live.icon}
367 </button>
368 </IconPicker>
369 )}
370 {editable && (
371 <div className="mb-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
372 {!live.icon && (
373 <IconPicker value={null} onChange={(icon) => send("update_page", { page_id: page.id, change: { icon } })}>
374 <button type="button" className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-faint hover:bg-raised hover:text-fg">
375 <SmilePlus size={13} /> Add icon
376 </button>
377 </IconPicker>
378 )}
379 {!cover && (
380 <CoverPicker onChange={(c) => send("update_page", { page_id: page.id, change: { cover: c } })}>
381 <button type="button" className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-faint hover:bg-raised hover:text-fg">
382 <ImagePlus size={13} /> Add cover
383 </button>
384 </CoverPicker>
385 )}
386 </div>
387 )}
388 {editable ? (
389 <textarea
390 value={title}
391 rows={1}
392 placeholder="Untitled"
393 aria-label="Title"
394 onFocus={() => (titleFocused.current = true)}
395 onBlur={() => (titleFocused.current = false)}
396 onChange={(e) => saveTitle(e.target.value.replace(/\n/g, " "))}
397 onKeyDown={(e) => {
398 if (e.key === "Enter") {
399 e.preventDefault();
400 (document.querySelector(".g1t-editor .bn-editor") as HTMLElement | null)?.focus();
401 }
402 }}
403 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"
404 />
405 ) : (
406 <h1 className="font-display text-4xl leading-tight font-semibold tracking-tight">{live.title || "Untitled"}</h1>
407 )}
408 </div>
409 {/* Who wrote it, who owns it, what it's about. */}
410 <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-faint">
411 {live.updated_by && (
412 <span className="flex items-center gap-1.5">
413 <Face who={live.updated_by} size={16} />
414 Last edited by <span className="text-muted">{live.updated_by.display_name}</span> · <TimeAgo at={live.updated_at} />
415 </span>
416 )}
417 {live.owners.length > 0 && (
418 <span className="flex items-center gap-1.5">
419 Owners <Faces people={live.owners} size={18} />
420 </span>
421 )}
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store422 <Describes slug={slug} describes={detail.describes} editable={editable} onSave={(describes) => send("update_page", { page_id: page.id, change: { describes } })} />
Docs: a workspace knowledge base people and agents write together423 {[...new Set([...live.projects, ...space.projects])].map((p) => (
424 <Link key={p} to={`/${p}`} className="rounded-full border border-line px-2 py-0.5 font-mono text-[0.6875rem] text-muted hover:border-line-strong hover:text-fg">
425 {p}
426 </Link>
427 ))}
428 <span>
429 {reading.words.toLocaleString()} words · {reading.minutes} min read
430 </span>
431 {!editable && <span className="rounded-full bg-raised px-2 py-0.5">{DOC_ROLE_LABELS[role]}</span>}
432 </div>
433 {error && (
434 <div className="mt-3">
435 <ErrorText>{error}</ErrorText>
436 </div>
437 )}
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store438 {detail.staleness && <StaleBanner staleness={detail.staleness} canMark={editable} onMark={() => send("mark_current", { page_id: page.id })} />}
Docs: a workspace knowledge base people and agents write together439
440 {/* Agents' suggestions, all together on a narrower screen (beside their blocks on a wide one). */}
441 {suggestions.length > 0 && (
442 <div className="mt-6 rounded-xl border border-accent/30 bg-accent/5 p-3 2xl:hidden">
443 <div className="mb-2 flex items-center gap-2">
444 <Sparkles size={14} className="text-accent" />
445 <span className="grow text-sm font-medium">
446 {suggestions.length} {suggestions.length === 1 ? "suggestion" : "suggestions"} waiting
447 </span>
448 {editable && suggestions.length > 1 && (
449 <button type="button" onClick={async () => { setSuggestions([]); await send("accept_all", { page_id: page.id }); }} className="text-xs font-medium text-accent hover:underline">
450 Accept all
451 </button>
452 )}
453 </div>
454 <div className="space-y-2">
455 {suggestions.map((s) => (
456 <SuggestionCard key={s.id} suggestion={s} canDecide={editable} onDecide={decide} compact />
457 ))}
458 </div>
459 </div>
460 )}
461 {suggestions.length > 1 && editable && (
462 <div className="mt-4 hidden justify-end 2xl:flex">
463 <button type="button" onClick={async () => { setSuggestions([]); await send("accept_all", { page_id: page.id }); }} className="inline-flex h-8 items-center gap-1.5 rounded-md bg-accent/15 px-3 text-xs font-medium text-accent hover:bg-accent/25">
464 <Check size={13} /> Accept all {suggestions.length} suggestions
465 </button>
466 </div>
467 )}
468 </div>
469
470 {/* The page itself: live once the editor loads; its saved Markdown until then. */}
471 <div className="mt-6">
472 {hydrated && !archived ? (
473 <Suspense fallback={<ReadView markdown={detail.markdown} />}>
474 <DocEditor
475 key={page.id}
476 slug={slug}
477 pageId={page.id}
478 role={role}
479 me={layout?.me ?? { key: "", name: "", display_name: "", avatar: null }}
480 mentionables={layout?.mentionables ?? []}
481 usercontent={usercontent}
482 suggestions={suggestions}
483 showComments={showComments}
484 onPresence={setPresence}
485 onStatus={setStatus}
486 onEvent={onEvent}
487 onPageThreads={setThreads}
488 renderSuggestion={(s) => <SuggestionCard suggestion={s} canDecide={editable} onDecide={decide} />}
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store489 projects={[...new Set([...live.projects, ...space.projects, ...detail.describes.map((d) => d.repo)])]}
Docs: a workspace knowledge base people and agents write together490 />
491 </Suspense>
492 ) : (
493 <ReadView markdown={detail.markdown} />
494 )}
495 </div>
496
497 <div className={showComments ? "xl:max-w-3xl" : ""}>
498 {(detail.children.length > 0 || detail.backlinks.length > 0) && (
499 <div className="mt-14 grid gap-6 border-t border-line pt-8 sm:grid-cols-2">
500 {detail.children.length > 0 && (
501 <section>
502 <h2 className="mb-2 text-xs font-medium tracking-wide text-faint uppercase">Pages inside</h2>
503 <ul className="space-y-1">
504 {detail.children.map((c) => (
505 <li key={c.id}>
506 <Link to={c.path} className="flex items-center gap-2 rounded-md px-2 py-1 text-sm hover:bg-raised">
507 <PageIcon icon={c.icon} /> {c.title || "Untitled"}
508 </Link>
509 </li>
510 ))}
511 </ul>
512 </section>
513 )}
514 {detail.backlinks.length > 0 && (
515 <section>
516 <h2 className="mb-2 text-xs font-medium tracking-wide text-faint uppercase">Linked from</h2>
517 <ul className="space-y-1">
518 {detail.backlinks.map((c) => (
519 <li key={c.id}>
520 <Link to={c.path} className="flex items-center gap-2 rounded-md px-2 py-1 text-sm hover:bg-raised">
521 <PageIcon icon={c.icon} /> {c.title || "Untitled"}
522 </Link>
523 </li>
524 ))}
525 </ul>
526 </section>
527 )}
528 </div>
529 )}
530 {hydrated && (
531 <Discussion
532 threads={threads}
533 people={people}
534 canComment={canDo(role, "comment")}
535 onPost={(text) => threadRequest("", "POST", { initialComment: { body: commentBody(text) }, page_level: true })}
536 onReply={(thread, text) => threadRequest(`/${encodeURIComponent(thread)}/comments`, "POST", { comment: { body: commentBody(text) } })}
537 onResolve={(thread, resolved) => void threadRequest(`/${encodeURIComponent(thread)}/${resolved ? "resolve" : "unresolve"}`, "POST", {})}
538 />
539 )}
540 </div>
541 </article>
542
543 <HistoryDialog slug={slug} pageId={page.id} open={history} onOpenChange={setHistory} canRestore={editable} onRestore={(versionId) => send("restore_version", { page_id: page.id, version_id: versionId })} />
544 {layout?.sidebar && (
545 <MoveDialog
546 spaces={layout.sidebar.spaces}
547 pageId={page.id}
548 current={{ space_id: page.space_id, parent_id: page.parent_id }}
549 open={moving}
550 onOpenChange={setMoving}
551 onMove={async (spaceId, parentId) => {
552 const moved = await send<DocPage>("move_page", { page_id: page.id, move: { space_id: spaceId, parent_id: parentId, before_id: null } });
553 setMoving(false);
554 if (moved.ok) navigate(moved.value.path);
555 }}
556 />
557 )}
558 <TemplateDialog open={templating} onOpenChange={setTemplating} title={live.title || "Untitled"} onSave={(name, description) => send("save_template", { page_id: page.id, name, description })} />
559 </div>
560 );
561}
562
563/** The page as Markdown: what shows before the editor loads, and what a reader without script sees. */
564function ReadView({ markdown }: { markdown: string }) {
565 if (!markdown.trim()) return <EditorSkeleton />;
566 return (
567 <div className="docs-read">
568 <Markdown source={markdown} />
569 </div>
570 );
571}