Skip to content

Commit

Docs: a workspace knowledge base people and agents write together

- services/docs (g1t-docs-service): spaces (workspace, team, private) with view, comment, edit and manage roles and linked projects; a page tree with favorites, recent, trash, templates and export; each page a live Yjs document in a Durable Object, saved as Markdown for the read view, search (FTS5), backlinks and history with diffs and restore; files in R2 behind a FileStore - The editor: slash menu, headings, lists, to-dos, toggles, callouts, tables, code, diagrams, math, live embeds of issues, pull requests, threads and pages, @people and agents, [[page links, dates, uploads, live cursors and faces, inline comments - Agents read, search, write and edit pages for the person they act for and only what everyone reading can see; where a space doesn't let agents edit, their edits are suggestions people accept or reject inline, attributed in history - Agents' tools: search_docs, read_page, list_doc_spaces, edit_page, create_page; available to people without Code too - Docs mode in the rail is real: home with a project filter, sidebar with space trees; the guide is rewritten

syntaqxcommitted Parent7c2dd61Browse files
80 files+7253−1410/80 viewed
+1−1
8282 { label: 'Routines', slug: 'guides/agent-routines' },
8383 { label: 'Agent budgets and spend', slug: 'guides/agent-budgets' },
8484 { label: 'What agents can do for whom', slug: 'guides/agent-access' },
85− { label: 'Docs', slug: 'guides/docs', badge: { text: 'Soon', variant: 'default' } },
85+ { label: 'Docs', slug: 'guides/docs' },
8686 { label: 'Agents in your chat app', slug: 'guides/chat-app', badge: { text: 'Soon', variant: 'default' } },
8787 ],
8888 },
+277−38
11 ---
22 title: Docs
3−description: Docs is coming to g1t. A workspace's specs, runbooks and decisions, written together, read by agents and kept current by them.
3+description: Write a workspace's specs, runbooks, decisions and onboarding together, live, with your agents. Spaces and pages, a block editor with diagrams and math, comments, suggestions from agents you accept or reject, full history, templates, search by project, and Markdown export.
44 ---
55
6+import { Steps } from '@astrojs/starlight/components';
67 import Soon from '../../../components/Soon.astro';
78 import Aside from '../../../components/Aside.astro';
89
9−<Soon />
10+**Docs is the workspace's written knowledge**: specs, runbooks, decisions,
11+onboarding and meeting notes, in the same place as the conversations and the
12+code they describe. People and agents write it together. Agents read it before
13+they answer, and when they change a page, you see exactly what they changed
14+and decide whether it stays.
15+
16+Docs is open to every member of a workspace, on every plan. Open **Docs** in
17+the rail on the left, or go to `g1t.sh/<workspace>/-/docs`.
18+
19+## How it fits together
20+
21+| | |
22+| --- | --- |
23+| **Spaces** | Where pages live: one for the whole workspace, one per team or project, or a private one for a few people. Each space decides who can read and write it, and how agents change it. |
24+| **Pages** | A tree inside each space. A page can hold other pages. Every page has an icon, an optional cover, owners, linked projects, history, comments and backlinks. |
25+| **The editor** | Blocks you add with `/`: text, headings, lists, to-dos, toggles, callouts, code, tables, images and files, Mermaid diagrams, math, and live cards for issues, pull requests, channels, projects and other pages. |
26+| **Agents** | Read the pages the person they work for can read. Suggest changes you accept or reject inline, or edit directly where a space allows it. Every change is attributed in the history. |
27+
28+Docs is its own mode, not a tab in a project. To see the pages about one
29+project, filter Docs by that project.
30+
31+## Spaces
32+
33+Every workspace starts with a **General** space for things everyone should
34+know. Make more from **New space** on Docs' home, or **+** beside **Spaces**
35+in the sidebar.
36+
37+A space is for one of three audiences:
38+
39+| Who it's for | Who can open it |
40+| --- | --- |
41+| **Everyone in the workspace** | Every member, with the space's base role. |
42+| **A team** | Members of that team, with the space's base role. |
43+| **Only its members** | The people, agents and teams listed in the space, and nobody else. Workspace owners don't see a private space unless they're added. |
44+
45+Whoever makes a space has full access to it. Owners of the workspace have full
46+access to every workspace and team space.
47+
48+### Roles
49+
50+Each person's role in a space is the highest that applies to them: the
51+space's base role (for everyone, or for the team), and any role they were
52+given in the space's **Members**, directly or through a team.
53+
54+| Role | Can |
55+| --- | --- |
56+| **Can view** | Read pages, their history and their comments. |
57+| **Can comment** | Read, and comment on passages or whole pages. |
58+| **Can edit** | Write and organize pages, accept or reject suggestions, restore versions, and move pages to the trash. |
59+| **Full access** | Edit, change the space's settings and members, and delete pages from the trash for good. |
60+
61+Change them in the space's **Settings** (the gear beside the space in the
62+sidebar, or **Settings** on the space's page):
63+
64+- **Members**: add a person, an agent or a team with a role; change or remove
65+ it. A private space always keeps at least one person with full access.
66+- **Agents in this space**: **Suggest changes** (the default) or **Edit
67+ directly**. See [Agents and pages](#agents-and-pages).
68+- **Projects**: the repositories the space is about, as `owner/name`. Docs
69+ can be filtered by them.
70+- **Archive this space** takes it out of the sidebar and search. Its pages and
71+ history are kept. The General space can't be archived.
72+
73+## Pages
74+
75+Make a page with **New page** on Docs' home or a space's page, **+** beside a
76+space or a page in the sidebar (a page inside it), or from a
77+[template](#templates). Give it a title, and press **Enter** to start
78+writing.
79+
80+Above the title, **Add icon** gives the page an emoji and **Add cover** a
81+banner. Under the title you see who edited it last and when, its owners, the
82+projects it's linked to, and how long it takes to read.
83+
84+### Organize
85+
86+- **Drag** a page in the sidebar onto another to put it inside, or onto the
87+ top edge of one to put it before it. Drop it on a space's name to move it
88+ to the top of that space.
89+- **⋯ → Move** does the same from the page, including to another space. Pages
90+ inside a page move with it.
91+- **⋯ → Duplicate** copies a page, content and all, next to it.
92+- A page's address ends in its id: `/<workspace>/-/docs/engineering/release-plan-pag_01j…`.
93+ Renaming or moving a page keeps every link to it working.
94+
95+### Favorites and recent
96+
97+Star a page (the star at the top of it) to keep it under **Favorites** in the
98+sidebar. **Recent** lists the pages you opened last.
99+
100+### Trash
101+
102+**⋯ → Move to trash** puts a page, and the pages inside it, in the **Trash**
103+(in the sidebar). Anyone who can edit the space can restore it from there,
104+with what was inside it. Deleting from the trash removes the page, its
105+history and its comments for good, and needs full access to the space.
10106
11−**Docs is the workspace's written knowledge**: specs, runbooks, decisions
12−and onboarding, in the same place as the conversations and the code they
13−describe. It is not built yet. This page says what it will do, so you can
14−plan for it.
107+## The editor
108+
109+Type `/` anywhere for the block menu, and keep typing to filter it.
15110
16−<Aside type="note" title="Until then">
17−Keep documentation in your repositories (`README.md`, a `docs/` folder),
18−where it changes through pull requests like any code. When Docs ships, a
19−project's `docs/` folder can appear as a read-only space beside the
20−workspace's own, with one search and one tree over both.
111+| Block | How to add it | In Markdown |
112+| --- | --- | --- |
113+| Text, Heading 1–3 | `/text`, `/h1`, or type `#`, `##`, `###` and a space | `#`, `##`, `###` |
114+| Bulleted, numbered and to-do lists | `/bullet`, `/numbered`, `/todo`, or type `-`, `1.`, `[]` | `-`, `1.`, `- [ ]` |
115+| Toggle | `/toggle` | `<details>` |
116+| Quote | `/quote`, or type `>` | `>` |
117+| Callout (info, warning, success) | `/callout`, `/warning`, `/tip`. Click its icon to change its kind. | `> [!NOTE]`, `> [!WARNING]`, `> [!TIP]`, `> [!CAUTION]` |
118+| Divider | `/divider`, or type `---` | `---` |
119+| Code, with syntax highlighting | `/code`, or type ` ``` ` and a language | fenced code |
120+| Table | `/table` | GitHub tables |
121+| Image, video, audio, file | `/image`, `/video`, `/audio`, `/file`: upload one, or paste a link | `![]()`, links |
122+| Diagram | `/diagram` or `/mermaid`. **Edit source** to change it; it's drawn as you type. | ` ```mermaid ` |
123+| Math | `/math` or `/latex`. Click the formula to change it. | `$$ … $$` |
124+| Embed from g1t | `/embed`, then paste the address of an issue, pull request, channel, project or page. The card shows its live title and state (open, merged, closed). | a link |
125+| Date | `/date` | the date |
126+
127+Inline:
128+
129+- **Bold**, *italic*, underline, ~~strikethrough~~, `code` and links from the
130+ toolbar that appears when you select text, or with Markdown as you type
131+ (`**bold**`, `_italic_`, `` `code` ``) and the usual shortcuts
132+ (<kbd>⌘ B</kbd>, <kbd>⌘ I</kbd>, <kbd>⌘ U</kbd>).
133+- **`@`** mentions a person or an agent. A person mentioned in a page is
134+ told, once, in their notifications.
135+- **`[[`** links to another page by title. The page you link to lists yours
136+ under **Linked from**.
137+
138+Every block has a handle on its left when you hover it: drag it to move the
139+block, or open its menu to change its type, color or delete it. Press
140+<kbd>Tab</kbd> to nest a block under the one above it.
141+
142+**Pasting Markdown** turns it into blocks. **⋯ → Copy as Markdown** puts the
143+whole page on your clipboard as Markdown.
144+
145+Images and files you add are kept with the workspace and served from
146+`g1tusercontent.com`, never from `g1t.sh`. Each can be up to 25 MB.
147+Anyone with a file's address can open it, as with any shared link, so don't
148+put a page's files where the page itself shouldn't go.
149+
150+<Aside type="note" title="Who can edit">
151+People who can only view or comment see the page live, with everyone's
152+changes as they happen, but can't change it. The editor says so under the
153+title.
21154 </Aside>
22155
23−## What it will be
156+## Writing together
157+
158+Pages are live: everyone with a page open sees each other's changes as they
159+type, with a cursor and name for each person. The faces at the top of the page
160+are who's on it now, agents included while they work on it.
161+
162+Edits merge however many people type at once, even in the same paragraph, and
163+nothing is lost if your connection drops: the page says **Offline** and
164+sends your changes when you're back.
165+
166+## Comments
167+
168+- **On a passage**: select text and choose **Comment**. The passage is
169+ highlighted, and the thread stays attached to it as the page changes.
170+- **On the whole page**: write in **Discussion** at the bottom of the page.
171+- **All of them**: the comments button at the top of the page opens them
172+ beside the text, in the order they appear in it.
173+
174+Reply in a thread, react to a comment, and **Resolve** a thread when it's
175+done (you can reopen it). `@`-mention someone in a comment to tell them; they
176+are told only if they can read the page.
24177
25−- **Spaces** hold pages: one per team or project, plus one for the whole
26− workspace.
27−- Docs is its own mode in the workspace's rail, not a tab in a project. To
28− see one project's pages, filter Docs by that project.
29−- **Pages** are edited together, live, with mentions of people, agents,
30− issues, pull requests, channels and other pages.
31−- Every page has **history**, **comments**, **backlinks** and **owners**.
178+Anyone who can comment can start threads, reply, react and resolve. Only a
179+comment's author edits it; its author or an editor deletes it; editors delete
180+whole threads.
32181
33−## Agents and docs
182+## Agents and pages
34183
35−This is why Docs belongs next to Chat and Code:
184+Agents work on Docs for a person, never on their own authority. An agent
185+asked by you:
36186
37−| | |
187+- **reads** only pages in spaces you can read. When it answers somewhere
188+ others will read the answer (a channel), it uses only pages everyone there
189+ can read. See [What agents can do for whom](/guides/agent-access/).
190+- **suggests** a change where you can comment or edit. A suggestion is a
191+ tracked change: the blocks it replaces are struck through in place, and
192+ the card beside them (or above the page on a narrower screen) shows what
193+ it would write instead.
194+- **edits directly** only where you can edit **and** the space's **Agents in
195+ this space** is **Edit directly**. Otherwise its edit becomes a
196+ suggestion.
197+- **makes new pages** where you can edit, such as "write this up" in a
198+ thread. The page is yours: you're its owner, and it links back to where it
199+ came from.
200+
201+### Accept or reject
202+
203+Each suggestion says which agent made it, who it was for, and why.
204+
205+- **Accept** applies it to the page, live for everyone on it. The history
206+ records it as "Suggested by @inky, accepted by @ana".
207+- **Reject** drops it. Nothing on the page changes.
208+- **Accept all** applies every open suggestion on the page, oldest first.
209+
210+Accepting needs edit access to the space. When the part of the page a
211+suggestion changes was deleted meanwhile, the suggestion can't be applied and
212+is marked stale.
213+
214+A page's owners are told when an agent suggests a change to it.
215+
216+<Aside type="tip" title="Ask in chat">
217+"@inky the rollout section of the release plan is out of date, fix it" in a
218+channel works: Inky reads the page, and suggests or makes the change for
219+you, depending on the space.
220+</Aside>
221+
222+## History
223+
224+**⋯ → History** lists every version of the page: when, who (people and agents),
225+and what kind of change it was. Choose a version to see what changed in it,
226+line by line.
227+
228+A new version is recorded:
229+
230+- after a burst of editing, at most every 10 minutes while people type;
231+- for every change an agent makes, and every suggestion accepted;
232+- when a version is restored.
233+
234+**Restore this version** makes the page what it was then, as a new version,
235+so nothing after it is lost. Restoring needs edit access.
236+
237+## Templates
238+
239+Start a page with its structure already in place: **Templates** in the
240+sidebar, the template row on Docs' home, or ask an agent to use one.
241+
242+g1t's templates:
243+
244+| Template | For |
38245 | --- | --- |
39−| **Agents read it** | Pages are indexed with the rest of the workspace's context, so what is written down reaches every answer. A space can be pinned to an agent as required reading. |
40−| **Agents write it** | An agent with write access to a space edits pages directly. Without it, its edit is a **suggestion**: tracked changes a person accepts or rejects inline, like a review. Every edit is attributed and in the page's history. |
41−| **Pages know what they describe** | A page can cite code: paths, symbols, endpoints, settings. When a merged pull request changes something a page cites, the page is marked possibly stale and its owners are told. If an agent owns the page, it drafts the update. |
42−| **Conversations become pages** | "Write this up" in a thread makes a page from it, linked both ways. Decisions made in chat get a home. |
246+| **Meeting notes** | Attendees, agenda, decisions, action items. |
247+| **Spec / RFC** | Problem, goals and non-goals, design with a diagram, alternatives, rollout, open questions. |
248+| **Decision record** | Context, the decision, options compared, consequences. |
249+| **Runbook** | Symptoms, checks, the fix, when to escalate. |
250+| **Onboarding** | A new teammate's first day and week, people to know, links. |
251+| **Project brief** | Why, what done looks like, scope, team, milestones, risks. |
252+| **Postmortem** | Severity, timeline, root cause, what went well and badly, action items. |
253+| **Weekly update** | Shipped, in progress, next, blocked. |
254+
255+Save any page as one of your workspace's templates with **⋯ → Save as
256+template**. Everyone in the workspace can use it. Whoever saved a template,
257+or an owner, can delete it.
258+
259+## Search and projects
260+
261+The search box at the top of the sidebar, and **Search docs** on Docs' home,
262+look through the titles and text of every page you can read. Narrow it to one
263+space or one project.
264+
265+A page belongs to a project when the page, or its space, is linked to the
266+project. On Docs' home, **All projects** filters the recently edited pages and
267+the spaces to one project.
268+
269+Agents search the same way, and only find what the people they answer can
270+read.
43271
44−The **Technical Writer** [template](/guides/agents/#role-templates) is
45−already available: hire Inky from it in a click. Today Inky works in chat;
46−with Docs, Inky will keep a space current:
47−updating pages after merges, writing release notes and the weekly summary,
48−and turning incident threads into postmortems.
272+## Export
49273
50−## How it will be stored
274+- **⋯ → Export Markdown** downloads a page as a `.md` file.
275+- **Export** on a space's page downloads the whole space as a zip of Markdown
276+ files, in folders that follow its page tree.
51277
52−Each space is a git repository in g1t's own storage, so history, blame and
53−export come with it, agents edit pages with the same tools they use on
54−code, and a self-hosted g1t keeps everything in git.
278+Diagrams export as ` ```mermaid ` blocks, math as `$$` blocks, callouts as
279+GitHub alerts, toggles as `<details>`, and embeds and page links as links, so
280+the files read well anywhere Markdown does.
55281
282+## Coming soon <Soon />
283+
284+- **Pages that know what they describe.** A page that cites code (a path, an
285+ endpoint, a setting) is marked possibly stale when a merged pull request
286+ changes it, and its owners are told. When an agent owns the page, it drafts
287+ the update.
288+- **"Write this up" from a thread** in one click, linked both ways.
289+- **A documenter agent** that keeps a space current after merges and writes
290+ the weekly summary.
291+- **A project's `docs/` folder as a read-only space**, in the same tree and
292+ search as your own spaces. Editing one of its pages opens a pull request.
293+
56294 ## Pricing
57295
58−Docs will be included on every plan, like [Chat](/guides/chat/): pages,
59−editing and history, with no seats. Files in pages are on the storage
60−meter. See [pricing](https://g1t.sh/pricing).
296+Docs is included on every plan: spaces, pages, live editing, comments, history
297+and search, with no seats. Files in pages count toward storage. Agents' work
298+on pages is charged to the agent, like any of its work; see
299+[what an agent costs](/guides/agents/#what-an-agent-costs).
+1−1
2424 | --- | --- |
2525 | **[Chat](/guides/chat/)** | Channels, direct messages and threads, live. People and agents are members alike. |
2626 | **[Agents](/guides/agents/)** | Your workspace's colleagues: who they are, what they answer for, what they cost. `@g1t` orchestrates. |
27−| **[Docs](/guides/docs/)** <Soon /> | Specs, runbooks and decisions, written together and kept current by agents. |
27+| **[Docs](/guides/docs/)** | Specs, runbooks and decisions, written together live by people and agents, and searchable by project. |
2828 | **[Code](/concepts/overview/)** | Repositories, issues, pull requests, checks, the merge queue and deployments. |
2929
3030 <CardGrid>
+95−0
694694 .card-md .prose pre {
695695 @apply p-2.5 text-xs leading-5;
696696 }
697+
698+/*
699+ * The Docs page editor (components/docs/editor.tsx) is BlockNote with its
700+ * shadcn components. Their classes are generated from the package, and
701+ * shadcn's token names map onto g1t's. `accent` and `muted` mean
702+ * something else in shadcn (a hover fill, a quiet fill), so inside the
703+ * editor they are redefined; g1t's own blocks there use the g1t tokens
704+ * directly.
705+ */
706+@source "../../../node_modules/@blocknote/shadcn/dist";
707+
708+@theme {
709+ --color-background: var(--g1t-bg);
710+ --color-foreground: var(--g1t-fg);
711+ --color-popover: var(--g1t-surface);
712+ --color-popover-foreground: var(--g1t-fg);
713+ --color-card: var(--g1t-surface);
714+ --color-card-foreground: var(--g1t-fg);
715+ --color-primary: var(--g1t-accent);
716+ --color-primary-foreground: var(--g1t-bg);
717+ --color-secondary: var(--g1t-raised);
718+ --color-secondary-foreground: var(--g1t-fg);
719+ --color-muted-foreground: var(--g1t-muted);
720+ --color-accent-foreground: var(--g1t-fg);
721+ --color-destructive: var(--g1t-danger);
722+ --color-destructive-foreground: var(--g1t-bg);
723+ --color-border: var(--g1t-line);
724+ --color-input: var(--g1t-line);
725+ --color-ring: var(--g1t-accent);
726+}
727+
728+.bn-shadcn {
729+ --color-accent: var(--g1t-raised);
730+ --color-muted: var(--g1t-raised);
731+ --bn-colors-editor-background: transparent;
732+ --bn-colors-editor-text: var(--g1t-fg);
733+ --bn-colors-menu-background: var(--g1t-surface);
734+ --bn-colors-menu-text: var(--g1t-fg);
735+ --bn-colors-hovered-background: var(--g1t-raised);
736+ --bn-colors-side-menu: var(--g1t-faint);
737+ --bn-colors-border: var(--g1t-line);
738+ --bn-border-radius: 8px;
739+}
740+
741+.g1t-editor .bn-editor {
742+ font-family: var(--g1t-font-sans);
743+ font-size: 1rem;
744+ line-height: 1.65;
745+ background: transparent;
746+ color: var(--g1t-fg);
747+}
748+
749+/* The drag handle and + sit in the margin, so the text lines up with the title. */
750+@media (min-width: 768px) {
751+ .g1t-editor .bn-container {
752+ margin-inline: -54px;
753+ }
754+}
755+
756+.g1t-editor .bn-block-content[data-content-type="heading"] h1,
757+.g1t-editor .bn-block-content[data-content-type="heading"] h2,
758+.g1t-editor .bn-block-content[data-content-type="heading"] h3 {
759+ font-family: var(--g1t-font-display);
760+ letter-spacing: -0.012em;
761+}
762+
763+.g1t-editor .bn-inline-content code {
764+ font-family: var(--g1t-font-mono);
765+ font-size: 0.875em;
766+ padding: 0.1em 0.35em;
767+ border-radius: 4px;
768+ background: var(--g1t-raised);
769+}
770+
771+.g1t-editor .bn-block-content[data-content-type="codeBlock"] {
772+ border-radius: 10px;
773+ background: var(--g1t-surface);
774+ border: 1px solid var(--g1t-line);
775+}
776+
777+.g1t-editor .bn-thread-mark {
778+ background: color-mix(in srgb, var(--g1t-warn) 22%, transparent);
779+ border-bottom: 2px solid color-mix(in srgb, var(--g1t-warn) 60%, transparent);
780+}
781+
782+.g1t-editor a {
783+ color: var(--g1t-accent);
784+}
785+
786+/* A page read before the editor loads: the same measure as the editor. */
787+.docs-read .prose {
788+ font-size: 1rem;
789+ line-height: 1.65;
790+}
791+
+56−0
1+/**
2+ * How Docs pages change things: a JSON request to `-/docs/api`, then the
3+ * sidebar and page load again so they show it. Returns the service's
4+ * `{ ok, value }` or `{ ok: false, error }`.
5+ */
6+import type { Result } from "@g1t/contracts";
7+import { useCallback, useState } from "react";
8+import { useRevalidator, useRouteLoaderData } from "react-router";
9+
10+import type { DocsLayoutData } from "../../routes/workspace/docs/layout";
11+
12+export function useDocsData(): DocsLayoutData | undefined {
13+ return useRouteLoaderData("routes/workspace/docs/layout") as DocsLayoutData | undefined;
14+}
15+
16+export async function docsRequest<T>(slug: string, intent: string, body: Record<string, unknown> = {}): Promise<Result<T>> {
17+ try {
18+ const response = await fetch(`/${slug}/-/docs/api`, {
19+ method: "POST",
20+ headers: { "content-type": "application/json", accept: "application/json" },
21+ body: JSON.stringify({ intent, ...body }),
22+ });
23+ return (await response.json()) as Result<T>;
24+ } catch {
25+ return { ok: false, error: { code: "conflict", message: "Docs didn't answer. Check your connection and try again." } };
26+ }
27+}
28+
29+export async function docsQuery<T>(slug: string, query: Record<string, string>): Promise<Result<T>> {
30+ try {
31+ const response = await fetch(`/${slug}/-/docs/api?${new URLSearchParams(query)}`, { headers: { accept: "application/json" } });
32+ return (await response.json()) as Result<T>;
33+ } catch {
34+ return { ok: false, error: { code: "conflict", message: "Docs didn't answer. Check your connection and try again." } };
35+ }
36+}
37+
38+/** `send(intent, body)`: makes the change, then reloads what shows it. `busy` while it works; `error` when it failed. */
39+export function useDocsAction(slug: string) {
40+ const revalidator = useRevalidator();
41+ const [busy, setBusy] = useState(false);
42+ const [error, setError] = useState<string | null>(null);
43+ const send = useCallback(
44+ async <T>(intent: string, body: Record<string, unknown> = {}, options: { reload?: boolean } = {}): Promise<Result<T>> => {
45+ setBusy(true);
46+ setError(null);
47+ const result = await docsRequest<T>(slug, intent, body);
48+ setBusy(false);
49+ if (!result.ok) setError(result.error.message);
50+ else if (options.reload !== false) void revalidator.revalidate();
51+ return result;
52+ },
53+ [slug, revalidator],
54+ );
55+ return { send, busy, error, setError };
56+}
+297−0
1+/**
2+ * g1t's own blocks in the page editor, beside BlockNote's: callouts,
3+ * Mermaid diagrams, math, and cards for g1t things (an issue, a pull
4+ * request, a channel, a project, another page); and inline mentions of
5+ * people, agents and pages, and dates. The docs service reads the same
6+ * names and attributes when it writes Markdown (services/docs
7+ * src/markdown.ts), so keep the two in step. Browser-only: loaded with
8+ * the editor.
9+ */
10+import { BlockNoteSchema, createCodeBlockSpec, defaultBlockSpecs, defaultInlineContentSpecs } from "@blocknote/core";
11+import { createReactBlockSpec, createReactInlineContentSpec } from "@blocknote/react";
12+import { codeBlockOptions } from "@blocknote/code-block";
13+import { AlertTriangle, CheckCircle2, CircleDot, FileText, GitPullRequest, Hash, Info, OctagonAlert, Package } from "lucide-react";
14+import { useEffect, useId, useState, type ReactNode } from "react";
15+
16+/** What an embed shows, from the site (`-/docs/api?embed=`). */
17+export type EmbedCard = { kind: "issue" | "pull" | "channel" | "project" | "page" | "link"; title: string; subtitle: string | null; state: string | null; href: string };
18+
19+const CALLOUTS = {
20+ info: { icon: <Info size={16} />, ring: "border-info/30 bg-info/8", text: "text-info" },
21+ warning: { icon: <AlertTriangle size={16} />, ring: "border-warn/30 bg-warn/8", text: "text-warn" },
22+ success: { icon: <CheckCircle2 size={16} />, ring: "border-success/30 bg-success/8", text: "text-success" },
23+ danger: { icon: <OctagonAlert size={16} />, ring: "border-danger/30 bg-danger/8", text: "text-danger" },
24+} as const;
25+
26+export const Callout = createReactBlockSpec(
27+ {
28+ type: "callout",
29+ propSchema: {
30+ kind: { default: "info", values: ["info", "warning", "success", "danger"] },
31+ backgroundColor: { default: "default" },
32+ textColor: { default: "default" },
33+ },
34+ content: "inline",
35+ },
36+ {
37+ render: ({ block, editor, contentRef }) => {
38+ const look = CALLOUTS[block.props.kind as keyof typeof CALLOUTS] ?? CALLOUTS.info;
39+ const next = { info: "warning", warning: "success", success: "danger", danger: "info" } as const;
40+ return (
41+ <div className={`my-1 flex w-full gap-2.5 rounded-lg border px-3.5 py-2.5 ${look.ring}`}>
42+ <button
43+ type="button"
44+ contentEditable={false}
45+ aria-label="Change the callout's kind"
46+ disabled={!editor.isEditable}
47+ onClick={() => editor.updateBlock(block, { props: { kind: next[block.props.kind as keyof typeof next] ?? "info" } })}
48+ className={`mt-0.5 shrink-0 ${look.text}`}
49+ >
50+ {look.icon}
51+ </button>
52+ <div ref={contentRef} className="min-w-0 grow" />
53+ </div>
54+ );
55+ },
56+ },
57+);
58+
59+/** A diagram drawn from its Mermaid source, live as it is typed. */
60+function MermaidView({ code }: { code: string }) {
61+ const id = useId().replace(/[^a-zA-Z0-9]/g, "");
62+ const [svg, setSvg] = useState<string | null>(null);
63+ const [error, setError] = useState<string | null>(null);
64+ useEffect(() => {
65+ let cancelled = false;
66+ const timer = setTimeout(async () => {
67+ try {
68+ const mermaid = (await import("mermaid")).default;
69+ mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict", fontFamily: "inherit" });
70+ const { svg } = await mermaid.render(`mermaid-${id}-${Date.now()}`, code || "flowchart LR\n A --> B");
71+ if (!cancelled) {
72+ setSvg(svg);
73+ setError(null);
74+ }
75+ } catch (e) {
76+ if (!cancelled) setError(e instanceof Error ? e.message.split("\n")[0]! : "This diagram has an error.");
77+ }
78+ }, 250);
79+ return () => {
80+ cancelled = true;
81+ clearTimeout(timer);
82+ };
83+ }, [code, id]);
84+ if (error) return <p className="text-xs text-danger">{error}</p>;
85+ if (!svg) return <p className="text-xs text-faint">Drawing…</p>;
86+ // Mermaid sanitizes what it draws (securityLevel "strict").
87+ return <div className="flex justify-center overflow-x-auto [&_svg]:max-w-full" dangerouslySetInnerHTML={{ __html: svg }} />;
88+}
89+
90+function SourceEditor({ value, onChange, placeholder, mono = true, editable }: { value: string; onChange: (v: string) => void; placeholder: string; mono?: boolean; editable: boolean }) {
91+ const [draft, setDraft] = useState(value);
92+ useEffect(() => setDraft(value), [value]);
93+ if (!editable) return null;
94+ return (
95+ <textarea
96+ value={draft}
97+ placeholder={placeholder}
98+ spellCheck={false}
99+ rows={Math.min(14, Math.max(2, draft.split("\n").length))}
100+ onChange={(e) => setDraft(e.target.value)}
101+ onBlur={() => draft !== value && onChange(draft)}
102+ onKeyDown={(e) => e.stopPropagation()}
103+ className={`w-full resize-y rounded-md border border-line bg-bg px-2.5 py-2 text-[0.8125rem] leading-relaxed text-fg outline-none focus:border-[var(--g1t-accent)] ${mono ? "font-mono" : ""}`}
104+ />
105+ );
106+}
107+
108+export const Mermaid = createReactBlockSpec(
109+ { type: "mermaid", propSchema: { code: { default: "flowchart LR\n A[Idea] --> B[Spec] --> C[Shipped]" } }, content: "none" },
110+ {
111+ render: ({ block, editor }) => {
112+ const [editing, setEditing] = useState(false);
113+ return (
114+ <div className="my-1 w-full rounded-lg border border-line bg-surface p-3" contentEditable={false}>
115+ <div className="mb-2 flex items-center justify-between text-[0.6875rem] font-medium tracking-wide text-faint uppercase">
116+ <span>Diagram</span>
117+ {editor.isEditable && (
118+ <button type="button" onClick={() => setEditing(!editing)} className="rounded px-1.5 py-0.5 normal-case hover:bg-raised hover:text-fg">
119+ {editing ? "Done" : "Edit source"}
120+ </button>
121+ )}
122+ </div>
123+ {editing && <SourceEditor editable value={block.props.code} placeholder="flowchart LR&#10; A --> B" onChange={(code) => editor.updateBlock(block, { props: { code } })} />}
124+ <div className={editing ? "mt-3" : ""}>
125+ <MermaidView code={block.props.code} />
126+ </div>
127+ </div>
128+ );
129+ },
130+ },
131+);
132+
133+/** A formula, typeset with KaTeX. */
134+function MathView({ expression, inline = false }: { expression: string; inline?: boolean }) {
135+ const [html, setHtml] = useState<string | null>(null);
136+ useEffect(() => {
137+ let cancelled = false;
138+ void (async () => {
139+ const [{ default: katex }] = await Promise.all([import("katex"), import("katex/dist/katex.min.css")]);
140+ const out = katex.renderToString(expression || "\\;", { throwOnError: false, displayMode: !inline, output: "html", trust: false, strict: "ignore" });
141+ if (!cancelled) setHtml(out);
142+ })();
143+ return () => {
144+ cancelled = true;
145+ };
146+ }, [expression, inline]);
147+ if (html === null) return <code className="text-xs text-faint">{expression}</code>;
148+ // KaTeX escapes what it is given and allows no commands that run anything (trust: false).
149+ return <div className="overflow-x-auto text-fg" dangerouslySetInnerHTML={{ __html: html }} />;
150+}
151+
152+export const MathBlock = createReactBlockSpec(
153+ { type: "math", propSchema: { expression: { default: "e^{i\\pi} + 1 = 0" } }, content: "none" },
154+ {
155+ render: ({ block, editor }) => {
156+ const [editing, setEditing] = useState(false);
157+ return (
158+ <div className="group/math my-1 w-full rounded-lg px-3 py-2 hover:bg-surface" contentEditable={false}>
159+ {editing ? (
160+ <SourceEditor editable value={block.props.expression} placeholder="\int_0^1 x^2\,dx" onChange={(expression) => editor.updateBlock(block, { props: { expression } })} />
161+ ) : null}
162+ <button type="button" className="block w-full text-left" disabled={!editor.isEditable} onClick={() => setEditing(!editing)} aria-label={editing ? "Done editing the formula" : "Edit the formula"}>
163+ <MathView expression={block.props.expression} />
164+ </button>
165+ </div>
166+ );
167+ },
168+ },
169+);
170+
171+const EMBED_ICONS: Record<EmbedCard["kind"], ReactNode> = {
172+ issue: <CircleDot size={16} />,
173+ pull: <GitPullRequest size={16} />,
174+ channel: <Hash size={16} />,
175+ project: <Package size={16} />,
176+ page: <FileText size={16} />,
177+ link: <FileText size={16} />,
178+};
179+
180+/** The card for a g1t address, asked of the site as the reader (so it shows only what they may see). */
181+function EmbedView({ url, title }: { url: string; title: string }) {
182+ const [card, setCard] = useState<EmbedCard | null | "missing">(null);
183+ useEffect(() => {
184+ let cancelled = false;
185+ const workspace = window.location.pathname.split("/")[1] ?? "";
186+ fetch(`/${workspace}/-/docs/api?embed=${encodeURIComponent(url)}`, { headers: { accept: "application/json" } })
187+ .then((r) => r.json() as Promise<{ ok: boolean; value?: EmbedCard }>)
188+ .then((r) => !cancelled && setCard(r.ok && r.value ? r.value : "missing"))
189+ .catch(() => !cancelled && setCard("missing"));
190+ return () => {
191+ cancelled = true;
192+ };
193+ }, [url]);
194+ const shown = card && card !== "missing" ? card : null;
195+ const state = shown?.state ?? null;
196+ const tone = state === "open" ? "text-success" : state === "merged" ? "text-merged" : state === "closed" ? "text-danger" : "text-[var(--g1t-muted)]";
197+ return (
198+ <a
199+ href={shown?.href ?? url}
200+ contentEditable={false}
201+ className="my-1 flex w-full items-center gap-3 rounded-lg border border-line bg-surface px-3.5 py-2.5 no-underline transition-colors hover:border-line-strong hover:bg-raised"
202+ >
203+ <span className={`shrink-0 ${tone}`}>{EMBED_ICONS[shown?.kind ?? "link"]}</span>
204+ <span className="min-w-0 grow">
205+ <span className="block truncate text-sm font-medium text-fg">{shown?.title ?? (title || url)}</span>
206+ <span className="block truncate text-xs text-faint">{card === null ? "Loading…" : card === "missing" ? "You can't see this, or it's gone." : shown?.subtitle}</span>
207+ </span>
208+ {state && <span className={`shrink-0 rounded-full px-2 py-0.5 text-[0.6875rem] font-medium capitalize ring-1 ring-current/30 ${tone}`}>{state}</span>}
209+ </a>
210+ );
211+}
212+
213+export const Embed = createReactBlockSpec(
214+ { type: "embed", propSchema: { url: { default: "" }, title: { default: "" }, kind: { default: "link" } }, content: "none" },
215+ {
216+ render: ({ block, editor }) => {
217+ const [draft, setDraft] = useState("");
218+ if (!block.props.url) {
219+ if (!editor.isEditable) return <div contentEditable={false} />;
220+ return (
221+ <form
222+ contentEditable={false}
223+ className="my-1 flex w-full gap-2 rounded-lg border border-dashed border-line p-2"
224+ onSubmit={(e) => {
225+ e.preventDefault();
226+ const url = draft.trim();
227+ if (url) editor.updateBlock(block, { props: { url } });
228+ }}
229+ >
230+ <input
231+ autoFocus
232+ value={draft}
233+ onChange={(e) => setDraft(e.target.value)}
234+ onKeyDown={(e) => e.stopPropagation()}
235+ placeholder="Paste the address of an issue, pull request, channel, project or page"
236+ className="min-w-0 grow bg-transparent px-1.5 text-sm text-fg outline-none placeholder:text-faint"
237+ />
238+ <button type="submit" className="rounded-md bg-raised px-2.5 py-1 text-xs font-medium text-fg hover:bg-line">
239+ Embed
240+ </button>
241+ </form>
242+ );
243+ }
244+ return <EmbedView url={block.props.url} title={block.props.title} />;
245+ },
246+ },
247+);
248+
249+/** A person, agent or page, inline. */
250+export const Mention = createReactInlineContentSpec(
251+ {
252+ type: "mention",
253+ propSchema: { kind: { default: "user" }, id: { default: "" }, name: { default: "" }, href: { default: "" } },
254+ content: "none",
255+ },
256+ {
257+ render: ({ inlineContent }) => {
258+ const p = inlineContent.props;
259+ if (p.kind === "page") {
260+ return (
261+ <a href={p.href || "#"} className="rounded px-0.5 font-medium text-[var(--g1t-accent)] no-underline hover:underline">
262+ <FileText size={13} className="mr-0.5 inline -translate-y-px" aria-hidden="true" />
263+ {p.name || "Untitled"}
264+ </a>
265+ );
266+ }
267+ return <span className={`rounded px-1 py-px font-medium ${p.kind === "agent" ? "bg-[color-mix(in_srgb,var(--g1t-accent)_15%,transparent)] text-[var(--g1t-accent)]" : "bg-info/12 text-info"}`}>@{p.name}</span>;
268+ },
269+ },
270+);
271+
272+/** A date, inline. */
273+export const DateChip = createReactInlineContentSpec(
274+ { type: "date", propSchema: { date: { default: "" } }, content: "none" },
275+ {
276+ render: ({ inlineContent }) => {
277+ const at = new Date(`${inlineContent.props.date}T12:00:00`);
278+ const label = Number.isNaN(at.getTime()) ? inlineContent.props.date : at.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", year: "numeric" });
279+ return <span className="rounded bg-raised px-1 py-px text-[0.9em] text-fg-soft">{label}</span>;
280+ },
281+ },
282+);
283+
284+export const schema = BlockNoteSchema.create({
285+ blockSpecs: {
286+ ...defaultBlockSpecs,
287+ codeBlock: createCodeBlockSpec(codeBlockOptions),
288+ callout: Callout(),
289+ mermaid: Mermaid(),
290+ math: MathBlock(),
291+ embed: Embed(),
292+ },
293+ inlineContentSpecs: { ...defaultInlineContentSpecs, mention: Mention, date: DateChip },
294+});
295+
296+export type DocSchema = typeof schema;
297+export type DocEditorInstance = typeof schema.BlockNoteEditor;
+10−0
1+/** Lines standing in for a page while its editor loads (no editor code, so the server can draw it). */
2+export function EditorSkeleton() {
3+ return (
4+ <div className="space-y-3 py-2" aria-busy="true" aria-label="Loading the editor">
5+ {[92, 80, 86, 40].map((w, i) => (
6+ <div key={i} className="h-4 animate-pulse rounded bg-raised motion-reduce:animate-none" style={{ width: `${w}%` }} />
7+ ))}
8+ </div>
9+ );
10+}
+382−0
1+/**
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+ */
9+import "@blocknote/shadcn/style.css";
10+
11+import { filterSuggestionItems, insertOrUpdateBlockForSlashMenu } from "@blocknote/core";
12+import { CommentsExtension, DefaultThreadStoreAuth, ThreadStoreAuth, type CommentData, type ThreadData } from "@blocknote/core/comments";
13+import { RESTYjsThreadStore, withCollaboration } from "@blocknote/core/yjs";
14+import { syntaxHighlighter } from "@blocknote/code-block";
15+import { BlockNoteViewEditor, SuggestionMenuController, ThreadsSidebar, getDefaultReactSlashMenuItems, useCreateBlockNote } from "@blocknote/react";
16+import { BlockNoteView } from "@blocknote/shadcn";
17+import type { DocFile, DocRole, DocSuggestion, DocsLiveEvent, Result } from "@g1t/contracts";
18+import { AlertTriangle, AtSign, Calendar, CheckCircle2, FileText, GitPullRequest, Info, Link2, Sigma, Workflow } from "lucide-react";
19+import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
20+
21+import { canDo, cursorColour } from "../../lib/docs";
22+import { schema, type DocEditorInstance } from "./blocks";
23+import { EditorSkeleton } from "./editor-skeleton";
24+import type { PageThread } from "./page-parts";
25+import { DocsProvider, type LiveStatus } from "./provider";
26+import { whoAre } from "./who";
27+
28+/** Someone who can be mentioned: a person (by username) or an agent (by id). */
29+export type DocMentionable = { kind: "user" | "agent"; id: string; name: string; display_name: string; avatar: string | null; avatar_seed?: string | null };
30+
31+/** Someone on the page now. */
32+export type Presence = { client: number; key: string; name: string; kind: "user" | "agent"; color: string; avatar: string | null; me: boolean };
33+
34+export type DocEditorProps = {
35+ slug: string;
36+ pageId: string;
37+ role: DocRole;
38+ me: { key: string; name: string; display_name: string; avatar: string | null };
39+ mentionables: DocMentionable[];
40+ usercontent: string;
41+ suggestions: DocSuggestion[];
42+ showComments: boolean;
43+ onPresence?: (people: Presence[]) => void;
44+ onStatus?: (status: LiveStatus) => void;
45+ onEvent?: (event: DocsLiveEvent) => void;
46+ /** The suggestion cards' own UI, drawn by the page beside the blocks each one changes. */
47+ renderSuggestion?: (suggestion: DocSuggestion) => ReactNode;
48+ /** Comments on the whole page (not on a passage), live from the document. */
49+ onPageThreads?: (threads: PageThread[]) => void;
50+};
51+
52+/** Nobody can do anything with threads: a reader's view. */
53+class 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+
83+export default function DocEditor(props: DocEditorProps) {
84+ const { slug, pageId } = props;
85+ const [provider, setProvider] = useState<DocsProvider | null>(null);
86+ const [synced, setSynced] = useState(false);
87+
88+ useEffect(() => {
89+ const scheme = window.location.protocol === "https:" ? "wss" : "ws";
90+ const live = new DocsProvider(`${scheme}://${window.location.host}/${slug}/-/docs/live?page=${encodeURIComponent(pageId)}`);
91+ setProvider(live);
92+ setSynced(false);
93+ // Mount the editor once the document is here, so it never starts from
94+ // an empty page 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 page.
108+ // eslint-disable-next-line react-hooks/exhaustive-deps
109+ }, [slug, pageId]);
110+
111+ if (!provider || !synced) return <EditorSkeleton />;
112+ return <LiveEditor {...props} provider={provider} />;
113+}
114+
115+function LiveEditor({ slug, pageId, role, me, mentionables, usercontent, suggestions, showComments, onPresence, renderSuggestion, onPageThreads, provider }: DocEditorProps & { provider: DocsProvider }) {
116+ const editable = canDo(role, "edit");
117+ const colour = cursorColour(me.name);
118+
119+ const threadStore = useMemo(() => {
120+ const auth = canDo(role, "comment") ? new DefaultThreadStoreAuth(me.key, editable ? "editor" : "comment") : new ReadOnlyAuth();
121+ return new RESTYjsThreadStore(`/${slug}/-/docs/threads/${pageId}`, {}, provider.doc.getMap("threads"), auth);
122+ }, [slug, pageId, role, me.key, editable, provider]);
123+
124+ const editor = useCreateBlockNote(
125+ withCollaboration({
126+ schema,
127+ collaboration: {
128+ fragment: provider.doc.getXmlFragment("document-store"),
129+ user: { name: me.display_name, color: colour, key: me.key, kind: "user", avatar: me.avatar ?? "" },
130+ provider,
131+ showCursorLabels: "activity",
132+ },
133+ extensions: [
134+ CommentsExtension({
135+ threadStore,
136+ resolveUsers: async (ids: string[]) => {
137+ const found = await whoAre(slug, ids);
138+ return ids.map((id) => {
139+ const p = found.get(id);
140+ const avatar = id === me.key ? me.avatar : (p?.avatar ?? null);
141+ return { id, username: id === me.key ? me.display_name : (p?.display_name ?? "Someone"), avatarUrl: avatar ? `${usercontent}/avatars/${avatar}` : "" };
142+ });
143+ },
144+ }),
145+ syntaxHighlighter,
146+ ],
147+ uploadFile: async (file: File) => {
148+ const response = await fetch(`/${slug}/-/docs/upload?page=${encodeURIComponent(pageId)}&name=${encodeURIComponent(file.name)}`, {
149+ method: "POST",
150+ headers: { "content-type": file.type || "application/octet-stream" },
151+ body: file,
152+ });
153+ const result = (await response.json()) as Result<DocFile>;
154+ if (!result.ok) throw new Error(result.error.message);
155+ return `${usercontent}${result.value.url}`;
156+ },
157+ }),
158+ [provider, threadStore],
159+ ) as unknown as DocEditorInstance;
160+
161+ // Who is here: everyone's awareness state, people and agents.
162+ useEffect(() => {
163+ const read = () => {
164+ const out: Presence[] = [];
165+ provider.awareness.getStates().forEach((state, client) => {
166+ const user = (state as { user?: { name?: string; color?: string; key?: string; kind?: string; avatar?: string } }).user;
167+ if (!user?.name) return;
168+ out.push({
169+ client,
170+ key: user.key ?? String(client),
171+ name: user.name,
172+ kind: user.kind === "agent" ? "agent" : "user",
173+ color: user.color ?? "#b8a6ff",
174+ avatar: user.avatar || null,
175+ me: client === provider.doc.clientID,
176+ });
177+ });
178+ // One face per person, however many tabs they have open.
179+ const seen = new Set<string>();
180+ onPresence?.(out.filter((p) => !seen.has(p.key) && seen.add(p.key)));
181+ };
182+ read();
183+ provider.awareness.on("change", read);
184+ return () => provider.awareness.off("change", read);
185+ }, [provider, onPresence]);
186+
187+ // Comments on the whole page, as the document holds them.
188+ useEffect(() => {
189+ if (!onPageThreads) return;
190+ const map = provider.doc.getMap("threads");
191+ const read = () => {
192+ const out: PageThread[] = [];
193+ map.forEach((value) => {
194+ const t = value as { get(key: string): unknown };
195+ if (typeof t?.get !== "function" || t.get("deletedAt")) return;
196+ const meta = (t.get("metadata") as { page_level?: boolean } | null) ?? {};
197+ if (!meta.page_level) return;
198+ const comments = (t.get("comments") as { toArray(): { get(key: string): unknown }[] } | undefined)?.toArray() ?? [];
199+ out.push({
200+ id: String(t.get("id")),
201+ resolved: !!t.get("resolved"),
202+ comments: comments
203+ .filter((c) => !c.get("deletedAt"))
204+ .map((c) => ({ id: String(c.get("id")), author: String(c.get("userId")), text: commentText(c.get("body")), created_at: Number(c.get("createdAt")) || 0 })),
205+ });
206+ });
207+ onPageThreads(out.sort((a, b) => (a.comments[0]?.created_at ?? 0) - (b.comments[0]?.created_at ?? 0)));
208+ };
209+ read();
210+ map.observeDeep(read);
211+ return () => map.unobserveDeep(read);
212+ }, [provider, onPageThreads]);
213+
214+ // `[[` links a page, as `@` mentions someone.
215+ const searchPages = async (query: string) => {
216+ try {
217+ const r = await fetch(`/${slug}/-/docs/api?search=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } });
218+ const result = (await r.json()) as Result<{ id: string; title: string; icon: string | null; path: string; space_name: string }[]>;
219+ return result.ok ? result.value : [];
220+ } catch {
221+ return [];
222+ }
223+ };
224+
225+ const slashItems = (query: string) => {
226+ const insert = (block: { type: string; props?: Record<string, unknown> }) => () => insertOrUpdateBlockForSlashMenu(editor as never, block as never);
227+ const custom = [
228+ { 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" } }) },
229+ { 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" } }) },
230+ { 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" } }) },
231+ { 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" }) },
232+ { title: "Math", subtext: "A formula, typeset with KaTeX", aliases: ["math", "latex", "katex", "equation", "formula"], group: "Advanced", icon: <Sigma size={18} />, onItemClick: insert({ type: "math" }) },
233+ { 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" }) },
234+ {
235+ title: "Mention",
236+ subtext: "A person or an agent",
237+ aliases: ["mention", "person", "agent", "@"],
238+ group: "g1t",
239+ icon: <AtSign size={18} />,
240+ onItemClick: () => editor.insertInlineContent("@" as never),
241+ },
242+ {
243+ title: "Link to page",
244+ subtext: "Another page in Docs",
245+ aliases: ["link", "page", "[["],
246+ group: "g1t",
247+ icon: <Link2 size={18} />,
248+ onItemClick: () => editor.insertInlineContent("[[" as never),
249+ },
250+ {
251+ title: "Date",
252+ subtext: "Today's date, which you can change",
253+ aliases: ["date", "today", "when"],
254+ group: "g1t",
255+ icon: <Calendar size={18} />,
256+ onItemClick: () => editor.insertInlineContent([{ type: "date", props: { date: new Date().toISOString().slice(0, 10) } }, " "] as never),
257+ },
258+ ];
259+ return filterSuggestionItems([...getDefaultReactSlashMenuItems(editor as never), ...custom], query);
260+ };
261+
262+ const mentionItems = (query: string) => {
263+ const q = query.toLowerCase();
264+ return mentionables
265+ .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q))
266+ .slice(0, 8)
267+ .map((p) => ({
268+ title: p.display_name,
269+ subtext: p.kind === "agent" ? `@${p.name} · agent` : `@${p.name}`,
270+ icon: <AtSign size={16} />,
271+ onItemClick: () => editor.insertInlineContent([{ type: "mention", props: { kind: p.kind, id: p.id, name: p.name, href: "" } }, " "] as never),
272+ }));
273+ };
274+
275+ const pageItems = async (query: string) => {
276+ const pages = await searchPages(query);
277+ return pages.slice(0, 8).map((p) => ({
278+ title: p.title || "Untitled",
279+ subtext: p.space_name,
280+ icon: p.icon ? <span className="text-base leading-none">{p.icon}</span> : <FileText size={16} />,
281+ onItemClick: () => editor.insertInlineContent([{ type: "mention", props: { kind: "page", id: p.id, name: p.title || "Untitled", href: p.path } }, " "] as never),
282+ }));
283+ };
284+
285+ // Suggestions: the blocks each one changes are struck through in place.
286+ const struck = suggestions
287+ .flatMap((s) => s.block_ids)
288+ .filter((id) => /^[0-9a-zA-Z-]+$/.test(id))
289+ .map((id) => `.g1t-editor .bn-block-outer[data-id="${id}"] > .bn-block > .bn-block-content`)
290+ .join(",\n");
291+
292+ return (
293+ <div className="g1t-editor relative">
294+ {struck && (
295+ <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>
296+ )}
297+ <BlockNoteView editor={editor as never} editable={editable} theme="dark" renderEditor={false} slashMenu={false} comments={canDo(role, "view")} className="g1t-bn">
298+ <div className={`grid gap-8 ${showComments ? "xl:grid-cols-[minmax(0,1fr)_18rem]" : ""}`}>
299+ <div className="relative min-w-0">
300+ <BlockNoteViewEditor />
301+ {renderSuggestion && <SuggestionRail editor={editor} suggestions={suggestions} render={renderSuggestion} hidden={showComments} />}
302+ </div>
303+ {showComments && (
304+ <aside aria-label="Comments" className="hidden xl:block">
305+ <div className="sticky top-20 max-h-[calc(100dvh-6rem)] overflow-y-auto [scrollbar-width:thin]">
306+ <ThreadsSidebar filter="all" sort="position" />
307+ </div>
308+ </aside>
309+ )}
310+ </div>
311+ <SuggestionMenuController triggerCharacter="/" getItems={async (query) => slashItems(query)} />
312+ <SuggestionMenuController triggerCharacter="@" getItems={async (query) => mentionItems(query)} />
313+ <SuggestionMenuController triggerCharacter="[[" getItems={pageItems} />
314+ </BlockNoteView>
315+ </div>
316+ );
317+}
318+
319+/**
320+ * Each suggestion beside the blocks it changes, in the margin on a wide
321+ * screen (the page lists them above the editor on a narrow one).
322+ */
323+function SuggestionRail({ editor, suggestions, render, hidden }: { editor: DocEditorInstance; suggestions: DocSuggestion[]; render: (s: DocSuggestion) => ReactNode; hidden: boolean }) {
324+ const box = useRef<HTMLDivElement>(null);
325+ const [tops, setTops] = useState<Record<string, number>>({});
326+ useLayoutEffect(() => {
327+ const place = () => {
328+ const root = box.current?.parentElement;
329+ if (!root) return;
330+ const base = root.getBoundingClientRect().top;
331+ const next: Record<string, number> = {};
332+ let floor = 0;
333+ for (const s of suggestions) {
334+ const first = s.block_ids[0];
335+ const el = first ? root.querySelector(`.bn-block-outer[data-id="${CSS.escape(first)}"]`) : null;
336+ const want = el ? el.getBoundingClientRect().top - base : root.getBoundingClientRect().height - 40;
337+ // Cards never overlap: each starts below the one before.
338+ const top = Math.max(want, floor);
339+ next[s.id] = top;
340+ floor = top + 190;
341+ }
342+ setTops(next);
343+ };
344+ place();
345+ const off = editor.onChange(() => requestAnimationFrame(place));
346+ const observer = new ResizeObserver(place);
347+ if (box.current?.parentElement) observer.observe(box.current.parentElement);
348+ return () => {
349+ off?.();
350+ observer.disconnect();
351+ };
352+ }, [editor, suggestions]);
353+ if (hidden || !suggestions.length) return null;
354+ return (
355+ <div ref={box} className="pointer-events-none absolute inset-y-0 left-full ml-8 hidden w-72 2xl:block" aria-label="Suggestions">
356+ {suggestions.map((s) => (
357+ <div key={s.id} className="pointer-events-auto absolute inset-x-0" style={{ top: tops[s.id] ?? 0 }}>
358+ {render(s)}
359+ </div>
360+ ))}
361+ </div>
362+ );
363+}
364+
365+/** A comment's text: its blocks' inline text, mentions as @name. */
366+function commentText(body: unknown): string {
367+ const out: string[] = [];
368+ const walk = (v: unknown) => {
369+ if (Array.isArray(v)) v.forEach(walk);
370+ else if (v && typeof v === "object") {
371+ const o = v as Record<string, unknown>;
372+ if (o.type === "text" && typeof o.text === "string") out.push(o.text);
373+ else if (o.type === "mention") out.push(`@${(o.props as { name?: string } | undefined)?.name ?? ""}`);
374+ else {
375+ walk(o.content);
376+ walk(o.children);
377+ }
378+ }
379+ };
380+ walk(body);
381+ return out.join("");
382+}
+458−0
1+/**
2+ * A page's surroundings: agents' suggestions with Accept and Reject, the
3+ * history dialog, moving a page, saving it as a template, the emoji and
4+ * cover pickers, and comments on the whole page.
5+ */
6+import type { DocDiffLine, DocSuggestion, DocVersion, DocVersionDetail, DocsSidebarSpace, MemberProfile, Result } from "@g1t/contracts";
7+import { Check, History, MessageSquare, RotateCcw, Sparkles, X } from "lucide-react";
8+import { type ReactNode, useEffect, useMemo, useState } from "react";
9+
10+import { COVER_GRADIENTS, buildTree, canDo, flatten } from "../../lib/docs";
11+import { Button, ErrorText, TimeAgo } from "../ui";
12+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
13+import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
14+import { SelectField } from "../ui/select";
15+import { docsQuery } from "./actions";
16+import { Face } from "./parts";
17+
18+// ── Suggestions ─────────────────────────────────────────────────────────────
19+
20+function DiffBlock({ text, tone }: { text: string; tone: "del" | "add" }) {
21+ if (!text.trim()) return <p className="text-xs text-faint italic">{tone === "del" ? "Nothing removed" : "Nothing added"}</p>;
22+ return (
23+ <pre
24+ className={`max-h-56 overflow-auto rounded-md px-2.5 py-2 font-sans text-[0.8125rem] leading-relaxed whitespace-pre-wrap [scrollbar-width:thin] ${
25+ tone === "del" ? "bg-danger/8 text-fg-soft line-through decoration-danger/60" : "bg-success/10 text-fg"
26+ }`}
27+ >
28+ {text}
29+ </pre>
30+ );
31+}
32+
33+function targetLabel(s: DocSuggestion): string {
34+ switch (s.target.kind) {
35+ case "append":
36+ return "Adds to the end";
37+ case "document":
38+ return "Rewrites the page";
39+ case "section":
40+ return `Changes “${s.target.heading}”`;
41+ default:
42+ return s.block_ids.length === 1 ? "Changes one block" : `Changes ${s.block_ids.length} blocks`;
43+ }
44+}
45+
46+export function SuggestionCard({ suggestion, canDecide, onDecide, compact = false }: { suggestion: DocSuggestion; canDecide: boolean; onDecide: (id: string, decision: "accept" | "reject") => void; compact?: boolean }) {
47+ const [open, setOpen] = useState(!compact);
48+ const s = suggestion;
49+ return (
50+ <article className="rounded-xl border border-accent/30 bg-surface shadow-lg shadow-black/20">
51+ <header className="flex items-center gap-2 border-b border-line px-3 py-2">
52+ <Face who={s.author} size={20} />
53+ <span className="min-w-0 grow">
54+ <span className="block truncate text-xs font-medium text-fg">{s.author.display_name} suggests</span>
55+ <span className="block truncate text-[0.6875rem] text-faint">
56+ {targetLabel(s)}
57+ {s.asked_by ? ` · for ${s.asked_by.display_name}` : ""} · <TimeAgo at={s.created_at} />
58+ </span>
59+ </span>
60+ <Sparkles size={14} className="shrink-0 text-accent" aria-hidden="true" />
61+ </header>
62+ <div className="space-y-2 px-3 py-2.5">
63+ {s.note && <p className="text-xs leading-relaxed text-muted">{s.note}</p>}
64+ {open ? (
65+ <>
66+ {s.target.kind !== "append" && <DiffBlock text={s.before_markdown} tone="del" />}
67+ <DiffBlock text={s.after_markdown} tone="add" />
68+ </>
69+ ) : (
70+ <button type="button" onClick={() => setOpen(true)} className="text-xs text-accent hover:underline">
71+ Show the change
72+ </button>
73+ )}
74+ </div>
75+ {canDecide && (
76+ <footer className="flex gap-2 border-t border-line px-3 py-2">
77+ <button type="button" onClick={() => onDecide(s.id, "accept")} className="inline-flex h-7 items-center gap-1 rounded-md bg-success/15 px-2.5 text-xs font-medium text-success hover:bg-success/25">
78+ <Check size={13} /> Accept
79+ </button>
80+ <button type="button" onClick={() => onDecide(s.id, "reject")} className="inline-flex h-7 items-center gap-1 rounded-md px-2.5 text-xs text-muted hover:bg-raised hover:text-fg">
81+ <X size={13} /> Reject
82+ </button>
83+ </footer>
84+ )}
85+ </article>
86+ );
87+}
88+
89+// ── History ─────────────────────────────────────────────────────────────────
90+
91+const KIND_LABELS: Record<DocVersion["kind"], string> = { created: "Created", edit: "Edited", agent: "Edited by an agent", suggestion: "Suggestion accepted", restore: "Restored" };
92+
93+function DiffView({ lines }: { lines: DocDiffLine[] }) {
94+ return (
95+ <div className="font-mono text-[0.75rem] leading-relaxed">
96+ {lines.map((l, i) => (
97+ <div key={i} className={`flex gap-2 px-2 whitespace-pre-wrap ${l.op === "add" ? "bg-success/10 text-fg" : l.op === "del" ? "bg-danger/10 text-fg-soft line-through decoration-danger/50" : "text-muted"}`}>
98+ <span className="w-3 shrink-0 text-faint select-none">{l.op === "add" ? "+" : l.op === "del" ? "−" : " "}</span>
99+ <span className="min-w-0">{l.text || " "}</span>
100+ </div>
101+ ))}
102+ </div>
103+ );
104+}
105+
106+export function HistoryDialog({ slug, pageId, open, onOpenChange, canRestore, onRestore }: { slug: string; pageId: string; open: boolean; onOpenChange: (o: boolean) => void; canRestore: boolean; onRestore: (versionId: string) => Promise<Result<unknown>> }) {
107+ const [versions, setVersions] = useState<DocVersion[] | null>(null);
108+ const [chosen, setChosen] = useState<string | null>(null);
109+ const [detail, setDetail] = useState<DocVersionDetail | null>(null);
110+ const [error, setError] = useState<string | null>(null);
111+ const [restoring, setRestoring] = useState(false);
112+ useEffect(() => {
113+ if (!open) return;
114+ setVersions(null);
115+ setError(null);
116+ void docsQuery<DocVersion[]>(slug, { versions: pageId }).then((r) => {
117+ if (r.ok) {
118+ setVersions(r.value);
119+ setChosen(r.value[0]?.id ?? null);
120+ } else setError(r.error.message);
121+ });
122+ }, [open, slug, pageId]);
123+ useEffect(() => {
124+ if (!chosen) return setDetail(null);
125+ setDetail(null);
126+ void docsQuery<DocVersionDetail>(slug, { version: chosen, page: pageId }).then((r) => r.ok && setDetail(r.value));
127+ }, [chosen, slug, pageId]);
128+ return (
129+ <Dialog open={open} onOpenChange={onOpenChange}>
130+ <DialogContent className="max-w-5xl">
131+ <DialogHeader>
132+ <DialogTitle className="flex items-center gap-2">
133+ <History size={16} /> History
134+ </DialogTitle>
135+ <DialogDescription>Every version, who made it, and what changed. Restoring makes a new version; nothing is lost.</DialogDescription>
136+ </DialogHeader>
137+ {error && <ErrorText>{error}</ErrorText>}
138+ <div className="grid min-h-[24rem] gap-4 md:grid-cols-[16rem_minmax(0,1fr)]">
139+ <ul className="max-h-[60vh] space-y-px overflow-y-auto [scrollbar-width:thin]">
140+ {versions === null && !error && <li className="px-2 py-1 text-xs text-faint">Loading…</li>}
141+ {versions?.map((v) => (
142+ <li key={v.id}>
143+ <button type="button" onClick={() => setChosen(v.id)} className={`w-full rounded-md px-2.5 py-2 text-left transition-colors ${chosen === v.id ? "bg-raised" : "hover:bg-raised/60"}`}>
144+ <span className="block text-xs font-medium text-fg">{new Date(v.created_at).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}</span>
145+ <span className="mt-0.5 block text-[0.6875rem] text-faint">{v.note ?? KIND_LABELS[v.kind]}</span>
146+ <span className="mt-1 flex items-center gap-1">
147+ {v.authors.slice(0, 4).map((a) => (
148+ <Face key={`${a.kind}:${a.id}`} who={a} size={14} />
149+ ))}
150+ <span className="ml-1 truncate text-[0.6875rem] text-muted">{v.authors.map((a) => a.display_name).join(", ")}</span>
151+ </span>
152+ </button>
153+ </li>
154+ ))}
155+ </ul>
156+ <div className="min-w-0 overflow-hidden rounded-lg border border-line">
157+ {detail ? (
158+ <div className="max-h-[60vh] overflow-auto py-2 [scrollbar-width:thin]">
159+ <DiffView lines={detail.diff} />
160+ </div>
161+ ) : (
162+ <p className="p-4 text-xs text-faint">{chosen ? "Loading…" : "Choose a version."}</p>
163+ )}
164+ </div>
165+ </div>
166+ <DialogFooter>
167+ {canRestore && chosen && versions?.[0]?.id !== chosen && (
168+ <Button
169+ type="button"
170+ variant="accent"
171+ disabled={restoring}
172+ onClick={async () => {
173+ setRestoring(true);
174+ const done = await onRestore(chosen);
175+ setRestoring(false);
176+ if (done.ok) onOpenChange(false);
177+ else setError(done.error.message);
178+ }}
179+ >
180+ <RotateCcw size={14} /> {restoring ? "Restoring…" : "Restore this version"}
181+ </Button>
182+ )}
183+ </DialogFooter>
184+ </DialogContent>
185+ </Dialog>
186+ );
187+}
188+
189+// ── Move ────────────────────────────────────────────────────────────────────
190+
191+export function MoveDialog({ spaces, pageId, current, open, onOpenChange, onMove }: { spaces: DocsSidebarSpace[]; pageId: string; current: { space_id: string; parent_id: string | null }; open: boolean; onOpenChange: (o: boolean) => void; onMove: (spaceId: string, parentId: string | null) => void }) {
192+ const writable = spaces.filter((s) => canDo(s.viewer_role, "edit"));
193+ const [space, setSpace] = useState(current.space_id);
194+ const [parent, setParent] = useState(current.parent_id ?? "");
195+ const pages = useMemo(() => {
196+ const s = writable.find((x) => x.id === space);
197+ if (!s) return [];
198+ const tree = flatten(buildTree(s.pages));
199+ // Not under itself or anything under it.
200+ const hidden = new Set<string>([pageId]);
201+ for (const item of tree) if (item.parent_id && hidden.has(item.parent_id)) hidden.add(item.id);
202+ return tree.filter((i) => !hidden.has(i.id));
203+ }, [writable, space, pageId]);
204+ return (
205+ <Dialog open={open} onOpenChange={onOpenChange}>
206+ <DialogContent>
207+ <DialogHeader>
208+ <DialogTitle>Move page</DialogTitle>
209+ <DialogDescription>Pages under it move with it.</DialogDescription>
210+ </DialogHeader>
211+ <div className="space-y-3">
212+ <SelectField aria-label="Space" value={space} onValueChange={(v) => { setSpace(v); setParent(""); }} options={writable.map((s) => ({ value: s.id, label: s.name }))} className="h-9 w-full" />
213+ <SelectField
214+ aria-label="Inside"
215+ value={parent}
216+ onValueChange={setParent}
217+ options={[{ value: "", label: "At the top of the space" }, ...pages.map((p) => ({ value: p.id, label: `${" ".repeat(p.depth)}${p.icon ?? ""} ${p.title || "Untitled"}`.trim() }))]}
218+ className="h-9 w-full"
219+ />
220+ </div>
221+ <DialogFooter>
222+ <Button type="button" variant="accent" onClick={() => onMove(space, parent || null)}>
223+ Move
224+ </Button>
225+ </DialogFooter>
226+ </DialogContent>
227+ </Dialog>
228+ );
229+}
230+
231+// ── Save as template ───────────────────────────────────────────────────────
232+
233+export function TemplateDialog({ open, onOpenChange, title, onSave }: { open: boolean; onOpenChange: (o: boolean) => void; title: string; onSave: (name: string, description: string) => Promise<Result<unknown>> }) {
234+ const [name, setName] = useState(title);
235+ const [description, setDescription] = useState("");
236+ const [error, setError] = useState<string | null>(null);
237+ useEffect(() => setName(title), [title]);
238+ return (
239+ <Dialog open={open} onOpenChange={onOpenChange}>
240+ <DialogContent>
241+ <DialogHeader>
242+ <DialogTitle>Save as a template</DialogTitle>
243+ <DialogDescription>Everyone in the workspace can start a page from it.</DialogDescription>
244+ </DialogHeader>
245+ <div className="space-y-3">
246+ <input aria-label="Name" value={name} onChange={(e) => setName(e.target.value)} className="h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none focus:border-accent/60" />
247+ <input aria-label="What it's for" placeholder="What it's for" value={description} onChange={(e) => setDescription(e.target.value)} className="h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none focus:border-accent/60" />
248+ {error && <ErrorText>{error}</ErrorText>}
249+ </div>
250+ <DialogFooter>
251+ <Button
252+ type="button"
253+ variant="accent"
254+ onClick={async () => {
255+ const done = await onSave(name, description);
256+ if (done.ok) onOpenChange(false);
257+ else setError(done.error.message);
258+ }}
259+ >
260+ Save template
261+ </Button>
262+ </DialogFooter>
263+ </DialogContent>
264+ </Dialog>
265+ );
266+}
267+
268+// ── Icon and cover ─────────────────────────────────────────────────────────
269+
270+const EMOJI = ["📄", "📘", "📚", "📝", "📐", "🧭", "🗺️", "🚀", "🛠️", "🧰", "🧪", "🐛", "🔒", "🔑", "⚙️", "📦", "🧩", "🎯", "📈", "📊", "🗓️", "⏱️", "✅", "⚠️", "🔥", "🩹", "💡", "🧠", "🤖", "👋", "🤝", "💬", "📣", "🏁", "🌱", "⭐"];
271+
272+export function IconPicker({ value, onChange, children }: { value: string | null; onChange: (icon: string | null) => void; children: ReactNode }) {
273+ const [open, setOpen] = useState(false);
274+ const [custom, setCustom] = useState("");
275+ return (
276+ <Popover open={open} onOpenChange={setOpen}>
277+ <PopoverTrigger asChild>{children}</PopoverTrigger>
278+ <PopoverContent align="start" className="w-72 p-3">
279+ <div className="grid grid-cols-9 gap-0.5">
280+ {EMOJI.map((e) => (
281+ <button
282+ key={e}
283+ type="button"
284+ onClick={() => {
285+ onChange(e);
286+ setOpen(false);
287+ }}
288+ className={`flex size-7 items-center justify-center rounded text-base hover:bg-raised ${value === e ? "bg-raised" : ""}`}
289+ aria-label={`Use ${e}`}
290+ >
291+ {e}
292+ </button>
293+ ))}
294+ </div>
295+ <form
296+ className="mt-2 flex gap-1.5"
297+ onSubmit={(e) => {
298+ e.preventDefault();
299+ if (custom.trim()) onChange(custom.trim());
300+ setOpen(false);
301+ }}
302+ >
303+ <input value={custom} onChange={(e) => setCustom(e.target.value)} placeholder="Any emoji" aria-label="Any emoji" className="h-8 min-w-0 grow rounded-md border border-line bg-bg px-2 text-sm outline-none focus:border-accent/60" />
304+ <button type="submit" className="h-8 rounded-md bg-raised px-2 text-xs hover:bg-line">
305+ Use
306+ </button>
307+ </form>
308+ {value && (
309+ <button
310+ type="button"
311+ onClick={() => {
312+ onChange(null);
313+ setOpen(false);
314+ }}
315+ className="mt-2 text-xs text-faint hover:text-fg"
316+ >
317+ Remove icon
318+ </button>
319+ )}
320+ </PopoverContent>
321+ </Popover>
322+ );
323+}
324+
325+export function CoverPicker({ onChange, children }: { onChange: (cover: string | null) => void; children: ReactNode }) {
326+ const [open, setOpen] = useState(false);
327+ return (
328+ <Popover open={open} onOpenChange={setOpen}>
329+ <PopoverTrigger asChild>{children}</PopoverTrigger>
330+ <PopoverContent align="start" className="w-72 p-3">
331+ <div className="grid grid-cols-3 gap-2">
332+ {COVER_GRADIENTS.map((g, i) => (
333+ <button
334+ key={i}
335+ type="button"
336+ aria-label={`Cover ${i + 1}`}
337+ onClick={() => {
338+ onChange(`gradient:${i}`);
339+ setOpen(false);
340+ }}
341+ className="h-12 rounded-md ring-1 ring-line hover:ring-accent"
342+ style={{ background: g }}
343+ />
344+ ))}
345+ </div>
346+ <button
347+ type="button"
348+ onClick={() => {
349+ onChange(null);
350+ setOpen(false);
351+ }}
352+ className="mt-3 text-xs text-faint hover:text-fg"
353+ >
354+ Remove cover
355+ </button>
356+ </PopoverContent>
357+ </Popover>
358+ );
359+}
360+
361+// ── Comments on the whole page ─────────────────────────────────────────────
362+
363+export type PageThread = { id: string; resolved: boolean; comments: { id: string; author: string; text: string; created_at: number }[] };
364+
365+export function Discussion({
366+ threads,
367+ people,
368+ canComment,
369+ onPost,
370+ onReply,
371+ onResolve,
372+}: {
373+ threads: PageThread[];
374+ people: (key: string) => Pick<MemberProfile, "kind" | "id" | "name" | "display_name" | "avatar" | "avatar_seed">;
375+ canComment: boolean;
376+ onPost: (text: string) => Promise<boolean>;
377+ onReply: (thread: string, text: string) => Promise<boolean>;
378+ onResolve: (thread: string, resolved: boolean) => void;
379+}) {
380+ const [draft, setDraft] = useState("");
381+ const [replies, setReplies] = useState<Record<string, string>>({});
382+ const open = threads.filter((t) => !t.resolved);
383+ const resolved = threads.filter((t) => t.resolved);
384+ const [showResolved, setShowResolved] = useState(false);
385+ const thread = (t: PageThread) => (
386+ <li key={t.id} className={`rounded-xl border border-line bg-surface ${t.resolved ? "opacity-70" : ""}`}>
387+ <ul className="divide-y divide-line">
388+ {t.comments.map((c) => {
389+ const who = people(c.author);
390+ return (
391+ <li key={c.id} className="flex gap-2.5 px-3.5 py-3">
392+ <Face who={who} size={22} />
393+ <div className="min-w-0 grow">
394+ <p className="text-xs">
395+ <span className="font-medium text-fg">{who.display_name}</span> <span className="text-faint">{c.created_at ? <TimeAgo at={c.created_at} /> : null}</span>
396+ </p>
397+ <p className="mt-0.5 text-sm leading-relaxed whitespace-pre-wrap text-fg-soft">{c.text}</p>
398+ </div>
399+ </li>
400+ );
401+ })}
402+ </ul>
403+ {canComment && (
404+ <form
405+ className="flex items-center gap-2 border-t border-line px-3 py-2"
406+ onSubmit={async (e) => {
407+ e.preventDefault();
408+ const text = (replies[t.id] ?? "").trim();
409+ if (text && (await onReply(t.id, text))) setReplies((r) => ({ ...r, [t.id]: "" }));
410+ }}
411+ >
412+ <input value={replies[t.id] ?? ""} onChange={(e) => setReplies((r) => ({ ...r, [t.id]: e.target.value }))} placeholder="Reply" aria-label="Reply" className="h-8 min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint" />
413+ <button type="button" onClick={() => onResolve(t.id, !t.resolved)} className="shrink-0 text-xs text-faint hover:text-fg">
414+ {t.resolved ? "Reopen" : "Resolve"}
415+ </button>
416+ </form>
417+ )}
418+ </li>
419+ );
420+ return (
421+ <section aria-labelledby="discussion" className="mt-14 border-t border-line pt-8">
422+ <h2 id="discussion" className="flex items-center gap-2 text-sm font-semibold">
423+ <MessageSquare size={15} /> Discussion
424+ </h2>
425+ <p className="mt-1 text-xs text-faint">Comments on the whole page. To comment on a passage, select it and choose Comment.</p>
426+ <ul className="mt-4 space-y-3">{open.map(thread)}</ul>
427+ {resolved.length > 0 && (
428+ <>
429+ <button type="button" onClick={() => setShowResolved(!showResolved)} className="mt-3 text-xs text-faint hover:text-fg">
430+ {showResolved ? "Hide" : "Show"} {resolved.length} resolved
431+ </button>
432+ {showResolved && <ul className="mt-3 space-y-3">{resolved.map(thread)}</ul>}
433+ </>
434+ )}
435+ {canComment && (
436+ <form
437+ className="mt-4 flex items-start gap-2"
438+ onSubmit={async (e) => {
439+ e.preventDefault();
440+ if (draft.trim() && (await onPost(draft.trim()))) setDraft("");
441+ }}
442+ >
443+ <textarea
444+ value={draft}
445+ onChange={(e) => setDraft(e.target.value)}
446+ rows={2}
447+ placeholder="Add a comment. @mention someone to tell them."
448+ aria-label="Add a comment"
449+ className="min-h-16 min-w-0 grow resize-y rounded-lg border border-line bg-surface px-3 py-2 text-sm outline-none placeholder:text-faint focus:border-accent/60"
450+ />
451+ <Button type="submit" variant="quiet" disabled={!draft.trim()}>
452+ Comment
453+ </Button>
454+ </form>
455+ )}
456+ </section>
457+ );
458+}
+122−0
1+/**
2+ * Pieces Docs' pages share: a page card, a space card, a face for a
3+ * person or an agent, a page's breadcrumbs, the template gallery.
4+ */
5+import type { DocPage, DocSpace, DocTemplate, MemberProfile } from "@g1t/contracts";
6+import { ChevronRight, Lock, Users } from "lucide-react";
7+import type { ReactNode } from "react";
8+import { Link } from "react-router";
9+
10+import { coverStyle } from "../../lib/docs";
11+import { AgentAvatar } from "../agent-avatar";
12+import { Avatar, TimeAgo } from "../ui";
13+import { Hint } from "../ui/hint";
14+import { PageIcon, SpaceIcon } from "./sidebar";
15+
16+export function Face({ who, size = 20 }: { who: Pick<MemberProfile, "kind" | "id" | "name" | "avatar" | "avatar_seed">; size?: number }) {
17+ if (who.kind === "agent") return <AgentAvatar agent={{ id: who.id, handle: who.name, avatar: who.avatar, avatar_seed: who.avatar_seed ?? null }} size={size} />;
18+ return <Avatar name={who.name} image={who.avatar} size={size} />;
19+}
20+
21+export function Faces({ people, size = 22, max = 4 }: { people: MemberProfile[]; size?: number; max?: number }) {
22+ const shown = people.slice(0, max);
23+ return (
24+ <span className="flex items-center -space-x-1.5">
25+ {shown.map((p) => (
26+ <Hint key={`${p.kind}:${p.id}`} label={p.display_name}>
27+ <span className="rounded-full ring-2 ring-bg">
28+ <Face who={p} size={size} />
29+ </span>
30+ </Hint>
31+ ))}
32+ {people.length > max && <span className="ml-2 text-xs text-faint">+{people.length - max}</span>}
33+ </span>
34+ );
35+}
36+
37+export function PageCard({ page, space }: { page: DocPage; space?: Pick<DocSpace, "name"> | null }) {
38+ const cover = coverStyle(page.cover);
39+ return (
40+ <Link to={page.path} prefetch="intent" className="group flex flex-col overflow-hidden rounded-xl border border-line bg-surface transition-colors hover:border-line-strong">
41+ <div className="h-14 shrink-0 border-b border-line" style={{ background: cover ?? "linear-gradient(120deg, color-mix(in srgb, var(--g1t-accent) 10%, var(--g1t-surface)), var(--g1t-surface))" }} />
42+ <div className="-mt-5 flex min-h-0 grow flex-col px-4 pb-3.5">
43+ <span className="flex size-9 items-center justify-center rounded-lg border border-line bg-bg text-lg">
44+ <PageIcon icon={page.icon} size={18} />
45+ </span>
46+ <span className="mt-2 line-clamp-1 text-sm font-medium text-fg group-hover:text-accent">{page.title || "Untitled"}</span>
47+ <span className="mt-1 line-clamp-2 min-h-[2.5em] text-xs leading-relaxed text-muted">{page.excerpt || "Nothing written yet."}</span>
48+ <span className="mt-3 flex items-center gap-1.5 text-[0.6875rem] text-faint">
49+ {page.updated_by && <Face who={page.updated_by} size={14} />}
50+ <span className="truncate">
51+ {space ? `${space.name} · ` : ""}
52+ <TimeAgo at={page.updated_at} />
53+ </span>
54+ </span>
55+ </div>
56+ </Link>
57+ );
58+}
59+
60+export function SpaceCard({ slug, space }: { slug: string; space: DocSpace }) {
61+ return (
62+ <Link to={`/${slug}/-/docs/${space.slug}`} prefetch="intent" className="group flex gap-3 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
63+ <span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-raised text-lg">
64+ <SpaceIcon space={space} size={18} />
65+ </span>
66+ <span className="min-w-0 grow">
67+ <span className="flex items-center gap-1.5">
68+ <span className="truncate text-sm font-medium text-fg group-hover:text-accent">{space.name}</span>
69+ {space.kind === "private" && <Lock size={12} className="shrink-0 text-faint" aria-label="Private" />}
70+ {space.kind === "team" && <Users size={12} className="shrink-0 text-faint" aria-label="Team space" />}
71+ </span>
72+ <span className="mt-0.5 line-clamp-2 text-xs leading-relaxed text-muted">{space.description || "No description."}</span>
73+ <span className="mt-2 block text-[0.6875rem] text-faint">
74+ {space.page_count} {space.page_count === 1 ? "page" : "pages"}
75+ {space.projects.length > 0 && ` · ${space.projects.join(", ")}`}
76+ </span>
77+ </span>
78+ </Link>
79+ );
80+}
81+
82+export function TemplateCard({ template, action }: { template: DocTemplate; action: ReactNode }) {
83+ return (
84+ <div className="flex flex-col rounded-xl border border-line bg-surface p-4">
85+ <span className="text-2xl leading-none" aria-hidden="true">
86+ {template.icon}
87+ </span>
88+ <span className="mt-3 text-sm font-medium">{template.name}</span>
89+ <span className="mt-1 grow text-xs leading-relaxed text-muted">{template.description || (template.builtin ? "" : "Saved by your workspace.")}</span>
90+ <div className="mt-3 flex items-center gap-2">{action}</div>
91+ </div>
92+ );
93+}
94+
95+export function Crumbs({ items }: { items: { label: ReactNode; to?: string }[] }) {
96+ return (
97+ <nav aria-label="Breadcrumbs" className="flex min-w-0 flex-wrap items-center gap-1 text-xs text-faint">
98+ {items.map((item, i) => (
99+ <span key={i} className="flex min-w-0 items-center gap-1">
100+ {i > 0 && <ChevronRight size={12} className="shrink-0" aria-hidden="true" />}
101+ {item.to ? (
102+ <Link to={item.to} prefetch="intent" className="truncate hover:text-fg">
103+ {item.label}
104+ </Link>
105+ ) : (
106+ <span className="truncate text-muted">{item.label}</span>
107+ )}
108+ </span>
109+ ))}
110+ </nav>
111+ );
112+}
113+
114+/** A heading for a section of a Docs page. */
115+export function SectionTitle({ children, action }: { children: ReactNode; action?: ReactNode }) {
116+ return (
117+ <div className="mb-3 flex items-center justify-between gap-3">
118+ <h2 className="text-sm font-semibold text-fg">{children}</h2>
119+ {action}
120+ </div>
121+ );
122+}
+189−0
1+/**
2+ * A page's live connection: the Yjs document and everyone's presence,
3+ * synced over `wss://<site>/<workspace>/-/docs/live?page=<id>` with the
4+ * page's room (services/docs src/room.ts). Binary frames speak the
5+ * y-protocols sync and awareness messages; text frames are the service's
6+ * own notices (`DocsLiveEvent`): a rename, a new suggestion, a version,
7+ * a change of access.
8+ *
9+ * It reconnects with backoff when the socket drops, and says where it is
10+ * (`status`) so the page can show "Offline, changes will sync".
11+ * Browser-only.
12+ */
13+import type { DocsLiveEvent } from "@g1t/contracts";
14+import * as decoding from "lib0/decoding";
15+import * as encoding from "lib0/encoding";
16+import * as awarenessProtocol from "y-protocols/awareness";
17+import * as syncProtocol from "y-protocols/sync";
18+import * as Y from "yjs";
19+
20+const MESSAGE_SYNC = 0;
21+const MESSAGE_AWARENESS = 1;
22+const MESSAGE_QUERY_AWARENESS = 3;
23+
24+export type LiveStatus = "connecting" | "synced" | "offline" | "closed";
25+
26+export class DocsProvider {
27+ readonly doc: Y.Doc;
28+ readonly awareness: awarenessProtocol.Awareness;
29+ status: LiveStatus = "connecting";
30+ private socket: WebSocket | null = null;
31+ private attempts = 0;
32+ private timer: ReturnType<typeof setTimeout> | null = null;
33+ private keepalive: ReturnType<typeof setInterval> | null = null;
34+ private stopped = false;
35+ private readonly statusListeners = new Set<(status: LiveStatus) => void>();
36+ private readonly eventListeners = new Set<(event: DocsLiveEvent) => void>();
37+
38+ constructor(
39+ private readonly url: string,
40+ doc?: Y.Doc,
41+ ) {
42+ this.doc = doc ?? new Y.Doc();
43+ this.awareness = new awarenessProtocol.Awareness(this.doc);
44+ this.doc.on("update", this.onDocUpdate);
45+ this.awareness.on("update", this.onAwarenessUpdate);
46+ if (typeof window !== "undefined") {
47+ window.addEventListener("beforeunload", this.onUnload);
48+ window.addEventListener("online", this.onOnline);
49+ }
50+ this.connect();
51+ }
52+
53+ onStatus(listener: (status: LiveStatus) => void): () => void {
54+ this.statusListeners.add(listener);
55+ listener(this.status);
56+ return () => this.statusListeners.delete(listener);
57+ }
58+
59+ onEvent(listener: (event: DocsLiveEvent) => void): () => void {
60+ this.eventListeners.add(listener);
61+ return () => this.eventListeners.delete(listener);
62+ }
63+
64+ private setStatus(status: LiveStatus) {
65+ if (this.status === status) return;
66+ this.status = status;
67+ for (const l of this.statusListeners) l(status);
68+ }
69+
70+ private connect() {
71+ if (this.stopped) return;
72+ this.setStatus(this.attempts ? "offline" : "connecting");
73+ const socket = new WebSocket(this.url);
74+ socket.binaryType = "arraybuffer";
75+ this.socket = socket;
76+ socket.onopen = () => {
77+ this.attempts = 0;
78+ // Our state vector: the room answers with what we lack, and asks for what it lacks.
79+ const encoder = encoding.createEncoder();
80+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
81+ syncProtocol.writeSyncStep1(encoder, this.doc);
82+ socket.send(encoding.toUint8Array(encoder));
83+ if (this.awareness.getLocalState() !== null) {
84+ const a = encoding.createEncoder();
85+ encoding.writeVarUint(a, MESSAGE_AWARENESS);
86+ encoding.writeVarUint8Array(a, awarenessProtocol.encodeAwarenessUpdate(this.awareness, [this.doc.clientID]));
87+ socket.send(encoding.toUint8Array(a));
88+ }
89+ const q = encoding.createEncoder();
90+ encoding.writeVarUint(q, MESSAGE_QUERY_AWARENESS);
91+ socket.send(encoding.toUint8Array(q));
92+ if (this.keepalive) clearInterval(this.keepalive);
93+ // Answered at the edge without waking the room.
94+ this.keepalive = setInterval(() => socket.readyState === WebSocket.OPEN && socket.send("ping"), 25_000);
95+ };
96+ socket.onmessage = (event) => {
97+ if (typeof event.data === "string") {
98+ if (event.data === "pong") return;
99+ try {
100+ const parsed = JSON.parse(event.data) as DocsLiveEvent;
101+ for (const l of this.eventListeners) l(parsed);
102+ } catch {
103+ // Not ours.
104+ }
105+ return;
106+ }
107+ this.receive(new Uint8Array(event.data as ArrayBuffer));
108+ };
109+ socket.onclose = (event) => {
110+ if (this.keepalive) clearInterval(this.keepalive);
111+ this.socket = null;
112+ // Others stop seeing our cursor; we stop seeing theirs.
113+ awarenessProtocol.removeAwarenessStates(
114+ this.awareness,
115+ [...this.awareness.getStates().keys()].filter((id) => id !== this.doc.clientID),
116+ this,
117+ );
118+ // 4403: no longer allowed; 4410: in the trash. Neither comes back by retrying.
119+ if (event.code === 4403 || event.code === 4410 || this.stopped) {
120+ this.setStatus("closed");
121+ return;
122+ }
123+ this.setStatus("offline");
124+ const delay = Math.min(30_000, 500 * 2 ** this.attempts) + Math.random() * 500;
125+ this.attempts++;
126+ this.timer = setTimeout(() => this.connect(), delay);
127+ };
128+ }
129+
130+ private receive(data: Uint8Array) {
131+ const decoder = decoding.createDecoder(data);
132+ const type = decoding.readVarUint(decoder);
133+ if (type === MESSAGE_SYNC) {
134+ const encoder = encoding.createEncoder();
135+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
136+ const step = syncProtocol.readSyncMessage(decoder, encoder, this.doc, this);
137+ if (encoding.length(encoder) > 1) this.socket?.send(encoding.toUint8Array(encoder));
138+ if (step === syncProtocol.messageYjsSyncStep2) this.setStatus("synced");
139+ return;
140+ }
141+ if (type === MESSAGE_AWARENESS) {
142+ awarenessProtocol.applyAwarenessUpdate(this.awareness, decoding.readVarUint8Array(decoder), this);
143+ }
144+ }
145+
146+ private onDocUpdate = (update: Uint8Array, origin: unknown) => {
147+ if (origin === this) return;
148+ const encoder = encoding.createEncoder();
149+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
150+ syncProtocol.writeUpdate(encoder, update);
151+ if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(encoding.toUint8Array(encoder));
152+ // While offline, the update stays in the document and goes in the next sync.
153+ };
154+
155+ private onAwarenessUpdate = ({ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }, origin: unknown) => {
156+ if (origin === this) return;
157+ const changed = [...added, ...updated, ...removed];
158+ const encoder = encoding.createEncoder();
159+ encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
160+ encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, changed));
161+ if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(encoding.toUint8Array(encoder));
162+ };
163+
164+ private onUnload = () => {
165+ awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], "unload");
166+ };
167+
168+ private onOnline = () => {
169+ if (this.socket || this.stopped) return;
170+ if (this.timer) clearTimeout(this.timer);
171+ this.attempts = 0;
172+ this.connect();
173+ };
174+
175+ destroy() {
176+ this.stopped = true;
177+ if (this.timer) clearTimeout(this.timer);
178+ if (this.keepalive) clearInterval(this.keepalive);
179+ awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], "destroy");
180+ this.doc.off("update", this.onDocUpdate);
181+ this.awareness.off("update", this.onAwarenessUpdate);
182+ if (typeof window !== "undefined") {
183+ window.removeEventListener("beforeunload", this.onUnload);
184+ window.removeEventListener("online", this.onOnline);
185+ }
186+ this.socket?.close();
187+ this.awareness.destroy();
188+ }
189+}
+370−0
1+/**
2+ * Docs mode's sidebar (beside the rail, docs/WORKSPACE.md "Shell"):
3+ * search, Home, Templates and Trash; Favorites and Recent; then each
4+ * space with its page tree, which opens to the page being read. Pages are
5+ * dragged to reorder them or to put one inside another; the ⋯ menu moves
6+ * them too, for keyboards and phones.
7+ */
8+import type { DocsSidebarSpace } from "@g1t/contracts";
9+import { BookOpen, ChevronRight, Clock, FileText, Home, LayoutTemplate, Lock, Plus, Search, Settings, Star, Trash2, Users } from "lucide-react";
10+import { type DragEvent, type ReactNode, useEffect, useMemo, useState } from "react";
11+import { NavLink, useLocation, useNavigate, useParams } from "react-router";
12+
13+import { buildTree, canDo, pageIdOf, pagePath, pathTo, type TreeItem } from "../../lib/docs";
14+import { Hint } from "../ui/hint";
15+import { Skeleton } from "../ui/skeleton";
16+import { docsRequest, useDocsAction, useDocsData } from "./actions";
17+
18+const ROW = "group flex h-8 items-center gap-1.5 rounded-md pr-1 text-[0.8125rem] transition-colors";
19+
20+function SideLink({ to, end, icon, children, trailing }: { to: string; end?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode }) {
21+ return (
22+ <NavLink
23+ to={to}
24+ end={end}
25+ prefetch="intent"
26+ className={({ isActive }) => `${ROW} pl-2 ${isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`}
27+ >
28+ <span className="flex w-5 shrink-0 justify-center text-faint">{icon}</span>
29+ <span className="min-w-0 grow truncate">{children}</span>
30+ {trailing}
31+ </NavLink>
32+ );
33+}
34+
35+function Section({ title, children, action, open: initially = true }: { title: string; children: ReactNode; action?: ReactNode; open?: boolean }) {
36+ const [open, setOpen] = useState(initially);
37+ return (
38+ <section className="mt-4">
39+ <div className="mb-0.5 flex items-center gap-1 pr-1">
40+ <button type="button" onClick={() => setOpen(!open)} aria-expanded={open} className="flex grow items-center gap-1 rounded px-1 text-xs font-medium text-faint transition-colors hover:text-muted">
41+ <ChevronRight size={12} className={`transition-transform ${open ? "rotate-90" : ""}`} />
42+ {title}
43+ </button>
44+ {action}
45+ </div>
46+ {open && children}
47+ </section>
48+ );
49+}
50+
51+/** A page's icon: its emoji, or the plain page. */
52+export function PageIcon({ icon, size = 15 }: { icon: string | null | undefined; size?: number }) {
53+ if (icon) return <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">{icon}</span>;
54+ return <FileText size={size} className="text-faint" aria-hidden="true" />;
55+}
56+
57+/** A space's icon: its emoji, or the book. */
58+export function SpaceIcon({ space, size = 15 }: { space: Pick<DocsSidebarSpace, "icon" | "kind">; size?: number }) {
59+ if (space.icon) return <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">{space.icon}</span>;
60+ if (space.kind === "private") return <Lock size={size} className="text-faint" aria-hidden="true" />;
61+ if (space.kind === "team") return <Users size={size} className="text-faint" aria-hidden="true" />;
62+ return <BookOpen size={size} className="text-faint" aria-hidden="true" />;
63+}
64+
65+type Drag = { id: string; space: string } | null;
66+
67+function TreeRow({
68+ slug,
69+ space,
70+ item,
71+ current,
72+ open,
73+ toggle,
74+ drag,
75+ setDrag,
76+ onDrop,
77+ onAdd,
78+}: {
79+ slug: string;
80+ space: DocsSidebarSpace;
81+ item: TreeItem;
82+ current: string | null;
83+ open: Set<string>;
84+ toggle: (id: string) => void;
85+ drag: Drag;
86+ setDrag: (d: Drag) => void;
87+ onDrop: (target: TreeItem, where: "before" | "inside") => void;
88+ onAdd: (parent: string) => void;
89+}) {
90+ const [over, setOver] = useState<"before" | "inside" | null>(null);
91+ const expanded = open.has(item.id);
92+ const active = current === item.id;
93+ const editable = canDo(space.viewer_role, "edit");
94+ const zone = (e: DragEvent<HTMLElement>): "before" | "inside" => {
95+ const box = e.currentTarget.getBoundingClientRect();
96+ return e.clientY - box.top < box.height * 0.35 ? "before" : "inside";
97+ };
98+ return (
99+ <li>
100+ <div
101+ draggable={editable}
102+ onDragStart={(e) => {
103+ e.dataTransfer.effectAllowed = "move";
104+ e.dataTransfer.setData("text/plain", item.id);
105+ setDrag({ id: item.id, space: space.id });
106+ }}
107+ onDragEnd={() => setDrag(null)}
108+ onDragOver={(e) => {
109+ if (!drag || drag.id === item.id || !editable) return;
110+ e.preventDefault();
111+ setOver(zone(e));
112+ }}
113+ onDragLeave={() => setOver(null)}
114+ onDrop={(e) => {
115+ e.preventDefault();
116+ const where = zone(e);
117+ setOver(null);
118+ if (drag && drag.id !== item.id) onDrop(item, where);
119+ }}
120+ className={`${ROW} relative ${active ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"} ${over === "inside" ? "ring-1 ring-accent/60 ring-inset" : ""}`}
121+ style={{ paddingLeft: 4 + item.depth * 14 }}
122+ >
123+ {over === "before" && <span className="pointer-events-none absolute inset-x-1 -top-px h-0.5 rounded bg-accent" aria-hidden="true" />}
124+ <button
125+ type="button"
126+ onClick={() => toggle(item.id)}
127+ aria-label={expanded ? "Collapse" : "Expand"}
128+ aria-expanded={expanded}
129+ className={`flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg ${item.children.length ? "" : "invisible"}`}
130+ >
131+ <ChevronRight size={13} className={`transition-transform ${expanded ? "rotate-90" : ""}`} />
132+ </button>
133+ <NavLink to={pagePath(slug, space.slug, item.title, item.id)} prefetch="intent" className="flex min-w-0 grow items-center gap-1.5">
134+ <span className="flex w-4 shrink-0 justify-center">
135+ <PageIcon icon={item.icon} size={14} />
136+ </span>
137+ <span className="min-w-0 truncate">{item.title || "Untitled"}</span>
138+ </NavLink>
139+ {editable && (
140+ <Hint label="Add a page inside">
141+ <button
142+ type="button"
143+ onClick={() => onAdd(item.id)}
144+ aria-label={`Add a page inside ${item.title || "Untitled"}`}
145+ className="flex size-6 shrink-0 items-center justify-center rounded text-faint opacity-0 group-hover:opacity-100 hover:bg-line hover:text-fg focus-visible:opacity-100"
146+ >
147+ <Plus size={13} />
148+ </button>
149+ </Hint>
150+ )}
151+ </div>
152+ {expanded && item.children.length > 0 && (
153+ <ul>
154+ {item.children.map((child) => (
155+ <TreeRow key={child.id} slug={slug} space={space} item={child} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={onDrop} onAdd={onAdd} />
156+ ))}
157+ </ul>
158+ )}
159+ </li>
160+ );
161+}
162+
163+function SpaceTree({ slug, space, current, drag, setDrag }: { slug: string; space: DocsSidebarSpace; current: string | null; drag: Drag; setDrag: (d: Drag) => void }) {
164+ const navigate = useNavigate();
165+ const { send } = useDocsAction(slug);
166+ const tree = useMemo(() => buildTree(space.pages), [space.pages]);
167+ const [open, setOpen] = useState<Set<string>>(() => new Set(pathTo(space.pages, current)));
168+ const [collapsed, setCollapsed] = useState(false);
169+ // Opening a page opens the way to it.
170+ useEffect(() => {
171+ if (!current) return;
172+ const path = pathTo(space.pages, current);
173+ if (path.length) setOpen((was) => new Set([...was, ...path]));
174+ }, [current, space.pages]);
175+ const toggle = (id: string) =>
176+ setOpen((was) => {
177+ const next = new Set(was);
178+ if (next.has(id)) next.delete(id);
179+ else next.add(id);
180+ return next;
181+ });
182+ const add = async (parent: string | null) => {
183+ const made = await send<{ path: string }>("create_page", { page: { space_id: space.id, parent_id: parent } });
184+ if (made.ok) {
185+ if (parent) setOpen((was) => new Set([...was, parent]));
186+ navigate(made.value.path);
187+ }
188+ };
189+ const drop = async (target: TreeItem, where: "before" | "inside") => {
190+ if (!drag) return;
191+ const move = where === "inside" ? { space_id: space.id, parent_id: target.id, before_id: null } : { space_id: space.id, parent_id: target.parent_id, before_id: target.id };
192+ setDrag(null);
193+ await send("move_page", { page_id: drag.id, move });
194+ if (where === "inside") setOpen((was) => new Set([...was, target.id]));
195+ };
196+ const editable = canDo(space.viewer_role, "edit");
197+ return (
198+ <li className="mt-1">
199+ <div
200+ className={`${ROW} text-fg-soft`}
201+ onDragOver={(e) => {
202+ if (drag && editable) e.preventDefault();
203+ }}
204+ onDrop={async (e) => {
205+ // Dropped on the space itself: to the top of it, at the end.
206+ e.preventDefault();
207+ if (!drag) return;
208+ const id = drag.id;
209+ setDrag(null);
210+ await send("move_page", { page_id: id, move: { space_id: space.id, parent_id: null, before_id: null } });
211+ }}
212+ >
213+ <button type="button" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed} aria-label={collapsed ? `Show ${space.name}` : `Hide ${space.name}`} className="flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg">
214+ <ChevronRight size={13} className={`transition-transform ${collapsed ? "" : "rotate-90"}`} />
215+ </button>
216+ <NavLink to={`/${slug}/-/docs/${space.slug}`} end prefetch="intent" className={({ isActive }) => `flex min-w-0 grow items-center gap-1.5 font-medium ${isActive ? "text-fg" : ""}`}>
217+ <span className="flex w-4 shrink-0 justify-center">
218+ <SpaceIcon space={space} size={14} />
219+ </span>
220+ <span className="min-w-0 truncate">{space.name}</span>
221+ </NavLink>
222+ {canDo(space.viewer_role, "manage") && (
223+ <Hint label="Space settings">
224+ <NavLink to={`/${slug}/-/docs/${space.slug}/settings`} aria-label={`${space.name} settings`} className="flex size-6 shrink-0 items-center justify-center rounded text-faint opacity-0 group-hover:opacity-100 hover:bg-line hover:text-fg focus-visible:opacity-100">
225+ <Settings size={13} />
226+ </NavLink>
227+ </Hint>
228+ )}
229+ {editable && (
230+ <Hint label={`New page in ${space.name}`}>
231+ <button type="button" onClick={() => add(null)} aria-label={`New page in ${space.name}`} className="flex size-6 shrink-0 items-center justify-center rounded text-faint opacity-0 group-hover:opacity-100 hover:bg-line hover:text-fg focus-visible:opacity-100">
232+ <Plus size={13} />
233+ </button>
234+ </Hint>
235+ )}
236+ </div>
237+ {!collapsed && (
238+ <ul>
239+ {tree.map((item) => (
240+ <TreeRow key={item.id} slug={slug} space={space} item={item} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={drop} onAdd={add} />
241+ ))}
242+ {tree.length === 0 && <li className="py-1 pr-2 pl-9 text-xs text-faint">No pages yet.</li>}
243+ </ul>
244+ )}
245+ </li>
246+ );
247+}
248+
249+export function DocsSidebar({ slug, onClose }: { slug: string; onClose?: () => void }) {
250+ const data = useDocsData();
251+ const navigate = useNavigate();
252+ const params = useParams();
253+ const { pathname } = useLocation();
254+ const current = pageIdOf(params.page);
255+ const [drag, setDrag] = useState<Drag>(null);
256+ const [query, setQuery] = useState("");
257+ const sidebar = data?.sidebar;
258+ const general = sidebar?.spaces.find((s) => s.is_default) ?? sidebar?.spaces.find((s) => canDo(s.viewer_role, "edit"));
259+ const newPage = async () => {
260+ if (!general) return;
261+ const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: general.id } });
262+ if (made.ok) navigate(made.value.path);
263+ };
264+ return (
265+ <div className="flex h-full flex-col">
266+ <div className="flex h-14 shrink-0 items-center gap-1 border-b border-line pr-2.5 pl-4">
267+ <h2 className="min-w-0 grow truncate text-[0.9375rem] font-semibold">Docs</h2>
268+ {general && (
269+ <Hint label="New page">
270+ <button type="button" onClick={newPage} aria-label="New page" className="flex size-8 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg">
271+ <Plus size={16} />
272+ </button>
273+ </Hint>
274+ )}
275+ {onClose && (
276+ <button type="button" aria-label="Close menu" onClick={onClose} className="flex size-8 shrink-0 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg">
277+ <ChevronRight size={16} className="rotate-180" />
278+ </button>
279+ )}
280+ </div>
281+ <nav aria-label="Docs" className="min-h-0 grow overflow-y-auto px-2.5 pt-3 pb-4 [scrollbar-width:thin]">
282+ <form
283+ role="search"
284+ onSubmit={(e) => {
285+ e.preventDefault();
286+ navigate(`/${slug}/-/docs/search?q=${encodeURIComponent(query.trim())}`);
287+ }}
288+ className="mb-2"
289+ >
290+ <label className="flex h-8 items-center gap-2 rounded-md border border-line bg-bg px-2 text-[0.8125rem] text-faint focus-within:border-accent/60">
291+ <Search size={14} aria-hidden="true" />
292+ <span className="sr-only">Search docs</span>
293+ <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search docs" className="min-w-0 grow bg-transparent text-fg outline-none placeholder:text-faint" />
294+ </label>
295+ </form>
296+ <div className="space-y-px">
297+ <SideLink to={`/${slug}/-/docs`} end icon={<Home size={15} />}>
298+ Home
299+ </SideLink>
300+ <SideLink to={`/${slug}/-/docs/templates`} icon={<LayoutTemplate size={15} />}>
301+ Templates
302+ </SideLink>
303+ <SideLink to={`/${slug}/-/docs/trash`} icon={<Trash2 size={15} />} trailing={sidebar?.trash_count ? <span className="text-[0.6875rem] text-faint tabular-nums">{sidebar.trash_count}</span> : undefined}>
304+ Trash
305+ </SideLink>
306+ </div>
307+ {sidebar === undefined || sidebar === null ? (
308+ sidebar === null ? (
309+ <p className="mt-4 px-2 text-xs leading-relaxed text-faint">Docs didn&apos;t answer. Your spaces will show here once it does.</p>
310+ ) : (
311+ <div className="mt-5 space-y-3 px-2" aria-busy="true">
312+ {[0, 1, 2, 3].map((i) => (
313+ <Skeleton key={i} className="h-3 w-36" />
314+ ))}
315+ </div>
316+ )
317+ ) : (
318+ <>
319+ {sidebar.favorites.length > 0 && (
320+ <Section title="Favorites">
321+ <ul className="space-y-px">
322+ {sidebar.favorites.map((p) => (
323+ <li key={p.id}>
324+ <SideLink to={p.path} icon={<PageIcon icon={p.icon} size={14} />} trailing={<Star size={12} className="shrink-0 fill-current text-warn/80" aria-hidden="true" />}>
325+ {p.title || "Untitled"}
326+ </SideLink>
327+ </li>
328+ ))}
329+ </ul>
330+ </Section>
331+ )}
332+ {sidebar.recent.length > 0 && (
333+ <Section title="Recent" open={false}>
334+ <ul className="space-y-px">
335+ {sidebar.recent.slice(0, 6).map((p) => (
336+ <li key={p.id}>
337+ <SideLink to={p.path} icon={<Clock size={14} />}>
338+ {p.title || "Untitled"}
339+ </SideLink>
340+ </li>
341+ ))}
342+ </ul>
343+ </Section>
344+ )}
345+ <Section
346+ title="Spaces"
347+ action={
348+ sidebar.can_create_space ? (
349+ <Hint label="New space">
350+ <NavLink to={`/${slug}/-/docs/new`} aria-label="New space" className="flex size-6 items-center justify-center rounded text-faint hover:bg-raised hover:text-fg">
351+ <Plus size={13} />
352+ </NavLink>
353+ </Hint>
354+ ) : undefined
355+ }
356+ >
357+ <ul>
358+ {sidebar.spaces.map((space) => (
359+ <SpaceTree key={space.id} slug={slug} space={space} current={current} drag={drag} setDrag={setDrag} />
360+ ))}
361+ </ul>
362+ </Section>
363+ {pathname === `/${slug}/-/docs` && sidebar.spaces.length === 0 && <p className="mt-3 px-2 text-xs text-faint">No spaces you can read yet.</p>}
364+ </>
365+ )}
366+ </nav>
367+ </div>
368+ );
369+}
370+
+152−0
1+/**
2+ * A space's settings, as a new space asks for them and its settings page
3+ * changes them: name, icon, address, who it is for, what they may do,
4+ * how agents change its pages, and the projects it is about.
5+ */
6+import { DOC_AGENT_MODE_LABELS, DOC_ROLE_LABELS, DOC_SPACE_KIND_LABELS, type DocAgentMode, type DocRole, type DocSpace, type DocSpaceKind } from "@g1t/contracts";
7+import { type FormEvent, useState } from "react";
8+
9+import { Button, ErrorText } from "../ui";
10+import { SelectField } from "../ui/select";
11+
12+export type SpaceFormValue = {
13+ name: string;
14+ icon: string;
15+ slug: string;
16+ description: string;
17+ kind: DocSpaceKind;
18+ team: string;
19+ default_role: DocRole;
20+ agent_mode: DocAgentMode;
21+ projects: string;
22+};
23+
24+export function initialSpace(space?: DocSpace | null): SpaceFormValue {
25+ return {
26+ name: space?.name ?? "",
27+ icon: space?.icon ?? "",
28+ slug: space?.slug ?? "",
29+ description: space?.description ?? "",
30+ kind: space?.kind ?? "workspace",
31+ team: space?.team ?? "",
32+ default_role: space?.default_role ?? "edit",
33+ agent_mode: space?.agent_mode ?? "suggest",
34+ projects: (space?.projects ?? []).join(", "),
35+ };
36+}
37+
38+const FIELD = "h-9 w-full rounded-md border border-line bg-bg px-3 text-sm text-fg outline-none placeholder:text-faint focus:border-accent/60";
39+
40+function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
41+ return (
42+ <div className="grid gap-1.5 sm:grid-cols-[12rem_minmax(0,1fr)] sm:gap-6">
43+ <div>
44+ <div className="text-sm font-medium">{label}</div>
45+ {hint && <p className="mt-0.5 text-xs leading-relaxed text-faint">{hint}</p>}
46+ </div>
47+ <div className="min-w-0">{children}</div>
48+ </div>
49+ );
50+}
51+
52+export function SpaceForm({
53+ initial,
54+ teams,
55+ isDefault = false,
56+ submitLabel,
57+ busy,
58+ error,
59+ onSubmit,
60+}: {
61+ initial: SpaceFormValue;
62+ teams: { slug: string; name: string }[];
63+ isDefault?: boolean;
64+ submitLabel: string;
65+ busy: boolean;
66+ error: string | null;
67+ onSubmit: (value: SpaceFormValue) => void;
68+}) {
69+ const [v, setV] = useState(initial);
70+ const set = <K extends keyof SpaceFormValue>(key: K, value: SpaceFormValue[K]) => setV((was) => ({ ...was, [key]: value }));
71+ const submit = (e: FormEvent) => {
72+ e.preventDefault();
73+ onSubmit(v);
74+ };
75+ const roles = (["view", "comment", "edit"] as DocRole[]).map((r) => ({ value: r, label: DOC_ROLE_LABELS[r] }));
76+ return (
77+ <form onSubmit={submit} className="space-y-6">
78+ <Row label="Name">
79+ <div className="flex gap-2">
80+ <input aria-label="Icon (an emoji)" value={v.icon} onChange={(e) => set("icon", e.target.value)} placeholder="📚" maxLength={8} className={`${FIELD} w-14 text-center`} />
81+ <input aria-label="Name" required value={v.name} onChange={(e) => set("name", e.target.value)} placeholder="Engineering" maxLength={80} className={FIELD} />
82+ </div>
83+ </Row>
84+ <Row label="Description" hint="One line on what belongs here.">
85+ <input aria-label="Description" value={v.description} onChange={(e) => set("description", e.target.value)} maxLength={300} placeholder="Specs, runbooks and decisions for the platform team" className={FIELD} />
86+ </Row>
87+ <Row label="Address" hint="In the space's links. Left empty, it comes from the name.">
88+ <input aria-label="Address" value={v.slug} onChange={(e) => set("slug", e.target.value.toLowerCase())} placeholder="engineering" pattern="[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?" className={`${FIELD} font-mono`} />
89+ </Row>
90+ <Row label="Who it's for" hint={isDefault ? "The General space is always the whole workspace's." : "Private spaces are only their members'. Not even owners see them unless added."}>
91+ <SelectField
92+ aria-label="Who it's for"
93+ value={v.kind}
94+ disabled={isDefault}
95+ onValueChange={(kind) => set("kind", kind as DocSpaceKind)}
96+ options={(["workspace", "team", "private"] as DocSpaceKind[]).filter((k) => k !== "team" || teams.length > 0).map((k) => ({ value: k, label: DOC_SPACE_KIND_LABELS[k] }))}
97+ className="h-9 w-full"
98+ />
99+ </Row>
100+ {v.kind === "team" && (
101+ <Row label="Team">
102+ <SelectField aria-label="Team" value={v.team} onValueChange={(team) => set("team", team)} placeholder="Choose a team" options={teams.map((t) => ({ value: t.slug, label: t.name }))} className="h-9 w-full" />
103+ </Row>
104+ )}
105+ {v.kind !== "private" && (
106+ <Row label={v.kind === "team" ? "The team can" : "Everyone can"} hint="Members you add below can be given more.">
107+ <SelectField aria-label="What they can do" value={v.default_role} onValueChange={(r) => set("default_role", r as DocRole)} options={roles} className="h-9 w-full" />
108+ </Row>
109+ )}
110+ <Row label="Agents in this space" hint="An agent never does more than the person it works for. With Suggest, its changes wait for a person to accept them.">
111+ <SelectField
112+ aria-label="Agents in this space"
113+ value={v.agent_mode}
114+ onValueChange={(m) => set("agent_mode", m as DocAgentMode)}
115+ options={(["suggest", "edit"] as DocAgentMode[]).map((m) => ({
116+ value: m,
117+ label: DOC_AGENT_MODE_LABELS[m],
118+ description: m === "suggest" ? "Tracked changes you accept or reject (recommended)" : "Agents edit pages directly; every edit is in the history",
119+ }))}
120+ className="h-9 w-full"
121+ />
122+ </Row>
123+ <Row label="Projects" hint="owner/name, comma separated. Docs can be filtered by project.">
124+ <input aria-label="Projects" value={v.projects} onChange={(e) => set("projects", e.target.value)} placeholder="acme/web, acme/api" className={`${FIELD} font-mono`} />
125+ </Row>
126+ {error && <ErrorText>{error}</ErrorText>}
127+ <div className="flex justify-end">
128+ <Button type="submit" variant="accent" disabled={busy}>
129+ {busy ? "Saving…" : submitLabel}
130+ </Button>
131+ </div>
132+ </form>
133+ );
134+}
135+
136+/** The form's value as the service takes it. */
137+export function spaceInput(v: SpaceFormValue) {
138+ return {
139+ name: v.name.trim(),
140+ icon: v.icon.trim() || null,
141+ slug: v.slug.trim() || null,
142+ description: v.description.trim() || null,
143+ kind: v.kind,
144+ team: v.kind === "team" ? v.team : null,
145+ default_role: v.kind === "private" ? null : v.default_role,
146+ agent_mode: v.agent_mode,
147+ projects: v.projects
148+ .split(/[\s,]+/)
149+ .map((p) => p.trim())
150+ .filter(Boolean),
151+ };
152+}
+2−0
1+/** The built-in templates Home shows first (the rest are on the Templates page). */
2+export const BUILTIN_TEMPLATE_IDS = ["builtin:meeting-notes", "builtin:rfc", "builtin:runbook", "builtin:postmortem", "builtin:decision", "builtin:onboarding", "builtin:project-brief", "builtin:weekly-update"];
+26−0
1+/**
2+ * Names and faces for member keys (`user:<id>`, `agent:<id>`): comment
3+ * authors and history. Asked of the site once per key per tab.
4+ */
5+import type { Result } from "@g1t/contracts";
6+
7+/** Someone by member key, as the site names them. */
8+export type Who = { key: string; kind: "user" | "agent"; id: string; name: string; display_name: string; avatar: string | null; avatar_seed: string | null };
9+
10+const known = new Map<string, Who>();
11+
12+/** Names and faces for member keys (`user:<id>`, `agent:<id>`), asked of the site once each. */
13+export async function whoAre(slug: string, keys: string[]): Promise<Map<string, Who>> {
14+ const wanted = [...new Set(keys)].filter((k) => !known.has(k) && /^(user|agent):/.test(k));
15+ if (wanted.length) {
16+ try {
17+ const r = await fetch(`/${slug}/-/docs/api?who=${encodeURIComponent(wanted.join(","))}`, { headers: { accept: "application/json" } });
18+ const result = (await r.json()) as Result<Who[]>;
19+ if (result.ok) for (const w of result.value) known.set(w.key, w);
20+ } catch {
21+ // Shown as "Someone" until asked again.
22+ }
23+ }
24+ return new Map(keys.map((k) => [k, known.get(k)!]).filter(([, w]) => !!w) as [string, Who][]);
25+}
26+
+5−11
190190
191191 /** What the line under the frame says about the moment: working today, or a preview. */
192192 function honesty(frame: Frame): { today: boolean; label: string } {
193− if (frame.scene === "docs") return { today: false, label: "Docs: coming soon" };
193+ if (frame.scene === "docs") return { today: false, label: "Docs works today; an agent updating a page after a merge on its own is coming soon" };
194194 if (frame.scene === "agents") return { today: true, label: "Sessions work today" };
195195 if (frame.scene === "code") return { today: true, label: "Works today" };
196196 if (frame.handoff && !frame.shipped) return { today: false, label: "g1t splitting work between colleagues: coming soon" };
10661066
10671067 /* Docs ---------------------------------------------------------------- */
10681068
1069−/** Docs is coming: its sidebar is a preview of what it will hold, and says so. */
1069+/** Docs' sidebar: search, then each space's pages. */
10701070 function DocsSide() {
10711071 return (
10721072 <>
1073− <SideHead action={<Plus size={14} />} soon>
1073+ <SideHead action={<Plus size={14} />}>
10741074 Docs
10751075 </SideHead>
10761076 <div className="px-2 pt-2.5">
10941094 );
10951095 }
10961096
1097−function DocsMain({ frame, tag = true }: { frame: Frame; tag?: boolean }) {
1097+function DocsMain({ frame }: { frame: Frame; tag?: boolean }) {
10981098 return (
10991099 <div className="relative h-full px-10 py-7">
1100− {tag && (
1101− <span className="absolute top-4 right-5 rounded-full bg-raised px-2.5 py-1 font-mono text-[10px] tracking-wide text-muted uppercase ring-1 ring-line-strong">
1102− Coming soon
1103− </span>
1104− )}
11051100 <p className="text-[11px] text-faint">Product / Exporting data</p>
11061101 <h3 className="mt-2 text-[22px] font-semibold tracking-tight text-fg">Exporting data</h3>
11071102 <p className="mt-1.5 flex items-center gap-2 text-[11px] text-faint">
13001295 <span className="text-faint">{MODE[frame.scene].icon}</span>
13011296 <span className="truncate">{MODE[frame.scene].label}</span>
13021297 </span>
1303− {frame.scene === "docs" && <Soon />}
13041298 </div>
13051299 <div className="relative min-h-0 flex-1">
13061300 {scenes.map((scene) => {
16081602 </div>
16091603 <p className="mx-auto mt-3 max-w-xl text-center text-xs leading-5 text-faint text-balance">
16101604 Chat, agents and their sessions (colleagues brought in included), pull requests, checks and the merge queue work
1611− today. g1t splitting one request between colleagues on its own, and Docs, are coming soon. Otto, Margo and the others are
1605+ today, and so does Docs. g1t splitting one request between colleagues on its own, and an agent updating a page after a merge by itself, are coming soon. Otto, Margo and the others are
16121606 agents this workspace hired from role templates; only @g1t comes built in.
16131607 </p>
16141608 </div>
+9−6
2828 import { type ModeKey, SETTINGS_PAGES, modeOf, sidebarCurrent } from "../lib/workspace-nav";
2929 import { AgentsSidebar } from "./agents-mode";
3030 import { ChatSidebar } from "./chat/sidebar";
31+import { DocsSidebar } from "./docs/sidebar";
3132 import { HelpMenu, Rail } from "./rail";
3233 import { HomeSidebar } from "./home-sidebar";
3334 import { G1tMark } from "./orchestrator";
20562057 const SIDEBAR_BOX = "h-full border-r border-line bg-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))]";
20572058
20582059 /**
2059− * Which sidebar sits beside the rail: each mode's own, or none (Docs, the
2060− * Inbox, which is a page of its own, and g1t's public pages, such as a
2061− * profile, which are no workspace's and carry their own left column).
2062− * Without a workspace (a visitor), the one sidebar there always was.
2060+ * Which sidebar sits beside the rail: each mode's own, or none (the Inbox,
2061+ * which is a page of its own, and g1t's public pages, such as a profile,
2062+ * which are no workspace's and carry their own left column). Without a
2063+ * workspace (a visitor), the one sidebar there always was.
20632064 */
20642065 function sidebarFor(mode: ModeKey | null): Panel | null {
20652066 if (mode == null) return "code";
2066− if (mode === "docs" || mode === "inbox" || mode === "site") return null;
2067+ if (mode === "inbox" || mode === "site") return null;
20672068 return mode;
20682069 }
20692070
20702071 /** The sidebars that sit beside the rail, one per mode that has one. */
2071−type Panel = "home" | "chat" | "agents" | "code" | "workspace" | "account";
2072+type Panel = "home" | "chat" | "docs" | "agents" | "code" | "workspace" | "account";
20722073
20732074 /**
20742075 * The app: a sidebar with the workspace, its repositories and the sections
21442145 return <HomeSidebar slug={ws.slug} shell={shell} code={code} onFind={find} header={<ModeHeader title="Home" onClose={close} />} />;
21452146 case "chat":
21462147 return <ChatSidebar slug={ws.slug} />;
2148+ case "docs":
2149+ return <DocsSidebar slug={ws.slug} onClose={close} />;
21472150 case "agents":
21482151 return <AgentsSidebar slug={ws.slug} shellAgents={shell.agents ?? null} code={code} owner={ws.role === "owner"} />;
21492152 case "workspace":
+53−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { buildTree, canDo, coverStyle, cursorColour, flatten, pageIdOf, pagePath, pathTo, readingTime, snippetParts } from "./docs.ts";
5+
6+const id = "pag_01jb2k7x9hfq0b3zj0f5s2m8ra";
7+
8+const nodes = [
9+ { id: "b", parent_id: null, position: 2, title: "B", icon: null, slug: "b" },
10+ { id: "a", parent_id: null, position: 1, title: "A", icon: null, slug: "a" },
11+ { id: "c", parent_id: "a", position: 1, title: "C", icon: null, slug: "c" },
12+ { id: "orphan", parent_id: "gone", position: 3, title: "O", icon: null, slug: "o" },
13+];
14+
15+test("the tree orders each level and lifts pages whose parent is gone", () => {
16+ const tree = buildTree(nodes);
17+ assert.deepEqual(
18+ tree.map((n) => n.id),
19+ ["a", "b", "orphan"],
20+ );
21+ assert.equal(tree[0]!.children[0]!.id, "c");
22+ assert.equal(tree[0]!.children[0]!.depth, 1);
23+ assert.deepEqual(
24+ flatten(tree).map((n) => n.id),
25+ ["a", "c", "b", "orphan"],
26+ );
27+ assert.deepEqual(pathTo(nodes, "c"), ["a"]);
28+});
29+
30+test("page addresses carry the id, read back from the end", () => {
31+ assert.equal(pagePath("acme", "eng", "Release plan!", id), `/acme/-/docs/eng/release-plan-${id}`);
32+ assert.equal(pagePath("acme", "eng", "", id), `/acme/-/docs/eng/${id}`);
33+ assert.equal(pageIdOf(`old-title-${id}`), id);
34+ assert.equal(pageIdOf("settings"), null);
35+});
36+
37+test("snippets mark their matches", () => {
38+ assert.deepEqual(snippetParts("a [[b]] c"), [
39+ { text: "a ", match: false },
40+ { text: "b", match: true },
41+ { text: " c", match: false },
42+ ]);
43+});
44+
45+test("covers, colours, roles, reading time", () => {
46+ assert.match(coverStyle("gradient:1")!, /^linear-gradient/);
47+ assert.match(coverStyle("https://g1tusercontent.com/docs-files/x")!, /url\("https:/);
48+ assert.equal(coverStyle("javascript:alert(1)"), null);
49+ assert.equal(cursorColour("ana"), cursorColour("ana"));
50+ assert.equal(canDo("edit", "comment"), true);
51+ assert.equal(canDo("comment", "edit"), false);
52+ assert.deepEqual(readingTime("one two three"), { words: 3, minutes: 1 });
53+});
+137−0
1+/**
2+ * Docs mode's pure helpers: page trees, addresses, cursor colours, covers
3+ * and how search snippets mark their matches. No Workers or DOM imports,
4+ * so it is tested under Node.
5+ */
6+import type { DocRole, DocTreeNode } from "@g1t/contracts";
7+
8+export type TreeItem = DocTreeNode & { children: TreeItem[]; depth: number };
9+
10+/** A space's flat page list as a tree, each level in position order. */
11+export function buildTree(nodes: DocTreeNode[]): TreeItem[] {
12+ const byParent = new Map<string | null, DocTreeNode[]>();
13+ const ids = new Set(nodes.map((n) => n.id));
14+ for (const node of nodes) {
15+ // A page whose parent is gone (in the trash) shows at the top.
16+ const parent = node.parent_id && ids.has(node.parent_id) ? node.parent_id : null;
17+ const list = byParent.get(parent) ?? [];
18+ list.push(node);
19+ byParent.set(parent, list);
20+ }
21+ const seen = new Set<string>();
22+ const build = (parent: string | null, depth: number): TreeItem[] =>
23+ (byParent.get(parent) ?? [])
24+ .sort((a, b) => a.position - b.position || a.id.localeCompare(b.id))
25+ .filter((n) => !seen.has(n.id) && seen.add(n.id))
26+ .map((n) => ({ ...n, depth, children: build(n.id, depth + 1) }));
27+ return build(null, 0);
28+}
29+
30+/** Every page in a tree, depth first, as a move dialog lists them. */
31+export function flatten(tree: TreeItem[]): TreeItem[] {
32+ const out: TreeItem[] = [];
33+ const walk = (items: TreeItem[]) => {
34+ for (const item of items) {
35+ out.push(item);
36+ walk(item.children);
37+ }
38+ };
39+ walk(tree);
40+ return out;
41+}
42+
43+/** The ids on the way to `id` (its ancestors), so the sidebar opens them. */
44+export function pathTo(nodes: DocTreeNode[], id: string | null): string[] {
45+ if (!id) return [];
46+ const byId = new Map(nodes.map((n) => [n.id, n]));
47+ const out: string[] = [];
48+ let at = byId.get(id)?.parent_id ?? null;
49+ while (at && !out.includes(at)) {
50+ out.unshift(at);
51+ at = byId.get(at)?.parent_id ?? null;
52+ }
53+ return out;
54+}
55+
56+/** The words of a title as an address: `release-plan`. */
57+export function titleSlug(title: string): string {
58+ return title
59+ .normalize("NFKD")
60+ .replace(/[\u0300-\u036f]/g, "")
61+ .toLowerCase()
62+ .replace(/[^a-z0-9]+/g, "-")
63+ .replace(/^-+|-+$/g, "")
64+ .slice(0, 50)
65+ .replace(/-+$/g, "");
66+}
67+
68+/** A page's address, from its space and current title; the id at the end is what is read. */
69+export function pagePath(workspace: string, space: string, title: string, id: string): string {
70+ const words = titleSlug(title);
71+ return `/${workspace}/-/docs/${space}/${words ? `${words}-${id}` : id}`;
72+}
73+
74+/** The page id at the end of a page's address segment. */
75+export function pageIdOf(segment: string | undefined): string | null {
76+ return /(pag_[0-9a-hjkmnp-tv-z]{26})$/.exec(segment ?? "")?.[1] ?? null;
77+}
78+
79+/** A search snippet's parts: `[[word]]` marks a match. */
80+export function snippetParts(snippet: string): { text: string; match: boolean }[] {
81+ const out: { text: string; match: boolean }[] = [];
82+ const re = /\[\[([\s\S]*?)\]\]/g;
83+ let at = 0;
84+ for (let m = re.exec(snippet); m; m = re.exec(snippet)) {
85+ if (m.index > at) out.push({ text: snippet.slice(at, m.index), match: false });
86+ out.push({ text: m[1]!, match: true });
87+ at = m.index + m[0].length;
88+ }
89+ if (at < snippet.length) out.push({ text: snippet.slice(at), match: false });
90+ return out;
91+}
92+
93+/** Cursor colours: distinct, readable on the dark page. */
94+export const CURSOR_COLOURS = ["#b8a6ff", "#7dd3fc", "#86efac", "#fcd34d", "#fca5a5", "#f9a8d4", "#a5b4fc", "#5eead4", "#fdba74", "#c4b5fd"];
95+
96+/** A person's cursor colour, stable for their name. */
97+export function cursorColour(name: string): string {
98+ let hash = 0;
99+ for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
100+ return CURSOR_COLOURS[Math.abs(hash) % CURSOR_COLOURS.length]!;
101+}
102+
103+/** The covers a page can take without an upload. */
104+export const COVER_GRADIENTS = [
105+ "linear-gradient(120deg, #2a2340 0%, #4b3d7a 50%, #8f7ee0 100%)",
106+ "linear-gradient(120deg, #11222c 0%, #1f4b5c 55%, #4fb3c8 100%)",
107+ "linear-gradient(120deg, #1d1f17 0%, #3d4a24 55%, #a3c45a 100%)",
108+ "linear-gradient(120deg, #2b1a1a 0%, #5c2e2e 55%, #d9776a 100%)",
109+ "linear-gradient(120deg, #241a2b 0%, #5a2d5f 55%, #d27bd8 100%)",
110+ "linear-gradient(120deg, #1a1d2b 0%, #2c3466 55%, #7a8cf0 100%)",
111+];
112+
113+/** A cover's CSS background: one of the gradients, or an image. */
114+export function coverStyle(cover: string | null | undefined): string | null {
115+ if (!cover) return null;
116+ const gradient = /^gradient:(\d{1,2})$/.exec(cover);
117+ if (gradient) return COVER_GRADIENTS[Number(gradient[1]) % COVER_GRADIENTS.length]!;
118+ if (/^https:\/\//.test(cover)) return `center / cover no-repeat url("${cover.replace(/["\\]/g, "")}")`;
119+ return null;
120+}
121+
122+const RANK: Record<DocRole, number> = { view: 1, comment: 2, edit: 3, manage: 4 };
123+
124+export function canDo(role: DocRole | null | undefined, need: DocRole): boolean {
125+ return !!role && RANK[role] >= RANK[need];
126+}
127+
128+/** How many words a page has, and the minutes it takes to read. */
129+export function readingTime(markdown: string): { words: number; minutes: number } {
130+ const words = markdown.replace(/```[\s\S]*?```/g, " ").split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length;
131+ return { words, minutes: Math.max(1, Math.round(words / 230)) };
132+}
133+
134+/** A Markdown file's name for a page. */
135+export function markdownFileName(title: string): string {
136+ return `${title.replace(/[\\/:*?"<>|]+/g, " ").trim() || "Untitled"}.md`;
137+}
+4−0
88 chatClient,
99 contextClient,
1010 deploymentsClient,
11+ docsClient,
1112 memoryReviewClient,
1213 packagesClient,
1314 projectsClient,
4748 const PACKAGES = instrumented("packages", env.PACKAGES);
4849 const CHAT = instrumented("chat", env.CHAT);
4950 const AGENTS = instrumented("agents", env.AGENTS);
51+const DOCS = instrumented("docs", env.DOCS);
5052
5153 export const identity = identityClient(IDENTITY);
5254 /** A person's email addresses and account security: methods of identity. */
8183 export const chat = chatClient(CHAT);
8284 /** The workspace's own agents: who they are, their limits and their desks. */
8385 export const workspaceAgents = workspaceAgentsClient(AGENTS);
86+/** Docs: spaces, pages, history, suggestions and comments. */
87+export const docs = docsClient(DOCS);
+3−1
141141 // Sessions, colleagues brought in, spend against a cap: built.
142142 { scene: "agents", label: "Agents", start: BEAT.agents, soon: false },
143143 { scene: "code", label: "Code", start: BEAT.code, soon: false },
144− { scene: "docs", label: "Docs", start: BEAT.docs, soon: true },
144+ // Docs is built; an agent keeping a page current after a merge on its
145+ // own is not yet, and the line under the frame says so.
146+ { scene: "docs", label: "Docs", start: BEAT.docs, soon: false },
145147 ];
146148
147149 /** Which pill a time falls under. Back in chat after the merge is still Code's story. */
+22−3
156156 route("dm/:id", "routes/workspace/chat/channel.tsx", { id: "routes/workspace/chat/dm" }),
157157 route(":channel", "routes/workspace/chat/channel.tsx"),
158158 ]),
159− // Docs mode, coming; Home for a member without Code; and what Code's
160− // pages say to them (docs/WORKSPACE.md, "Members without Code").
161− route("-/docs", "routes/workspace/docs.tsx"),
159+ // Docs mode (docs/WORKSPACE.md, "Docs"): what its pages call as they
160+ // run (a page's live socket, JSON, comments, uploads, export), then
161+ // Home, search, templates, the trash, a new space, and each space and
162+ // page by its address. A page's address ends in its id, so renaming it
163+ // keeps links working.
164+ route("-/docs/live", "routes/workspace/docs/live.ts"),
165+ route("-/docs/api", "routes/workspace/docs/api.ts"),
166+ route("-/docs/threads/:page/*", "routes/workspace/docs/threads.ts"),
167+ route("-/docs/upload", "routes/workspace/docs/upload.ts"),
168+ route("-/docs/export", "routes/workspace/docs/export.ts"),
169+ route("-/docs", "routes/workspace/docs/layout.tsx", [
170+ index("routes/workspace/docs/home.tsx"),
171+ route("search", "routes/workspace/docs/search.tsx"),
172+ route("templates", "routes/workspace/docs/templates.tsx"),
173+ route("trash", "routes/workspace/docs/trash.tsx"),
174+ route("new", "routes/workspace/docs/new-space.tsx"),
175+ route(":space", "routes/workspace/docs/space.tsx"),
176+ route(":space/settings", "routes/workspace/docs/space-settings.tsx"),
177+ route(":space/:page", "routes/workspace/docs/page.tsx"),
178+ ]),
179+ // Home for a member without Code, and what Code's pages say to them
180+ // (docs/WORKSPACE.md, "Members without Code").
162181 route("-/home", "routes/workspace/home.tsx"),
163182 route("-/code-access", "routes/workspace/code-access.tsx"),
164183 route("-/memory", "routes/workspace/memory.tsx"),
+0−54
1−import { BookOpen, FileText, GitBranch, MessagesSquare, Sparkles } from "lucide-react";
2−import type { ReactNode } from "react";
3−
4−import type { Route } from "./+types/docs";
5−import { page } from "../../lib/meta";
6−
7−export function meta({ params, ...args }: Route.MetaArgs) {
8− return page(args, { title: `Docs · ${params.owner} · g1t` });
9−}
10−
11−function Point({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
12− return (
13− <li className="flex gap-3.5 rounded-xl border border-line bg-surface p-4">
14− <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-raised text-muted">{icon}</span>
15− <span>
16− <span className="block text-sm font-medium">{title}</span>
17− <span className="mt-0.5 block text-[0.8125rem] leading-relaxed text-muted">{children}</span>
18− </span>
19− </li>
20− );
21−}
22−
23−/** Docs mode, coming: what it will be (docs/WORKSPACE.md, "Docs"). */
24−export default function Docs() {
25− return (
26− <div className="mx-auto max-w-3xl">
27− <div className="flex items-center gap-2">
28− <span className="flex size-10 items-center justify-center rounded-xl bg-accent/15 text-accent">
29− <BookOpen size={20} />
30− </span>
31− <span className="rounded-full px-2 py-0.5 text-[0.6875rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">Coming soon</span>
32− </div>
33− <h1 className="mt-5 text-3xl font-semibold tracking-tight">Docs that keep themselves true</h1>
34− <p className="mt-3 max-w-2xl text-[0.9375rem] leading-relaxed text-muted">
35− The workspace&apos;s written knowledge: specs, runbooks, decisions and onboarding, edited together in real time. Agents read it before they
36− work and keep it current after every merge.
37− </p>
38− <ul className="mt-8 grid gap-3 sm:grid-cols-2">
39− <Point icon={<FileText size={16} />} title="Spaces and pages">
40− One space per team or project, each a tree of pages with history, comments and backlinks.
41− </Point>
42− <Point icon={<Sparkles size={16} />} title="Agents read and write">
43− Every reply and task draws on it. Without write access, an agent&apos;s edit is a suggestion you accept or reject.
44− </Point>
45− <Point icon={<GitBranch size={16} />} title="Pages know what they describe">
46− A page that cites code is marked stale when a merge changes it, and its owner drafts the update.
47− </Point>
48− <Point icon={<MessagesSquare size={16} />} title="Conversations become pages">
49− &ldquo;Write this up&rdquo; in a thread makes a page from it, linked both ways, so decisions get a home.
50− </Point>
51− </ul>
52− </div>
53− );
54−}
+190−0
1+import { data } from "react-router";
2+
3+import type { DocEditTarget, DocMove, DocPageChange, DocRole, DocSpaceChange, NewDocPage, NewDocSpace, Result } from "@g1t/contracts";
4+
5+import type { Route } from "./+types/api";
6+import { workspacePeople } from "../../../lib/chat.server";
7+import { docs, identity, repos, work } from "../../../lib/services.server";
8+import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
9+import { pageIdOf } from "../../../lib/docs";
10+
11+/**
12+ * What Docs pages ask for as they run, as JSON: page search for links and
13+ * the sidebar's search box, a page's history and one version, cards for
14+ * embedded g1t things; and everything they change: pages, spaces, members,
15+ * suggestions, templates. Every answer is `{ ok, value }` or
16+ * `{ ok: false, error }`, the services' own shape.
17+ */
18+export async function loader({ params, context, request }: Route.LoaderArgs) {
19+ const viewer = requireUser(context, request);
20+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
21+ const slug = params.owner.toLowerCase();
22+ const url = new URL(request.url);
23+ const q = url.searchParams;
24+ const answer = await safely<unknown>(async () => {
25+ if (q.has("search")) return docs.search(slug, viewer, { query: q.get("search") ?? "", space_id: q.get("space"), project: q.get("project"), limit: Number(q.get("limit")) || 10 });
26+ if (q.get("versions")) return docs.versions(slug, q.get("versions")!, viewer);
27+ if (q.get("version") && q.get("page")) return docs.version(slug, q.get("page")!, q.get("version")!, viewer);
28+ if (q.get("suggestions")) return docs.suggestions(slug, q.get("suggestions")!, viewer);
29+ if (q.get("threads")) return docs.threads(slug, q.get("threads")!, viewer);
30+ if (q.get("space")) return docs.space(slug, q.get("space")!, viewer);
31+ if (q.get("embed")) return embed(slug, q.get("embed")!, viewer, url.origin);
32+ if (q.has("templates")) return docs.templates(slug, viewer);
33+ // How member keys show (`user:<id>`, `agent:<id>`): comment authors, people on a page.
34+ if (q.get("who")) return who(slug, viewer, q.get("who")!.split(",").slice(0, 100));
35+ // A member's key (`user:<id>`) by username, for adding them to a space.
36+ if (q.get("person")) {
37+ const user = await identity.userByUsername(q.get("person")!.toLowerCase());
38+ return user ? { ok: true, value: `user:${user.id}` } : { ok: false, error: { code: "not_found", message: "No such person." } };
39+ }
40+ return docs.sidebar(slug, viewer);
41+ });
42+ return Response.json(answer, { headers: { "cache-control": "no-store" } });
43+}
44+
45+type Sent = {
46+ intent?: string;
47+ page_id?: string;
48+ space_id?: string;
49+ suggestion_id?: string;
50+ version_id?: string;
51+ template_id?: string;
52+ member?: string;
53+ role?: DocRole | null;
54+ decision?: "accept" | "reject";
55+ on?: boolean;
56+ page?: NewDocPage;
57+ change?: DocPageChange;
58+ move?: DocMove;
59+ space?: NewDocSpace;
60+ space_change?: DocSpaceChange;
61+ name?: string;
62+ description?: string | null;
63+ target?: DocEditTarget;
64+};
65+
66+export async function action({ params, context, request }: Route.ActionArgs) {
67+ assertSameOrigin(request);
68+ const viewer = requireUser(context, request);
69+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
70+ const slug = params.owner.toLowerCase();
71+ const sent = (await request.json().catch(() => ({}))) as Sent;
72+ const page = sent.page_id ?? "";
73+ const answer = await safely(async (): Promise<Result<unknown>> => {
74+ switch (sent.intent) {
75+ case "create_page":
76+ return docs.createPage(slug, viewer, sent.page ?? ({ space_id: sent.space_id ?? "" } as NewDocPage));
77+ case "update_page":
78+ return docs.updatePage(slug, page, viewer, sent.change ?? {});
79+ case "move_page":
80+ return docs.movePage(slug, page, viewer, sent.move ?? { parent_id: null });
81+ case "duplicate_page":
82+ return docs.duplicatePage(slug, page, viewer);
83+ case "archive_page":
84+ return docs.archivePage(slug, page, viewer);
85+ case "restore_page":
86+ return docs.restorePage(slug, page, viewer);
87+ case "delete_page":
88+ return docs.deletePage(slug, page, viewer);
89+ case "favorite":
90+ return docs.favorite(slug, page, viewer, !!sent.on);
91+ case "restore_version":
92+ return docs.restoreVersion(slug, page, sent.version_id ?? "", viewer);
93+ case "decide":
94+ return docs.decideSuggestion(slug, sent.suggestion_id ?? "", viewer, sent.decision === "accept" ? "accept" : "reject");
95+ case "accept_all":
96+ return docs.acceptAll(slug, page, viewer);
97+ case "save_template":
98+ return docs.saveTemplate(slug, viewer, { page_id: page, name: sent.name ?? "", description: sent.description ?? null });
99+ case "delete_template":
100+ return docs.deleteTemplate(slug, sent.template_id ?? "", viewer);
101+ case "create_space":
102+ return docs.createSpace(slug, viewer, sent.space ?? ({ name: "", kind: "workspace" } as NewDocSpace));
103+ case "update_space":
104+ return docs.updateSpace(slug, sent.space_id ?? "", viewer, sent.space_change ?? {});
105+ case "set_member":
106+ return docs.setSpaceMember(slug, sent.space_id ?? "", viewer, sent.member ?? "", sent.role ?? null);
107+ default:
108+ return { ok: false, error: { code: "invalid", message: "Unknown request." } };
109+ }
110+ });
111+ return Response.json(answer, { headers: { "cache-control": "no-store" } });
112+}
113+
114+async function safely<T>(run: () => Promise<Result<T>>): Promise<Result<T>> {
115+ try {
116+ return await run();
117+ } catch (error) {
118+ console.error("docs api:", error);
119+ return { ok: false, error: { code: "conflict", message: "Docs didn't answer. Try again in a moment." } };
120+ }
121+}
122+
123+/**
124+ * The card for a g1t address embedded in a page, as this reader may see
125+ * it: an issue or pull request with its state, a channel, a project, a
126+ * page. Anything they can't read is not found, exactly as one that does
127+ * not exist.
128+ */
129+async function embed(slug: string, address: string, viewer: Parameters<typeof docs.page>[2], origin: string): Promise<Result<unknown>> {
130+ let path: string;
131+ try {
132+ const url = new URL(address, origin);
133+ if (url.origin !== origin) return { ok: true, value: { kind: "link", title: url.hostname + url.pathname, subtitle: url.hostname, state: null, href: url.toString() } };
134+ path = url.pathname;
135+ } catch {
136+ return { ok: false, error: { code: "invalid", message: "That isn't an address." } };
137+ }
138+ const parts = path.split("/").filter(Boolean).map(decodeURIComponent);
139+ const missing = { ok: false as const, error: { code: "not_found" as const, message: "Not found." } };
140+ // /<owner>/-/docs/<space>/<page>
141+ if (parts[1] === "-" && parts[2] === "docs" && parts[4]) {
142+ if (parts[0]!.toLowerCase() !== slug) return missing;
143+ const id = pageIdOf(parts[4]);
144+ if (!id) return missing;
145+ const found = await docs.page(slug, id, viewer);
146+ if (!found.ok) return missing;
147+ return { ok: true, value: { kind: "page", title: `${found.value.page.icon ? `${found.value.page.icon} ` : ""}${found.value.page.title || "Untitled"}`, subtitle: found.value.space.name, state: null, href: found.value.page.path } };
148+ }
149+ // /<owner>/-/chat/<channel>
150+ if (parts[1] === "-" && parts[2] === "chat" && parts[3]) {
151+ return { ok: true, value: { kind: "channel", title: parts[3] === "dm" ? "A direct message" : `#${parts[3]}`, subtitle: "Chat", state: null, href: path } };
152+ }
153+ if (parts.length < 2 || parts[1] === "-") return missing;
154+ const repo = { namespace: parts[0]!, name: parts[1]! };
155+ // /<owner>/<repo>/issues/<n> and /pull/<n>
156+ const number = Number(parts[3]);
157+ if ((parts[2] === "issues" || parts[2] === "pull" || parts[2] === "pulls") && Number.isInteger(number) && number > 0) {
158+ if (parts[2] === "issues") {
159+ const found = await work.getIssue(repo, number, viewer);
160+ if (!found.ok) return missing;
161+ return { ok: true, value: { kind: "issue", title: `${found.value.issue.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state: found.value.issue.state, href: path } };
162+ }
163+ const found = await work.getPull(repo, number, viewer);
164+ if (!found.ok) return missing;
165+ const pull = found.value.pull as { title: string; status: string; mergedAt?: string | null };
166+ const state = pull.mergedAt ? "merged" : String(pull.status ?? "open");
167+ return { ok: true, value: { kind: "pull", title: `${pull.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state, href: path } };
168+ }
169+ const found = await repos.get(repo, viewer);
170+ if (!found.ok) return missing;
171+ return { ok: true, value: { kind: "project", title: `${repo.namespace}/${repo.name}`, subtitle: (found.value as { description?: string | null }).description ?? "Project", state: null, href: `/${repo.namespace}/${repo.name}` } };
172+}
173+
174+/** Names and faces for member keys, as the workspace knows them. */
175+async function who(slug: string, viewer: Parameters<typeof docs.page>[2], keys: string[]): Promise<Result<unknown>> {
176+ const userIds = keys.filter((k) => k.startsWith("user:")).map((k) => k.slice(5));
177+ const agentIds = new Set(keys.filter((k) => k.startsWith("agent:")).map((k) => k.slice(6)));
178+ const [names, { people, agents }] = await Promise.all([userIds.length ? identity.usernames(userIds).catch(() => ({}) as Record<string, string>) : Promise.resolve({} as Record<string, string>), workspacePeople(slug, viewer)]);
179+ const out = keys.map((key) => {
180+ if (key.startsWith("agent:")) {
181+ const agent = agentIds.has(key.slice(6)) ? agents.find((a) => a.id === key.slice(6)) : undefined;
182+ return { key, kind: "agent", id: key.slice(6), name: agent?.handle ?? "agent", display_name: agent?.display_name ?? "Former agent", avatar: agent?.avatar ?? null, avatar_seed: agent?.avatar_seed ?? null };
183+ }
184+ const username = names[key.slice(5)]?.toLowerCase();
185+ const person = username ? people.find((p) => p.name === username) : undefined;
186+ return { key, kind: "user", id: key.slice(5), name: username ?? "ghost", display_name: person?.display_name ?? username ?? "Former member", avatar: person?.avatar ?? null, avatar_seed: null };
187+ });
188+ return { ok: true, value: out };
189+}
190+
+45−0
1+import { data } from "react-router";
2+
3+import type { Route } from "./+types/export";
4+import { docs } from "../../../lib/services.server";
5+import { requireUser, roleIn } from "../../../lib/session.server";
6+import { zip } from "../../../lib/zip";
7+
8+/**
9+ * Docs as Markdown files: `?page=<id>` downloads a page as `<title>.md`;
10+ * `?space=<id>` downloads a space as a zip of its pages, in folders that
11+ * follow its tree. Only what the viewer can read.
12+ */
13+export async function loader({ params, context, request }: Route.LoaderArgs) {
14+ const viewer = requireUser(context, request);
15+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
16+ const slug = params.owner.toLowerCase();
17+ const url = new URL(request.url);
18+ const page = url.searchParams.get("page");
19+ const space = url.searchParams.get("space");
20+ if (page) {
21+ const found = await docs.exportPage(slug, page, viewer);
22+ if (!found.ok) throw data(found.error.message, { status: found.error.code === "not_found" ? 404 : 403 });
23+ return new Response(found.value.markdown, {
24+ headers: {
25+ "content-type": "text/markdown; charset=utf-8",
26+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(found.value.filename)}`,
27+ "cache-control": "no-store",
28+ },
29+ });
30+ }
31+ if (space) {
32+ const found = await docs.exportSpace(slug, space, viewer);
33+ if (!found.ok) throw data(found.error.message, { status: found.error.code === "not_found" ? 404 : 403 });
34+ const encoder = new TextEncoder();
35+ const bytes = await zip(found.value.files.map((f) => ({ path: `${found.value.name}/${f.path}`, data: encoder.encode(f.markdown) })));
36+ return new Response(bytes, {
37+ headers: {
38+ "content-type": "application/zip",
39+ "content-disposition": `attachment; filename="${found.value.name}.zip"`,
40+ "cache-control": "no-store",
41+ },
42+ });
43+ }
44+ throw data("Which page or space?", { status: 400 });
45+}
+195−0
1+import type { DocTemplate, DocsHome } from "@g1t/contracts";
2+import { BookOpen, FilePlus2, Plus, Search } from "lucide-react";
3+import { useState } from "react";
4+import { Form, Link, data, useNavigate, useSubmit } from "react-router";
5+
6+import type { Route } from "./+types/home";
7+import { docsRequest, useDocsData } from "../../../components/docs/actions";
8+import { PageCard, SectionTitle, SpaceCard, TemplateCard } from "../../../components/docs/parts";
9+import { DocsSidebar } from "../../../components/docs/sidebar";
10+import { ButtonLink, EmptyState, ErrorText } from "../../../components/ui";
11+import { SelectField } from "../../../components/ui/select";
12+import { canDo } from "../../../lib/docs";
13+import { page } from "../../../lib/meta";
14+import { docs } from "../../../lib/services.server";
15+import { requireUser, roleIn } from "../../../lib/session.server";
16+import { BUILTIN_TEMPLATE_IDS } from "../../../components/docs/templates";
17+
18+export function meta({ params, ...args }: Route.MetaArgs) {
19+ return page(args, { title: `Docs · ${params.owner} · g1t` });
20+}
21+
22+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ home: DocsHome | null; templates: DocTemplate[] }> {
23+ const viewer = requireUser(context, request);
24+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
25+ const slug = params.owner.toLowerCase();
26+ const project = new URL(request.url).searchParams.get("project");
27+ const [home, templates] = await Promise.all([
28+ docs
29+ .home(slug, viewer, { project })
30+ .then((r) => (r.ok ? r.value : null))
31+ .catch(() => null),
32+ docs
33+ .templates(slug, viewer)
34+ .then((r) => (r.ok ? r.value : []))
35+ .catch(() => []),
36+ ]);
37+ return { home, templates };
38+}
39+
40+/** Docs' home: what changed lately, your pages, the spaces, templates to start from; filtered by project. */
41+export default function DocsHomePage({ loaderData, params }: Route.ComponentProps) {
42+ const slug = params.owner.toLowerCase();
43+ const { home, templates } = loaderData;
44+ const layout = useDocsData();
45+ const navigate = useNavigate();
46+ const submit = useSubmit();
47+ const [error, setError] = useState<string | null>(null);
48+ const general = layout?.sidebar?.spaces.find((s) => s.is_default) ?? layout?.sidebar?.spaces.find((s) => canDo(s.viewer_role, "edit"));
49+ const spacesById = new Map((home?.spaces ?? []).map((s) => [s.id, s]));
50+ const create = async (template?: string) => {
51+ if (!general) return;
52+ const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: general.id, template_id: template ?? null } });
53+ if (made.ok) navigate(made.value.path);
54+ else setError(made.error.message);
55+ };
56+ if (!home) {
57+ return (
58+ <div className="mx-auto max-w-3xl">
59+ <EmptyState title="Docs didn't answer">Try again in a moment.</EmptyState>
60+ </div>
61+ );
62+ }
63+ const featured = templates.filter((t) => BUILTIN_TEMPLATE_IDS.includes(t.id)).slice(0, 4);
64+ return (
65+ <div className="mx-auto max-w-5xl">
66+ {/* On a phone, the spaces and pages first: the sidebar as the page. */}
67+ <div className="-mx-4 -mt-6 mb-6 border-b border-line md:hidden">
68+ <div className="h-[60dvh]">
69+ <DocsSidebar slug={slug} />
70+ </div>
71+ </div>
72+ <div className="flex flex-wrap items-end justify-between gap-4">
73+ <div>
74+ <div className="flex items-center gap-2 text-accent">
75+ <BookOpen size={18} />
76+ <span className="text-xs font-medium tracking-wide uppercase">Docs</span>
77+ </div>
78+ <h1 className="mt-2 text-2xl font-semibold tracking-tight">What your workspace knows</h1>
79+ <p className="mt-1 max-w-xl text-sm text-muted">Specs, runbooks, decisions and onboarding, written together with your agents. Everything here is searchable, and agents read it before they work.</p>
80+ </div>
81+ <div className="flex items-center gap-2">
82+ <ButtonLink to={`/${slug}/-/docs/new`} variant="quiet">
83+ <Plus size={15} /> New space
84+ </ButtonLink>
85+ {general && (
86+ <button type="button" onClick={() => create()} className="inline-flex h-9 items-center gap-1.5 rounded-md bg-accent px-3 text-sm font-medium text-bg transition-colors hover:bg-accent-hover">
87+ <FilePlus2 size={15} /> New page
88+ </button>
89+ )}
90+ </div>
91+ </div>
92+ {error && (
93+ <div className="mt-3">
94+ <ErrorText>{error}</ErrorText>
95+ </div>
96+ )}
97+
98+ <div className="mt-6 flex flex-wrap items-center gap-2">
99+ <Form method="get" action={`/${slug}/-/docs/search`} className="flex h-9 min-w-0 grow items-center gap-2 rounded-md border border-line bg-surface px-3 focus-within:border-accent/60 sm:max-w-md">
100+ <Search size={15} className="text-faint" aria-hidden="true" />
101+ <input name="q" placeholder="Search every page you can read" aria-label="Search docs" className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint" />
102+ {home.project && <input type="hidden" name="project" value={home.project} />}
103+ </Form>
104+ {home.projects.length > 0 && (
105+ <Form method="get" id="docs-project">
106+ <SelectField
107+ name="project"
108+ aria-label="Filter by project"
109+ value={home.project ?? ""}
110+ afterChange={() => submit(document.getElementById("docs-project") as HTMLFormElement)}
111+ options={[{ value: "", label: "All projects" }, ...home.projects.map((p) => ({ value: p, label: p }))]}
112+ className="h-9 min-w-48"
113+ />
114+ </Form>
115+ )}
116+ </div>
117+
118+ <section className="mt-8">
119+ <SectionTitle>{home.project ? `Recently edited · ${home.project}` : "Recently edited"}</SectionTitle>
120+ {home.recent.length ? (
121+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
122+ {home.recent.map((p) => (
123+ <PageCard key={p.id} page={p} space={spacesById.get(p.space_id)} />
124+ ))}
125+ </div>
126+ ) : (
127+ <EmptyState title={home.project ? "No pages about this project yet" : "No pages yet"}>
128+ {home.project ? "Link a space or a page to this project from its settings." : "Start one from a template below, or with New page."}
129+ </EmptyState>
130+ )}
131+ </section>
132+
133+ {home.mine.length > 0 && (
134+ <section className="mt-10">
135+ <SectionTitle>Your pages</SectionTitle>
136+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
137+ {home.mine.map((p) => (
138+ <li key={p.id}>
139+ <Link to={p.path} prefetch="intent" className="flex items-center gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-raised">
140+ <span className="w-5 text-center">{p.icon ?? "📄"}</span>
141+ <span className="min-w-0 grow truncate">{p.title || "Untitled"}</span>
142+ <span className="hidden text-xs text-faint sm:inline">{spacesById.get(p.space_id)?.name}</span>
143+ </Link>
144+ </li>
145+ ))}
146+ </ul>
147+ </section>
148+ )}
149+
150+ <section className="mt-10">
151+ <SectionTitle
152+ action={
153+ <Link to={`/${slug}/-/docs/new`} className="text-xs text-muted hover:text-fg">
154+ New space
155+ </Link>
156+ }
157+ >
158+ Spaces
159+ </SectionTitle>
160+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
161+ {home.spaces.map((s) => (
162+ <SpaceCard key={s.id} slug={slug} space={s} />
163+ ))}
164+ </div>
165+ </section>
166+
167+ {general && featured.length > 0 && (
168+ <section className="mt-10 mb-6">
169+ <SectionTitle
170+ action={
171+ <Link to={`/${slug}/-/docs/templates`} className="text-xs text-muted hover:text-fg">
172+ All templates
173+ </Link>
174+ }
175+ >
176+ Start from a template
177+ </SectionTitle>
178+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
179+ {featured.map((t) => (
180+ <TemplateCard
181+ key={t.id}
182+ template={t}
183+ action={
184+ <button type="button" onClick={() => create(t.id)} className="text-xs font-medium text-accent hover:underline">
185+ Use in {general.name}
186+ </button>
187+ }
188+ />
189+ ))}
190+ </div>
191+ </section>
192+ )}
193+ </div>
194+ );
195+}
+68−0
1+import { Outlet, data, type ShouldRevalidateFunctionArgs } from "react-router";
2+
3+import { type DocsSidebar as Sidebar, shownUsername } from "@g1t/contracts";
4+
5+import type { Route } from "./+types/layout";
6+import type { DocMentionable } from "../../../components/docs/editor";
7+import { workspacePeople } from "../../../lib/chat.server";
8+import { docs } from "../../../lib/services.server";
9+import { requireUser, roleIn } from "../../../lib/session.server";
10+
11+/**
12+ * Docs mode: what its sidebar shows (the shell draws it from this data:
13+ * spaces and their page trees, favorites, recent pages) and who can be
14+ * mentioned in a page. Each part is empty rather than an error when its
15+ * service does not answer.
16+ */
17+export type DocsLayoutData = {
18+ slug: string;
19+ role: string;
20+ me: { key: string; name: string; display_name: string; avatar: string | null };
21+ sidebar: Sidebar | null;
22+ mentionables: DocMentionable[];
23+};
24+
25+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<DocsLayoutData> {
26+ const viewer = requireUser(context, request);
27+ const role = roleIn(viewer, params.owner);
28+ if (!role) throw data(null, { status: 404 });
29+ const slug = params.owner.toLowerCase();
30+ const [sidebar, { people, agents }] = await Promise.all([
31+ docs
32+ .sidebar(slug, viewer)
33+ .then((r) => (r.ok ? r.value : null))
34+ .catch(() => null),
35+ workspacePeople(slug, viewer).catch(() => ({ people: [], agents: [] })),
36+ ]);
37+ const self = people.find((p) => p.name === viewer.username.toLowerCase());
38+ return {
39+ slug,
40+ role,
41+ me: {
42+ key: `user:${viewer.id}`,
43+ name: viewer.username.toLowerCase(),
44+ display_name: self?.display_name ?? shownUsername({ username: viewer.username, display_username: viewer.display_username ?? null }),
45+ avatar: viewer.avatar ?? null,
46+ },
47+ sidebar,
48+ mentionables: [
49+ ...people.map((p): DocMentionable => ({ kind: "user", id: p.name, name: p.name, display_name: p.display_name, avatar: p.avatar })),
50+ ...agents.map((a): DocMentionable => ({ kind: "agent", id: a.id, name: a.handle, display_name: a.display_name, avatar: a.avatar, avatar_seed: a.avatar_seed ?? null })),
51+ ],
52+ };
53+}
54+
55+/**
56+ * The tree changes when something is made, renamed, moved or trashed (a
57+ * submission, or the page asking again with `useRevalidator`); not when
58+ * moving between pages.
59+ */
60+export function shouldRevalidate({ currentParams, nextParams, currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
61+ if (formMethod && formMethod !== "GET") return defaultShouldRevalidate;
62+ if (currentUrl.href === nextUrl.href) return defaultShouldRevalidate;
63+ return currentParams.owner !== nextParams.owner;
64+}
65+
66+export default function DocsLayout() {
67+ return <Outlet />;
68+}
+36−0
1+import { env } from "cloudflare:workers";
2+
3+import { DOCS_VIEWER_HEADER } from "@g1t/contracts";
4+
5+import type { Route } from "./+types/live";
6+import { getViewer, roleIn } from "../../../lib/session.server";
7+
8+/**
9+ * A page's live socket: `wss://<site>/<workspace>/-/docs/live?page=<id>`.
10+ * The site checks the session and that the page asking is the site's own,
11+ * then hands the upgrade to the docs service with the viewer, which checks
12+ * their role in the page's space and gives the socket to the page's room
13+ * (one Durable Object per page), which enforces that role.
14+ */
15+export async function loader({ params, context, request }: Route.LoaderArgs) {
16+ const viewer = getViewer(context);
17+ if (!viewer) return new Response("Sign in to use Docs.", { status: 401 });
18+ if (!roleIn(viewer, params.owner)) return new Response("Not found", { status: 404 });
19+ if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
20+ return new Response("This address takes a WebSocket.", { status: 426, headers: { upgrade: "websocket" } });
21+ }
22+ const origin = request.headers.get("origin");
23+ if (origin && origin !== new URL(request.url).origin) return new Response("Cross-origin socket refused", { status: 403 });
24+ const page = new URL(request.url).searchParams.get("page");
25+ if (!page) return new Response("Which page?", { status: 400 });
26+ const headers = new Headers(request.headers);
27+ headers.delete("cookie");
28+ headers.set(DOCS_VIEWER_HEADER, JSON.stringify(viewer));
29+ const target = `https://docs/live?page=${encodeURIComponent(page)}&workspace=${encodeURIComponent(params.owner.toLowerCase())}`;
30+ try {
31+ return await env.DOCS.fetch(new Request(target, { method: "GET", headers }));
32+ } catch (error) {
33+ console.error("docs: the live socket could not be handed over", error);
34+ return new Response("Docs didn't answer.", { status: 503 });
35+ }
36+}
+43−0
1+import { data, useNavigate } from "react-router";
2+
3+import type { Route } from "./+types/new-space";
4+import { useDocsAction } from "../../../components/docs/actions";
5+import { SpaceForm, initialSpace, spaceInput } from "../../../components/docs/space-form";
6+import { page } from "../../../lib/meta";
7+import { identity } from "../../../lib/services.server";
8+import { requireUser, roleIn } from "../../../lib/session.server";
9+
10+export function meta({ params, ...args }: Route.MetaArgs) {
11+ return page(args, { title: `New space · Docs · ${params.owner} · g1t` });
12+}
13+
14+export async function loader({ params, context, request }: Route.LoaderArgs) {
15+ const viewer = requireUser(context, request);
16+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
17+ const teams = await identity.listTeams(viewer, params.owner.toLowerCase()).catch(() => null);
18+ return { teams: teams?.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] };
19+}
20+
21+/** A new space: for everyone, a team, or only its members. Whoever makes it has full access. */
22+export default function NewSpace({ loaderData, params }: Route.ComponentProps) {
23+ const slug = params.owner.toLowerCase();
24+ const navigate = useNavigate();
25+ const { send, busy, error } = useDocsAction(slug);
26+ return (
27+ <div className="mx-auto max-w-2xl">
28+ <h1 className="text-xl font-semibold tracking-tight">New space</h1>
29+ <p className="mt-1 mb-8 text-sm text-muted">A space holds a tree of pages: one per team, project or topic.</p>
30+ <SpaceForm
31+ initial={initialSpace()}
32+ teams={loaderData.teams}
33+ submitLabel="Create space"
34+ busy={busy}
35+ error={error}
36+ onSubmit={async (value) => {
37+ const made = await send<{ slug: string }>("create_space", { space: spaceInput(value) });
38+ if (made.ok) navigate(`/${slug}/-/docs/${made.value.slug}`);
39+ }}
40+ />
41+ </div>
42+ );
43+}
+567−0
1+import { DOC_AGENT_MODE_LABELS, DOC_ROLE_LABELS, type DocPage, type DocPageDetail, type DocSuggestion, type DocsLiveEvent, type MemberProfile } from "@g1t/contracts";
2+import {
3+ ArrowRightLeft,
4+ Check,
5+ ClipboardCopy,
6+ Copy,
7+ Download,
8+ Ellipsis,
9+ History,
10+ ImagePlus,
11+ LayoutTemplate,
12+ Link2,
13+ MessageSquare,
14+ Share2,
15+ SmilePlus,
16+ Sparkles,
17+ Star,
18+ Trash2,
19+ WifiOff,
20+} from "lucide-react";
21+import { type ComponentType, Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
22+import { Link, data, redirect, useNavigate, useRevalidator, useRouteLoaderData } from "react-router";
23+
24+import type { Route } from "./+types/page";
25+import { docsRequest, useDocsAction, useDocsData } from "../../../components/docs/actions";
26+import type { DocEditorProps, Presence } from "../../../components/docs/editor";
27+import { EditorSkeleton } from "../../../components/docs/editor-skeleton";
28+import { CoverPicker, Discussion, HistoryDialog, IconPicker, MoveDialog, SuggestionCard, TemplateDialog, type PageThread } from "../../../components/docs/page-parts";
29+import { Crumbs, Face, Faces } from "../../../components/docs/parts";
30+import type { LiveStatus } from "../../../components/docs/provider";
31+import { PageIcon } from "../../../components/docs/sidebar";
32+import { type Who, whoAre } from "../../../components/docs/who";
33+import { Markdown } from "../../../components/markdown";
34+import { ErrorText, TimeAgo } from "../../../components/ui";
35+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../../../components/ui/dropdown-menu";
36+import { Hint } from "../../../components/ui/hint";
37+import { Popover, PopoverContent, PopoverTrigger } from "../../../components/ui/popover";
38+import { usercontentFrom } from "../../../lib/addresses";
39+import { canDo, coverStyle, pageIdOf, pagePath, readingTime } from "../../../lib/docs";
40+import { page as pageMeta } from "../../../lib/meta";
41+import { docs } from "../../../lib/services.server";
42+import { requireUser, roleIn } from "../../../lib/session.server";
43+
44+/**
45+ * The editor and everything it brings, loaded only in the browser, after
46+ * the page shows. The server's bundle never includes it: the server
47+ * renders the page's Markdown instead.
48+ */
49+const DocEditor = lazy(
50+ (): Promise<{ default: ComponentType<DocEditorProps> }> => (import.meta.env.SSR ? Promise.resolve({ default: () => null }) : import("../../../components/docs/editor")),
51+);
52+
53+export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
54+ const p = loaded?.detail.page;
55+ return pageMeta(args, { title: `${p ? `${p.icon ? `${p.icon} ` : ""}${p.title || "Untitled"}` : "Page"} · ${params.owner} · g1t`, description: p?.excerpt, type: "article" });
56+}
57+
58+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ detail: DocPageDetail }> {
59+ const viewer = requireUser(context, request);
60+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
61+ const id = pageIdOf(params.page);
62+ if (!id) throw data(null, { status: 404 });
63+ const found = await docs.page(params.owner.toLowerCase(), id, viewer);
64+ if (!found.ok) throw data(null, { status: found.error.code === "not_found" ? 404 : 403 });
65+ const detail = found.value;
66+ // An old address (renamed page, moved space) goes to the current one.
67+ const url = new URL(request.url);
68+ if (url.pathname !== detail.page.path) throw redirect(`${detail.page.path}${url.search}`);
69+ return { detail };
70+}
71+
72+function useHydrated() {
73+ const [hydrated, setHydrated] = useState(false);
74+ useEffect(() => setHydrated(true), []);
75+ return hydrated;
76+}
77+
78+/** One page: its header, the live editor (or its Markdown until the editor loads), suggestions, comments, links. */
79+export default function DocPageView({ loaderData, params }: Route.ComponentProps) {
80+ const slug = params.owner.toLowerCase();
81+ const { detail } = loaderData;
82+ const { page, space } = detail;
83+ const layout = useDocsData();
84+ const root = useRouteLoaderData("root");
85+ const usercontent = usercontentFrom(root);
86+ const navigate = useNavigate();
87+ const revalidator = useRevalidator();
88+ const hydrated = useHydrated();
89+ const { send, error, setError } = useDocsAction(slug);
90+
91+ const [role, setRole] = useState(detail.role);
92+ useEffect(() => setRole(detail.role), [detail.role, page.id]);
93+ const editable = canDo(role, "edit");
94+
95+ // The header follows the page, and anyone renaming it live.
96+ const [live, setLive] = useState<DocPage>(page);
97+ useEffect(() => setLive(page), [page]);
98+ const [title, setTitle] = useState(page.title);
99+ const titleFocused = useRef(false);
100+ useEffect(() => {
101+ if (!titleFocused.current) setTitle(live.title);
102+ }, [live.title]);
103+ const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
104+ const saveTitle = (next: string) => {
105+ setTitle(next);
106+ if (saveTimer.current) clearTimeout(saveTimer.current);
107+ saveTimer.current = setTimeout(() => void send("update_page", { page_id: page.id, change: { title: next } }), 700);
108+ };
109+
110+ const [suggestions, setSuggestions] = useState<DocSuggestion[]>(detail.suggestions);
111+ useEffect(() => setSuggestions(detail.suggestions), [detail.suggestions]);
112+ const [presence, setPresence] = useState<Presence[]>([]);
113+ const [status, setStatus] = useState<LiveStatus>("connecting");
114+ const [archived, setArchived] = useState(!!page.archived_at);
115+ useEffect(() => setArchived(!!page.archived_at), [page.archived_at]);
116+ const [showComments, setShowComments] = useState(false);
117+ const [history, setHistory] = useState(false);
118+ const [moving, setMoving] = useState(false);
119+ const [templating, setTemplating] = useState(false);
120+ const [favorite, setFavorite] = useState(detail.favorite);
121+ useEffect(() => setFavorite(detail.favorite), [detail.favorite]);
122+ const [copied, setCopied] = useState<string | null>(null);
123+ const [threads, setThreads] = useState<PageThread[]>([]);
124+ const [names, setNames] = useState<Map<string, Who>>(new Map());
125+ useEffect(() => {
126+ const keys = threads.flatMap((t) => t.comments.map((c) => c.author));
127+ if (keys.some((k) => !names.has(k))) void whoAre(slug, keys).then((found) => setNames((was) => new Map([...was, ...found])));
128+ }, [threads, slug, names]);
129+
130+ const onEvent = useCallback(
131+ (event: DocsLiveEvent) => {
132+ if (event.type === "page.updated") setLive(event.page);
133+ else if (event.type === "page.archived") setArchived(true);
134+ else if (event.type === "access") {
135+ if (event.role) setRole(event.role);
136+ else navigate(`/${slug}/-/docs`);
137+ } else if (event.type === "suggestion.created") setSuggestions((was) => [...was.filter((s) => s.id !== event.suggestion.id), event.suggestion]);
138+ else if (event.type === "suggestion.updated") setSuggestions((was) => (event.suggestion.status === "open" ? was.map((s) => (s.id === event.suggestion.id ? event.suggestion : s)) : was.filter((s) => s.id !== event.suggestion.id)));
139+ else if (event.type === "version.created") void revalidator.revalidate();
140+ },
141+ [navigate, slug, revalidator],
142+ );
143+
144+ const decide = async (id: string, decision: "accept" | "reject") => {
145+ setSuggestions((was) => was.filter((s) => s.id !== id));
146+ const done = await send("decide", { suggestion_id: id, decision }, { reload: false });
147+ if (!done.ok) void revalidator.revalidate();
148+ };
149+
150+ const people = useCallback(
151+ (key: string): Pick<MemberProfile, "kind" | "id" | "name" | "display_name" | "avatar" | "avatar_seed"> => {
152+ const at = key.indexOf(":");
153+ const kind = key.slice(0, at) === "agent" ? "agent" : "user";
154+ const id = key.slice(at + 1);
155+ if (layout?.me.key === key) return { kind: "user", id, name: layout.me.name, display_name: layout.me.display_name, avatar: layout.me.avatar, avatar_seed: null };
156+ const found = names.get(key);
157+ return { kind, id, name: found?.name ?? "someone", display_name: found?.display_name ?? (kind === "agent" ? "An agent" : "Someone"), avatar: found?.avatar ?? null, avatar_seed: found?.avatar_seed ?? null };
158+ },
159+ [layout, names],
160+ );
161+
162+ const threadRequest = async (path: string, method: string, body: unknown) => {
163+ try {
164+ const r = await fetch(`/${slug}/-/docs/threads/${page.id}${path}`, { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
165+ if (!r.ok) {
166+ const result = (await r.json().catch(() => null)) as { error?: { message?: string } } | null;
167+ setError(result?.error?.message ?? "That comment didn't go through.");
168+ return false;
169+ }
170+ return true;
171+ } catch {
172+ setError("That comment didn't go through.");
173+ return false;
174+ }
175+ };
176+ /** A comment's text as the editor's comments hold it: blocks, with @mentions as mentions. */
177+ const commentBody = (text: string) => {
178+ const parts: unknown[] = [];
179+ for (const piece of text.split(/(@[a-z0-9][a-z0-9-]*)/i)) {
180+ const who = piece.startsWith("@") ? layout?.mentionables.find((m) => m.name.toLowerCase() === piece.slice(1).toLowerCase()) : null;
181+ if (who) parts.push({ type: "mention", props: { kind: who.kind, id: who.id, name: who.name, href: "" } });
182+ else if (piece) parts.push({ type: "text", text: piece, styles: {} });
183+ }
184+ return [{ type: "paragraph", content: parts, children: [] }];
185+ };
186+
187+ const copy = async (what: "link" | "markdown") => {
188+ try {
189+ if (what === "link") await navigator.clipboard.writeText(`${window.location.origin}${pagePath(slug, space.slug, live.title, page.id)}`);
190+ else {
191+ const r = await fetch(`/${slug}/-/docs/export?page=${encodeURIComponent(page.id)}`);
192+ await navigator.clipboard.writeText(await r.text());
193+ }
194+ setCopied(what);
195+ setTimeout(() => setCopied(null), 1600);
196+ } catch {
197+ setError("Your browser didn't allow copying.");
198+ }
199+ };
200+
201+ const cover = coverStyle(live.cover);
202+ const reading = useMemo(() => readingTime(detail.markdown), [detail.markdown]);
203+ const others = presence.filter((p) => !p.me);
204+ const crumbs = [
205+ { label: "Docs", to: `/${slug}/-/docs` },
206+ { label: space.name, to: `/${slug}/-/docs/${space.slug}` },
207+ ...detail.breadcrumbs.map((b) => ({ label: `${b.icon ? `${b.icon} ` : ""}${b.title || "Untitled"}`, to: b.path })),
208+ ];
209+
210+ return (
211+ <div className="-mt-6">
212+ {cover && (
213+ <div className="group relative -mx-4 h-44 sm:-mx-6 lg:-mx-8" style={{ background: cover }}>
214+ {editable && (
215+ <CoverPicker onChange={(c) => send("update_page", { page_id: page.id, change: { cover: c } })}>
216+ <button type="button" className="absolute right-4 bottom-3 rounded-md bg-bg/70 px-2.5 py-1 text-xs text-fg opacity-0 backdrop-blur group-hover:opacity-100 focus-visible:opacity-100">
217+ Change cover
218+ </button>
219+ </CoverPicker>
220+ )}
221+ </div>
222+ )}
223+ {/* The bar above the page: where it is, who is here, and what can be done. */}
224+ <div className="sticky top-14 z-20 -mx-4 flex h-12 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
225+ <div className="min-w-0 grow">
226+ <Crumbs items={crumbs} />
227+ </div>
228+ {status === "offline" && (
229+ <Hint label="Your changes are kept and sync when the connection is back.">
230+ <span className="flex items-center gap-1 text-xs text-warn">
231+ <WifiOff size={13} /> Offline
232+ </span>
233+ </Hint>
234+ )}
235+ {others.length > 0 && (
236+ <span className="flex items-center -space-x-1.5" aria-label={`${others.length} others here`}>
237+ {others.slice(0, 5).map((p) => (
238+ <Hint key={p.key} label={`${p.name}${p.kind === "agent" ? " (agent)" : ""}`}>
239+ <span className="rounded-full ring-2" style={{ ["--tw-ring-color" as string]: p.color }}>
240+ <Face who={{ kind: p.kind, id: p.key.slice(p.key.indexOf(":") + 1), name: p.name, avatar: p.avatar, avatar_seed: null }} size={24} />
241+ </span>
242+ </Hint>
243+ ))}
244+ {others.length > 5 && <span className="pl-3 text-xs text-faint">+{others.length - 5}</span>}
245+ </span>
246+ )}
247+ <Hint label={showComments ? "Hide comments" : "Comments"}>
248+ <button type="button" onClick={() => setShowComments(!showComments)} aria-pressed={showComments} aria-label="Comments" className={`hidden size-8 items-center justify-center rounded-md xl:flex ${showComments ? "bg-raised text-fg" : "text-faint hover:bg-raised hover:text-fg"}`}>
249+ <MessageSquare size={16} />
250+ </button>
251+ </Hint>
252+ <Hint label={favorite ? "Remove from favorites" : "Add to favorites"}>
253+ <button
254+ type="button"
255+ aria-label={favorite ? "Remove from favorites" : "Add to favorites"}
256+ aria-pressed={favorite}
257+ onClick={async () => {
258+ setFavorite(!favorite);
259+ await send("favorite", { page_id: page.id, on: !favorite });
260+ }}
261+ className="flex size-8 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg"
262+ >
263+ <Star size={16} className={favorite ? "fill-current text-warn" : ""} />
264+ </button>
265+ </Hint>
266+ <Popover>
267+ <PopoverTrigger asChild>
268+ <button type="button" className="hidden h-8 items-center gap-1.5 rounded-md border border-line px-2.5 text-xs text-fg/85 hover:bg-raised sm:inline-flex">
269+ <Share2 size={13} /> Share
270+ </button>
271+ </PopoverTrigger>
272+ <PopoverContent align="end" className="w-80 p-4 text-sm">
273+ <p className="font-medium">Who can see this page</p>
274+ <p className="mt-1 text-xs leading-relaxed text-muted">
275+ Everyone with access to <span className="text-fg">{space.name}</span>:{" "}
276+ {space.kind === "workspace" ? `the whole workspace (${space.default_role ? DOC_ROLE_LABELS[space.default_role].toLowerCase() : "listed members only"})` : space.kind === "team" ? `the ${space.team} team, and members added to the space` : "only the space's members"}. Agents here: {DOC_AGENT_MODE_LABELS[space.agent_mode].toLowerCase()}.
277+ </p>
278+ <p className="mt-2 text-xs text-muted">Your access: {DOC_ROLE_LABELS[role].toLowerCase()}.</p>
279+ <div className="mt-3 flex gap-2">
280+ <button type="button" onClick={() => copy("link")} className="inline-flex h-8 items-center gap-1.5 rounded-md bg-raised px-2.5 text-xs hover:bg-line">
281+ {copied === "link" ? <Check size={13} /> : <Link2 size={13} />} {copied === "link" ? "Copied" : "Copy link"}
282+ </button>
283+ {canDo(space.viewer_role, "manage") && (
284+ <Link to={`/${slug}/-/docs/${space.slug}/settings`} className="inline-flex h-8 items-center rounded-md px-2.5 text-xs text-muted hover:bg-raised hover:text-fg">
285+ Space access
286+ </Link>
287+ )}
288+ </div>
289+ </PopoverContent>
290+ </Popover>
291+ <DropdownMenu>
292+ <DropdownMenuTrigger asChild>
293+ <button type="button" aria-label="More" className="flex size-8 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg">
294+ <Ellipsis size={16} />
295+ </button>
296+ </DropdownMenuTrigger>
297+ <DropdownMenuContent align="end" className="w-56">
298+ <DropdownMenuItem onSelect={() => setHistory(true)}>
299+ <History size={14} /> History
300+ </DropdownMenuItem>
301+ <DropdownMenuItem onSelect={() => copy("markdown")}>
302+ <ClipboardCopy size={14} /> Copy as Markdown
303+ </DropdownMenuItem>
304+ <DropdownMenuItem asChild>
305+ <a href={`/${slug}/-/docs/export?page=${encodeURIComponent(page.id)}`}>
306+ <Download size={14} /> Export Markdown
307+ </a>
308+ </DropdownMenuItem>
309+ <DropdownMenuItem onSelect={() => copy("link")}>
310+ <Link2 size={14} /> Copy link
311+ </DropdownMenuItem>
312+ {editable && (
313+ <>
314+ <DropdownMenuSeparator />
315+ <DropdownMenuItem onSelect={() => setMoving(true)}>
316+ <ArrowRightLeft size={14} /> Move
317+ </DropdownMenuItem>
318+ <DropdownMenuItem
319+ onSelect={async () => {
320+ const made = await docsRequest<DocPage>(slug, "duplicate_page", { page_id: page.id });
321+ if (made.ok) navigate(made.value.path);
322+ else setError(made.error.message);
323+ }}
324+ >
325+ <Copy size={14} /> Duplicate
326+ </DropdownMenuItem>
327+ </>
328+ )}
329+ <DropdownMenuItem onSelect={() => setTemplating(true)}>
330+ <LayoutTemplate size={14} /> Save as template
331+ </DropdownMenuItem>
332+ {editable && (
333+ <>
334+ <DropdownMenuSeparator />
335+ <DropdownMenuItem
336+ onSelect={async () => {
337+ const done = await send("archive_page", { page_id: page.id });
338+ if (done.ok) navigate(`/${slug}/-/docs/${space.slug}`);
339+ }}
340+ className="text-danger focus:text-danger"
341+ >
342+ <Trash2 size={14} /> Move to trash
343+ </DropdownMenuItem>
344+ </>
345+ )}
346+ </DropdownMenuContent>
347+ </DropdownMenu>
348+ </div>
349+
350+ <article className={`mx-auto pt-10 ${showComments ? "max-w-6xl" : "max-w-3xl"}`}>
351+ <div className={showComments ? "xl:max-w-3xl" : ""}>
352+ {archived && (
353+ <p className="mb-6 rounded-lg border border-warn/30 bg-warn/10 px-3 py-2 text-sm text-warn">
354+ This page was moved to the trash.{" "}
355+ <Link to={`/${slug}/-/docs/trash`} className="underline">
356+ Open the trash
357+ </Link>
358+ </p>
359+ )}
360+ {/* Icon, cover, title. */}
361+ <div className="group">
362+ {live.icon && (
363+ <IconPicker value={live.icon} onChange={(icon) => editable && send("update_page", { page_id: page.id, change: { icon } })}>
364+ <button type="button" disabled={!editable} aria-label="Change the icon" className="-ml-1 mb-2 rounded-lg p-1 text-5xl leading-none hover:bg-raised disabled:hover:bg-transparent">
365+ {live.icon}
366+ </button>
367+ </IconPicker>
368+ )}
369+ {editable && (
370+ <div className="mb-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
371+ {!live.icon && (
372+ <IconPicker value={null} onChange={(icon) => send("update_page", { page_id: page.id, change: { icon } })}>
373+ <button type="button" className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-faint hover:bg-raised hover:text-fg">
374+ <SmilePlus size={13} /> Add icon
375+ </button>
376+ </IconPicker>
377+ )}
378+ {!cover && (
379+ <CoverPicker onChange={(c) => send("update_page", { page_id: page.id, change: { cover: c } })}>
380+ <button type="button" className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-faint hover:bg-raised hover:text-fg">
381+ <ImagePlus size={13} /> Add cover
382+ </button>
383+ </CoverPicker>
384+ )}
385+ </div>
386+ )}
387+ {editable ? (
388+ <textarea
389+ value={title}
390+ rows={1}
391+ placeholder="Untitled"
392+ aria-label="Title"
393+ onFocus={() => (titleFocused.current = true)}
394+ onBlur={() => (titleFocused.current = false)}
395+ onChange={(e) => saveTitle(e.target.value.replace(/\n/g, " "))}
396+ onKeyDown={(e) => {
397+ if (e.key === "Enter") {
398+ e.preventDefault();
399+ (document.querySelector(".g1t-editor .bn-editor") as HTMLElement | null)?.focus();
400+ }
401+ }}
402+ className="field-sizing-content w-full resize-none bg-transparent font-display text-4xl leading-tight font-semibold tracking-tight text-fg outline-none placeholder:text-faint/60"
403+ />
404+ ) : (
405+ <h1 className="font-display text-4xl leading-tight font-semibold tracking-tight">{live.title || "Untitled"}</h1>
406+ )}
407+ </div>
408+ {/* Who wrote it, who owns it, what it's about. */}
409+ <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-faint">
410+ {live.updated_by && (
411+ <span className="flex items-center gap-1.5">
412+ <Face who={live.updated_by} size={16} />
413+ Last edited by <span className="text-muted">{live.updated_by.display_name}</span> · <TimeAgo at={live.updated_at} />
414+ </span>
415+ )}
416+ {live.owners.length > 0 && (
417+ <span className="flex items-center gap-1.5">
418+ Owners <Faces people={live.owners} size={18} />
419+ </span>
420+ )}
421+ {[...new Set([...live.projects, ...space.projects])].map((p) => (
422+ <Link key={p} to={`/${p}`} className="rounded-full border border-line px-2 py-0.5 font-mono text-[0.6875rem] text-muted hover:border-line-strong hover:text-fg">
423+ {p}
424+ </Link>
425+ ))}
426+ <span>
427+ {reading.words.toLocaleString()} words · {reading.minutes} min read
428+ </span>
429+ {!editable && <span className="rounded-full bg-raised px-2 py-0.5">{DOC_ROLE_LABELS[role]}</span>}
430+ </div>
431+ {error && (
432+ <div className="mt-3">
433+ <ErrorText>{error}</ErrorText>
434+ </div>
435+ )}
436+
437+ {/* Agents' suggestions, all together on a narrower screen (beside their blocks on a wide one). */}
438+ {suggestions.length > 0 && (
439+ <div className="mt-6 rounded-xl border border-accent/30 bg-accent/5 p-3 2xl:hidden">
440+ <div className="mb-2 flex items-center gap-2">
441+ <Sparkles size={14} className="text-accent" />
442+ <span className="grow text-sm font-medium">
443+ {suggestions.length} {suggestions.length === 1 ? "suggestion" : "suggestions"} waiting
444+ </span>
445+ {editable && suggestions.length > 1 && (
446+ <button type="button" onClick={async () => { setSuggestions([]); await send("accept_all", { page_id: page.id }); }} className="text-xs font-medium text-accent hover:underline">
447+ Accept all
448+ </button>
449+ )}
450+ </div>
451+ <div className="space-y-2">
452+ {suggestions.map((s) => (
453+ <SuggestionCard key={s.id} suggestion={s} canDecide={editable} onDecide={decide} compact />
454+ ))}
455+ </div>
456+ </div>
457+ )}
458+ {suggestions.length > 1 && editable && (
459+ <div className="mt-4 hidden justify-end 2xl:flex">
460+ <button type="button" onClick={async () => { setSuggestions([]); await send("accept_all", { page_id: page.id }); }} className="inline-flex h-8 items-center gap-1.5 rounded-md bg-accent/15 px-3 text-xs font-medium text-accent hover:bg-accent/25">
461+ <Check size={13} /> Accept all {suggestions.length} suggestions
462+ </button>
463+ </div>
464+ )}
465+ </div>
466+
467+ {/* The page itself: live once the editor loads; its saved Markdown until then. */}
468+ <div className="mt-6">
469+ {hydrated && !archived ? (
470+ <Suspense fallback={<ReadView markdown={detail.markdown} />}>
471+ <DocEditor
472+ key={page.id}
473+ slug={slug}
474+ pageId={page.id}
475+ role={role}
476+ me={layout?.me ?? { key: "", name: "", display_name: "", avatar: null }}
477+ mentionables={layout?.mentionables ?? []}
478+ usercontent={usercontent}
479+ suggestions={suggestions}
480+ showComments={showComments}
481+ onPresence={setPresence}
482+ onStatus={setStatus}
483+ onEvent={onEvent}
484+ onPageThreads={setThreads}
485+ renderSuggestion={(s) => <SuggestionCard suggestion={s} canDecide={editable} onDecide={decide} />}
486+ />
487+ </Suspense>
488+ ) : (
489+ <ReadView markdown={detail.markdown} />
490+ )}
491+ </div>
492+
493+ <div className={showComments ? "xl:max-w-3xl" : ""}>
494+ {(detail.children.length > 0 || detail.backlinks.length > 0) && (
495+ <div className="mt-14 grid gap-6 border-t border-line pt-8 sm:grid-cols-2">
496+ {detail.children.length > 0 && (
497+ <section>
498+ <h2 className="mb-2 text-xs font-medium tracking-wide text-faint uppercase">Pages inside</h2>
499+ <ul className="space-y-1">
500+ {detail.children.map((c) => (
501+ <li key={c.id}>
502+ <Link to={c.path} className="flex items-center gap-2 rounded-md px-2 py-1 text-sm hover:bg-raised">
503+ <PageIcon icon={c.icon} /> {c.title || "Untitled"}
504+ </Link>
505+ </li>
506+ ))}
507+ </ul>
508+ </section>
509+ )}
510+ {detail.backlinks.length > 0 && (
511+ <section>
512+ <h2 className="mb-2 text-xs font-medium tracking-wide text-faint uppercase">Linked from</h2>
513+ <ul className="space-y-1">
514+ {detail.backlinks.map((c) => (
515+ <li key={c.id}>
516+ <Link to={c.path} className="flex items-center gap-2 rounded-md px-2 py-1 text-sm hover:bg-raised">
517+ <PageIcon icon={c.icon} /> {c.title || "Untitled"}
518+ </Link>
519+ </li>
520+ ))}
521+ </ul>
522+ </section>
523+ )}
524+ </div>
525+ )}
526+ {hydrated && (
527+ <Discussion
528+ threads={threads}
529+ people={people}
530+ canComment={canDo(role, "comment")}
531+ onPost={(text) => threadRequest("", "POST", { initialComment: { body: commentBody(text) }, page_level: true })}
532+ onReply={(thread, text) => threadRequest(`/${encodeURIComponent(thread)}/comments`, "POST", { comment: { body: commentBody(text) } })}
533+ onResolve={(thread, resolved) => void threadRequest(`/${encodeURIComponent(thread)}/${resolved ? "resolve" : "unresolve"}`, "POST", {})}
534+ />
535+ )}
536+ </div>
537+ </article>
538+
539+ <HistoryDialog slug={slug} pageId={page.id} open={history} onOpenChange={setHistory} canRestore={editable} onRestore={(versionId) => send("restore_version", { page_id: page.id, version_id: versionId })} />
540+ {layout?.sidebar && (
541+ <MoveDialog
542+ spaces={layout.sidebar.spaces}
543+ pageId={page.id}
544+ current={{ space_id: page.space_id, parent_id: page.parent_id }}
545+ open={moving}
546+ onOpenChange={setMoving}
547+ onMove={async (spaceId, parentId) => {
548+ const moved = await send<DocPage>("move_page", { page_id: page.id, move: { space_id: spaceId, parent_id: parentId, before_id: null } });
549+ setMoving(false);
550+ if (moved.ok) navigate(moved.value.path);
551+ }}
552+ />
553+ )}
554+ <TemplateDialog open={templating} onOpenChange={setTemplating} title={live.title || "Untitled"} onSave={(name, description) => send("save_template", { page_id: page.id, name, description })} />
555+ </div>
556+ );
557+}
558+
559+/** The page as Markdown: what shows before the editor loads, and what a reader without script sees. */
560+function ReadView({ markdown }: { markdown: string }) {
561+ if (!markdown.trim()) return <EditorSkeleton />;
562+ return (
563+ <div className="docs-read">
564+ <Markdown source={markdown} />
565+ </div>
566+ );
567+}
+105−0
1+import type { DocSearchHit } from "@g1t/contracts";
2+import { Search } from "lucide-react";
3+import { Form, Link, data, useSubmit } from "react-router";
4+
5+import type { Route } from "./+types/search";
6+import { useDocsData } from "../../../components/docs/actions";
7+import { PageIcon } from "../../../components/docs/sidebar";
8+import { EmptyState, TimeAgo } from "../../../components/ui";
9+import { SelectField } from "../../../components/ui/select";
10+import { snippetParts } from "../../../lib/docs";
11+import { page } from "../../../lib/meta";
12+import { docs } from "../../../lib/services.server";
13+import { requireUser, roleIn } from "../../../lib/session.server";
14+
15+export function meta({ params, ...args }: Route.MetaArgs) {
16+ return page(args, { title: `Search docs · ${params.owner} · g1t` });
17+}
18+
19+export async function loader({ params, context, request }: Route.LoaderArgs) {
20+ const viewer = requireUser(context, request);
21+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
22+ const url = new URL(request.url);
23+ const q = url.searchParams.get("q") ?? "";
24+ const space = url.searchParams.get("space") || null;
25+ const project = url.searchParams.get("project") || null;
26+ const found = q.trim()
27+ ? await docs.search(params.owner.toLowerCase(), viewer, { query: q, space_id: space, project, limit: 50 }).catch(() => null)
28+ : null;
29+ return { q, space, project, hits: found?.ok ? found.value : q.trim() ? null : ([] as DocSearchHit[]) };
30+}
31+
32+/** Full text over every page the viewer can read; by space and by project. */
33+export default function DocsSearch({ loaderData, params }: Route.ComponentProps) {
34+ const slug = params.owner.toLowerCase();
35+ const { q, space, project, hits } = loaderData;
36+ const layout = useDocsData();
37+ const submit = useSubmit();
38+ const spaces = layout?.sidebar?.spaces ?? [];
39+ const projects = [...new Set(spaces.flatMap((s) => s.projects))].sort();
40+ const refresh = () => submit(document.getElementById("docs-search") as HTMLFormElement);
41+ return (
42+ <div className="mx-auto max-w-3xl">
43+ <h1 className="text-xl font-semibold tracking-tight">Search docs</h1>
44+ <Form method="get" id="docs-search" className="mt-4 flex flex-wrap gap-2">
45+ <label className="flex h-10 min-w-0 grow items-center gap-2 rounded-md border border-line bg-surface px-3 focus-within:border-accent/60">
46+ <Search size={16} className="text-faint" aria-hidden="true" />
47+ <input name="q" defaultValue={q} autoFocus placeholder="Words in a title or a page" aria-label="Search" className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint" />
48+ </label>
49+ <SelectField
50+ name="space"
51+ aria-label="Space"
52+ value={space ?? ""}
53+ afterChange={refresh}
54+ options={[{ value: "", label: "Every space" }, ...spaces.map((s) => ({ value: s.id, label: s.name }))]}
55+ className="h-10 min-w-40"
56+ />
57+ {projects.length > 0 && (
58+ <SelectField name="project" aria-label="Project" value={project ?? ""} afterChange={refresh} options={[{ value: "", label: "Any project" }, ...projects.map((p) => ({ value: p, label: p }))]} className="h-10 min-w-40" />
59+ )}
60+ </Form>
61+ <div className="mt-6">
62+ {hits === null ? (
63+ <EmptyState title="Search didn't answer">Try again in a moment.</EmptyState>
64+ ) : !q.trim() ? (
65+ <p className="text-sm text-muted">Search titles and text across every space you can read. Agents search the same way, and only see what the people they answer can see.</p>
66+ ) : hits.length === 0 ? (
67+ <EmptyState title="Nothing found">No page you can read matches &ldquo;{q}&rdquo;.</EmptyState>
68+ ) : (
69+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
70+ {hits.map((hit) => (
71+ <li key={hit.id}>
72+ <Link to={hit.path} className="block px-4 py-3 transition-colors hover:bg-raised">
73+ <span className="flex items-center gap-2">
74+ <PageIcon icon={hit.icon} />
75+ <span className="truncate text-sm font-medium">{hit.title || "Untitled"}</span>
76+ <span className="ml-auto shrink-0 text-xs text-faint">
77+ {hit.space_name} · <TimeAgo at={hit.updated_at} />
78+ </span>
79+ </span>
80+ <span className="mt-1 line-clamp-2 block text-xs leading-relaxed text-muted">
81+ {snippetParts(hit.snippet).map((part, i) =>
82+ part.match ? (
83+ <mark key={i} className="rounded bg-accent/20 px-0.5 text-fg">
84+ {part.text}
85+ </mark>
86+ ) : (
87+ <span key={i}>{part.text}</span>
88+ ),
89+ )}
90+ </span>
91+ {hit.projects.length > 0 && <span className="mt-1 block text-[0.6875rem] text-faint">{hit.projects.join(" · ")}</span>}
92+ </Link>
93+ </li>
94+ ))}
95+ </ul>
96+ )}
97+ </div>
98+ <p className="mt-4 text-xs text-faint">
99+ <Link to={`/${slug}/-/docs`} className="hover:text-fg">
100+ Back to Docs
101+ </Link>
102+ </p>
103+ </div>
104+ );
105+}
+167−0
1+import { DOC_ROLES, DOC_ROLE_LABELS, DOC_ROLE_SUMMARIES, type DocRole, type DocSpace, type DocSpaceMember } from "@g1t/contracts";
2+import { Archive } from "lucide-react";
3+import { useState } from "react";
4+import { Link, data, useNavigate } from "react-router";
5+
6+import type { Route } from "./+types/space-settings";
7+import { useDocsAction, useDocsData } from "../../../components/docs/actions";
8+import { Face, SectionTitle } from "../../../components/docs/parts";
9+import { SpaceForm, initialSpace, spaceInput } from "../../../components/docs/space-form";
10+import { Button, ErrorText } from "../../../components/ui";
11+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../../components/ui/alert-dialog";
12+import { SelectField } from "../../../components/ui/select";
13+import { page } from "../../../lib/meta";
14+import { docs, identity } from "../../../lib/services.server";
15+import { requireUser, roleIn } from "../../../lib/session.server";
16+
17+export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
18+ return page(args, { title: `Settings · ${loaded?.space.name ?? "Space"} · Docs · ${params.owner} · g1t` });
19+}
20+
21+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ space: DocSpace; members: DocSpaceMember[]; teams: { slug: string; name: string }[] }> {
22+ const viewer = requireUser(context, request);
23+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
24+ const slug = params.owner.toLowerCase();
25+ const [found, teams] = await Promise.all([docs.space(slug, params.space, viewer), identity.listTeams(viewer, slug).catch(() => null)]);
26+ if (!found.ok) throw data(null, { status: 404 });
27+ if (found.value.space.viewer_role !== "manage") throw data(null, { status: 403 });
28+ return { space: found.value.space, members: found.value.members, teams: teams?.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] };
29+}
30+
31+const ROLE_OPTIONS = DOC_ROLES.map((r) => ({ value: r, label: DOC_ROLE_LABELS[r], description: DOC_ROLE_SUMMARIES[r] }));
32+
33+/** A space's settings: what it is, who it's for, its members and their roles, and how agents work in it. */
34+export default function SpaceSettings({ loaderData, params }: Route.ComponentProps) {
35+ const slug = params.owner.toLowerCase();
36+ const { space, members, teams } = loaderData;
37+ const layout = useDocsData();
38+ const navigate = useNavigate();
39+ const settings = useDocsAction(slug);
40+ const people = useDocsAction(slug);
41+ const [adding, setAdding] = useState("");
42+ const [addRole, setAddRole] = useState<DocRole>("edit");
43+ const [archiving, setArchiving] = useState(false);
44+ const listed = new Set(members.map((m) => m.key));
45+ const candidates = [
46+ ...(layout?.mentionables ?? []).map((m) => ({ value: m.kind === "agent" ? `agent:${m.id}` : `username:${m.name}`, label: m.kind === "agent" ? `${m.display_name} (agent)` : `${m.display_name} (@${m.name})` })),
47+ ...teams.map((t) => ({ value: `team:${t.slug}`, label: `${t.name} (team)` })),
48+ ].filter((c) => !listed.has(c.value));
49+ return (
50+ <div className="mx-auto max-w-3xl">
51+ <p className="text-xs text-faint">
52+ <Link to={`/${slug}/-/docs/${space.slug}`} className="hover:text-fg">
53+ {space.name}
54+ </Link>{" "}
55+ / Settings
56+ </p>
57+ <h1 className="mt-1 text-xl font-semibold tracking-tight">Space settings</h1>
58+ <div className="mt-8">
59+ <SpaceForm
60+ initial={initialSpace(space)}
61+ teams={teams}
62+ isDefault={space.is_default}
63+ submitLabel="Save"
64+ busy={settings.busy}
65+ error={settings.error}
66+ onSubmit={async (value) => {
67+ const saved = await settings.send<DocSpace>("update_space", { space_id: space.id, space_change: spaceInput(value) });
68+ if (saved.ok && saved.value.slug !== space.slug) navigate(`/${slug}/-/docs/${saved.value.slug}/settings`, { replace: true });
69+ }}
70+ />
71+ </div>
72+
73+ <section className="mt-12">
74+ <SectionTitle>Members</SectionTitle>
75+ <p className="mb-4 text-xs leading-relaxed text-faint">
76+ {space.kind === "private"
77+ ? "Only the people, agents and teams here can open this space."
78+ : "Everyone the space is for already has its base role; add people here to give them more. Workspace owners always have full access."}{" "}
79+ An agent added here still never does more than the person it works for.
80+ </p>
81+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
82+ {members.map((m) => (
83+ <li key={m.key} className="flex items-center gap-3 px-4 py-2.5">
84+ {m.kind === "team" ? (
85+ <span className="flex size-6 items-center justify-center rounded-full bg-raised text-xs">@</span>
86+ ) : (
87+ <Face who={{ kind: m.kind, id: m.key.slice(m.key.indexOf(":") + 1), name: m.name, avatar: m.avatar, avatar_seed: m.avatar_seed ?? null }} size={24} />
88+ )}
89+ <span className="min-w-0 grow">
90+ <span className="block truncate text-sm">{m.display_name}</span>
91+ <span className="block text-xs text-faint">{m.kind === "team" ? "Team" : m.kind === "agent" ? "Agent" : `@${m.name}`}</span>
92+ </span>
93+ <SelectField aria-label={`${m.display_name}'s role`} value={m.role} onValueChange={(role) => people.send("set_member", { space_id: space.id, member: m.key, role })} options={ROLE_OPTIONS} className="h-8 w-40" />
94+ <button type="button" onClick={() => people.send("set_member", { space_id: space.id, member: m.key, role: null })} className="text-xs text-faint hover:text-danger">
95+ Remove
96+ </button>
97+ </li>
98+ ))}
99+ {members.length === 0 && <li className="px-4 py-3 text-sm text-muted">Nobody added yet.</li>}
100+ </ul>
101+ <div className="mt-3 flex flex-wrap items-center gap-2">
102+ <SelectField aria-label="Person, agent or team" value={adding} onValueChange={setAdding} placeholder="Add a person, agent or team" options={candidates} className="h-9 min-w-64 grow" />
103+ <SelectField aria-label="Their role" value={addRole} onValueChange={(r) => setAddRole(r as DocRole)} options={ROLE_OPTIONS} className="h-9 w-40" />
104+ <Button
105+ type="button"
106+ variant="quiet"
107+ disabled={!adding || people.busy}
108+ onClick={async () => {
109+ // People are named by username here; the service keys them by id.
110+ const member = adding.startsWith("username:") ? await memberKeyFor(slug, adding.slice(9)) : adding;
111+ if (!member) return people.setError("That person isn't in the workspace.");
112+ const done = await people.send("set_member", { space_id: space.id, member, role: addRole });
113+ if (done.ok) setAdding("");
114+ }}
115+ >
116+ Add
117+ </Button>
118+ </div>
119+ {people.error && (
120+ <div className="mt-2">
121+ <ErrorText>{people.error}</ErrorText>
122+ </div>
123+ )}
124+ </section>
125+
126+ {!space.is_default && (
127+ <section className="mt-12 rounded-xl border border-danger/30 p-5">
128+ <h2 className="text-sm font-semibold">Archive this space</h2>
129+ <p className="mt-1 text-xs leading-relaxed text-muted">It leaves the sidebar and search. Its pages, history and comments are kept.</p>
130+ <Button type="button" variant="danger" onClick={() => setArchiving(true)} className="mt-3">
131+ <Archive size={14} /> Archive space
132+ </Button>
133+ </section>
134+ )}
135+ <AlertDialog open={archiving} onOpenChange={setArchiving}>
136+ <AlertDialogContent>
137+ <AlertDialogHeader>
138+ <AlertDialogTitle>Archive {space.name}?</AlertDialogTitle>
139+ <AlertDialogDescription>Nobody will see it in Docs until it is brought back.</AlertDialogDescription>
140+ </AlertDialogHeader>
141+ <AlertDialogFooter>
142+ <AlertDialogCancel>Keep it</AlertDialogCancel>
143+ <AlertDialogAction
144+ onClick={async () => {
145+ const done = await settings.send("update_space", { space_id: space.id, space_change: { archived: true } });
146+ if (done.ok) navigate(`/${slug}/-/docs`);
147+ }}
148+ >
149+ Archive
150+ </AlertDialogAction>
151+ </AlertDialogFooter>
152+ </AlertDialogContent>
153+ </AlertDialog>
154+ </div>
155+ );
156+}
157+
158+/** A person's member key (`user:<id>`), from their username, as the site resolves it. */
159+async function memberKeyFor(slug: string, username: string): Promise<string | null> {
160+ try {
161+ const r = await fetch(`/${slug}/-/docs/api?person=${encodeURIComponent(username)}`, { headers: { accept: "application/json" } });
162+ const result = (await r.json()) as { ok: boolean; value?: string };
163+ return result.ok && result.value ? result.value : null;
164+ } catch {
165+ return null;
166+ }
167+}
+121−0
1+import { DOC_AGENT_MODE_LABELS, DOC_ROLE_LABELS, DOC_SPACE_KIND_LABELS, type DocPage, type DocSpace, type DocSpaceMember } from "@g1t/contracts";
2+import { Download, FilePlus2, Settings } from "lucide-react";
3+import { Link, data, useNavigate } from "react-router";
4+
5+import type { Route } from "./+types/space";
6+import { useDocsAction } from "../../../components/docs/actions";
7+import { Face, SectionTitle } from "../../../components/docs/parts";
8+import { PageIcon, SpaceIcon } from "../../../components/docs/sidebar";
9+import { ButtonLink, EmptyState, ErrorText, TimeAgo } from "../../../components/ui";
10+import { buildTree, canDo, flatten } from "../../../lib/docs";
11+import { page } from "../../../lib/meta";
12+import { docs } from "../../../lib/services.server";
13+import { requireUser, roleIn } from "../../../lib/session.server";
14+
15+export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
16+ return page(args, { title: `${loaded?.space.name ?? "Space"} · Docs · ${params.owner} · g1t`, description: loaded?.space.description });
17+}
18+
19+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ space: DocSpace; members: DocSpaceMember[]; pages: DocPage[] }> {
20+ const viewer = requireUser(context, request);
21+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
22+ const found = await docs.space(params.owner.toLowerCase(), params.space, viewer);
23+ if (!found.ok) throw data(null, { status: found.error.code === "not_found" ? 404 : 403 });
24+ return found.value;
25+}
26+
27+/** A space: what it is, who it's for, and its pages as a tree. */
28+export default function SpacePage({ loaderData, params }: Route.ComponentProps) {
29+ const slug = params.owner.toLowerCase();
30+ const { space, members, pages } = loaderData;
31+ const navigate = useNavigate();
32+ const { send, busy, error } = useDocsAction(slug);
33+ const tree = flatten(buildTree(pages.map((p) => ({ id: p.id, parent_id: p.parent_id, position: p.position, title: p.title, icon: p.icon, slug: p.slug }))));
34+ const byId = new Map(pages.map((p) => [p.id, p]));
35+ const create = async () => {
36+ const made = await send<{ path: string }>("create_page", { page: { space_id: space.id } });
37+ if (made.ok) navigate(made.value.path);
38+ };
39+ return (
40+ <div className="mx-auto max-w-4xl">
41+ <div className="flex flex-wrap items-start gap-4">
42+ <span className="flex size-14 items-center justify-center rounded-2xl bg-raised text-2xl">
43+ <SpaceIcon space={space} size={26} />
44+ </span>
45+ <div className="min-w-0 grow">
46+ <h1 className="text-2xl font-semibold tracking-tight">{space.name}</h1>
47+ {space.description && <p className="mt-1 text-sm text-muted">{space.description}</p>}
48+ <p className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-faint">
49+ <span>{space.kind === "team" ? `Team @${space.team}` : DOC_SPACE_KIND_LABELS[space.kind]}</span>
50+ {space.default_role && <span>{DOC_ROLE_LABELS[space.default_role]}</span>}
51+ <span>Agents: {DOC_AGENT_MODE_LABELS[space.agent_mode].toLowerCase()}</span>
52+ <span>Your access: {DOC_ROLE_LABELS[space.viewer_role].toLowerCase()}</span>
53+ {space.projects.map((p) => (
54+ <Link key={p} to={`/${p}`} className="font-mono hover:text-fg">
55+ {p}
56+ </Link>
57+ ))}
58+ </p>
59+ </div>
60+ <div className="flex items-center gap-2">
61+ <a href={`/${slug}/-/docs/export?space=${encodeURIComponent(space.id)}`} className="inline-flex h-9 items-center gap-1.5 rounded-md border border-line px-3 text-sm text-fg/80 hover:bg-surface hover:text-fg">
62+ <Download size={15} /> Export
63+ </a>
64+ {canDo(space.viewer_role, "manage") && (
65+ <ButtonLink to={`/${slug}/-/docs/${space.slug}/settings`} variant="quiet">
66+ <Settings size={15} /> Settings
67+ </ButtonLink>
68+ )}
69+ {canDo(space.viewer_role, "edit") && (
70+ <button type="button" disabled={busy} onClick={create} className="inline-flex h-9 items-center gap-1.5 rounded-md bg-accent px-3 text-sm font-medium text-bg hover:bg-accent-hover">
71+ <FilePlus2 size={15} /> New page
72+ </button>
73+ )}
74+ </div>
75+ </div>
76+ {error && (
77+ <div className="mt-3">
78+ <ErrorText>{error}</ErrorText>
79+ </div>
80+ )}
81+ <section className="mt-8">
82+ <SectionTitle>Pages</SectionTitle>
83+ {tree.length === 0 ? (
84+ <EmptyState title="No pages yet">{canDo(space.viewer_role, "edit") ? "Start with New page, or from a template." : "Nothing has been written here yet."}</EmptyState>
85+ ) : (
86+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
87+ {tree.map((item) => {
88+ const p = byId.get(item.id)!;
89+ return (
90+ <li key={item.id}>
91+ <Link to={p.path} prefetch="intent" className="flex items-center gap-3 py-2.5 pr-4 text-sm transition-colors hover:bg-raised" style={{ paddingLeft: 16 + item.depth * 20 }}>
92+ <PageIcon icon={p.icon} />
93+ <span className="min-w-0 grow truncate">{p.title || "Untitled"}</span>
94+ <span className="hidden shrink-0 items-center gap-1.5 text-xs text-faint sm:flex">
95+ {p.updated_by && <Face who={p.updated_by} size={14} />}
96+ <TimeAgo at={p.updated_at} />
97+ </span>
98+ </Link>
99+ </li>
100+ );
101+ })}
102+ </ul>
103+ )}
104+ </section>
105+ {members.length > 0 && (
106+ <section className="mt-8">
107+ <SectionTitle>Members</SectionTitle>
108+ <ul className="flex flex-wrap gap-2">
109+ {members.map((m) => (
110+ <li key={m.key} className="flex items-center gap-2 rounded-full border border-line bg-surface py-1 pr-3 pl-1 text-xs">
111+ {m.kind === "team" ? <span className="flex size-5 items-center justify-center rounded-full bg-raised text-[0.625rem]">@</span> : <Face who={{ kind: m.kind, id: m.key.slice(m.key.indexOf(":") + 1), name: m.name, avatar: m.avatar, avatar_seed: m.avatar_seed ?? null }} size={20} />}
112+ <span>{m.display_name}</span>
113+ <span className="text-faint">{DOC_ROLE_LABELS[m.role]}</span>
114+ </li>
115+ ))}
116+ </ul>
117+ </section>
118+ )}
119+ </div>
120+ );
121+}
+104−0
1+import type { DocTemplate } from "@g1t/contracts";
2+import { useState } from "react";
3+import { data, useNavigate } from "react-router";
4+
5+import type { Route } from "./+types/templates";
6+import { docsRequest, useDocsAction, useDocsData } from "../../../components/docs/actions";
7+import { SectionTitle, TemplateCard } from "../../../components/docs/parts";
8+import { ErrorText } from "../../../components/ui";
9+import { SelectField } from "../../../components/ui/select";
10+import { canDo } from "../../../lib/docs";
11+import { page } from "../../../lib/meta";
12+import { docs } from "../../../lib/services.server";
13+import { requireUser, roleIn } from "../../../lib/session.server";
14+
15+export function meta({ params, ...args }: Route.MetaArgs) {
16+ return page(args, { title: `Templates · Docs · ${params.owner} · g1t` });
17+}
18+
19+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ templates: DocTemplate[] }> {
20+ const viewer = requireUser(context, request);
21+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
22+ const found = await docs.templates(params.owner.toLowerCase(), viewer).catch(() => null);
23+ return { templates: found?.ok ? found.value : [] };
24+}
25+
26+/** g1t's templates and the workspace's own; start a page from one in any space you can write in. */
27+export default function DocsTemplates({ loaderData, params }: Route.ComponentProps) {
28+ const slug = params.owner.toLowerCase();
29+ const layout = useDocsData();
30+ const navigate = useNavigate();
31+ const { send, error: actionError } = useDocsAction(slug);
32+ const writable = (layout?.sidebar?.spaces ?? []).filter((s) => canDo(s.viewer_role, "edit"));
33+ const [space, setSpace] = useState(writable.find((s) => s.is_default)?.id ?? writable[0]?.id ?? "");
34+ const [error, setError] = useState<string | null>(null);
35+ const use = async (template: string) => {
36+ const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: space, template_id: template } });
37+ if (made.ok) navigate(made.value.path);
38+ else setError(made.error.message);
39+ };
40+ const builtin = loaderData.templates.filter((t) => t.builtin);
41+ const own = loaderData.templates.filter((t) => !t.builtin);
42+ return (
43+ <div className="mx-auto max-w-5xl">
44+ <div className="flex flex-wrap items-end justify-between gap-4">
45+ <div>
46+ <h1 className="text-xl font-semibold tracking-tight">Templates</h1>
47+ <p className="mt-1 text-sm text-muted">Start a page with its structure already there. Save any page as a template from its ⋯ menu.</p>
48+ </div>
49+ {writable.length > 0 && (
50+ <label className="flex items-center gap-2 text-sm text-muted">
51+ New pages go in
52+ <SelectField aria-label="Space" value={space} onValueChange={setSpace} options={writable.map((s) => ({ value: s.id, label: s.name }))} className="h-9 min-w-40" />
53+ </label>
54+ )}
55+ </div>
56+ {(error || actionError) && (
57+ <div className="mt-3">
58+ <ErrorText>{error ?? actionError}</ErrorText>
59+ </div>
60+ )}
61+ <section className="mt-8">
62+ <SectionTitle>From g1t</SectionTitle>
63+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
64+ {builtin.map((t) => (
65+ <TemplateCard
66+ key={t.id}
67+ template={t}
68+ action={
69+ <button type="button" disabled={!space} onClick={() => use(t.id)} className="text-xs font-medium text-accent hover:underline disabled:text-faint">
70+ Use template
71+ </button>
72+ }
73+ />
74+ ))}
75+ </div>
76+ </section>
77+ <section className="mt-10">
78+ <SectionTitle>Your workspace&apos;s</SectionTitle>
79+ {own.length === 0 ? (
80+ <p className="text-sm text-muted">None yet. Open a page, then choose Save as template from its ⋯ menu.</p>
81+ ) : (
82+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
83+ {own.map((t) => (
84+ <TemplateCard
85+ key={t.id}
86+ template={t}
87+ action={
88+ <>
89+ <button type="button" disabled={!space} onClick={() => use(t.id)} className="text-xs font-medium text-accent hover:underline disabled:text-faint">
90+ Use template
91+ </button>
92+ <button type="button" onClick={() => send("delete_template", { template_id: t.id })} className="ml-auto text-xs text-faint hover:text-danger">
93+ Delete
94+ </button>
95+ </>
96+ }
97+ />
98+ ))}
99+ </div>
100+ )}
101+ </section>
102+ </div>
103+ );
104+}
+69−0
1+import { data } from "react-router";
2+
3+import type { DocThreadAction } from "@g1t/contracts";
4+
5+import type { Route } from "./+types/threads";
6+import { docs } from "../../../lib/services.server";
7+import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
8+
9+/**
10+ * The editor's comment store (BlockNote's `RESTYjsThreadStore`) writes
11+ * here; it reads threads straight from the page's live document, where
12+ * the page's room puts what these requests change:
13+ *
14+ * POST "" start a thread
15+ * POST /<thread>/addToDocument anchor it to the selected text
16+ * POST /<thread>/comments reply
17+ * PUT /<thread>/comments/<comment> edit a comment
18+ * DELETE /<thread>/comments/<comment>?soft= delete a comment
19+ * DELETE /<thread> delete a thread
20+ * POST /<thread>/resolve | /unresolve
21+ * POST /<thread>/comments/<comment>/reactions { emoji }
22+ * DELETE /<thread>/comments/<comment>/reactions/<emoji>
23+ *
24+ * A comment on the whole page is a thread started with `page_level: true`.
25+ */
26+export async function action({ params, context, request }: Route.ActionArgs) {
27+ assertSameOrigin(request);
28+ const viewer = requireUser(context, request);
29+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
30+ const slug = params.owner.toLowerCase();
31+ const url = new URL(request.url);
32+ const parts = (params["*"] ?? "").split("/").filter(Boolean).map(decodeURIComponent);
33+ const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
34+ const method = request.method.toUpperCase();
35+ const [thread, what, comment, sub, emoji] = parts;
36+ let action: DocThreadAction | null = null;
37+ if (!thread && method === "POST") {
38+ const initial = (body.initialComment ?? {}) as { body?: unknown; metadata?: unknown };
39+ action = { op: "create", body: initial.body ?? body.body, metadata: body.metadata ?? initial.metadata, page_level: body.page_level === true };
40+ } else if (thread && !what && method === "DELETE") action = { op: "delete_thread", thread_id: thread };
41+ else if (thread && what === "addToDocument" && method === "POST") {
42+ const selection = (body.selection ?? {}) as { yjs?: { anchor?: unknown; head?: unknown } };
43+ if (!selection.yjs) return Response.json({ ok: false, error: { code: "invalid", message: "Select some text to comment on." } }, { status: 422 });
44+ action = { op: "anchor", thread_id: thread, anchor: selection.yjs.anchor, head: selection.yjs.head };
45+ } else if (thread && (what === "resolve" || what === "unresolve") && method === "POST") action = { op: what, thread_id: thread };
46+ else if (thread && what === "comments" && !comment && method === "POST") {
47+ const c = (body.comment ?? {}) as { body?: unknown; metadata?: unknown };
48+ action = { op: "comment", thread_id: thread, body: c.body, metadata: c.metadata };
49+ } else if (thread && what === "comments" && comment && !sub && method === "PUT") {
50+ const c = (body.comment ?? {}) as { body?: unknown; metadata?: unknown };
51+ action = { op: "edit_comment", thread_id: thread, comment_id: comment, body: c.body, metadata: c.metadata };
52+ } else if (thread && what === "comments" && comment && !sub && method === "DELETE") {
53+ action = { op: "delete_comment", thread_id: thread, comment_id: comment, soft: url.searchParams.get("soft") === "true" };
54+ } else if (thread && what === "comments" && comment && sub === "reactions" && method === "POST") {
55+ action = { op: "react", thread_id: thread, comment_id: comment, emoji: String(body.emoji ?? "") };
56+ } else if (thread && what === "comments" && comment && sub === "reactions" && emoji && method === "DELETE") {
57+ action = { op: "unreact", thread_id: thread, comment_id: comment, emoji };
58+ }
59+ if (!action) return Response.json({ ok: false, error: { code: "invalid", message: "Unknown comment request." } }, { status: 404 });
60+ try {
61+ const result = await docs.thread(slug, params.page, viewer, action);
62+ // The thread store reads the answer's body as the thread or comment.
63+ if (!result.ok) return Response.json(result, { status: result.error.code === "forbidden" ? 403 : result.error.code === "not_found" ? 404 : 422 });
64+ return Response.json(result.value ?? {}, { headers: { "cache-control": "no-store" } });
65+ } catch (error) {
66+ console.error("docs threads:", error);
67+ return Response.json({ ok: false, error: { code: "conflict", message: "Docs didn't answer." } }, { status: 503 });
68+ }
69+}
+89−0
1+import type { DocPage } from "@g1t/contracts";
2+import { RotateCcw, Trash2 } from "lucide-react";
3+import { useState } from "react";
4+import { data } from "react-router";
5+
6+import type { Route } from "./+types/trash";
7+import { useDocsAction } from "../../../components/docs/actions";
8+import { PageIcon } from "../../../components/docs/sidebar";
9+import { EmptyState, ErrorText, TimeAgo } from "../../../components/ui";
10+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../../components/ui/alert-dialog";
11+import { page } from "../../../lib/meta";
12+import { docs } from "../../../lib/services.server";
13+import { requireUser, roleIn } from "../../../lib/session.server";
14+
15+export function meta({ params, ...args }: Route.MetaArgs) {
16+ return page(args, { title: `Trash · Docs · ${params.owner} · g1t` });
17+}
18+
19+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ pages: DocPage[] | null }> {
20+ const viewer = requireUser(context, request);
21+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
22+ const found = await docs.trash(params.owner.toLowerCase(), viewer).catch(() => null);
23+ return { pages: found?.ok ? found.value : null };
24+}
25+
26+/** Pages moved to the trash, in spaces the viewer can edit: restore them, or delete them for good. */
27+export default function DocsTrash({ loaderData, params }: Route.ComponentProps) {
28+ const slug = params.owner.toLowerCase();
29+ const { send, error } = useDocsAction(slug);
30+ const [forever, setForever] = useState<DocPage | null>(null);
31+ const pages = loaderData.pages;
32+ return (
33+ <div className="mx-auto max-w-3xl">
34+ <h1 className="text-xl font-semibold tracking-tight">Trash</h1>
35+ <p className="mt-1 text-sm text-muted">Pages come back with what was under them. Deleting for good can&apos;t be undone and needs full access to the space.</p>
36+ {error && (
37+ <div className="mt-3">
38+ <ErrorText>{error}</ErrorText>
39+ </div>
40+ )}
41+ <div className="mt-6">
42+ {pages === null ? (
43+ <EmptyState title="Docs didn't answer">Try again in a moment.</EmptyState>
44+ ) : pages.length === 0 ? (
45+ <EmptyState title="The trash is empty" />
46+ ) : (
47+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
48+ {pages.map((p) => (
49+ <li key={p.id} className="flex items-center gap-3 px-4 py-2.5">
50+ <PageIcon icon={p.icon} />
51+ <span className="min-w-0 grow">
52+ <span className="block truncate text-sm">{p.title || "Untitled"}</span>
53+ <span className="block text-xs text-faint">
54+ {p.space_slug} · trashed {p.archived_at ? <TimeAgo at={p.archived_at} /> : null}
55+ </span>
56+ </span>
57+ <button type="button" onClick={() => send("restore_page", { page_id: p.id })} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-line px-2.5 text-xs text-fg hover:bg-raised">
58+ <RotateCcw size={13} /> Restore
59+ </button>
60+ <button type="button" onClick={() => setForever(p)} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-danger/40 px-2.5 text-xs text-danger hover:bg-danger/10">
61+ <Trash2 size={13} /> Delete
62+ </button>
63+ </li>
64+ ))}
65+ </ul>
66+ )}
67+ </div>
68+ <AlertDialog open={!!forever} onOpenChange={(open) => !open && setForever(null)}>
69+ <AlertDialogContent>
70+ <AlertDialogHeader>
71+ <AlertDialogTitle>Delete &ldquo;{forever?.title || "Untitled"}&rdquo; for good?</AlertDialogTitle>
72+ <AlertDialogDescription>It goes with its history, comments and every page under it. This can&apos;t be undone.</AlertDialogDescription>
73+ </AlertDialogHeader>
74+ <AlertDialogFooter>
75+ <AlertDialogCancel>Keep it</AlertDialogCancel>
76+ <AlertDialogAction
77+ onClick={() => {
78+ if (forever) void send("delete_page", { page_id: forever.id });
79+ setForever(null);
80+ }}
81+ >
82+ Delete for good
83+ </AlertDialogAction>
84+ </AlertDialogFooter>
85+ </AlertDialogContent>
86+ </AlertDialog>
87+ </div>
88+ );
89+}
+44−0
1+import { env } from "cloudflare:workers";
2+import { data } from "react-router";
3+
4+import { DOCS_VIEWER_HEADER, DOC_MAX_FILE_BYTES } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/upload";
7+import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
8+
9+/**
10+ * A file put in a page (an image, a PDF, an attachment):
11+ * `POST <site>/<workspace>/-/docs/upload?page=<id>&name=<file name>` with
12+ * the file as the body. The docs service checks the viewer can edit the
13+ * page, keeps the file, and answers with its address on the usercontent
14+ * origin, where it is served, never on the site.
15+ */
16+export async function action({ params, context, request }: Route.ActionArgs) {
17+ assertSameOrigin(request);
18+ const viewer = requireUser(context, request);
19+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
20+ const url = new URL(request.url);
21+ const length = Number(request.headers.get("content-length") ?? "0");
22+ if (!length || length > DOC_MAX_FILE_BYTES) {
23+ return Response.json({ ok: false, error: { code: "invalid", message: `Files can be up to ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB.` } });
24+ }
25+ const target = new URL("https://docs/files");
26+ target.searchParams.set("workspace", params.owner.toLowerCase());
27+ target.searchParams.set("page", url.searchParams.get("page") ?? "");
28+ target.searchParams.set("name", url.searchParams.get("name") ?? "file");
29+ try {
30+ const answer = await env.DOCS.fetch(target.toString(), {
31+ method: "PUT",
32+ headers: {
33+ "content-type": request.headers.get("content-type") ?? "application/octet-stream",
34+ "content-length": String(length),
35+ [DOCS_VIEWER_HEADER]: JSON.stringify(viewer),
36+ },
37+ body: request.body,
38+ });
39+ return new Response(answer.body, { headers: { "content-type": "application/json", "cache-control": "no-store" } });
40+ } catch (error) {
41+ console.error("docs: upload", error);
42+ return Response.json({ ok: false, error: { code: "conflict", message: "Docs didn't answer. Try again." } });
43+ }
44+}
+13−3
1313 "postinstall": "wrangler types --include-env=false"
1414 },
1515 "dependencies": {
16+ "@blocknote/code-block": "^0.55.0",
17+ "@blocknote/core": "^0.55.0",
18+ "@blocknote/react": "^0.55.0",
19+ "@blocknote/shadcn": "^0.55.0",
1620 "@g1t/contracts": "*",
1721 "@g1t/theme": "*",
1822 "class-variance-authority": "^0.7.1",
1923 "clsx": "^2.1.1",
2024 "cmdk": "^1.1.1",
21− "isbot": "^5.1.36",
2225 "hast-util-to-jsx-runtime": "^2.3.6",
2326 "html-url-attributes": "^3.0.1",
27+ "isbot": "^5.1.36",
28+ "katex": "^0.19.0",
29+ "lib0": "^0.2.119",
2430 "lucide-react": "^1.49.0",
2531 "mdast-util-find-and-replace": "^3.0.2",
32+ "mermaid": "^12.1.0",
2633 "radix-ui": "^1.6.7",
2734 "react": "^19.2.8",
2835 "react-dom": "^19.2.8",
3643 "shiki": "^4.5.0",
3744 "simple-icons": "^16.34.0",
3845 "tailwind-merge": "^3.7.0",
46+ "unified": "^11.0.5",
3947 "unist-util-visit": "^5.1.0",
40− "unified": "^11.0.5",
4148 "uqr": "^0.1.3",
42− "vfile": "^6.0.3"
49+ "vfile": "^6.0.3",
50+ "y-prosemirror": "^1.3.7",
51+ "y-protocols": "^1.0.7",
52+ "yjs": "^13.6.33"
4353 },
4454 "devDependencies": {
4555 "@cloudflare/vite-plugin": "^1.62.4",
+2−0
2727 * socket, forwarded as it is (app/lib/chat-live.server.ts).
2828 */
2929 CHAT: ServiceBinding & { fetch(request: Request): Promise<Response> };
30+ /** Docs (services/docs): RPC, each page's live socket, and files in pages. */
31+ DOCS: ServiceBinding & { fetch(request: Request | string, init?: RequestInit): Promise<Response> };
3032 /** The workspace's own agents: definitions, templates and desks. */
3133 AGENTS: ServiceBinding;
3234 /**
+26−1
66 */
77 import { reposClient } from "@g1t/contracts";
88
9−import { MAX_RAW_BYTES, isCommit, parseRawPath, rawHeaders, verifyRaw } from "../app/lib/usercontent";
9+import { MAX_RAW_BYTES, PDF_POLICY, USERCONTENT_POLICY, isCommit, parseRawPath, rawHeaders, verifyRaw } from "../app/lib/usercontent";
1010
1111 /** An uploaded avatar, by the SHA-256 of its bytes. */
1212 export const AVATAR_PATH = /^\/avatars\/([0-9a-f]{64})$/;
1313 /** A workspace's custom emoji, by the SHA-256 of its bytes: kept by chat under `emoji/<hash>` in the same namespace. */
1414 export const EMOJI_PATH = /^\/emoji\/([0-9a-f]{64})$/;
15+/** A file put in a Docs page, by its random key: kept by the docs service (services/docs). */
16+export const DOCS_FILE_PATH = /^\/docs-files\/([0-9a-f]{64})$/;
1517 /** The only types identity stores, having checked each image's bytes. */
1618 const AVATAR_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
1719
4042 if (avatar) return serveAvatar(env, ctx, method, avatar[1]!, new URL(request.url).origin);
4143 const emoji = EMOJI_PATH.exec(path);
4244 if (emoji) return serveAvatar(env, ctx, method, `emoji/${emoji[1]!}`, new URL(request.url).origin);
45+ const docsFile = DOCS_FILE_PATH.exec(path);
46+ if (docsFile) return serveDocsFile(env, method, docsFile[1]!);
4347 const file = parseRawPath(path);
4448 if (file) return serveRaw(env, request, method, file);
4549 return plain(404, "Not found", "public, max-age=300");
114118 ctx.waitUntil(cache.put(cacheKey, new Response(value, { headers })));
115119 return new Response(method === "HEAD" ? null : value, { headers });
116120 }
121+
122+/**
123+ * A file put in a Docs page. Its key is 256 random bits the docs service
124+ * made, so the address is the permission, as a shared link is; the docs
125+ * service serves images, media and PDFs as themselves and everything else
126+ * as a download, and nothing here can run script.
127+ */
128+async function serveDocsFile(env: Env, method: string, key: string): Promise<Response> {
129+ let answer: Response;
130+ try {
131+ answer = await env.DOCS.fetch(`https://docs/files/${key}`, { method });
132+ } catch {
133+ return plain(503, "Docs didn't answer");
134+ }
135+ if (!answer.ok) return plain(answer.status === 404 ? 404 : 502, "Not found", "public, max-age=60");
136+ const headers = new Headers(answer.headers);
137+ headers.set("x-content-type-options", "nosniff");
138+ headers.set("content-security-policy", headers.get("content-type") === "application/pdf" ? PDF_POLICY : USERCONTENT_POLICY);
139+ headers.set("cross-origin-resource-policy", "cross-origin");
140+ return new Response(method === "HEAD" ? null : answer.body, { status: 200, headers });
141+}
+2−0
4747 { "binding": "SEARCH", "service": "g1t-search" },
4848 // Chat: channels, direct messages and the live socket (services/chat).
4949 { "binding": "CHAT", "service": "g1t-chat" },
50+ // Docs: spaces, pages, each page's live socket, and files in pages (services/docs).
51+ { "binding": "DOCS", "service": "g1t-docs-service" },
5052 // The workspace's own agents: definitions, templates, desks (services/agents).
5153 { "binding": "AGENTS", "service": "g1t-agents" },
5254 // Live notifications, counts and browser push: each tab's feed socket (services/notify).
+15−0
127127 ],
128128 "self_host": "run"
129129 },
130+ // Docs mode's service. Its Worker is g1t-docs-service: g1t-docs is
131+ // the documentation site (apps/docs).
132+ "docs-service": {
133+ "path": "services/docs",
134+ "kind": "ts-worker",
135+ "worker": "g1t-docs-service",
136+ "d1": { "database": "g1t-docs", "migrations": "migrations" },
137+ "stage": "core",
138+ "secrets": [],
139+ "setup": [
140+ "The D1 database, before the first deploy: npx wrangler d1 create g1t-docs, then put its id in services/docs/wrangler.jsonc",
141+ "The R2 bucket for files in pages: npx wrangler r2 bucket create g1t-docs-files"
142+ ],
143+ "self_host": "run"
144+ },
130145 "notify": {
131146 "path": "services/notify",
132147 "kind": "ts-worker",
+1−0
112112 | `services/events` | Rust | Queues (producer and fan-out) | Runs unchanged; the off services' queues are not produced to |
113113 | `services/projects` | TS | Queue consumer | Runs unchanged |
114114 | `services/chat` | TS | Durable Objects (one room per channel, WebSocket hibernation), KV `AVATARS` (custom emoji images, under `emoji/`) | Runs unchanged; workerd runs its Durable Objects, and the site serves emoji images from the same KV |
115+| `services/docs` | TS | Durable Objects (one room per page: the Yjs document, WebSocket hibernation, SQLite storage, alarms), **R2** (`FILES`, files in pages, behind the `FileStore` interface in `src/files.ts`), D1 with FTS5 | Runs unchanged; workerd runs its Durable Objects, and `FILES` is the local R2 bucket Wrangler keeps on disk (an S3 `FileStore` for RustFS is the next step) |
115116 | `services/notify` | TS | Durable Objects (one feed per person: WebSocket hibernation, SQLite storage); outbound HTTPS to browsers' push services | Runs unchanged; browser push needs a VAPID key pair (`node scripts/ops/vapid-keys.mjs`), else notifications are live in open tabs only |
116117 | `services/agents` | TS | Durable Objects (one desk per agent, alarms) | Runs unchanged; replies reach a model through the `MODELS` binding (the model proxy), which is off, so an agent answers with a short apology |
117118 | `services/search` | Rust | Queues (events and its own jobs); FTS5 | Runs unchanged |
+52−18
464464 - **Agents read it.** Pages are indexed by `services/context`, so the
465465 knowledge reaches every reply, session and plan. A space can be pinned to
466466 an agent as required reading.
467−- **Agents write it.** An agent with write access to a space edits pages
468− directly. Without it, the edit becomes a **suggestion**: tracked changes
469− a person accepts or rejects inline, the same review loop as a pull
470− request. Every edit is attributed and in the page history.
467+- **Agents write it.** An agent edits pages directly where the space lets
468+ agents edit and the person it acts for can edit. Otherwise the edit
469+ becomes a **suggestion**: tracked changes a person accepts or rejects
470+ inline, the same review loop as a pull request. Every edit is attributed
471+ and in the page history.
471472 - **Pages know what they describe.** A page can cite code: paths, symbols,
472473 endpoints, environment variables. When a merged pull request changes
473474 something a page cites, the page is marked possibly stale and its owners
493494
494495 ### How it is stored
495496
496−- **Live editing.** Each open page is a Durable Object holding a CRDT
497− (Yjs), reached over a WebSocket.
498−- **Durable storage.** On idle, the page is saved as Markdown. Each space
499− is a git repository in g1t's own git storage, so:
500− - history, blame and export come free;
501− - agents can work on a space with the same tools they use on code;
502− - self-hosted installs keep everything in git.
503−- **Metadata** lives in `services/docs`' D1: tree, owners, permissions,
504− citations, staleness, comments.
497+Built (`services/docs`, contract `packages/contracts/src/docs.ts`):
498+
499+- **Live editing.** Each page is a Durable Object (`PageRoom`) that owns
500+ the page's Yjs document, reached over a WebSocket through the site
501+ (`/<workspace>/-/docs/live?page=<id>`), speaking the y-protocols sync
502+ and awareness messages. The editor is BlockNote (MPL-2.0) on that
503+ document. The room keeps the document in its own SQLite storage as a
504+ snapshot plus the updates since, and enforces the socket's role: a
505+ viewer or commenter never changes the document.
506+- **Everything else in D1** (`g1t-docs`): spaces, members and roles,
507+ linked projects, the page tree, owners, favorites, views, backlinks,
508+ versions, suggestions, templates, files' metadata, and a full-text index
509+ (FTS5) over titles and Markdown.
510+- **Markdown is derived.** A few seconds after a burst of edits the room
511+ saves the page's Markdown rendition to D1: what search indexes, agents
512+ read, export writes, and the page shows before its editor loads. Agents
513+ write Markdown too; it becomes blocks in the same document, so their
514+ changes merge with whatever people are typing.
515+- **History** is a version (the Yjs state and its Markdown) at most every
516+ 10 minutes of editing, and for every agent edit, accepted suggestion and
517+ restore, with who made it. Restore copies an old version's blocks in as
518+ a new change.
519+- **Comments** live in the page's document too (a `threads` map, in the
520+ shape BlockNote's comment UI reads), written only through the service,
521+ which checks the role; a passage's comment is a mark on its text.
522+- **Files** go to R2 (`g1t-docs-files`) behind a small store interface and
523+ are served from the usercontent origin at `/docs-files/<key>`, 256
524+ random bits per file.
525+
526+Decided: **not git, for now.** Spaces were planned as git repositories.
527+D1 + Durable Objects ships live collaboration, comments, suggestions and
528+search without a git write per keystroke burst, and Markdown export (a
529+page, or a space as a zip in its tree's folders) keeps the content
530+portable. Repository-backed spaces (a project's `docs/` as a read-only
531+space, edits as pull requests) remain the next step for repository docs.
532+For self-hosting, the Durable Object, R2 and D1 sit behind the room,
533+`FileStore` and SQL; nothing above them depends on Cloudflare.
505534
535+Not built yet: citations and staleness, "write this up" from a thread,
536+the documenter agent, repository docs as spaces, `doc.page.*` events and
537+indexing pages in `services/context`.
538+
506539 ## Chat
507540
508541 Channels, direct messages, threads, reactions, read state and the
10731106 | `services/notify` (new, TS) | One feed per person: live notifications and unread counts over each tab's socket, browser push (VAPID), preferences, presence, status and Do Not Disturb; one presence room per workspace. Durable Object SQLite storage, no D1. |
10741107 | `services/chat` (new, TS) | Channels, members, messages, threads, reactions, read state; one Durable Object per channel for live delivery with WebSocket hibernation. |
10751108 | `services/agents` (new, TS) | Agent definitions and versions, the desk Durable Object per agent, the coordinator Durable Object per workspace (claims), replies (the no-sandbox model loop over g1t MCP). |
1076−| `services/docs` (new, TS) | Spaces, pages, the page Durable Object (CRDT), suggestions, comments, citations and staleness, git-backed storage. |
1109+| `services/docs` (new, TS) | Spaces, pages, the page Durable Object (Yjs), history, suggestions, comments, templates, search (FTS5), files (R2); citations and staleness to come. |
10771110 | `services/work` | Tasks and task links beside `agent_runs` (which gains `task_id`); `agent_messages` widened to task addresses. |
10781111 | `services/runner` | Resumable sessions: transcript save and restore in R2, `--resume`, the steer hook reading task threads, claims checked at start and widened from diffs. |
10791112 | `services/context` | Indexes doc pages and channel decisions; serves them to replies and sessions. |
11381171 assigning work stays out.
11391172 - **Replies without a sandbox.** Chat answers come from a Worker-side model
11401173 loop, not a container. Recommended for speed and cost.
1141−- **Docs stored in git.** Each space is a git repository, and live editing
1142− is a CRDT saved to it.
1143−- **Agent edits to docs** are suggestions unless the agent has write access
1144− to that space. Recommended default.
1174+- **Docs stored in git.** Decided against for now: D1 + a Durable Object
1175+ per page, with Markdown export (see "How it is stored").
1176+- **Agent edits to docs** are suggestions by default. Built: a space's
1177+ managers can set "Agents in this space" to edit directly, which applies
1178+ only where the person the agent acts for can edit.
11451179 - **Default capacity** of 3 concurrent tasks per agent, and a hop limit
11461180 of 6.
+2022−3
7373 "hasInstallScript": true,
7474 "license": "MIT",
7575 "dependencies": {
76+ "@blocknote/code-block": "^0.55.0",
77+ "@blocknote/core": "^0.55.0",
78+ "@blocknote/react": "^0.55.0",
79+ "@blocknote/shadcn": "^0.55.0",
7680 "@g1t/contracts": "*",
7781 "@g1t/theme": "*",
7882 "class-variance-authority": "^0.7.1",
8185 "hast-util-to-jsx-runtime": "^2.3.6",
8286 "html-url-attributes": "^3.0.1",
8387 "isbot": "^5.1.36",
88+ "katex": "^0.19.0",
89+ "lib0": "^0.2.119",
8490 "lucide-react": "^1.49.0",
8591 "mdast-util-find-and-replace": "^3.0.2",
92+ "mermaid": "^12.1.0",
8693 "radix-ui": "^1.6.7",
8794 "react": "^19.2.8",
8895 "react-dom": "^19.2.8",
99106 "unified": "^11.0.5",
100107 "unist-util-visit": "^5.1.0",
101108 "uqr": "^0.1.3",
102− "vfile": "^6.0.3"
109+ "vfile": "^6.0.3",
110+ "y-prosemirror": "^1.3.7",
111+ "y-protocols": "^1.0.7",
112+ "yjs": "^13.6.33"
103113 },
104114 "devDependencies": {
105115 "@cloudflare/vite-plugin": "^1.62.4",
114124 "wrangler": "^4.146.0"
115125 }
116126 },
127+ "node_modules/@antfu/install-pkg": {
128+ "version": "2.1.0",
129+ "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-2.1.0.tgz",
130+ "integrity": "sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==",
131+ "license": "MIT",
132+ "dependencies": {
133+ "package-manager-detector": "^1.8.0",
134+ "tinyexec": "^1.3.1"
135+ },
136+ "funding": {
137+ "url": "https://github.com/sponsors/antfu"
138+ }
139+ },
117140 "node_modules/@astrojs/compiler-binding": {
118141 "version": "0.5.1",
119142 "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.5.1.tgz",
684707 "node": ">=6.0.0"
685708 }
686709 },
710+ "node_modules/@babel/runtime": {
711+ "version": "7.29.10",
712+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
713+ "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
714+ "license": "MIT",
715+ "engines": {
716+ "node": ">=6.9.0"
717+ }
718+ },
687719 "node_modules/@babel/template": {
688720 "version": "7.29.7",
689721 "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
731763 "node": ">=6.9.0"
732764 }
733765 },
766+ "node_modules/@base-ui/react": {
767+ "version": "1.8.0",
768+ "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.8.0.tgz",
769+ "integrity": "sha512-P0/1sxo6SBVZOklKMIedvTWqw2s2IQzi9x5bIVsXu980cuSOD4NeuRSs+/L7LZQfDkZP/uRZyGPyfFl/B1oH+Q==",
770+ "license": "MIT",
771+ "dependencies": {
772+ "@babel/runtime": "^7.29.7",
773+ "@base-ui/utils": "0.4.0",
774+ "@floating-ui/react-dom": "^2.1.9",
775+ "@floating-ui/utils": "^0.2.12",
776+ "use-sync-external-store": "^1.6.0"
777+ },
778+ "engines": {
779+ "node": ">=14.0.0"
780+ },
781+ "funding": {
782+ "type": "opencollective",
783+ "url": "https://opencollective.com/mui-org"
784+ },
785+ "peerDependencies": {
786+ "@date-fns/tz": "^1.2.0",
787+ "@types/react": "^17 || ^18 || ^19",
788+ "date-fns": "^4.0.0",
789+ "react": "^17 || ^18 || ^19",
790+ "react-dom": "^17 || ^18 || ^19"
791+ },
792+ "peerDependenciesMeta": {
793+ "@date-fns/tz": {
794+ "optional": true
795+ },
796+ "@types/react": {
797+ "optional": true
798+ },
799+ "date-fns": {
800+ "optional": true
801+ }
802+ }
803+ },
804+ "node_modules/@base-ui/utils": {
805+ "version": "0.4.0",
806+ "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.4.0.tgz",
807+ "integrity": "sha512-bO9fz25kKtPf+aZVyfQrC0PDmJdmVni31W2hCS5/Owb+inwdIL3XU26pCPRPlt4LSxZrBgLwubXQXQlKaFEZzw==",
808+ "license": "MIT",
809+ "dependencies": {
810+ "@babel/runtime": "^7.29.7",
811+ "@floating-ui/utils": "^0.2.12",
812+ "reselect": "^5.2.0",
813+ "use-sync-external-store": "^1.6.0"
814+ },
815+ "peerDependencies": {
816+ "@types/react": "^17 || ^18 || ^19",
817+ "react": "^17 || ^18 || ^19",
818+ "react-dom": "^17 || ^18 || ^19"
819+ },
820+ "peerDependenciesMeta": {
821+ "@types/react": {
822+ "optional": true
823+ }
824+ }
825+ },
826+ "node_modules/@blocknote/code-block": {
827+ "version": "0.55.0",
828+ "resolved": "https://registry.npmjs.org/@blocknote/code-block/-/code-block-0.55.0.tgz",
829+ "integrity": "sha512-ctdD9YUEuK9nWbLkNcEd8jGdtmUpuSkJ2VP6iXTJm33ryMd1VnsWwaj/I1VNST1s/rMF3QvFq5Z1paIvsi4+zQ==",
830+ "license": "MPL-2.0",
831+ "dependencies": {
832+ "@shikijs/core": "^4.4.3",
833+ "@shikijs/engine-javascript": "^4.4.3",
834+ "@shikijs/langs-precompiled": "^4.4.3",
835+ "@shikijs/themes": "^4.4.3"
836+ },
837+ "optionalDependencies": {
838+ "@shikijs/types": "^4.4.3"
839+ },
840+ "peerDependencies": {
841+ "@blocknote/core": "^0.55.0"
842+ }
843+ },
844+ "node_modules/@blocknote/core": {
845+ "version": "0.55.0",
846+ "resolved": "https://registry.npmjs.org/@blocknote/core/-/core-0.55.0.tgz",
847+ "integrity": "sha512-QRRnOuVDrt0pPwaCXQ/dc4gxQ3KEGkBC8Hh4hyl4lxOU+5TMLNKKX9gZgeGkq7WKVK2EZbnPea6pcx25aS5SGA==",
848+ "license": "MPL-2.0",
849+ "dependencies": {
850+ "@emoji-mart/data": "^1.2.1",
851+ "@handlewithcare/prosemirror-inputrules": "^0.1.4",
852+ "@shikijs/types": "^4.4.3",
853+ "@tiptap/core": "^3.31.3",
854+ "@tiptap/extension-bold": "^3.31.3",
855+ "@tiptap/extension-code": "^3.31.3",
856+ "@tiptap/extension-italic": "^3.31.3",
857+ "@tiptap/extension-strike": "^3.31.3",
858+ "@tiptap/extension-text": "^3.31.3",
859+ "@tiptap/extension-underline": "^3.31.3",
860+ "@tiptap/extensions": "^3.31.3",
861+ "@tiptap/pm": "^3.31.3",
862+ "emoji-mart": "^5.6.0",
863+ "fast-deep-equal": "^3.1.3",
864+ "lib0": "1.0.0-rc.22",
865+ "prosemirror-highlight": "^0.15.3",
866+ "prosemirror-model": "^1.25.11",
867+ "prosemirror-state": "^1.4.4",
868+ "prosemirror-tables": "^1.8.5",
869+ "prosemirror-transform": "^1.12.1",
870+ "prosemirror-view": "^1.42.4"
871+ },
872+ "peerDependencies": {
873+ "@y/prosemirror": "^2.0.0-6",
874+ "@y/protocols": "^1.0.6-rc.1",
875+ "@y/y": "^14.0.0-rc.23",
876+ "y-prosemirror": "^1.3.7",
877+ "y-protocols": "^1.0.6",
878+ "yjs": "^13.6.27"
879+ },
880+ "peerDependenciesMeta": {
881+ "@y/prosemirror": {
882+ "optional": true
883+ },
884+ "@y/protocols": {
885+ "optional": true
886+ },
887+ "@y/y": {
888+ "optional": true
889+ },
890+ "y-prosemirror": {
891+ "optional": true
892+ },
893+ "y-protocols": {
894+ "optional": true
895+ },
896+ "yjs": {
897+ "optional": true
898+ }
899+ }
900+ },
901+ "node_modules/@blocknote/core/node_modules/lib0": {
902+ "version": "1.0.0-rc.22",
903+ "resolved": "https://registry.npmjs.org/lib0/-/lib0-1.0.0-rc.22.tgz",
904+ "integrity": "sha512-KNefJloRQIsWncTF2tIcRqQXSQ7bDRYHwVSUhf1lY2P65Rej4WWFnen6L8L+odJQIo1ZNJGVVjK2WzqB9a+B/g==",
905+ "license": "MIT",
906+ "bin": {
907+ "0ecdsa-generate-keypair": "src/bin/0ecdsa-generate-keypair.js",
908+ "0gentesthtml": "src/bin/gentesthtml.js",
909+ "0serve": "src/bin/0serve.js"
910+ },
911+ "engines": {
912+ "node": ">=22"
913+ },
914+ "funding": {
915+ "type": "GitHub Sponsors ❤",
916+ "url": "https://github.com/sponsors/dmonad"
917+ }
918+ },
919+ "node_modules/@blocknote/react": {
920+ "version": "0.55.0",
921+ "resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.55.0.tgz",
922+ "integrity": "sha512-t+4N8+3xHhlnesxpWhF3FZnoHcnwhO+VgHiz6WEx7kTOTgk2YH84EOD7FsPh/hCm3I9MH9cHuiMfo6W6PHj/9w==",
923+ "license": "MPL-2.0",
924+ "dependencies": {
925+ "@blocknote/core": "^0.55.0",
926+ "@emoji-mart/data": "^1.2.1",
927+ "@floating-ui/react": "^0.27.18",
928+ "@tiptap/core": "^3.31.3",
929+ "@tiptap/pm": "^3.31.3",
930+ "@tiptap/react": "^3.31.3",
931+ "emoji-mart": "^5.6.0",
932+ "fast-deep-equal": "^3.1.3",
933+ "use-sync-external-store": "1.6.0"
934+ },
935+ "optionalDependencies": {
936+ "@types/use-sync-external-store": "1.5.0"
937+ },
938+ "peerDependencies": {
939+ "react": "^18.0 || ^19.0 || >= 19.0.0-rc",
940+ "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc"
941+ }
942+ },
943+ "node_modules/@blocknote/shadcn": {
944+ "version": "0.55.0",
945+ "resolved": "https://registry.npmjs.org/@blocknote/shadcn/-/shadcn-0.55.0.tgz",
946+ "integrity": "sha512-r00Rijcn0Gb9btkjEf1ojthQuJ5hxsHMRo/ljrk3xIlC7hlskEyR9/50E1Su9CIYTf61nzebTtHUZiXYyNx59A==",
947+ "license": "MPL-2.0",
948+ "dependencies": {
949+ "@base-ui/react": "^1.6.0",
950+ "@blocknote/core": "^0.55.0",
951+ "@blocknote/react": "^0.55.0",
952+ "class-variance-authority": "^0.7.1",
953+ "clsx": "^2.1.1",
954+ "lucide-react": "^0.525.0",
955+ "tailwind-merge": "^2.6.0"
956+ },
957+ "peerDependencies": {
958+ "react": "^18.0 || ^19.0 || >= 19.0.0-rc",
959+ "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc",
960+ "tailwindcss": "^4.1.12"
961+ }
962+ },
963+ "node_modules/@blocknote/shadcn/node_modules/lucide-react": {
964+ "version": "0.525.0",
965+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz",
966+ "integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==",
967+ "license": "ISC",
968+ "peerDependencies": {
969+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
970+ }
971+ },
972+ "node_modules/@blocknote/shadcn/node_modules/tailwind-merge": {
973+ "version": "2.6.1",
974+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
975+ "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
976+ "license": "MIT",
977+ "funding": {
978+ "type": "github",
979+ "url": "https://github.com/sponsors/dcastil"
980+ }
981+ },
982+ "node_modules/@braintree/sanitize-url": {
983+ "version": "7.1.2",
984+ "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz",
985+ "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==",
986+ "license": "MIT"
987+ },
734988 "node_modules/@bruits/satteri-darwin-arm64": {
735989 "version": "0.10.5",
736990 "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz",
9081162 "node": ">=18"
9091163 }
9101164 },
1165+ "node_modules/@chevrotain/cst-dts-gen": {
1166+ "version": "13.2.0",
1167+ "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-13.2.0.tgz",
1168+ "integrity": "sha512-bA9dvWlhAUcHkNwU+8w9yiUxJPlj/Lm3VqS7MD+NbIR/7LmpNLuK/AWNFdCo/T3SK5ND0/UHf3IJvl/Jn/i6yg==",
1169+ "license": "Apache-2.0",
1170+ "dependencies": {
1171+ "@chevrotain/gast": "13.2.0",
1172+ "@chevrotain/types": "13.2.0"
1173+ }
1174+ },
1175+ "node_modules/@chevrotain/gast": {
1176+ "version": "13.2.0",
1177+ "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-13.2.0.tgz",
1178+ "integrity": "sha512-bCARdAsG+eEbOveSXcOAC7pVxA04I1ionzeMOwIYf5NX7SaS7zgn4Dgug2BSqoL+iyKeMNSorQjsFSHJHsCNwg==",
1179+ "license": "Apache-2.0",
1180+ "dependencies": {
1181+ "@chevrotain/types": "13.2.0"
1182+ }
1183+ },
1184+ "node_modules/@chevrotain/regexp-to-ast": {
1185+ "version": "13.2.0",
1186+ "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-13.2.0.tgz",
1187+ "integrity": "sha512-kxpUkLtiaK09ph+dib+Yvm+3sWSnuwlo25gqfKRgTPNmF6H7uW4gVkMjaRdtnN783S9sM6vUNWh9CsxYX4QwKw==",
1188+ "license": "Apache-2.0"
1189+ },
1190+ "node_modules/@chevrotain/types": {
1191+ "version": "13.2.0",
1192+ "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-13.2.0.tgz",
1193+ "integrity": "sha512-7qrYHRlKfWf2AvBfJi5eVOrKAkbxvMFsy/iIHTGIiApNcEecCaVIdR+mIQhyCBsTDJxBz4JWXHMo6krbhR9hVg==",
1194+ "license": "Apache-2.0"
1195+ },
1196+ "node_modules/@chevrotain/utils": {
1197+ "version": "13.2.0",
1198+ "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-13.2.0.tgz",
1199+ "integrity": "sha512-SzKYY68rseOrb9KILVzPsl6SjkQtdWw01SRGB5aCVI5kFcHltQsOYV3JB0Ffst72DgkvHCyDQ0NLwxtmCCf97Q==",
1200+ "license": "Apache-2.0"
1201+ },
9111202 "node_modules/@clack/core": {
9121203 "version": "1.5.1",
9131204 "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz",
11431434 "tslib": "^2.4.0"
11441435 }
11451436 },
1437+ "node_modules/@emoji-mart/data": {
1438+ "version": "1.2.1",
1439+ "resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz",
1440+ "integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==",
1441+ "license": "MIT"
1442+ },
11461443 "node_modules/@esbuild/aix-ppc64": {
11471444 "version": "0.28.1",
11481445 "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
16231920 "@floating-ui/utils": "^0.2.12"
16241921 }
16251922 },
1923+ "node_modules/@floating-ui/react": {
1924+ "version": "0.27.20",
1925+ "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz",
1926+ "integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==",
1927+ "license": "MIT",
1928+ "dependencies": {
1929+ "@floating-ui/react-dom": "^2.1.9",
1930+ "@floating-ui/utils": "^0.2.12",
1931+ "tabbable": "^6.0.0"
1932+ },
1933+ "peerDependencies": {
1934+ "react": ">=17.0.0",
1935+ "react-dom": ">=17.0.0"
1936+ }
1937+ },
16261938 "node_modules/@floating-ui/react-dom": {
16271939 "version": "2.1.9",
16281940 "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
16661978 "resolved": "apps/docs",
16671979 "link": true
16681980 },
1981+ "node_modules/@g1t/docs-service": {
1982+ "resolved": "services/docs",
1983+ "link": true
1984+ },
16691985 "node_modules/@g1t/models": {
16701986 "resolved": "services/models",
16711987 "link": true
17062022 "resolved": "apps/web",
17072023 "link": true
17082024 },
2025+ "node_modules/@handlewithcare/prosemirror-inputrules": {
2026+ "version": "0.1.4",
2027+ "resolved": "https://registry.npmjs.org/@handlewithcare/prosemirror-inputrules/-/prosemirror-inputrules-0.1.4.tgz",
2028+ "integrity": "sha512-GMqlBeG2MKM+tXEFd2N+wIv5z4VvJTg8JtfJUrdjvFq2W6v+AW8oTgiWyFw8L3iEQwvtQcVJxU873iB0LXUNNw==",
2029+ "license": "MIT",
2030+ "dependencies": {
2031+ "prosemirror-history": "^1.4.1",
2032+ "prosemirror-transform": "^1.0.0"
2033+ },
2034+ "peerDependencies": {
2035+ "prosemirror-model": "^1.0.0",
2036+ "prosemirror-state": "^1.0.0",
2037+ "prosemirror-view": "^1.0.0"
2038+ }
2039+ },
2040+ "node_modules/@iconify/types": {
2041+ "version": "2.0.0",
2042+ "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
2043+ "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
2044+ "license": "MIT"
2045+ },
2046+ "node_modules/@iconify/utils": {
2047+ "version": "3.1.7",
2048+ "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.7.tgz",
2049+ "integrity": "sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==",
2050+ "license": "MIT",
2051+ "dependencies": {
2052+ "@antfu/install-pkg": "^2.0.1",
2053+ "@iconify/types": "^2.0.0",
2054+ "import-meta-resolve": "^4.2.0"
2055+ }
2056+ },
17092057 "node_modules/@img/colour": {
17102058 "version": "1.1.0",
17112059 "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
23342682 "astro": "^4 || ^5 || ^6 || ^7"
23352683 }
23362684 },
2685+ "node_modules/@mermaid-js/parser": {
2686+ "version": "2.0.1",
2687+ "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-2.0.1.tgz",
2688+ "integrity": "sha512-j37dj/h6p6pots1L7pLPPNuABEEu9WP4OYg9pW1g+nYG331j0+3canE9OagtYdytYvdSAMG+2fKU5cxep6LgjA==",
2689+ "license": "MIT",
2690+ "dependencies": {
2691+ "@chevrotain/types": "~13.2.0"
2692+ },
2693+ "engines": {
2694+ "node": ">=22.12.0"
2695+ }
2696+ },
23372697 "node_modules/@napi-rs/wasm-runtime": {
23382698 "version": "1.2.4",
23392699 "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz",
44414801 "node": ">=20"
44424802 }
44434803 },
4804+ "node_modules/@shikijs/langs-precompiled": {
4805+ "version": "4.5.0",
4806+ "resolved": "https://registry.npmjs.org/@shikijs/langs-precompiled/-/langs-precompiled-4.5.0.tgz",
4807+ "integrity": "sha512-l6x3sNA4qvSQPtFogjhVj9IjjYhQ6Z4zy+E2vP5/PteFYCtT7GR7xZIjUT8QQKgA7k6glMDQXI0265qmIkJXLg==",
4808+ "license": "MIT",
4809+ "dependencies": {
4810+ "@shikijs/types": "4.5.0",
4811+ "oniguruma-to-es": "^4.3.6"
4812+ },
4813+ "engines": {
4814+ "node": ">=20"
4815+ }
4816+ },
44444817 "node_modules/@shikijs/primitive": {
44454818 "version": "4.5.0",
44464819 "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.5.0.tgz",
48065179 "vite": "^5.2.0 || ^6 || ^7 || ^8"
48075180 }
48085181 },
5182+ "node_modules/@tiptap/core": {
5183+ "version": "3.31.4",
5184+ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.4.tgz",
5185+ "integrity": "sha512-cIVUQYBycighzsk6GbmCef8ZiEDVxoT5EuAe4xg2q9dNkDqCXVqI4YGr9aavCQEiAS4uHiRwqvcsPqJHipFDfg==",
5186+ "license": "MIT",
5187+ "funding": {
5188+ "type": "github",
5189+ "url": "https://github.com/sponsors/ueberdosis"
5190+ },
5191+ "peerDependencies": {
5192+ "@tiptap/pm": "3.31.4"
5193+ }
5194+ },
5195+ "node_modules/@tiptap/extension-bold": {
5196+ "version": "3.31.4",
5197+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.4.tgz",
5198+ "integrity": "sha512-QX1xoXvO0gz6uVsoLegumqQ4MJXL7lcUDQZ4J7la7LBqiIh4efrCmp2EXbhpr37M/YQL7w8q3g7H/VovSh7xIg==",
5199+ "license": "MIT",
5200+ "funding": {
5201+ "type": "github",
5202+ "url": "https://github.com/sponsors/ueberdosis"
5203+ },
5204+ "peerDependencies": {
5205+ "@tiptap/core": "3.31.4"
5206+ }
5207+ },
5208+ "node_modules/@tiptap/extension-bubble-menu": {
5209+ "version": "3.31.4",
5210+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.4.tgz",
5211+ "integrity": "sha512-Ml/yswuys+3QkOMajfXyAGlUKCxEbT3dMyF8vHwQ5mDk9MDm+dqp10It5CrDBkUT/KkVTa6AJI3kb0TXZJZiAA==",
5212+ "license": "MIT",
5213+ "optional": true,
5214+ "dependencies": {
5215+ "@floating-ui/dom": "^1.0.0"
5216+ },
5217+ "funding": {
5218+ "type": "github",
5219+ "url": "https://github.com/sponsors/ueberdosis"
5220+ },
5221+ "peerDependencies": {
5222+ "@tiptap/core": "3.31.4",
5223+ "@tiptap/pm": "3.31.4"
5224+ }
5225+ },
5226+ "node_modules/@tiptap/extension-code": {
5227+ "version": "3.31.4",
5228+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.4.tgz",
5229+ "integrity": "sha512-pzi8GNDs5J1DEoO1HNYiJ5WZpgOZzlc/2VIa77mMpEuD4hZxuK0BQzlF/O6Ps/O5P1lGJT4xS0Yowpzb9mD9RA==",
5230+ "license": "MIT",
5231+ "funding": {
5232+ "type": "github",
5233+ "url": "https://github.com/sponsors/ueberdosis"
5234+ },
5235+ "peerDependencies": {
5236+ "@tiptap/core": "3.31.4"
5237+ }
5238+ },
5239+ "node_modules/@tiptap/extension-floating-menu": {
5240+ "version": "3.31.4",
5241+ "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.4.tgz",
5242+ "integrity": "sha512-pKzulkDmolpVt7IP9FSPsC5pKbXQ5pyeqkaAYr8cyOVW+KUObpPRIzAuXvEso8PCjWgwJ1f2qyishOEMw8MbfQ==",
5243+ "license": "MIT",
5244+ "optional": true,
5245+ "funding": {
5246+ "type": "github",
5247+ "url": "https://github.com/sponsors/ueberdosis"
5248+ },
5249+ "peerDependencies": {
5250+ "@floating-ui/dom": "^1.0.0",
5251+ "@tiptap/core": "3.31.4",
5252+ "@tiptap/pm": "3.31.4"
5253+ }
5254+ },
5255+ "node_modules/@tiptap/extension-italic": {
5256+ "version": "3.31.4",
5257+ "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.4.tgz",
5258+ "integrity": "sha512-pftTQBPYZocNDAj/u6OyDIp7YxmN0+N895cQPogJQ6qMHdLiRhzgawiEewJkniyN1w8k3Wx/Etp8pTKrxaOOBQ==",
5259+ "license": "MIT",
5260+ "funding": {
5261+ "type": "github",
5262+ "url": "https://github.com/sponsors/ueberdosis"
5263+ },
5264+ "peerDependencies": {
5265+ "@tiptap/core": "3.31.4"
5266+ }
5267+ },
5268+ "node_modules/@tiptap/extension-strike": {
5269+ "version": "3.31.4",
5270+ "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.4.tgz",
5271+ "integrity": "sha512-4MBr63TJIMqxCjE+gAPdlY1IdELgvbAP516K3Ntn/BkcpEbEoPMEYVz+XTxULyHQEuARDO6jsXPPxRXpA74jaw==",
5272+ "license": "MIT",
5273+ "funding": {
5274+ "type": "github",
5275+ "url": "https://github.com/sponsors/ueberdosis"
5276+ },
5277+ "peerDependencies": {
5278+ "@tiptap/core": "3.31.4"
5279+ }
5280+ },
5281+ "node_modules/@tiptap/extension-text": {
5282+ "version": "3.31.4",
5283+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.4.tgz",
5284+ "integrity": "sha512-WL2ZRpFksgQcXJN05/UQH09YCH9LlhdLrSEcviASvEH/aAfsKiJlDrouN8PET+YAWK/F5xFc5/Q1z0xj+I23jw==",
5285+ "license": "MIT",
5286+ "funding": {
5287+ "type": "github",
5288+ "url": "https://github.com/sponsors/ueberdosis"
5289+ },
5290+ "peerDependencies": {
5291+ "@tiptap/core": "3.31.4"
5292+ }
5293+ },
5294+ "node_modules/@tiptap/extension-underline": {
5295+ "version": "3.31.4",
5296+ "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.4.tgz",
5297+ "integrity": "sha512-0iFGPIqmqGkCrOFh1OPo7Dzv5s86dVN3bZaugsz0jK04otkb8bn04ZDKP2/6Wq+lcK0M9DfJ0QqfW5hC2nBZxQ==",
5298+ "license": "MIT",
5299+ "funding": {
5300+ "type": "github",
5301+ "url": "https://github.com/sponsors/ueberdosis"
5302+ },
5303+ "peerDependencies": {
5304+ "@tiptap/core": "3.31.4"
5305+ }
5306+ },
5307+ "node_modules/@tiptap/extensions": {
5308+ "version": "3.31.4",
5309+ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.4.tgz",
5310+ "integrity": "sha512-nIupIne/sHpV2Kw1M4MOmr4+G454KI8BXM5+LWKOU2EpzfALZWVSy7koF9lVRna4Zp5Gv8hfg5Z8kvmrYQvhkw==",
5311+ "license": "MIT",
5312+ "funding": {
5313+ "type": "github",
5314+ "url": "https://github.com/sponsors/ueberdosis"
5315+ },
5316+ "peerDependencies": {
5317+ "@tiptap/core": "3.31.4",
5318+ "@tiptap/pm": "3.31.4"
5319+ }
5320+ },
5321+ "node_modules/@tiptap/pm": {
5322+ "version": "3.31.4",
5323+ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.4.tgz",
5324+ "integrity": "sha512-x7dwth79cQp4QGae66/RJqMDJQv0+n7b7dD7cyFQBWqzPFLJxN/4paHY4qbWedZjNLhRyIOAD9a5q4AYM1zmPA==",
5325+ "license": "MIT",
5326+ "dependencies": {
5327+ "prosemirror-changeset": "^2.4.1",
5328+ "prosemirror-commands": "^1.7.1",
5329+ "prosemirror-dropcursor": "^1.8.2",
5330+ "prosemirror-gapcursor": "^1.4.1",
5331+ "prosemirror-history": "^1.5.0",
5332+ "prosemirror-inputrules": "^1.5.1",
5333+ "prosemirror-keymap": "^1.2.3",
5334+ "prosemirror-model": "^1.25.11",
5335+ "prosemirror-schema-list": "^1.5.1",
5336+ "prosemirror-state": "^1.4.4",
5337+ "prosemirror-tables": "^1.8.5",
5338+ "prosemirror-transform": "^1.12.0",
5339+ "prosemirror-view": "^1.42.3"
5340+ },
5341+ "funding": {
5342+ "type": "github",
5343+ "url": "https://github.com/sponsors/ueberdosis"
5344+ }
5345+ },
5346+ "node_modules/@tiptap/react": {
5347+ "version": "3.31.4",
5348+ "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.4.tgz",
5349+ "integrity": "sha512-mBvvXgdPhMinT1NPfwHvFK1dFdMXHVWMSeNn0IPj7/zV0XBmOnASkEOwoIvl8NluKYEtpZBE1yA+6pN2enLa5g==",
5350+ "license": "MIT",
5351+ "dependencies": {
5352+ "@types/use-sync-external-store": "^0.0.6",
5353+ "fast-equals": "^5.3.3",
5354+ "use-sync-external-store": "^1.4.0"
5355+ },
5356+ "funding": {
5357+ "type": "github",
5358+ "url": "https://github.com/sponsors/ueberdosis"
5359+ },
5360+ "optionalDependencies": {
5361+ "@tiptap/extension-bubble-menu": "3.31.4",
5362+ "@tiptap/extension-floating-menu": "3.31.4"
5363+ },
5364+ "peerDependencies": {
5365+ "@tiptap/core": "3.31.4",
5366+ "@tiptap/pm": "3.31.4",
5367+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
5368+ "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
5369+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
5370+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
5371+ }
5372+ },
5373+ "node_modules/@tiptap/react/node_modules/@types/use-sync-external-store": {
5374+ "version": "0.0.6",
5375+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
5376+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
5377+ "license": "MIT"
5378+ },
48095379 "node_modules/@tootallnate/quickjs-emscripten": {
48105380 "version": "0.23.0",
48115381 "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
48225392 "tslib": "^2.4.0"
48235393 }
48245394 },
5395+ "node_modules/@types/d3": {
5396+ "version": "7.4.3",
5397+ "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
5398+ "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
5399+ "license": "MIT",
5400+ "dependencies": {
5401+ "@types/d3-array": "*",
5402+ "@types/d3-axis": "*",
5403+ "@types/d3-brush": "*",
5404+ "@types/d3-chord": "*",
5405+ "@types/d3-color": "*",
5406+ "@types/d3-contour": "*",
5407+ "@types/d3-delaunay": "*",
5408+ "@types/d3-dispatch": "*",
5409+ "@types/d3-drag": "*",
5410+ "@types/d3-dsv": "*",
5411+ "@types/d3-ease": "*",
5412+ "@types/d3-fetch": "*",
5413+ "@types/d3-force": "*",
5414+ "@types/d3-format": "*",
5415+ "@types/d3-geo": "*",
5416+ "@types/d3-hierarchy": "*",
5417+ "@types/d3-interpolate": "*",
5418+ "@types/d3-path": "*",
5419+ "@types/d3-polygon": "*",
5420+ "@types/d3-quadtree": "*",
5421+ "@types/d3-random": "*",
5422+ "@types/d3-scale": "*",
5423+ "@types/d3-scale-chromatic": "*",
5424+ "@types/d3-selection": "*",
5425+ "@types/d3-shape": "*",
5426+ "@types/d3-time": "*",
5427+ "@types/d3-time-format": "*",
5428+ "@types/d3-timer": "*",
5429+ "@types/d3-transition": "*",
5430+ "@types/d3-zoom": "*"
5431+ }
5432+ },
5433+ "node_modules/@types/d3-array": {
5434+ "version": "3.2.2",
5435+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
5436+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
5437+ "license": "MIT"
5438+ },
5439+ "node_modules/@types/d3-axis": {
5440+ "version": "3.0.6",
5441+ "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
5442+ "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
5443+ "license": "MIT",
5444+ "dependencies": {
5445+ "@types/d3-selection": "*"
5446+ }
5447+ },
5448+ "node_modules/@types/d3-brush": {
5449+ "version": "3.0.6",
5450+ "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
5451+ "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
5452+ "license": "MIT",
5453+ "dependencies": {
5454+ "@types/d3-selection": "*"
5455+ }
5456+ },
5457+ "node_modules/@types/d3-chord": {
5458+ "version": "3.0.6",
5459+ "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
5460+ "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
5461+ "license": "MIT"
5462+ },
5463+ "node_modules/@types/d3-color": {
5464+ "version": "3.1.3",
5465+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
5466+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
5467+ "license": "MIT"
5468+ },
5469+ "node_modules/@types/d3-contour": {
5470+ "version": "3.0.6",
5471+ "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
5472+ "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
5473+ "license": "MIT",
5474+ "dependencies": {
5475+ "@types/d3-array": "*",
5476+ "@types/geojson": "*"
5477+ }
5478+ },
5479+ "node_modules/@types/d3-delaunay": {
5480+ "version": "6.0.4",
5481+ "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
5482+ "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
5483+ "license": "MIT"
5484+ },
5485+ "node_modules/@types/d3-dispatch": {
5486+ "version": "3.0.7",
5487+ "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
5488+ "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
5489+ "license": "MIT"
5490+ },
5491+ "node_modules/@types/d3-drag": {
5492+ "version": "3.0.7",
5493+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
5494+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
5495+ "license": "MIT",
5496+ "dependencies": {
5497+ "@types/d3-selection": "*"
5498+ }
5499+ },
5500+ "node_modules/@types/d3-dsv": {
5501+ "version": "3.0.7",
5502+ "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
5503+ "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
5504+ "license": "MIT"
5505+ },
5506+ "node_modules/@types/d3-ease": {
5507+ "version": "3.0.2",
5508+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
5509+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
5510+ "license": "MIT"
5511+ },
5512+ "node_modules/@types/d3-fetch": {
5513+ "version": "3.0.7",
5514+ "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
5515+ "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
5516+ "license": "MIT",
5517+ "dependencies": {
5518+ "@types/d3-dsv": "*"
5519+ }
5520+ },
5521+ "node_modules/@types/d3-force": {
5522+ "version": "3.0.10",
5523+ "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
5524+ "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
5525+ "license": "MIT"
5526+ },
5527+ "node_modules/@types/d3-format": {
5528+ "version": "3.0.4",
5529+ "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
5530+ "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
5531+ "license": "MIT"
5532+ },
5533+ "node_modules/@types/d3-geo": {
5534+ "version": "3.1.1",
5535+ "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz",
5536+ "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==",
5537+ "license": "MIT",
5538+ "dependencies": {
5539+ "@types/geojson": "*"
5540+ }
5541+ },
5542+ "node_modules/@types/d3-hierarchy": {
5543+ "version": "3.1.7",
5544+ "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
5545+ "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
5546+ "license": "MIT"
5547+ },
5548+ "node_modules/@types/d3-interpolate": {
5549+ "version": "3.0.4",
5550+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
5551+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
5552+ "license": "MIT",
5553+ "dependencies": {
5554+ "@types/d3-color": "*"
5555+ }
5556+ },
5557+ "node_modules/@types/d3-path": {
5558+ "version": "3.1.1",
5559+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
5560+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
5561+ "license": "MIT"
5562+ },
5563+ "node_modules/@types/d3-polygon": {
5564+ "version": "3.0.2",
5565+ "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
5566+ "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
5567+ "license": "MIT"
5568+ },
5569+ "node_modules/@types/d3-quadtree": {
5570+ "version": "3.0.6",
5571+ "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
5572+ "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
5573+ "license": "MIT"
5574+ },
5575+ "node_modules/@types/d3-random": {
5576+ "version": "3.0.4",
5577+ "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz",
5578+ "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==",
5579+ "license": "MIT"
5580+ },
5581+ "node_modules/@types/d3-scale": {
5582+ "version": "4.0.9",
5583+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
5584+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
5585+ "license": "MIT",
5586+ "dependencies": {
5587+ "@types/d3-time": "*"
5588+ }
5589+ },
5590+ "node_modules/@types/d3-scale-chromatic": {
5591+ "version": "3.1.0",
5592+ "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
5593+ "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
5594+ "license": "MIT"
5595+ },
5596+ "node_modules/@types/d3-selection": {
5597+ "version": "3.0.12",
5598+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz",
5599+ "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==",
5600+ "license": "MIT"
5601+ },
5602+ "node_modules/@types/d3-shape": {
5603+ "version": "3.2.0",
5604+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
5605+ "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
5606+ "license": "MIT",
5607+ "dependencies": {
5608+ "@types/d3-path": "*"
5609+ }
5610+ },
5611+ "node_modules/@types/d3-time": {
5612+ "version": "3.0.4",
5613+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
5614+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
5615+ "license": "MIT"
5616+ },
5617+ "node_modules/@types/d3-time-format": {
5618+ "version": "4.0.3",
5619+ "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
5620+ "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
5621+ "license": "MIT"
5622+ },
5623+ "node_modules/@types/d3-timer": {
5624+ "version": "3.0.2",
5625+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
5626+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
5627+ "license": "MIT"
5628+ },
5629+ "node_modules/@types/d3-transition": {
5630+ "version": "3.0.9",
5631+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
5632+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
5633+ "license": "MIT",
5634+ "dependencies": {
5635+ "@types/d3-selection": "*"
5636+ }
5637+ },
5638+ "node_modules/@types/d3-zoom": {
5639+ "version": "3.0.9",
5640+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz",
5641+ "integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==",
5642+ "license": "MIT",
5643+ "dependencies": {
5644+ "@types/d3-interpolate": "*",
5645+ "@types/d3-selection": "*"
5646+ }
5647+ },
48255648 "node_modules/@types/debug": {
48265649 "version": "4.1.13",
48275650 "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
48465669 "@types/estree": "*"
48475670 }
48485671 },
5672+ "node_modules/@types/geojson": {
5673+ "version": "7946.0.16",
5674+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
5675+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
5676+ "license": "MIT"
5677+ },
48495678 "node_modules/@types/hast": {
48505679 "version": "3.0.5",
48515680 "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
49075736 "version": "19.3.0",
49085737 "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
49095738 "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
4910− "devOptional": true,
49115739 "license": "MIT",
49125740 "peerDependencies": {
49135741 "@types/react": "^19.3.0"
49225750 "@types/node": "*"
49235751 }
49245752 },
5753+ "node_modules/@types/trusted-types": {
5754+ "version": "2.0.7",
5755+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
5756+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
5757+ "license": "MIT",
5758+ "optional": true
5759+ },
49255760 "node_modules/@types/unist": {
49265761 "version": "3.0.3",
49275762 "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
49285763 "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
49295764 "license": "MIT"
49305765 },
5766+ "node_modules/@types/use-sync-external-store": {
5767+ "version": "1.5.0",
5768+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
5769+ "integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==",
5770+ "license": "MIT",
5771+ "optional": true
5772+ },
49315773 "node_modules/@types/yauzl": {
49325774 "version": "2.10.3",
49335775 "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
49445786 "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
49455787 "license": "ISC"
49465788 },
5789+ "node_modules/@upsetjs/venn.js": {
5790+ "version": "2.0.0",
5791+ "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
5792+ "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==",
5793+ "license": "MIT",
5794+ "optionalDependencies": {
5795+ "d3-selection": "^3.0.0",
5796+ "d3-transition": "^3.0.1"
5797+ }
5798+ },
49475799 "node_modules/agent-base": {
49485800 "version": "7.1.4",
49495801 "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
55406392 "url": "https://github.com/sponsors/wooorm"
55416393 }
55426394 },
6395+ "node_modules/chevrotain": {
6396+ "version": "13.2.0",
6397+ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-13.2.0.tgz",
6398+ "integrity": "sha512-GTouk5YzgBxlTPCaDn5q0q2EJKkrYmnPF+HmF8v0Qo4r3iyPG7HJivkN1wthCXZcm3sIcZFpGTRovLPNdJNRCA==",
6399+ "license": "Apache-2.0",
6400+ "dependencies": {
6401+ "@chevrotain/cst-dts-gen": "13.2.0",
6402+ "@chevrotain/gast": "13.2.0",
6403+ "@chevrotain/regexp-to-ast": "13.2.0",
6404+ "@chevrotain/types": "13.2.0",
6405+ "@chevrotain/utils": "13.2.0"
6406+ },
6407+ "engines": {
6408+ "node": ">=22.0.0"
6409+ }
6410+ },
55436411 "node_modules/chokidar": {
55446412 "version": "5.0.0",
55456413 "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
57016569 "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
57026570 "license": "MIT"
57036571 },
6572+ "node_modules/cose-base": {
6573+ "version": "1.0.3",
6574+ "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
6575+ "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==",
6576+ "license": "MIT",
6577+ "dependencies": {
6578+ "layout-base": "^1.0.0"
6579+ }
6580+ },
57046581 "node_modules/crossws": {
57056582 "version": "0.3.5",
57066583 "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz",
58596736 "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
58606737 "license": "MIT"
58616738 },
6739+ "node_modules/cytoscape": {
6740+ "version": "3.34.3",
6741+ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz",
6742+ "integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==",
6743+ "license": "MIT",
6744+ "engines": {
6745+ "node": ">=0.10"
6746+ }
6747+ },
6748+ "node_modules/cytoscape-cose-bilkent": {
6749+ "version": "4.1.0",
6750+ "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz",
6751+ "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==",
6752+ "license": "MIT",
6753+ "dependencies": {
6754+ "cose-base": "^1.0.0"
6755+ },
6756+ "peerDependencies": {
6757+ "cytoscape": "^3.2.0"
6758+ }
6759+ },
6760+ "node_modules/cytoscape-fcose": {
6761+ "version": "2.2.0",
6762+ "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz",
6763+ "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==",
6764+ "license": "MIT",
6765+ "dependencies": {
6766+ "cose-base": "^2.2.0"
6767+ },
6768+ "peerDependencies": {
6769+ "cytoscape": "^3.2.0"
6770+ }
6771+ },
6772+ "node_modules/cytoscape-fcose/node_modules/cose-base": {
6773+ "version": "2.2.0",
6774+ "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz",
6775+ "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==",
6776+ "license": "MIT",
6777+ "dependencies": {
6778+ "layout-base": "^2.0.0"
6779+ }
6780+ },
6781+ "node_modules/cytoscape-fcose/node_modules/layout-base": {
6782+ "version": "2.0.1",
6783+ "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz",
6784+ "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==",
6785+ "license": "MIT"
6786+ },
6787+ "node_modules/d3": {
6788+ "version": "7.9.0",
6789+ "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
6790+ "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
6791+ "license": "ISC",
6792+ "dependencies": {
6793+ "d3-array": "3",
6794+ "d3-axis": "3",
6795+ "d3-brush": "3",
6796+ "d3-chord": "3",
6797+ "d3-color": "3",
6798+ "d3-contour": "4",
6799+ "d3-delaunay": "6",
6800+ "d3-dispatch": "3",
6801+ "d3-drag": "3",
6802+ "d3-dsv": "3",
6803+ "d3-ease": "3",
6804+ "d3-fetch": "3",
6805+ "d3-force": "3",
6806+ "d3-format": "3",
6807+ "d3-geo": "3",
6808+ "d3-hierarchy": "3",
6809+ "d3-interpolate": "3",
6810+ "d3-path": "3",
6811+ "d3-polygon": "3",
6812+ "d3-quadtree": "3",
6813+ "d3-random": "3",
6814+ "d3-scale": "4",
6815+ "d3-scale-chromatic": "3",
6816+ "d3-selection": "3",
6817+ "d3-shape": "3",
6818+ "d3-time": "3",
6819+ "d3-time-format": "4",
6820+ "d3-timer": "3",
6821+ "d3-transition": "3",
6822+ "d3-zoom": "3"
6823+ },
6824+ "engines": {
6825+ "node": ">=12"
6826+ }
6827+ },
6828+ "node_modules/d3-array": {
6829+ "version": "3.2.4",
6830+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
6831+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
6832+ "license": "ISC",
6833+ "dependencies": {
6834+ "internmap": "1 - 2"
6835+ },
6836+ "engines": {
6837+ "node": ">=12"
6838+ }
6839+ },
6840+ "node_modules/d3-axis": {
6841+ "version": "3.0.0",
6842+ "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
6843+ "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
6844+ "license": "ISC",
6845+ "engines": {
6846+ "node": ">=12"
6847+ }
6848+ },
6849+ "node_modules/d3-brush": {
6850+ "version": "3.0.0",
6851+ "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
6852+ "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
6853+ "license": "ISC",
6854+ "dependencies": {
6855+ "d3-dispatch": "1 - 3",
6856+ "d3-drag": "2 - 3",
6857+ "d3-interpolate": "1 - 3",
6858+ "d3-selection": "3",
6859+ "d3-transition": "3"
6860+ },
6861+ "engines": {
6862+ "node": ">=12"
6863+ }
6864+ },
6865+ "node_modules/d3-chord": {
6866+ "version": "3.0.1",
6867+ "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
6868+ "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
6869+ "license": "ISC",
6870+ "dependencies": {
6871+ "d3-path": "1 - 3"
6872+ },
6873+ "engines": {
6874+ "node": ">=12"
6875+ }
6876+ },
6877+ "node_modules/d3-color": {
6878+ "version": "3.1.0",
6879+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
6880+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
6881+ "license": "ISC",
6882+ "engines": {
6883+ "node": ">=12"
6884+ }
6885+ },
6886+ "node_modules/d3-contour": {
6887+ "version": "4.0.2",
6888+ "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
6889+ "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
6890+ "license": "ISC",
6891+ "dependencies": {
6892+ "d3-array": "^3.2.0"
6893+ },
6894+ "engines": {
6895+ "node": ">=12"
6896+ }
6897+ },
6898+ "node_modules/d3-delaunay": {
6899+ "version": "6.0.4",
6900+ "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
6901+ "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
6902+ "license": "ISC",
6903+ "dependencies": {
6904+ "delaunator": "5"
6905+ },
6906+ "engines": {
6907+ "node": ">=12"
6908+ }
6909+ },
6910+ "node_modules/d3-dispatch": {
6911+ "version": "3.0.1",
6912+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
6913+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
6914+ "license": "ISC",
6915+ "engines": {
6916+ "node": ">=12"
6917+ }
6918+ },
6919+ "node_modules/d3-drag": {
6920+ "version": "3.0.0",
6921+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
6922+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
6923+ "license": "ISC",
6924+ "dependencies": {
6925+ "d3-dispatch": "1 - 3",
6926+ "d3-selection": "3"
6927+ },
6928+ "engines": {
6929+ "node": ">=12"
6930+ }
6931+ },
6932+ "node_modules/d3-dsv": {
6933+ "version": "3.0.1",
6934+ "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
6935+ "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
6936+ "license": "ISC",
6937+ "dependencies": {
6938+ "commander": "7",
6939+ "iconv-lite": "0.6",
6940+ "rw": "1"
6941+ },
6942+ "bin": {
6943+ "csv2json": "bin/dsv2json.js",
6944+ "csv2tsv": "bin/dsv2dsv.js",
6945+ "dsv2dsv": "bin/dsv2dsv.js",
6946+ "dsv2json": "bin/dsv2json.js",
6947+ "json2csv": "bin/json2dsv.js",
6948+ "json2dsv": "bin/json2dsv.js",
6949+ "json2tsv": "bin/json2dsv.js",
6950+ "tsv2csv": "bin/dsv2dsv.js",
6951+ "tsv2json": "bin/dsv2json.js"
6952+ },
6953+ "engines": {
6954+ "node": ">=12"
6955+ }
6956+ },
6957+ "node_modules/d3-dsv/node_modules/commander": {
6958+ "version": "7.2.0",
6959+ "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
6960+ "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
6961+ "license": "MIT",
6962+ "engines": {
6963+ "node": ">= 10"
6964+ }
6965+ },
6966+ "node_modules/d3-ease": {
6967+ "version": "3.0.1",
6968+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
6969+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
6970+ "license": "BSD-3-Clause",
6971+ "engines": {
6972+ "node": ">=12"
6973+ }
6974+ },
6975+ "node_modules/d3-fetch": {
6976+ "version": "3.0.1",
6977+ "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
6978+ "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
6979+ "license": "ISC",
6980+ "dependencies": {
6981+ "d3-dsv": "1 - 3"
6982+ },
6983+ "engines": {
6984+ "node": ">=12"
6985+ }
6986+ },
6987+ "node_modules/d3-force": {
6988+ "version": "3.0.0",
6989+ "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
6990+ "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
6991+ "license": "ISC",
6992+ "dependencies": {
6993+ "d3-dispatch": "1 - 3",
6994+ "d3-quadtree": "1 - 3",
6995+ "d3-timer": "1 - 3"
6996+ },
6997+ "engines": {
6998+ "node": ">=12"
6999+ }
7000+ },
7001+ "node_modules/d3-format": {
7002+ "version": "3.1.2",
7003+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
7004+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
7005+ "license": "ISC",
7006+ "engines": {
7007+ "node": ">=12"
7008+ }
7009+ },
7010+ "node_modules/d3-geo": {
7011+ "version": "3.1.1",
7012+ "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
7013+ "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
7014+ "license": "ISC",
7015+ "dependencies": {
7016+ "d3-array": "2.5.0 - 3"
7017+ },
7018+ "engines": {
7019+ "node": ">=12"
7020+ }
7021+ },
7022+ "node_modules/d3-hierarchy": {
7023+ "version": "3.1.2",
7024+ "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
7025+ "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
7026+ "license": "ISC",
7027+ "engines": {
7028+ "node": ">=12"
7029+ }
7030+ },
7031+ "node_modules/d3-interpolate": {
7032+ "version": "3.0.1",
7033+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
7034+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
7035+ "license": "ISC",
7036+ "dependencies": {
7037+ "d3-color": "1 - 3"
7038+ },
7039+ "engines": {
7040+ "node": ">=12"
7041+ }
7042+ },
7043+ "node_modules/d3-path": {
7044+ "version": "3.1.0",
7045+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
7046+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
7047+ "license": "ISC",
7048+ "engines": {
7049+ "node": ">=12"
7050+ }
7051+ },
7052+ "node_modules/d3-polygon": {
7053+ "version": "3.0.1",
7054+ "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
7055+ "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
7056+ "license": "ISC",
7057+ "engines": {
7058+ "node": ">=12"
7059+ }
7060+ },
7061+ "node_modules/d3-quadtree": {
7062+ "version": "3.0.1",
7063+ "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
7064+ "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
7065+ "license": "ISC",
7066+ "engines": {
7067+ "node": ">=12"
7068+ }
7069+ },
7070+ "node_modules/d3-random": {
7071+ "version": "3.0.1",
7072+ "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
7073+ "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
7074+ "license": "ISC",
7075+ "engines": {
7076+ "node": ">=12"
7077+ }
7078+ },
7079+ "node_modules/d3-sankey": {
7080+ "version": "0.12.3",
7081+ "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz",
7082+ "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==",
7083+ "license": "BSD-3-Clause",
7084+ "dependencies": {
7085+ "d3-array": "1 - 2",
7086+ "d3-shape": "^1.2.0"
7087+ }
7088+ },
7089+ "node_modules/d3-sankey/node_modules/d3-array": {
7090+ "version": "2.12.1",
7091+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
7092+ "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
7093+ "license": "BSD-3-Clause",
7094+ "dependencies": {
7095+ "internmap": "^1.0.0"
7096+ }
7097+ },
7098+ "node_modules/d3-sankey/node_modules/d3-path": {
7099+ "version": "1.0.9",
7100+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
7101+ "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==",
7102+ "license": "BSD-3-Clause"
7103+ },
7104+ "node_modules/d3-sankey/node_modules/d3-shape": {
7105+ "version": "1.3.7",
7106+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
7107+ "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
7108+ "license": "BSD-3-Clause",
7109+ "dependencies": {
7110+ "d3-path": "1"
7111+ }
7112+ },
7113+ "node_modules/d3-sankey/node_modules/internmap": {
7114+ "version": "1.0.1",
7115+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
7116+ "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
7117+ "license": "ISC"
7118+ },
7119+ "node_modules/d3-scale": {
7120+ "version": "4.0.2",
7121+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
7122+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
7123+ "license": "ISC",
7124+ "dependencies": {
7125+ "d3-array": "2.10.0 - 3",
7126+ "d3-format": "1 - 3",
7127+ "d3-interpolate": "1.2.0 - 3",
7128+ "d3-time": "2.1.1 - 3",
7129+ "d3-time-format": "2 - 4"
7130+ },
7131+ "engines": {
7132+ "node": ">=12"
7133+ }
7134+ },
7135+ "node_modules/d3-scale-chromatic": {
7136+ "version": "3.1.0",
7137+ "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
7138+ "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
7139+ "license": "ISC",
7140+ "dependencies": {
7141+ "d3-color": "1 - 3",
7142+ "d3-interpolate": "1 - 3"
7143+ },
7144+ "engines": {
7145+ "node": ">=12"
7146+ }
7147+ },
7148+ "node_modules/d3-selection": {
7149+ "version": "3.0.0",
7150+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
7151+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
7152+ "license": "ISC",
7153+ "engines": {
7154+ "node": ">=12"
7155+ }
7156+ },
7157+ "node_modules/d3-shape": {
7158+ "version": "3.2.0",
7159+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
7160+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
7161+ "license": "ISC",
7162+ "dependencies": {
7163+ "d3-path": "^3.1.0"
7164+ },
7165+ "engines": {
7166+ "node": ">=12"
7167+ }
7168+ },
7169+ "node_modules/d3-time": {
7170+ "version": "3.1.0",
7171+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
7172+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
7173+ "license": "ISC",
7174+ "dependencies": {
7175+ "d3-array": "2 - 3"
7176+ },
7177+ "engines": {
7178+ "node": ">=12"
7179+ }
7180+ },
7181+ "node_modules/d3-time-format": {
7182+ "version": "4.1.0",
7183+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
7184+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
7185+ "license": "ISC",
7186+ "dependencies": {
7187+ "d3-time": "1 - 3"
7188+ },
7189+ "engines": {
7190+ "node": ">=12"
7191+ }
7192+ },
7193+ "node_modules/d3-timer": {
7194+ "version": "3.0.1",
7195+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
7196+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
7197+ "license": "ISC",
7198+ "engines": {
7199+ "node": ">=12"
7200+ }
7201+ },
7202+ "node_modules/d3-transition": {
7203+ "version": "3.0.1",
7204+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
7205+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
7206+ "license": "ISC",
7207+ "dependencies": {
7208+ "d3-color": "1 - 3",
7209+ "d3-dispatch": "1 - 3",
7210+ "d3-ease": "1 - 3",
7211+ "d3-interpolate": "1 - 3",
7212+ "d3-timer": "1 - 3"
7213+ },
7214+ "engines": {
7215+ "node": ">=12"
7216+ },
7217+ "peerDependencies": {
7218+ "d3-selection": "2 - 3"
7219+ }
7220+ },
7221+ "node_modules/d3-zoom": {
7222+ "version": "3.0.0",
7223+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
7224+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
7225+ "license": "ISC",
7226+ "dependencies": {
7227+ "d3-dispatch": "1 - 3",
7228+ "d3-drag": "2 - 3",
7229+ "d3-interpolate": "1 - 3",
7230+ "d3-selection": "2 - 3",
7231+ "d3-transition": "2 - 3"
7232+ },
7233+ "engines": {
7234+ "node": ">=12"
7235+ }
7236+ },
7237+ "node_modules/dagre-d3-es": {
7238+ "version": "7.0.14",
7239+ "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
7240+ "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==",
7241+ "license": "MIT",
7242+ "dependencies": {
7243+ "d3": "^7.9.0",
7244+ "lodash-es": "^4.17.21"
7245+ }
7246+ },
58627247 "node_modules/data-uri-to-buffer": {
58637248 "version": "6.0.2",
58647249 "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
58687253 "node": ">= 14"
58697254 }
58707255 },
7256+ "node_modules/dayjs": {
7257+ "version": "1.11.23",
7258+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
7259+ "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
7260+ "license": "MIT"
7261+ },
58717262 "node_modules/debug": {
58727263 "version": "4.4.3",
58737264 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
59337324 "node": ">= 14"
59347325 }
59357326 },
7327+ "node_modules/delaunator": {
7328+ "version": "5.1.0",
7329+ "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
7330+ "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
7331+ "license": "ISC",
7332+ "dependencies": {
7333+ "robust-predicates": "^3.0.2"
7334+ }
7335+ },
59367336 "node_modules/dequal": {
59377337 "version": "2.0.3",
59387338 "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
60517451 "url": "https://github.com/fb55/domhandler?sponsor=1"
60527452 }
60537453 },
7454+ "node_modules/dompurify": {
7455+ "version": "3.4.16",
7456+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
7457+ "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
7458+ "license": "(MPL-2.0 OR Apache-2.0)",
7459+ "optionalDependencies": {
7460+ "@types/trusted-types": "^2.0.7"
7461+ }
7462+ },
60547463 "node_modules/domutils": {
60557464 "version": "3.2.2",
60567465 "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
60817490 "dev": true,
60827491 "license": "ISC"
60837492 },
7493+ "node_modules/elkjs": {
7494+ "version": "0.9.3",
7495+ "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz",
7496+ "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==",
7497+ "license": "EPL-2.0"
7498+ },
7499+ "node_modules/emoji-mart": {
7500+ "version": "5.6.0",
7501+ "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
7502+ "integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
7503+ "license": "MIT"
7504+ },
60847505 "node_modules/emoji-regex": {
60857506 "version": "8.0.0",
60867507 "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
61477568 "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
61487569 "license": "MIT"
61497570 },
7571+ "node_modules/es-toolkit": {
7572+ "version": "1.52.0",
7573+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
7574+ "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
7575+ "license": "MIT",
7576+ "workspaces": [
7577+ "docs",
7578+ "benchmarks",
7579+ "tests/types",
7580+ "tests/browser-compat"
7581+ ]
7582+ },
61507583 "node_modules/esbuild": {
61517584 "version": "0.28.1",
61527585 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
63507783 "@types/yauzl": "^2.9.1"
63517784 }
63527785 },
7786+ "node_modules/fast-deep-equal": {
7787+ "version": "3.1.3",
7788+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
7789+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
7790+ "license": "MIT"
7791+ },
7792+ "node_modules/fast-equals": {
7793+ "version": "5.4.4",
7794+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.4.tgz",
7795+ "integrity": "sha512-d9/I4rfMfUlO9HOkQqLJgfyRM4siqk1SF3Gjxm4OAMZzV+kCKHe1t/bHxn3xtCPHs3HCQFcvqzkTuanzqECQxw==",
7796+ "license": "MIT",
7797+ "engines": {
7798+ "node": ">=6.0.0"
7799+ }
7800+ },
63537801 "node_modules/fast-fifo": {
63547802 "version": "1.3.2",
63557803 "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
65738021 "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==",
65748022 "license": "MIT"
65758023 },
8024+ "node_modules/hachure-fill": {
8025+ "version": "0.5.2",
8026+ "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz",
8027+ "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==",
8028+ "license": "MIT"
8029+ },
65768030 "node_modules/hast-util-embedded": {
65778031 "version": "3.0.0",
65788032 "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz",
70158469 }
70168470 }
70178471 },
8472+ "node_modules/iconv-lite": {
8473+ "version": "0.6.3",
8474+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
8475+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
8476+ "license": "MIT",
8477+ "dependencies": {
8478+ "safer-buffer": ">= 2.1.2 < 3.0.0"
8479+ },
8480+ "engines": {
8481+ "node": ">=0.10.0"
8482+ }
8483+ },
70188484 "node_modules/ieee754": {
70198485 "version": "1.2.1",
70208486 "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
70358501 ],
70368502 "license": "BSD-3-Clause"
70378503 },
8504+ "node_modules/import-meta-resolve": {
8505+ "version": "4.2.0",
8506+ "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
8507+ "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
8508+ "license": "MIT",
8509+ "funding": {
8510+ "type": "github",
8511+ "url": "https://github.com/sponsors/wooorm"
8512+ }
8513+ },
70388514 "node_modules/inline-style-parser": {
70398515 "version": "0.2.7",
70408516 "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
70418517 "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
70428518 "license": "MIT"
70438519 },
8520+ "node_modules/internmap": {
8521+ "version": "2.0.3",
8522+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
8523+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
8524+ "license": "ISC",
8525+ "engines": {
8526+ "node": ">=12"
8527+ }
8528+ },
70448529 "node_modules/ip-address": {
70458530 "version": "10.7.3",
70468531 "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz",
71488633 "node": ">=18"
71498634 }
71508635 },
8636+ "node_modules/isomorphic.js": {
8637+ "version": "0.2.5",
8638+ "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
8639+ "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
8640+ "license": "MIT",
8641+ "funding": {
8642+ "type": "GitHub Sponsors ❤",
8643+ "url": "https://github.com/sponsors/dmonad"
8644+ }
8645+ },
71518646 "node_modules/jiti": {
71528647 "version": "2.7.0",
71538648 "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
72198714 "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
72208715 "license": "MIT"
72218716 },
8717+ "node_modules/katex": {
8718+ "version": "0.19.0",
8719+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.19.0.tgz",
8720+ "integrity": "sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q==",
8721+ "funding": [
8722+ "https://opencollective.com/katex",
8723+ "https://github.com/sponsors/katex"
8724+ ],
8725+ "license": "MIT",
8726+ "dependencies": {
8727+ "commander": "^15.0.0"
8728+ },
8729+ "bin": {
8730+ "katex": "cli.js"
8731+ }
8732+ },
8733+ "node_modules/katex/node_modules/commander": {
8734+ "version": "15.0.0",
8735+ "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
8736+ "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
8737+ "license": "MIT",
8738+ "engines": {
8739+ "node": ">=22.12.0"
8740+ }
8741+ },
8742+ "node_modules/khroma": {
8743+ "version": "2.1.0",
8744+ "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
8745+ "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
8746+ },
72228747 "node_modules/kleur": {
72238748 "version": "4.1.5",
72248749 "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
72388763 "node": ">= 8"
72398764 }
72408765 },
8766+ "node_modules/layout-base": {
8767+ "version": "1.0.2",
8768+ "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
8769+ "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==",
8770+ "license": "MIT"
8771+ },
8772+ "node_modules/lib0": {
8773+ "version": "0.2.119",
8774+ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.119.tgz",
8775+ "integrity": "sha512-vh+TiejVy8Xm+hCdnRfn9BthGNvNUZdEXltNjVZL3TzRIKP3s1lnQh8pWd/Q0wCgY0FPAGVVjFF4ZiHUV3LFgA==",
8776+ "license": "MIT",
8777+ "dependencies": {
8778+ "isomorphic.js": "^0.2.4"
8779+ },
8780+ "bin": {
8781+ "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
8782+ "0gentesthtml": "bin/gentesthtml.js",
8783+ "0serve": "bin/0serve.js"
8784+ },
8785+ "engines": {
8786+ "node": ">=16"
8787+ },
8788+ "funding": {
8789+ "type": "GitHub Sponsors ❤",
8790+ "url": "https://github.com/sponsors/dmonad"
8791+ }
8792+ },
72418793 "node_modules/lightningcss": {
72428794 "version": "1.32.0",
72438795 "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
75289080 "dev": true,
75299081 "license": "MIT"
75309082 },
9083+ "node_modules/lodash-es": {
9084+ "version": "4.18.1",
9085+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
9086+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
9087+ "license": "MIT"
9088+ },
75319089 "node_modules/longest-streak": {
75329090 "version": "3.1.0",
75339091 "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
75949152 "url": "https://github.com/sponsors/wooorm"
75959153 }
75969154 },
9155+ "node_modules/marked": {
9156+ "version": "16.4.2",
9157+ "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
9158+ "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
9159+ "license": "MIT",
9160+ "bin": {
9161+ "marked": "bin/marked.js"
9162+ },
9163+ "engines": {
9164+ "node": ">= 20"
9165+ }
9166+ },
75979167 "node_modules/mdast-util-directive": {
75989168 "version": "3.1.0",
75999169 "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz",
78939463 "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
78949464 "license": "CC0-1.0"
78959465 },
9466+ "node_modules/mermaid": {
9467+ "version": "12.1.0",
9468+ "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-12.1.0.tgz",
9469+ "integrity": "sha512-wlVCp+8eTupfCeeFvoZNNiTuHrvag0P2jz/ILgb/f/6jkVokUefOcujefi8qUe/j2asHiSePncVsz/xzzA80LQ==",
9470+ "license": "MIT",
9471+ "dependencies": {
9472+ "@braintree/sanitize-url": "^7.1.2",
9473+ "@iconify/utils": "^3.0.2",
9474+ "@mermaid-js/parser": "^2.0.1",
9475+ "@types/d3": "^7.4.3",
9476+ "@upsetjs/venn.js": "^2.0.0",
9477+ "chevrotain": "~13.2.0",
9478+ "cytoscape": "^3.34.0",
9479+ "cytoscape-cose-bilkent": "^4.1.0",
9480+ "cytoscape-fcose": "^2.2.0",
9481+ "d3": "^7.9.0",
9482+ "d3-sankey": "^0.12.3",
9483+ "dagre-d3-es": "7.0.14",
9484+ "dayjs": "^1.11.21",
9485+ "dompurify": "^3.4.12",
9486+ "elkjs": "^0.9.3",
9487+ "es-toolkit": "^1.45.1",
9488+ "katex": "^0.16.47",
9489+ "khroma": "^2.1.0",
9490+ "marked": "^16.3.0",
9491+ "roughjs": "^4.6.6",
9492+ "stylis": "^4.3.6",
9493+ "ts-dedent": "^2.2.0",
9494+ "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
9495+ },
9496+ "engines": {
9497+ "node": ">=22.12.0"
9498+ }
9499+ },
9500+ "node_modules/mermaid/node_modules/commander": {
9501+ "version": "8.3.0",
9502+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
9503+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
9504+ "license": "MIT",
9505+ "engines": {
9506+ "node": ">= 12"
9507+ }
9508+ },
9509+ "node_modules/mermaid/node_modules/katex": {
9510+ "version": "0.16.47",
9511+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
9512+ "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
9513+ "funding": [
9514+ "https://opencollective.com/katex",
9515+ "https://github.com/sponsors/katex"
9516+ ],
9517+ "license": "MIT",
9518+ "dependencies": {
9519+ "commander": "^8.3.0"
9520+ },
9521+ "bin": {
9522+ "katex": "cli.js"
9523+ }
9524+ },
78969525 "node_modules/micromark": {
78979526 "version": "4.0.3",
78989527 "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz",
867710306 "regex-recursion": "^6.0.2"
867810307 }
867910308 },
10309+ "node_modules/orderedmap": {
10310+ "version": "2.1.1",
10311+ "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
10312+ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
10313+ "license": "MIT"
10314+ },
868010315 "node_modules/p-limit": {
868110316 "version": "7.3.3",
868210317 "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.3.tgz",
885410489 "url": "https://github.com/fb55/entities?sponsor=1"
885510490 }
885610491 },
10492+ "node_modules/path-data-parser": {
10493+ "version": "0.1.0",
10494+ "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz",
10495+ "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==",
10496+ "license": "MIT"
10497+ },
885710498 "node_modules/path-to-regexp": {
885810499 "version": "6.3.0",
885910500 "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
891010551 "pathe": "^2.0.3"
891110552 }
891210553 },
10554+ "node_modules/points-on-curve": {
10555+ "version": "0.2.0",
10556+ "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
10557+ "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==",
10558+ "license": "MIT"
10559+ },
10560+ "node_modules/points-on-path": {
10561+ "version": "0.2.1",
10562+ "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz",
10563+ "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==",
10564+ "license": "MIT",
10565+ "dependencies": {
10566+ "path-data-parser": "0.1.0",
10567+ "points-on-curve": "0.2.0"
10568+ }
10569+ },
891310570 "node_modules/postcss": {
891410571 "version": "8.5.28",
891510572 "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
901910676 "url": "https://github.com/sponsors/wooorm"
902010677 }
902110678 },
10679+ "node_modules/prosemirror-changeset": {
10680+ "version": "2.4.4",
10681+ "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz",
10682+ "integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==",
10683+ "license": "MIT",
10684+ "dependencies": {
10685+ "prosemirror-transform": "^1.0.0"
10686+ }
10687+ },
10688+ "node_modules/prosemirror-commands": {
10689+ "version": "1.7.2",
10690+ "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
10691+ "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
10692+ "license": "MIT",
10693+ "dependencies": {
10694+ "prosemirror-model": "^1.0.0",
10695+ "prosemirror-state": "^1.0.0",
10696+ "prosemirror-transform": "^1.10.2"
10697+ }
10698+ },
10699+ "node_modules/prosemirror-dropcursor": {
10700+ "version": "1.8.4",
10701+ "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz",
10702+ "integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==",
10703+ "license": "MIT",
10704+ "dependencies": {
10705+ "prosemirror-state": "^1.0.0",
10706+ "prosemirror-transform": "^1.1.0",
10707+ "prosemirror-view": "^1.1.0"
10708+ }
10709+ },
10710+ "node_modules/prosemirror-gapcursor": {
10711+ "version": "1.4.1",
10712+ "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
10713+ "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
10714+ "license": "MIT",
10715+ "dependencies": {
10716+ "prosemirror-keymap": "^1.0.0",
10717+ "prosemirror-model": "^1.0.0",
10718+ "prosemirror-state": "^1.0.0",
10719+ "prosemirror-view": "^1.0.0"
10720+ }
10721+ },
10722+ "node_modules/prosemirror-highlight": {
10723+ "version": "0.15.3",
10724+ "resolved": "https://registry.npmjs.org/prosemirror-highlight/-/prosemirror-highlight-0.15.3.tgz",
10725+ "integrity": "sha512-WVV2st0fX1w2TkAgmTmdbj77BlWYuLfW4BGXPo8JfIWsSly5xYcfID8QJQL+GT8kisMRuu4jgT6JAqj1DOwvvg==",
10726+ "license": "MIT",
10727+ "funding": {
10728+ "url": "https://github.com/sponsors/ocavue"
10729+ },
10730+ "peerDependencies": {
10731+ "@lezer/common": "^1.0.0",
10732+ "@lezer/highlight": "^1.0.0",
10733+ "@shikijs/types": "^1.29.2 || ^2.0.0 || ^3.0.0 || ^4.0.0",
10734+ "@types/hast": "^3.0.0",
10735+ "highlight.js": "^11.9.0",
10736+ "lowlight": "^3.1.0",
10737+ "prosemirror-model": "^1.19.3",
10738+ "prosemirror-state": "^1.4.3",
10739+ "prosemirror-transform": "^1.8.0",
10740+ "prosemirror-view": "^1.32.4",
10741+ "refractor": "^5.0.0",
10742+ "sugar-high": "^0.6.1 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^1.0.0"
10743+ },
10744+ "peerDependenciesMeta": {
10745+ "@lezer/common": {
10746+ "optional": true
10747+ },
10748+ "@lezer/highlight": {
10749+ "optional": true
10750+ },
10751+ "@shikijs/types": {
10752+ "optional": true
10753+ },
10754+ "@types/hast": {
10755+ "optional": true
10756+ },
10757+ "highlight.js": {
10758+ "optional": true
10759+ },
10760+ "lowlight": {
10761+ "optional": true
10762+ },
10763+ "prosemirror-model": {
10764+ "optional": true
10765+ },
10766+ "prosemirror-state": {
10767+ "optional": true
10768+ },
10769+ "prosemirror-transform": {
10770+ "optional": true
10771+ },
10772+ "prosemirror-view": {
10773+ "optional": true
10774+ },
10775+ "refractor": {
10776+ "optional": true
10777+ },
10778+ "sugar-high": {
10779+ "optional": true
10780+ }
10781+ }
10782+ },
10783+ "node_modules/prosemirror-history": {
10784+ "version": "1.5.1",
10785+ "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz",
10786+ "integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==",
10787+ "license": "MIT",
10788+ "dependencies": {
10789+ "prosemirror-state": "^1.2.2",
10790+ "prosemirror-transform": "^1.0.0",
10791+ "prosemirror-view": "^1.31.0",
10792+ "rope-sequence": "^1.3.0"
10793+ }
10794+ },
10795+ "node_modules/prosemirror-inputrules": {
10796+ "version": "1.5.1",
10797+ "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
10798+ "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
10799+ "license": "MIT",
10800+ "dependencies": {
10801+ "prosemirror-state": "^1.0.0",
10802+ "prosemirror-transform": "^1.0.0"
10803+ }
10804+ },
10805+ "node_modules/prosemirror-keymap": {
10806+ "version": "1.2.3",
10807+ "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
10808+ "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
10809+ "license": "MIT",
10810+ "dependencies": {
10811+ "prosemirror-state": "^1.0.0",
10812+ "w3c-keyname": "^2.2.0"
10813+ }
10814+ },
10815+ "node_modules/prosemirror-model": {
10816+ "version": "1.25.12",
10817+ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
10818+ "integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
10819+ "license": "MIT",
10820+ "dependencies": {
10821+ "orderedmap": "^2.0.0"
10822+ }
10823+ },
10824+ "node_modules/prosemirror-schema-list": {
10825+ "version": "1.5.1",
10826+ "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
10827+ "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
10828+ "license": "MIT",
10829+ "dependencies": {
10830+ "prosemirror-model": "^1.0.0",
10831+ "prosemirror-state": "^1.0.0",
10832+ "prosemirror-transform": "^1.7.3"
10833+ }
10834+ },
10835+ "node_modules/prosemirror-state": {
10836+ "version": "1.4.4",
10837+ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
10838+ "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
10839+ "license": "MIT",
10840+ "dependencies": {
10841+ "prosemirror-model": "^1.0.0",
10842+ "prosemirror-transform": "^1.0.0",
10843+ "prosemirror-view": "^1.27.0"
10844+ }
10845+ },
10846+ "node_modules/prosemirror-tables": {
10847+ "version": "1.8.5",
10848+ "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
10849+ "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
10850+ "license": "MIT",
10851+ "dependencies": {
10852+ "prosemirror-keymap": "^1.2.3",
10853+ "prosemirror-model": "^1.25.4",
10854+ "prosemirror-state": "^1.4.4",
10855+ "prosemirror-transform": "^1.10.5",
10856+ "prosemirror-view": "^1.41.4"
10857+ }
10858+ },
10859+ "node_modules/prosemirror-transform": {
10860+ "version": "1.12.2",
10861+ "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz",
10862+ "integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==",
10863+ "license": "MIT",
10864+ "dependencies": {
10865+ "prosemirror-model": "^1.21.0"
10866+ }
10867+ },
10868+ "node_modules/prosemirror-view": {
10869+ "version": "1.42.6",
10870+ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz",
10871+ "integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==",
10872+ "license": "MIT",
10873+ "dependencies": {
10874+ "prosemirror-model": "^1.25.12",
10875+ "prosemirror-state": "^1.0.0",
10876+ "prosemirror-transform": "^1.1.0"
10877+ }
10878+ },
902210879 "node_modules/proxy-agent": {
902310880 "version": "6.5.0",
902410881 "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",
946111318 "node": ">=0.10.0"
946211319 }
946311320 },
11321+ "node_modules/reselect": {
11322+ "version": "5.3.0",
11323+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz",
11324+ "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==",
11325+ "license": "MIT"
11326+ },
946411327 "node_modules/resolve-pkg-maps": {
946511328 "version": "1.0.0",
946611329 "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
948511348 "url": "https://opencollective.com/unified"
948611349 }
948711350 },
11351+ "node_modules/robust-predicates": {
11352+ "version": "3.0.3",
11353+ "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
11354+ "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
11355+ "license": "Unlicense"
11356+ },
948811357 "node_modules/rolldown": {
948911358 "version": "1.2.12",
949011359 "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
951811387 "@rolldown/binding-win32-x64-msvc": "1.2.12"
951911388 }
952011389 },
11390+ "node_modules/rope-sequence": {
11391+ "version": "1.3.4",
11392+ "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
11393+ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
11394+ "license": "MIT"
11395+ },
11396+ "node_modules/roughjs": {
11397+ "version": "4.6.6",
11398+ "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz",
11399+ "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==",
11400+ "license": "MIT",
11401+ "dependencies": {
11402+ "hachure-fill": "^0.5.2",
11403+ "path-data-parser": "^0.1.0",
11404+ "points-on-curve": "^0.2.0",
11405+ "points-on-path": "^0.2.1"
11406+ }
11407+ },
11408+ "node_modules/rw": {
11409+ "version": "1.3.3",
11410+ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
11411+ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
11412+ "license": "BSD-3-Clause"
11413+ },
11414+ "node_modules/safer-buffer": {
11415+ "version": "2.1.2",
11416+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
11417+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
11418+ "license": "MIT"
11419+ },
952111420 "node_modules/satteri": {
952211421 "version": "0.10.5",
952311422 "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz",
984911748 "inline-style-parser": "0.2.7"
985011749 }
985111750 },
11751+ "node_modules/stylis": {
11752+ "version": "4.4.0",
11753+ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
11754+ "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
11755+ "license": "MIT"
11756+ },
985211757 "node_modules/supports-color": {
985311758 "version": "10.2.2",
985411759 "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
988711792 "url": "https://opencollective.com/svgo"
988811793 }
988911794 },
11795+ "node_modules/tabbable": {
11796+ "version": "6.5.0",
11797+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
11798+ "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
11799+ "license": "MIT"
11800+ },
989011801 "node_modules/tailwind-merge": {
989111802 "version": "3.7.0",
989211803 "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz",
990111812 "version": "4.3.3",
990211813 "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
990311814 "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
9904− "dev": true,
990511815 "license": "MIT"
990611816 },
990711817 "node_modules/tapable": {
1002811938 "url": "https://github.com/sponsors/wooorm"
1002911939 }
1003011940 },
11941+ "node_modules/ts-dedent": {
11942+ "version": "2.3.0",
11943+ "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz",
11944+ "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==",
11945+ "license": "MIT",
11946+ "engines": {
11947+ "node": ">=6.10"
11948+ }
11949+ },
1003111950 "node_modules/tslib": {
1003211951 "version": "2.8.1",
1003311952 "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
1043012349 }
1043112350 }
1043212351 },
12352+ "node_modules/use-sync-external-store": {
12353+ "version": "1.6.0",
12354+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
12355+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
12356+ "license": "MIT",
12357+ "peerDependencies": {
12358+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
12359+ }
12360+ },
1043312361 "node_modules/util-deprecate": {
1043412362 "version": "1.0.2",
1043512363 "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
1043612364 "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
1043712365 "license": "MIT"
1043812366 },
12367+ "node_modules/uuid": {
12368+ "version": "14.0.2",
12369+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz",
12370+ "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
12371+ "funding": [
12372+ "https://github.com/sponsors/broofa",
12373+ "https://github.com/sponsors/ctavan"
12374+ ],
12375+ "license": "MIT",
12376+ "bin": {
12377+ "uuid": "dist-node/bin/uuid"
12378+ }
12379+ },
1043912380 "node_modules/valibot": {
1044012381 "version": "1.5.0",
1044112382 "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.5.0.tgz",
1086212803 }
1086312804 }
1086412805 },
12806+ "node_modules/w3c-keyname": {
12807+ "version": "2.2.8",
12808+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
12809+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
12810+ "license": "MIT"
12811+ },
1086512812 "node_modules/web-namespaces": {
1086612813 "version": "2.0.1",
1086712814 "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
1097912926 "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==",
1098012927 "license": "MIT"
1098112928 },
12929+ "node_modules/y-prosemirror": {
12930+ "version": "1.3.7",
12931+ "resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz",
12932+ "integrity": "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==",
12933+ "license": "MIT",
12934+ "dependencies": {
12935+ "lib0": "^0.2.109"
12936+ },
12937+ "engines": {
12938+ "node": ">=16.0.0",
12939+ "npm": ">=8.0.0"
12940+ },
12941+ "funding": {
12942+ "type": "GitHub Sponsors ❤",
12943+ "url": "https://github.com/sponsors/dmonad"
12944+ },
12945+ "peerDependencies": {
12946+ "prosemirror-model": "^1.7.1",
12947+ "prosemirror-state": "^1.2.3",
12948+ "prosemirror-view": "^1.9.10",
12949+ "y-protocols": "^1.0.1",
12950+ "yjs": "^13.5.38"
12951+ }
12952+ },
12953+ "node_modules/y-protocols": {
12954+ "version": "1.0.7",
12955+ "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
12956+ "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
12957+ "license": "MIT",
12958+ "dependencies": {
12959+ "lib0": "^0.2.85"
12960+ },
12961+ "engines": {
12962+ "node": ">=16.0.0",
12963+ "npm": ">=8.0.0"
12964+ },
12965+ "funding": {
12966+ "type": "GitHub Sponsors ❤",
12967+ "url": "https://github.com/sponsors/dmonad"
12968+ },
12969+ "peerDependencies": {
12970+ "yjs": "^13.0.0"
12971+ }
12972+ },
1098212973 "node_modules/y18n": {
1098312974 "version": "5.0.8",
1098412975 "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
1105613047 "fd-slicer": "~1.1.0"
1105713048 }
1105813049 },
13050+ "node_modules/yjs": {
13051+ "version": "13.6.33",
13052+ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.33.tgz",
13053+ "integrity": "sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==",
13054+ "license": "MIT",
13055+ "dependencies": {
13056+ "lib0": "^0.2.99"
13057+ },
13058+ "engines": {
13059+ "node": ">=16.0.0",
13060+ "npm": ">=8.0.0"
13061+ },
13062+ "funding": {
13063+ "type": "GitHub Sponsors ❤",
13064+ "url": "https://github.com/sponsors/dmonad"
13065+ }
13066+ },
1105913067 "node_modules/yocto-queue": {
1106013068 "version": "1.2.2",
1106113069 "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
1116113169 "@g1t/contracts": "*"
1116213170 }
1116313171 },
13172+ "services/docs": {
13173+ "name": "@g1t/docs-service",
13174+ "version": "0.1.0",
13175+ "license": "MIT",
13176+ "dependencies": {
13177+ "@g1t/contracts": "*",
13178+ "lib0": "^0.2.117",
13179+ "y-protocols": "^1.0.7",
13180+ "yjs": "^13.6.33"
13181+ }
13182+ },
1116413183 "services/models": {
1116513184 "name": "@g1t/models",
1116613185 "version": "0.1.0",
+548−0
1+/**
2+ * Docs: the workspace's written knowledge, kept by the docs service
3+ * (`services/docs`). Spaces hold trees of pages; each page is a CRDT
4+ * document (Yjs) edited live over a socket, saved with a Markdown
5+ * rendition that search, agents, export and the read view use. Plan and
6+ * decisions: docs/WORKSPACE.md, "Docs".
7+ *
8+ * Wire shapes are snake_case end to end: the site, agents and the live
9+ * socket all carry the same objects.
10+ *
11+ * Access, in one place:
12+ *
13+ * - A space has a kind: `workspace` (every member gets `default_role`),
14+ * `team` (the team's members get `default_role`) or `private` (only the
15+ * people, agents and teams listed as its members).
16+ * - Members are listed with a role: view < comment < edit < manage. A
17+ * person's role is the highest of the space's base role (when it applies
18+ * to them) and every listing that names them or one of their teams.
19+ * Workspace owners manage every workspace and team space; a private space
20+ * is its members' alone.
21+ * - An agent never sees or changes more than the person it acts for (the
22+ * `viewer` on every agent call). It reads what that person can read,
23+ * narrowed further to what every person in the `audience` can read; it
24+ * suggests where that person can comment; and it edits directly only
25+ * where that person can edit AND the space lets agents edit
26+ * (`agent_mode: "edit"`). Otherwise its edit becomes a suggestion.
27+ */
28+import type { MemberProfile, Principal } from "./chat";
29+import type { ServiceBinding } from "./clients";
30+import type { User } from "./identity";
31+import type { Result } from "./result";
32+
33+/** What someone may do in a space, weakest first. */
34+export type DocRole = "view" | "comment" | "edit" | "manage";
35+export const DOC_ROLES: readonly DocRole[] = ["view", "comment", "edit", "manage"];
36+
37+export const DOC_ROLE_LABELS: Record<DocRole, string> = {
38+ view: "Can view",
39+ comment: "Can comment",
40+ edit: "Can edit",
41+ manage: "Full access",
42+};
43+
44+export const DOC_ROLE_SUMMARIES: Record<DocRole, string> = {
45+ view: "Read pages and their history.",
46+ comment: "Read pages and comment on them.",
47+ edit: "Write and organize pages, and accept or reject suggestions.",
48+ manage: "Edit, and change who has access and how agents work here.",
49+};
50+
51+/** Who a space is for. */
52+export type DocSpaceKind = "workspace" | "team" | "private";
53+
54+export const DOC_SPACE_KIND_LABELS: Record<DocSpaceKind, string> = {
55+ workspace: "Everyone in the workspace",
56+ team: "A team",
57+ private: "Only its members",
58+};
59+
60+/** How agents change pages in a space: as tracked suggestions (the default), or directly. */
61+export type DocAgentMode = "suggest" | "edit";
62+
63+export const DOC_AGENT_MODE_LABELS: Record<DocAgentMode, string> = {
64+ suggest: "Suggest changes",
65+ edit: "Edit directly",
66+};
67+
68+/** A key for a space member: `user:<id>`, `agent:<id>` or `team:<slug>`. */
69+export type DocMemberKey = string;
70+
71+export type DocSpace = {
72+ id: string;
73+ workspace_id: string;
74+ /** Unique in the workspace; in the URL: `/<workspace>/-/docs/<slug>`. */
75+ slug: string;
76+ name: string;
77+ description: string | null;
78+ /** An emoji, or null for the default book. */
79+ icon: string | null;
80+ kind: DocSpaceKind;
81+ /** The team's slug, for a team space. */
82+ team: string | null;
83+ /** What every workspace member (workspace) or team member (team) gets; null for a private space. */
84+ default_role: DocRole | null;
85+ agent_mode: DocAgentMode;
86+ /** The workspace's General space, made the first time Docs is opened. Can't be archived. */
87+ is_default: boolean;
88+ /** Projects (repositories, `owner/name`) the space is about: Docs filters by them. */
89+ projects: string[];
90+ created_by: Principal;
91+ created_at: string;
92+ archived_at: string | null;
93+ /** The viewer's role in it. */
94+ viewer_role: DocRole;
95+ /** Pages in it that are not in the trash. */
96+ page_count: number;
97+};
98+
99+/** A space member, as the space's settings show it. */
100+export type DocSpaceMember = {
101+ key: DocMemberKey;
102+ kind: "user" | "agent" | "team";
103+ /** Username, agent handle or team slug. */
104+ name: string;
105+ display_name: string;
106+ avatar: string | null;
107+ avatar_seed?: string | null;
108+ role: DocRole;
109+};
110+
111+/** Enough to link to a page. */
112+export type DocPageRef = {
113+ id: string;
114+ space_id: string;
115+ space_slug: string;
116+ title: string;
117+ /** An emoji, or null for the default page icon. */
118+ icon: string | null;
119+ /** `<title-slug>-<id>`: the last part of the page's URL. */
120+ slug: string;
121+ /** `/<workspace>/-/docs/<space>/<slug>`. */
122+ path: string;
123+};
124+
125+export type DocPage = DocPageRef & {
126+ parent_id: string | null;
127+ position: number;
128+ /** A cover image's URL, or a CSS gradient name (`gradient:<n>`); null for none. */
129+ cover: string | null;
130+ created_by: MemberProfile;
131+ created_at: string;
132+ updated_by: MemberProfile | null;
133+ updated_at: string;
134+ /** When it went to the trash; null when it is not there. */
135+ archived_at: string | null;
136+ has_children: boolean;
137+ /** Projects (`owner/name`) the page is about, besides its space's. */
138+ projects: string[];
139+ owners: MemberProfile[];
140+ /** The first lines of its text, for cards. */
141+ excerpt: string;
142+};
143+
144+/** A page as the sidebar's tree lists it: flat, ordered by `position` within each parent. */
145+export type DocTreeNode = {
146+ id: string;
147+ parent_id: string | null;
148+ position: number;
149+ title: string;
150+ icon: string | null;
151+ slug: string;
152+};
153+
154+export type DocsSidebarSpace = DocSpace & { pages: DocTreeNode[] };
155+
156+export type DocsSidebar = {
157+ spaces: DocsSidebarSpace[];
158+ favorites: DocPageRef[];
159+ /** The pages the viewer opened last, newest first. */
160+ recent: DocPageRef[];
161+ /** Whether the viewer may make spaces (members of the workspace may). */
162+ can_create_space: boolean;
163+ trash_count: number;
164+};
165+
166+export type DocsHome = {
167+ /** Recently edited pages the viewer can read, newest first. */
168+ recent: DocPage[];
169+ /** Pages the viewer made or owns. */
170+ mine: DocPage[];
171+ spaces: DocSpace[];
172+ /** Every project some space or page is linked to, for the filter. */
173+ projects: string[];
174+ /** The project the lists are filtered to, or null. */
175+ project: string | null;
176+};
177+
178+/** A tracked change an agent proposed. `blocks` targets name top-level block ids from `page_markdown`. */
179+export type DocEditTarget =
180+ /** Add to the end of the page. */
181+ | { kind: "append" }
182+ /** Replace the whole page. */
183+ | { kind: "document" }
184+ /** Replace a section: the heading whose text matches (case-insensitive) and everything under it, up to the next heading of the same or a higher level. The new Markdown should include the heading if it is to stay. */
185+ | { kind: "section"; heading: string }
186+ /** Replace top-level blocks `from_block` through `to_block`, inclusive (with any blocks nested under them). */
187+ | { kind: "blocks"; from_block: string; to_block: string };
188+
189+export type DocSuggestionStatus = "open" | "accepted" | "rejected" | "stale";
190+
191+export type DocSuggestion = {
192+ id: string;
193+ page_id: string;
194+ /** The agent that suggested it. */
195+ author: MemberProfile;
196+ /** The person it acted for. */
197+ asked_by: MemberProfile | null;
198+ target: DocEditTarget;
199+ /** The target's Markdown when it was suggested. */
200+ before_markdown: string;
201+ /** What it proposes instead. */
202+ after_markdown: string;
203+ /** Why, in a line. */
204+ note: string | null;
205+ status: DocSuggestionStatus;
206+ created_at: string;
207+ decided_by: MemberProfile | null;
208+ decided_at: string | null;
209+ /** The top-level blocks it covers now, so the editor can mark them; empty for an append or when the target is gone. */
210+ block_ids: string[];
211+};
212+
213+export type DocVersionKind = "created" | "edit" | "agent" | "suggestion" | "restore";
214+
215+export type DocVersion = {
216+ id: string;
217+ page_id: string;
218+ created_at: string;
219+ /** Everyone whose changes are in it, people and agents. */
220+ authors: MemberProfile[];
221+ kind: DocVersionKind;
222+ /** "Suggested by @inky, accepted by @ana"; "Restored from Oct 3, 14:02". */
223+ note: string | null;
224+};
225+
226+export type DocDiffLine = { op: "same" | "add" | "del"; text: string };
227+
228+export type DocVersionDetail = DocVersion & {
229+ markdown: string;
230+ /** Against the version before it (or nothing, for the first). */
231+ diff: DocDiffLine[];
232+};
233+
234+export type DocPageDetail = {
235+ page: DocPage;
236+ space: DocSpace;
237+ /** The page's ancestors, root first. */
238+ breadcrumbs: DocPageRef[];
239+ /** As last saved: the read view, and what the editor shows before the socket connects. */
240+ markdown: string;
241+ role: DocRole;
242+ backlinks: DocPageRef[];
243+ children: DocPageRef[];
244+ favorite: boolean;
245+ /** When the viewer last opened it, before now. */
246+ last_viewed_at: string | null;
247+ suggestions: DocSuggestion[];
248+};
249+
250+export type DocSearchHit = DocPageRef & {
251+ space_name: string;
252+ /** Text around the match; `[[` and `]]` mark matched words. */
253+ snippet: string;
254+ updated_at: string;
255+ projects: string[];
256+};
257+
258+export type DocTemplate = {
259+ id: string;
260+ name: string;
261+ description: string;
262+ icon: string;
263+ /** Built in (meeting notes, RFC, ...) rather than saved by the workspace. */
264+ builtin: boolean;
265+ markdown: string;
266+ created_by: Principal | null;
267+};
268+
269+/** One top-level block, as agents see a page. */
270+export type DocBlockOutline = {
271+ id: string;
272+ /** BlockNote's type: `heading`, `paragraph`, `bulletListItem`, `codeBlock`, `callout`, ... */
273+ type: string;
274+ /** For a heading. */
275+ level: number | null;
276+ /** The block and anything nested under it. */
277+ markdown: string;
278+};
279+
280+/** What an agent may do on a page, for the person it acts for. */
281+export type DocAgentAbilities = { read: boolean; suggest: boolean; edit: boolean };
282+
283+export type DocAgentPage = {
284+ page: DocPageRef & { updated_at: string };
285+ space: { id: string; slug: string; name: string; agent_mode: DocAgentMode };
286+ markdown: string;
287+ blocks: DocBlockOutline[];
288+ can: DocAgentAbilities;
289+};
290+
291+/** Who will see what an agent says; it reads only what they all can. */
292+export type DocAudience =
293+ /** These people (by user id), e.g. a DM's or a private channel's members. */
294+ | { kind: "people"; user_ids: string[] }
295+ /** Everyone in the workspace, e.g. a public channel: only workspace-wide spaces. */
296+ | { kind: "workspace" };
297+
298+/** What `apply_edit` did: applied it, or filed a suggestion because it may not edit there. */
299+export type DocAgentEditResult =
300+ | { mode: "applied"; version_id: string | null; page: DocPageRef }
301+ | { mode: "suggested"; suggestion: DocSuggestion; page: DocPageRef };
302+
303+export type NewDocSpace = {
304+ name: string;
305+ slug?: string | null;
306+ description?: string | null;
307+ icon?: string | null;
308+ kind: DocSpaceKind;
309+ team?: string | null;
310+ default_role?: DocRole | null;
311+ agent_mode?: DocAgentMode | null;
312+ projects?: string[] | null;
313+};
314+
315+export type DocSpaceChange = Partial<Omit<NewDocSpace, "kind">> & { kind?: DocSpaceKind; archived?: boolean };
316+
317+export type NewDocPage = {
318+ space_id: string;
319+ parent_id?: string | null;
320+ title?: string | null;
321+ icon?: string | null;
322+ /** A built-in template's id (`builtin:<name>`) or a saved one's. */
323+ template_id?: string | null;
324+ /** Starting Markdown, when there is no template. */
325+ markdown?: string | null;
326+ projects?: string[] | null;
327+};
328+
329+export type DocPageChange = {
330+ title?: string;
331+ icon?: string | null;
332+ cover?: string | null;
333+ projects?: string[];
334+ /** Member keys (`user:<id>`, `agent:<id>`). */
335+ owners?: string[];
336+};
337+
338+/** Where a page goes: under `parent_id` (null for the top of the space), before `before_id` (null for the end). */
339+export type DocMove = { space_id?: string | null; parent_id: string | null; before_id?: string | null };
340+
341+export type DocSearchQuery = {
342+ query: string;
343+ space_id?: string | null;
344+ /** `owner/name`: pages linked to it, or in a space linked to it. */
345+ project?: string | null;
346+ limit?: number | null;
347+};
348+
349+/**
350+ * The comment operations of the editor's thread store (BlockNote's
351+ * `RESTYjsThreadStore`), applied by the page's room to the `threads` map in
352+ * the page's Yjs document, so every open editor sees them at once.
353+ */
354+export type DocThreadAction =
355+ | { op: "create"; body: unknown; metadata?: unknown; page_level?: boolean }
356+ /** Anchors a thread to the text between two Yjs relative positions (JSON). */
357+ | { op: "anchor"; thread_id: string; anchor: unknown; head: unknown }
358+ | { op: "comment"; thread_id: string; body: unknown; metadata?: unknown }
359+ | { op: "edit_comment"; thread_id: string; comment_id: string; body: unknown; metadata?: unknown }
360+ | { op: "delete_comment"; thread_id: string; comment_id: string; soft?: boolean }
361+ | { op: "delete_thread"; thread_id: string }
362+ | { op: "resolve" | "unresolve"; thread_id: string }
363+ | { op: "react" | "unreact"; thread_id: string; comment_id: string; emoji: string };
364+
365+/** A comment thread, as agents and the inbox see it. */
366+export type DocThread = {
367+ id: string;
368+ /** The text it is on; null for a comment on the whole page. */
369+ quote: string | null;
370+ resolved: boolean;
371+ comments: { id: string; author: MemberProfile; text: string; created_at: string }[];
372+};
373+
374+/** A file put in a page: served from the usercontent origin. */
375+export type DocFile = { id: string; url: string; name: string; content_type: string; bytes: number };
376+
377+/**
378+ * What a page's live socket carries besides the Yjs protocol (binary
379+ * frames: y-protocols sync and awareness). These are JSON text frames.
380+ */
381+export type DocsLiveEvent =
382+ | { type: "page.updated"; page: DocPage }
383+ | { type: "page.archived"; page_id: string }
384+ | { type: "suggestion.created" | "suggestion.updated"; suggestion: DocSuggestion }
385+ | { type: "version.created"; version: DocVersion }
386+ /** The viewer's role changed, or their access ended (`role` null). */
387+ | { type: "access"; role: DocRole | null };
388+
389+/**
390+ * Header the site sets on a forwarded live socket and on uploads: the
391+ * viewer, as JSON. Trusted only because the docs service is reachable
392+ * through service bindings alone.
393+ */
394+export const DOCS_VIEWER_HEADER = "x-g1t-docs-viewer";
395+
396+/** The largest file a page takes, in bytes. */
397+export const DOC_MAX_FILE_BYTES = 25 * 1024 * 1024;
398+
399+/** The name of the Yjs XML fragment that holds a page's blocks. */
400+export const DOC_FRAGMENT = "document-store";
401+/** The name of the Yjs map that holds a page's comment threads. */
402+export const DOC_THREADS = "threads";
403+
404+export type DocsApi = {
405+ // ── The site ─────────────────────────────────────────────────────────
406+
407+ /** Spaces with their page trees, favorites, recent pages. Makes the General space the first time. */
408+ sidebar(workspace: string, viewer: User): Promise<Result<DocsSidebar>>;
409+ home(workspace: string, viewer: User, options?: { project?: string | null }): Promise<Result<DocsHome>>;
410+ space(workspace: string, spaceSlug: string, viewer: User): Promise<Result<{ space: DocSpace; members: DocSpaceMember[]; pages: DocPage[] }>>;
411+ createSpace(workspace: string, viewer: User, input: NewDocSpace): Promise<Result<DocSpace>>;
412+ /** Manage role only. */
413+ updateSpace(workspace: string, spaceId: string, viewer: User, change: DocSpaceChange): Promise<Result<DocSpace>>;
414+ /** Adds, changes (`role`) or removes (`role` null) a member. Manage role only; the last manager stays. */
415+ setSpaceMember(workspace: string, spaceId: string, viewer: User, member: DocMemberKey, role: DocRole | null): Promise<Result<DocSpaceMember[]>>;
416+
417+ /** A page, and the viewer's role in it; records the view. Not found when they can't read it. */
418+ page(workspace: string, pageId: string, viewer: User): Promise<Result<DocPageDetail>>;
419+ createPage(workspace: string, viewer: User, input: NewDocPage): Promise<Result<DocPage>>;
420+ updatePage(workspace: string, pageId: string, viewer: User, change: DocPageChange): Promise<Result<DocPage>>;
421+ /** Edit role in both spaces. Refuses moving a page under itself. */
422+ movePage(workspace: string, pageId: string, viewer: User, move: DocMove): Promise<Result<DocPage>>;
423+ duplicatePage(workspace: string, pageId: string, viewer: User): Promise<Result<DocPage>>;
424+ /** To the trash, with every page under it. */
425+ archivePage(workspace: string, pageId: string, viewer: User): Promise<Result<DocPage>>;
426+ restorePage(workspace: string, pageId: string, viewer: User): Promise<Result<DocPage>>;
427+ /** For good: only from the trash, manage role. */
428+ deletePage(workspace: string, pageId: string, viewer: User): Promise<Result<boolean>>;
429+ trash(workspace: string, viewer: User): Promise<Result<DocPage[]>>;
430+ favorite(workspace: string, pageId: string, viewer: User, on: boolean): Promise<Result<boolean>>;
431+
432+ search(workspace: string, viewer: User, query: DocSearchQuery): Promise<Result<DocSearchHit[]>>;
433+
434+ versions(workspace: string, pageId: string, viewer: User): Promise<Result<DocVersion[]>>;
435+ version(workspace: string, pageId: string, versionId: string, viewer: User): Promise<Result<DocVersionDetail>>;
436+ /** Makes the page what it was then, as a new version. Edit role. */
437+ restoreVersion(workspace: string, pageId: string, versionId: string, viewer: User): Promise<Result<DocVersion>>;
438+
439+ templates(workspace: string, viewer: User): Promise<Result<DocTemplate[]>>;
440+ /** Saves a page as one of the workspace's templates. */
441+ saveTemplate(workspace: string, viewer: User, input: { page_id: string; name: string; description?: string | null }): Promise<Result<DocTemplate>>;
442+ deleteTemplate(workspace: string, templateId: string, viewer: User): Promise<Result<boolean>>;
443+
444+ exportPage(workspace: string, pageId: string, viewer: User): Promise<Result<{ filename: string; markdown: string }>>;
445+ /** Every page in a space as Markdown, at paths that follow the tree. */
446+ exportSpace(workspace: string, spaceId: string, viewer: User): Promise<Result<{ name: string; files: { path: string; markdown: string }[] }>>;
447+
448+ suggestions(workspace: string, pageId: string, viewer: User): Promise<Result<DocSuggestion[]>>;
449+ /** Accept (applies it to the live document, attributed to both) or reject. Edit role. */
450+ decideSuggestion(workspace: string, suggestionId: string, viewer: User, decision: "accept" | "reject"): Promise<Result<DocSuggestion>>;
451+ acceptAll(workspace: string, pageId: string, viewer: User): Promise<Result<DocSuggestion[]>>;
452+
453+ /** A comment operation from the editor; comment role (deleting others' comments or threads needs edit). */
454+ thread(workspace: string, pageId: string, viewer: User, action: DocThreadAction): Promise<Result<unknown>>;
455+ threads(workspace: string, pageId: string, viewer: User): Promise<Result<DocThread[]>>;
456+
457+ // ── Agents (services/agents) ─────────────────────────────────────────
458+ //
459+ // Each takes the agent and the person it acts for (`viewer`, the asker).
460+ // The service checks the agent is a live agent of the workspace and caps
461+ // everything by the viewer's access; `audience`, when given, narrows
462+ // reads further to what every person in it can read. A page the agent
463+ // may not read is not found, exactly as one that does not exist.
464+
465+ /** Spaces the agent may read for the viewer (and audience), with what it may do in each. */
466+ spacesForAgent(workspace: string, agentId: string, viewer: User, audience?: DocAudience | null): Promise<Result<(Pick<DocSpace, "id" | "slug" | "name" | "description" | "kind" | "agent_mode" | "projects"> & { can: DocAgentAbilities })[]>>;
467+ /** A page's Markdown and its top-level blocks (ids for `blocks` targets). */
468+ pageMarkdown(workspace: string, agentId: string, viewer: User, pageId: string, audience?: DocAudience | null): Promise<Result<DocAgentPage>>;
469+ /** Full-text search over pages every reader can read; at most 20, best first. */
470+ searchForAgent(workspace: string, agentId: string, viewer: User, query: DocSearchQuery, audience?: DocAudience | null): Promise<Result<DocSearchHit[]>>;
471+ /** Files a tracked suggestion; people with edit access accept or reject it inline. Needs the viewer's comment role. Notifies the page's owners. */
472+ suggestEdit(workspace: string, agentId: string, viewer: User, pageId: string, edit: { target: DocEditTarget; markdown: string; note?: string | null }): Promise<Result<DocSuggestion>>;
473+ /**
474+ * Applies an edit to the live document when the space lets agents edit
475+ * and the viewer can edit; otherwise files it as a suggestion (and says
476+ * so in `mode`). Attributed to the agent in the page's history.
477+ */
478+ applyEdit(workspace: string, agentId: string, viewer: User, pageId: string, edit: { target: DocEditTarget; markdown: string; note?: string | null }): Promise<Result<DocAgentEditResult>>;
479+ /**
480+ * A new page, written by the agent: in `space_id` (the General space
481+ * when null), under `parent_id`. Needs the viewer's edit role there
482+ * (whatever the space's agent mode: a new page changes nothing anyone
483+ * wrote). `source` links where it came from ("write this up").
484+ */
485+ createPageAsAgent(
486+ workspace: string,
487+ agentId: string,
488+ viewer: User,
489+ input: { space_id?: string | null; parent_id?: string | null; title: string; icon?: string | null; markdown: string; source?: { title: string; href: string } | null },
490+ ): Promise<Result<DocPageRef>>;
491+ /** A page's comment threads, for an agent asked about them. */
492+ threadsForAgent(workspace: string, agentId: string, viewer: User, pageId: string, audience?: DocAudience | null): Promise<Result<DocThread[]>>;
493+};
494+
495+async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
496+ const response = await service.fetch(`https://service/rpc/${method}`, {
497+ method: "POST",
498+ headers: { "content-type": "application/json" },
499+ body: JSON.stringify(args),
500+ });
501+ if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
502+ return (await response.json()) as T;
503+}
504+
505+export function docsClient(service: ServiceBinding): DocsApi {
506+ const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
507+ return {
508+ sidebar: (workspace, viewer) => call("sidebar", { workspace, viewer }),
509+ home: (workspace, viewer, options) => call("home", { workspace, viewer, project: options?.project ?? null }),
510+ space: (workspace, spaceSlug, viewer) => call("space", { workspace, space: spaceSlug, viewer }),
511+ createSpace: (workspace, viewer, input) => call("create_space", { workspace, viewer, input }),
512+ updateSpace: (workspace, spaceId, viewer, change) => call("update_space", { workspace, space_id: spaceId, viewer, change }),
513+ setSpaceMember: (workspace, spaceId, viewer, member, role) => call("set_space_member", { workspace, space_id: spaceId, viewer, member, role }),
514+ page: (workspace, pageId, viewer) => call("page", { workspace, page_id: pageId, viewer }),
515+ createPage: (workspace, viewer, input) => call("create_page", { workspace, viewer, input }),
516+ updatePage: (workspace, pageId, viewer, change) => call("update_page", { workspace, page_id: pageId, viewer, change }),
517+ movePage: (workspace, pageId, viewer, move) => call("move_page", { workspace, page_id: pageId, viewer, move }),
518+ duplicatePage: (workspace, pageId, viewer) => call("duplicate_page", { workspace, page_id: pageId, viewer }),
519+ archivePage: (workspace, pageId, viewer) => call("archive_page", { workspace, page_id: pageId, viewer }),
520+ restorePage: (workspace, pageId, viewer) => call("restore_page", { workspace, page_id: pageId, viewer }),
521+ deletePage: (workspace, pageId, viewer) => call("delete_page", { workspace, page_id: pageId, viewer }),
522+ trash: (workspace, viewer) => call("trash", { workspace, viewer }),
523+ favorite: (workspace, pageId, viewer, on) => call("favorite", { workspace, page_id: pageId, viewer, on }),
524+ search: (workspace, viewer, query) => call("search", { workspace, viewer, query }),
525+ versions: (workspace, pageId, viewer) => call("versions", { workspace, page_id: pageId, viewer }),
526+ version: (workspace, pageId, versionId, viewer) => call("version", { workspace, page_id: pageId, version_id: versionId, viewer }),
527+ restoreVersion: (workspace, pageId, versionId, viewer) => call("restore_version", { workspace, page_id: pageId, version_id: versionId, viewer }),
528+ templates: (workspace, viewer) => call("templates", { workspace, viewer }),
529+ saveTemplate: (workspace, viewer, input) => call("save_template", { workspace, viewer, input }),
530+ deleteTemplate: (workspace, templateId, viewer) => call("delete_template", { workspace, template_id: templateId, viewer }),
531+ exportPage: (workspace, pageId, viewer) => call("export_page", { workspace, page_id: pageId, viewer }),
532+ exportSpace: (workspace, spaceId, viewer) => call("export_space", { workspace, space_id: spaceId, viewer }),
533+ suggestions: (workspace, pageId, viewer) => call("suggestions", { workspace, page_id: pageId, viewer }),
534+ decideSuggestion: (workspace, suggestionId, viewer, decision) => call("decide_suggestion", { workspace, suggestion_id: suggestionId, viewer, decision }),
535+ acceptAll: (workspace, pageId, viewer) => call("accept_all", { workspace, page_id: pageId, viewer }),
536+ thread: (workspace, pageId, viewer, action) => call("thread", { workspace, page_id: pageId, viewer, action }),
537+ threads: (workspace, pageId, viewer) => call("threads", { workspace, page_id: pageId, viewer }),
538+ spacesForAgent: (workspace, agentId, viewer, audience) => call("spaces_for_agent", { workspace, agent_id: agentId, viewer, audience: audience ?? null }),
539+ pageMarkdown: (workspace, agentId, viewer, pageId, audience) =>
540+ call("page_markdown", { workspace, agent_id: agentId, viewer, page_id: pageId, audience: audience ?? null }),
541+ searchForAgent: (workspace, agentId, viewer, query, audience) => call("search_for_agent", { workspace, agent_id: agentId, viewer, query, audience: audience ?? null }),
542+ suggestEdit: (workspace, agentId, viewer, pageId, edit) => call("suggest_edit", { workspace, agent_id: agentId, viewer, page_id: pageId, edit }),
543+ applyEdit: (workspace, agentId, viewer, pageId, edit) => call("apply_edit", { workspace, agent_id: agentId, viewer, page_id: pageId, edit }),
544+ createPageAsAgent: (workspace, agentId, viewer, input) => call("create_page_as_agent", { workspace, agent_id: agentId, viewer, input }),
545+ threadsForAgent: (workspace, agentId, viewer, pageId, audience) =>
546+ call("threads_for_agent", { workspace, agent_id: agentId, viewer, page_id: pageId, audience: audience ?? null }),
547+ };
548+}
+1−0
1313 export * from "./compute";
1414 export * from "./context";
1515 export * from "./d1";
16+export * from "./docs";
1617 export * from "./deploy-keys";
1718 export * from "./deployments";
1819 export * from "./events";
+57−1
77 import {
88 type ServiceBinding,
99 type User,
10+ type DocPageRef,
1011 chatClient,
12+ docsClient,
1113 identityClient,
1214 reposClient,
1315 searchClient,
1517 } from "@g1t/contracts";
1618
1719 import type { AudiencePorts, RepoRef } from "./audience.ts";
18−import type { FoundMessage, ToolPorts } from "./tools.ts";
20+import type { DocsPorts, FoundMessage, ToolPorts } from "./tools.ts";
1921
2022 export type PortsEnv = {
2123 DB: D1Database;
2426 REPOS: ServiceBinding;
2527 WORK: ServiceBinding;
2628 SEARCH: ServiceBinding;
29+ /** Docs, for agents reading and writing pages; absent on an installation without it. */
30+ DOCS?: ServiceBinding;
2731 };
2832
2933 const refOf = (repo: { id: string; namespace: string; name: string; isPrivate: boolean; defaultBranch: string; forkOf?: string | null }): RepoRef => ({
6468 workspaceId: string,
6569 channelId: string,
6670 consult: ToolPorts["consult"],
71+ agentId: string | null = null,
6772 ): ToolPorts {
6873 const repos = reposClient(env.REPOS);
6974 const work = workClient(env.WORK);
151156 return `People:\n${people}${teamLines ? `\n\nTeams:\n${teamLines}` : ""}\n\nAgents:\n${agentLines}`;
152157 },
153158 consult,
159+ ...(env.DOCS && agentId ? { docs: docsPorts(env.DOCS, workspace, agentId) } : {}),
160+ };
161+}
162+
163+/** Docs as an agent reads and writes them: Markdown in, Markdown out, every call checked by the docs service. */
164+function docsPorts(binding: ServiceBinding, workspace: string, agentId: string): DocsPorts {
165+ const docs = docsClient(binding);
166+ const where = (page: DocPageRef) => `${page.title} (${page.path}, id ${page.id})`;
167+ return {
168+ async spaces(viewer, audience) {
169+ const found = await docs.spacesForAgent(workspace, agentId, viewer, audience);
170+ if (!found.ok) return null;
171+ if (!found.value.length) return "There are no Docs spaces everyone here can read.";
172+ return found.value
173+ .map((s) => {
174+ const can = s.can.edit ? "you can edit" : s.can.suggest ? "you can suggest edits" : "read only";
175+ const projects = s.projects?.length ? `; about ${s.projects.join(", ")}` : "";
176+ return `- ${s.name} (id ${s.id}, ${s.kind}; ${can}${projects})${s.description ? `: ${s.description}` : ""}`;
177+ })
178+ .join("\n");
179+ },
180+ async search(viewer, audience, query, project) {
181+ const found = await docs.searchForAgent(workspace, agentId, viewer, { query, project, limit: 10 }, audience);
182+ if (!found.ok) return null;
183+ if (!found.value.length) return "No pages found.";
184+ return found.value.map((hit) => `- ${where(hit)} in ${hit.space_name}, updated ${hit.updated_at.slice(0, 10)}: ${hit.snippet.replace(/\[\[|\]\]/g, "")}`).join("\n");
185+ },
186+ async read(viewer, audience, pageId) {
187+ const found = await docs.pageMarkdown(workspace, agentId, viewer, pageId, audience);
188+ if (!found.ok) return null;
189+ const p = found.value;
190+ const can = p.can.edit ? "you can edit it" : p.can.suggest ? "you can suggest edits" : "you can only read it";
191+ const blocks = p.blocks.map((b) => `${b.id} ${b.type}${b.level ? ` ${b.level}` : ""}`).join(", ");
192+ return `# ${where(p.page)}\nSpace: ${p.space.name}; ${can}. Updated ${p.page.updated_at.slice(0, 16)}.\nTop-level blocks: ${blocks}\n\n${p.markdown}`;
193+ },
194+ async edit(viewer, pageId, edit, suggestOnly) {
195+ const done = suggestOnly
196+ ? await docs.suggestEdit(workspace, agentId, viewer, pageId, edit).then((r) => (r.ok ? { ok: true as const, value: { mode: "suggested" as const, suggestion: r.value, page: null } } : r))
197+ : await docs.applyEdit(workspace, agentId, viewer, pageId, edit);
198+ if (!done.ok) return { ok: false, message: `That didn't work: ${done.error.message}` };
199+ const v = done.value;
200+ const page = v.page ? ` on ${where(v.page)}` : "";
201+ return v.mode === "applied"
202+ ? { ok: true, message: `Changed${page}. It's in the page's history as yours.` }
203+ : { ok: true, message: `Suggested${page}: people accept or reject it on the page. Link the page so they can.` };
204+ },
205+ async create(viewer, input) {
206+ const made = await docs.createPageAsAgent(workspace, agentId, viewer, input);
207+ if (!made.ok) return { ok: false, message: `The page couldn't be made: ${made.error.message}` };
208+ return { ok: true, message: `Wrote ${where(made.value)}. Link it.` };
209+ },
154210 };
155211 }
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.