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

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