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.

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";
You choose how g1t looks: Auto follows your system, Light and Dark stay put, set from the account menu, the phone's You and help sheet, Settings → Account or the command palette; the page is drawn in your choice from the first byte, every colour has a light value that reads at 4.5:1 or better, code and diffs are highlighted for both, diagrams and the document editor follow along, the product tour and the g1t mark's avatar stay dark, and Dark is what you see until you choose; the workspaces guide says how.21import { useDrawnTheme } from "../../../lib/theme";
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.22import { schema, type DocEditorInstance } from "./blocks";
23import { CiteDialog } from "./code";
24import { EditorSkeleton } from "./editor-skeleton";
25import type { PageThread } from "./page-parts";
26import { FolioProvider, type LiveStatus } from "../provider";
27import type { Presence } from "../shell";
28import { whoAre } from "../who";
29
30/** Someone who can be mentioned: a person (by username) or an agent (by id). */
31export type DocMentionable = { kind: "user" | "agent"; id: string; name: string; display_name: string; avatar: string | null; avatar_seed?: string | null };
32
33export type DocEditorProps = {
34 slug: string;
35 folioId: string;
36 role: DocRole;
37 me: { key: string; name: string; display_name: string; avatar: string | null };
38 mentionables: DocMentionable[];
39 usercontent: string;
40 suggestions: FolioSuggestion[];
41 showComments: boolean;
42 onPresence?: (people: Presence[]) => void;
43 onStatus?: (status: LiveStatus) => void;
44 onEvent?: (event: FoliosLiveEvent) => void;
45 /** The suggestion cards' own UI, drawn by the page beside the blocks each one changes. */
46 renderSuggestion?: (suggestion: FolioSuggestion) => ReactNode;
47 /** Comments on the whole doc (not on a passage), live from the document. */
48 onPageThreads?: (threads: PageThread[]) => void;
49 /** The projects the doc is about: Cite code offers them first. */
50 projects?: string[];
51};
52
53/** Nobody can do anything with threads: a reader's view. */
54class ReadOnlyAuth extends ThreadStoreAuth {
55 canCreateThread() {
56 return false;
57 }
58 canAddComment(_t: ThreadData) {
59 return false;
60 }
61 canUpdateComment(_c: CommentData) {
62 return false;
63 }
64 canDeleteComment(_c: CommentData) {
65 return false;
66 }
67 canDeleteThread(_t: ThreadData) {
68 return false;
69 }
70 canResolveThread(_t: ThreadData) {
71 return false;
72 }
73 canUnresolveThread(_t: ThreadData) {
74 return false;
75 }
76 canAddReaction(_c: CommentData) {
77 return false;
78 }
79 canDeleteReaction(_c: CommentData) {
80 return false;
81 }
82}
83
84export default function DocEditor(props: DocEditorProps) {
85 const { slug, folioId } = props;
86 const [provider, setProvider] = useState<FolioProvider | null>(null);
87 const [synced, setSynced] = useState(false);
88
89 useEffect(() => {
90 const scheme = window.location.protocol === "https:" ? "wss" : "ws";
91 const live = new FolioProvider(`${scheme}://${window.location.host}/${slug}/-/artifacts/live?folio=${encodeURIComponent(folioId)}`);
92 setProvider(live);
93 setSynced(false);
94 // Mount the editor once the document is here, so it never starts from
95 // an empty doc of its own; offline, after a moment, from what we have.
96 const fallback = setTimeout(() => setSynced(true), 4000);
97 const off = live.onStatus((status) => {
98 props.onStatus?.(status);
99 if (status === "synced") setSynced(true);
100 });
101 const offEvent = live.onEvent((event) => props.onEvent?.(event));
102 return () => {
103 clearTimeout(fallback);
104 off();
105 offEvent();
106 live.destroy();
107 };
108 // The callbacks are read through props each time; reconnect only for a new folio.
109 // eslint-disable-next-line react-hooks/exhaustive-deps
110 }, [slug, folioId]);
111
112 if (!provider || !synced) return <EditorSkeleton />;
113 return <LiveEditor {...props} provider={provider} />;
114}
115
116function LiveEditor({ slug, folioId, role, me, mentionables, usercontent, suggestions, showComments, onPresence, renderSuggestion, onPageThreads, projects, provider }: DocEditorProps & { provider: FolioProvider }) {
117 const editable = canDo(role, "edit");
You choose how g1t looks: Auto follows your system, Light and Dark stay put, set from the account menu, the phone's You and help sheet, Settings → Account or the command palette; the page is drawn in your choice from the first byte, every colour has a light value that reads at 4.5:1 or better, code and diffs are highlighted for both, diagrams and the document editor follow along, the product tour and the g1t mark's avatar stay dark, and Dark is what you see until you choose; the workspaces guide says how.118 // BlockNote's own parts follow the page's Appearance (lib/theme.ts).
119 const theme = useDrawnTheme();
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.120 const [citing, setCiting] = useState(false);
121 const colour = cursorColour(me.name);
122 // 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]);
127
128 const threadStore = useMemo(() => {
129 const auth = canDo(role, "comment") ? new DefaultThreadStoreAuth(me.key, editable ? "editor" : "comment") : new ReadOnlyAuth();
130 return new RESTYjsThreadStore(`/${slug}/-/artifacts/threads/${folioId}`, {}, provider.doc.getMap("threads"), auth);
131 }, [slug, folioId, 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}/-/artifacts/upload?folio=${encodeURIComponent(folioId)}&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 doc, 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 an artifact, as `@` mentions someone.
224 const searchPages = async (query: string) => {
225 try {
226 const r = await fetch(`/${slug}/-/artifacts/api?search=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } });
227 const result = (await r.json()) as Result<FolioSearchHit[]>;
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 artifact, 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 an artifact",
253 subtext: "A doc or another artifact",
254 aliases: ["link", "page", "artifact", "doc", "[["],
255 group: "g1t",
256 icon: <Link2 size={18} />,
257 onItemClick: () => editor.insertInlineContent("[[" as never),
258 },
259 {
260 title: "Cite code",
261 subtext: "A file, folder, symbol, endpoint or variable this doc 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 {
268 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 ?? "Private",
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 )}
You choose how g1t looks: Auto follows your system, Light and Dark stay put, set from the account menu, the phone's You and help sheet, Settings → Account or the command palette; the page is drawn in your choice from the first byte, every colour has a light value that reads at 4.5:1 or better, code and diffs are highlighted for both, diagrams and the document editor follow along, the product tour and the g1t mark's avatar stay dark, and Dark is what you see until you choose; the workspaces guide says how.314 <BlockNoteView editor={editor as never} editable={editable} theme={theme} renderEditor={false} slashMenu={false} comments={canDo(role, "view")} className="g1t-bn">
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.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 && (
321 <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.322 <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.323 <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>
332 {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 )}
344 </div>
345 );
346}
347
348/**
349 * Each suggestion beside the blocks it changes, in the margin on a wide
350 * screen (the doc lists them above the editor on a narrow one).
351 */
352function SuggestionRail({ editor, suggestions, render, hidden }: { editor: DocEditorInstance; suggestions: FolioSuggestion[]; render: (s: FolioSuggestion) => 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}