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 | */ | |
| 8 | import "@blocknote/shadcn/style.css"; | |
| 9 | ||
| 10 | import { filterSuggestionItems, insertOrUpdateBlockForSlashMenu } from "@blocknote/core"; | |
| 11 | import { CommentsExtension, DefaultThreadStoreAuth, ThreadStoreAuth, type CommentData, type ThreadData } from "@blocknote/core/comments"; | |
| 12 | import { RESTYjsThreadStore, withCollaboration } from "@blocknote/core/yjs"; | |
| 13 | import { syntaxHighlighter } from "@blocknote/code-block"; | |
| 14 | import { BlockNoteViewEditor, SuggestionMenuController, ThreadsSidebar, getDefaultReactSlashMenuItems, useCreateBlockNote } from "@blocknote/react"; | |
| 15 | import { BlockNoteView } from "@blocknote/shadcn"; | |
| Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent. | 16 | import type { AgentLook, DocFile, DocRole, FolioSearchHit, FolioSuggestion, Result } from "@g1t/contracts"; |
| 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. | 17 | import { AlertTriangle, AtSign, Calendar, CheckCircle2, FileCode2, FileText, GitPullRequest, Info, Link2, Sigma, Workflow } from "lucide-react"; |
| 18 | import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 19 | ||
| 20 | import { 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. | 21 | import { 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. | 22 | import { schema, type DocEditorInstance } from "./blocks"; |
| 23 | import { CiteDialog } from "./code"; | |
| 24 | import { EditorSkeleton } from "./editor-skeleton"; | |
| 25 | import type { PageThread } from "./page-parts"; | |
| A doc opens at once: the page carries the document's saved state, so the editor mounts without waiting for its live room, which connects in the background and sends only what changed, with a quiet dot that turns green when it's synced; edits made before then are kept and sent once. The editor's code is preloaded from the page and fetched early when you hover a doc, the app's shared code ships in a few chunks instead of a hundred small ones, socket tickets come with the page instead of a round trip, and the artifacts service answers a page, a sidebar and a room in parallel lookups instead of a dozen in a row, reporting its database round trips in Server-Timing. The artifacts guide says how a doc opens, and CONTRIBUTING's Speed section has the numbers. | 26 | import type { FolioProvider } from "../provider"; |
| 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. | 27 | import type { Presence } from "../shell"; |
| 28 | import { whoAre } from "../who"; | |
| 29 | ||
| 30 | /** Someone who can be mentioned: a person (by username) or an agent (by id). */ | |
| Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent. | 31 | export type DocMentionable = { kind: "user" | "agent"; id: string; name: string; display_name: string; avatar: string | null; avatar_seed?: string | null; look?: AgentLook | null }; |
| 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. | 32 | |
| 33 | export type DocEditorProps = { | |
| 34 | slug: string; | |
| 35 | folioId: string; | |
| A doc opens at once: the page carries the document's saved state, so the editor mounts without waiting for its live room, which connects in the background and sends only what changed, with a quiet dot that turns green when it's synced; edits made before then are kept and sent once. The editor's code is preloaded from the page and fetched early when you hover a doc, the app's shared code ships in a few chunks instead of a hundred small ones, socket tickets come with the page instead of a round trip, and the artifacts service answers a page, a sidebar and a room in parallel lookups instead of a dozen in a row, reporting its database round trips in Server-Timing. The artifacts guide says how a doc opens, and CONTRIBUTING's Speed section has the numbers. | 36 | /** The folio's live document, opened by the page as it hydrated (routes/workspace/folios/folio.tsx). */ |
| 37 | provider: FolioProvider; | |
| 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. | 38 | role: DocRole; |
| 39 | me: { key: string; name: string; display_name: string; avatar: string | null }; | |
| 40 | mentionables: DocMentionable[]; | |
| 41 | usercontent: string; | |
| 42 | suggestions: FolioSuggestion[]; | |
| 43 | showComments: boolean; | |
| 44 | onPresence?: (people: Presence[]) => 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. */ | |
| 54 | class 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 | ||
| 84 | export default function DocEditor(props: DocEditorProps) { | |
| A doc opens at once: the page carries the document's saved state, so the editor mounts without waiting for its live room, which connects in the background and sends only what changed, with a quiet dot that turns green when it's synced; edits made before then are kept and sent once. The editor's code is preloaded from the page and fetched early when you hover a doc, the app's shared code ships in a few chunks instead of a hundred small ones, socket tickets come with the page instead of a round trip, and the artifacts service answers a page, a sidebar and a room in parallel lookups instead of a dozen in a row, reporting its database round trips in Server-Timing. The artifacts guide says how a doc opens, and CONTRIBUTING's Speed section has the numbers. | 85 | const { provider } = props; |
| 86 | // A document the page carried is here already: the editor opens on it | |
| 87 | // now, and the room's answer (what changed since the save) merges in | |
| 88 | // as it arrives. Without one, the editor waits for the room's copy, so | |
| 89 | // it never starts from an empty document of its own; offline, after a | |
| 90 | // moment, from what we have. | |
| 91 | const [ready, setReady] = useState(provider.seeded || provider.status === "synced"); | |
| 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. | 92 | useEffect(() => { |
| A doc opens at once: the page carries the document's saved state, so the editor mounts without waiting for its live room, which connects in the background and sends only what changed, with a quiet dot that turns green when it's synced; edits made before then are kept and sent once. The editor's code is preloaded from the page and fetched early when you hover a doc, the app's shared code ships in a few chunks instead of a hundred small ones, socket tickets come with the page instead of a round trip, and the artifacts service answers a page, a sidebar and a room in parallel lookups instead of a dozen in a row, reporting its database round trips in Server-Timing. The artifacts guide says how a doc opens, and CONTRIBUTING's Speed section has the numbers. | 93 | if (provider.seeded) return; |
| 94 | const fallback = setTimeout(() => setReady(true), 4000); | |
| 95 | const off = provider.onStatus((status) => { | |
| 96 | if (status === "synced") setReady(true); | |
| 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. | 97 | }); |
| 98 | return () => { | |
| 99 | clearTimeout(fallback); | |
| 100 | off(); | |
| 101 | }; | |
| A doc opens at once: the page carries the document's saved state, so the editor mounts without waiting for its live room, which connects in the background and sends only what changed, with a quiet dot that turns green when it's synced; edits made before then are kept and sent once. The editor's code is preloaded from the page and fetched early when you hover a doc, the app's shared code ships in a few chunks instead of a hundred small ones, socket tickets come with the page instead of a round trip, and the artifacts service answers a page, a sidebar and a room in parallel lookups instead of a dozen in a row, reporting its database round trips in Server-Timing. The artifacts guide says how a doc opens, and CONTRIBUTING's Speed section has the numbers. | 102 | }, [provider]); |
| 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. | 103 | |
| A doc opens at once: the page carries the document's saved state, so the editor mounts without waiting for its live room, which connects in the background and sends only what changed, with a quiet dot that turns green when it's synced; edits made before then are kept and sent once. The editor's code is preloaded from the page and fetched early when you hover a doc, the app's shared code ships in a few chunks instead of a hundred small ones, socket tickets come with the page instead of a round trip, and the artifacts service answers a page, a sidebar and a room in parallel lookups instead of a dozen in a row, reporting its database round trips in Server-Timing. The artifacts guide says how a doc opens, and CONTRIBUTING's Speed section has the numbers. | 104 | if (!ready) return <EditorSkeleton />; |
| 105 | return <LiveEditor {...props} />; | |
| 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. | 106 | } |
| 107 | ||
| A doc opens at once: the page carries the document's saved state, so the editor mounts without waiting for its live room, which connects in the background and sends only what changed, with a quiet dot that turns green when it's synced; edits made before then are kept and sent once. The editor's code is preloaded from the page and fetched early when you hover a doc, the app's shared code ships in a few chunks instead of a hundred small ones, socket tickets come with the page instead of a round trip, and the artifacts service answers a page, a sidebar and a room in parallel lookups instead of a dozen in a row, reporting its database round trips in Server-Timing. The artifacts guide says how a doc opens, and CONTRIBUTING's Speed section has the numbers. | 108 | function LiveEditor({ slug, folioId, role, me, mentionables, usercontent, suggestions, showComments, onPresence, renderSuggestion, onPageThreads, projects, provider }: DocEditorProps) { |
| 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. | 109 | 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. | 110 | // BlockNote's own parts follow the page's Appearance (lib/theme.ts). |
| 111 | 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. | 112 | const [citing, setCiting] = useState(false); |
| 113 | const colour = cursorColour(me.name); | |
| 114 | // Below 1280px the comments sit under the page: opening them goes there. | |
| 115 | const comments = useRef<HTMLElement>(null); | |
| 116 | useEffect(() => { | |
| 117 | if (showComments && window.matchMedia("(max-width: 1279px)").matches) comments.current?.scrollIntoView({ behavior: "smooth", block: "start" }); | |
| 118 | }, [showComments]); | |
| 119 | ||
| 120 | const threadStore = useMemo(() => { | |
| 121 | const auth = canDo(role, "comment") ? new DefaultThreadStoreAuth(me.key, editable ? "editor" : "comment") : new ReadOnlyAuth(); | |
| 122 | return new RESTYjsThreadStore(`/${slug}/-/artifacts/threads/${folioId}`, {}, provider.doc.getMap("threads"), auth); | |
| 123 | }, [slug, folioId, role, me.key, editable, provider]); | |
| 124 | ||
| 125 | const editor = useCreateBlockNote( | |
| 126 | withCollaboration({ | |
| 127 | schema, | |
| 128 | collaboration: { | |
| 129 | fragment: provider.doc.getXmlFragment("document-store"), | |
| 130 | user: { name: me.display_name, color: colour, key: me.key, kind: "user", avatar: me.avatar ?? "" }, | |
| 131 | provider, | |
| 132 | showCursorLabels: "activity", | |
| 133 | }, | |
| 134 | extensions: [ | |
| 135 | CommentsExtension({ | |
| 136 | threadStore, | |
| 137 | resolveUsers: async (ids: string[]) => { | |
| 138 | const found = await whoAre(slug, ids); | |
| 139 | return ids.map((id) => { | |
| 140 | const p = found.get(id); | |
| 141 | const avatar = id === me.key ? me.avatar : (p?.avatar ?? null); | |
| 142 | return { id, username: id === me.key ? me.display_name : (p?.display_name ?? "Someone"), avatarUrl: avatar ? `${usercontent}/avatars/${avatar}` : "" }; | |
| 143 | }); | |
| 144 | }, | |
| 145 | }), | |
| 146 | syntaxHighlighter, | |
| 147 | ], | |
| 148 | uploadFile: async (file: File) => { | |
| 149 | const response = await fetch(`/${slug}/-/artifacts/upload?folio=${encodeURIComponent(folioId)}&name=${encodeURIComponent(file.name)}`, { | |
| 150 | method: "POST", | |
| 151 | headers: { "content-type": file.type || "application/octet-stream" }, | |
| 152 | body: file, | |
| 153 | }); | |
| 154 | const result = (await response.json()) as Result<DocFile>; | |
| 155 | if (!result.ok) throw new Error(result.error.message); | |
| 156 | return `${usercontent}${result.value.url}`; | |
| 157 | }, | |
| 158 | }), | |
| 159 | [provider, threadStore], | |
| 160 | ) as unknown as DocEditorInstance; | |
| 161 | ||
| 162 | // Who is here: everyone's awareness state, people and agents. | |
| 163 | useEffect(() => { | |
| 164 | const read = () => { | |
| 165 | const out: Presence[] = []; | |
| 166 | provider.awareness.getStates().forEach((state, client) => { | |
| 167 | const user = (state as { user?: { name?: string; color?: string; key?: string; kind?: string; avatar?: string } }).user; | |
| 168 | if (!user?.name) return; | |
| 169 | out.push({ | |
| 170 | client, | |
| 171 | key: user.key ?? String(client), | |
| 172 | name: user.name, | |
| 173 | kind: user.kind === "agent" ? "agent" : "user", | |
| 174 | color: user.color ?? "#b8a6ff", | |
| 175 | avatar: user.avatar || null, | |
| 176 | me: client === provider.doc.clientID, | |
| 177 | }); | |
| 178 | }); | |
| 179 | // One face per person, however many tabs they have open. | |
| 180 | const seen = new Set<string>(); | |
| 181 | onPresence?.(out.filter((p) => !seen.has(p.key) && seen.add(p.key))); | |
| 182 | }; | |
| 183 | read(); | |
| 184 | provider.awareness.on("change", read); | |
| 185 | return () => provider.awareness.off("change", read); | |
| 186 | }, [provider, onPresence]); | |
| 187 | ||
| 188 | // Comments on the whole doc, as the document holds them. | |
| 189 | useEffect(() => { | |
| 190 | if (!onPageThreads) return; | |
| 191 | const map = provider.doc.getMap("threads"); | |
| 192 | const read = () => { | |
| 193 | const out: PageThread[] = []; | |
| 194 | map.forEach((value) => { | |
| 195 | const t = value as { get(key: string): unknown }; | |
| 196 | if (typeof t?.get !== "function" || t.get("deletedAt")) return; | |
| 197 | const meta = (t.get("metadata") as { page_level?: boolean } | null) ?? {}; | |
| 198 | if (!meta.page_level) return; | |
| 199 | const comments = (t.get("comments") as { toArray(): { get(key: string): unknown }[] } | undefined)?.toArray() ?? []; | |
| 200 | out.push({ | |
| 201 | id: String(t.get("id")), | |
| 202 | resolved: !!t.get("resolved"), | |
| 203 | comments: comments | |
| 204 | .filter((c) => !c.get("deletedAt")) | |
| 205 | .map((c) => ({ id: String(c.get("id")), author: String(c.get("userId")), text: commentText(c.get("body")), created_at: Number(c.get("createdAt")) || 0 })), | |
| 206 | }); | |
| 207 | }); | |
| 208 | onPageThreads(out.sort((a, b) => (a.comments[0]?.created_at ?? 0) - (b.comments[0]?.created_at ?? 0))); | |
| 209 | }; | |
| 210 | read(); | |
| 211 | map.observeDeep(read); | |
| 212 | return () => map.unobserveDeep(read); | |
| 213 | }, [provider, onPageThreads]); | |
| 214 | ||
| 215 | // `[[` links an artifact, as `@` mentions someone. | |
| 216 | const searchPages = async (query: string) => { | |
| 217 | try { | |
| 218 | const r = await fetch(`/${slug}/-/artifacts/api?search=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } }); | |
| 219 | const result = (await r.json()) as Result<FolioSearchHit[]>; | |
| 220 | return result.ok ? result.value : []; | |
| 221 | } catch { | |
| 222 | return []; | |
| 223 | } | |
| 224 | }; | |
| 225 | ||
| 226 | const slashItems = (query: string) => { | |
| 227 | const insert = (block: { type: string; props?: Record<string, unknown> }) => () => insertOrUpdateBlockForSlashMenu(editor as never, block as never); | |
| 228 | const custom = [ | |
| 229 | { 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" } }) }, | |
| 230 | { 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" } }) }, | |
| 231 | { 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" } }) }, | |
| 232 | { 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" }) }, | |
| 233 | { title: "Math", subtext: "A formula, typeset with KaTeX", aliases: ["math", "latex", "katex", "equation", "formula"], group: "Advanced", icon: <Sigma size={18} />, onItemClick: insert({ type: "math" }) }, | |
| 234 | { 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" }) }, | |
| 235 | { | |
| 236 | title: "Mention", | |
| 237 | subtext: "A person or an agent", | |
| 238 | aliases: ["mention", "person", "agent", "@"], | |
| 239 | group: "g1t", | |
| 240 | icon: <AtSign size={18} />, | |
| 241 | onItemClick: () => editor.insertInlineContent("@" as never), | |
| 242 | }, | |
| 243 | { | |
| 244 | title: "Link to an artifact", | |
| 245 | subtext: "A doc or another artifact", | |
| 246 | aliases: ["link", "page", "artifact", "doc", "[["], | |
| 247 | group: "g1t", | |
| 248 | icon: <Link2 size={18} />, | |
| 249 | onItemClick: () => editor.insertInlineContent("[[" as never), | |
| 250 | }, | |
| 251 | { | |
| 252 | title: "Cite code", | |
| 253 | subtext: "A file, folder, symbol, endpoint or variable this doc describes", | |
| 254 | aliases: ["cite", "code", "citation", "path", "file", "symbol", "endpoint", "env", "variable"], | |
| 255 | group: "g1t", | |
| 256 | icon: <FileCode2 size={18} />, | |
| 257 | onItemClick: () => setCiting(true), | |
| 258 | }, | |
| 259 | { | |
| 260 | title: "Date", | |
| 261 | subtext: "Today's date, which you can change", | |
| 262 | aliases: ["date", "today", "when"], | |
| 263 | group: "g1t", | |
| 264 | icon: <Calendar size={18} />, | |
| 265 | onItemClick: () => editor.insertInlineContent([{ type: "date", props: { date: new Date().toISOString().slice(0, 10) } }, " "] as never), | |
| 266 | }, | |
| 267 | ]; | |
| 268 | return filterSuggestionItems([...getDefaultReactSlashMenuItems(editor as never), ...custom], query); | |
| 269 | }; | |
| 270 | ||
| 271 | const mentionItems = (query: string) => { | |
| 272 | const q = query.toLowerCase(); | |
| 273 | return mentionables | |
| 274 | .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q)) | |
| 275 | .slice(0, 8) | |
| 276 | .map((p) => ({ | |
| 277 | title: p.display_name, | |
| 278 | subtext: p.kind === "agent" ? `@${p.name} · agent` : `@${p.name}`, | |
| 279 | icon: <AtSign size={16} />, | |
| 280 | onItemClick: () => editor.insertInlineContent([{ type: "mention", props: { kind: p.kind, id: p.id, name: p.name, href: "" } }, " "] as never), | |
| 281 | })); | |
| 282 | }; | |
| 283 | ||
| 284 | const pageItems = async (query: string) => { | |
| 285 | const pages = await searchPages(query); | |
| 286 | return pages.slice(0, 8).map((p) => ({ | |
| 287 | title: p.title || "Untitled", | |
| 288 | subtext: p.space_name ?? "Private", | |
| 289 | icon: p.icon ? <span className="text-base leading-none">{p.icon}</span> : <FileText size={16} />, | |
| 290 | onItemClick: () => editor.insertInlineContent([{ type: "mention", props: { kind: "page", id: p.id, name: p.title || "Untitled", href: p.path } }, " "] as never), | |
| 291 | })); | |
| 292 | }; | |
| 293 | ||
| 294 | // Suggestions: the blocks each one changes are struck through in place. | |
| 295 | const struck = suggestions | |
| 296 | .flatMap((s) => s.block_ids) | |
| 297 | .filter((id) => /^[0-9a-zA-Z-]+$/.test(id)) | |
| 298 | .map((id) => `.g1t-editor .bn-block-outer[data-id="${id}"] > .bn-block > .bn-block-content`) | |
| 299 | .join(",\n"); | |
| 300 | ||
| 301 | return ( | |
| 302 | <div className="g1t-editor relative"> | |
| 303 | {struck && ( | |
| 304 | <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> | |
| 305 | )} | |
| 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. | 306 | <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. | 307 | <div className={`grid gap-8 ${showComments ? "xl:grid-cols-[minmax(0,1fr)_18rem]" : ""}`}> |
| 308 | <div className="relative min-w-0"> | |
| 309 | <BlockNoteViewEditor /> | |
| 310 | {renderSuggestion && <SuggestionRail editor={editor} suggestions={suggestions} render={renderSuggestion} hidden={showComments} />} | |
| 311 | </div> | |
| 312 | {showComments && ( | |
| 313 | <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. | 314 | <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. | 315 | <ThreadsSidebar filter="all" sort="position" /> |
| 316 | </div> | |
| 317 | </aside> | |
| 318 | )} | |
| 319 | </div> | |
| 320 | <SuggestionMenuController triggerCharacter="/" getItems={async (query) => slashItems(query)} /> | |
| 321 | <SuggestionMenuController triggerCharacter="@" getItems={async (query) => mentionItems(query)} /> | |
| 322 | <SuggestionMenuController triggerCharacter="[[" getItems={pageItems} /> | |
| 323 | </BlockNoteView> | |
| 324 | {editable && ( | |
| 325 | <CiteDialog | |
| 326 | slug={slug} | |
| 327 | open={citing} | |
| 328 | onOpenChange={setCiting} | |
| 329 | projects={projects} | |
| 330 | onCite={(c) => { | |
| 331 | editor.focus(); | |
| 332 | editor.insertInlineContent([{ type: "citation", props: c }, " "] as never); | |
| 333 | }} | |
| 334 | /> | |
| 335 | )} | |
| 336 | </div> | |
| 337 | ); | |
| 338 | } | |
| 339 | ||
| 340 | /** | |
| 341 | * Each suggestion beside the blocks it changes, in the margin on a wide | |
| 342 | * screen (the doc lists them above the editor on a narrow one). | |
| 343 | */ | |
| 344 | function SuggestionRail({ editor, suggestions, render, hidden }: { editor: DocEditorInstance; suggestions: FolioSuggestion[]; render: (s: FolioSuggestion) => ReactNode; hidden: boolean }) { | |
| 345 | const box = useRef<HTMLDivElement>(null); | |
| 346 | const [tops, setTops] = useState<Record<string, number>>({}); | |
| 347 | useLayoutEffect(() => { | |
| 348 | const place = () => { | |
| 349 | const root = box.current?.parentElement; | |
| 350 | if (!root) return; | |
| 351 | const base = root.getBoundingClientRect().top; | |
| 352 | const next: Record<string, number> = {}; | |
| 353 | let floor = 0; | |
| 354 | for (const s of suggestions) { | |
| 355 | const first = s.block_ids[0]; | |
| 356 | const el = first ? root.querySelector(`.bn-block-outer[data-id="${CSS.escape(first)}"]`) : null; | |
| 357 | const want = el ? el.getBoundingClientRect().top - base : root.getBoundingClientRect().height - 40; | |
| 358 | // Cards never overlap: each starts below the one before. | |
| 359 | const top = Math.max(want, floor); | |
| 360 | next[s.id] = top; | |
| 361 | floor = top + 190; | |
| 362 | } | |
| 363 | setTops(next); | |
| 364 | }; | |
| 365 | place(); | |
| 366 | const off = editor.onChange(() => requestAnimationFrame(place)); | |
| 367 | const observer = new ResizeObserver(place); | |
| 368 | if (box.current?.parentElement) observer.observe(box.current.parentElement); | |
| 369 | return () => { | |
| 370 | off?.(); | |
| 371 | observer.disconnect(); | |
| 372 | }; | |
| 373 | }, [editor, suggestions]); | |
| 374 | if (hidden || !suggestions.length) return null; | |
| 375 | return ( | |
| 376 | <div ref={box} className="pointer-events-none absolute inset-y-0 left-full ml-8 hidden w-72 2xl:block" aria-label="Suggestions"> | |
| 377 | {suggestions.map((s) => ( | |
| 378 | <div key={s.id} className="pointer-events-auto absolute inset-x-0" style={{ top: tops[s.id] ?? 0 }}> | |
| 379 | {render(s)} | |
| 380 | </div> | |
| 381 | ))} | |
| 382 | </div> | |
| 383 | ); | |
| 384 | } | |
| 385 | ||
| 386 | /** A comment's text: its blocks' inline text, mentions as @name. */ | |
| 387 | function commentText(body: unknown): string { | |
| 388 | const out: string[] = []; | |
| 389 | const walk = (v: unknown) => { | |
| 390 | if (Array.isArray(v)) v.forEach(walk); | |
| 391 | else if (v && typeof v === "object") { | |
| 392 | const o = v as Record<string, unknown>; | |
| 393 | if (o.type === "text" && typeof o.text === "string") out.push(o.text); | |
| 394 | else if (o.type === "mention") out.push(`@${(o.props as { name?: string } | undefined)?.name ?? ""}`); | |
| 395 | else { | |
| 396 | walk(o.content); | |
| 397 | walk(o.children); | |
| 398 | } | |
| 399 | } | |
| 400 | }; | |
| 401 | walk(body); | |
| 402 | return out.join(""); | |
| 403 | } |