Skip to content
411 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 together1/**
2 * The page editor: BlockNote on the page's live Yjs document, with g1t's
3 * own blocks (components/docs/blocks.tsx), live cursors and presence,
4 * comments anchored to text, and agents' suggestions marked in place.
5 * Loaded lazily and only in the browser (routes/workspace/docs/page.tsx),
6 * so the page's server render stays light: until it loads, the reader
7 * sees the page's Markdown.
8 */
9import "@blocknote/shadcn/style.css";
10
11import { filterSuggestionItems, insertOrUpdateBlockForSlashMenu } from "@blocknote/core";
12import { CommentsExtension, DefaultThreadStoreAuth, ThreadStoreAuth, type CommentData, type ThreadData } from "@blocknote/core/comments";
13import { RESTYjsThreadStore, withCollaboration } from "@blocknote/core/yjs";
14import { syntaxHighlighter } from "@blocknote/code-block";
15import { BlockNoteViewEditor, SuggestionMenuController, ThreadsSidebar, getDefaultReactSlashMenuItems, useCreateBlockNote } from "@blocknote/react";
16import { BlockNoteView } from "@blocknote/shadcn";
17import type { DocFile, DocRole, DocSuggestion, DocsLiveEvent, Result } from "@g1t/contracts";
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store18import { AlertTriangle, AtSign, Calendar, CheckCircle2, FileCode2, FileText, GitPullRequest, Info, Link2, Sigma, Workflow } from "lucide-react";
Docs: a workspace knowledge base people and agents write together19import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
20
21import { canDo, cursorColour } from "../../lib/docs";
22import { schema, type DocEditorInstance } from "./blocks";
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store23import { CiteDialog } from "./code";
Docs: a workspace knowledge base people and agents write together24import { EditorSkeleton } from "./editor-skeleton";
25import type { PageThread } from "./page-parts";
26import { DocsProvider, type LiveStatus } from "./provider";
27import { whoAre } from "./who";
28
29/** Someone who can be mentioned: a person (by username) or an agent (by id). */
30export type DocMentionable = { kind: "user" | "agent"; id: string; name: string; display_name: string; avatar: string | null; avatar_seed?: string | null };
31
32/** Someone on the page now. */
33export type Presence = { client: number; key: string; name: string; kind: "user" | "agent"; color: string; avatar: string | null; me: boolean };
34
35export type DocEditorProps = {
36 slug: string;
37 pageId: string;
38 role: DocRole;
39 me: { key: string; name: string; display_name: string; avatar: string | null };
40 mentionables: DocMentionable[];
41 usercontent: string;
42 suggestions: DocSuggestion[];
43 showComments: boolean;
44 onPresence?: (people: Presence[]) => void;
45 onStatus?: (status: LiveStatus) => void;
46 onEvent?: (event: DocsLiveEvent) => void;
47 /** The suggestion cards' own UI, drawn by the page beside the blocks each one changes. */
48 renderSuggestion?: (suggestion: DocSuggestion) => ReactNode;
49 /** Comments on the whole page (not on a passage), live from the document. */
50 onPageThreads?: (threads: PageThread[]) => void;
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store51 /** The projects the page and its space are about: Cite code offers them first. */
52 projects?: string[];
Docs: a workspace knowledge base people and agents write together53};
54
55/** Nobody can do anything with threads: a reader's view. */
56class ReadOnlyAuth extends ThreadStoreAuth {
57 canCreateThread() {
58 return false;
59 }
60 canAddComment(_t: ThreadData) {
61 return false;
62 }
63 canUpdateComment(_c: CommentData) {
64 return false;
65 }
66 canDeleteComment(_c: CommentData) {
67 return false;
68 }
69 canDeleteThread(_t: ThreadData) {
70 return false;
71 }
72 canResolveThread(_t: ThreadData) {
73 return false;
74 }
75 canUnresolveThread(_t: ThreadData) {
76 return false;
77 }
78 canAddReaction(_c: CommentData) {
79 return false;
80 }
81 canDeleteReaction(_c: CommentData) {
82 return false;
83 }
84}
85
86export default function DocEditor(props: DocEditorProps) {
87 const { slug, pageId } = props;
88 const [provider, setProvider] = useState<DocsProvider | null>(null);
89 const [synced, setSynced] = useState(false);
90
91 useEffect(() => {
92 const scheme = window.location.protocol === "https:" ? "wss" : "ws";
93 const live = new DocsProvider(`${scheme}://${window.location.host}/${slug}/-/docs/live?page=${encodeURIComponent(pageId)}`);
94 setProvider(live);
95 setSynced(false);
96 // Mount the editor once the document is here, so it never starts from
97 // an empty page of its own; offline, after a moment, from what we have.
98 const fallback = setTimeout(() => setSynced(true), 4000);
99 const off = live.onStatus((status) => {
100 props.onStatus?.(status);
101 if (status === "synced") setSynced(true);
102 });
103 const offEvent = live.onEvent((event) => props.onEvent?.(event));
104 return () => {
105 clearTimeout(fallback);
106 off();
107 offEvent();
108 live.destroy();
109 };
110 // The callbacks are read through props each time; reconnect only for a new page.
111 // eslint-disable-next-line react-hooks/exhaustive-deps
112 }, [slug, pageId]);
113
114 if (!provider || !synced) return <EditorSkeleton />;
115 return <LiveEditor {...props} provider={provider} />;
116}
117
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store118function LiveEditor({ slug, pageId, role, me, mentionables, usercontent, suggestions, showComments, onPresence, renderSuggestion, onPageThreads, projects, provider }: DocEditorProps & { provider: DocsProvider }) {
Docs: a workspace knowledge base people and agents write together119 const editable = canDo(role, "edit");
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store120 const [citing, setCiting] = useState(false);
Docs: a workspace knowledge base people and agents write together121 const colour = cursorColour(me.name);
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 bar122 // Below 1280px the comments sit under the page: opening them goes there.
123 const comments = useRef<HTMLElement>(null);
124 useEffect(() => {
125 if (showComments && window.matchMedia("(max-width: 1279px)").matches) comments.current?.scrollIntoView({ behavior: "smooth", block: "start" });
126 }, [showComments]);
Docs: a workspace knowledge base people and agents write together127
128 const threadStore = useMemo(() => {
129 const auth = canDo(role, "comment") ? new DefaultThreadStoreAuth(me.key, editable ? "editor" : "comment") : new ReadOnlyAuth();
130 return new RESTYjsThreadStore(`/${slug}/-/docs/threads/${pageId}`, {}, provider.doc.getMap("threads"), auth);
131 }, [slug, pageId, role, me.key, editable, provider]);
132
133 const editor = useCreateBlockNote(
134 withCollaboration({
135 schema,
136 collaboration: {
137 fragment: provider.doc.getXmlFragment("document-store"),
138 user: { name: me.display_name, color: colour, key: me.key, kind: "user", avatar: me.avatar ?? "" },
139 provider,
140 showCursorLabels: "activity",
141 },
142 extensions: [
143 CommentsExtension({
144 threadStore,
145 resolveUsers: async (ids: string[]) => {
146 const found = await whoAre(slug, ids);
147 return ids.map((id) => {
148 const p = found.get(id);
149 const avatar = id === me.key ? me.avatar : (p?.avatar ?? null);
150 return { id, username: id === me.key ? me.display_name : (p?.display_name ?? "Someone"), avatarUrl: avatar ? `${usercontent}/avatars/${avatar}` : "" };
151 });
152 },
153 }),
154 syntaxHighlighter,
155 ],
156 uploadFile: async (file: File) => {
157 const response = await fetch(`/${slug}/-/docs/upload?page=${encodeURIComponent(pageId)}&name=${encodeURIComponent(file.name)}`, {
158 method: "POST",
159 headers: { "content-type": file.type || "application/octet-stream" },
160 body: file,
161 });
162 const result = (await response.json()) as Result<DocFile>;
163 if (!result.ok) throw new Error(result.error.message);
164 return `${usercontent}${result.value.url}`;
165 },
166 }),
167 [provider, threadStore],
168 ) as unknown as DocEditorInstance;
169
170 // Who is here: everyone's awareness state, people and agents.
171 useEffect(() => {
172 const read = () => {
173 const out: Presence[] = [];
174 provider.awareness.getStates().forEach((state, client) => {
175 const user = (state as { user?: { name?: string; color?: string; key?: string; kind?: string; avatar?: string } }).user;
176 if (!user?.name) return;
177 out.push({
178 client,
179 key: user.key ?? String(client),
180 name: user.name,
181 kind: user.kind === "agent" ? "agent" : "user",
182 color: user.color ?? "#b8a6ff",
183 avatar: user.avatar || null,
184 me: client === provider.doc.clientID,
185 });
186 });
187 // One face per person, however many tabs they have open.
188 const seen = new Set<string>();
189 onPresence?.(out.filter((p) => !seen.has(p.key) && seen.add(p.key)));
190 };
191 read();
192 provider.awareness.on("change", read);
193 return () => provider.awareness.off("change", read);
194 }, [provider, onPresence]);
195
196 // Comments on the whole page, as the document holds them.
197 useEffect(() => {
198 if (!onPageThreads) return;
199 const map = provider.doc.getMap("threads");
200 const read = () => {
201 const out: PageThread[] = [];
202 map.forEach((value) => {
203 const t = value as { get(key: string): unknown };
204 if (typeof t?.get !== "function" || t.get("deletedAt")) return;
205 const meta = (t.get("metadata") as { page_level?: boolean } | null) ?? {};
206 if (!meta.page_level) return;
207 const comments = (t.get("comments") as { toArray(): { get(key: string): unknown }[] } | undefined)?.toArray() ?? [];
208 out.push({
209 id: String(t.get("id")),
210 resolved: !!t.get("resolved"),
211 comments: comments
212 .filter((c) => !c.get("deletedAt"))
213 .map((c) => ({ id: String(c.get("id")), author: String(c.get("userId")), text: commentText(c.get("body")), created_at: Number(c.get("createdAt")) || 0 })),
214 });
215 });
216 onPageThreads(out.sort((a, b) => (a.comments[0]?.created_at ?? 0) - (b.comments[0]?.created_at ?? 0)));
217 };
218 read();
219 map.observeDeep(read);
220 return () => map.unobserveDeep(read);
221 }, [provider, onPageThreads]);
222
223 // `[[` links a page, as `@` mentions someone.
224 const searchPages = async (query: string) => {
225 try {
226 const r = await fetch(`/${slug}/-/docs/api?search=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } });
227 const result = (await r.json()) as Result<{ id: string; title: string; icon: string | null; path: string; space_name: string }[]>;
228 return result.ok ? result.value : [];
229 } catch {
230 return [];
231 }
232 };
233
234 const slashItems = (query: string) => {
235 const insert = (block: { type: string; props?: Record<string, unknown> }) => () => insertOrUpdateBlockForSlashMenu(editor as never, block as never);
236 const custom = [
237 { title: "Info callout", subtext: "A note set apart from the text", aliases: ["callout", "note", "info"], group: "Callouts", icon: <Info size={18} />, onItemClick: insert({ type: "callout", props: { kind: "info" } }) },
238 { title: "Warning callout", subtext: "Something to be careful about", aliases: ["callout", "warning", "caution"], group: "Callouts", icon: <AlertTriangle size={18} />, onItemClick: insert({ type: "callout", props: { kind: "warning" } }) },
239 { title: "Success callout", subtext: "A tip, or what good looks like", aliases: ["callout", "success", "tip"], group: "Callouts", icon: <CheckCircle2 size={18} />, onItemClick: insert({ type: "callout", props: { kind: "success" } }) },
240 { title: "Diagram", subtext: "A Mermaid diagram, drawn as you type", aliases: ["mermaid", "flowchart", "sequence", "chart"], group: "Advanced", icon: <Workflow size={18} />, onItemClick: insert({ type: "mermaid" }) },
241 { title: "Math", subtext: "A formula, typeset with KaTeX", aliases: ["math", "latex", "katex", "equation", "formula"], group: "Advanced", icon: <Sigma size={18} />, onItemClick: insert({ type: "math" }) },
242 { title: "Embed from g1t", subtext: "An issue, pull request, channel, project or page, live", aliases: ["embed", "issue", "pull", "pr", "channel", "project"], group: "g1t", icon: <GitPullRequest size={18} />, onItemClick: insert({ type: "embed" }) },
243 {
244 title: "Mention",
245 subtext: "A person or an agent",
246 aliases: ["mention", "person", "agent", "@"],
247 group: "g1t",
248 icon: <AtSign size={18} />,
249 onItemClick: () => editor.insertInlineContent("@" as never),
250 },
251 {
252 title: "Link to page",
253 subtext: "Another page in Docs",
254 aliases: ["link", "page", "[["],
255 group: "g1t",
256 icon: <Link2 size={18} />,
257 onItemClick: () => editor.insertInlineContent("[[" as never),
258 },
259 {
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store260 title: "Cite code",
261 subtext: "A file, folder, symbol, endpoint or variable this page describes",
262 aliases: ["cite", "code", "citation", "path", "file", "symbol", "endpoint", "env", "variable"],
263 group: "g1t",
264 icon: <FileCode2 size={18} />,
265 onItemClick: () => setCiting(true),
266 },
267 {
Docs: a workspace knowledge base people and agents write together268 title: "Date",
269 subtext: "Today's date, which you can change",
270 aliases: ["date", "today", "when"],
271 group: "g1t",
272 icon: <Calendar size={18} />,
273 onItemClick: () => editor.insertInlineContent([{ type: "date", props: { date: new Date().toISOString().slice(0, 10) } }, " "] as never),
274 },
275 ];
276 return filterSuggestionItems([...getDefaultReactSlashMenuItems(editor as never), ...custom], query);
277 };
278
279 const mentionItems = (query: string) => {
280 const q = query.toLowerCase();
281 return mentionables
282 .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q))
283 .slice(0, 8)
284 .map((p) => ({
285 title: p.display_name,
286 subtext: p.kind === "agent" ? `@${p.name} · agent` : `@${p.name}`,
287 icon: <AtSign size={16} />,
288 onItemClick: () => editor.insertInlineContent([{ type: "mention", props: { kind: p.kind, id: p.id, name: p.name, href: "" } }, " "] as never),
289 }));
290 };
291
292 const pageItems = async (query: string) => {
293 const pages = await searchPages(query);
294 return pages.slice(0, 8).map((p) => ({
295 title: p.title || "Untitled",
296 subtext: p.space_name,
297 icon: p.icon ? <span className="text-base leading-none">{p.icon}</span> : <FileText size={16} />,
298 onItemClick: () => editor.insertInlineContent([{ type: "mention", props: { kind: "page", id: p.id, name: p.title || "Untitled", href: p.path } }, " "] as never),
299 }));
300 };
301
302 // Suggestions: the blocks each one changes are struck through in place.
303 const struck = suggestions
304 .flatMap((s) => s.block_ids)
305 .filter((id) => /^[0-9a-zA-Z-]+$/.test(id))
306 .map((id) => `.g1t-editor .bn-block-outer[data-id="${id}"] > .bn-block > .bn-block-content`)
307 .join(",\n");
308
309 return (
310 <div className="g1t-editor relative">
311 {struck && (
312 <style>{`${struck} { text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--g1t-danger) 70%, transparent); background: color-mix(in srgb, var(--g1t-danger) 8%, transparent); border-radius: 4px; }`}</style>
313 )}
314 <BlockNoteView editor={editor as never} editable={editable} theme="dark" renderEditor={false} slashMenu={false} comments={canDo(role, "view")} className="g1t-bn">
315 <div className={`grid gap-8 ${showComments ? "xl:grid-cols-[minmax(0,1fr)_18rem]" : ""}`}>
316 <div className="relative min-w-0">
317 <BlockNoteViewEditor />
318 {renderSuggestion && <SuggestionRail editor={editor} suggestions={suggestions} render={renderSuggestion} hidden={showComments} />}
319 </div>
320 {showComments && (
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 bar321 <aside ref={comments} aria-label="Comments" className="scroll-mt-20 max-xl:border-t max-xl:border-line max-xl:pt-6">
322 <div className="xl:sticky xl:top-20 xl:max-h-[calc(100dvh-6rem)] xl:overflow-y-auto xl:[scrollbar-width:thin]">
Docs: a workspace knowledge base people and agents write together323 <ThreadsSidebar filter="all" sort="position" />
324 </div>
325 </aside>
326 )}
327 </div>
328 <SuggestionMenuController triggerCharacter="/" getItems={async (query) => slashItems(query)} />
329 <SuggestionMenuController triggerCharacter="@" getItems={async (query) => mentionItems(query)} />
330 <SuggestionMenuController triggerCharacter="[[" getItems={pageItems} />
331 </BlockNoteView>
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store332 {editable && (
333 <CiteDialog
334 slug={slug}
335 open={citing}
336 onOpenChange={setCiting}
337 projects={projects}
338 onCite={(c) => {
339 editor.focus();
340 editor.insertInlineContent([{ type: "citation", props: c }, " "] as never);
341 }}
342 />
343 )}
Docs: a workspace knowledge base people and agents write together344 </div>
345 );
346}
347
348/**
349 * Each suggestion beside the blocks it changes, in the margin on a wide
350 * screen (the page lists them above the editor on a narrow one).
351 */
352function SuggestionRail({ editor, suggestions, render, hidden }: { editor: DocEditorInstance; suggestions: DocSuggestion[]; render: (s: DocSuggestion) => ReactNode; hidden: boolean }) {
353 const box = useRef<HTMLDivElement>(null);
354 const [tops, setTops] = useState<Record<string, number>>({});
355 useLayoutEffect(() => {
356 const place = () => {
357 const root = box.current?.parentElement;
358 if (!root) return;
359 const base = root.getBoundingClientRect().top;
360 const next: Record<string, number> = {};
361 let floor = 0;
362 for (const s of suggestions) {
363 const first = s.block_ids[0];
364 const el = first ? root.querySelector(`.bn-block-outer[data-id="${CSS.escape(first)}"]`) : null;
365 const want = el ? el.getBoundingClientRect().top - base : root.getBoundingClientRect().height - 40;
366 // Cards never overlap: each starts below the one before.
367 const top = Math.max(want, floor);
368 next[s.id] = top;
369 floor = top + 190;
370 }
371 setTops(next);
372 };
373 place();
374 const off = editor.onChange(() => requestAnimationFrame(place));
375 const observer = new ResizeObserver(place);
376 if (box.current?.parentElement) observer.observe(box.current.parentElement);
377 return () => {
378 off?.();
379 observer.disconnect();
380 };
381 }, [editor, suggestions]);
382 if (hidden || !suggestions.length) return null;
383 return (
384 <div ref={box} className="pointer-events-none absolute inset-y-0 left-full ml-8 hidden w-72 2xl:block" aria-label="Suggestions">
385 {suggestions.map((s) => (
386 <div key={s.id} className="pointer-events-auto absolute inset-x-0" style={{ top: tops[s.id] ?? 0 }}>
387 {render(s)}
388 </div>
389 ))}
390 </div>
391 );
392}
393
394/** A comment's text: its blocks' inline text, mentions as @name. */
395function commentText(body: unknown): string {
396 const out: string[] = [];
397 const walk = (v: unknown) => {
398 if (Array.isArray(v)) v.forEach(walk);
399 else if (v && typeof v === "object") {
400 const o = v as Record<string, unknown>;
401 if (o.type === "text" && typeof o.text === "string") out.push(o.text);
402 else if (o.type === "mention") out.push(`@${(o.props as { name?: string } | undefined)?.name ?? ""}`);
403 else {
404 walk(o.content);
405 walk(o.children);
406 }
407 }
408 };
409 walk(body);
410 return out.join("");
411}