Skip to content

Commit

Artifacts is a mode in the rail, the shell and the phone's tab bar in place of Docs: its home lists everything you can open by day or as cards under All, Yours and Shared with you, with search, filters and tiles to start a doc (slides, designs and dashboards say they are coming soon), and its sidebar holds favorites, spaces, Private, Shared, projects' docs and the trash.

Each artifact opens at a flat address ending in its id, under a shared header with where it sits, who is here, Share, comments, history, favorite and a menu to rename, duplicate, move, copy the link, export, save as a template or trash it. The share dialog invites people, agents and teams, shows where everyone's access comes from, sets whether it follows its space or only people invited, opens it to the workspace or to anyone with the link, and says how agents may change it. A doc keeps Docs' editor, suggestions, comments and citations. History, the trash, templates, spaces to browse and join, a space's page and its settings, with Editors can share, have pages of their own, and someone without access can ask for it. In chat, Write this up as an artifact asks for a doc in a space, in your Private or shared with the conversation, and an artifact's link shows a card only to people who can open it; anyone else sees that there is an artifact they don't have access to. A card never counts as opening the link. Docs' old -/docs addresses are gone and are not found.

syntaqxcommitted Parent35541b9Browse files
84 files+6364−51320/84 viewed
+7−4
4545 import { BottomSheet, SheetRow, useBack, useSwipeBack } from "../mobile";
4646 import { MessageText, type TextContext } from "./text";
4747 import { WriteUpDialog } from "./write-up";
48+import { FolioUnfurls } from "../folios/unfurl";
4849 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
4950 import { threadLink, writeUpAgents } from "../../lib/write-up";
5051 import {
492493 setParams(next, { preventScrollReset: true, replace: !!threadId && !!id });
493494 };
494495
495− // A thread's link, to copy and to cite; and "Write this up in Docs", which
496− // asks an agent, in the thread and as the person, for a page about it.
496+ // A thread's link, to copy and to cite; and "Write this up as an artifact",
497+ // which asks an agent, in the thread and as the person, for a doc about it.
497498 const [writeUp, setWriteUp] = useState<string | null>(null);
498499 const linkToThread = (root: string) => threadLink(window.location.origin, channelPath(slug, channel), root);
499500 const copyThreadLink = (root: string) => {
748749 slug={slug}
749750 agents={writers}
750751 link={writeUp && typeof window !== "undefined" ? linkToThread(writeUp) : ""}
752+ shared={channel.kind === "dm" || channel.private}
751753 onSend={askForWriteUp}
752754 />
753755 <BottomSheet open={editing != null} onOpenChange={(open) => !open && setEditing(null)} title="Edit message">
11811183 )}
11821184 {!message.pending && onWriteUp && (
11831185 <SheetRow icon={<FileText />} onClick={act(() => onWriteUp(message))}>
1184− Write this up in Docs
1186+ Write this up as an artifact
11851187 </SheetRow>
11861188 )}
11871189 {mine && message.kind === "text" && (
12771279 )}
12781280 <div className={message.pending && !message.failed ? "opacity-60" : undefined}>
12791281 {message.body && <MessageText body={message.body} context={context} />}
1282+ {message.body && !message.pending && <FolioUnfurls slug={slug} body={message.body} />}
12801283 {message.card && (
12811284 <CardBox
12821285 card={message.card}
13631366 {onWriteUp && (
13641367 <DropdownMenuItem onSelect={onWriteUp}>
13651368 <FileText />
1366− Write this up in Docs
1369+ Write this up as an artifact
13671370 </DropdownMenuItem>
13681371 )}
13691372 </>
+42−43
11 import { FileText, LoaderCircle } from "lucide-react";
22 import { useEffect, useState } from "react";
3−import { Link } from "react-router";
43
5−import type { DocsSidebar, Result } from "@g1t/contracts";
4+import type { FoliosSidebar, Result } from "@g1t/contracts";
65
76 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
8−import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field";
7+import { Field, FieldError, FieldLabel } from "../ui/field";
98 import { Input } from "../ui/input";
109 import { SelectField } from "../ui/select";
11−import { ORCHESTRATOR, type WritableSpace, type WriteUpAgent, writableSpaces, writeUpMessage } from "../../lib/write-up";
10+import { ORCHESTRATOR, type WritableSpace, type WriteUpAgent, type WriteUpWhere, defaultWhere, writableSpaces, writeUpMessage } from "../../lib/write-up";
1211
13−// "Write this up in Docs" (docs/WORKSPACE.md, "Docs"): which space, a
14−// title if you have one, and which agent writes it. Sending posts the ask
15−// in the thread, as you, where everyone sees it; the agent answers it as
16−// it answers any mention, and links the page it made.
12+// "Write this up as an artifact" (docs/ARTIFACTS_MODE.md section 4.4):
13+// where it goes, a title if you have one, and which agent writes it. The
14+// kind is a doc for now. Sending posts the ask in the thread, as you,
15+// where everyone sees it; the agent answers it as it answers any mention,
16+// and links the artifact it made.
1717
1818 type Spaces = { state: "loading" } | { state: "ready"; spaces: WritableSpace[] } | { state: "failed"; message: string };
1919
2323 slug,
2424 agents,
2525 link,
26+ shared,
2627 onSend,
2728 }: {
2829 open: boolean;
3233 agents: WriteUpAgent[];
3334 /** The thread's link, cited as the source. */
3435 link: string;
36+ /** A DM or a private channel: "Shared with this conversation" is offered, and chosen first. */
37+ shared: boolean;
3538 /** Posts the ask in the thread; answers with what went wrong, or null. */
3639 onSend: (body: string) => Promise<string | null>;
3740 }) {
3841 const [spaces, setSpaces] = useState<Spaces>({ state: "loading" });
39− const [space, setSpace] = useState("");
42+ const [where, setWhere] = useState<string>("private");
4043 const [agent, setAgent] = useState(ORCHESTRATOR);
4144 const [title, setTitle] = useState("");
4245 const [busy, setBusy] = useState(false);
5154 setTitle("");
5255 setAgent(ORCHESTRATOR);
5356 (async () => {
57+ let list: WritableSpace[] = [];
5458 try {
55− const response = await fetch(`/${slug}/-/docs/api`);
56− const result = (await response.json()) as Result<DocsSidebar>;
59+ const response = await fetch(`/${slug}/-/artifacts/api`);
60+ const result = (await response.json()) as Result<FoliosSidebar>;
5761 if (!live) return;
58− if (!result.ok) return setSpaces({ state: "failed", message: result.error.message });
59− const list = writableSpaces(result.value.spaces);
60− setSpaces({ state: "ready", spaces: list });
61− setSpace((now) => (list.some((s) => s.id === now) ? now : (list[0]?.id ?? "")));
62+ if (!result.ok) setSpaces({ state: "failed", message: result.error.message });
63+ else {
64+ list = writableSpaces(result.value.spaces);
65+ setSpaces({ state: "ready", spaces: list });
66+ }
6267 } catch {
63− if (live) setSpaces({ state: "failed", message: "Couldn't load your Docs spaces. Try again in a moment." });
68+ if (live) setSpaces({ state: "failed", message: "Couldn't load your spaces. Private and this conversation still work." });
6469 }
70+ if (!live) return;
71+ const first = defaultWhere(shared, list);
72+ setWhere(first.kind === "space" ? first.id : first.kind);
6573 })();
6674 return () => {
6775 live = false;
6876 };
69− }, [open, slug]);
77+ }, [open, slug, shared]);
7078
71− const chosen = spaces.state === "ready" ? spaces.spaces.find((s) => s.id === space) ?? null : null;
79+ const list = spaces.state === "ready" ? spaces.spaces : [];
80+ const space = list.find((s) => s.id === where);
81+ const chosen: WriteUpWhere | null = where === "private" ? { kind: "private" } : where === "conversation" ? { kind: "conversation" } : space ? { kind: "space", id: space.id, name: space.name } : null;
7282 const writer = agents.find((a) => a.handle === agent) ?? agents[0] ?? { handle: ORCHESTRATOR, name: ORCHESTRATOR };
73− const body = chosen ? writeUpMessage({ agent: writer.handle, space: chosen.name, title, link }) : null;
83+ const body = chosen ? writeUpMessage({ agent: writer.handle, where: chosen, title, link }) : null;
7484
7585 const submit = async () => {
7686 if (!body || busy) return;
8696 <Dialog open={open} onOpenChange={onOpenChange}>
8797 <DialogContent className="max-w-md">
8898 <DialogHeader>
89− <DialogTitle>Write this up in Docs</DialogTitle>
90− <DialogDescription>An agent turns this thread into a page: what was decided, why, and what's next, with a link back here.</DialogDescription>
99+ <DialogTitle>Write this up as an artifact</DialogTitle>
100+ <DialogDescription>An agent turns this thread into a doc: what was decided, why, and what's next, with a link back here.</DialogDescription>
91101 </DialogHeader>
92102 <form
93103 className="grid gap-4"
97107 }}
98108 >
99109 <Field>
100− <FieldLabel htmlFor="write-up-space">Space</FieldLabel>
110+ <FieldLabel htmlFor="write-up-where">Where</FieldLabel>
101111 {spaces.state === "loading" ? (
102112 <div className="flex h-9 items-center gap-2 rounded-md border border-line px-3 text-sm text-faint">
103113 <LoaderCircle size={14} className="animate-spin" aria-hidden="true" />
104114 Loading spaces…
105115 </div>
106− ) : spaces.state === "failed" ? (
107− <FieldError>{spaces.message}</FieldError>
108− ) : spaces.spaces.length === 0 ? (
109− <FieldDescription>
110− There's no Docs space you can write in yet.{" "}
111− <Link to={`/${slug}/-/docs/new`} className="font-medium text-accent hover:underline" onClick={() => onOpenChange(false)}>
112− Create one
113− </Link>
114− </FieldDescription>
115116 ) : (
116117 <SelectField
117− id="write-up-space"
118− value={space}
119− onValueChange={setSpace}
120− options={spaces.spaces.map((s) => ({ value: s.id, label: s.name }))}
118+ id="write-up-where"
119+ value={where}
120+ onValueChange={setWhere}
121+ options={[
122+ ...(shared ? [{ value: "conversation", label: "Shared with this conversation" }] : []),
123+ { value: "private", label: "Private (just me)" },
124+ ...list.map((s) => ({ value: s.id, label: s.name })),
125+ ]}
121126 />
122127 )}
128+ {spaces.state === "failed" && <FieldError>{spaces.message}</FieldError>}
123129 </Field>
124130 <Field>
125131 <FieldLabel htmlFor="write-up-title">
126132 Title <span className="font-normal text-faint">(optional)</span>
127133 </FieldLabel>
128− <Input
129− id="write-up-title"
130− value={title}
131− onChange={(event) => setTitle(event.target.value)}
132− placeholder="Left to the agent"
133− maxLength={120}
134− autoComplete="off"
135− />
134+ <Input id="write-up-title" value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Left to the agent" maxLength={120} autoComplete="off" />
136135 </Field>
137136 <Field>
138137 <FieldLabel htmlFor="write-up-agent">Written by</FieldLabel>
+0−70
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−/** How long a Docs request may take before the page says so instead of waiting on. */
17−export const DOCS_TIMEOUT_MS = 20_000;
18−
19−/** What a request that failed or took too long says. */
20−export function docsFailure<T>(error: unknown): Result<T> {
21− const late = error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError");
22− const message = late ? "Docs took too long to answer. Try again in a moment." : "Docs didn't answer. Check your connection and try again.";
23− return { ok: false, error: { code: "conflict", message } };
24−}
25−
26−export async function docsRequest<T>(slug: string, intent: string, body: Record<string, unknown> = {}): Promise<Result<T>> {
27− try {
28− const response = await fetch(`/${slug}/-/docs/api`, {
29− method: "POST",
30− headers: { "content-type": "application/json", accept: "application/json" },
31− body: JSON.stringify({ intent, ...body }),
32− signal: AbortSignal.timeout(DOCS_TIMEOUT_MS),
33− });
34− return (await response.json()) as Result<T>;
35− } catch (error) {
36− return docsFailure(error);
37− }
38−}
39−
40−export async function docsQuery<T>(slug: string, query: Record<string, string>): Promise<Result<T>> {
41− try {
42− const response = await fetch(`/${slug}/-/docs/api?${new URLSearchParams(query)}`, {
43− headers: { accept: "application/json" },
44− signal: AbortSignal.timeout(DOCS_TIMEOUT_MS),
45− });
46− return (await response.json()) as Result<T>;
47− } catch (error) {
48− return docsFailure(error);
49− }
50−}
51−
52−/** `send(intent, body)`: makes the change, then reloads what shows it. `busy` while it works; `error` when it failed. */
53−export function useDocsAction(slug: string) {
54− const revalidator = useRevalidator();
55− const [busy, setBusy] = useState(false);
56− const [error, setError] = useState<string | null>(null);
57− const send = useCallback(
58− async <T>(intent: string, body: Record<string, unknown> = {}, options: { reload?: boolean } = {}): Promise<Result<T>> => {
59− setBusy(true);
60− setError(null);
61− const result = await docsRequest<T>(slug, intent, body);
62− setBusy(false);
63− if (!result.ok) setError(result.error.message);
64− else if (options.reload !== false) void revalidator.revalidate();
65− return result;
66− },
67− [slug, revalidator],
68− );
69− return { send, busy, error, setError };
70−}
+0−338
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, dates, and citations of code (a file, folder
6− * or pattern in a repository, and the symbol, endpoint or environment
7− * variable there the page describes). The docs service reads the same
8− * names and attributes when it writes Markdown (services/docs
9− * src/markdown.ts), so keep the two in step. Browser-only: loaded with
10− * the editor.
11− */
12−import { BlockNoteSchema, createCodeBlockSpec, defaultBlockSpecs, defaultInlineContentSpecs } from "@blocknote/core";
13−import { createReactBlockSpec, createReactInlineContentSpec } from "@blocknote/react";
14−import { codeBlockOptions } from "@blocknote/code-block";
15−import { AlertTriangle, Braces, CheckCircle2, CircleDot, FileCode2, FileText, GitPullRequest, Hash, Info, KeyRound, OctagonAlert, Package, Route } from "lucide-react";
16−import { useEffect, useId, useState, type ReactNode } from "react";
17−
18−import { citationHref } from "../../lib/docs";
19−
20−/** What an embed shows, from the site (`-/docs/api?embed=`). */
21−export type EmbedCard = { kind: "issue" | "pull" | "channel" | "project" | "page" | "link"; title: string; subtitle: string | null; state: string | null; href: string };
22−
23−const CALLOUTS = {
24− info: { icon: <Info size={16} />, ring: "border-info/30 bg-info/8", text: "text-info" },
25− warning: { icon: <AlertTriangle size={16} />, ring: "border-warn/30 bg-warn/8", text: "text-warn" },
26− success: { icon: <CheckCircle2 size={16} />, ring: "border-success/30 bg-success/8", text: "text-success" },
27− danger: { icon: <OctagonAlert size={16} />, ring: "border-danger/30 bg-danger/8", text: "text-danger" },
28−} as const;
29−
30−export const Callout = createReactBlockSpec(
31− {
32− type: "callout",
33− propSchema: {
34− kind: { default: "info", values: ["info", "warning", "success", "danger"] },
35− backgroundColor: { default: "default" },
36− textColor: { default: "default" },
37− },
38− content: "inline",
39− },
40− {
41− render: ({ block, editor, contentRef }) => {
42− const look = CALLOUTS[block.props.kind as keyof typeof CALLOUTS] ?? CALLOUTS.info;
43− const next = { info: "warning", warning: "success", success: "danger", danger: "info" } as const;
44− return (
45− <div className={`my-1 flex w-full gap-2.5 rounded-lg border px-3.5 py-2.5 ${look.ring}`}>
46− <button
47− type="button"
48− contentEditable={false}
49− aria-label="Change the callout's kind"
50− disabled={!editor.isEditable}
51− onClick={() => editor.updateBlock(block, { props: { kind: next[block.props.kind as keyof typeof next] ?? "info" } })}
52− className={`mt-0.5 shrink-0 ${look.text}`}
53− >
54− {look.icon}
55− </button>
56− <div ref={contentRef} className="min-w-0 grow" />
57− </div>
58− );
59− },
60− },
61−);
62−
63−/** A diagram drawn from its Mermaid source, live as it is typed. */
64−function MermaidView({ code }: { code: string }) {
65− const id = useId().replace(/[^a-zA-Z0-9]/g, "");
66− const [svg, setSvg] = useState<string | null>(null);
67− const [error, setError] = useState<string | null>(null);
68− useEffect(() => {
69− let cancelled = false;
70− const timer = setTimeout(async () => {
71− try {
72− const mermaid = (await import("mermaid")).default;
73− mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict", fontFamily: "inherit" });
74− const { svg } = await mermaid.render(`mermaid-${id}-${Date.now()}`, code || "flowchart LR\n A --> B");
75− if (!cancelled) {
76− setSvg(svg);
77− setError(null);
78− }
79− } catch (e) {
80− if (!cancelled) setError(e instanceof Error ? e.message.split("\n")[0]! : "This diagram has an error.");
81− }
82− }, 250);
83− return () => {
84− cancelled = true;
85− clearTimeout(timer);
86− };
87− }, [code, id]);
88− if (error) return <p className="text-xs text-danger">{error}</p>;
89− if (!svg) return <p className="text-xs text-faint">Drawing…</p>;
90− // Mermaid sanitizes what it draws (securityLevel "strict").
91− return <div className="flex justify-center overflow-x-auto [&_svg]:max-w-full" dangerouslySetInnerHTML={{ __html: svg }} />;
92−}
93−
94−function SourceEditor({ value, onChange, placeholder, mono = true, editable }: { value: string; onChange: (v: string) => void; placeholder: string; mono?: boolean; editable: boolean }) {
95− const [draft, setDraft] = useState(value);
96− useEffect(() => setDraft(value), [value]);
97− if (!editable) return null;
98− return (
99− <textarea
100− value={draft}
101− placeholder={placeholder}
102− spellCheck={false}
103− rows={Math.min(14, Math.max(2, draft.split("\n").length))}
104− onChange={(e) => setDraft(e.target.value)}
105− onBlur={() => draft !== value && onChange(draft)}
106− onKeyDown={(e) => e.stopPropagation()}
107− 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" : ""}`}
108− />
109− );
110−}
111−
112−export const Mermaid = createReactBlockSpec(
113− { type: "mermaid", propSchema: { code: { default: "flowchart LR\n A[Idea] --> B[Spec] --> C[Shipped]" } }, content: "none" },
114− {
115− render: ({ block, editor }) => {
116− const [editing, setEditing] = useState(false);
117− return (
118− <div className="my-1 w-full rounded-lg border border-line bg-surface p-3" contentEditable={false}>
119− <div className="mb-2 flex items-center justify-between text-[0.6875rem] font-medium tracking-wide text-faint uppercase">
120− <span>Diagram</span>
121− {editor.isEditable && (
122− <button type="button" onClick={() => setEditing(!editing)} className="rounded px-1.5 py-0.5 normal-case hover:bg-raised hover:text-fg">
123− {editing ? "Done" : "Edit source"}
124− </button>
125− )}
126− </div>
127− {editing && <SourceEditor editable value={block.props.code} placeholder="flowchart LR&#10; A --> B" onChange={(code) => editor.updateBlock(block, { props: { code } })} />}
128− <div className={editing ? "mt-3" : ""}>
129− <MermaidView code={block.props.code} />
130− </div>
131− </div>
132− );
133− },
134− },
135−);
136−
137−/** A formula, typeset with KaTeX. */
138−function MathView({ expression, inline = false }: { expression: string; inline?: boolean }) {
139− const [html, setHtml] = useState<string | null>(null);
140− useEffect(() => {
141− let cancelled = false;
142− void (async () => {
143− const [{ default: katex }] = await Promise.all([import("katex"), import("katex/dist/katex.min.css")]);
144− const out = katex.renderToString(expression || "\\;", { throwOnError: false, displayMode: !inline, output: "html", trust: false, strict: "ignore" });
145− if (!cancelled) setHtml(out);
146− })();
147− return () => {
148− cancelled = true;
149− };
150− }, [expression, inline]);
151− if (html === null) return <code className="text-xs text-faint">{expression}</code>;
152− // KaTeX escapes what it is given and allows no commands that run anything (trust: false).
153− return <div className="overflow-x-auto text-fg" dangerouslySetInnerHTML={{ __html: html }} />;
154−}
155−
156−export const MathBlock = createReactBlockSpec(
157− { type: "math", propSchema: { expression: { default: "e^{i\\pi} + 1 = 0" } }, content: "none" },
158− {
159− render: ({ block, editor }) => {
160− const [editing, setEditing] = useState(false);
161− return (
162− <div className="group/math my-1 w-full rounded-lg px-3 py-2 hover:bg-surface" contentEditable={false}>
163− {editing ? (
164− <SourceEditor editable value={block.props.expression} placeholder="\int_0^1 x^2\,dx" onChange={(expression) => editor.updateBlock(block, { props: { expression } })} />
165− ) : null}
166− <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"}>
167− <MathView expression={block.props.expression} />
168− </button>
169− </div>
170− );
171− },
172− },
173−);
174−
175−const EMBED_ICONS: Record<EmbedCard["kind"], ReactNode> = {
176− issue: <CircleDot size={16} />,
177− pull: <GitPullRequest size={16} />,
178− channel: <Hash size={16} />,
179− project: <Package size={16} />,
180− page: <FileText size={16} />,
181− link: <FileText size={16} />,
182−};
183−
184−/** The card for a g1t address, asked of the site as the reader (so it shows only what they may see). */
185−function EmbedView({ url, title }: { url: string; title: string }) {
186− const [card, setCard] = useState<EmbedCard | null | "missing">(null);
187− useEffect(() => {
188− let cancelled = false;
189− const workspace = window.location.pathname.split("/")[1] ?? "";
190− fetch(`/${workspace}/-/docs/api?embed=${encodeURIComponent(url)}`, { headers: { accept: "application/json" } })
191− .then((r) => r.json() as Promise<{ ok: boolean; value?: EmbedCard }>)
192− .then((r) => !cancelled && setCard(r.ok && r.value ? r.value : "missing"))
193− .catch(() => !cancelled && setCard("missing"));
194− return () => {
195− cancelled = true;
196− };
197− }, [url]);
198− const shown = card && card !== "missing" ? card : null;
199− const state = shown?.state ?? null;
200− const tone = state === "open" ? "text-success" : state === "merged" ? "text-merged" : state === "closed" ? "text-danger" : "text-[var(--g1t-muted)]";
201− return (
202− <a
203− href={shown?.href ?? url}
204− contentEditable={false}
205− 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"
206− >
207− <span className={`shrink-0 ${tone}`}>{EMBED_ICONS[shown?.kind ?? "link"]}</span>
208− <span className="min-w-0 grow">
209− <span className="block truncate text-sm font-medium text-fg">{shown?.title ?? (title || url)}</span>
210− <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>
211− </span>
212− {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>}
213− </a>
214− );
215−}
216−
217−export const Embed = createReactBlockSpec(
218− { type: "embed", propSchema: { url: { default: "" }, title: { default: "" }, kind: { default: "link" } }, content: "none" },
219− {
220− render: ({ block, editor }) => {
221− const [draft, setDraft] = useState("");
222− if (!block.props.url) {
223− if (!editor.isEditable) return <div contentEditable={false} />;
224− return (
225− <form
226− contentEditable={false}
227− className="my-1 flex w-full gap-2 rounded-lg border border-dashed border-line p-2"
228− onSubmit={(e) => {
229− e.preventDefault();
230− const url = draft.trim();
231− if (url) editor.updateBlock(block, { props: { url } });
232− }}
233− >
234− <input
235− autoFocus
236− value={draft}
237− onChange={(e) => setDraft(e.target.value)}
238− onKeyDown={(e) => e.stopPropagation()}
239− placeholder="Paste the address of an issue, pull request, channel, project or page"
240− className="min-w-0 grow bg-transparent px-1.5 text-sm text-fg outline-none placeholder:text-faint"
241− />
242− <button type="submit" className="rounded-md bg-raised px-2.5 py-1 text-xs font-medium text-fg hover:bg-line">
243− Embed
244− </button>
245− </form>
246− );
247− }
248− return <EmbedView url={block.props.url} title={block.props.title} />;
249− },
250− },
251−);
252−
253−/** A person, agent or page, inline. */
254−export const Mention = createReactInlineContentSpec(
255− {
256− type: "mention",
257− propSchema: { kind: { default: "user" }, id: { default: "" }, name: { default: "" }, href: { default: "" } },
258− content: "none",
259− },
260− {
261− render: ({ inlineContent }) => {
262− const p = inlineContent.props;
263− if (p.kind === "page") {
264− return (
265− <a href={p.href || "#"} className="rounded px-0.5 font-medium text-[var(--g1t-accent)] no-underline hover:underline">
266− <FileText size={13} className="mr-0.5 inline -translate-y-px" aria-hidden="true" />
267− {p.name || "Untitled"}
268− </a>
269− );
270− }
271− 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>;
272− },
273− },
274−);
275−
276−/** A date, inline. */
277−export const DateChip = createReactInlineContentSpec(
278− { type: "date", propSchema: { date: { default: "" } }, content: "none" },
279− {
280− render: ({ inlineContent }) => {
281− const at = new Date(`${inlineContent.props.date}T12:00:00`);
282− const label = Number.isNaN(at.getTime()) ? inlineContent.props.date : at.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", year: "numeric" });
283− return <span className="rounded bg-raised px-1 py-px text-[0.9em] text-fg-soft">{label}</span>;
284− },
285− },
286−);
287−
288−const CITATION_ICONS: Record<string, ReactNode> = {
289− path: <FileCode2 size={12} aria-hidden="true" />,
290− symbol: <Braces size={12} aria-hidden="true" />,
291− endpoint: <Route size={12} aria-hidden="true" />,
292− env: <KeyRound size={12} aria-hidden="true" />,
293−};
294−
295−/**
296− * Code the page cites, inline: what it names (the path, or the symbol,
297− * endpoint or variable there), linking to it in Code at the commit it was
298− * cited at. When a merge changes it, the page is marked possibly out of
299− * date (services/docs src/staleness.ts).
300− */
301−export const Citation = createReactInlineContentSpec(
302− {
303− type: "citation",
304− propSchema: { repo: { default: "" }, path: { default: "" }, kind: { default: "path" }, label: { default: "" }, ref: { default: "" } },
305− content: "none",
306− },
307− {
308− render: ({ inlineContent }) => {
309− const p = inlineContent.props;
310− const shown = p.kind !== "path" && p.label ? p.label : p.path;
311− return (
312− <a
313− href={citationHref({ repo: p.repo, path: p.path, ref: p.ref || null })}
314− aria-label={`${shown} in ${p.repo}${p.kind !== "path" ? `, ${p.path}` : ""}`}
315− className="inline-flex items-center gap-1 rounded border border-line bg-raised px-1 py-px align-baseline font-mono text-[0.85em] text-fg no-underline hover:border-line-strong"
316− >
317− <span className="text-faint">{CITATION_ICONS[p.kind] ?? CITATION_ICONS.path}</span>
318− {shown}
319− </a>
320− );
321− },
322− },
323−);
324−
325−export const schema = BlockNoteSchema.create({
326− blockSpecs: {
327− ...defaultBlockSpecs,
328− codeBlock: createCodeBlockSpec(codeBlockOptions),
329− callout: Callout(),
330− mermaid: Mermaid(),
331− math: MathBlock(),
332− embed: Embed(),
333− },
334− inlineContentSpecs: { ...defaultInlineContentSpecs, mention: Mention, date: DateChip, citation: Citation },
335−});
336−
337−export type DocSchema = typeof schema;
338−export type DocEditorInstance = typeof schema.BlockNoteEditor;
+0−331
1−/**
2− * Docs and code: citing code in a page (the Cite code dialog), the
3− * header's "Describes" list, the banner on a page possibly out of date,
4− * and showing a project's docs folder in Docs. Plan: docs/WORKSPACE.md,
5− * "Agents and docs" and "Docs and repository docs".
6− */
7−import { DOC_CITATION_KIND_LABELS, type DocCitationKind, type DocDescribes, type DocRepoSpace, type DocStaleness } from "@g1t/contracts";
8−import { AlertTriangle, Check, FileCode2, FolderGit2, GitCommitHorizontal, GitPullRequest, Plus, X } from "lucide-react";
9−import { useEffect, useState } from "react";
10−import { Link } from "react-router";
11−
12−import { citationHref } from "../../lib/docs";
13−import { Button, ErrorText, Field, Input, TimeAgo } from "../ui";
14−import { Combobox } from "../ui/combobox";
15−import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
16−import { Hint } from "../ui/hint";
17−import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
18−import { SelectField } from "../ui/select";
19−import { docsQuery, docsRequest } from "./actions";
20−
21−export type RepoChoice = { repo: string; default_branch: string; private: boolean };
22−
23−/** The workspace's repositories the viewer can read, asked for once the dialog that needs them opens. */
24−export function useRepoChoices(slug: string, wanted: boolean): { repos: RepoChoice[] | null; error: string | null } {
25− const [repos, setRepos] = useState<RepoChoice[] | null>(null);
26− const [error, setError] = useState<string | null>(null);
27− useEffect(() => {
28− if (!wanted || repos) return;
29− let cancelled = false;
30− void docsQuery<RepoChoice[]>(slug, { repos: "1" }).then((r) => {
31− if (cancelled) return;
32− if (r.ok) setRepos(r.value);
33− else setError(r.error.message);
34− });
35− return () => {
36− cancelled = true;
37− };
38− }, [slug, wanted, repos]);
39− return { repos, error };
40−}
41−
42−function RepoPicker({ repos, value, onChange, id }: { repos: RepoChoice[] | null; value: string; onChange: (repo: string) => void; id?: string }) {
43− return (
44− <Combobox
45− id={id}
46− value={value}
47− onValueChange={onChange}
48− placeholder={repos ? "Choose a repository" : "Loading repositories…"}
49− searchPlaceholder="Find a repository"
50− emptyText="No repository you can read matches."
51− disabled={!repos}
52− options={(repos ?? []).map((r) => ({ value: r.repo, label: r.repo, description: r.private ? "Private" : undefined, icon: <FolderGit2 size={14} /> }))}
53− className="w-full"
54− />
55− );
56−}
57−
58−/** What the Cite code dialog inserts: a citation chip's attributes. */
59−export type CitationInput = { repo: string; path: string; kind: DocCitationKind; label: string; ref: string };
60−
61−const LABEL_HINTS: Record<DocCitationKind, string> = {
62− path: "",
63− symbol: "The function, type or class, e.g. exportCsv",
64− endpoint: "The method and route, e.g. POST /v1/exports",
65− env: "The variable's name, e.g. EXPORT_BUCKET",
66−};
67−
68−/**
69− * Cite code: a repository, a path in it (a file, a folder, or a pattern
70− * like `src/export/**`), and what there the page describes. The citation
71− * is pinned to the default branch's head, and the page is marked possibly
72− * out of date when a merge changes it.
73− */
74−export function CiteDialog({ slug, open, onOpenChange, onCite, projects = [] }: { slug: string; open: boolean; onOpenChange: (open: boolean) => void; onCite: (c: CitationInput) => void; projects?: string[] }) {
75− const { repos, error: loadError } = useRepoChoices(slug, open);
76− const [repo, setRepo] = useState("");
77− const [path, setPath] = useState("");
78− const [kind, setKind] = useState<DocCitationKind>("path");
79− const [label, setLabel] = useState("");
80− const [busy, setBusy] = useState(false);
81− const [error, setError] = useState<string | null>(null);
82− useEffect(() => {
83− if (!open) return;
84− setError(null);
85− setPath("");
86− setLabel("");
87− setKind("path");
88− }, [open]);
89− // The page's own project first, when it has one.
90− useEffect(() => {
91− if (!repo && repos?.length) setRepo(repos.find((r) => projects.includes(r.repo))?.repo ?? repos[0]!.repo);
92− }, [repos, repo, projects]);
93− const submit = async () => {
94− if (!repo || !path.trim()) return setError("Choose a repository and name a path in it.");
95− if (kind !== "path" && !label.trim()) return setError(`Name the ${kind === "env" ? "variable" : kind}.`);
96− setBusy(true);
97− setError(null);
98− const found = await docsQuery<{ repo: string; path: string; ref: string | null }>(slug, { cite: repo, path: path.trim() });
99− setBusy(false);
100− if (!found.ok) return setError(found.error.message);
101− onCite({ repo: found.value.repo, path: found.value.path, kind, label: kind === "path" ? "" : label.trim(), ref: found.value.ref ?? "" });
102− onOpenChange(false);
103− };
104− return (
105− <Dialog open={open} onOpenChange={onOpenChange}>
106− <DialogContent className="max-w-lg">
107− <DialogHeader>
108− <DialogTitle className="flex items-center gap-2">
109− <FileCode2 size={16} /> Cite code
110− </DialogTitle>
111− <DialogDescription>Link this page to the code it describes. When a merged pull request changes it, the page&apos;s owners hear that it may be out of date.</DialogDescription>
112− </DialogHeader>
113− <form
114− className="space-y-4"
115− onSubmit={(e) => {
116− e.preventDefault();
117− void submit();
118− }}
119− >
120− <Field label="Repository">
121− <RepoPicker repos={repos} value={repo} onChange={setRepo} />
122− </Field>
123− <Field label="Path" hint="A file, a folder (everything in it), or a pattern such as src/export/** or *.sql.">
124− <Input value={path} onChange={(e) => setPath(e.target.value)} placeholder="src/export.ts" autoComplete="off" spellCheck={false} className="font-mono" />
125− </Field>
126− <Field label="What it describes">
127− <SelectField value={kind} onValueChange={(v) => setKind(v as DocCitationKind)} options={(Object.keys(DOC_CITATION_KIND_LABELS) as DocCitationKind[]).map((k) => ({ value: k, label: DOC_CITATION_KIND_LABELS[k] }))} className="w-full" aria-label="What it describes" />
128− </Field>
129− {kind !== "path" && (
130− <Field label={kind === "symbol" ? "Symbol" : kind === "endpoint" ? "Endpoint" : "Variable"} hint={LABEL_HINTS[kind]}>
131− <Input value={label} onChange={(e) => setLabel(e.target.value)} autoComplete="off" spellCheck={false} className="font-mono" />
132− </Field>
133− )}
134− {(error || loadError) && <ErrorText>{error ?? loadError}</ErrorText>}
135− <DialogFooter>
136− <Button type="submit" variant="accent" disabled={busy || !repos}>
137− {busy ? "Checking…" : "Cite"}
138− </Button>
139− </DialogFooter>
140− </form>
141− </DialogContent>
142− </Dialog>
143− );
144−}
145−
146−/** The header's "Describes": which code the page is about, editable by anyone who can edit it. */
147−export function Describes({ slug, describes, editable, onSave }: { slug: string; describes: DocDescribes[]; editable: boolean; onSave: (next: DocDescribes[]) => Promise<unknown> }) {
148− const [open, setOpen] = useState(false);
149− const { repos } = useRepoChoices(slug, open);
150− const [repo, setRepo] = useState("");
151− const [path, setPath] = useState("");
152− useEffect(() => {
153− if (!repo && repos?.length) setRepo(describes[0]?.repo ?? repos[0]!.repo);
154− }, [repos, repo, describes]);
155− if (!describes.length && !editable) return null;
156− const add = async () => {
157− const clean = path.trim().replace(/^\/+|\/+$/g, "");
158− if (!repo || !clean) return;
159− await onSave([...describes, { repo, path: clean }]);
160− setPath("");
161− };
162− return (
163− <span className="flex flex-wrap items-center gap-1.5">
164− <span>Describes</span>
165− {describes.map((d) => (
166− <Link key={`${d.repo}:${d.path}`} to={citationHref({ repo: d.repo, path: d.path, ref: null })} className="inline-flex items-center gap-1 rounded-full border border-line px-2 py-0.5 font-mono text-[0.6875rem] text-muted hover:border-line-strong hover:text-fg">
167− <FileCode2 size={11} aria-hidden="true" />
168− {d.repo}:{d.path}
169− </Link>
170− ))}
171− {editable && (
172− <Popover open={open} onOpenChange={setOpen}>
173− <Hint label={describes.length ? "Change what this page describes" : "Say which code this page describes"}>
174− <PopoverTrigger asChild>
175− <button type="button" aria-label="Edit what this page describes" className="inline-flex size-5 items-center justify-center rounded-full border border-dashed border-line text-faint hover:border-line-strong hover:text-fg">
176− <Plus size={11} />
177− </button>
178− </PopoverTrigger>
179− </Hint>
180− <PopoverContent align="start" className="w-96 p-4 text-sm">
181− <p className="font-medium">Code this page describes</p>
182− <p className="mt-1 text-xs leading-relaxed text-muted">When a merged pull request changes any of it, the page is marked possibly out of date and its owners are told.</p>
183− {describes.length > 0 && (
184− <ul className="mt-3 space-y-1">
185− {describes.map((d, i) => (
186− <li key={`${d.repo}:${d.path}`} className="flex items-center gap-2 rounded-md bg-raised px-2 py-1 font-mono text-xs">
187− <span className="min-w-0 grow truncate">
188− {d.repo}:{d.path}
189− </span>
190− <button type="button" aria-label={`Remove ${d.repo}:${d.path}`} onClick={() => void onSave(describes.filter((_, j) => j !== i))} className="text-faint hover:text-danger">
191− <X size={13} />
192− </button>
193− </li>
194− ))}
195− </ul>
196− )}
197− <form
198− className="mt-3 space-y-2"
199− onSubmit={(e) => {
200− e.preventDefault();
201− void add();
202− }}
203− >
204− <RepoPicker repos={repos} value={repo} onChange={setRepo} />
205− <div className="flex gap-2">
206− <Input value={path} onChange={(e) => setPath(e.target.value)} placeholder="src/export or src/**/*.sql" aria-label="Path" autoComplete="off" spellCheck={false} className="font-mono" />
207− <Button type="submit" variant="quiet" disabled={!repo || !path.trim()}>
208− Add
209− </Button>
210− </div>
211− </form>
212− </PopoverContent>
213− </Popover>
214− )}
215− </span>
216− );
217−}
218−
219−function changeLink(c: DocStaleness["changes"][number]): { href: string; label: string } | null {
220− if (!c.visible || !c.repo) return null;
221− if (c.pull) return { href: `/${c.repo}/pull/${c.pull.number}`, label: `${c.repo}#${c.pull.number}` };
222− if (c.commit) return { href: `/${c.repo}/commit/${c.commit}`, label: `${c.repo}@${c.commit.slice(0, 7)}` };
223− return null;
224−}
225−
226−/**
227− * "Possibly out of date since acme/web#431 changed src/export.ts":
228− * the newest change the reader can see, the rest counted; Review changes
229− * opens it; Mark as current clears it for everyone.
230− */
231−export function StaleBanner({ staleness, canMark, onMark }: { staleness: DocStaleness; canMark: boolean; onMark: () => Promise<unknown> }) {
232− const [busy, setBusy] = useState(false);
233− const latest = staleness.changes[0];
234− if (!latest) return null;
235− const link = changeLink(latest);
236− const more = staleness.changes.length - 1;
237− return (
238− <div role="status" className="mt-6 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border border-warn/30 bg-warn/8 px-3 py-2 text-sm">
239− <AlertTriangle size={15} className="shrink-0 text-warn" aria-hidden="true" />
240− <span className="min-w-0 grow text-fg-soft">
241− Possibly out of date since{" "}
242− {link ? (
243− <>
244− <Link to={link.href} className="font-medium text-fg hover:underline">
245− {link.label}
246− </Link>
247− {latest.paths.length > 0 && (
248− <>
249− {" "}
250− changed <span className="font-mono text-[0.8125rem] text-fg">{latest.paths[0]}</span>
251− {latest.paths.length > 1 && ` and ${latest.paths.length - 1} more`}
252− </>
253− )}
254− </>
255− ) : (
256− "a change you can't see touched code it cites"
257− )}
258− {more > 0 && <span className="text-muted"> · {more === 1 ? "1 more change" : `${more} more changes`}</span>}
259− <span className="text-faint">
260− {" "}
261− · <TimeAgo at={latest.at} />
262− </span>
263− </span>
264− {link && (
265− <Link to={latest.pull ? `${link.href}?tab=changes` : link.href} className="inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-fg hover:bg-raised">
266− {latest.pull ? <GitPullRequest size={13} /> : <GitCommitHorizontal size={13} />} Review changes
267− </Link>
268− )}
269− {canMark && (
270− <button
271− type="button"
272− disabled={busy}
273− onClick={async () => {
274− setBusy(true);
275− await onMark();
276− setBusy(false);
277− }}
278− className="inline-flex h-7 items-center gap-1.5 rounded-md bg-warn/15 px-2.5 text-xs font-medium text-warn hover:bg-warn/25 disabled:opacity-60"
279− >
280− <Check size={13} /> {busy ? "Marking…" : "Mark as current"}
281− </button>
282− )}
283− </div>
284− );
285−}
286−
287−/** Show a project's docs: choose a repository the viewer can read; its `docs/` folder and README appear in the sidebar and search. */
288−export function RepoDocsDialog({ slug, open, onOpenChange, shown, onAdded }: { slug: string; open: boolean; onOpenChange: (open: boolean) => void; shown: string[]; onAdded: (space: DocRepoSpace) => void }) {
289− const { repos, error: loadError } = useRepoChoices(slug, open);
290− const [repo, setRepo] = useState("");
291− const [busy, setBusy] = useState(false);
292− const [error, setError] = useState<string | null>(null);
293− const choices = repos?.filter((r) => !shown.includes(r.repo)) ?? null;
294− return (
295− <Dialog open={open} onOpenChange={onOpenChange}>
296− <DialogContent className="max-w-lg">
297− <DialogHeader>
298− <DialogTitle className="flex items-center gap-2">
299− <FolderGit2 size={16} /> Show a project&apos;s docs
300− </DialogTitle>
301− <DialogDescription>The repository&apos;s docs folder and README, read from its default branch, appear beside your spaces and in search, and follow every push. Only people who can read the repository see them. Changes go through the repository.</DialogDescription>
302− </DialogHeader>
303− <form
304− className="space-y-4"
305− onSubmit={async (e) => {
306− e.preventDefault();
307− if (!repo) return;
308− setBusy(true);
309− setError(null);
310− const added = await docsRequest<DocRepoSpace>(slug, "add_repo_space", { repo });
311− setBusy(false);
312− if (!added.ok) return setError(added.error.message);
313− onAdded(added.value);
314− onOpenChange(false);
315− }}
316− >
317− <Field label="Repository">
318− <RepoPicker repos={choices} value={repo} onChange={setRepo} />
319− </Field>
320− {choices && choices.length === 0 && <p className="text-xs text-faint">Every repository you can read is already shown.</p>}
321− {(error || loadError) && <ErrorText>{error ?? loadError}</ErrorText>}
322− <DialogFooter>
323− <Button type="submit" variant="accent" disabled={busy || !repo}>
324− {busy ? "Reading its docs…" : "Show its docs"}
325− </Button>
326− </DialogFooter>
327− </form>
328− </DialogContent>
329− </Dialog>
330− );
331−}
+0−10
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−}
+0−411
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, FileCode2, 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 { CiteDialog } from "./code";
24−import { EditorSkeleton } from "./editor-skeleton";
25−import type { PageThread } from "./page-parts";
26−import { DocsProvider, type LiveStatus } from "./provider";
27−import { whoAre } from "./who";
28−
29−/** Someone who can be mentioned: a person (by username) or an agent (by id). */
30−export type DocMentionable = { kind: "user" | "agent"; id: string; name: string; display_name: string; avatar: string | null; avatar_seed?: string | null };
31−
32−/** Someone on the page now. */
33−export type Presence = { client: number; key: string; name: string; kind: "user" | "agent"; color: string; avatar: string | null; me: boolean };
34−
35−export type DocEditorProps = {
36− slug: string;
37− pageId: string;
38− role: DocRole;
39− me: { key: string; name: string; display_name: string; avatar: string | null };
40− mentionables: DocMentionable[];
41− usercontent: string;
42− suggestions: DocSuggestion[];
43− showComments: boolean;
44− onPresence?: (people: Presence[]) => void;
45− onStatus?: (status: LiveStatus) => void;
46− onEvent?: (event: DocsLiveEvent) => void;
47− /** The suggestion cards' own UI, drawn by the page beside the blocks each one changes. */
48− renderSuggestion?: (suggestion: DocSuggestion) => ReactNode;
49− /** Comments on the whole page (not on a passage), live from the document. */
50− onPageThreads?: (threads: PageThread[]) => void;
51− /** The projects the page and its space are about: Cite code offers them first. */
52− projects?: string[];
53−};
54−
55−/** Nobody can do anything with threads: a reader's view. */
56−class ReadOnlyAuth extends ThreadStoreAuth {
57− canCreateThread() {
58− return false;
59− }
60− canAddComment(_t: ThreadData) {
61− return false;
62− }
63− canUpdateComment(_c: CommentData) {
64− return false;
65− }
66− canDeleteComment(_c: CommentData) {
67− return false;
68− }
69− canDeleteThread(_t: ThreadData) {
70− return false;
71− }
72− canResolveThread(_t: ThreadData) {
73− return false;
74− }
75− canUnresolveThread(_t: ThreadData) {
76− return false;
77− }
78− canAddReaction(_c: CommentData) {
79− return false;
80− }
81− canDeleteReaction(_c: CommentData) {
82− return false;
83− }
84−}
85−
86−export default function DocEditor(props: DocEditorProps) {
87− const { slug, pageId } = props;
88− const [provider, setProvider] = useState<DocsProvider | null>(null);
89− const [synced, setSynced] = useState(false);
90−
91− useEffect(() => {
92− const scheme = window.location.protocol === "https:" ? "wss" : "ws";
93− const live = new DocsProvider(`${scheme}://${window.location.host}/${slug}/-/docs/live?page=${encodeURIComponent(pageId)}`);
94− setProvider(live);
95− setSynced(false);
96− // Mount the editor once the document is here, so it never starts from
97− // an empty page of its own; offline, after a moment, from what we have.
98− const fallback = setTimeout(() => setSynced(true), 4000);
99− const off = live.onStatus((status) => {
100− props.onStatus?.(status);
101− if (status === "synced") setSynced(true);
102− });
103− const offEvent = live.onEvent((event) => props.onEvent?.(event));
104− return () => {
105− clearTimeout(fallback);
106− off();
107− offEvent();
108− live.destroy();
109− };
110− // The callbacks are read through props each time; reconnect only for a new page.
111− // eslint-disable-next-line react-hooks/exhaustive-deps
112− }, [slug, pageId]);
113−
114− if (!provider || !synced) return <EditorSkeleton />;
115− return <LiveEditor {...props} provider={provider} />;
116−}
117−
118−function LiveEditor({ slug, pageId, role, me, mentionables, usercontent, suggestions, showComments, onPresence, renderSuggestion, onPageThreads, projects, provider }: DocEditorProps & { provider: DocsProvider }) {
119− const editable = canDo(role, "edit");
120− const [citing, setCiting] = useState(false);
121− const colour = cursorColour(me.name);
122− // Below 1280px the comments sit under the page: opening them goes there.
123− const comments = useRef<HTMLElement>(null);
124− useEffect(() => {
125− if (showComments && window.matchMedia("(max-width: 1279px)").matches) comments.current?.scrollIntoView({ behavior: "smooth", block: "start" });
126− }, [showComments]);
127−
128− const threadStore = useMemo(() => {
129− const auth = canDo(role, "comment") ? new DefaultThreadStoreAuth(me.key, editable ? "editor" : "comment") : new ReadOnlyAuth();
130− return new RESTYjsThreadStore(`/${slug}/-/docs/threads/${pageId}`, {}, provider.doc.getMap("threads"), auth);
131− }, [slug, pageId, role, me.key, editable, provider]);
132−
133− const editor = useCreateBlockNote(
134− withCollaboration({
135− schema,
136− collaboration: {
137− fragment: provider.doc.getXmlFragment("document-store"),
138− user: { name: me.display_name, color: colour, key: me.key, kind: "user", avatar: me.avatar ?? "" },
139− provider,
140− showCursorLabels: "activity",
141− },
142− extensions: [
143− CommentsExtension({
144− threadStore,
145− resolveUsers: async (ids: string[]) => {
146− const found = await whoAre(slug, ids);
147− return ids.map((id) => {
148− const p = found.get(id);
149− const avatar = id === me.key ? me.avatar : (p?.avatar ?? null);
150− return { id, username: id === me.key ? me.display_name : (p?.display_name ?? "Someone"), avatarUrl: avatar ? `${usercontent}/avatars/${avatar}` : "" };
151− });
152− },
153− }),
154− syntaxHighlighter,
155− ],
156− uploadFile: async (file: File) => {
157− const response = await fetch(`/${slug}/-/docs/upload?page=${encodeURIComponent(pageId)}&name=${encodeURIComponent(file.name)}`, {
158− method: "POST",
159− headers: { "content-type": file.type || "application/octet-stream" },
160− body: file,
161− });
162− const result = (await response.json()) as Result<DocFile>;
163− if (!result.ok) throw new Error(result.error.message);
164− return `${usercontent}${result.value.url}`;
165− },
166− }),
167− [provider, threadStore],
168− ) as unknown as DocEditorInstance;
169−
170− // Who is here: everyone's awareness state, people and agents.
171− useEffect(() => {
172− const read = () => {
173− const out: Presence[] = [];
174− provider.awareness.getStates().forEach((state, client) => {
175− const user = (state as { user?: { name?: string; color?: string; key?: string; kind?: string; avatar?: string } }).user;
176− if (!user?.name) return;
177− out.push({
178− client,
179− key: user.key ?? String(client),
180− name: user.name,
181− kind: user.kind === "agent" ? "agent" : "user",
182− color: user.color ?? "#b8a6ff",
183− avatar: user.avatar || null,
184− me: client === provider.doc.clientID,
185− });
186− });
187− // One face per person, however many tabs they have open.
188− const seen = new Set<string>();
189− onPresence?.(out.filter((p) => !seen.has(p.key) && seen.add(p.key)));
190− };
191− read();
192− provider.awareness.on("change", read);
193− return () => provider.awareness.off("change", read);
194− }, [provider, onPresence]);
195−
196− // Comments on the whole page, as the document holds them.
197− useEffect(() => {
198− if (!onPageThreads) return;
199− const map = provider.doc.getMap("threads");
200− const read = () => {
201− const out: PageThread[] = [];
202− map.forEach((value) => {
203− const t = value as { get(key: string): unknown };
204− if (typeof t?.get !== "function" || t.get("deletedAt")) return;
205− const meta = (t.get("metadata") as { page_level?: boolean } | null) ?? {};
206− if (!meta.page_level) return;
207− const comments = (t.get("comments") as { toArray(): { get(key: string): unknown }[] } | undefined)?.toArray() ?? [];
208− out.push({
209− id: String(t.get("id")),
210− resolved: !!t.get("resolved"),
211− comments: comments
212− .filter((c) => !c.get("deletedAt"))
213− .map((c) => ({ id: String(c.get("id")), author: String(c.get("userId")), text: commentText(c.get("body")), created_at: Number(c.get("createdAt")) || 0 })),
214− });
215− });
216− onPageThreads(out.sort((a, b) => (a.comments[0]?.created_at ?? 0) - (b.comments[0]?.created_at ?? 0)));
217− };
218− read();
219− map.observeDeep(read);
220− return () => map.unobserveDeep(read);
221− }, [provider, onPageThreads]);
222−
223− // `[[` links a page, as `@` mentions someone.
224− const searchPages = async (query: string) => {
225− try {
226− const r = await fetch(`/${slug}/-/docs/api?search=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } });
227− const result = (await r.json()) as Result<{ id: string; title: string; icon: string | null; path: string; space_name: string }[]>;
228− return result.ok ? result.value : [];
229− } catch {
230− return [];
231− }
232− };
233−
234− const slashItems = (query: string) => {
235− const insert = (block: { type: string; props?: Record<string, unknown> }) => () => insertOrUpdateBlockForSlashMenu(editor as never, block as never);
236− const custom = [
237− { title: "Info callout", subtext: "A note set apart from the text", aliases: ["callout", "note", "info"], group: "Callouts", icon: <Info size={18} />, onItemClick: insert({ type: "callout", props: { kind: "info" } }) },
238− { title: "Warning callout", subtext: "Something to be careful about", aliases: ["callout", "warning", "caution"], group: "Callouts", icon: <AlertTriangle size={18} />, onItemClick: insert({ type: "callout", props: { kind: "warning" } }) },
239− { title: "Success callout", subtext: "A tip, or what good looks like", aliases: ["callout", "success", "tip"], group: "Callouts", icon: <CheckCircle2 size={18} />, onItemClick: insert({ type: "callout", props: { kind: "success" } }) },
240− { title: "Diagram", subtext: "A Mermaid diagram, drawn as you type", aliases: ["mermaid", "flowchart", "sequence", "chart"], group: "Advanced", icon: <Workflow size={18} />, onItemClick: insert({ type: "mermaid" }) },
241− { title: "Math", subtext: "A formula, typeset with KaTeX", aliases: ["math", "latex", "katex", "equation", "formula"], group: "Advanced", icon: <Sigma size={18} />, onItemClick: insert({ type: "math" }) },
242− { title: "Embed from g1t", subtext: "An issue, pull request, channel, project or page, live", aliases: ["embed", "issue", "pull", "pr", "channel", "project"], group: "g1t", icon: <GitPullRequest size={18} />, onItemClick: insert({ type: "embed" }) },
243− {
244− title: "Mention",
245− subtext: "A person or an agent",
246− aliases: ["mention", "person", "agent", "@"],
247− group: "g1t",
248− icon: <AtSign size={18} />,
249− onItemClick: () => editor.insertInlineContent("@" as never),
250− },
251− {
252− title: "Link to page",
253− subtext: "Another page in Docs",
254− aliases: ["link", "page", "[["],
255− group: "g1t",
256− icon: <Link2 size={18} />,
257− onItemClick: () => editor.insertInlineContent("[[" as never),
258− },
259− {
260− title: "Cite code",
261− subtext: "A file, folder, symbol, endpoint or variable this page describes",
262− aliases: ["cite", "code", "citation", "path", "file", "symbol", "endpoint", "env", "variable"],
263− group: "g1t",
264− icon: <FileCode2 size={18} />,
265− onItemClick: () => setCiting(true),
266− },
267− {
268− title: "Date",
269− subtext: "Today's date, which you can change",
270− aliases: ["date", "today", "when"],
271− group: "g1t",
272− icon: <Calendar size={18} />,
273− onItemClick: () => editor.insertInlineContent([{ type: "date", props: { date: new Date().toISOString().slice(0, 10) } }, " "] as never),
274− },
275− ];
276− return filterSuggestionItems([...getDefaultReactSlashMenuItems(editor as never), ...custom], query);
277− };
278−
279− const mentionItems = (query: string) => {
280− const q = query.toLowerCase();
281− return mentionables
282− .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q))
283− .slice(0, 8)
284− .map((p) => ({
285− title: p.display_name,
286− subtext: p.kind === "agent" ? `@${p.name} · agent` : `@${p.name}`,
287− icon: <AtSign size={16} />,
288− onItemClick: () => editor.insertInlineContent([{ type: "mention", props: { kind: p.kind, id: p.id, name: p.name, href: "" } }, " "] as never),
289− }));
290− };
291−
292− const pageItems = async (query: string) => {
293− const pages = await searchPages(query);
294− return pages.slice(0, 8).map((p) => ({
295− title: p.title || "Untitled",
296− subtext: p.space_name,
297− icon: p.icon ? <span className="text-base leading-none">{p.icon}</span> : <FileText size={16} />,
298− onItemClick: () => editor.insertInlineContent([{ type: "mention", props: { kind: "page", id: p.id, name: p.title || "Untitled", href: p.path } }, " "] as never),
299− }));
300− };
301−
302− // Suggestions: the blocks each one changes are struck through in place.
303− const struck = suggestions
304− .flatMap((s) => s.block_ids)
305− .filter((id) => /^[0-9a-zA-Z-]+$/.test(id))
306− .map((id) => `.g1t-editor .bn-block-outer[data-id="${id}"] > .bn-block > .bn-block-content`)
307− .join(",\n");
308−
309− return (
310− <div className="g1t-editor relative">
311− {struck && (
312− <style>{`${struck} { text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--g1t-danger) 70%, transparent); background: color-mix(in srgb, var(--g1t-danger) 8%, transparent); border-radius: 4px; }`}</style>
313− )}
314− <BlockNoteView editor={editor as never} editable={editable} theme="dark" renderEditor={false} slashMenu={false} comments={canDo(role, "view")} className="g1t-bn">
315− <div className={`grid gap-8 ${showComments ? "xl:grid-cols-[minmax(0,1fr)_18rem]" : ""}`}>
316− <div className="relative min-w-0">
317− <BlockNoteViewEditor />
318− {renderSuggestion && <SuggestionRail editor={editor} suggestions={suggestions} render={renderSuggestion} hidden={showComments} />}
319− </div>
320− {showComments && (
321− <aside ref={comments} aria-label="Comments" className="scroll-mt-20 max-xl:border-t max-xl:border-line max-xl:pt-6">
322− <div className="xl:sticky xl:top-20 xl:max-h-[calc(100dvh-6rem)] xl:overflow-y-auto xl:[scrollbar-width:thin]">
323− <ThreadsSidebar filter="all" sort="position" />
324− </div>
325− </aside>
326− )}
327− </div>
328− <SuggestionMenuController triggerCharacter="/" getItems={async (query) => slashItems(query)} />
329− <SuggestionMenuController triggerCharacter="@" getItems={async (query) => mentionItems(query)} />
330− <SuggestionMenuController triggerCharacter="[[" getItems={pageItems} />
331− </BlockNoteView>
332− {editable && (
333− <CiteDialog
334− slug={slug}
335− open={citing}
336− onOpenChange={setCiting}
337− projects={projects}
338− onCite={(c) => {
339− editor.focus();
340− editor.insertInlineContent([{ type: "citation", props: c }, " "] as never);
341− }}
342− />
343− )}
344− </div>
345− );
346−}
347−
348−/**
349− * Each suggestion beside the blocks it changes, in the margin on a wide
350− * screen (the page lists them above the editor on a narrow one).
351− */
352−function SuggestionRail({ editor, suggestions, render, hidden }: { editor: DocEditorInstance; suggestions: DocSuggestion[]; render: (s: DocSuggestion) => ReactNode; hidden: boolean }) {
353− const box = useRef<HTMLDivElement>(null);
354− const [tops, setTops] = useState<Record<string, number>>({});
355− useLayoutEffect(() => {
356− const place = () => {
357− const root = box.current?.parentElement;
358− if (!root) return;
359− const base = root.getBoundingClientRect().top;
360− const next: Record<string, number> = {};
361− let floor = 0;
362− for (const s of suggestions) {
363− const first = s.block_ids[0];
364− const el = first ? root.querySelector(`.bn-block-outer[data-id="${CSS.escape(first)}"]`) : null;
365− const want = el ? el.getBoundingClientRect().top - base : root.getBoundingClientRect().height - 40;
366− // Cards never overlap: each starts below the one before.
367− const top = Math.max(want, floor);
368− next[s.id] = top;
369− floor = top + 190;
370− }
371− setTops(next);
372− };
373− place();
374− const off = editor.onChange(() => requestAnimationFrame(place));
375− const observer = new ResizeObserver(place);
376− if (box.current?.parentElement) observer.observe(box.current.parentElement);
377− return () => {
378− off?.();
379− observer.disconnect();
380− };
381− }, [editor, suggestions]);
382− if (hidden || !suggestions.length) return null;
383− return (
384− <div ref={box} className="pointer-events-none absolute inset-y-0 left-full ml-8 hidden w-72 2xl:block" aria-label="Suggestions">
385− {suggestions.map((s) => (
386− <div key={s.id} className="pointer-events-auto absolute inset-x-0" style={{ top: tops[s.id] ?? 0 }}>
387− {render(s)}
388− </div>
389− ))}
390− </div>
391− );
392−}
393−
394−/** A comment's text: its blocks' inline text, mentions as @name. */
395−function commentText(body: unknown): string {
396− const out: string[] = [];
397− const walk = (v: unknown) => {
398− if (Array.isArray(v)) v.forEach(walk);
399− else if (v && typeof v === "object") {
400− const o = v as Record<string, unknown>;
401− if (o.type === "text" && typeof o.text === "string") out.push(o.text);
402− else if (o.type === "mention") out.push(`@${(o.props as { name?: string } | undefined)?.name ?? ""}`);
403− else {
404− walk(o.content);
405− walk(o.children);
406− }
407− }
408− };
409− walk(body);
410− return out.join("");
411−}
+0−458
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−}
+0−125
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 flex items-center gap-2">
47− <span className="line-clamp-1 min-w-0 text-sm font-medium text-fg group-hover:text-accent">{page.title || "Untitled"}</span>
48− {page.stale && <span className="shrink-0 rounded-full bg-warn/12 px-1.5 py-px text-[0.625rem] font-medium text-warn">Possibly stale</span>}
49− </span>
50− <span className="mt-1 line-clamp-2 min-h-[2.5em] text-xs leading-relaxed text-muted">{page.excerpt || "Nothing written yet."}</span>
51− <span className="mt-3 flex items-center gap-1.5 text-[0.6875rem] text-faint">
52− {page.updated_by && <Face who={page.updated_by} size={14} />}
53− <span className="truncate">
54− {space ? `${space.name} · ` : ""}
55− <TimeAgo at={page.updated_at} />
56− </span>
57− </span>
58− </div>
59− </Link>
60− );
61−}
62−
63−export function SpaceCard({ slug, space }: { slug: string; space: DocSpace }) {
64− return (
65− <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">
66− <span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-raised text-lg">
67− <SpaceIcon space={space} size={18} />
68− </span>
69− <span className="min-w-0 grow">
70− <span className="flex items-center gap-1.5">
71− <span className="truncate text-sm font-medium text-fg group-hover:text-accent">{space.name}</span>
72− {space.kind === "private" && <Lock size={12} className="shrink-0 text-faint" aria-label="Private" />}
73− {space.kind === "team" && <Users size={12} className="shrink-0 text-faint" aria-label="Team space" />}
74− </span>
75− <span className="mt-0.5 line-clamp-2 text-xs leading-relaxed text-muted">{space.description || "No description."}</span>
76− <span className="mt-2 block text-[0.6875rem] text-faint">
77− {space.page_count} {space.page_count === 1 ? "page" : "pages"}
78− {space.projects.length > 0 && ` · ${space.projects.join(", ")}`}
79− </span>
80− </span>
81− </Link>
82− );
83−}
84−
85−export function TemplateCard({ template, action }: { template: DocTemplate; action: ReactNode }) {
86− return (
87− <div className="flex flex-col rounded-xl border border-line bg-surface p-4">
88− <span className="text-2xl leading-none" aria-hidden="true">
89− {template.icon}
90− </span>
91− <span className="mt-3 text-sm font-medium">{template.name}</span>
92− <span className="mt-1 grow text-xs leading-relaxed text-muted">{template.description || (template.builtin ? "" : "Saved by your workspace.")}</span>
93− <div className="mt-3 flex items-center gap-2">{action}</div>
94− </div>
95− );
96−}
97−
98−export function Crumbs({ items }: { items: { label: ReactNode; to?: string }[] }) {
99− return (
100− <nav aria-label="Breadcrumbs" className="flex min-w-0 flex-wrap items-center gap-1 text-xs text-faint">
101− {items.map((item, i) => (
102− <span key={i} className="flex min-w-0 items-center gap-1">
103− {i > 0 && <ChevronRight size={12} className="shrink-0" aria-hidden="true" />}
104− {item.to ? (
105− <Link to={item.to} prefetch="intent" className="truncate hover:text-fg">
106− {item.label}
107− </Link>
108− ) : (
109− <span className="truncate text-muted">{item.label}</span>
110− )}
111− </span>
112− ))}
113− </nav>
114− );
115−}
116−
117−/** A heading for a section of a Docs page. */
118−export function SectionTitle({ children, action }: { children: ReactNode; action?: ReactNode }) {
119− return (
120− <div className="mb-3 flex items-center justify-between gap-3">
121− <h2 className="text-sm font-semibold text-fg">{children}</h2>
122− {action}
123− </div>
124− );
125−}
+0−196
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−import { heldOpen } from "../../lib/notify-store";
21−
22−const MESSAGE_SYNC = 0;
23−const MESSAGE_AWARENESS = 1;
24−const MESSAGE_QUERY_AWARENESS = 3;
25−
26−export type LiveStatus = "connecting" | "synced" | "offline" | "closed";
27−
28−export class DocsProvider {
29− readonly doc: Y.Doc;
30− readonly awareness: awarenessProtocol.Awareness;
31− status: LiveStatus = "connecting";
32− private socket: WebSocket | null = null;
33− private attempts = 0;
34− /** When the current connection opened; the backoff starts over only once one holds. */
35− private openedAt: number | null = null;
36− private timer: ReturnType<typeof setTimeout> | null = null;
37− private keepalive: ReturnType<typeof setInterval> | null = null;
38− private stopped = false;
39− private readonly statusListeners = new Set<(status: LiveStatus) => void>();
40− private readonly eventListeners = new Set<(event: DocsLiveEvent) => void>();
41−
42− constructor(
43− private readonly url: string,
44− doc?: Y.Doc,
45− ) {
46− this.doc = doc ?? new Y.Doc();
47− this.awareness = new awarenessProtocol.Awareness(this.doc);
48− this.doc.on("update", this.onDocUpdate);
49− this.awareness.on("update", this.onAwarenessUpdate);
50− if (typeof window !== "undefined") {
51− window.addEventListener("beforeunload", this.onUnload);
52− window.addEventListener("online", this.onOnline);
53− }
54− this.connect();
55− }
56−
57− onStatus(listener: (status: LiveStatus) => void): () => void {
58− this.statusListeners.add(listener);
59− listener(this.status);
60− return () => this.statusListeners.delete(listener);
61− }
62−
63− onEvent(listener: (event: DocsLiveEvent) => void): () => void {
64− this.eventListeners.add(listener);
65− return () => this.eventListeners.delete(listener);
66− }
67−
68− private setStatus(status: LiveStatus) {
69− if (this.status === status) return;
70− this.status = status;
71− for (const l of this.statusListeners) l(status);
72− }
73−
74− private connect() {
75− if (this.stopped) return;
76− this.setStatus(this.attempts ? "offline" : "connecting");
77− const socket = new WebSocket(this.url);
78− socket.binaryType = "arraybuffer";
79− this.socket = socket;
80− socket.onopen = () => {
81− this.openedAt = Date.now();
82− // Our state vector: the room answers with what we lack, and asks for what it lacks.
83− const encoder = encoding.createEncoder();
84− encoding.writeVarUint(encoder, MESSAGE_SYNC);
85− syncProtocol.writeSyncStep1(encoder, this.doc);
86− socket.send(encoding.toUint8Array(encoder));
87− if (this.awareness.getLocalState() !== null) {
88− const a = encoding.createEncoder();
89− encoding.writeVarUint(a, MESSAGE_AWARENESS);
90− encoding.writeVarUint8Array(a, awarenessProtocol.encodeAwarenessUpdate(this.awareness, [this.doc.clientID]));
91− socket.send(encoding.toUint8Array(a));
92− }
93− const q = encoding.createEncoder();
94− encoding.writeVarUint(q, MESSAGE_QUERY_AWARENESS);
95− socket.send(encoding.toUint8Array(q));
96− if (this.keepalive) clearInterval(this.keepalive);
97− // Answered at the edge without waking the room.
98− this.keepalive = setInterval(() => socket.readyState === WebSocket.OPEN && socket.send("ping"), 25_000);
99− };
100− socket.onmessage = (event) => {
101− if (typeof event.data === "string") {
102− if (event.data === "pong") return;
103− try {
104− const parsed = JSON.parse(event.data) as DocsLiveEvent;
105− for (const l of this.eventListeners) l(parsed);
106− } catch {
107− // Not ours.
108− }
109− return;
110− }
111− this.receive(new Uint8Array(event.data as ArrayBuffer));
112− };
113− socket.onclose = (event) => {
114− if (this.keepalive) clearInterval(this.keepalive);
115− this.socket = null;
116− // Others stop seeing our cursor; we stop seeing theirs.
117− awarenessProtocol.removeAwarenessStates(
118− this.awareness,
119− [...this.awareness.getStates().keys()].filter((id) => id !== this.doc.clientID),
120− this,
121− );
122− // 4403: no longer allowed; 4410: in the trash. Neither comes back by retrying.
123− if (event.code === 4403 || event.code === 4410 || this.stopped) {
124− this.setStatus("closed");
125− return;
126− }
127− this.setStatus("offline");
128− // Only a connection that held starts the backoff over.
129− if (heldOpen(this.openedAt)) this.attempts = 0;
130− this.openedAt = null;
131− const delay = Math.min(30_000, 500 * 2 ** this.attempts) + Math.random() * 500;
132− this.attempts++;
133− this.timer = setTimeout(() => this.connect(), delay);
134− };
135− }
136−
137− private receive(data: Uint8Array) {
138− const decoder = decoding.createDecoder(data);
139− const type = decoding.readVarUint(decoder);
140− if (type === MESSAGE_SYNC) {
141− const encoder = encoding.createEncoder();
142− encoding.writeVarUint(encoder, MESSAGE_SYNC);
143− const step = syncProtocol.readSyncMessage(decoder, encoder, this.doc, this);
144− if (encoding.length(encoder) > 1) this.socket?.send(encoding.toUint8Array(encoder));
145− if (step === syncProtocol.messageYjsSyncStep2) this.setStatus("synced");
146− return;
147− }
148− if (type === MESSAGE_AWARENESS) {
149− awarenessProtocol.applyAwarenessUpdate(this.awareness, decoding.readVarUint8Array(decoder), this);
150− }
151− }
152−
153− private onDocUpdate = (update: Uint8Array, origin: unknown) => {
154− if (origin === this) return;
155− const encoder = encoding.createEncoder();
156− encoding.writeVarUint(encoder, MESSAGE_SYNC);
157− syncProtocol.writeUpdate(encoder, update);
158− if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(encoding.toUint8Array(encoder));
159− // While offline, the update stays in the document and goes in the next sync.
160− };
161−
162− private onAwarenessUpdate = ({ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }, origin: unknown) => {
163− if (origin === this) return;
164− const changed = [...added, ...updated, ...removed];
165− const encoder = encoding.createEncoder();
166− encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
167− encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, changed));
168− if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(encoding.toUint8Array(encoder));
169− };
170−
171− private onUnload = () => {
172− awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], "unload");
173− };
174−
175− private onOnline = () => {
176− if (this.socket || this.stopped) return;
177− if (this.timer) clearTimeout(this.timer);
178− this.attempts = 0;
179− this.connect();
180− };
181−
182− destroy() {
183− this.stopped = true;
184− if (this.timer) clearTimeout(this.timer);
185− if (this.keepalive) clearInterval(this.keepalive);
186− awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], "destroy");
187− this.doc.off("update", this.onDocUpdate);
188− this.awareness.off("update", this.onAwarenessUpdate);
189− if (typeof window !== "undefined") {
190− window.removeEventListener("beforeunload", this.onUnload);
191− window.removeEventListener("online", this.onOnline);
192− }
193− this.socket?.close();
194− this.awareness.destroy();
195− }
196−}
+0−489
1−/**
2− * Docs mode's sidebar (beside the rail, docs/WORKSPACE.md "Shell"):
3− * search, Home, Possibly stale, Templates and Trash; Favorites and
4− * Recent; each space with its page tree, which opens to the page being
5− * read; then projects' docs folders, read-only. Pages are
6− * dragged to reorder them or to put one inside another; the ⋯ menu moves
7− * them too, for keyboards and phones.
8− */
9−import type { DocRepoSpace, DocsSidebarSpace } from "@g1t/contracts";
10−import { AlertTriangle, BookOpen, ChevronRight, Clock, FileText, Folder, FolderGit2, Home, LayoutTemplate, Lock, Plus, Search, Settings, Star, Trash2, Users } from "lucide-react";
11−import { type DragEvent, type ReactNode, useEffect, useMemo, useState } from "react";
12−import { NavLink, useLocation, useNavigate, useParams, useRevalidator } from "react-router";
13−
14−import { buildTree, canDo, pageIdOf, pagePath, pathTo, repoFilePath, repoFolders, type RepoFolder, type TreeItem } from "../../lib/docs";
15−import { Hint } from "../ui/hint";
16−import { Skeleton } from "../ui/skeleton";
17−import { docsRequest, useDocsAction, useDocsData } from "./actions";
18−import { RepoDocsDialog } from "./code";
19−
20−const ROW = "group flex h-8 items-center gap-1.5 rounded-md pr-1 text-[0.8125rem] transition-colors";
21−
22−function SideLink({ to, end, icon, children, trailing }: { to: string; end?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode }) {
23− return (
24− <NavLink
25− to={to}
26− end={end}
27− prefetch="intent"
28− className={({ isActive }) => `${ROW} pl-2 ${isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`}
29− >
30− <span className="flex w-5 shrink-0 justify-center text-faint">{icon}</span>
31− <span className="min-w-0 grow truncate">{children}</span>
32− {trailing}
33− </NavLink>
34− );
35−}
36−
37−function Section({ title, children, action, open: initially = true }: { title: string; children: ReactNode; action?: ReactNode; open?: boolean }) {
38− const [open, setOpen] = useState(initially);
39− return (
40− <section className="mt-4">
41− <div className="mb-0.5 flex items-center gap-1 pr-1">
42− <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">
43− <ChevronRight size={12} className={`transition-transform ${open ? "rotate-90" : ""}`} />
44− {title}
45− </button>
46− {action}
47− </div>
48− {open && children}
49− </section>
50− );
51−}
52−
53−/** A page's icon: its emoji, or the plain page. */
54−export function PageIcon({ icon, size = 15 }: { icon: string | null | undefined; size?: number }) {
55− if (icon) return <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">{icon}</span>;
56− return <FileText size={size} className="text-faint" aria-hidden="true" />;
57−}
58−
59−/** A space's icon: its emoji, or the book. */
60−export function SpaceIcon({ space, size = 15 }: { space: Pick<DocsSidebarSpace, "icon" | "kind">; size?: number }) {
61− if (space.icon) return <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">{space.icon}</span>;
62− if (space.kind === "private") return <Lock size={size} className="text-faint" aria-hidden="true" />;
63− if (space.kind === "team") return <Users size={size} className="text-faint" aria-hidden="true" />;
64− return <BookOpen size={size} className="text-faint" aria-hidden="true" />;
65−}
66−
67−type Drag = { id: string; space: string } | null;
68−
69−function TreeRow({
70− slug,
71− space,
72− item,
73− current,
74− open,
75− toggle,
76− drag,
77− setDrag,
78− onDrop,
79− onAdd,
80− stale,
81−}: {
82− slug: string;
83− space: DocsSidebarSpace;
84− stale: Set<string>;
85− item: TreeItem;
86− current: string | null;
87− open: Set<string>;
88− toggle: (id: string) => void;
89− drag: Drag;
90− setDrag: (d: Drag) => void;
91− onDrop: (target: TreeItem, where: "before" | "inside") => void;
92− onAdd: (parent: string) => void;
93−}) {
94− const [over, setOver] = useState<"before" | "inside" | null>(null);
95− const expanded = open.has(item.id);
96− const active = current === item.id;
97− const editable = canDo(space.viewer_role, "edit");
98− const zone = (e: DragEvent<HTMLElement>): "before" | "inside" => {
99− const box = e.currentTarget.getBoundingClientRect();
100− return e.clientY - box.top < box.height * 0.35 ? "before" : "inside";
101− };
102− return (
103− <li>
104− <div
105− draggable={editable}
106− onDragStart={(e) => {
107− e.dataTransfer.effectAllowed = "move";
108− e.dataTransfer.setData("text/plain", item.id);
109− setDrag({ id: item.id, space: space.id });
110− }}
111− onDragEnd={() => setDrag(null)}
112− onDragOver={(e) => {
113− if (!drag || drag.id === item.id || !editable) return;
114− e.preventDefault();
115− setOver(zone(e));
116− }}
117− onDragLeave={() => setOver(null)}
118− onDrop={(e) => {
119− e.preventDefault();
120− const where = zone(e);
121− setOver(null);
122− if (drag && drag.id !== item.id) onDrop(item, where);
123− }}
124− 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" : ""}`}
125− style={{ paddingLeft: 4 + item.depth * 14 }}
126− >
127− {over === "before" && <span className="pointer-events-none absolute inset-x-1 -top-px h-0.5 rounded bg-accent" aria-hidden="true" />}
128− <button
129− type="button"
130− onClick={() => toggle(item.id)}
131− aria-label={expanded ? "Collapse" : "Expand"}
132− aria-expanded={expanded}
133− className={`flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg ${item.children.length ? "" : "invisible"}`}
134− >
135− <ChevronRight size={13} className={`transition-transform ${expanded ? "rotate-90" : ""}`} />
136− </button>
137− <NavLink to={pagePath(slug, space.slug, item.title, item.id)} prefetch="intent" className="flex min-w-0 grow items-center gap-1.5">
138− <span className="flex w-4 shrink-0 justify-center">
139− <PageIcon icon={item.icon} size={14} />
140− </span>
141− <span className="min-w-0 truncate">{item.title || "Untitled"}</span>
142− {stale.has(item.id) && (
143− <Hint label="Possibly out of date: code it cites changed">
144− <span className="ml-auto size-1.5 shrink-0 rounded-full bg-warn" aria-label="Possibly out of date" />
145− </Hint>
146− )}
147− </NavLink>
148− {editable && (
149− <Hint label="Add a page inside">
150− <button
151− type="button"
152− onClick={() => onAdd(item.id)}
153− aria-label={`Add a page inside ${item.title || "Untitled"}`}
154− 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 pointer-coarse:size-9 pointer-coarse:opacity-100"
155− >
156− <Plus size={13} />
157− </button>
158− </Hint>
159− )}
160− </div>
161− {expanded && item.children.length > 0 && (
162− <ul>
163− {item.children.map((child) => (
164− <TreeRow key={child.id} slug={slug} space={space} stale={stale} item={child} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={onDrop} onAdd={onAdd} />
165− ))}
166− </ul>
167− )}
168− </li>
169− );
170−}
171−
172−function SpaceTree({ slug, space, current, drag, setDrag }: { slug: string; space: DocsSidebarSpace; current: string | null; drag: Drag; setDrag: (d: Drag) => void }) {
173− const navigate = useNavigate();
174− const { send } = useDocsAction(slug);
175− const tree = useMemo(() => buildTree(space.pages), [space.pages]);
176− const stale = useMemo(() => new Set(space.pages.filter((p) => p.stale).map((p) => p.id)), [space.pages]);
177− const [open, setOpen] = useState<Set<string>>(() => new Set(pathTo(space.pages, current)));
178− const [collapsed, setCollapsed] = useState(false);
179− // Opening a page opens the way to it.
180− useEffect(() => {
181− if (!current) return;
182− const path = pathTo(space.pages, current);
183− if (path.length) setOpen((was) => new Set([...was, ...path]));
184− }, [current, space.pages]);
185− const toggle = (id: string) =>
186− setOpen((was) => {
187− const next = new Set(was);
188− if (next.has(id)) next.delete(id);
189− else next.add(id);
190− return next;
191− });
192− const add = async (parent: string | null) => {
193− const made = await send<{ path: string }>("create_page", { page: { space_id: space.id, parent_id: parent } });
194− if (made.ok) {
195− if (parent) setOpen((was) => new Set([...was, parent]));
196− navigate(made.value.path);
197− }
198− };
199− const drop = async (target: TreeItem, where: "before" | "inside") => {
200− if (!drag) return;
201− 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 };
202− setDrag(null);
203− await send("move_page", { page_id: drag.id, move });
204− if (where === "inside") setOpen((was) => new Set([...was, target.id]));
205− };
206− const editable = canDo(space.viewer_role, "edit");
207− return (
208− <li className="mt-1">
209− <div
210− className={`${ROW} text-fg-soft`}
211− onDragOver={(e) => {
212− if (drag && editable) e.preventDefault();
213− }}
214− onDrop={async (e) => {
215− // Dropped on the space itself: to the top of it, at the end.
216− e.preventDefault();
217− if (!drag) return;
218− const id = drag.id;
219− setDrag(null);
220− await send("move_page", { page_id: id, move: { space_id: space.id, parent_id: null, before_id: null } });
221− }}
222− >
223− <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">
224− <ChevronRight size={13} className={`transition-transform ${collapsed ? "" : "rotate-90"}`} />
225− </button>
226− <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" : ""}`}>
227− <span className="flex w-4 shrink-0 justify-center">
228− <SpaceIcon space={space} size={14} />
229− </span>
230− <span className="min-w-0 truncate">{space.name}</span>
231− </NavLink>
232− {canDo(space.viewer_role, "manage") && (
233− <Hint label="Space settings">
234− <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 pointer-coarse:size-9 pointer-coarse:opacity-100">
235− <Settings size={13} />
236− </NavLink>
237− </Hint>
238− )}
239− {editable && (
240− <Hint label={`New page in ${space.name}`}>
241− <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 pointer-coarse:size-9 pointer-coarse:opacity-100">
242− <Plus size={13} />
243− </button>
244− </Hint>
245− )}
246− </div>
247− {!collapsed && (
248− <ul>
249− {tree.map((item) => (
250− <TreeRow key={item.id} slug={slug} space={space} stale={stale} item={item} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={drop} onAdd={add} />
251− ))}
252− {tree.length === 0 && <li className="py-1 pr-2 pl-9 text-xs text-faint">No pages yet.</li>}
253− </ul>
254− )}
255− </li>
256− );
257−}
258−
259−function RepoFolderRows({ slug, repo, folder, depth, current }: { slug: string; repo: string; folder: RepoFolder; depth: number; current: string }) {
260− const [open, setOpen] = useState<Set<string>>(() => new Set(folder.folders.filter((f) => current.includes(`/${f.path}/`)).map((f) => f.path)));
261− return (
262− <>
263− {folder.files.map((f) => {
264− const to = repoFilePath(slug, repo, f.path);
265− return (
266− <li key={f.path}>
267− <NavLink to={to} prefetch="intent" className={({ isActive }) => `${ROW} ${isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`} style={{ paddingLeft: 26 + depth * 14 }}>
268− <FileText size={14} className="shrink-0 text-faint" aria-hidden="true" />
269− <span className="min-w-0 truncate">{f.title}</span>
270− </NavLink>
271− </li>
272− );
273− })}
274− {folder.folders.map((sub) => {
275− const expanded = open.has(sub.path);
276− return (
277− <li key={sub.path}>
278− <button
279− type="button"
280− aria-expanded={expanded}
281− onClick={() =>
282− setOpen((was) => {
283− const next = new Set(was);
284− if (next.has(sub.path)) next.delete(sub.path);
285− else next.add(sub.path);
286− return next;
287− })
288− }
289− className={`${ROW} w-full text-muted hover:bg-raised/60 hover:text-fg`}
290− style={{ paddingLeft: 4 + (depth + 1) * 14 }}
291− >
292− <ChevronRight size={13} className={`shrink-0 text-faint transition-transform ${expanded ? "rotate-90" : ""}`} />
293− <Folder size={14} className="shrink-0 text-faint" aria-hidden="true" />
294− <span className="min-w-0 truncate">{sub.name}</span>
295− </button>
296− {expanded && (
297− <ul>
298− <RepoFolderRows slug={slug} repo={repo} folder={sub} depth={depth + 1} current={current} />
299− </ul>
300− )}
301− </li>
302− );
303− })}
304− </>
305− );
306−}
307−
308−/** A project's docs folder: read-only, its files as folders, from the repository's default branch. */
309−function RepoTree({ slug, space }: { slug: string; space: DocRepoSpace }) {
310− const { pathname } = useLocation();
311− const base = `/${slug}/-/docs/repo/${space.repo}/`;
312− const here = decodeURIComponent(pathname).startsWith(base);
313− const [collapsed, setCollapsed] = useState(!here);
314− const root = useMemo(() => repoFolders(space.files), [space.files]);
315− return (
316− <li className="mt-1">
317− <div className={`${ROW} text-fg-soft`}>
318− <button type="button" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed} aria-label={collapsed ? `Show ${space.repo}'s docs` : `Hide ${space.repo}'s docs`} className="flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg">
319− <ChevronRight size={13} className={`transition-transform ${collapsed ? "" : "rotate-90"}`} />
320− </button>
321− <span className="flex w-4 shrink-0 justify-center">
322− <FolderGit2 size={14} className="text-faint" aria-hidden="true" />
323− </span>
324− <span className="min-w-0 grow truncate font-medium">{space.repo}</span>
325− </div>
326− {!collapsed && (
327− <ul>
328− <RepoFolderRows slug={slug} repo={space.repo} folder={root} depth={0} current={decodeURIComponent(pathname)} />
329− {space.files.length === 0 && <li className="py-1 pr-2 pl-9 text-xs text-faint">{space.indexed_at ? "No docs folder or README." : "Reading its docs…"}</li>}
330− </ul>
331− )}
332− </li>
333− );
334−}
335−
336−export function DocsSidebar({ slug, onClose }: { slug: string; onClose?: () => void }) {
337− const data = useDocsData();
338− const navigate = useNavigate();
339− const params = useParams();
340− const { pathname } = useLocation();
341− const current = pageIdOf(params.page);
342− const [drag, setDrag] = useState<Drag>(null);
343− const [query, setQuery] = useState("");
344− const [addingRepo, setAddingRepo] = useState(false);
345− const { revalidate } = useRevalidator();
346− const sidebar = data?.sidebar;
347− const general = sidebar?.spaces.find((s) => s.is_default) ?? sidebar?.spaces.find((s) => canDo(s.viewer_role, "edit"));
348− const newPage = async () => {
349− if (!general) return;
350− const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: general.id } });
351− if (made.ok) navigate(made.value.path);
352− };
353− return (
354− <div className="flex h-full flex-col">
355− <div className="flex h-14 shrink-0 items-center gap-1 border-b border-line pr-2.5 pl-4">
356− <h2 className="min-w-0 grow truncate text-[0.9375rem] font-semibold">Docs</h2>
357− {general && (
358− <Hint label="New page">
359− <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">
360− <Plus size={16} />
361− </button>
362− </Hint>
363− )}
364− {onClose && (
365− <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">
366− <ChevronRight size={16} className="rotate-180" />
367− </button>
368− )}
369− </div>
370− <nav aria-label="Docs" className="min-h-0 grow overflow-y-auto px-2.5 pt-3 pb-4 [scrollbar-width:thin]">
371− <form
372− role="search"
373− onSubmit={(e) => {
374− e.preventDefault();
375− navigate(`/${slug}/-/docs/search?q=${encodeURIComponent(query.trim())}`);
376− }}
377− className="mb-2"
378− >
379− <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">
380− <Search size={14} aria-hidden="true" />
381− <span className="sr-only">Search docs</span>
382− <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" />
383− </label>
384− </form>
385− <div className="space-y-px">
386− <SideLink to={`/${slug}/-/docs`} end icon={<Home size={15} />}>
387− Home
388− </SideLink>
389− {!!sidebar?.stale_count && (
390− <SideLink to={`/${slug}/-/docs/stale`} icon={<AlertTriangle size={15} className="text-warn" />} trailing={<span className="text-[0.6875rem] text-warn tabular-nums">{sidebar.stale_count}</span>}>
391− Possibly stale
392− </SideLink>
393− )}
394− <SideLink to={`/${slug}/-/docs/templates`} icon={<LayoutTemplate size={15} />}>
395− Templates
396− </SideLink>
397− <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}>
398− Trash
399− </SideLink>
400− </div>
401− {sidebar === undefined || sidebar === null ? (
402− sidebar === null ? (
403− <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>
404− ) : (
405− <div className="mt-5 space-y-3 px-2" aria-busy="true">
406− {[0, 1, 2, 3].map((i) => (
407− <Skeleton key={i} className="h-3 w-36" />
408− ))}
409− </div>
410− )
411− ) : (
412− <>
413− {sidebar.favorites.length > 0 && (
414− <Section title="Favorites">
415− <ul className="space-y-px">
416− {sidebar.favorites.map((p) => (
417− <li key={p.id}>
418− <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" />}>
419− {p.title || "Untitled"}
420− </SideLink>
421− </li>
422− ))}
423− </ul>
424− </Section>
425− )}
426− {sidebar.recent.length > 0 && (
427− <Section title="Recent" open={false}>
428− <ul className="space-y-px">
429− {sidebar.recent.slice(0, 6).map((p) => (
430− <li key={p.id}>
431− <SideLink to={p.path} icon={<Clock size={14} />}>
432− {p.title || "Untitled"}
433− </SideLink>
434− </li>
435− ))}
436− </ul>
437− </Section>
438− )}
439− <Section
440− title="Spaces"
441− action={
442− sidebar.can_create_space ? (
443− <Hint label="New space">
444− <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">
445− <Plus size={13} />
446− </NavLink>
447− </Hint>
448− ) : undefined
449− }
450− >
451− <ul>
452− {sidebar.spaces.map((space) => (
453− <SpaceTree key={space.id} slug={slug} space={space} current={current} drag={drag} setDrag={setDrag} />
454− ))}
455− </ul>
456− </Section>
457− {pathname === `/${slug}/-/docs` && sidebar.spaces.length === 0 && <p className="mt-3 px-2 text-xs text-faint">No spaces you can read yet.</p>}
458− <Section
459− title="Projects' docs"
460− open={(sidebar.repos ?? []).length > 0}
461− action={
462− <Hint label="Show a project's docs">
463− <button type="button" onClick={() => setAddingRepo(true)} aria-label="Show a project's docs" className="flex size-6 items-center justify-center rounded text-faint hover:bg-raised hover:text-fg">
464− <Plus size={13} />
465− </button>
466− </Hint>
467− }
468− >
469− <ul>
470− {(sidebar.repos ?? []).map((space) => (
471− <RepoTree key={space.id} slug={slug} space={space} />
472− ))}
473− {(sidebar.repos ?? []).length === 0 && (
474− <li>
475− <button type="button" onClick={() => setAddingRepo(true)} className="px-2 py-1 text-left text-xs text-faint hover:text-fg">
476− Show a repository&apos;s docs folder here, read-only.
477− </button>
478− </li>
479− )}
480− </ul>
481− </Section>
482− <RepoDocsDialog slug={slug} open={addingRepo} onOpenChange={setAddingRepo} shown={(sidebar.repos ?? []).map((r) => r.repo.toLowerCase())} onAdded={() => void revalidate()} />
483− </>
484− )}
485− </nav>
486− </div>
487− );
488−}
489−
+0−152
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−}
+0−2
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"];
+0−26
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−
+88−0
1+/**
2+ * How Artifacts' pages change things: a JSON request to `-/artifacts/api`,
3+ * then the sidebar and page load again so they show it. Returns the
4+ * service's `{ ok, value }` or `{ ok: false, error }`.
5+ */
6+import type { Result } from "@g1t/contracts";
7+import { useCallback, useEffect, useState } from "react";
8+import { useRevalidator, useRouteLoaderData } from "react-router";
9+
10+import { browserTimeZone } from "../../lib/time-zone";
11+import type { FoliosLayoutData } from "../../routes/workspace/folios/layout";
12+
13+export function useFoliosData(): FoliosLayoutData | undefined {
14+ return useRouteLoaderData("routes/workspace/folios/layout") as FoliosLayoutData | undefined;
15+}
16+
17+/**
18+ * The viewer's time zone, for the home list's days. The server renders
19+ * with the zone the browser last told it (the `g1t_tz` cookie, else UTC);
20+ * the browser then uses its own and remembers it for next time.
21+ */
22+export function useViewerZone(): string {
23+ const saved = useFoliosData()?.zone ?? null;
24+ const [zone, setZone] = useState(saved ?? "UTC");
25+ useEffect(() => {
26+ const own = browserTimeZone();
27+ if (!own) return;
28+ setZone(own);
29+ if (own !== saved) document.cookie = `g1t_tz=${encodeURIComponent(own)}; Path=/; Max-Age=31536000; SameSite=Lax`;
30+ }, [saved]);
31+ return zone;
32+}
33+
34+/** How long a request may take before the page says so instead of waiting on. */
35+export const FOLIOS_TIMEOUT_MS = 20_000;
36+
37+/** What a request that failed or took too long says. */
38+export function foliosFailure<T>(error: unknown): Result<T> {
39+ const late = error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError");
40+ const message = late ? "Artifacts took too long to answer. Try again in a moment." : "Artifacts didn't answer. Check your connection and try again.";
41+ return { ok: false, error: { code: "conflict", message } };
42+}
43+
44+export async function foliosRequest<T>(slug: string, intent: string, body: Record<string, unknown> = {}): Promise<Result<T>> {
45+ try {
46+ const response = await fetch(`/${slug}/-/artifacts/api`, {
47+ method: "POST",
48+ headers: { "content-type": "application/json", accept: "application/json" },
49+ body: JSON.stringify({ intent, ...body }),
50+ signal: AbortSignal.timeout(FOLIOS_TIMEOUT_MS),
51+ });
52+ return (await response.json()) as Result<T>;
53+ } catch (error) {
54+ return foliosFailure(error);
55+ }
56+}
57+
58+export async function foliosQuery<T>(slug: string, query: Record<string, string>): Promise<Result<T>> {
59+ try {
60+ const response = await fetch(`/${slug}/-/artifacts/api?${new URLSearchParams(query)}`, {
61+ headers: { accept: "application/json" },
62+ signal: AbortSignal.timeout(FOLIOS_TIMEOUT_MS),
63+ });
64+ return (await response.json()) as Result<T>;
65+ } catch (error) {
66+ return foliosFailure(error);
67+ }
68+}
69+
70+/** `send(intent, body)`: makes the change, then reloads what shows it. `busy` while it works; `error` when it failed. */
71+export function useFoliosAction(slug: string) {
72+ const revalidator = useRevalidator();
73+ const [busy, setBusy] = useState(false);
74+ const [error, setError] = useState<string | null>(null);
75+ const send = useCallback(
76+ async <T>(intent: string, body: Record<string, unknown> = {}, options: { reload?: boolean } = {}): Promise<Result<T>> => {
77+ setBusy(true);
78+ setError(null);
79+ const result = await foliosRequest<T>(slug, intent, body);
80+ setBusy(false);
81+ if (!result.ok) setError(result.error.message);
82+ else if (options.reload !== false) void revalidator.revalidate();
83+ return result;
84+ },
85+ [slug, revalidator],
86+ );
87+ return { send, busy, error, setError };
88+}
+194−0
1+/**
2+ * The small dialogs an artifact's ⋯ menu opens, from Home's rows and from
3+ * its own page: rename, move (to a space or Private, inside a doc), and
4+ * save as a template.
5+ */
6+import { FOLIO_MAX_TITLE, type Folio, type FoliosSidebar, type Result } from "@g1t/contracts";
7+import { useEffect, useMemo, useState } from "react";
8+
9+import { buildTree, canDo, flatten } from "../../lib/folios";
10+import { Button, ErrorText } from "../ui";
11+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
12+import { SelectField } from "../ui/select";
13+
14+const FIELD = "h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none focus:border-accent/60";
15+
16+export function RenameDialog({ open, onOpenChange, title, onSave }: { open: boolean; onOpenChange: (o: boolean) => void; title: string; onSave: (title: string) => Promise<Result<unknown>> }) {
17+ const [value, setValue] = useState(title);
18+ const [error, setError] = useState<string | null>(null);
19+ const [busy, setBusy] = useState(false);
20+ useEffect(() => {
21+ if (open) {
22+ setValue(title);
23+ setError(null);
24+ }
25+ }, [open, title]);
26+ return (
27+ <Dialog open={open} onOpenChange={onOpenChange}>
28+ <DialogContent>
29+ <form
30+ onSubmit={async (e) => {
31+ e.preventDefault();
32+ setBusy(true);
33+ const done = await onSave(value.trim());
34+ setBusy(false);
35+ if (done.ok) onOpenChange(false);
36+ else setError(done.error.message);
37+ }}
38+ >
39+ <DialogHeader>
40+ <DialogTitle>Rename</DialogTitle>
41+ <DialogDescription>Its link keeps working: the address ends in its id.</DialogDescription>
42+ </DialogHeader>
43+ <div className="mt-4 space-y-3">
44+ <input aria-label="Title" autoFocus maxLength={FOLIO_MAX_TITLE} placeholder="Untitled" value={value} onChange={(e) => setValue(e.target.value)} className={FIELD} />
45+ {error && <ErrorText>{error}</ErrorText>}
46+ </div>
47+ <DialogFooter className="mt-4">
48+ <Button type="submit" variant="accent" disabled={busy}>
49+ {busy ? "Saving…" : "Rename"}
50+ </Button>
51+ </DialogFooter>
52+ </form>
53+ </DialogContent>
54+ </Dialog>
55+ );
56+}
57+
58+/**
59+ * Where an artifact can go: the top of a space the viewer can edit, or of
60+ * their own Private, or inside a doc there. Never inside itself or
61+ * anything under it.
62+ */
63+export function MoveDialog({
64+ sidebar,
65+ folio,
66+ me,
67+ open,
68+ onOpenChange,
69+ onMove,
70+}: {
71+ sidebar: FoliosSidebar | null | undefined;
72+ folio: Pick<Folio, "id" | "title" | "space" | "parent_id" | "owner">;
73+ /** The viewer's member key: only an artifact's owner can move it to their Private. */
74+ me: string;
75+ open: boolean;
76+ onOpenChange: (o: boolean) => void;
77+ onMove: (spaceId: string | null, parentId: string | null) => Promise<Result<unknown>>;
78+}) {
79+ const mine = `user:${folio.owner.id}` === me;
80+ const places = useMemo(() => {
81+ const out: { value: string; label: string; tree: ReturnType<typeof buildTree> }[] = [];
82+ if (mine) out.push({ value: "private", label: "Private (just you)", tree: buildTree(sidebar?.private_tree ?? []) });
83+ for (const s of sidebar?.spaces ?? []) if (canDo(s.viewer_role, "edit")) out.push({ value: s.id, label: s.name, tree: buildTree(s.tree) });
84+ return out;
85+ }, [sidebar, mine]);
86+ const [place, setPlace] = useState(folio.space?.id ?? "private");
87+ const [parent, setParent] = useState(folio.parent_id ?? "");
88+ const [error, setError] = useState<string | null>(null);
89+ const [busy, setBusy] = useState(false);
90+ useEffect(() => {
91+ if (!open) return;
92+ setPlace(folio.space?.id ?? (mine ? "private" : (places[0]?.value ?? "")));
93+ setParent(folio.parent_id ?? "");
94+ setError(null);
95+ }, [open, folio.space?.id, folio.parent_id, mine, places]);
96+ const docs = useMemo(() => {
97+ const tree = flatten(places.find((p) => p.value === place)?.tree ?? []);
98+ const hidden = new Set<string>([folio.id]);
99+ for (const item of tree) if (item.parent_id && hidden.has(item.parent_id)) hidden.add(item.id);
100+ return tree.filter((i) => i.kind === "doc" && !hidden.has(i.id));
101+ }, [places, place, folio.id]);
102+ return (
103+ <Dialog open={open} onOpenChange={onOpenChange}>
104+ <DialogContent>
105+ <DialogHeader>
106+ <DialogTitle>Move “{folio.title || "Untitled"}”</DialogTitle>
107+ <DialogDescription>Everything inside it moves with it. In a space, it follows the space&apos;s access unless it&apos;s set to only people invited.</DialogDescription>
108+ </DialogHeader>
109+ {places.length === 0 ? (
110+ <p className="text-sm text-muted">There&apos;s nowhere you can move it: you can&apos;t add to any space, and only its owner can move it to their Private.</p>
111+ ) : (
112+ <div className="space-y-3">
113+ <SelectField
114+ aria-label="Where"
115+ value={place}
116+ onValueChange={(v) => {
117+ setPlace(v);
118+ setParent("");
119+ }}
120+ options={places.map((p) => ({ value: p.value, label: p.label }))}
121+ className="h-9 w-full"
122+ />
123+ <SelectField
124+ aria-label="Inside"
125+ value={parent}
126+ onValueChange={setParent}
127+ options={[{ value: "", label: "At the top" }, ...docs.map((p) => ({ value: p.id, label: `${" ".repeat(p.depth)}${p.icon ?? ""} ${p.title || "Untitled"}`.trim() }))]}
128+ className="h-9 w-full"
129+ />
130+ {error && <ErrorText>{error}</ErrorText>}
131+ </div>
132+ )}
133+ <DialogFooter>
134+ <Button
135+ type="button"
136+ variant="accent"
137+ disabled={busy || places.length === 0}
138+ onClick={async () => {
139+ setBusy(true);
140+ const done = await onMove(place === "private" ? null : place, parent || null);
141+ setBusy(false);
142+ if (done.ok) onOpenChange(false);
143+ else setError(done.error.message);
144+ }}
145+ >
146+ {busy ? "Moving…" : "Move"}
147+ </Button>
148+ </DialogFooter>
149+ </DialogContent>
150+ </Dialog>
151+ );
152+}
153+
154+/** Save as a template. From something not everyone can read, it says so first: the template is everyone's (plan section 4.3 rule 8). */
155+export function TemplateDialog({ open, onOpenChange, title, widens, onSave }: { open: boolean; onOpenChange: (o: boolean) => void; title: string; widens: boolean; onSave: (name: string, description: string) => Promise<Result<unknown>> }) {
156+ const [name, setName] = useState(title);
157+ const [description, setDescription] = useState("");
158+ const [error, setError] = useState<string | null>(null);
159+ useEffect(() => {
160+ if (open) {
161+ setName(title);
162+ setError(null);
163+ }
164+ }, [open, title]);
165+ return (
166+ <Dialog open={open} onOpenChange={onOpenChange}>
167+ <DialogContent>
168+ <DialogHeader>
169+ <DialogTitle>Save as a template</DialogTitle>
170+ <DialogDescription>Everyone in the workspace will be able to use this template.</DialogDescription>
171+ </DialogHeader>
172+ <div className="space-y-3">
173+ {widens && <p className="rounded-md border border-warn/30 bg-warn/10 px-3 py-2 text-xs leading-relaxed text-warn">Not everyone in the workspace can open this now. Its content will be in the template, which everyone can use.</p>}
174+ <input aria-label="Name" value={name} onChange={(e) => setName(e.target.value)} className={FIELD} />
175+ <input aria-label="What it's for" placeholder="What it's for" value={description} onChange={(e) => setDescription(e.target.value)} className={FIELD} />
176+ {error && <ErrorText>{error}</ErrorText>}
177+ </div>
178+ <DialogFooter>
179+ <Button
180+ type="button"
181+ variant="accent"
182+ onClick={async () => {
183+ const done = await onSave(name, description);
184+ if (done.ok) onOpenChange(false);
185+ else setError(done.error.message);
186+ }}
187+ >
188+ Save template
189+ </Button>
190+ </DialogFooter>
191+ </DialogContent>
192+ </Dialog>
193+ );
194+}
+338−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, dates, and citations of code (a file, folder
6+ * or pattern in a repository, and the symbol, endpoint or environment
7+ * variable there the page describes). The docs service reads the same
8+ * names and attributes when it writes Markdown (services/docs
9+ * src/markdown.ts), so keep the two in step. Browser-only: loaded with
10+ * the editor.
11+ */
12+import { BlockNoteSchema, createCodeBlockSpec, defaultBlockSpecs, defaultInlineContentSpecs } from "@blocknote/core";
13+import { createReactBlockSpec, createReactInlineContentSpec } from "@blocknote/react";
14+import { codeBlockOptions } from "@blocknote/code-block";
15+import { AlertTriangle, Braces, CheckCircle2, CircleDot, FileCode2, FileText, GitPullRequest, Hash, Info, KeyRound, OctagonAlert, Package, Route } from "lucide-react";
16+import { useEffect, useId, useState, type ReactNode } from "react";
17+
18+import { citationHref } from "../../../lib/folios";
19+
20+/** What an embed shows, from the site (`-/artifacts/api?embed=`). */
21+export type EmbedCard = { kind: "issue" | "pull" | "channel" | "project" | "page" | "link"; title: string; subtitle: string | null; state: string | null; href: string };
22+
23+const CALLOUTS = {
24+ info: { icon: <Info size={16} />, ring: "border-info/30 bg-info/8", text: "text-info" },
25+ warning: { icon: <AlertTriangle size={16} />, ring: "border-warn/30 bg-warn/8", text: "text-warn" },
26+ success: { icon: <CheckCircle2 size={16} />, ring: "border-success/30 bg-success/8", text: "text-success" },
27+ danger: { icon: <OctagonAlert size={16} />, ring: "border-danger/30 bg-danger/8", text: "text-danger" },
28+} as const;
29+
30+export const Callout = createReactBlockSpec(
31+ {
32+ type: "callout",
33+ propSchema: {
34+ kind: { default: "info", values: ["info", "warning", "success", "danger"] },
35+ backgroundColor: { default: "default" },
36+ textColor: { default: "default" },
37+ },
38+ content: "inline",
39+ },
40+ {
41+ render: ({ block, editor, contentRef }) => {
42+ const look = CALLOUTS[block.props.kind as keyof typeof CALLOUTS] ?? CALLOUTS.info;
43+ const next = { info: "warning", warning: "success", success: "danger", danger: "info" } as const;
44+ return (
45+ <div className={`my-1 flex w-full gap-2.5 rounded-lg border px-3.5 py-2.5 ${look.ring}`}>
46+ <button
47+ type="button"
48+ contentEditable={false}
49+ aria-label="Change the callout's kind"
50+ disabled={!editor.isEditable}
51+ onClick={() => editor.updateBlock(block, { props: { kind: next[block.props.kind as keyof typeof next] ?? "info" } })}
52+ className={`mt-0.5 shrink-0 ${look.text}`}
53+ >
54+ {look.icon}
55+ </button>
56+ <div ref={contentRef} className="min-w-0 grow" />
57+ </div>
58+ );
59+ },
60+ },
61+);
62+
63+/** A diagram drawn from its Mermaid source, live as it is typed. */
64+function MermaidView({ code }: { code: string }) {
65+ const id = useId().replace(/[^a-zA-Z0-9]/g, "");
66+ const [svg, setSvg] = useState<string | null>(null);
67+ const [error, setError] = useState<string | null>(null);
68+ useEffect(() => {
69+ let cancelled = false;
70+ const timer = setTimeout(async () => {
71+ try {
72+ const mermaid = (await import("mermaid")).default;
73+ mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict", fontFamily: "inherit" });
74+ const { svg } = await mermaid.render(`mermaid-${id}-${Date.now()}`, code || "flowchart LR\n A --> B");
75+ if (!cancelled) {
76+ setSvg(svg);
77+ setError(null);
78+ }
79+ } catch (e) {
80+ if (!cancelled) setError(e instanceof Error ? e.message.split("\n")[0]! : "This diagram has an error.");
81+ }
82+ }, 250);
83+ return () => {
84+ cancelled = true;
85+ clearTimeout(timer);
86+ };
87+ }, [code, id]);
88+ if (error) return <p className="text-xs text-danger">{error}</p>;
89+ if (!svg) return <p className="text-xs text-faint">Drawing…</p>;
90+ // Mermaid sanitizes what it draws (securityLevel "strict").
91+ return <div className="flex justify-center overflow-x-auto [&_svg]:max-w-full" dangerouslySetInnerHTML={{ __html: svg }} />;
92+}
93+
94+function SourceEditor({ value, onChange, placeholder, mono = true, editable }: { value: string; onChange: (v: string) => void; placeholder: string; mono?: boolean; editable: boolean }) {
95+ const [draft, setDraft] = useState(value);
96+ useEffect(() => setDraft(value), [value]);
97+ if (!editable) return null;
98+ return (
99+ <textarea
100+ value={draft}
101+ placeholder={placeholder}
102+ spellCheck={false}
103+ rows={Math.min(14, Math.max(2, draft.split("\n").length))}
104+ onChange={(e) => setDraft(e.target.value)}
105+ onBlur={() => draft !== value && onChange(draft)}
106+ onKeyDown={(e) => e.stopPropagation()}
107+ 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" : ""}`}
108+ />
109+ );
110+}
111+
112+export const Mermaid = createReactBlockSpec(
113+ { type: "mermaid", propSchema: { code: { default: "flowchart LR\n A[Idea] --> B[Spec] --> C[Shipped]" } }, content: "none" },
114+ {
115+ render: ({ block, editor }) => {
116+ const [editing, setEditing] = useState(false);
117+ return (
118+ <div className="my-1 w-full rounded-lg border border-line bg-surface p-3" contentEditable={false}>
119+ <div className="mb-2 flex items-center justify-between text-[0.6875rem] font-medium tracking-wide text-faint uppercase">
120+ <span>Diagram</span>
121+ {editor.isEditable && (
122+ <button type="button" onClick={() => setEditing(!editing)} className="rounded px-1.5 py-0.5 normal-case hover:bg-raised hover:text-fg">
123+ {editing ? "Done" : "Edit source"}
124+ </button>
125+ )}
126+ </div>
127+ {editing && <SourceEditor editable value={block.props.code} placeholder="flowchart LR&#10; A --> B" onChange={(code) => editor.updateBlock(block, { props: { code } })} />}
128+ <div className={editing ? "mt-3" : ""}>
129+ <MermaidView code={block.props.code} />
130+ </div>
131+ </div>
132+ );
133+ },
134+ },
135+);
136+
137+/** A formula, typeset with KaTeX. */
138+function MathView({ expression, inline = false }: { expression: string; inline?: boolean }) {
139+ const [html, setHtml] = useState<string | null>(null);
140+ useEffect(() => {
141+ let cancelled = false;
142+ void (async () => {
143+ const [{ default: katex }] = await Promise.all([import("katex"), import("katex/dist/katex.min.css")]);
144+ const out = katex.renderToString(expression || "\\;", { throwOnError: false, displayMode: !inline, output: "html", trust: false, strict: "ignore" });
145+ if (!cancelled) setHtml(out);
146+ })();
147+ return () => {
148+ cancelled = true;
149+ };
150+ }, [expression, inline]);
151+ if (html === null) return <code className="text-xs text-faint">{expression}</code>;
152+ // KaTeX escapes what it is given and allows no commands that run anything (trust: false).
153+ return <div className="overflow-x-auto text-fg" dangerouslySetInnerHTML={{ __html: html }} />;
154+}
155+
156+export const MathBlock = createReactBlockSpec(
157+ { type: "math", propSchema: { expression: { default: "e^{i\\pi} + 1 = 0" } }, content: "none" },
158+ {
159+ render: ({ block, editor }) => {
160+ const [editing, setEditing] = useState(false);
161+ return (
162+ <div className="group/math my-1 w-full rounded-lg px-3 py-2 hover:bg-surface" contentEditable={false}>
163+ {editing ? (
164+ <SourceEditor editable value={block.props.expression} placeholder="\int_0^1 x^2\,dx" onChange={(expression) => editor.updateBlock(block, { props: { expression } })} />
165+ ) : null}
166+ <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"}>
167+ <MathView expression={block.props.expression} />
168+ </button>
169+ </div>
170+ );
171+ },
172+ },
173+);
174+
175+const EMBED_ICONS: Record<EmbedCard["kind"], ReactNode> = {
176+ issue: <CircleDot size={16} />,
177+ pull: <GitPullRequest size={16} />,
178+ channel: <Hash size={16} />,
179+ project: <Package size={16} />,
180+ page: <FileText size={16} />,
181+ link: <FileText size={16} />,
182+};
183+
184+/** The card for a g1t address, asked of the site as the reader (so it shows only what they may see). */
185+function EmbedView({ url, title }: { url: string; title: string }) {
186+ const [card, setCard] = useState<EmbedCard | null | "missing">(null);
187+ useEffect(() => {
188+ let cancelled = false;
189+ const workspace = window.location.pathname.split("/")[1] ?? "";
190+ fetch(`/${workspace}/-/artifacts/api?embed=${encodeURIComponent(url)}`, { headers: { accept: "application/json" } })
191+ .then((r) => r.json() as Promise<{ ok: boolean; value?: EmbedCard }>)
192+ .then((r) => !cancelled && setCard(r.ok && r.value ? r.value : "missing"))
193+ .catch(() => !cancelled && setCard("missing"));
194+ return () => {
195+ cancelled = true;
196+ };
197+ }, [url]);
198+ const shown = card && card !== "missing" ? card : null;
199+ const state = shown?.state ?? null;
200+ const tone = state === "open" ? "text-success" : state === "merged" ? "text-merged" : state === "closed" ? "text-danger" : "text-[var(--g1t-muted)]";
201+ return (
202+ <a
203+ href={shown?.href ?? url}
204+ contentEditable={false}
205+ 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"
206+ >
207+ <span className={`shrink-0 ${tone}`}>{EMBED_ICONS[shown?.kind ?? "link"]}</span>
208+ <span className="min-w-0 grow">
209+ <span className="block truncate text-sm font-medium text-fg">{shown?.title ?? (title || url)}</span>
210+ <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>
211+ </span>
212+ {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>}
213+ </a>
214+ );
215+}
216+
217+export const Embed = createReactBlockSpec(
218+ { type: "embed", propSchema: { url: { default: "" }, title: { default: "" }, kind: { default: "link" } }, content: "none" },
219+ {
220+ render: ({ block, editor }) => {
221+ const [draft, setDraft] = useState("");
222+ if (!block.props.url) {
223+ if (!editor.isEditable) return <div contentEditable={false} />;
224+ return (
225+ <form
226+ contentEditable={false}
227+ className="my-1 flex w-full gap-2 rounded-lg border border-dashed border-line p-2"
228+ onSubmit={(e) => {
229+ e.preventDefault();
230+ const url = draft.trim();
231+ if (url) editor.updateBlock(block, { props: { url } });
232+ }}
233+ >
234+ <input
235+ autoFocus
236+ value={draft}
237+ onChange={(e) => setDraft(e.target.value)}
238+ onKeyDown={(e) => e.stopPropagation()}
239+ placeholder="Paste the address of an issue, pull request, channel, project or artifact"
240+ className="min-w-0 grow bg-transparent px-1.5 text-sm text-fg outline-none placeholder:text-faint"
241+ />
242+ <button type="submit" className="rounded-md bg-raised px-2.5 py-1 text-xs font-medium text-fg hover:bg-line">
243+ Embed
244+ </button>
245+ </form>
246+ );
247+ }
248+ return <EmbedView url={block.props.url} title={block.props.title} />;
249+ },
250+ },
251+);
252+
253+/** A person, agent or page, inline. */
254+export const Mention = createReactInlineContentSpec(
255+ {
256+ type: "mention",
257+ propSchema: { kind: { default: "user" }, id: { default: "" }, name: { default: "" }, href: { default: "" } },
258+ content: "none",
259+ },
260+ {
261+ render: ({ inlineContent }) => {
262+ const p = inlineContent.props;
263+ if (p.kind === "page") {
264+ return (
265+ <a href={p.href || "#"} className="rounded px-0.5 font-medium text-[var(--g1t-accent)] no-underline hover:underline">
266+ <FileText size={13} className="mr-0.5 inline -translate-y-px" aria-hidden="true" />
267+ {p.name || "Untitled"}
268+ </a>
269+ );
270+ }
271+ 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>;
272+ },
273+ },
274+);
275+
276+/** A date, inline. */
277+export const DateChip = createReactInlineContentSpec(
278+ { type: "date", propSchema: { date: { default: "" } }, content: "none" },
279+ {
280+ render: ({ inlineContent }) => {
281+ const at = new Date(`${inlineContent.props.date}T12:00:00`);
282+ const label = Number.isNaN(at.getTime()) ? inlineContent.props.date : at.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", year: "numeric" });
283+ return <span className="rounded bg-raised px-1 py-px text-[0.9em] text-fg-soft">{label}</span>;
284+ },
285+ },
286+);
287+
288+const CITATION_ICONS: Record<string, ReactNode> = {
289+ path: <FileCode2 size={12} aria-hidden="true" />,
290+ symbol: <Braces size={12} aria-hidden="true" />,
291+ endpoint: <Route size={12} aria-hidden="true" />,
292+ env: <KeyRound size={12} aria-hidden="true" />,
293+};
294+
295+/**
296+ * Code the page cites, inline: what it names (the path, or the symbol,
297+ * endpoint or variable there), linking to it in Code at the commit it was
298+ * cited at. When a merge changes it, the page is marked possibly out of
299+ * date (services/docs src/staleness.ts).
300+ */
301+export const Citation = createReactInlineContentSpec(
302+ {
303+ type: "citation",
304+ propSchema: { repo: { default: "" }, path: { default: "" }, kind: { default: "path" }, label: { default: "" }, ref: { default: "" } },
305+ content: "none",
306+ },
307+ {
308+ render: ({ inlineContent }) => {
309+ const p = inlineContent.props;
310+ const shown = p.kind !== "path" && p.label ? p.label : p.path;
311+ return (
312+ <a
313+ href={citationHref({ repo: p.repo, path: p.path, ref: p.ref || null })}
314+ aria-label={`${shown} in ${p.repo}${p.kind !== "path" ? `, ${p.path}` : ""}`}
315+ className="inline-flex items-center gap-1 rounded border border-line bg-raised px-1 py-px align-baseline font-mono text-[0.85em] text-fg no-underline hover:border-line-strong"
316+ >
317+ <span className="text-faint">{CITATION_ICONS[p.kind] ?? CITATION_ICONS.path}</span>
318+ {shown}
319+ </a>
320+ );
321+ },
322+ },
323+);
324+
325+export const schema = BlockNoteSchema.create({
326+ blockSpecs: {
327+ ...defaultBlockSpecs,
328+ codeBlock: createCodeBlockSpec(codeBlockOptions),
329+ callout: Callout(),
330+ mermaid: Mermaid(),
331+ math: MathBlock(),
332+ embed: Embed(),
333+ },
334+ inlineContentSpecs: { ...defaultInlineContentSpecs, mention: Mention, date: DateChip, citation: Citation },
335+});
336+
337+export type DocSchema = typeof schema;
338+export type DocEditorInstance = typeof schema.BlockNoteEditor;
+343−0
1+/**
2+ * A doc's page under the shared header: its icon and title, who
3+ * edited it and where it came from, agents' suggestions, the live editor
4+ * (or its saved Markdown until the editor loads, and for a reader without
5+ * script), what is inside it and what links to it, and comments on the
6+ * whole doc. The editor and everything it brings load only in the
7+ * browser, after the page shows.
8+ */
9+import type { FolioSuggestion, FoliosLiveEvent, MemberProfile } from "@g1t/contracts";
10+import { AlertTriangle, Check, MessageSquareQuote, SmilePlus, Sparkles } from "lucide-react";
11+import { type ComponentType, Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
12+import { Link, useNavigate, useRevalidator, useRouteLoaderData } from "react-router";
13+
14+import { usercontentFrom } from "../../../lib/addresses";
15+import { canDo, readingTime } from "../../../lib/folios";
16+import { Markdown } from "../../markdown";
17+import { TimeAgo } from "../../ui";
18+import { foliosRequest, useFoliosData } from "../actions";
19+import { FolioGlyph, type FolioBodyProps } from "../kinds";
20+import { Face } from "../parts";
21+import { type Who, whoAre } from "../who";
22+import type { DocEditorProps } from "./editor";
23+import { EditorSkeleton } from "./editor-skeleton";
24+import { Discussion, IconPicker, SuggestionCard, type PageThread } from "./page-parts";
25+
26+/** The BlockNote editor, loaded only in the browser: the server renders the doc's Markdown instead. */
27+const DocEditor = lazy((): Promise<{ default: ComponentType<DocEditorProps> }> => (import.meta.env.SSR ? Promise.resolve({ default: () => null }) : import("./editor")));
28+
29+function useHydrated() {
30+ const [hydrated, setHydrated] = useState(false);
31+ useEffect(() => setHydrated(true), []);
32+ return hydrated;
33+}
34+
35+export default function DocBody({ slug, page, folio, role, showComments, onPresence, onStatus, onFolio, onRole, onError }: FolioBodyProps) {
36+ const layout = useFoliosData();
37+ const root = useRouteLoaderData("root");
38+ const usercontent = usercontentFrom(root);
39+ const navigate = useNavigate();
40+ const revalidator = useRevalidator();
41+ const hydrated = useHydrated();
42+ const editable = canDo(role, "edit");
43+ const send = useCallback(
44+ async (intent: string, body: Record<string, unknown>, reload = true) => {
45+ const done = await foliosRequest(slug, intent, { folio_id: folio.id, ...body });
46+ if (!done.ok) onError(done.error.message);
47+ else if (reload) void revalidator.revalidate();
48+ return done;
49+ },
50+ [slug, folio.id, onError, revalidator],
51+ );
52+
53+ // The title follows the folio, and anyone renaming it live.
54+ const [title, setTitle] = useState(folio.title);
55+ const titleFocused = useRef(false);
56+ useEffect(() => {
57+ if (!titleFocused.current) setTitle(folio.title);
58+ }, [folio.title]);
59+ const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
60+ const saveTitle = (next: string) => {
61+ setTitle(next);
62+ onFolio({ ...folio, title: next });
63+ if (saveTimer.current) clearTimeout(saveTimer.current);
64+ saveTimer.current = setTimeout(() => void send("update", { change: { title: next } }, false), 700);
65+ };
66+
67+ const [suggestions, setSuggestions] = useState<FolioSuggestion[]>(page.suggestions);
68+ useEffect(() => setSuggestions(page.suggestions), [page.suggestions]);
69+ const [threads, setThreads] = useState<PageThread[]>([]);
70+ const [names, setNames] = useState<Map<string, Who>>(new Map());
71+ useEffect(() => {
72+ const keys = threads.flatMap((t) => t.comments.map((c) => c.author));
73+ if (keys.some((k) => !names.has(k))) void whoAre(slug, keys).then((found) => setNames((was) => new Map([...was, ...found])));
74+ }, [threads, slug, names]);
75+
76+ const onEvent = useCallback(
77+ (event: FoliosLiveEvent) => {
78+ if (event.type === "folio.updated") onFolio(event.folio);
79+ else if (event.type === "folio.trashed") void revalidator.revalidate();
80+ else if (event.type === "access") {
81+ onRole(event.role);
82+ if (!event.role) navigate(`/${slug}/-/artifacts`);
83+ } else if (event.type === "suggestion.created") setSuggestions((was) => [...was.filter((s) => s.id !== event.suggestion.id), event.suggestion]);
84+ 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)));
85+ else if (event.type === "version.created" || event.type === "folio.staleness" || event.type === "folio.access") void revalidator.revalidate();
86+ },
87+ [navigate, slug, revalidator, onFolio, onRole],
88+ );
89+
90+ const decide = async (id: string, decision: "accept" | "reject") => {
91+ setSuggestions((was) => was.filter((s) => s.id !== id));
92+ const done = await send("decide", { suggestion_id: id, decision }, false);
93+ if (!done.ok) void revalidator.revalidate();
94+ };
95+ const acceptAll = async () => {
96+ const all = suggestions;
97+ setSuggestions([]);
98+ for (const s of all) await send("decide", { suggestion_id: s.id, decision: "accept" }, false);
99+ void revalidator.revalidate();
100+ };
101+
102+ const people = useCallback(
103+ (key: string): Pick<MemberProfile, "kind" | "id" | "name" | "display_name" | "avatar" | "avatar_seed"> => {
104+ const at = key.indexOf(":");
105+ const kind = key.slice(0, at) === "agent" ? "agent" : "user";
106+ const id = key.slice(at + 1);
107+ 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 };
108+ const found = names.get(key);
109+ 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 };
110+ },
111+ [layout, names],
112+ );
113+
114+ const threadRequest = async (path: string, method: string, body: unknown) => {
115+ try {
116+ const r = await fetch(`/${slug}/-/artifacts/threads/${folio.id}${path}`, { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
117+ if (!r.ok) {
118+ const result = (await r.json().catch(() => null)) as { error?: { message?: string } } | null;
119+ onError(result?.error?.message ?? "That comment didn't go through.");
120+ return false;
121+ }
122+ return true;
123+ } catch {
124+ onError("That comment didn't go through.");
125+ return false;
126+ }
127+ };
128+ /** A comment's text as the editor's comments hold it: blocks, with @mentions as mentions. */
129+ const commentBody = (text: string) => {
130+ const parts: unknown[] = [];
131+ for (const piece of text.split(/(@[a-z0-9][a-z0-9-]*)/i)) {
132+ const who = piece.startsWith("@") ? layout?.mentionables.find((m) => m.name.toLowerCase() === piece.slice(1).toLowerCase()) : null;
133+ if (who) parts.push({ type: "mention", props: { kind: who.kind, id: who.id, name: who.name, href: "" } });
134+ else if (piece) parts.push({ type: "text", text: piece, styles: {} });
135+ }
136+ return [{ type: "paragraph", content: parts, children: [] }];
137+ };
138+
139+ const reading = useMemo(() => readingTime(page.text), [page.text]);
140+ const trashed = !!folio.trashed_at;
141+
142+ return (
143+ <div className="-mt-6">
144+ <article className={`mx-auto pt-10 ${showComments ? "max-w-6xl" : "max-w-3xl"}`}>
145+ <div className={showComments ? "xl:max-w-3xl" : ""}>
146+ {trashed && (
147+ <p className="mb-6 rounded-lg border border-warn/30 bg-warn/10 px-3 py-2 text-sm text-warn">
148+ This is in the trash.{" "}
149+ <Link to={`/${slug}/-/artifacts/trash`} className="underline">
150+ Open the trash
151+ </Link>{" "}
152+ to restore it.
153+ </p>
154+ )}
155+ {/* Icon and title. */}
156+ <div className="group">
157+ {folio.icon && (
158+ <IconPicker value={folio.icon} onChange={(icon) => editable && send("update", { change: { icon } })}>
159+ <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">
160+ {folio.icon}
161+ </button>
162+ </IconPicker>
163+ )}
164+ {editable && !trashed && (
165+ <div className="mb-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100">
166+ {!folio.icon && (
167+ <IconPicker value={null} onChange={(icon) => send("update", { change: { icon } })}>
168+ <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">
169+ <SmilePlus size={13} /> Add icon
170+ </button>
171+ </IconPicker>
172+ )}
173+ </div>
174+ )}
175+ {editable && !trashed ? (
176+ <textarea
177+ value={title}
178+ rows={1}
179+ placeholder="Untitled"
180+ aria-label="Title"
181+ onFocus={() => (titleFocused.current = true)}
182+ onBlur={() => (titleFocused.current = false)}
183+ onChange={(e) => saveTitle(e.target.value.replace(/\n/g, " "))}
184+ onKeyDown={(e) => {
185+ if (e.key === "Enter") {
186+ e.preventDefault();
187+ (document.querySelector(".g1t-editor .bn-editor") as HTMLElement | null)?.focus();
188+ }
189+ }}
190+ 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"
191+ />
192+ ) : (
193+ <h1 className="font-display text-4xl leading-tight font-semibold tracking-tight">{folio.title || "Untitled"}</h1>
194+ )}
195+ </div>
196+ {/* Who wrote it, who owns it, where it came from. */}
197+ <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-faint">
198+ {folio.edited_by && (
199+ <span className="flex items-center gap-1.5">
200+ <Face who={folio.edited_by} size={16} />
201+ Edited by <span className="text-muted">{folio.edited_by.display_name}</span> · <TimeAgo at={folio.edited_at} />
202+ </span>
203+ )}
204+ <span className="flex items-center gap-1.5">
205+ Owner <Face who={folio.owner} size={16} /> <span className="text-muted">{folio.owner.display_name}</span>
206+ </span>
207+ {folio.source && (
208+ <Link to={folio.source.href} className="inline-flex items-center gap-1 hover:text-fg">
209+ <MessageSquareQuote size={13} /> From a conversation
210+ </Link>
211+ )}
212+ <span>
213+ {reading.words.toLocaleString()} words · {reading.minutes} min read
214+ </span>
215+ {!editable && <span className="rounded-full bg-raised px-2 py-0.5">{role === "comment" ? "Can comment" : "Can view"}</span>}
216+ </div>
217+ {folio.stale && (
218+ <div className="mt-5 flex flex-wrap items-center gap-3 rounded-lg border border-warn/30 bg-warn/8 px-3 py-2.5 text-sm">
219+ <AlertTriangle size={15} className="shrink-0 text-warn" />
220+ <span className="min-w-0 grow text-fg-soft">Possibly out of date: code this doc cites changed since it was last brought up to date.</span>
221+ {editable && (
222+ <button type="button" onClick={() => send("mark_current", {})} className="text-xs font-medium text-warn hover:underline">
223+ It&apos;s current
224+ </button>
225+ )}
226+ </div>
227+ )}
228+
229+ {/* Agents' suggestions, all together on a narrower screen (beside their blocks on a wide one). */}
230+ {suggestions.length > 0 && (
231+ <div className="mt-6 rounded-xl border border-accent/30 bg-accent/5 p-3 2xl:hidden">
232+ <div className="mb-2 flex items-center gap-2">
233+ <Sparkles size={14} className="text-accent" />
234+ <span className="grow text-sm font-medium">
235+ {suggestions.length} {suggestions.length === 1 ? "suggestion" : "suggestions"} waiting
236+ </span>
237+ {editable && suggestions.length > 1 && (
238+ <button type="button" onClick={acceptAll} className="text-xs font-medium text-accent hover:underline">
239+ Accept all
240+ </button>
241+ )}
242+ </div>
243+ <div className="space-y-2">
244+ {suggestions.map((s) => (
245+ <SuggestionCard key={s.id} suggestion={s} canDecide={editable} onDecide={decide} compact />
246+ ))}
247+ </div>
248+ </div>
249+ )}
250+ {suggestions.length > 1 && editable && (
251+ <div className="mt-4 hidden justify-end 2xl:flex">
252+ <button type="button" onClick={acceptAll} 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">
253+ <Check size={13} /> Accept all {suggestions.length} suggestions
254+ </button>
255+ </div>
256+ )}
257+ </div>
258+
259+ {/* The doc itself: live once the editor loads; its saved Markdown until then. */}
260+ <div className="mt-6">
261+ {hydrated && !trashed ? (
262+ <Suspense fallback={<ReadView markdown={page.text} />}>
263+ <DocEditor
264+ key={folio.id}
265+ slug={slug}
266+ folioId={folio.id}
267+ role={role}
268+ me={layout?.me ?? { key: "", name: "", display_name: "", avatar: null }}
269+ mentionables={layout?.mentionables ?? []}
270+ usercontent={usercontent}
271+ suggestions={suggestions}
272+ showComments={showComments}
273+ onPresence={onPresence}
274+ onStatus={onStatus}
275+ onEvent={onEvent}
276+ onPageThreads={setThreads}
277+ renderSuggestion={(s) => <SuggestionCard suggestion={s} canDecide={editable} onDecide={decide} />}
278+ projects={[]}
279+ />
280+ </Suspense>
281+ ) : (
282+ <ReadView markdown={page.text} />
283+ )}
284+ </div>
285+
286+ <div className={showComments ? "xl:max-w-3xl" : ""}>
287+ {(page.children.length > 0 || page.backlinks.length > 0) && (
288+ <div className="mt-14 grid gap-6 border-t border-line pt-8 sm:grid-cols-2">
289+ {page.children.length > 0 && (
290+ <section>
291+ <h2 className="mb-2 text-xs font-medium tracking-wide text-faint uppercase">Inside</h2>
292+ <ul className="space-y-1">
293+ {page.children.map((c) => (
294+ <li key={c.id}>
295+ <Link to={c.path} className="flex items-center gap-2 rounded-md px-2 py-1 text-sm hover:bg-raised">
296+ <FolioGlyph folio={c} /> {c.title || "Untitled"}
297+ </Link>
298+ </li>
299+ ))}
300+ </ul>
301+ </section>
302+ )}
303+ {page.backlinks.length > 0 && (
304+ <section>
305+ <h2 className="mb-2 text-xs font-medium tracking-wide text-faint uppercase">Linked from</h2>
306+ <ul className="space-y-1">
307+ {page.backlinks.map((c) => (
308+ <li key={c.id}>
309+ <Link to={c.path} className="flex items-center gap-2 rounded-md px-2 py-1 text-sm hover:bg-raised">
310+ <FolioGlyph folio={c} /> {c.title || "Untitled"}
311+ </Link>
312+ </li>
313+ ))}
314+ </ul>
315+ </section>
316+ )}
317+ </div>
318+ )}
319+ {hydrated && (
320+ <Discussion
321+ threads={threads}
322+ people={people}
323+ canComment={canDo(role, "comment")}
324+ onPost={(text) => threadRequest("", "POST", { initialComment: { body: commentBody(text) }, page_level: true })}
325+ onReply={(thread, text) => threadRequest(`/${encodeURIComponent(thread)}/comments`, "POST", { comment: { body: commentBody(text) } })}
326+ onResolve={(thread, resolved) => void threadRequest(`/${encodeURIComponent(thread)}/${resolved ? "resolve" : "unresolve"}`, "POST", {})}
327+ />
328+ )}
329+ </div>
330+ </article>
331+ </div>
332+ );
333+}
334+
335+/** The doc as Markdown: what shows before the editor loads, and what a reader without script sees. */
336+function ReadView({ markdown }: { markdown: string }) {
337+ if (!markdown.trim()) return <EditorSkeleton />;
338+ return (
339+ <div className="docs-read">
340+ <Markdown source={markdown} />
341+ </div>
342+ );
343+}
+185−0
1+/**
2+ * Docs and code: citing code in a doc (the Cite code dialog), and showing
3+ * a project's docs folder in Artifacts. Plan: docs/WORKSPACE.md, "Agents
4+ * and docs" and "Docs and repository docs".
5+ */
6+import { DOC_CITATION_KIND_LABELS, type DocCitationKind, type DocRepoSpace } from "@g1t/contracts";
7+import { FileCode2, FolderGit2 } from "lucide-react";
8+import { useEffect, useState } from "react";
9+
10+import { Button, ErrorText, Field, Input } from "../../ui";
11+import { Combobox } from "../../ui/combobox";
12+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog";
13+import { SelectField } from "../../ui/select";
14+import { foliosQuery as docsQuery, foliosRequest as docsRequest } from "../actions";
15+
16+export type RepoChoice = { repo: string; default_branch: string; private: boolean };
17+
18+/** The workspace's repositories the viewer can read, asked for once the dialog that needs them opens. */
19+export function useRepoChoices(slug: string, wanted: boolean): { repos: RepoChoice[] | null; error: string | null } {
20+ const [repos, setRepos] = useState<RepoChoice[] | null>(null);
21+ const [error, setError] = useState<string | null>(null);
22+ useEffect(() => {
23+ if (!wanted || repos) return;
24+ let cancelled = false;
25+ void docsQuery<RepoChoice[]>(slug, { repos: "1" }).then((r) => {
26+ if (cancelled) return;
27+ if (r.ok) setRepos(r.value);
28+ else setError(r.error.message);
29+ });
30+ return () => {
31+ cancelled = true;
32+ };
33+ }, [slug, wanted, repos]);
34+ return { repos, error };
35+}
36+
37+function RepoPicker({ repos, value, onChange, id }: { repos: RepoChoice[] | null; value: string; onChange: (repo: string) => void; id?: string }) {
38+ return (
39+ <Combobox
40+ id={id}
41+ value={value}
42+ onValueChange={onChange}
43+ placeholder={repos ? "Choose a repository" : "Loading repositories…"}
44+ searchPlaceholder="Find a repository"
45+ emptyText="No repository you can read matches."
46+ disabled={!repos}
47+ options={(repos ?? []).map((r) => ({ value: r.repo, label: r.repo, description: r.private ? "Private" : undefined, icon: <FolderGit2 size={14} /> }))}
48+ className="w-full"
49+ />
50+ );
51+}
52+
53+/** What the Cite code dialog inserts: a citation chip's attributes. */
54+export type CitationInput = { repo: string; path: string; kind: DocCitationKind; label: string; ref: string };
55+
56+const LABEL_HINTS: Record<DocCitationKind, string> = {
57+ path: "",
58+ symbol: "The function, type or class, e.g. exportCsv",
59+ endpoint: "The method and route, e.g. POST /v1/exports",
60+ env: "The variable's name, e.g. EXPORT_BUCKET",
61+};
62+
63+/**
64+ * Cite code: a repository, a path in it (a file, a folder, or a pattern
65+ * like `src/export/**`), and what there the doc describes. The citation
66+ * is pinned to the default branch's head, and the doc is marked possibly
67+ * out of date when a merge changes it.
68+ */
69+export function CiteDialog({ slug, open, onOpenChange, onCite, projects = [] }: { slug: string; open: boolean; onOpenChange: (open: boolean) => void; onCite: (c: CitationInput) => void; projects?: string[] }) {
70+ const { repos, error: loadError } = useRepoChoices(slug, open);
71+ const [repo, setRepo] = useState("");
72+ const [path, setPath] = useState("");
73+ const [kind, setKind] = useState<DocCitationKind>("path");
74+ const [label, setLabel] = useState("");
75+ const [busy, setBusy] = useState(false);
76+ const [error, setError] = useState<string | null>(null);
77+ useEffect(() => {
78+ if (!open) return;
79+ setError(null);
80+ setPath("");
81+ setLabel("");
82+ setKind("path");
83+ }, [open]);
84+ // The doc's own project first, when it has one.
85+ useEffect(() => {
86+ if (!repo && repos?.length) setRepo(repos.find((r) => projects.includes(r.repo))?.repo ?? repos[0]!.repo);
87+ }, [repos, repo, projects]);
88+ const submit = async () => {
89+ if (!repo || !path.trim()) return setError("Choose a repository and name a path in it.");
90+ if (kind !== "path" && !label.trim()) return setError(`Name the ${kind === "env" ? "variable" : kind}.`);
91+ setBusy(true);
92+ setError(null);
93+ const found = await docsQuery<{ repo: string; path: string; ref: string | null }>(slug, { cite: repo, path: path.trim() });
94+ setBusy(false);
95+ if (!found.ok) return setError(found.error.message);
96+ onCite({ repo: found.value.repo, path: found.value.path, kind, label: kind === "path" ? "" : label.trim(), ref: found.value.ref ?? "" });
97+ onOpenChange(false);
98+ };
99+ return (
100+ <Dialog open={open} onOpenChange={onOpenChange}>
101+ <DialogContent className="max-w-lg">
102+ <DialogHeader>
103+ <DialogTitle className="flex items-center gap-2">
104+ <FileCode2 size={16} /> Cite code
105+ </DialogTitle>
106+ <DialogDescription>Link this doc to the code it describes. When a merged pull request changes it, the doc&apos;s owner hears that it may be out of date.</DialogDescription>
107+ </DialogHeader>
108+ <form
109+ className="space-y-4"
110+ onSubmit={(e) => {
111+ e.preventDefault();
112+ void submit();
113+ }}
114+ >
115+ <Field label="Repository">
116+ <RepoPicker repos={repos} value={repo} onChange={setRepo} />
117+ </Field>
118+ <Field label="Path" hint="A file, a folder (everything in it), or a pattern such as src/export/** or *.sql.">
119+ <Input value={path} onChange={(e) => setPath(e.target.value)} placeholder="src/export.ts" autoComplete="off" spellCheck={false} className="font-mono" />
120+ </Field>
121+ <Field label="What it describes">
122+ <SelectField value={kind} onValueChange={(v) => setKind(v as DocCitationKind)} options={(Object.keys(DOC_CITATION_KIND_LABELS) as DocCitationKind[]).map((k) => ({ value: k, label: DOC_CITATION_KIND_LABELS[k] }))} className="w-full" aria-label="What it describes" />
123+ </Field>
124+ {kind !== "path" && (
125+ <Field label={kind === "symbol" ? "Symbol" : kind === "endpoint" ? "Endpoint" : "Variable"} hint={LABEL_HINTS[kind]}>
126+ <Input value={label} onChange={(e) => setLabel(e.target.value)} autoComplete="off" spellCheck={false} className="font-mono" />
127+ </Field>
128+ )}
129+ {(error || loadError) && <ErrorText>{error ?? loadError}</ErrorText>}
130+ <DialogFooter>
131+ <Button type="submit" variant="accent" disabled={busy || !repos}>
132+ {busy ? "Checking…" : "Cite"}
133+ </Button>
134+ </DialogFooter>
135+ </form>
136+ </DialogContent>
137+ </Dialog>
138+ );
139+}
140+
141+/** Show a project's docs: choose a repository the viewer can read; its `docs/` folder and README appear in the sidebar and search. */
142+export function RepoDocsDialog({ slug, open, onOpenChange, shown, onAdded }: { slug: string; open: boolean; onOpenChange: (open: boolean) => void; shown: string[]; onAdded: (space: DocRepoSpace) => void }) {
143+ const { repos, error: loadError } = useRepoChoices(slug, open);
144+ const [repo, setRepo] = useState("");
145+ const [busy, setBusy] = useState(false);
146+ const [error, setError] = useState<string | null>(null);
147+ const choices = repos?.filter((r) => !shown.includes(r.repo)) ?? null;
148+ return (
149+ <Dialog open={open} onOpenChange={onOpenChange}>
150+ <DialogContent className="max-w-lg">
151+ <DialogHeader>
152+ <DialogTitle className="flex items-center gap-2">
153+ <FolderGit2 size={16} /> Show a project&apos;s docs
154+ </DialogTitle>
155+ <DialogDescription>The repository&apos;s docs folder and README, read from its default branch, appear beside your spaces and in search, and follow every push. Only people who can read the repository see them. Changes go through the repository.</DialogDescription>
156+ </DialogHeader>
157+ <form
158+ className="space-y-4"
159+ onSubmit={async (e) => {
160+ e.preventDefault();
161+ if (!repo) return;
162+ setBusy(true);
163+ setError(null);
164+ const added = await docsRequest<DocRepoSpace>(slug, "add_repo_space", { repo });
165+ setBusy(false);
166+ if (!added.ok) return setError(added.error.message);
167+ onAdded(added.value);
168+ onOpenChange(false);
169+ }}
170+ >
171+ <Field label="Repository">
172+ <RepoPicker repos={choices} value={repo} onChange={setRepo} />
173+ </Field>
174+ {choices && choices.length === 0 && <p className="text-xs text-faint">Every repository you can read is already shown.</p>}
175+ {(error || loadError) && <ErrorText>{error ?? loadError}</ErrorText>}
176+ <DialogFooter>
177+ <Button type="submit" variant="accent" disabled={busy || !repo}>
178+ {busy ? "Reading its docs…" : "Show its docs"}
179+ </Button>
180+ </DialogFooter>
181+ </form>
182+ </DialogContent>
183+ </Dialog>
184+ );
185+}
+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+}
+408−0
1+/**
2+ * A doc's editor: BlockNote on the folio's live Yjs document, with g1t's
3+ * own blocks (./blocks.tsx), live cursors and presence, comments anchored
4+ * to text, and agents' suggestions marked in place. Loaded lazily and
5+ * only in the browser (./body.tsx), so the page's server render stays
6+ * light: until it loads, the reader sees the doc's Markdown.
7+ */
8+import "@blocknote/shadcn/style.css";
9+
10+import { filterSuggestionItems, insertOrUpdateBlockForSlashMenu } from "@blocknote/core";
11+import { CommentsExtension, DefaultThreadStoreAuth, ThreadStoreAuth, type CommentData, type ThreadData } from "@blocknote/core/comments";
12+import { RESTYjsThreadStore, withCollaboration } from "@blocknote/core/yjs";
13+import { syntaxHighlighter } from "@blocknote/code-block";
14+import { BlockNoteViewEditor, SuggestionMenuController, ThreadsSidebar, getDefaultReactSlashMenuItems, useCreateBlockNote } from "@blocknote/react";
15+import { BlockNoteView } from "@blocknote/shadcn";
16+import type { DocFile, DocRole, FolioSearchHit, FolioSuggestion, FoliosLiveEvent, Result } from "@g1t/contracts";
17+import { AlertTriangle, AtSign, Calendar, CheckCircle2, FileCode2, FileText, GitPullRequest, Info, Link2, Sigma, Workflow } from "lucide-react";
18+import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
19+
20+import { canDo, cursorColour } from "../../../lib/folios";
21+import { schema, type DocEditorInstance } from "./blocks";
22+import { CiteDialog } from "./code";
23+import { EditorSkeleton } from "./editor-skeleton";
24+import type { PageThread } from "./page-parts";
25+import { FolioProvider, type LiveStatus } from "../provider";
26+import type { Presence } from "../shell";
27+import { whoAre } from "../who";
28+
29+/** Someone who can be mentioned: a person (by username) or an agent (by id). */
30+export type DocMentionable = { kind: "user" | "agent"; id: string; name: string; display_name: string; avatar: string | null; avatar_seed?: string | null };
31+
32+export type DocEditorProps = {
33+ slug: string;
34+ folioId: string;
35+ role: DocRole;
36+ me: { key: string; name: string; display_name: string; avatar: string | null };
37+ mentionables: DocMentionable[];
38+ usercontent: string;
39+ suggestions: FolioSuggestion[];
40+ showComments: boolean;
41+ onPresence?: (people: Presence[]) => void;
42+ onStatus?: (status: LiveStatus) => void;
43+ onEvent?: (event: FoliosLiveEvent) => void;
44+ /** The suggestion cards' own UI, drawn by the page beside the blocks each one changes. */
45+ renderSuggestion?: (suggestion: FolioSuggestion) => ReactNode;
46+ /** Comments on the whole doc (not on a passage), live from the document. */
47+ onPageThreads?: (threads: PageThread[]) => void;
48+ /** The projects the doc is about: Cite code offers them first. */
49+ projects?: string[];
50+};
51+
52+/** Nobody can do anything with threads: a reader's view. */
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, folioId } = props;
85+ const [provider, setProvider] = useState<FolioProvider | null>(null);
86+ const [synced, setSynced] = useState(false);
87+
88+ useEffect(() => {
89+ const scheme = window.location.protocol === "https:" ? "wss" : "ws";
90+ const live = new FolioProvider(`${scheme}://${window.location.host}/${slug}/-/artifacts/live?folio=${encodeURIComponent(folioId)}`);
91+ setProvider(live);
92+ setSynced(false);
93+ // Mount the editor once the document is here, so it never starts from
94+ // an empty doc of its own; offline, after a moment, from what we have.
95+ const fallback = setTimeout(() => setSynced(true), 4000);
96+ const off = live.onStatus((status) => {
97+ props.onStatus?.(status);
98+ if (status === "synced") setSynced(true);
99+ });
100+ const offEvent = live.onEvent((event) => props.onEvent?.(event));
101+ return () => {
102+ clearTimeout(fallback);
103+ off();
104+ offEvent();
105+ live.destroy();
106+ };
107+ // The callbacks are read through props each time; reconnect only for a new folio.
108+ // eslint-disable-next-line react-hooks/exhaustive-deps
109+ }, [slug, folioId]);
110+
111+ if (!provider || !synced) return <EditorSkeleton />;
112+ return <LiveEditor {...props} provider={provider} />;
113+}
114+
115+function LiveEditor({ slug, folioId, role, me, mentionables, usercontent, suggestions, showComments, onPresence, renderSuggestion, onPageThreads, projects, provider }: DocEditorProps & { provider: FolioProvider }) {
116+ const editable = canDo(role, "edit");
117+ const [citing, setCiting] = useState(false);
118+ const colour = cursorColour(me.name);
119+ // Below 1280px the comments sit under the page: opening them goes there.
120+ const comments = useRef<HTMLElement>(null);
121+ useEffect(() => {
122+ if (showComments && window.matchMedia("(max-width: 1279px)").matches) comments.current?.scrollIntoView({ behavior: "smooth", block: "start" });
123+ }, [showComments]);
124+
125+ const threadStore = useMemo(() => {
126+ const auth = canDo(role, "comment") ? new DefaultThreadStoreAuth(me.key, editable ? "editor" : "comment") : new ReadOnlyAuth();
127+ return new RESTYjsThreadStore(`/${slug}/-/artifacts/threads/${folioId}`, {}, provider.doc.getMap("threads"), auth);
128+ }, [slug, folioId, role, me.key, editable, provider]);
129+
130+ const editor = useCreateBlockNote(
131+ withCollaboration({
132+ schema,
133+ collaboration: {
134+ fragment: provider.doc.getXmlFragment("document-store"),
135+ user: { name: me.display_name, color: colour, key: me.key, kind: "user", avatar: me.avatar ?? "" },
136+ provider,
137+ showCursorLabels: "activity",
138+ },
139+ extensions: [
140+ CommentsExtension({
141+ threadStore,
142+ resolveUsers: async (ids: string[]) => {
143+ const found = await whoAre(slug, ids);
144+ return ids.map((id) => {
145+ const p = found.get(id);
146+ const avatar = id === me.key ? me.avatar : (p?.avatar ?? null);
147+ return { id, username: id === me.key ? me.display_name : (p?.display_name ?? "Someone"), avatarUrl: avatar ? `${usercontent}/avatars/${avatar}` : "" };
148+ });
149+ },
150+ }),
151+ syntaxHighlighter,
152+ ],
153+ uploadFile: async (file: File) => {
154+ const response = await fetch(`/${slug}/-/artifacts/upload?folio=${encodeURIComponent(folioId)}&name=${encodeURIComponent(file.name)}`, {
155+ method: "POST",
156+ headers: { "content-type": file.type || "application/octet-stream" },
157+ body: file,
158+ });
159+ const result = (await response.json()) as Result<DocFile>;
160+ if (!result.ok) throw new Error(result.error.message);
161+ return `${usercontent}${result.value.url}`;
162+ },
163+ }),
164+ [provider, threadStore],
165+ ) as unknown as DocEditorInstance;
166+
167+ // Who is here: everyone's awareness state, people and agents.
168+ useEffect(() => {
169+ const read = () => {
170+ const out: Presence[] = [];
171+ provider.awareness.getStates().forEach((state, client) => {
172+ const user = (state as { user?: { name?: string; color?: string; key?: string; kind?: string; avatar?: string } }).user;
173+ if (!user?.name) return;
174+ out.push({
175+ client,
176+ key: user.key ?? String(client),
177+ name: user.name,
178+ kind: user.kind === "agent" ? "agent" : "user",
179+ color: user.color ?? "#b8a6ff",
180+ avatar: user.avatar || null,
181+ me: client === provider.doc.clientID,
182+ });
183+ });
184+ // One face per person, however many tabs they have open.
185+ const seen = new Set<string>();
186+ onPresence?.(out.filter((p) => !seen.has(p.key) && seen.add(p.key)));
187+ };
188+ read();
189+ provider.awareness.on("change", read);
190+ return () => provider.awareness.off("change", read);
191+ }, [provider, onPresence]);
192+
193+ // Comments on the whole doc, as the document holds them.
194+ useEffect(() => {
195+ if (!onPageThreads) return;
196+ const map = provider.doc.getMap("threads");
197+ const read = () => {
198+ const out: PageThread[] = [];
199+ map.forEach((value) => {
200+ const t = value as { get(key: string): unknown };
201+ if (typeof t?.get !== "function" || t.get("deletedAt")) return;
202+ const meta = (t.get("metadata") as { page_level?: boolean } | null) ?? {};
203+ if (!meta.page_level) return;
204+ const comments = (t.get("comments") as { toArray(): { get(key: string): unknown }[] } | undefined)?.toArray() ?? [];
205+ out.push({
206+ id: String(t.get("id")),
207+ resolved: !!t.get("resolved"),
208+ comments: comments
209+ .filter((c) => !c.get("deletedAt"))
210+ .map((c) => ({ id: String(c.get("id")), author: String(c.get("userId")), text: commentText(c.get("body")), created_at: Number(c.get("createdAt")) || 0 })),
211+ });
212+ });
213+ onPageThreads(out.sort((a, b) => (a.comments[0]?.created_at ?? 0) - (b.comments[0]?.created_at ?? 0)));
214+ };
215+ read();
216+ map.observeDeep(read);
217+ return () => map.unobserveDeep(read);
218+ }, [provider, onPageThreads]);
219+
220+ // `[[` links an artifact, as `@` mentions someone.
221+ const searchPages = async (query: string) => {
222+ try {
223+ const r = await fetch(`/${slug}/-/artifacts/api?search=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } });
224+ const result = (await r.json()) as Result<FolioSearchHit[]>;
225+ return result.ok ? result.value : [];
226+ } catch {
227+ return [];
228+ }
229+ };
230+
231+ const slashItems = (query: string) => {
232+ const insert = (block: { type: string; props?: Record<string, unknown> }) => () => insertOrUpdateBlockForSlashMenu(editor as never, block as never);
233+ const custom = [
234+ { title: "Info callout", subtext: "A note set apart from the text", aliases: ["callout", "note", "info"], group: "Callouts", icon: <Info size={18} />, onItemClick: insert({ type: "callout", props: { kind: "info" } }) },
235+ { title: "Warning callout", subtext: "Something to be careful about", aliases: ["callout", "warning", "caution"], group: "Callouts", icon: <AlertTriangle size={18} />, onItemClick: insert({ type: "callout", props: { kind: "warning" } }) },
236+ { title: "Success callout", subtext: "A tip, or what good looks like", aliases: ["callout", "success", "tip"], group: "Callouts", icon: <CheckCircle2 size={18} />, onItemClick: insert({ type: "callout", props: { kind: "success" } }) },
237+ { title: "Diagram", subtext: "A Mermaid diagram, drawn as you type", aliases: ["mermaid", "flowchart", "sequence", "chart"], group: "Advanced", icon: <Workflow size={18} />, onItemClick: insert({ type: "mermaid" }) },
238+ { title: "Math", subtext: "A formula, typeset with KaTeX", aliases: ["math", "latex", "katex", "equation", "formula"], group: "Advanced", icon: <Sigma size={18} />, onItemClick: insert({ type: "math" }) },
239+ { title: "Embed from g1t", subtext: "An issue, pull request, channel, project or artifact, live", aliases: ["embed", "issue", "pull", "pr", "channel", "project"], group: "g1t", icon: <GitPullRequest size={18} />, onItemClick: insert({ type: "embed" }) },
240+ {
241+ title: "Mention",
242+ subtext: "A person or an agent",
243+ aliases: ["mention", "person", "agent", "@"],
244+ group: "g1t",
245+ icon: <AtSign size={18} />,
246+ onItemClick: () => editor.insertInlineContent("@" as never),
247+ },
248+ {
249+ title: "Link to an artifact",
250+ subtext: "A doc or another artifact",
251+ aliases: ["link", "page", "artifact", "doc", "[["],
252+ group: "g1t",
253+ icon: <Link2 size={18} />,
254+ onItemClick: () => editor.insertInlineContent("[[" as never),
255+ },
256+ {
257+ title: "Cite code",
258+ subtext: "A file, folder, symbol, endpoint or variable this doc describes",
259+ aliases: ["cite", "code", "citation", "path", "file", "symbol", "endpoint", "env", "variable"],
260+ group: "g1t",
261+ icon: <FileCode2 size={18} />,
262+ onItemClick: () => setCiting(true),
263+ },
264+ {
265+ title: "Date",
266+ subtext: "Today's date, which you can change",
267+ aliases: ["date", "today", "when"],
268+ group: "g1t",
269+ icon: <Calendar size={18} />,
270+ onItemClick: () => editor.insertInlineContent([{ type: "date", props: { date: new Date().toISOString().slice(0, 10) } }, " "] as never),
271+ },
272+ ];
273+ return filterSuggestionItems([...getDefaultReactSlashMenuItems(editor as never), ...custom], query);
274+ };
275+
276+ const mentionItems = (query: string) => {
277+ const q = query.toLowerCase();
278+ return mentionables
279+ .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q))
280+ .slice(0, 8)
281+ .map((p) => ({
282+ title: p.display_name,
283+ subtext: p.kind === "agent" ? `@${p.name} · agent` : `@${p.name}`,
284+ icon: <AtSign size={16} />,
285+ onItemClick: () => editor.insertInlineContent([{ type: "mention", props: { kind: p.kind, id: p.id, name: p.name, href: "" } }, " "] as never),
286+ }));
287+ };
288+
289+ const pageItems = async (query: string) => {
290+ const pages = await searchPages(query);
291+ return pages.slice(0, 8).map((p) => ({
292+ title: p.title || "Untitled",
293+ subtext: p.space_name ?? "Private",
294+ icon: p.icon ? <span className="text-base leading-none">{p.icon}</span> : <FileText size={16} />,
295+ onItemClick: () => editor.insertInlineContent([{ type: "mention", props: { kind: "page", id: p.id, name: p.title || "Untitled", href: p.path } }, " "] as never),
296+ }));
297+ };
298+
299+ // Suggestions: the blocks each one changes are struck through in place.
300+ const struck = suggestions
301+ .flatMap((s) => s.block_ids)
302+ .filter((id) => /^[0-9a-zA-Z-]+$/.test(id))
303+ .map((id) => `.g1t-editor .bn-block-outer[data-id="${id}"] > .bn-block > .bn-block-content`)
304+ .join(",\n");
305+
306+ return (
307+ <div className="g1t-editor relative">
308+ {struck && (
309+ <style>{`${struck} { text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--g1t-danger) 70%, transparent); background: color-mix(in srgb, var(--g1t-danger) 8%, transparent); border-radius: 4px; }`}</style>
310+ )}
311+ <BlockNoteView editor={editor as never} editable={editable} theme="dark" renderEditor={false} slashMenu={false} comments={canDo(role, "view")} className="g1t-bn">
312+ <div className={`grid gap-8 ${showComments ? "xl:grid-cols-[minmax(0,1fr)_18rem]" : ""}`}>
313+ <div className="relative min-w-0">
314+ <BlockNoteViewEditor />
315+ {renderSuggestion && <SuggestionRail editor={editor} suggestions={suggestions} render={renderSuggestion} hidden={showComments} />}
316+ </div>
317+ {showComments && (
318+ <aside ref={comments} aria-label="Comments" className="scroll-mt-20 max-xl:border-t max-xl:border-line max-xl:pt-6">
319+ <div className="xl:sticky xl:top-20 xl:max-h-[calc(100dvh-6rem)] xl:overflow-y-auto xl:[scrollbar-width:thin]">
320+ <ThreadsSidebar filter="all" sort="position" />
321+ </div>
322+ </aside>
323+ )}
324+ </div>
325+ <SuggestionMenuController triggerCharacter="/" getItems={async (query) => slashItems(query)} />
326+ <SuggestionMenuController triggerCharacter="@" getItems={async (query) => mentionItems(query)} />
327+ <SuggestionMenuController triggerCharacter="[[" getItems={pageItems} />
328+ </BlockNoteView>
329+ {editable && (
330+ <CiteDialog
331+ slug={slug}
332+ open={citing}
333+ onOpenChange={setCiting}
334+ projects={projects}
335+ onCite={(c) => {
336+ editor.focus();
337+ editor.insertInlineContent([{ type: "citation", props: c }, " "] as never);
338+ }}
339+ />
340+ )}
341+ </div>
342+ );
343+}
344+
345+/**
346+ * Each suggestion beside the blocks it changes, in the margin on a wide
347+ * screen (the doc lists them above the editor on a narrow one).
348+ */
349+function SuggestionRail({ editor, suggestions, render, hidden }: { editor: DocEditorInstance; suggestions: FolioSuggestion[]; render: (s: FolioSuggestion) => ReactNode; hidden: boolean }) {
350+ const box = useRef<HTMLDivElement>(null);
351+ const [tops, setTops] = useState<Record<string, number>>({});
352+ useLayoutEffect(() => {
353+ const place = () => {
354+ const root = box.current?.parentElement;
355+ if (!root) return;
356+ const base = root.getBoundingClientRect().top;
357+ const next: Record<string, number> = {};
358+ let floor = 0;
359+ for (const s of suggestions) {
360+ const first = s.block_ids[0];
361+ const el = first ? root.querySelector(`.bn-block-outer[data-id="${CSS.escape(first)}"]`) : null;
362+ const want = el ? el.getBoundingClientRect().top - base : root.getBoundingClientRect().height - 40;
363+ // Cards never overlap: each starts below the one before.
364+ const top = Math.max(want, floor);
365+ next[s.id] = top;
366+ floor = top + 190;
367+ }
368+ setTops(next);
369+ };
370+ place();
371+ const off = editor.onChange(() => requestAnimationFrame(place));
372+ const observer = new ResizeObserver(place);
373+ if (box.current?.parentElement) observer.observe(box.current.parentElement);
374+ return () => {
375+ off?.();
376+ observer.disconnect();
377+ };
378+ }, [editor, suggestions]);
379+ if (hidden || !suggestions.length) return null;
380+ return (
381+ <div ref={box} className="pointer-events-none absolute inset-y-0 left-full ml-8 hidden w-72 2xl:block" aria-label="Suggestions">
382+ {suggestions.map((s) => (
383+ <div key={s.id} className="pointer-events-auto absolute inset-x-0" style={{ top: tops[s.id] ?? 0 }}>
384+ {render(s)}
385+ </div>
386+ ))}
387+ </div>
388+ );
389+}
390+
391+/** A comment's text: its blocks' inline text, mentions as @name. */
392+function commentText(body: unknown): string {
393+ const out: string[] = [];
394+ const walk = (v: unknown) => {
395+ if (Array.isArray(v)) v.forEach(walk);
396+ else if (v && typeof v === "object") {
397+ const o = v as Record<string, unknown>;
398+ if (o.type === "text" && typeof o.text === "string") out.push(o.text);
399+ else if (o.type === "mention") out.push(`@${(o.props as { name?: string } | undefined)?.name ?? ""}`);
400+ else {
401+ walk(o.content);
402+ walk(o.children);
403+ }
404+ }
405+ };
406+ walk(body);
407+ return out.join("");
408+}
+238−0
1+/**
2+ * A doc's surroundings: agents' suggestions with Accept and Reject, the
3+ * emoji and cover pickers, and comments on the whole doc.
4+ */
5+import type { FolioSuggestion as DocSuggestion, MemberProfile } from "@g1t/contracts";
6+import { Check, MessageSquare, Sparkles, X } from "lucide-react";
7+import { type ReactNode, useState } from "react";
8+
9+import { Button, TimeAgo } from "../../ui";
10+import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
11+import { Face } from "../parts";
12+
13+// ── Suggestions ─────────────────────────────────────────────────────────────
14+
15+function DiffBlock({ text, tone }: { text: string; tone: "del" | "add" }) {
16+ if (!text.trim()) return <p className="text-xs text-faint italic">{tone === "del" ? "Nothing removed" : "Nothing added"}</p>;
17+ return (
18+ <pre
19+ 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] ${
20+ tone === "del" ? "bg-danger/8 text-fg-soft line-through decoration-danger/60" : "bg-success/10 text-fg"
21+ }`}
22+ >
23+ {text}
24+ </pre>
25+ );
26+}
27+
28+function targetLabel(s: DocSuggestion): string {
29+ switch (s.target.kind) {
30+ case "append":
31+ return "Adds to the end";
32+ case "document":
33+ return "Rewrites the doc";
34+ case "section":
35+ return `Changes “${s.target.heading}”`;
36+ default:
37+ return s.block_ids.length === 1 ? "Changes one block" : `Changes ${s.block_ids.length} blocks`;
38+ }
39+}
40+
41+export function SuggestionCard({ suggestion, canDecide, onDecide, compact = false }: { suggestion: DocSuggestion; canDecide: boolean; onDecide: (id: string, decision: "accept" | "reject") => void; compact?: boolean }) {
42+ const [open, setOpen] = useState(!compact);
43+ const s = suggestion;
44+ return (
45+ <article className="rounded-xl border border-accent/30 bg-surface shadow-lg shadow-black/20">
46+ <header className="flex items-center gap-2 border-b border-line px-3 py-2">
47+ <Face who={s.author} size={20} />
48+ <span className="min-w-0 grow">
49+ <span className="block truncate text-xs font-medium text-fg">{s.author.display_name} suggests</span>
50+ <span className="block truncate text-[0.6875rem] text-faint">
51+ {targetLabel(s)}
52+ {s.asked_by ? ` · for ${s.asked_by.display_name}` : ""} · <TimeAgo at={s.created_at} />
53+ </span>
54+ </span>
55+ <Sparkles size={14} className="shrink-0 text-accent" aria-hidden="true" />
56+ </header>
57+ <div className="space-y-2 px-3 py-2.5">
58+ {s.note && <p className="text-xs leading-relaxed text-muted">{s.note}</p>}
59+ {open ? (
60+ <>
61+ {s.target.kind !== "append" && <DiffBlock text={s.before_markdown} tone="del" />}
62+ <DiffBlock text={s.after_markdown} tone="add" />
63+ </>
64+ ) : (
65+ <button type="button" onClick={() => setOpen(true)} className="text-xs text-accent hover:underline">
66+ Show the change
67+ </button>
68+ )}
69+ </div>
70+ {canDecide && (
71+ <footer className="flex gap-2 border-t border-line px-3 py-2">
72+ <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">
73+ <Check size={13} /> Accept
74+ </button>
75+ <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">
76+ <X size={13} /> Reject
77+ </button>
78+ </footer>
79+ )}
80+ </article>
81+ );
82+}
83+
84+// ── Icon ──────────────────────────────────────────────────────────────────
85+
86+const EMOJI = ["📄", "📘", "📚", "📝", "📐", "🧭", "🗺️", "🚀", "🛠️", "🧰", "🧪", "🐛", "🔒", "🔑", "⚙️", "📦", "🧩", "🎯", "📈", "📊", "🗓️", "⏱️", "✅", "⚠️", "🔥", "🩹", "💡", "🧠", "🤖", "👋", "🤝", "💬", "📣", "🏁", "🌱", "⭐"];
87+
88+export function IconPicker({ value, onChange, children }: { value: string | null; onChange: (icon: string | null) => void; children: ReactNode }) {
89+ const [open, setOpen] = useState(false);
90+ const [custom, setCustom] = useState("");
91+ return (
92+ <Popover open={open} onOpenChange={setOpen}>
93+ <PopoverTrigger asChild>{children}</PopoverTrigger>
94+ <PopoverContent align="start" className="w-72 p-3">
95+ <div className="grid grid-cols-9 gap-0.5">
96+ {EMOJI.map((e) => (
97+ <button
98+ key={e}
99+ type="button"
100+ onClick={() => {
101+ onChange(e);
102+ setOpen(false);
103+ }}
104+ className={`flex size-7 items-center justify-center rounded text-base hover:bg-raised ${value === e ? "bg-raised" : ""}`}
105+ aria-label={`Use ${e}`}
106+ >
107+ {e}
108+ </button>
109+ ))}
110+ </div>
111+ <form
112+ className="mt-2 flex gap-1.5"
113+ onSubmit={(e) => {
114+ e.preventDefault();
115+ if (custom.trim()) onChange(custom.trim());
116+ setOpen(false);
117+ }}
118+ >
119+ <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" />
120+ <button type="submit" className="h-8 rounded-md bg-raised px-2 text-xs hover:bg-line">
121+ Use
122+ </button>
123+ </form>
124+ {value && (
125+ <button
126+ type="button"
127+ onClick={() => {
128+ onChange(null);
129+ setOpen(false);
130+ }}
131+ className="mt-2 text-xs text-faint hover:text-fg"
132+ >
133+ Remove icon
134+ </button>
135+ )}
136+ </PopoverContent>
137+ </Popover>
138+ );
139+}
140+
141+// ── Comments on the whole doc ──────────────────────────────────────────────
142+
143+export type PageThread = { id: string; resolved: boolean; comments: { id: string; author: string; text: string; created_at: number }[] };
144+
145+export function Discussion({
146+ threads,
147+ people,
148+ canComment,
149+ onPost,
150+ onReply,
151+ onResolve,
152+}: {
153+ threads: PageThread[];
154+ people: (key: string) => Pick<MemberProfile, "kind" | "id" | "name" | "display_name" | "avatar" | "avatar_seed">;
155+ canComment: boolean;
156+ onPost: (text: string) => Promise<boolean>;
157+ onReply: (thread: string, text: string) => Promise<boolean>;
158+ onResolve: (thread: string, resolved: boolean) => void;
159+}) {
160+ const [draft, setDraft] = useState("");
161+ const [replies, setReplies] = useState<Record<string, string>>({});
162+ const open = threads.filter((t) => !t.resolved);
163+ const resolved = threads.filter((t) => t.resolved);
164+ const [showResolved, setShowResolved] = useState(false);
165+ const thread = (t: PageThread) => (
166+ <li key={t.id} className={`rounded-xl border border-line bg-surface ${t.resolved ? "opacity-70" : ""}`}>
167+ <ul className="divide-y divide-line">
168+ {t.comments.map((c) => {
169+ const who = people(c.author);
170+ return (
171+ <li key={c.id} className="flex gap-2.5 px-3.5 py-3">
172+ <Face who={who} size={22} />
173+ <div className="min-w-0 grow">
174+ <p className="text-xs">
175+ <span className="font-medium text-fg">{who.display_name}</span> <span className="text-faint">{c.created_at ? <TimeAgo at={c.created_at} /> : null}</span>
176+ </p>
177+ <p className="mt-0.5 text-sm leading-relaxed whitespace-pre-wrap text-fg-soft">{c.text}</p>
178+ </div>
179+ </li>
180+ );
181+ })}
182+ </ul>
183+ {canComment && (
184+ <form
185+ className="flex items-center gap-2 border-t border-line px-3 py-2"
186+ onSubmit={async (e) => {
187+ e.preventDefault();
188+ const text = (replies[t.id] ?? "").trim();
189+ if (text && (await onReply(t.id, text))) setReplies((r) => ({ ...r, [t.id]: "" }));
190+ }}
191+ >
192+ <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" />
193+ <button type="button" onClick={() => onResolve(t.id, !t.resolved)} className="shrink-0 text-xs text-faint hover:text-fg">
194+ {t.resolved ? "Reopen" : "Resolve"}
195+ </button>
196+ </form>
197+ )}
198+ </li>
199+ );
200+ return (
201+ <section aria-labelledby="discussion" className="mt-14 border-t border-line pt-8">
202+ <h2 id="discussion" className="flex items-center gap-2 text-sm font-semibold">
203+ <MessageSquare size={15} /> Discussion
204+ </h2>
205+ <p className="mt-1 text-xs text-faint">Comments on the whole doc. To comment on a passage, select it and choose Comment.</p>
206+ <ul className="mt-4 space-y-3">{open.map(thread)}</ul>
207+ {resolved.length > 0 && (
208+ <>
209+ <button type="button" onClick={() => setShowResolved(!showResolved)} className="mt-3 text-xs text-faint hover:text-fg">
210+ {showResolved ? "Hide" : "Show"} {resolved.length} resolved
211+ </button>
212+ {showResolved && <ul className="mt-3 space-y-3">{resolved.map(thread)}</ul>}
213+ </>
214+ )}
215+ {canComment && (
216+ <form
217+ className="mt-4 flex items-start gap-2"
218+ onSubmit={async (e) => {
219+ e.preventDefault();
220+ if (draft.trim() && (await onPost(draft.trim()))) setDraft("");
221+ }}
222+ >
223+ <textarea
224+ value={draft}
225+ onChange={(e) => setDraft(e.target.value)}
226+ rows={2}
227+ placeholder="Add a comment. @mention someone to tell them."
228+ aria-label="Add a comment"
229+ 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"
230+ />
231+ <Button type="submit" variant="quiet" disabled={!draft.trim()}>
232+ Comment
233+ </Button>
234+ </form>
235+ )}
236+ </section>
237+ );
238+}
+114−0
1+/**
2+ * Each kind of artifact as the site shows it: its icon and colour, whether
3+ * it can be made yet, and its page body. One line per kind, so each kind's
4+ * phase adds its own (docs/ARTIFACTS_MODE.md section 8: slides, design and
5+ * dashboards are "Coming soon" until theirs ships). Bodies are lazy, so
6+ * Home and the sidebar carry no editor code.
7+ */
8+import { FOLIO_KIND_LABELS, type DocRole, type Folio, type FolioKind, type FolioPage, type FolioPreview } from "@g1t/contracts";
9+import { FileText, LayoutDashboard, type LucideIcon, PenTool, Presentation } from "lucide-react";
10+import { type ComponentType, type LazyExoticComponent, lazy } from "react";
11+
12+import type { LiveStatus } from "./provider";
13+import type { Presence } from "./shell";
14+
15+/**
16+ * What a kind's page body is given: the folio's page and the folio as it
17+ * is now, the viewer's role, and the header's comment toggle; it tells the
18+ * header who is here, how the connection is, and what changed live.
19+ */
20+export type FolioBodyProps = {
21+ slug: string;
22+ page: FolioPage;
23+ folio: Folio;
24+ role: DocRole;
25+ showComments: boolean;
26+ onPresence: (people: Presence[]) => void;
27+ onStatus: (status: LiveStatus) => void;
28+ /** A rename or another change to the folio, from the room. */
29+ onFolio: (folio: Folio) => void;
30+ /** The viewer's role changed; null: their access ended. */
31+ onRole: (role: DocRole | null) => void;
32+ onError: (message: string) => void;
33+};
34+
35+export type FolioKindUi = {
36+ label: string;
37+ icon: LucideIcon;
38+ /** Tailwind classes for the kind's icon on its soft square. */
39+ tone: string;
40+ /** False: its tile says "Coming soon" and nothing makes one yet. */
41+ ready: boolean;
42+ beta?: boolean;
43+ /** Can be edited on a phone (docs can; a design canvas can't). */
44+ mobileEditable: boolean;
45+ Body: LazyExoticComponent<ComponentType<FolioBodyProps>> | null;
46+};
47+
48+export const FOLIO_KIND_UI: Record<FolioKind, FolioKindUi> = {
49+ doc: { label: FOLIO_KIND_LABELS.doc, icon: FileText, tone: "text-info bg-info/12", ready: true, mobileEditable: true, Body: lazy(() => import("./doc/body")) },
50+ slides: { label: FOLIO_KIND_LABELS.slides, icon: Presentation, tone: "text-warn bg-warn/12", ready: false, mobileEditable: false, Body: null },
51+ design: { label: FOLIO_KIND_LABELS.design, icon: PenTool, tone: "text-merged bg-merged/12", ready: false, mobileEditable: false, Body: null },
52+ dashboard: { label: FOLIO_KIND_LABELS.dashboard, icon: LayoutDashboard, tone: "text-success bg-success/12", ready: false, beta: true, mobileEditable: false, Body: null },
53+};
54+
55+/** A kind's icon in its colour on a soft square, as list rows and tiles show it. */
56+export function KindIcon({ kind, size = 16, box = 28 }: { kind: FolioKind; size?: number; box?: number }) {
57+ const ui = FOLIO_KIND_UI[kind];
58+ const Icon = ui.icon;
59+ return (
60+ <span className={`flex shrink-0 items-center justify-center rounded-md ${ui.tone}`} style={{ width: box, height: box }} aria-hidden="true">
61+ <Icon size={size} />
62+ </span>
63+ );
64+}
65+
66+/** A folio's own icon: its emoji, or its kind's icon in its colour (no square). */
67+export function FolioGlyph({ folio, size = 15 }: { folio: { kind: FolioKind; icon: string | null }; size?: number }) {
68+ if (folio.icon) {
69+ return (
70+ <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">
71+ {folio.icon}
72+ </span>
73+ );
74+ }
75+ const ui = FOLIO_KIND_UI[folio.kind];
76+ const Icon = ui.icon;
77+ return <Icon size={size} className={ui.tone.split(" ")[0]} aria-hidden="true" />;
78+}
79+
80+/**
81+ * A card's picture, drawn from `preview` (written when the folio is
82+ * saved; never data values): a doc's first lines, a deck's first slide, a
83+ * design's frame, a dashboard's tile boxes.
84+ */
85+export function FolioThumbnail({ folio }: { folio: Pick<Folio, "kind" | "preview" | "excerpt" | "title"> }) {
86+ const preview: FolioPreview | null = folio.preview;
87+ if (preview?.kind === "doc" || (!preview && folio.kind === "doc")) {
88+ const lines = preview?.kind === "doc" ? preview.lines : folio.excerpt ? [folio.excerpt] : [];
89+ return (
90+ <div className="h-full overflow-hidden bg-bg px-4 pt-4 text-[0.6875rem] leading-relaxed text-muted">
91+ <p className="mb-1.5 line-clamp-1 text-xs font-semibold text-fg-soft">{folio.title || "Untitled"}</p>
92+ {lines.length ? (
93+ lines.slice(0, 6).map((line, i) => (
94+ <p key={i} className="line-clamp-2">
95+ {line}
96+ </p>
97+ ))
98+ ) : (
99+ <div className="space-y-1.5 pt-1" aria-hidden="true">
100+ <div className="h-1.5 w-11/12 rounded bg-line" />
101+ <div className="h-1.5 w-4/5 rounded bg-line" />
102+ <div className="h-1.5 w-2/3 rounded bg-line" />
103+ </div>
104+ )}
105+ </div>
106+ );
107+ }
108+ // The other kinds draw their own once they ship; until then, the kind's mark.
109+ return (
110+ <div className="flex h-full items-center justify-center bg-bg">
111+ <KindIcon kind={folio.kind} size={22} box={44} />
112+ </div>
113+ );
114+}
+118−0
1+/**
2+ * Artifacts as lists show them (docs/ARTIFACTS_MODE.md section 6.2): rows
3+ * grouped by the day they were last edited, in the viewer's time zone, or
4+ * cards with a picture drawn from each one's preview. Each has its kind,
5+ * who can see it, its space, when it was edited and by whom, and its ⋯
6+ * menu.
7+ */
8+import type { Folio } from "@g1t/contracts";
9+import { useMemo } from "react";
10+import { Link } from "react-router";
11+
12+import { dayGroups } from "../../lib/folios";
13+import { TimeAgo } from "../ui";
14+import { Hint } from "../ui/hint";
15+import { FolioThumbnail, KindIcon } from "./kinds";
16+import { FolioMenu } from "./menu";
17+import { AccessMark } from "./parts";
18+
19+function Edited({ folio }: { folio: Folio }) {
20+ const by = folio.edited_by?.display_name;
21+ return (
22+ <Hint label={by ? `Edited by ${by}` : "Edited"}>
23+ <span className="shrink-0 text-xs whitespace-nowrap text-faint" tabIndex={-1}>
24+ Edited <TimeAgo at={folio.edited_at} />
25+ </span>
26+ </Hint>
27+ );
28+}
29+
30+export function FolioRow({ slug, folio, onError }: { slug: string; folio: Folio; onError?: (message: string) => void }) {
31+ return (
32+ <li className="group relative flex items-center gap-3 px-3 py-2 transition-colors hover:bg-raised/60 sm:px-4">
33+ <KindIcon kind={folio.kind} />
34+ <span className="flex min-w-0 grow items-center gap-2">
35+ <Link to={folio.path} prefetch="intent" className="min-w-0 truncate text-sm text-fg after:absolute after:inset-0 hover:text-accent">
36+ {folio.icon && <span className="mr-1.5">{folio.icon}</span>}
37+ {folio.title || "Untitled"}
38+ </Link>
39+ {folio.stale && (
40+ <Hint label="Possibly out of date: code it cites changed">
41+ <span className="relative size-1.5 shrink-0 rounded-full bg-warn" aria-label="Possibly out of date" />
42+ </Hint>
43+ )}
44+ </span>
45+ <span className="relative hidden shrink-0 sm:flex">
46+ <AccessMark folio={folio} />
47+ </span>
48+ <span className="hidden w-32 shrink-0 truncate text-xs text-faint md:block">{folio.space?.name ?? "Private"}</span>
49+ <span className="relative w-28 shrink-0 text-right max-sm:w-auto">
50+ <Edited folio={folio} />
51+ </span>
52+ <span className="relative">
53+ <FolioMenu slug={slug} folio={folio} onError={onError} />
54+ </span>
55+ </li>
56+ );
57+}
58+
59+/** Rows under Today, Yesterday, then dates. */
60+export function FolioDays({ slug, items, zone, onError }: { slug: string; items: Folio[]; zone: string; onError?: (message: string) => void }) {
61+ const days = useMemo(() => dayGroups(items, (f) => f.edited_at, zone), [items, zone]);
62+ return (
63+ <div className="space-y-6">
64+ {days.map((day) => (
65+ <section key={day.key} aria-label={day.label}>
66+ <h3 className="mb-1.5 px-1 text-xs font-medium text-faint">{day.label}</h3>
67+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
68+ {day.items.map((f) => (
69+ <FolioRow key={f.id} slug={slug} folio={f} onError={onError} />
70+ ))}
71+ </ul>
72+ </section>
73+ ))}
74+ </div>
75+ );
76+}
77+
78+export function FolioCard({ slug, folio, onError }: { slug: string; folio: Folio; onError?: (message: string) => void }) {
79+ return (
80+ <div className="group relative flex flex-col overflow-hidden rounded-xl border border-line bg-surface transition-colors hover:border-line-strong">
81+ <div className="pointer-events-none h-32 shrink-0 border-b border-line">
82+ <FolioThumbnail folio={folio} />
83+ </div>
84+ <div className="flex items-start gap-2.5 px-3.5 pt-3 pb-3">
85+ <KindIcon kind={folio.kind} size={14} box={24} />
86+ <span className="min-w-0 grow">
87+ <Link to={folio.path} prefetch="intent" className="line-clamp-1 text-sm font-medium text-fg after:absolute after:inset-0 group-hover:text-accent">
88+ {folio.icon && <span className="mr-1">{folio.icon}</span>}
89+ {folio.title || "Untitled"}
90+ </Link>
91+ <span className="mt-0.5 flex items-center gap-1.5 text-[0.6875rem] text-faint">
92+ <span className="truncate">{folio.space?.name ?? "Private"}</span>
93+ <span aria-hidden="true">·</span>
94+ <span className="relative">
95+ <Edited folio={folio} />
96+ </span>
97+ </span>
98+ </span>
99+ <span className="relative -mt-1 -mr-1.5 flex items-center">
100+ <span className="relative">
101+ <AccessMark folio={folio} />
102+ </span>
103+ <FolioMenu slug={slug} folio={folio} onError={onError} />
104+ </span>
105+ </div>
106+ </div>
107+ );
108+}
109+
110+export function FolioGrid({ slug, items, onError }: { slug: string; items: Folio[]; onError?: (message: string) => void }) {
111+ return (
112+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
113+ {items.map((f) => (
114+ <FolioCard key={f.id} slug={slug} folio={f} onError={onError} />
115+ ))}
116+ </div>
117+ );
118+}
+148−0
1+/**
2+ * An artifact's ⋯ menu, the same on Home's rows and cards and in its own
3+ * header: open in a new tab, favorite, share, rename, duplicate, move,
4+ * copy the link, export, save as a template, move to the trash. What the
5+ * viewer's role doesn't allow isn't offered.
6+ */
7+import type { Folio } from "@g1t/contracts";
8+import { ArrowRightLeft, Copy, Download, Ellipsis, ExternalLink, History, LayoutTemplate, Link2, PencilLine, Share2, Star, Trash2 } from "lucide-react";
9+import { type ReactNode, useState } from "react";
10+import { Link, useNavigate, useRevalidator } from "react-router";
11+
12+import { canDo } from "../../lib/folios";
13+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu";
14+import { foliosRequest, useFoliosData } from "./actions";
15+import { MoveDialog, RenameDialog, TemplateDialog } from "./dialogs";
16+import { ShareDialog } from "./share-dialog";
17+
18+export function FolioMenu({
19+ slug,
20+ folio,
21+ onError,
22+ onTrashed,
23+ page = false,
24+ trigger,
25+}: {
26+ slug: string;
27+ folio: Folio;
28+ onError?: (message: string) => void;
29+ /** After it went to the trash (its own page goes back Home). */
30+ onTrashed?: () => void;
31+ /** On its own page: History is offered, and "Open in new tab" isn't. */
32+ page?: boolean;
33+ trigger?: ReactNode;
34+}) {
35+ const layout = useFoliosData();
36+ const navigate = useNavigate();
37+ const { revalidate } = useRevalidator();
38+ const [sharing, setSharing] = useState(false);
39+ const [renaming, setRenaming] = useState(false);
40+ const [moving, setMoving] = useState(false);
41+ const [templating, setTemplating] = useState(false);
42+ const editable = canDo(folio.viewer_role, "edit");
43+ const fail = (message: string) => onError?.(message);
44+ const send = async <T,>(intent: string, body: Record<string, unknown>) => {
45+ const done = await foliosRequest<T>(slug, intent, { folio_id: folio.id, ...body });
46+ if (done.ok) void revalidate();
47+ else fail(done.error.message);
48+ return done;
49+ };
50+ return (
51+ <>
52+ <DropdownMenu>
53+ <DropdownMenuTrigger asChild>
54+ {trigger ?? (
55+ <button type="button" aria-label={`More for ${folio.title || "Untitled"}`} className="flex size-8 shrink-0 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg max-md:size-10">
56+ <Ellipsis size={16} />
57+ </button>
58+ )}
59+ </DropdownMenuTrigger>
60+ <DropdownMenuContent align="end" className="w-56">
61+ {!page && (
62+ <DropdownMenuItem asChild>
63+ <a href={folio.path} target="_blank" rel="noopener">
64+ <ExternalLink size={14} /> Open in new tab
65+ </a>
66+ </DropdownMenuItem>
67+ )}
68+ <DropdownMenuItem onSelect={() => void send("favorite", { on: !folio.favorite })}>
69+ <Star size={14} className={folio.favorite ? "fill-current text-warn" : ""} /> {folio.favorite ? "Remove from Favorites" : "Add to Favorites"}
70+ </DropdownMenuItem>
71+ <DropdownMenuItem onSelect={() => setSharing(true)}>
72+ <Share2 size={14} /> Share…
73+ </DropdownMenuItem>
74+ {page && (
75+ <DropdownMenuItem asChild>
76+ <Link to={`${folio.path}/history`}>
77+ <History size={14} /> History
78+ </Link>
79+ </DropdownMenuItem>
80+ )}
81+ <DropdownMenuSeparator />
82+ {editable && (
83+ <DropdownMenuItem onSelect={() => setRenaming(true)}>
84+ <PencilLine size={14} /> Rename
85+ </DropdownMenuItem>
86+ )}
87+ <DropdownMenuItem
88+ onSelect={async () => {
89+ const made = await send<Folio>("duplicate", {});
90+ if (made.ok) navigate(made.value.path);
91+ }}
92+ >
93+ <Copy size={14} /> Duplicate
94+ </DropdownMenuItem>
95+ {editable && (
96+ <DropdownMenuItem onSelect={() => setMoving(true)}>
97+ <ArrowRightLeft size={14} /> Move to…
98+ </DropdownMenuItem>
99+ )}
100+ <DropdownMenuItem
101+ onSelect={async () => {
102+ try {
103+ await navigator.clipboard.writeText(`${window.location.origin}${folio.path}`);
104+ } catch {
105+ fail("Your browser didn't allow copying.");
106+ }
107+ }}
108+ >
109+ <Link2 size={14} /> Copy link
110+ </DropdownMenuItem>
111+ <DropdownMenuItem asChild>
112+ <a href={`/${slug}/-/artifacts/export?folio=${encodeURIComponent(folio.id)}`}>
113+ <Download size={14} /> Export Markdown
114+ </a>
115+ </DropdownMenuItem>
116+ <DropdownMenuItem onSelect={() => setTemplating(true)}>
117+ <LayoutTemplate size={14} /> Save as template
118+ </DropdownMenuItem>
119+ {editable && (
120+ <>
121+ <DropdownMenuSeparator />
122+ <DropdownMenuItem
123+ onSelect={async () => {
124+ const done = await send("trash", {});
125+ if (done.ok) onTrashed?.();
126+ }}
127+ className="text-danger focus:text-danger"
128+ >
129+ <Trash2 size={14} /> Move to trash
130+ </DropdownMenuItem>
131+ </>
132+ )}
133+ </DropdownMenuContent>
134+ </DropdownMenu>
135+ <ShareDialog slug={slug} folio={folio} open={sharing} onOpenChange={setSharing} onChanged={() => void revalidate()} />
136+ <RenameDialog open={renaming} onOpenChange={setRenaming} title={folio.title} onSave={(title) => send("update", { change: { title } })} />
137+ <MoveDialog
138+ sidebar={layout?.sidebar}
139+ folio={folio}
140+ me={layout?.me.key ?? ""}
141+ open={moving}
142+ onOpenChange={setMoving}
143+ onMove={(spaceId, parentId) => send("move", { move: { space_id: spaceId, parent_id: parentId, before_id: null } })}
144+ />
145+ <TemplateDialog open={templating} onOpenChange={setTemplating} title={folio.title || "Untitled"} widens={folio.general_access !== "workspace" && !(folio.space?.kind === "workspace" && folio.inherit)} onSave={(name, description) => send("save_template", { name, description })} />
146+ </>
147+ );
148+}
+148−0
1+/**
2+ * Pieces Artifacts' pages share: a face for a person or an agent, who an
3+ * artifact is shared with, breadcrumbs, a space's icon, a section heading,
4+ * a template card.
5+ */
6+import { FOLIO_SPACE_KIND_LABELS, type DocSpaceKind, type Folio, type FolioTemplate, type MemberProfile } from "@g1t/contracts";
7+import { ChevronRight, Globe, Library, Link2, Lock, Users } from "lucide-react";
8+import type { ReactNode } from "react";
9+import { Link } from "react-router";
10+
11+import { AgentAvatar } from "../agent-avatar";
12+import { Avatar } from "../ui";
13+import { Hint } from "../ui/hint";
14+import { KindIcon } from "./kinds";
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+/** A space's icon: its emoji, or one for who it's for. */
38+export function SpaceIcon({ space, size = 15 }: { space: { icon: string | null; kind: DocSpaceKind }; size?: number }) {
39+ if (space.icon) {
40+ return (
41+ <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">
42+ {space.icon}
43+ </span>
44+ );
45+ }
46+ if (space.kind === "private") return <Lock size={size} className="text-faint" aria-hidden="true" />;
47+ if (space.kind === "team") return <Users size={size} className="text-faint" aria-hidden="true" />;
48+ return <Library size={size} className="text-faint" aria-hidden="true" />;
49+}
50+
51+/** Who a space is for, in its words: Open, Team, Members only. */
52+export function spaceKindLabel(kind: DocSpaceKind): string {
53+ return FOLIO_SPACE_KIND_LABELS[kind];
54+}
55+
56+/**
57+ * Who can open an artifact, in a glance: a lock when only its owner can,
58+ * else the way it is opened up (the workspace, a link) and how many it is
59+ * shared with directly.
60+ */
61+export function AccessMark({ folio }: { folio: Pick<Folio, "private" | "general_access" | "shared_count" | "space" | "inherit"> }) {
62+ if (folio.private) {
63+ return (
64+ <Hint label="Only you can see this">
65+ <span className="flex size-5 items-center justify-center text-faint" aria-label="Only you can see this">
66+ <Lock size={13} />
67+ </span>
68+ </Hint>
69+ );
70+ }
71+ if (folio.general_access === "workspace") {
72+ return (
73+ <Hint label="Everyone in the workspace can open it">
74+ <span className="flex size-5 items-center justify-center text-faint" aria-label="Everyone in the workspace">
75+ <Globe size={13} />
76+ </span>
77+ </Hint>
78+ );
79+ }
80+ if (folio.general_access === "link") {
81+ return (
82+ <Hint label="Anyone in the workspace with the link can open it">
83+ <span className="flex size-5 items-center justify-center text-faint" aria-label="Anyone in the workspace with the link">
84+ <Link2 size={13} />
85+ </span>
86+ </Hint>
87+ );
88+ }
89+ if (folio.shared_count > 0) {
90+ const label = `Shared with ${folio.shared_count} ${folio.shared_count === 1 ? "other" : "others"}${folio.space && folio.inherit ? ` and ${folio.space.name}` : ""}`;
91+ return (
92+ <Hint label={label}>
93+ <span className="flex h-5 items-center gap-0.5 text-[0.6875rem] text-faint tabular-nums" aria-label={label}>
94+ <Users size={13} /> {folio.shared_count}
95+ </span>
96+ </Hint>
97+ );
98+ }
99+ return null;
100+}
101+
102+export function Crumbs({ items }: { items: { label: ReactNode; to?: string }[] }) {
103+ return (
104+ <nav aria-label="Breadcrumbs" className="flex min-w-0 items-center gap-1 overflow-hidden text-xs text-faint">
105+ {items.map((item, i) => (
106+ <span key={i} className={`flex items-center gap-1 ${i === items.length - 1 ? "min-w-0" : "shrink-0 max-sm:hidden"}`}>
107+ {i > 0 && <ChevronRight size={12} className="shrink-0" aria-hidden="true" />}
108+ {item.to ? (
109+ <Link to={item.to} prefetch="intent" className="max-w-48 truncate hover:text-fg">
110+ {item.label}
111+ </Link>
112+ ) : (
113+ <span className="truncate text-muted">{item.label}</span>
114+ )}
115+ </span>
116+ ))}
117+ </nav>
118+ );
119+}
120+
121+/** A heading for a section of an Artifacts page. */
122+export function SectionTitle({ children, action }: { children: ReactNode; action?: ReactNode }) {
123+ return (
124+ <div className="mb-3 flex items-center justify-between gap-3">
125+ <h2 className="text-sm font-semibold text-fg">{children}</h2>
126+ {action}
127+ </div>
128+ );
129+}
130+
131+export function TemplateCard({ template, action }: { template: FolioTemplate; action: ReactNode }) {
132+ return (
133+ <div className="flex flex-col rounded-xl border border-line bg-surface p-4">
134+ <span className="flex items-center gap-2">
135+ {template.icon ? (
136+ <span className="text-2xl leading-none" aria-hidden="true">
137+ {template.icon}
138+ </span>
139+ ) : (
140+ <KindIcon kind={template.kind} />
141+ )}
142+ </span>
143+ <span className="mt-3 text-sm font-medium">{template.name}</span>
144+ <span className="mt-1 grow text-xs leading-relaxed text-muted">{template.description || (template.builtin ? "" : `Saved by ${template.created_by?.display_name ?? "your workspace"}.`)}</span>
145+ <div className="mt-3 flex items-center gap-2">{action}</div>
146+ </div>
147+ );
148+}
+197−0
1+/**
2+ * An artifact's live connection, whatever its kind: the Yjs document and
3+ * everyone's presence, synced over
4+ * `wss://<site>/<workspace>/-/artifacts/live?folio=<id>` with the folio's
5+ * room (services/docs src/folios/room.ts). Binary frames speak the
6+ * y-protocols sync and awareness messages; text frames are the service's
7+ * own notices (`FoliosLiveEvent`): a rename, a new suggestion, a version,
8+ * a change of access.
9+ *
10+ * It reconnects with backoff when the socket drops, and says where it is
11+ * (`status`) so the page can show "Offline, changes will sync".
12+ * Browser-only.
13+ */
14+import type { FoliosLiveEvent } from "@g1t/contracts";
15+import * as decoding from "lib0/decoding";
16+import * as encoding from "lib0/encoding";
17+import * as awarenessProtocol from "y-protocols/awareness";
18+import * as syncProtocol from "y-protocols/sync";
19+import * as Y from "yjs";
20+
21+import { heldOpen } from "../../lib/notify-store";
22+
23+const MESSAGE_SYNC = 0;
24+const MESSAGE_AWARENESS = 1;
25+const MESSAGE_QUERY_AWARENESS = 3;
26+
27+export type LiveStatus = "connecting" | "synced" | "offline" | "closed";
28+
29+export class FolioProvider {
30+ readonly doc: Y.Doc;
31+ readonly awareness: awarenessProtocol.Awareness;
32+ status: LiveStatus = "connecting";
33+ private socket: WebSocket | null = null;
34+ private attempts = 0;
35+ /** When the current connection opened; the backoff starts over only once one holds. */
36+ private openedAt: number | null = null;
37+ private timer: ReturnType<typeof setTimeout> | null = null;
38+ private keepalive: ReturnType<typeof setInterval> | null = null;
39+ private stopped = false;
40+ private readonly statusListeners = new Set<(status: LiveStatus) => void>();
41+ private readonly eventListeners = new Set<(event: FoliosLiveEvent) => void>();
42+
43+ constructor(
44+ private readonly url: string,
45+ doc?: Y.Doc,
46+ ) {
47+ this.doc = doc ?? new Y.Doc();
48+ this.awareness = new awarenessProtocol.Awareness(this.doc);
49+ this.doc.on("update", this.onDocUpdate);
50+ this.awareness.on("update", this.onAwarenessUpdate);
51+ if (typeof window !== "undefined") {
52+ window.addEventListener("beforeunload", this.onUnload);
53+ window.addEventListener("online", this.onOnline);
54+ }
55+ this.connect();
56+ }
57+
58+ onStatus(listener: (status: LiveStatus) => void): () => void {
59+ this.statusListeners.add(listener);
60+ listener(this.status);
61+ return () => this.statusListeners.delete(listener);
62+ }
63+
64+ onEvent(listener: (event: FoliosLiveEvent) => void): () => void {
65+ this.eventListeners.add(listener);
66+ return () => this.eventListeners.delete(listener);
67+ }
68+
69+ private setStatus(status: LiveStatus) {
70+ if (this.status === status) return;
71+ this.status = status;
72+ for (const l of this.statusListeners) l(status);
73+ }
74+
75+ private connect() {
76+ if (this.stopped) return;
77+ this.setStatus(this.attempts ? "offline" : "connecting");
78+ const socket = new WebSocket(this.url);
79+ socket.binaryType = "arraybuffer";
80+ this.socket = socket;
81+ socket.onopen = () => {
82+ this.openedAt = Date.now();
83+ // Our state vector: the room answers with what we lack, and asks for what it lacks.
84+ const encoder = encoding.createEncoder();
85+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
86+ syncProtocol.writeSyncStep1(encoder, this.doc);
87+ socket.send(encoding.toUint8Array(encoder));
88+ if (this.awareness.getLocalState() !== null) {
89+ const a = encoding.createEncoder();
90+ encoding.writeVarUint(a, MESSAGE_AWARENESS);
91+ encoding.writeVarUint8Array(a, awarenessProtocol.encodeAwarenessUpdate(this.awareness, [this.doc.clientID]));
92+ socket.send(encoding.toUint8Array(a));
93+ }
94+ const q = encoding.createEncoder();
95+ encoding.writeVarUint(q, MESSAGE_QUERY_AWARENESS);
96+ socket.send(encoding.toUint8Array(q));
97+ if (this.keepalive) clearInterval(this.keepalive);
98+ // Answered at the edge without waking the room.
99+ this.keepalive = setInterval(() => socket.readyState === WebSocket.OPEN && socket.send("ping"), 25_000);
100+ };
101+ socket.onmessage = (event) => {
102+ if (typeof event.data === "string") {
103+ if (event.data === "pong") return;
104+ try {
105+ const parsed = JSON.parse(event.data) as FoliosLiveEvent;
106+ for (const l of this.eventListeners) l(parsed);
107+ } catch {
108+ // Not ours.
109+ }
110+ return;
111+ }
112+ this.receive(new Uint8Array(event.data as ArrayBuffer));
113+ };
114+ socket.onclose = (event) => {
115+ if (this.keepalive) clearInterval(this.keepalive);
116+ this.socket = null;
117+ // Others stop seeing our cursor; we stop seeing theirs.
118+ awarenessProtocol.removeAwarenessStates(
119+ this.awareness,
120+ [...this.awareness.getStates().keys()].filter((id) => id !== this.doc.clientID),
121+ this,
122+ );
123+ // 4403: no longer allowed; 4410: in the trash. Neither comes back by retrying.
124+ if (event.code === 4403 || event.code === 4410 || this.stopped) {
125+ this.setStatus("closed");
126+ return;
127+ }
128+ this.setStatus("offline");
129+ // Only a connection that held starts the backoff over.
130+ if (heldOpen(this.openedAt)) this.attempts = 0;
131+ this.openedAt = null;
132+ const delay = Math.min(30_000, 500 * 2 ** this.attempts) + Math.random() * 500;
133+ this.attempts++;
134+ this.timer = setTimeout(() => this.connect(), delay);
135+ };
136+ }
137+
138+ private receive(data: Uint8Array) {
139+ const decoder = decoding.createDecoder(data);
140+ const type = decoding.readVarUint(decoder);
141+ if (type === MESSAGE_SYNC) {
142+ const encoder = encoding.createEncoder();
143+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
144+ const step = syncProtocol.readSyncMessage(decoder, encoder, this.doc, this);
145+ if (encoding.length(encoder) > 1) this.socket?.send(encoding.toUint8Array(encoder));
146+ if (step === syncProtocol.messageYjsSyncStep2) this.setStatus("synced");
147+ return;
148+ }
149+ if (type === MESSAGE_AWARENESS) {
150+ awarenessProtocol.applyAwarenessUpdate(this.awareness, decoding.readVarUint8Array(decoder), this);
151+ }
152+ }
153+
154+ private onDocUpdate = (update: Uint8Array, origin: unknown) => {
155+ if (origin === this) return;
156+ const encoder = encoding.createEncoder();
157+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
158+ syncProtocol.writeUpdate(encoder, update);
159+ if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(encoding.toUint8Array(encoder));
160+ // While offline, the update stays in the document and goes in the next sync.
161+ };
162+
163+ private onAwarenessUpdate = ({ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }, origin: unknown) => {
164+ if (origin === this) return;
165+ const changed = [...added, ...updated, ...removed];
166+ const encoder = encoding.createEncoder();
167+ encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
168+ encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, changed));
169+ if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(encoding.toUint8Array(encoder));
170+ };
171+
172+ private onUnload = () => {
173+ awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], "unload");
174+ };
175+
176+ private onOnline = () => {
177+ if (this.socket || this.stopped) return;
178+ if (this.timer) clearTimeout(this.timer);
179+ this.attempts = 0;
180+ this.connect();
181+ };
182+
183+ destroy() {
184+ this.stopped = true;
185+ if (this.timer) clearTimeout(this.timer);
186+ if (this.keepalive) clearInterval(this.keepalive);
187+ awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], "destroy");
188+ this.doc.off("update", this.onDocUpdate);
189+ this.awareness.off("update", this.onAwarenessUpdate);
190+ if (typeof window !== "undefined") {
191+ window.removeEventListener("beforeunload", this.onUnload);
192+ window.removeEventListener("online", this.onOnline);
193+ }
194+ this.socket?.close();
195+ this.awareness.destroy();
196+ }
197+}
+324−0
1+/**
2+ * Share an artifact (docs/ARTIFACTS_MODE.md section 6.4): invite people,
3+ * agents and teams with a role, see everyone who has access and where it
4+ * comes from, choose whether it follows its space or parent or only
5+ * people invited, open it to the workspace or to anyone in it with the
6+ * link, and say how agents may change it. People who can't share see it
7+ * read-only.
8+ */
9+import { DOC_AGENT_MODE_LABELS, DOC_ROLE_LABELS, FOLIO_GENERAL_ROLES, type DocRole, type Folio, type FolioAccessChange, type FolioAccessList, type FolioGeneralAccess, type FolioGeneralRole } from "@g1t/contracts";
10+import { Check, Globe, Link2, Lock, Users } from "lucide-react";
11+import { type ReactNode, useEffect, useMemo, useState } from "react";
12+import { Link } from "react-router";
13+
14+import { spacePath } from "../../lib/folios";
15+import { Button, ErrorText } from "../ui";
16+import { Combobox } from "../ui/combobox";
17+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog";
18+import { Hint } from "../ui/hint";
19+import { SelectField } from "../ui/select";
20+import { Skeleton } from "../ui/skeleton";
21+import { foliosQuery, foliosRequest, useFoliosData } from "./actions";
22+import { Face } from "./parts";
23+
24+const ROLES: DocRole[] = ["view", "comment", "edit", "manage"];
25+
26+type Candidate = { value: string; label: string; kind: "user" | "agent" | "team"; name: string };
27+
28+export function ShareDialog({ slug, folio, open, onOpenChange, onChanged }: { slug: string; folio: Pick<Folio, "id" | "title" | "path" | "space" | "parent_id" | "viewer_role">; open: boolean; onOpenChange: (o: boolean) => void; onChanged?: () => void }) {
29+ const layout = useFoliosData();
30+ const [access, setAccess] = useState<FolioAccessList | null>(null);
31+ const [teams, setTeams] = useState<{ slug: string; name: string }[]>([]);
32+ const [error, setError] = useState<string | null>(null);
33+ const [saved, setSaved] = useState(false);
34+ const [busy, setBusy] = useState(false);
35+ const [who, setWho] = useState("");
36+ const [role, setRole] = useState<DocRole>("edit");
37+ const [note, setNote] = useState("");
38+ const [copied, setCopied] = useState(false);
39+
40+ useEffect(() => {
41+ if (!open) return;
42+ setAccess(null);
43+ setError(null);
44+ setSaved(false);
45+ void foliosQuery<FolioAccessList>(slug, { access: folio.id }).then((r) => (r.ok ? setAccess(r.value) : setError(r.error.message)));
46+ void foliosQuery<{ slug: string; name: string }[]>(slug, { teams: "1" }).then((r) => r.ok && setTeams(r.value));
47+ }, [open, slug, folio.id]);
48+
49+ const change = async (body: FolioAccessChange) => {
50+ setBusy(true);
51+ setError(null);
52+ setSaved(false);
53+ const done = await foliosRequest<FolioAccessList>(slug, "access", { folio_id: folio.id, access: body });
54+ setBusy(false);
55+ if (!done.ok) {
56+ setError(done.error.message);
57+ return false;
58+ }
59+ setAccess(done.value);
60+ setSaved(true);
61+ onChanged?.();
62+ return true;
63+ };
64+
65+ const manager = folio.viewer_role === "manage";
66+ const canShare = !!access?.can_share;
67+ const listed = new Set([...(access?.rows ?? []).map((r) => r.principal), access ? `user:${access.owner.id}` : ""]);
68+ const candidates: Candidate[] = useMemo(
69+ () => [
70+ ...(layout?.mentionables ?? []).map((m): Candidate => (m.kind === "agent" ? { value: `agent:${m.id}`, label: `${m.display_name} (agent)`, kind: "agent", name: m.name } : { value: `username:${m.name}`, label: `${m.display_name} (@${m.name})`, kind: "user", name: m.name })),
71+ ...teams.map((t): Candidate => ({ value: `team:${t.slug}`, label: `${t.name} (team)`, kind: "team", name: t.slug })),
72+ ],
73+ [layout?.mentionables, teams],
74+ );
75+ const usernameOf = new Map((access?.rows ?? []).filter((r) => r.profile.kind === "user").map((r) => [r.profile.name, r.principal]));
76+ const offered = candidates.filter((c) => !listed.has(c.value) && !(c.kind === "user" && (usernameOf.has(c.name) || access?.owner.name === c.name)));
77+
78+ const invite = async () => {
79+ if (!who) return;
80+ const principal = who.startsWith("username:") ? await memberKey(slug, who.slice(9)) : who;
81+ if (!principal) return setError("That person isn't in the workspace.");
82+ if (await change({ op: "grant", principal, role, notify: note.trim() || null })) {
83+ setWho("");
84+ setNote("");
85+ }
86+ };
87+
88+ const root = !folio.parent_id || access?.inherit === false;
89+ const general: FolioGeneralAccess = access?.general_access ?? "none";
90+ const generalRole: FolioGeneralRole = access?.general_role ?? "view";
91+ const roleOptions = ROLES.filter((r) => manager || r !== "manage").map((r) => ({ value: r, label: DOC_ROLE_LABELS[r] }));
92+
93+ return (
94+ <Dialog open={open} onOpenChange={onOpenChange}>
95+ <DialogContent className="max-w-xl">
96+ <DialogHeader>
97+ <DialogTitle className="truncate pr-6">Share “{folio.title || "Untitled"}”</DialogTitle>
98+ <DialogDescription>{canShare ? "People you add get an inbox item with a link." : `Only people with full access can change who can open it. Ask ${access?.owner.display_name ?? "its owner"} to share it.`}</DialogDescription>
99+ </DialogHeader>
100+
101+ {canShare && (
102+ <div className="space-y-2">
103+ <div className="flex flex-wrap gap-2">
104+ <Combobox options={offered.map((c) => ({ value: c.value, label: c.label }))} value={who} onValueChange={setWho} placeholder="Add people, agents or teams" searchPlaceholder="Search the workspace" emptyText="Nobody else to add." className="h-9 min-w-0 grow basis-56" />
105+ <SelectField aria-label="Their role" value={role} onValueChange={(r) => setRole(r as DocRole)} options={roleOptions} className="h-9 w-36" />
106+ </div>
107+ {who && (
108+ <div className="flex gap-2">
109+ <input aria-label="Message" placeholder="Add a message (optional)" value={note} onChange={(e) => setNote(e.target.value)} maxLength={2000} className="h-9 min-w-0 grow rounded-md border border-line bg-bg px-3 text-sm outline-none focus:border-accent/60" />
110+ <Button type="button" variant="accent" disabled={busy} onClick={invite}>
111+ Invite
112+ </Button>
113+ </div>
114+ )}
115+ </div>
116+ )}
117+
118+ <section className="mt-2">
119+ <h3 className="mb-1.5 flex items-center gap-2 text-xs font-medium text-faint">
120+ Who has access
121+ {saved && (
122+ <span className="inline-flex items-center gap-1 text-success">
123+ <Check size={12} /> Updated
124+ </span>
125+ )}
126+ </h3>
127+ {!access ? (
128+ <div className="space-y-2 py-1" aria-busy="true">
129+ <Skeleton className="h-6 w-56" />
130+ <Skeleton className="h-6 w-44" />
131+ </div>
132+ ) : (
133+ <ul className="max-h-64 space-y-0.5 overflow-y-auto [scrollbar-width:thin]">
134+ <Row face={<Face who={access.owner} size={26} />} name={access.owner.display_name} sub={`@${access.owner.name}`} end={<span className="text-xs text-faint">Owner</span>} />
135+ {access.rows.map((r) => {
136+ const face = r.profile.kind === "team" ? <TeamFace /> : <Face who={r.profile} size={26} />;
137+ const sub = r.profile.kind === "team" ? "Team" : r.profile.kind === "agent" ? "Agent" : `@${r.profile.name}`;
138+ if (r.source.kind === "grant") {
139+ const editable = canShare && (manager || r.role !== "manage");
140+ return (
141+ <Row
142+ key={r.principal}
143+ face={face}
144+ name={r.profile.display_name}
145+ sub={sub}
146+ end={
147+ editable ? (
148+ <SelectField
149+ aria-label={`${r.profile.display_name}'s access`}
150+ value={r.role}
151+ onValueChange={(v) => (v === "remove" ? change({ op: "revoke", principal: r.principal }) : change({ op: "grant", principal: r.principal, role: v as DocRole }))}
152+ options={[...roleOptions, { value: "remove", label: "Remove access" }]}
153+ className="h-8 w-36"
154+ />
155+ ) : (
156+ <span className="text-xs text-muted">{DOC_ROLE_LABELS[r.role]}</span>
157+ )
158+ }
159+ />
160+ );
161+ }
162+ const from = r.source;
163+ return (
164+ <Row
165+ key={r.principal}
166+ face={face}
167+ name={r.profile.display_name}
168+ sub={
169+ <>
170+ From{" "}
171+ <Link to={from.kind === "folio" ? from.path : `/${slug}/-/artifacts`} onClick={() => onOpenChange(false)} className="text-muted hover:text-fg hover:underline">
172+ {from.kind === "folio" ? from.title : from.kind === "space" ? from.name : "its owner"}
173+ </Link>
174+ </>
175+ }
176+ end={<span className="text-xs text-muted">{DOC_ROLE_LABELS[r.role]}</span>}
177+ />
178+ );
179+ })}
180+ </ul>
181+ )}
182+ </section>
183+
184+ {access && (
185+ <section className="mt-2 space-y-3 border-t border-line pt-4">
186+ <h3 className="text-xs font-medium text-faint">General access</h3>
187+ {(folio.space || folio.parent_id) && (
188+ <div className="flex items-start gap-3">
189+ <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-raised text-muted">{access.inherit ? <Users size={15} /> : <Lock size={15} />}</span>
190+ <div className="min-w-0 grow">
191+ <SelectField
192+ aria-label="Follows its space or parent"
193+ disabled={!manager || busy}
194+ value={access.inherit ? "inherit" : "restrict"}
195+ onValueChange={(v) => change({ op: "inherit", inherit: v === "inherit" })}
196+ options={[
197+ { value: "inherit", label: access.inherited_from ? `Everyone in ${access.inherited_from.name}` : folio.space ? `Everyone in ${folio.space.name}` : "Follows the doc it's in" },
198+ { value: "restrict", label: "Only people invited" },
199+ ]}
200+ className="h-8 w-full max-w-xs"
201+ />
202+ <p className="mt-1 text-xs text-faint">
203+ {access.inherit ? "Whoever can open " : "Access stops here: whoever can open "}
204+ {folio.parent_id ? "the doc it's in" : folio.space ? <Link to={spacePath(slug, folio.space.slug)} className="hover:text-fg hover:underline">{folio.space.name}</Link> : "its space"}
205+ {access.inherit ? " can open this, with the same role." : " can't open this unless invited."}
206+ </p>
207+ </div>
208+ </div>
209+ )}
210+ {root ? (
211+ <div className="flex items-start gap-3">
212+ <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-raised text-muted">{general === "workspace" ? <Globe size={15} /> : general === "link" ? <Link2 size={15} /> : <Lock size={15} />}</span>
213+ <div className="flex min-w-0 grow flex-wrap items-center gap-2">
214+ <SelectField
215+ aria-label="General access"
216+ disabled={!manager || busy}
217+ value={general}
218+ onValueChange={(v) => change(v === "none" ? { op: "general", access: "none", role: null } : { op: "general", access: v as FolioGeneralAccess, role: generalRole })}
219+ options={[
220+ { value: "none", label: "Restricted" },
221+ { value: "workspace", label: `Everyone in ${layout?.slug ?? "the workspace"}` },
222+ { value: "link", label: "Anyone in the workspace with the link" },
223+ ]}
224+ className="h-8 min-w-0 grow basis-56"
225+ />
226+ {general !== "none" && (
227+ <SelectField
228+ aria-label="Their role"
229+ disabled={!manager || busy}
230+ value={generalRole}
231+ onValueChange={(v) => change({ op: "general", access: general, role: v as FolioGeneralRole })}
232+ options={FOLIO_GENERAL_ROLES.map((r) => ({ value: r, label: DOC_ROLE_LABELS[r] }))}
233+ className="h-8 w-36"
234+ />
235+ )}
236+ <p className="w-full text-xs text-faint">
237+ {general === "none" ? "Only the people above can open it." : general === "workspace" ? "Every member can find and open it." : "Members who open the link can. It isn't listed or searchable until they do."}
238+ </p>
239+ </div>
240+ </div>
241+ ) : (
242+ <p className="text-xs text-faint">It follows the doc it&apos;s in. Change who else can open it there, or choose &ldquo;Only people invited&rdquo;.</p>
243+ )}
244+ <div className="flex items-center gap-3">
245+ <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-raised text-faint">
246+ <Globe size={15} />
247+ </span>
248+ <Hint label="Links for people outside the workspace aren't available yet.">
249+ <span className="text-sm text-faint" tabIndex={0}>
250+ Public link: off for this workspace
251+ </span>
252+ </Hint>
253+ </div>
254+ <div className="flex flex-wrap items-center gap-2">
255+ <span className="text-sm text-muted">Agents may</span>
256+ <SelectField
257+ aria-label="How agents change it"
258+ disabled={!manager || busy}
259+ value={access.agent_mode ?? "default"}
260+ onValueChange={(v) => change({ op: "agent_mode", agent_mode: v === "default" ? null : (v as "suggest" | "edit") })}
261+ options={[
262+ { value: "default", label: folio.space ? `As ${folio.space.name} says` : "Suggest changes (default)" },
263+ { value: "suggest", label: DOC_AGENT_MODE_LABELS.suggest },
264+ { value: "edit", label: DOC_AGENT_MODE_LABELS.edit },
265+ ]}
266+ className="h-8 w-52"
267+ />
268+ </div>
269+ </section>
270+ )}
271+
272+ {error && <ErrorText>{error}</ErrorText>}
273+
274+ <div className="mt-2 flex items-center justify-between gap-2 border-t border-line pt-4">
275+ <Button
276+ type="button"
277+ variant="quiet"
278+ onClick={async () => {
279+ try {
280+ await navigator.clipboard.writeText(`${window.location.origin}${folio.path}`);
281+ setCopied(true);
282+ setTimeout(() => setCopied(false), 1600);
283+ } catch {
284+ setError("Your browser didn't allow copying.");
285+ }
286+ }}
287+ >
288+ {copied ? <Check size={14} /> : <Link2 size={14} />} {copied ? "Copied" : "Copy link"}
289+ </Button>
290+ <Button type="button" variant="accent" onClick={() => onOpenChange(false)}>
291+ Done
292+ </Button>
293+ </div>
294+ </DialogContent>
295+ </Dialog>
296+ );
297+}
298+
299+function Row({ face, name, sub, end }: { face: ReactNode; name: string; sub: ReactNode; end: ReactNode }) {
300+ return (
301+ <li className="flex items-center gap-3 rounded-md px-1 py-1.5">
302+ {face}
303+ <span className="min-w-0 grow">
304+ <span className="block truncate text-sm text-fg">{name}</span>
305+ <span className="block truncate text-xs text-faint">{sub}</span>
306+ </span>
307+ {end}
308+ </li>
309+ );
310+}
311+
312+function TeamFace() {
313+ return (
314+ <span className="flex size-[26px] shrink-0 items-center justify-center rounded-full bg-raised text-muted">
315+ <Users size={13} />
316+ </span>
317+ );
318+}
319+
320+/** A person's member key (`user:<id>`), from their username, as the site resolves it. */
321+export async function memberKey(slug: string, username: string): Promise<string | null> {
322+ const found = await foliosQuery<string>(slug, { person: username });
323+ return found.ok ? found.value : null;
324+}
+128−0
1+/**
2+ * The header every artifact's page shares, whatever its kind
3+ * (docs/ARTIFACTS_MODE.md section 6.5): where it is (a space or Private,
4+ * then the docs it sits under), who is here, "Offline, changes will
5+ * sync", and Share, comments, history, favorite and the ⋯ menu. A kind's
6+ * own actions go in `actions`.
7+ */
8+import type { Folio, FolioRef } from "@g1t/contracts";
9+import { History, MessageSquare, Share2, Star, WifiOff } from "lucide-react";
10+import { type ReactNode, useState } from "react";
11+import { Link, useRevalidator } from "react-router";
12+
13+import { spacePath } from "../../lib/folios";
14+import { Hint } from "../ui/hint";
15+import { foliosRequest } from "./actions";
16+import { FolioGlyph } from "./kinds";
17+import { FolioMenu } from "./menu";
18+import { Crumbs, Face } from "./parts";
19+import type { LiveStatus } from "./provider";
20+import { ShareDialog } from "./share-dialog";
21+
22+/** Someone here now, as the live document's awareness says. */
23+export type Presence = { client: number; key: string; name: string; kind: "user" | "agent"; color: string; avatar: string | null; me: boolean };
24+
25+const ICON_BUTTON = "flex size-8 shrink-0 items-center justify-center rounded-md max-md:size-10";
26+
27+export function FolioHeader({
28+ slug,
29+ folio,
30+ breadcrumbs,
31+ presence,
32+ status,
33+ comments,
34+ onError,
35+ onTrashed,
36+ actions,
37+}: {
38+ slug: string;
39+ folio: Folio;
40+ breadcrumbs: FolioRef[];
41+ presence: Presence[];
42+ status: LiveStatus;
43+ /** The comments toggle, for kinds that have comments. */
44+ comments?: { open: boolean; onToggle: () => void } | null;
45+ onError: (message: string) => void;
46+ onTrashed: () => void;
47+ actions?: ReactNode;
48+}) {
49+ const { revalidate } = useRevalidator();
50+ const [sharing, setSharing] = useState(false);
51+ const [favorite, setFavorite] = useState(folio.favorite);
52+ const others = presence.filter((p) => !p.me);
53+ const crumbs = [
54+ folio.space ? { label: folio.space.name, to: spacePath(slug, folio.space.slug) } : { label: "Private", to: `/${slug}/-/artifacts?space=private` },
55+ ...breadcrumbs.map((b) => ({ label: `${b.icon ? `${b.icon} ` : ""}${b.title || "Untitled"}`, to: b.path })),
56+ {
57+ label: (
58+ <span className="inline-flex items-center gap-1.5">
59+ <FolioGlyph folio={folio} size={13} />
60+ {folio.title || "Untitled"}
61+ </span>
62+ ),
63+ },
64+ ];
65+ return (
66+ <div className="sticky top-(--topbar-h) z-20 -mx-4 flex h-12 items-center gap-2 border-b border-line bg-bg/85 px-4 backdrop-blur sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
67+ <div className="min-w-0 grow">
68+ <Crumbs items={crumbs} />
69+ </div>
70+ {status === "offline" && (
71+ <Hint label="Your changes are kept and sync when the connection is back.">
72+ <span className="flex items-center gap-1 text-xs text-warn" tabIndex={0}>
73+ <WifiOff size={13} /> <span className="max-sm:hidden">Offline, changes will sync</span>
74+ </span>
75+ </Hint>
76+ )}
77+ {others.length > 0 && (
78+ <span className="flex items-center -space-x-1.5 max-sm:hidden" aria-label={`${others.length} others here`}>
79+ {others.slice(0, 5).map((p) => (
80+ <Hint key={p.key} label={`${p.name}${p.kind === "agent" ? " (agent)" : ""}`}>
81+ <span className="rounded-full ring-2" style={{ ["--tw-ring-color" as string]: p.color }}>
82+ <Face who={{ kind: p.kind, id: p.key.slice(p.key.indexOf(":") + 1), name: p.name, avatar: p.avatar, avatar_seed: null }} size={24} />
83+ </span>
84+ </Hint>
85+ ))}
86+ {others.length > 5 && <span className="pl-3 text-xs text-faint">+{others.length - 5}</span>}
87+ </span>
88+ )}
89+ {actions}
90+ <button type="button" onClick={() => setSharing(true)} className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-line px-2.5 text-xs text-fg/85 hover:bg-raised max-md:size-10 max-md:justify-center max-md:px-0" aria-label="Share">
91+ <Share2 size={13} /> <span className="max-md:hidden">Share</span>
92+ </button>
93+ {comments && (
94+ <Hint label={comments.open ? "Hide comments" : "Comments"}>
95+ <button type="button" onClick={comments.onToggle} aria-pressed={comments.open} aria-label="Comments" className={`${ICON_BUTTON} ${comments.open ? "bg-raised text-fg" : "text-faint hover:bg-raised hover:text-fg"}`}>
96+ <MessageSquare size={16} />
97+ </button>
98+ </Hint>
99+ )}
100+ <Hint label="History">
101+ <Link to={`${folio.path}/history`} aria-label="History" className={`${ICON_BUTTON} text-faint hover:bg-raised hover:text-fg max-sm:hidden`}>
102+ <History size={16} />
103+ </Link>
104+ </Hint>
105+ <Hint label={favorite ? "Remove from Favorites" : "Add to Favorites"}>
106+ <button
107+ type="button"
108+ aria-label={favorite ? "Remove from Favorites" : "Add to Favorites"}
109+ aria-pressed={favorite}
110+ onClick={async () => {
111+ setFavorite(!favorite);
112+ const done = await foliosRequest(slug, "favorite", { folio_id: folio.id, on: !favorite });
113+ if (done.ok) void revalidate();
114+ else {
115+ setFavorite(favorite);
116+ onError(done.error.message);
117+ }
118+ }}
119+ className={`${ICON_BUTTON} text-faint hover:bg-raised hover:text-fg max-sm:hidden`}
120+ >
121+ <Star size={16} className={favorite ? "fill-current text-warn" : ""} />
122+ </button>
123+ </Hint>
124+ <FolioMenu slug={slug} folio={{ ...folio, favorite }} page onError={onError} onTrashed={onTrashed} />
125+ <ShareDialog slug={slug} folio={folio} open={sharing} onOpenChange={setSharing} onChanged={() => void revalidate()} />
126+ </div>
127+ );
128+}
+534−0
1+/**
2+ * Artifacts mode's sidebar (beside the rail; docs/ARTIFACTS_MODE.md
3+ * section 6.3): search, Home, New and Templates; Favorites; the spaces
4+ * shown (joined open spaces, team spaces, members-only spaces) each with
5+ * its tree; Private, everything of yours in no space; Shared, the tops of
6+ * what others shared with you; then what's possibly out of date,
7+ * projects' docs folders (read-only) and the trash. Rows are dragged to
8+ * reorder them or to put one inside a doc.
9+ */
10+import { FOLIO_KINDS, folioIdFrom, folioSlug, type DocRepoSpace, type FolioKind, type FolioRef, type FolioTreeNode, type FoliosSidebarSpace } from "@g1t/contracts";
11+import { AlertTriangle, ChevronDown, ChevronRight, Compass, FileText, Folder, FolderGit2, Home, LayoutTemplate, Lock, Plus, Search, Settings, Star, Trash2, X } from "lucide-react";
12+import { type DragEvent, type ReactNode, useEffect, useMemo, useState } from "react";
13+import { NavLink, useLocation, useNavigate, useParams, useRevalidator } from "react-router";
14+
15+import { buildTree, canDo, pathTo, repoFilePath, repoFolders, spacePath, type RepoFolder, type TreeItem } from "../../lib/folios";
16+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu";
17+import { Hint } from "../ui/hint";
18+import { Skeleton } from "../ui/skeleton";
19+import { foliosRequest, useFoliosAction, useFoliosData } from "./actions";
20+import { RepoDocsDialog } from "./doc/code";
21+import { FOLIO_KIND_UI, FolioGlyph, KindIcon } from "./kinds";
22+import { SpaceIcon } from "./parts";
23+
24+const ROW = "group flex h-8 items-center gap-1.5 rounded-md pr-1 text-[0.8125rem] transition-colors";
25+const ROW_BUTTON = "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 pointer-coarse:size-9 pointer-coarse:opacity-100";
26+
27+function SideLink({ to, end, icon, children, trailing }: { to: string; end?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode }) {
28+ return (
29+ <NavLink to={to} end={end} prefetch="intent" className={({ isActive }) => `${ROW} pl-2 ${isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`}>
30+ <span className="flex w-5 shrink-0 justify-center text-faint">{icon}</span>
31+ <span className="min-w-0 grow truncate">{children}</span>
32+ {trailing}
33+ </NavLink>
34+ );
35+}
36+
37+function Section({ title, children, action, open: initially = true }: { title: string; children: ReactNode; action?: ReactNode; open?: boolean }) {
38+ const [open, setOpen] = useState(initially);
39+ return (
40+ <section className="mt-4">
41+ <div className="mb-0.5 flex items-center gap-1 pr-1">
42+ <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">
43+ <ChevronRight size={12} className={`transition-transform ${open ? "rotate-90" : ""}`} />
44+ {title}
45+ </button>
46+ {action}
47+ </div>
48+ {open && children}
49+ </section>
50+ );
51+}
52+
53+/** "New ▾": the four kinds, the ones not here yet shown as coming. */
54+export function NewMenu({ onMake, children, align = "end" }: { onMake: (kind: FolioKind) => void; children: ReactNode; align?: "start" | "end" }) {
55+ return (
56+ <DropdownMenu>
57+ <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
58+ <DropdownMenuContent align={align} className="w-52">
59+ {FOLIO_KINDS.map((kind) => {
60+ const ui = FOLIO_KIND_UI[kind];
61+ return (
62+ <DropdownMenuItem key={kind} disabled={!ui.ready} onSelect={() => onMake(kind)}>
63+ <KindIcon kind={kind} size={13} box={22} />
64+ <span className="grow">{ui.label}</span>
65+ {!ui.ready && <span className="text-[0.625rem] text-faint">Coming soon</span>}
66+ </DropdownMenuItem>
67+ );
68+ })}
69+ </DropdownMenuContent>
70+ </DropdownMenu>
71+ );
72+}
73+
74+type Drag = { id: string; place: string } | null;
75+
76+/** Where a tree lives: a space (by id) or the viewer's Private (null). */
77+type Place = { space_id: string | null; key: string; editable: boolean };
78+
79+function TreeRow({ slug, place, item, current, open, toggle, drag, setDrag, onDrop, onAdd }: { slug: string; place: Place; item: TreeItem; current: string | null; open: Set<string>; toggle: (id: string) => void; drag: Drag; setDrag: (d: Drag) => void; onDrop: (target: TreeItem, where: "before" | "inside") => void; onAdd: (parent: string) => void }) {
80+ const [over, setOver] = useState<"before" | "inside" | null>(null);
81+ const expanded = open.has(item.id);
82+ const active = current === item.id;
83+ const holds = item.kind === "doc";
84+ const zone = (e: DragEvent<HTMLElement>): "before" | "inside" => {
85+ if (!holds) return "before";
86+ const box = e.currentTarget.getBoundingClientRect();
87+ return e.clientY - box.top < box.height * 0.35 ? "before" : "inside";
88+ };
89+ return (
90+ <li>
91+ <div
92+ draggable={place.editable}
93+ onDragStart={(e) => {
94+ e.dataTransfer.effectAllowed = "move";
95+ e.dataTransfer.setData("text/plain", item.id);
96+ setDrag({ id: item.id, place: place.key });
97+ }}
98+ onDragEnd={() => setDrag(null)}
99+ onDragOver={(e) => {
100+ if (!drag || drag.id === item.id || !place.editable) return;
101+ e.preventDefault();
102+ setOver(zone(e));
103+ }}
104+ onDragLeave={() => setOver(null)}
105+ onDrop={(e) => {
106+ e.preventDefault();
107+ const where = zone(e);
108+ setOver(null);
109+ if (drag && drag.id !== item.id) onDrop(item, where);
110+ }}
111+ 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" : ""}`}
112+ style={{ paddingLeft: 4 + item.depth * 14 }}
113+ >
114+ {over === "before" && <span className="pointer-events-none absolute inset-x-1 -top-px h-0.5 rounded bg-accent" aria-hidden="true" />}
115+ <button
116+ type="button"
117+ onClick={() => toggle(item.id)}
118+ aria-label={expanded ? "Collapse" : "Expand"}
119+ aria-expanded={expanded}
120+ className={`flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg ${item.children.length ? "" : "invisible"}`}
121+ >
122+ <ChevronRight size={13} className={`transition-transform ${expanded ? "rotate-90" : ""}`} />
123+ </button>
124+ <NavLink to={`/${slug}/-/artifacts/${folioSlug(item.title, item.id)}`} prefetch="intent" className="flex min-w-0 grow items-center gap-1.5">
125+ <span className="flex w-4 shrink-0 justify-center">
126+ <FolioGlyph folio={item} size={14} />
127+ </span>
128+ <span className="min-w-0 truncate">{item.title || "Untitled"}</span>
129+ {item.restricted && (
130+ <Hint label="Only people invited">
131+ <Lock size={11} className="shrink-0 text-faint" aria-label="Only people invited" />
132+ </Hint>
133+ )}
134+ {item.stale && (
135+ <Hint label="Possibly out of date: code it cites changed">
136+ <span className="ml-auto size-1.5 shrink-0 rounded-full bg-warn" aria-label="Possibly out of date" />
137+ </Hint>
138+ )}
139+ </NavLink>
140+ {place.editable && holds && (
141+ <Hint label="Add a doc inside">
142+ <button type="button" onClick={() => onAdd(item.id)} aria-label={`Add a doc inside ${item.title || "Untitled"}`} className={ROW_BUTTON}>
143+ <Plus size={13} />
144+ </button>
145+ </Hint>
146+ )}
147+ </div>
148+ {expanded && item.children.length > 0 && (
149+ <ul>
150+ {item.children.map((child) => (
151+ <TreeRow key={child.id} slug={slug} place={place} item={child} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={onDrop} onAdd={onAdd} />
152+ ))}
153+ </ul>
154+ )}
155+ </li>
156+ );
157+}
158+
159+/** A tree of folios in one place, opened to the one being read; drag to reorder or nest. */
160+function Tree({ slug, place, nodes, current, drag, setDrag, empty }: { slug: string; place: Place; nodes: FolioTreeNode[]; current: string | null; drag: Drag; setDrag: (d: Drag) => void; empty: string }) {
161+ const navigate = useNavigate();
162+ const { send } = useFoliosAction(slug);
163+ const tree = useMemo(() => buildTree(nodes), [nodes]);
164+ const [open, setOpen] = useState<Set<string>>(() => new Set(pathTo(nodes, current)));
165+ // Opening an artifact opens the way to it.
166+ useEffect(() => {
167+ if (!current) return;
168+ const path = pathTo(nodes, current);
169+ if (path.length) setOpen((was) => new Set([...was, ...path]));
170+ }, [current, nodes]);
171+ const toggle = (id: string) =>
172+ setOpen((was) => {
173+ const next = new Set(was);
174+ if (next.has(id)) next.delete(id);
175+ else next.add(id);
176+ return next;
177+ });
178+ const add = async (parent: string) => {
179+ const made = await send<{ path: string }>("create", { folio: { kind: "doc", parent_id: parent } });
180+ if (made.ok) {
181+ setOpen((was) => new Set([...was, parent]));
182+ navigate(made.value.path);
183+ }
184+ };
185+ const drop = async (target: TreeItem, where: "before" | "inside") => {
186+ if (!drag) return;
187+ const move = where === "inside" ? { space_id: place.space_id, parent_id: target.id, before_id: null } : { space_id: place.space_id, parent_id: target.parent_id, before_id: target.id };
188+ setDrag(null);
189+ await send("move", { folio_id: drag.id, move });
190+ if (where === "inside") setOpen((was) => new Set([...was, target.id]));
191+ };
192+ return (
193+ <ul>
194+ {tree.map((item) => (
195+ <TreeRow key={item.id} slug={slug} place={place} item={item} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={drop} onAdd={add} />
196+ ))}
197+ {tree.length === 0 && <li className="py-1 pr-2 pl-9 text-xs text-faint">{empty}</li>}
198+ </ul>
199+ );
200+}
201+
202+function SpaceTree({ slug, space, current, drag, setDrag }: { slug: string; space: FoliosSidebarSpace; current: string | null; drag: Drag; setDrag: (d: Drag) => void }) {
203+ const navigate = useNavigate();
204+ const { send } = useFoliosAction(slug);
205+ const [collapsed, setCollapsed] = useState(false);
206+ const editable = canDo(space.viewer_role, "edit");
207+ const place: Place = { space_id: space.id, key: space.id, editable };
208+ return (
209+ <li className="mt-1">
210+ <div
211+ className={`${ROW} text-fg-soft`}
212+ onDragOver={(e) => {
213+ if (drag && editable) e.preventDefault();
214+ }}
215+ onDrop={async (e) => {
216+ // Dropped on the space itself: to its top, at the end.
217+ e.preventDefault();
218+ if (!drag) return;
219+ const id = drag.id;
220+ setDrag(null);
221+ await send("move", { folio_id: id, move: { space_id: space.id, parent_id: null, before_id: null } });
222+ }}
223+ >
224+ <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">
225+ <ChevronRight size={13} className={`transition-transform ${collapsed ? "" : "rotate-90"}`} />
226+ </button>
227+ <NavLink to={spacePath(slug, space.slug)} end prefetch="intent" className={({ isActive }) => `flex min-w-0 grow items-center gap-1.5 font-medium ${isActive ? "text-fg" : ""}`}>
228+ <span className="flex w-4 shrink-0 justify-center">
229+ <SpaceIcon space={space} size={14} />
230+ </span>
231+ <span className="min-w-0 truncate">{space.name}</span>
232+ </NavLink>
233+ {canDo(space.viewer_role, "manage") && (
234+ <Hint label="Space settings">
235+ <NavLink to={`${spacePath(slug, space.slug)}/settings`} aria-label={`${space.name} settings`} className={ROW_BUTTON}>
236+ <Settings size={13} />
237+ </NavLink>
238+ </Hint>
239+ )}
240+ {editable && (
241+ <Hint label={`New doc in ${space.name}`}>
242+ <button
243+ type="button"
244+ onClick={async () => {
245+ const made = await send<{ path: string }>("create", { folio: { kind: "doc", space_id: space.id } });
246+ if (made.ok) navigate(made.value.path);
247+ }}
248+ aria-label={`New doc in ${space.name}`}
249+ className={ROW_BUTTON}
250+ >
251+ <Plus size={13} />
252+ </button>
253+ </Hint>
254+ )}
255+ </div>
256+ {!collapsed && <Tree slug={slug} place={place} nodes={space.tree} current={current} drag={drag} setDrag={setDrag} empty="Nothing here yet." />}
257+ </li>
258+ );
259+}
260+
261+function RepoFolderRows({ slug, repo, folder, depth, current }: { slug: string; repo: string; folder: RepoFolder; depth: number; current: string }) {
262+ const [open, setOpen] = useState<Set<string>>(() => new Set(folder.folders.filter((f) => current.includes(`/${f.path}/`)).map((f) => f.path)));
263+ return (
264+ <>
265+ {folder.files.map((f) => (
266+ <li key={f.path}>
267+ <NavLink to={repoFilePath(slug, repo, f.path)} prefetch="intent" className={({ isActive }) => `${ROW} ${isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`} style={{ paddingLeft: 26 + depth * 14 }}>
268+ <FileText size={14} className="shrink-0 text-faint" aria-hidden="true" />
269+ <span className="min-w-0 truncate">{f.title}</span>
270+ </NavLink>
271+ </li>
272+ ))}
273+ {folder.folders.map((sub) => {
274+ const expanded = open.has(sub.path);
275+ return (
276+ <li key={sub.path}>
277+ <button
278+ type="button"
279+ aria-expanded={expanded}
280+ onClick={() =>
281+ setOpen((was) => {
282+ const next = new Set(was);
283+ if (next.has(sub.path)) next.delete(sub.path);
284+ else next.add(sub.path);
285+ return next;
286+ })
287+ }
288+ className={`${ROW} w-full text-muted hover:bg-raised/60 hover:text-fg`}
289+ style={{ paddingLeft: 4 + (depth + 1) * 14 }}
290+ >
291+ <ChevronRight size={13} className={`shrink-0 text-faint transition-transform ${expanded ? "rotate-90" : ""}`} />
292+ <Folder size={14} className="shrink-0 text-faint" aria-hidden="true" />
293+ <span className="min-w-0 truncate">{sub.name}</span>
294+ </button>
295+ {expanded && (
296+ <ul>
297+ <RepoFolderRows slug={slug} repo={repo} folder={sub} depth={depth + 1} current={current} />
298+ </ul>
299+ )}
300+ </li>
301+ );
302+ })}
303+ </>
304+ );
305+}
306+
307+/** A project's docs folder: read-only, its files as folders, from the repository's default branch. */
308+function RepoTree({ slug, space }: { slug: string; space: DocRepoSpace }) {
309+ const { pathname } = useLocation();
310+ const base = `/${slug}/-/artifacts/repo/${space.repo}/`;
311+ const here = decodeURIComponent(pathname).startsWith(base);
312+ const [collapsed, setCollapsed] = useState(!here);
313+ const root = useMemo(() => repoFolders(space.files), [space.files]);
314+ return (
315+ <li className="mt-1">
316+ <div className={`${ROW} text-fg-soft`}>
317+ <button type="button" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed} aria-label={collapsed ? `Show ${space.repo}'s docs` : `Hide ${space.repo}'s docs`} className="flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg">
318+ <ChevronRight size={13} className={`transition-transform ${collapsed ? "" : "rotate-90"}`} />
319+ </button>
320+ <span className="flex w-4 shrink-0 justify-center">
321+ <FolderGit2 size={14} className="text-faint" aria-hidden="true" />
322+ </span>
323+ <span className="min-w-0 grow truncate font-medium">{space.repo}</span>
324+ </div>
325+ {!collapsed && (
326+ <ul>
327+ <RepoFolderRows slug={slug} repo={space.repo} folder={root} depth={0} current={decodeURIComponent(pathname)} />
328+ {space.files.length === 0 && <li className="py-1 pr-2 pl-9 text-xs text-faint">{space.indexed_at ? "No docs folder or README." : "Reading its docs…"}</li>}
329+ </ul>
330+ )}
331+ </li>
332+ );
333+}
334+
335+function RefRow({ folio, trailing }: { folio: FolioRef; trailing?: ReactNode }) {
336+ return (
337+ <li>
338+ <SideLink to={folio.path} icon={<FolioGlyph folio={folio} size={14} />} trailing={trailing}>
339+ {folio.title || "Untitled"}
340+ </SideLink>
341+ </li>
342+ );
343+}
344+
345+export function FoliosSidebar({ slug, onClose }: { slug: string; onClose?: () => void }) {
346+ const data = useFoliosData();
347+ const navigate = useNavigate();
348+ const params = useParams();
349+ const current = folioIdFrom(params.folio);
350+ const [drag, setDrag] = useState<Drag>(null);
351+ const [query, setQuery] = useState("");
352+ const [addingRepo, setAddingRepo] = useState(false);
353+ const [error, setError] = useState<string | null>(null);
354+ const { revalidate } = useRevalidator();
355+ const sidebar = data?.sidebar;
356+ const make = async (kind: FolioKind) => {
357+ setError(null);
358+ const made = await foliosRequest<{ path: string }>(slug, "create", { folio: { kind } });
359+ if (made.ok) {
360+ void revalidate();
361+ navigate(made.value.path);
362+ } else setError(made.error.message);
363+ };
364+ return (
365+ <div className="flex h-full flex-col">
366+ <div className="flex h-14 shrink-0 items-center gap-1 border-b border-line pr-2.5 pl-4">
367+ <h2 className="min-w-0 grow truncate text-[0.9375rem] font-semibold">Artifacts</h2>
368+ <NewMenu onMake={make}>
369+ <button type="button" aria-label="New" className="flex h-8 items-center gap-0.5 rounded-md px-1.5 text-faint transition-colors hover:bg-raised hover:text-fg">
370+ <Plus size={16} />
371+ <ChevronDown size={12} />
372+ </button>
373+ </NewMenu>
374+ {onClose && (
375+ <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">
376+ <X size={16} />
377+ </button>
378+ )}
379+ </div>
380+ <nav aria-label="Artifacts" className="min-h-0 grow overflow-y-auto px-2.5 pt-3 pb-4 [scrollbar-width:thin]">
381+ <form
382+ role="search"
383+ onSubmit={(e) => {
384+ e.preventDefault();
385+ navigate(`/${slug}/-/artifacts?q=${encodeURIComponent(query.trim())}`);
386+ }}
387+ className="mb-2"
388+ >
389+ <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">
390+ <Search size={14} aria-hidden="true" />
391+ <span className="sr-only">Search artifacts</span>
392+ <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search artifacts" className="min-w-0 grow bg-transparent text-fg outline-none placeholder:text-faint" />
393+ </label>
394+ </form>
395+ {error && <p className="mb-2 px-2 text-xs text-danger">{error}</p>}
396+ <div className="space-y-px">
397+ <SideLink to={`/${slug}/-/artifacts`} end icon={<Home size={15} />}>
398+ Home
399+ </SideLink>
400+ <SideLink to={`/${slug}/-/artifacts/templates`} icon={<LayoutTemplate size={15} />}>
401+ Templates
402+ </SideLink>
403+ </div>
404+ {sidebar === undefined || sidebar === null ? (
405+ sidebar === null ? (
406+ <p className="mt-4 px-2 text-xs leading-relaxed text-faint">Artifacts didn&apos;t answer. Your spaces will show here once it does.</p>
407+ ) : (
408+ <div className="mt-5 space-y-3 px-2" aria-busy="true">
409+ {[0, 1, 2, 3].map((i) => (
410+ <Skeleton key={i} className="h-3 w-36" />
411+ ))}
412+ </div>
413+ )
414+ ) : (
415+ <>
416+ {sidebar.favorites.length > 0 && (
417+ <Section title="Favorites">
418+ <ul className="space-y-px">
419+ {sidebar.favorites.map((f) => (
420+ <RefRow key={f.id} folio={f} trailing={<Star size={12} className="shrink-0 fill-current text-warn/80" aria-hidden="true" />} />
421+ ))}
422+ </ul>
423+ </Section>
424+ )}
425+ <Section
426+ title="Spaces"
427+ action={
428+ <DropdownMenu>
429+ <Hint label="Browse or make spaces">
430+ <DropdownMenuTrigger asChild>
431+ <button type="button" aria-label="Browse or make spaces" className="flex size-6 items-center justify-center rounded text-faint hover:bg-raised hover:text-fg">
432+ <Plus size={13} />
433+ </button>
434+ </DropdownMenuTrigger>
435+ </Hint>
436+ <DropdownMenuContent align="end" className="w-44">
437+ <DropdownMenuItem onSelect={() => navigate(`/${slug}/-/artifacts/spaces`)}>
438+ <Compass size={14} /> Browse spaces
439+ </DropdownMenuItem>
440+ <DropdownMenuSeparator />
441+ <DropdownMenuItem onSelect={() => navigate(`/${slug}/-/artifacts/spaces/new`)}>
442+ <Plus size={14} /> New space
443+ </DropdownMenuItem>
444+ </DropdownMenuContent>
445+ </DropdownMenu>
446+ }
447+ >
448+ <ul>
449+ {sidebar.spaces.map((space) => (
450+ <SpaceTree key={space.id} slug={slug} space={space} current={current} drag={drag} setDrag={setDrag} />
451+ ))}
452+ {sidebar.spaces.length === 0 && <li className="px-2 py-1 text-xs text-faint">No spaces yet.</li>}
453+ </ul>
454+ </Section>
455+ <Section
456+ title="Private"
457+ action={
458+ <Hint label="New private doc">
459+ <button type="button" onClick={() => make("doc")} aria-label="New private doc" className="flex size-6 items-center justify-center rounded text-faint hover:bg-raised hover:text-fg">
460+ <Plus size={13} />
461+ </button>
462+ </Hint>
463+ }
464+ >
465+ <div
466+ onDragOver={(e) => {
467+ if (drag) e.preventDefault();
468+ }}
469+ onDrop={async (e) => {
470+ // Dropped on Private's empty space: to its top, at the end.
471+ if (e.target !== e.currentTarget || !drag) return;
472+ e.preventDefault();
473+ const id = drag.id;
474+ setDrag(null);
475+ const moved = await foliosRequest(slug, "move", { folio_id: id, move: { space_id: null, parent_id: null, before_id: null } });
476+ if (moved.ok) void revalidate();
477+ else setError(moved.error.message);
478+ }}
479+ >
480+ <Tree slug={slug} place={{ space_id: null, key: "private", editable: true }} nodes={sidebar.private_tree} current={current} drag={drag} setDrag={setDrag} empty="Only you can see what's here." />
481+ </div>
482+ </Section>
483+ {sidebar.shared.length > 0 && (
484+ <Section title="Shared">
485+ <ul className="space-y-px">
486+ {sidebar.shared.map((f) => (
487+ <RefRow key={f.id} folio={f} />
488+ ))}
489+ </ul>
490+ </Section>
491+ )}
492+ <div className="mt-4 space-y-px">
493+ {!!sidebar.stale_count && (
494+ <SideLink to={`/${slug}/-/artifacts/stale`} icon={<AlertTriangle size={15} className="text-warn" />} trailing={<span className="text-[0.6875rem] text-warn tabular-nums">{sidebar.stale_count}</span>}>
495+ Possibly out of date
496+ </SideLink>
497+ )}
498+ </div>
499+ <Section
500+ title="Projects' docs"
501+ open={(sidebar.repos ?? []).length > 0}
502+ action={
503+ <Hint label="Show a project's docs">
504+ <button type="button" onClick={() => setAddingRepo(true)} aria-label="Show a project's docs" className="flex size-6 items-center justify-center rounded text-faint hover:bg-raised hover:text-fg">
505+ <Plus size={13} />
506+ </button>
507+ </Hint>
508+ }
509+ >
510+ <ul>
511+ {(sidebar.repos ?? []).map((space) => (
512+ <RepoTree key={space.id} slug={slug} space={space} />
513+ ))}
514+ {(sidebar.repos ?? []).length === 0 && (
515+ <li>
516+ <button type="button" onClick={() => setAddingRepo(true)} className="px-2 py-1 text-left text-xs text-faint hover:text-fg">
517+ Show a repository&apos;s docs folder here, read-only.
518+ </button>
519+ </li>
520+ )}
521+ </ul>
522+ </Section>
523+ <div className="mt-4 space-y-px">
524+ <SideLink to={`/${slug}/-/artifacts/trash`} icon={<Trash2 size={15} />} trailing={sidebar.trash_count ? <span className="text-[0.6875rem] text-faint tabular-nums">{sidebar.trash_count}</span> : undefined}>
525+ Trash
526+ </SideLink>
527+ </div>
528+ <RepoDocsDialog slug={slug} open={addingRepo} onOpenChange={setAddingRepo} shown={(sidebar.repos ?? []).map((r) => r.repo.toLowerCase())} onAdded={() => void revalidate()} />
529+ </>
530+ )}
531+ </nav>
532+ </div>
533+ );
534+}
+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 what is in it, 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." : "Members-only 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 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. Artifacts 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+}
+87−0
1+/**
2+ * Chat's cards for artifact links (docs/ARTIFACTS_MODE.md section 4.3 rule
3+ * 3): each viewer asks the site for their own, so someone who can open the
4+ * artifact sees its kind, title, space and last edit, and anyone else sees
5+ * only "An artifact you don't have access to", with no title. Looking is
6+ * not opening: a card never makes a link-shared artifact readable.
7+ */
8+import type { FolioKind, Result } from "@g1t/contracts";
9+import { Lock } from "lucide-react";
10+import { useEffect, useMemo, useState } from "react";
11+import { Link } from "react-router";
12+
13+import { linkedArtifacts } from "../../lib/folios";
14+import { TimeAgo } from "../ui";
15+import { KindIcon } from "./kinds";
16+
17+/** What the site answers for one link (routes/workspace/folios/api.ts `unfurl`); null: not for this viewer. */
18+type Card = { id: string; kind: FolioKind; title: string; icon: string | null; path: string; space: string | null; private: boolean; excerpt: string; edited_at: string; edited_by: string | null } | null;
19+
20+/** The most cards one message shows. */
21+const MAX_CARDS = 3;
22+/** How long a card is kept in this tab before it is asked for again. */
23+const KEEP_MS = 60_000;
24+
25+const cards = new Map<string, { at: number; card: Promise<Card | undefined> }>();
26+
27+function cardFor(slug: string, id: string): Promise<Card | undefined> {
28+ const key = `${slug}/${id}`;
29+ const kept = cards.get(key);
30+ if (kept && Date.now() - kept.at < KEEP_MS) return kept.card;
31+ const card = fetch(`/${slug}/-/artifacts/api?unfurl=${encodeURIComponent(id)}`, { headers: { accept: "application/json" } })
32+ .then((r) => r.json() as Promise<Result<Card>>)
33+ .then((r) => (r.ok ? r.value : undefined))
34+ // Not answered: no card at all, rather than a wrong one.
35+ .catch(() => undefined);
36+ cards.set(key, { at: Date.now(), card });
37+ return card;
38+}
39+
40+export function FolioUnfurls({ slug, body }: { slug: string; body: string }) {
41+ const ids = useMemo(() => linkedArtifacts(body, slug).slice(0, MAX_CARDS), [body, slug]);
42+ if (!ids.length) return null;
43+ return (
44+ <div className="mt-1.5 flex max-w-md flex-col gap-1.5">
45+ {ids.map((id) => (
46+ <Unfurl key={id} slug={slug} id={id} />
47+ ))}
48+ </div>
49+ );
50+}
51+
52+function Unfurl({ slug, id }: { slug: string; id: string }) {
53+ const [card, setCard] = useState<Card | undefined | "loading">("loading");
54+ useEffect(() => {
55+ let live = true;
56+ void cardFor(slug, id).then((c) => live && setCard(c));
57+ return () => {
58+ live = false;
59+ };
60+ }, [slug, id]);
61+ if (card === "loading" || card === undefined) return null;
62+ if (card === null) {
63+ return (
64+ <div className="flex items-center gap-2.5 rounded-lg border border-line bg-surface px-3 py-2 text-xs text-faint">
65+ <Lock size={14} className="shrink-0" />
66+ An artifact you don&apos;t have access to
67+ </div>
68+ );
69+ }
70+ return (
71+ <Link to={card.path} className="group flex items-start gap-2.5 rounded-lg border border-line bg-surface px-3 py-2 transition-colors hover:border-line-strong">
72+ <KindIcon kind={card.kind} size={14} box={26} />
73+ <span className="min-w-0 grow">
74+ <span className="block truncate text-sm font-medium text-fg group-hover:text-accent">
75+ {card.icon && <span className="mr-1">{card.icon}</span>}
76+ {card.title || "Untitled"}
77+ </span>
78+ {card.excerpt && <span className="mt-0.5 line-clamp-2 text-xs text-muted">{card.excerpt}</span>}
79+ <span className="mt-0.5 flex items-center gap-1 text-[0.6875rem] text-faint">
80+ {card.private && <Lock size={10} aria-label="Only you can see this" />}
81+ {card.space ?? "Private"} · Edited <TimeAgo at={card.edited_at} />
82+ {card.edited_by ? ` by ${card.edited_by}` : ""}
83+ </span>
84+ </span>
85+ </Link>
86+ );
87+}
+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}/-/artifacts/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−4
1818 Plug,
1919 Plus,
2020 Settings,
21+ Shapes,
2122 Smile,
2223 Sparkles,
2324 Sun,
146147 { key: "home", label: "Home", to: `/${slug}/-/home`, icon: <House size={21} /> },
147148 code
148149 ? { key: "code", label: "Code", to: `/${slug}/-/projects`, icon: <Code2 size={21} /> }
149− : { key: "docs", label: "Docs", to: `/${slug}/-/docs`, icon: <BookOpen size={21} /> },
150+ : { key: "artifacts", label: "Artifacts", to: `/${slug}/-/artifacts`, icon: <Shapes size={21} /> },
150151 {
151152 key: "chat",
152153 label: "Chat",
376377 </div>
377378 </div>
378379 <SheetGroup title="Workspace">
379− {/* Docs has no tab of its own for someone with Code: it leads here. */}
380+ {/* Artifacts has no tab of its own for someone with Code: it leads here. */}
380381 {hasCodeAccess(workspace) && (
381− <SheetRow to={`/${slug}/-/docs`} icon={<BookOpen />} end={<ChevronRight size={16} className="text-faint" />}>
382− Docs
382+ <SheetRow to={`/${slug}/-/artifacts`} icon={<Shapes />} end={<ChevronRight size={16} className="text-faint" />}>
383+ Artifacts
383384 </SheetRow>
384385 )}
385386 <SheetRow to={`/${slug}/-/workspace`} icon={<Building2 />}>
+3−2
5656 SendHorizontal,
5757 Settings,
5858 ShieldCheck,
59+ Shapes,
5960 Sparkles,
6061 SquarePen,
6162 } from "lucide-react";
188189
189190 /** What the line under the frame says about the moment: working today, or a preview. */
190191 function honesty(frame: Frame): { today: boolean; label: string } {
191− 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" };
192+ if (frame.scene === "docs") return { today: false, label: "Docs in Artifacts work today; an agent updating one after a merge on its own is coming soon" };
192193 if (frame.scene === "agents") return { today: true, label: "Sessions work today" };
193194 if (frame.scene === "code") return { today: true, label: "Works today" };
194195 if (frame.handoff && !frame.shipped) return { today: false, label: "g1t splitting work between colleagues: coming soon" };
351352 { key: "home", icon: <House size={15} />, label: "Home" },
352353 { key: "code", icon: <Code2 size={15} />, label: "Code", target: "rail-code", scene: "code" },
353354 { key: "chat", icon: <MessagesSquare size={15} />, label: "Chat", target: "rail-chat", scene: "chat", badge: 3 },
354− { key: "docs", icon: <BookOpen size={15} />, label: "Docs", target: "rail-docs", scene: "docs" },
355+ { key: "artifacts", icon: <Shapes size={15} />, label: "Artifacts", target: "rail-docs", scene: "docs" },
355356 { key: "agents", icon: <Sparkles size={15} />, label: "Agents", target: "rail-agents", scene: "agents" },
356357 { key: "inbox", icon: <Inbox size={15} />, label: "Inbox", badge: 2 },
357358 ];
+2−2
1−import { Activity, BookOpen, Building2, Check, CircleHelp, CircleUserRound, Code2, House, Inbox, Keyboard, LifeBuoy, MessagesSquare, Plus, Settings, Sparkles } from "lucide-react";
1+import { Activity, BookOpen, Building2, Check, CircleHelp, CircleUserRound, Code2, House, Inbox, Keyboard, LifeBuoy, MessagesSquare, Plus, Settings, Shapes, Sparkles } from "lucide-react";
22 import { type ReactNode, useState } from "react";
33 import { Link, useLocation, useNavigation } from "react-router";
44
264264 icon: <MessagesSquare size={19} />,
265265 badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 },
266266 },
267− { key: "docs", label: "Docs", icon: <BookOpen size={19} /> },
267+ { key: "artifacts", label: "Artifacts", icon: <Shapes size={19} /> },
268268 { key: "agents", label: "Agents", icon: <Sparkles size={19} /> },
269269 { key: "inbox", label: "Inbox", icon: <Inbox size={19} />, badge: { count: unread.inbox, loud: true } },
270270 ];
+6−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";
31+import { FoliosSidebar } from "./folios/sidebar";
3232 import { HelpMenu, Rail } from "./rail";
3333 import { HomeSidebar } from "./home-sidebar";
3434 import { G1tMark } from "./orchestrator";
17381738 insights: "Insights",
17391739 sessions: "Sessions",
17401740 chat: "Chat",
1741− docs: "Docs",
1741+ artifacts: "Artifacts",
17421742 home: "Home",
17431743 "code-access": "Code access",
17441744 overview: "Overview",
20712071 }
20722072
20732073 /** The sidebars that sit beside the rail, one per mode that has one. */
2074−type Panel = "home" | "chat" | "docs" | "agents" | "code" | "workspace" | "account";
2074+type Panel = "home" | "chat" | "artifacts" | "agents" | "code" | "workspace" | "account";
20752075
20762076 /** Each sidebar's name, for the phone's button that opens it. */
20772077 const MODE_MENU: Record<Panel, string> = {
20782078 home: "Home",
20792079 chat: "Chat",
2080− docs: "Docs",
2080+ artifacts: "Artifacts",
20812081 agents: "Agents",
20822082 code: "Code",
20832083 workspace: "Workspace",
21582158 return <HomeSidebar slug={ws.slug} shell={shell} code={code} onFind={find} header={<ModeHeader title="Home" onClose={close} />} />;
21592159 case "chat":
21602160 return <ChatSidebar slug={ws.slug} />;
2161− case "docs":
2162− return <DocsSidebar slug={ws.slug} onClose={close} />;
2161+ case "artifacts":
2162+ return <FoliosSidebar slug={ws.slug} onClose={close} />;
21632163 case "agents":
21642164 return <AgentsSidebar slug={ws.slug} shellAgents={shell.agents ?? null} code={code} owner={ws.role === "owner"} />;
21652165 case "workspace":
+0−74
1−import assert from "node:assert/strict";
2−import { test } from "node:test";
3−
4−import { buildTree, canDo, citationHref, coverStyle, cursorColour, flatten, pageIdOf, pagePath, pathTo, readingTime, repoFilePath, repoFolders, 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−});
54−
55−test("a citation links to its code, a glob to the folder it starts from", () => {
56− assert.equal(citationHref({ repo: "acme/web", path: "src/export.ts", ref: "abc1234" }), "/acme/web/blob/abc1234/src/export.ts");
57− assert.equal(citationHref({ repo: "acme/web", path: "src/jobs", ref: null }), "/acme/web/tree/HEAD/src/jobs");
58− assert.equal(citationHref({ repo: "acme/web", path: "src/**/*.sql", ref: "abc" }), "/acme/web/tree/abc/src");
59−});
60−
61−test("a project's docs become folders under the space", () => {
62− const root = repoFolders([
63− { path: "README.md", title: "Acme" },
64− { path: "docs/setup.md", title: "Setup" },
65− { path: "docs/guides/deploy.md", title: "Deploy" },
66− ]);
67− assert.deepEqual(
68− root.files.map((f) => f.path),
69− ["README.md", "docs/setup.md"],
70− );
71− assert.equal(root.folders[0]!.name, "guides");
72− assert.deepEqual(root.folders[0]!.files.map((f) => f.title), ["Deploy"]);
73− assert.equal(repoFilePath("acme", "acme/web", "docs/a b.md"), "/acme/-/docs/repo/acme/web/docs/a%20b.md");
74−});
+0−185
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−}
138−
139−/**
140− * Where a citation links: the file or folder in Code at the commit it was
141− * cited at, or the default branch (`HEAD`). A glob links to the folder it
142− * starts from. Mirrors `citationHref` in services/docs src/citations.ts.
143− */
144−export function citationHref(c: { repo: string; path: string; ref: string | null }): string {
145− const parts = c.path.split("/").filter(Boolean);
146− const globAt = parts.findIndex((p) => /[*?]/.test(p));
147− const glob = globAt >= 0;
148− const shown = (glob ? parts.slice(0, globAt) : parts).map(encodeURIComponent).join("/");
149− const kind = glob || !/\.[A-Za-z0-9]{1,10}$/.test(c.path) ? "tree" : "blob";
150− return `/${c.repo}/${kind}/${encodeURIComponent(c.ref || "HEAD")}${shown ? `/${shown}` : ""}`;
151−}
152−
153−/** A project's docs file's address in Docs. */
154−export function repoFilePath(workspace: string, repo: string, path: string): string {
155− return `/${workspace}/-/docs/repo/${repo}/${path.split("/").map(encodeURIComponent).join("/")}`;
156−}
157−
158−/** A folder of a project's docs, as the sidebar shows it: files, then folders, each by name. */
159−export type RepoFolder = { name: string; path: string; files: { path: string; title: string }[]; folders: RepoFolder[] };
160−
161−/** A project's docs files as folders: README and `docs/` at the top, `docs/a/b.md` under `a`. */
162−export function repoFolders(files: { path: string; title: string }[]): RepoFolder {
163− const root: RepoFolder = { name: "", path: "", files: [], folders: [] };
164− for (const file of files) {
165− // `docs/` is the space itself: its files sit at the top beside the README.
166− const parts = file.path.replace(/^docs\//i, "").split("/");
167− parts.pop();
168− let at = root;
169− for (const part of parts) {
170− let next = at.folders.find((f) => f.name === part);
171− if (!next) {
172− next = { name: part, path: at.path ? `${at.path}/${part}` : part, files: [], folders: [] };
173− at.folders.push(next);
174− }
175− at = next;
176− }
177− at.files.push(file);
178− }
179− const sort = (f: RepoFolder) => {
180− f.folders.sort((a, b) => a.name.localeCompare(b.name));
181− f.folders.forEach(sort);
182− };
183− sort(root);
184− return root;
185−}
+98−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { buildTree, canDo, citationHref, coverStyle, cursorColour, dayGroups, dayKey, flatten, linkedArtifacts, pathTo, readingTime, repoFilePath, repoFolders, snippetParts, spacePath } from "./folios.ts";
5+
6+const node = (id: string, parent_id: string | null, position: number) => ({ id, kind: "doc" as const, parent_id, position, title: id.toUpperCase(), icon: null, slug: id, restricted: false });
7+
8+const nodes = [node("b", null, 2), node("a", null, 1), node("c", "a", 1), node("orphan", "gone", 3)];
9+
10+test("the tree orders each level and lifts folios whose parent isn't there", () => {
11+ const tree = buildTree(nodes);
12+ assert.deepEqual(
13+ tree.map((n) => n.id),
14+ ["a", "b", "orphan"],
15+ );
16+ assert.equal(tree[0]!.children[0]!.id, "c");
17+ assert.equal(tree[0]!.children[0]!.depth, 1);
18+ assert.deepEqual(
19+ flatten(tree).map((n) => n.id),
20+ ["a", "c", "b", "orphan"],
21+ );
22+ assert.deepEqual(pathTo(nodes, "c"), ["a"]);
23+});
24+
25+test("the home list groups by the viewer's day: Today, Yesterday, then dates", () => {
26+ const now = new Date("2026-10-09T15:00:00Z");
27+ const items = ["2026-10-09T14:00:00Z", "2026-10-09T01:00:00Z", "2026-10-08T20:00:00Z", "2026-10-07T12:00:00Z", "2025-12-31T12:00:00Z"];
28+ const utc = dayGroups(items, (i) => i, "UTC", now);
29+ assert.deepEqual(
30+ utc.map((g) => [g.label, g.items.length]),
31+ [
32+ ["Today", 2],
33+ ["Yesterday", 1],
34+ ["Oct 7", 1],
35+ ["Dec 31, 2025", 1],
36+ ],
37+ );
38+ // At 01:00 UTC it is still the 8th in Chicago: that one is yesterday's there.
39+ const chicago = dayGroups(items, (i) => i, "America/Chicago", now);
40+ assert.deepEqual(
41+ chicago.map((g) => [g.label, g.items.length]),
42+ [
43+ ["Today", 1],
44+ ["Yesterday", 2],
45+ ["Oct 7", 1],
46+ ["Dec 31, 2025", 1],
47+ ],
48+ );
49+ assert.equal(dayKey("2026-10-09T01:00:00Z", "America/Chicago"), "2026-10-08");
50+});
51+
52+test("chat messages name the artifacts they link to, in this workspace only", () => {
53+ const id = "fol_01jb2k7x9hfq0b3zj0f5s2m8ra";
54+ const other = "fol_01jb2k7x9hfq0b3zj0f5s2m8rb";
55+ assert.deepEqual(linkedArtifacts(`See https://g1t.sh/acme/-/artifacts/q4-plan-${id} and /acme/-/artifacts/${other}, and again /Acme/-/artifacts/${id}`, "acme"), [id, other]);
56+ assert.deepEqual(linkedArtifacts(`/other/-/artifacts/x-${id}`, "acme"), []);
57+ assert.deepEqual(linkedArtifacts(`/acme/-/artifacts/spaces/general`, "acme"), []);
58+});
59+
60+test("snippets mark their matches", () => {
61+ assert.deepEqual(snippetParts("a [[b]] c"), [
62+ { text: "a ", match: false },
63+ { text: "b", match: true },
64+ { text: " c", match: false },
65+ ]);
66+});
67+
68+test("covers, colours, roles, reading time", () => {
69+ assert.match(coverStyle("gradient:1")!, /^linear-gradient/);
70+ assert.match(coverStyle("https://g1tusercontent.com/docs-files/x")!, /url\("https:/);
71+ assert.equal(coverStyle("javascript:alert(1)"), null);
72+ assert.equal(cursorColour("ana"), cursorColour("ana"));
73+ assert.equal(canDo("edit", "comment"), true);
74+ assert.equal(canDo("comment", "edit"), false);
75+ assert.deepEqual(readingTime("one two three"), { words: 3, minutes: 1 });
76+});
77+
78+test("a citation links to its code, a glob to the folder it starts from", () => {
79+ assert.equal(citationHref({ repo: "acme/web", path: "src/export.ts", ref: "abc1234" }), "/acme/web/blob/abc1234/src/export.ts");
80+ assert.equal(citationHref({ repo: "acme/web", path: "src/jobs", ref: null }), "/acme/web/tree/HEAD/src/jobs");
81+ assert.equal(citationHref({ repo: "acme/web", path: "src/**/*.sql", ref: "abc" }), "/acme/web/tree/abc/src");
82+});
83+
84+test("a project's docs become folders, and spaces and files have addresses under Artifacts", () => {
85+ const root = repoFolders([
86+ { path: "README.md", title: "Acme" },
87+ { path: "docs/setup.md", title: "Setup" },
88+ { path: "docs/guides/deploy.md", title: "Deploy" },
89+ ]);
90+ assert.deepEqual(
91+ root.files.map((f) => f.path),
92+ ["README.md", "docs/setup.md"],
93+ );
94+ assert.equal(root.folders[0]!.name, "guides");
95+ assert.deepEqual(root.folders[0]!.files.map((f) => f.title), ["Deploy"]);
96+ assert.equal(repoFilePath("acme", "acme/web", "docs/a b.md"), "/acme/-/artifacts/repo/acme/web/docs/a%20b.md");
97+ assert.equal(spacePath("acme", "general"), "/acme/-/artifacts/spaces/general");
98+});
+238−0
1+/**
2+ * Artifacts mode's pure helpers (code says "folio", people see
3+ * "artifact"; docs/ARTIFACTS_MODE.md): sidebar trees, the home list's
4+ * days, cursor colours, covers, roles, how search snippets mark their
5+ * matches, and projects' docs folders. No Workers or DOM imports, so it is
6+ * tested under Node.
7+ */
8+import type { DocRole, FolioKind, FolioListQuery, FolioTreeNode } from "@g1t/contracts";
9+
10+const KINDS: readonly string[] = ["doc", "slides", "design", "dashboard"];
11+
12+/**
13+ * The home list's query, from an address's search
14+ * (`?tab=&kind=&space=&owner=&project=&q=&cursor=`). `owner` is passed
15+ * through as written: Home turns a username into a member key.
16+ */
17+export function listQuery(q: URLSearchParams): FolioListQuery {
18+ const tab = q.get("tab");
19+ const kind = q.get("kind") ?? "";
20+ return {
21+ tab: tab === "yours" || tab === "shared" ? tab : "all",
22+ kinds: KINDS.includes(kind) ? [kind as FolioKind] : null,
23+ space_id: q.get("space") || null,
24+ owner: q.get("owner") || null,
25+ project: q.get("project") || null,
26+ q: q.get("q")?.trim() || null,
27+ cursor: q.get("cursor") || null,
28+ limit: 30,
29+ };
30+}
31+
32+export type TreeItem = FolioTreeNode & { children: TreeItem[]; depth: number };
33+
34+type TreeNode = Pick<FolioTreeNode, "id" | "parent_id" | "position">;
35+
36+/** A flat list of folios as a tree, each level in position order. */
37+export function buildTree(nodes: FolioTreeNode[]): TreeItem[] {
38+ const byParent = new Map<string | null, FolioTreeNode[]>();
39+ const ids = new Set(nodes.map((n) => n.id));
40+ for (const node of nodes) {
41+ // One whose parent isn't in the list (in the trash, or not readable) shows at the top.
42+ const parent = node.parent_id && ids.has(node.parent_id) ? node.parent_id : null;
43+ const list = byParent.get(parent) ?? [];
44+ list.push(node);
45+ byParent.set(parent, list);
46+ }
47+ const seen = new Set<string>();
48+ const build = (parent: string | null, depth: number): TreeItem[] =>
49+ (byParent.get(parent) ?? [])
50+ .sort((a, b) => a.position - b.position || a.id.localeCompare(b.id))
51+ .filter((n) => !seen.has(n.id) && seen.add(n.id))
52+ .map((n) => ({ ...n, depth, children: build(n.id, depth + 1) }));
53+ return build(null, 0);
54+}
55+
56+/** Every folio in a tree, depth first, as a move dialog lists them. */
57+export function flatten(tree: TreeItem[]): TreeItem[] {
58+ const out: TreeItem[] = [];
59+ const walk = (items: TreeItem[]) => {
60+ for (const item of items) {
61+ out.push(item);
62+ walk(item.children);
63+ }
64+ };
65+ walk(tree);
66+ return out;
67+}
68+
69+/** The ids on the way to `id` (its ancestors), so the sidebar opens them. */
70+export function pathTo(nodes: TreeNode[], id: string | null): string[] {
71+ if (!id) return [];
72+ const byId = new Map(nodes.map((n) => [n.id, n]));
73+ const out: string[] = [];
74+ let at = byId.get(id)?.parent_id ?? null;
75+ while (at && !out.includes(at)) {
76+ out.unshift(at);
77+ at = byId.get(at)?.parent_id ?? null;
78+ }
79+ return out;
80+}
81+
82+/** A day of the home list: Today, Yesterday, then `Oct 7`, with the year when it isn't this year. */
83+export type DayGroup<T> = { key: string; label: string; items: T[] };
84+
85+/** The calendar day of `at` in `zone`, as `YYYY-MM-DD`. */
86+export function dayKey(at: string | number | Date, zone: string): string {
87+ const parts = new Intl.DateTimeFormat("en-US", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(new Date(at));
88+ const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
89+ return `${get("year")}-${get("month")}-${get("day")}`;
90+}
91+
92+/**
93+ * Items grouped by the day `at` gives each, in `zone` (the viewer's), in
94+ * the order they come: lists arrive newest first, so the days do too.
95+ */
96+export function dayGroups<T>(items: readonly T[], at: (item: T) => string, zone: string, now: Date = new Date()): DayGroup<T>[] {
97+ const today = dayKey(now, zone);
98+ const yesterday = dayKey(now.getTime() - 86_400_000, zone);
99+ const year = today.slice(0, 4);
100+ const out: DayGroup<T>[] = [];
101+ for (const item of items) {
102+ const key = dayKey(at(item), zone);
103+ let group = out.find((g) => g.key === key);
104+ if (!group) {
105+ const [y, m, d] = key.split("-").map(Number) as [number, number, number];
106+ // Noon UTC names the same calendar day in any zone's words.
107+ const date = new Date(Date.UTC(y, m - 1, d, 12));
108+ const label =
109+ key === today
110+ ? "Today"
111+ : key === yesterday
112+ ? "Yesterday"
113+ : new Intl.DateTimeFormat("en-US", { timeZone: "UTC", month: "short", day: "numeric", ...(key.slice(0, 4) === year ? {} : { year: "numeric" }) }).format(date);
114+ group = { key, label, items: [] };
115+ out.push(group);
116+ }
117+ group.items.push(item);
118+ }
119+ return out;
120+}
121+
122+/** A search snippet's parts: `[[word]]` marks a match. */
123+export function snippetParts(snippet: string): { text: string; match: boolean }[] {
124+ const out: { text: string; match: boolean }[] = [];
125+ const re = /\[\[([\s\S]*?)\]\]/g;
126+ let at = 0;
127+ for (let m = re.exec(snippet); m; m = re.exec(snippet)) {
128+ if (m.index > at) out.push({ text: snippet.slice(at, m.index), match: false });
129+ out.push({ text: m[1]!, match: true });
130+ at = m.index + m[0].length;
131+ }
132+ if (at < snippet.length) out.push({ text: snippet.slice(at), match: false });
133+ return out;
134+}
135+
136+/** Cursor colours: distinct, readable on the dark page. */
137+export const CURSOR_COLOURS = ["#b8a6ff", "#7dd3fc", "#86efac", "#fcd34d", "#fca5a5", "#f9a8d4", "#a5b4fc", "#5eead4", "#fdba74", "#c4b5fd"];
138+
139+/** A person's cursor colour, stable for their name. */
140+export function cursorColour(name: string): string {
141+ let hash = 0;
142+ for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
143+ return CURSOR_COLOURS[Math.abs(hash) % CURSOR_COLOURS.length]!;
144+}
145+
146+/** The covers a doc can take without an upload. */
147+export const COVER_GRADIENTS = [
148+ "linear-gradient(120deg, #2a2340 0%, #4b3d7a 50%, #8f7ee0 100%)",
149+ "linear-gradient(120deg, #11222c 0%, #1f4b5c 55%, #4fb3c8 100%)",
150+ "linear-gradient(120deg, #1d1f17 0%, #3d4a24 55%, #a3c45a 100%)",
151+ "linear-gradient(120deg, #2b1a1a 0%, #5c2e2e 55%, #d9776a 100%)",
152+ "linear-gradient(120deg, #241a2b 0%, #5a2d5f 55%, #d27bd8 100%)",
153+ "linear-gradient(120deg, #1a1d2b 0%, #2c3466 55%, #7a8cf0 100%)",
154+];
155+
156+/** A cover's CSS background: one of the gradients, or an image. */
157+export function coverStyle(cover: string | null | undefined): string | null {
158+ if (!cover) return null;
159+ const gradient = /^gradient:(\d{1,2})$/.exec(cover);
160+ if (gradient) return COVER_GRADIENTS[Number(gradient[1]) % COVER_GRADIENTS.length]!;
161+ if (/^https:\/\//.test(cover)) return `center / cover no-repeat url("${cover.replace(/["\\]/g, "")}")`;
162+ return null;
163+}
164+
165+const RANK: Record<DocRole, number> = { view: 1, comment: 2, edit: 3, manage: 4 };
166+
167+export function canDo(role: DocRole | null | undefined, need: DocRole): boolean {
168+ return !!role && RANK[role] >= RANK[need];
169+}
170+
171+/** How many words a doc has, and the minutes it takes to read. */
172+export function readingTime(markdown: string): { words: number; minutes: number } {
173+ const words = markdown.replace(/```[\s\S]*?```/g, " ").split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length;
174+ return { words, minutes: Math.max(1, Math.round(words / 230)) };
175+}
176+
177+/**
178+ * Where a citation links: the file or folder in Code at the commit it was
179+ * cited at, or the default branch (`HEAD`). A glob links to the folder it
180+ * starts from. Mirrors `citationHref` in services/docs src/citations.ts.
181+ */
182+export function citationHref(c: { repo: string; path: string; ref: string | null }): string {
183+ const parts = c.path.split("/").filter(Boolean);
184+ const globAt = parts.findIndex((p) => /[*?]/.test(p));
185+ const glob = globAt >= 0;
186+ const shown = (glob ? parts.slice(0, globAt) : parts).map(encodeURIComponent).join("/");
187+ const kind = glob || !/\.[A-Za-z0-9]{1,10}$/.test(c.path) ? "tree" : "blob";
188+ return `/${c.repo}/${kind}/${encodeURIComponent(c.ref || "HEAD")}${shown ? `/${shown}` : ""}`;
189+}
190+
191+/** A project's docs file's address in Artifacts. */
192+export function repoFilePath(workspace: string, repo: string, path: string): string {
193+ return `/${workspace}/-/artifacts/repo/${repo}/${path.split("/").map(encodeURIComponent).join("/")}`;
194+}
195+
196+/** A space's address in Artifacts. */
197+export function spacePath(workspace: string, space: string): string {
198+ return `/${workspace}/-/artifacts/spaces/${space}`;
199+}
200+
201+/** A folder of a project's docs, as the sidebar shows it: files, then folders, each by name. */
202+export type RepoFolder = { name: string; path: string; files: { path: string; title: string }[]; folders: RepoFolder[] };
203+
204+/** A project's docs files as folders: README and `docs/` at the top, `docs/a/b.md` under `a`. */
205+export function repoFolders(files: { path: string; title: string }[]): RepoFolder {
206+ const root: RepoFolder = { name: "", path: "", files: [], folders: [] };
207+ for (const file of files) {
208+ // `docs/` is the space itself: its files sit at the top beside the README.
209+ const parts = file.path.replace(/^docs\//i, "").split("/");
210+ parts.pop();
211+ let at = root;
212+ for (const part of parts) {
213+ let next = at.folders.find((f) => f.name === part);
214+ if (!next) {
215+ next = { name: part, path: at.path ? `${at.path}/${part}` : part, files: [], folders: [] };
216+ at.folders.push(next);
217+ }
218+ at = next;
219+ }
220+ at.files.push(file);
221+ }
222+ const sort = (f: RepoFolder) => {
223+ f.folders.sort((a, b) => a.name.localeCompare(b.name));
224+ f.folders.forEach(sort);
225+ };
226+ sort(root);
227+ return root;
228+}
229+
230+/** The artifact ids a chat message links to in this workspace, in order, once each. */
231+export function linkedArtifacts(body: string, workspace: string): string[] {
232+ const out: string[] = [];
233+ const re = /\/([A-Za-z0-9_.-]+)\/-\/artifacts\/(?:[a-z0-9-]*-)?(fol_[0-9a-hjkmnp-tv-z]{26})(?![0-9a-z])/g;
234+ for (let m = re.exec(body); m; m = re.exec(body)) {
235+ if (m[1]!.toLowerCase() === workspace.toLowerCase() && !out.includes(m[2]!)) out.push(m[2]!);
236+ }
237+ return out;
238+}
+4−1
99 contextClient,
1010 deploymentsClient,
1111 docsClient,
12+ foliosClient,
1213 memoryReviewClient,
1314 mirrorsClient,
1415 packagesClient,
8687 export const chat = chatClient(CHAT);
8788 /** The workspace's own agents: who they are, their limits and their desks. */
8889 export const workspaceAgents = workspaceAgentsClient(AGENTS);
89−/** Docs: spaces, pages, history, suggestions and comments. */
90+/** The docs service's spaces (Artifacts' spaces) and projects' docs. Its old pages are no longer read. */
9091 export const docs = docsClient(DOCS);
92+/** Artifacts (folios): docs, and later slides, designs and dashboards, kept by the docs service. */
93+export const folios = foliosClient(DOCS);
+3−3
105105 { scene: "chat", label: "Chat", start: BEAT.chat, soon: false },
106106 // Sessions, colleagues brought in, spend against a cap: built.
107107 { scene: "agents", label: "Agents", start: BEAT.agents, soon: false },
108− // Docs is built; an agent keeping a page current after a merge on its
109− // own is not yet, and the line under the frame says so.
110− { scene: "docs", label: "Docs", start: BEAT.docs, soon: false },
108+ // Docs in Artifacts are built; an agent keeping one current after a merge
109+ // on its own is not yet, and the line under the frame says so.
110+ { scene: "docs", label: "Artifacts", start: BEAT.docs, soon: false },
111111 ];
112112
113113 /** Which pill a time falls under. */
+4−1
107107 assert.equal(modeOf("/acme/-/agents/ship/profile", "acme"), "agents");
108108 assert.equal(modeOf("/acme/-/context", "acme"), "agents");
109109 assert.equal(modeOf("/acme/-/memory", "acme"), "agents");
110− assert.equal(modeOf("/acme/-/docs", "acme"), "docs");
110+ assert.equal(modeOf("/acme/-/artifacts", "acme"), "artifacts");
111+ assert.equal(modeOf("/acme/-/artifacts/spaces/general", "acme"), "artifacts");
112+ // Docs mode is gone: its old addresses are nobody's mode, so they fall to Code's not-found page.
113+ assert.equal(modeOf("/acme/-/docs", "acme"), "code");
111114 assert.equal(modeOf("/acme/-/overview", "acme"), "code");
112115 assert.equal(modeOf("/acme/-/projects", "acme"), "code");
113116 assert.equal(modeOf("/acme/-/security", "acme"), "code");
+6−6
168168 }
169169
170170 /**
171− * The rail's modes (docs/WORKSPACE.md, "Shell"). Home, Code, Chat, Docs
172− * and Agents are where work happens; the Inbox spans them; Workspace is the
171+ * The rail's modes (docs/WORKSPACE.md, "Shell"). Home, Code, Chat,
172+ * Artifacts and Agents are where work happens; the Inbox spans them; Workspace is the
173173 * workspace itself: its people, money, policies and settings. `account` is
174174 * your own settings, under the avatar. `site` is g1t's own public pages
175175 * (a profile, Explore, Search): no workspace's, so no mode is lit and no
176176 * mode's sidebar sits beside them. Each mode has a sidebar of its own, or
177177 * none, and which one is lit follows the address.
178178 */
179−export type ModeKey = "home" | "chat" | "docs" | "agents" | "code" | "inbox" | "workspace" | "account" | "site";
179+export type ModeKey = "home" | "chat" | "artifacts" | "agents" | "code" | "inbox" | "workspace" | "account" | "site";
180180
181181 /** Workspace pages under `-/`, by the mode they belong to. Anything else of the workspace's is Code's. */
182182 const PAGE_MODES: Record<string, ModeKey> = {
183183 home: "home",
184184 chat: "chat",
185− docs: "docs",
185+ artifacts: "artifacts",
186186 agents: "agents",
187187 context: "agents",
188188 memory: "agents",
243243 return "/inbox";
244244 case "chat":
245245 return `/${slug}/-/chat`;
246− case "docs":
247− return `/${slug}/-/docs`;
246+ case "artifacts":
247+ return `/${slug}/-/artifacts`;
248248 case "agents":
249249 return `/${slug}/-/agents`;
250250 case "code":
+24−18
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { cleanTitle, threadLink, writableSpaces, writeUpAgents, writeUpMessage } from "./write-up.ts";
4+import { cleanTitle, defaultWhere, threadLink, whereWords, writableSpaces, writeUpAgents, writeUpMessage } from "./write-up.ts";
55
66 test("a thread's link is its conversation with ?thread=", () => {
77 assert.equal(threadLink("https://g1t.sh/", "/acme/-/chat/launch", "msg_1"), "https://g1t.sh/acme/-/chat/launch?thread=msg_1");
88 assert.equal(threadLink("https://g1t.sh", "/acme/-/chat/dm/chn_1", "a b"), "https://g1t.sh/acme/-/chat/dm/chn_1?thread=a%20b");
99 });
1010
11−test("only spaces the person can write in, not archived, the default first", () => {
12− const spaces = writableSpaces([
13− { id: "s1", name: "Design", viewer_role: "edit", archived_at: null },
14− { id: "s2", name: "Board", viewer_role: "view", archived_at: null },
15− { id: "s3", name: "Old", viewer_role: "manage", archived_at: "2026-01-01T00:00:00Z" },
16− { id: "s4", name: "General", viewer_role: "manage", archived_at: null, is_default: true },
17− { id: "s5", name: "Notes", viewer_role: "comment", archived_at: null },
18− ]);
11+const spaces = writableSpaces([
12+ { id: "s1", name: "Design", viewer_role: "edit", archived_at: null },
13+ { id: "s2", name: "Board", viewer_role: "view", archived_at: null },
14+ { id: "s3", name: "Old", viewer_role: "manage", archived_at: "2026-01-01T00:00:00Z" },
15+ { id: "s4", name: "General", viewer_role: "manage", archived_at: null, is_default: true },
16+ { id: "s5", name: "Notes", viewer_role: "comment", archived_at: null },
17+]);
18+
19+test("only spaces the person can add to, not archived, General first", () => {
1920 assert.deepEqual(spaces, [
20− { id: "s4", name: "General" },
21− { id: "s1", name: "Design" },
21+ { id: "s4", name: "General", is_default: true },
22+ { id: "s1", name: "Design", is_default: false },
2223 ]);
2324 });
2425
26+test("it goes to the conversation in a DM or private channel; to General, else Private, in a public one", () => {
27+ assert.deepEqual(defaultWhere(true, spaces), { kind: "conversation" });
28+ assert.deepEqual(defaultWhere(false, spaces), { kind: "space", id: "s4", name: "General" });
29+ assert.deepEqual(defaultWhere(false, spaces.filter((s) => !s.is_default)), { kind: "private" });
30+});
31+
2532 test("@g1t is always offered first, then the conversation's agents, once each", () => {
2633 const agents = writeUpAgents([
2734 { kind: "user", name: "ana", display_name: "Ana" },
4350 assert.equal(cleanTitle("x".repeat(200)).length, 120);
4451 });
4552
46−test("the ask names the agent, the space, the title if any, and the thread to cite", () => {
53+test("the ask names the agent, the kind, where it goes, the title if any, and the thread to cite", () => {
4754 const link = "https://g1t.sh/acme/-/chat/launch?thread=msg_1";
4855 assert.equal(
49− writeUpMessage({ agent: "g1t", space: "Engineering", title: "Launch plan", link }),
50− `@g1t write this thread up as a Docs page in Engineering titled "Launch plan": what was decided, why, and what's next. Link this thread as the source: ${link}`,
56+ writeUpMessage({ agent: "g1t", where: { kind: "space", id: "s1", name: "Engineering" }, title: "Launch plan", link }),
57+ `@g1t write this thread up as an artifact (a doc) in the Engineering space titled "Launch plan". Link this thread as the source: ${link}`,
5158 );
52− assert.equal(
53− writeUpMessage({ agent: "@Scribe", space: " Product notes ", title: " ", link }),
54− `@scribe write this thread up as a Docs page in Product notes: what was decided, why, and what's next. Link this thread as the source: ${link}`,
55− );
59+ assert.equal(writeUpMessage({ agent: "@Scribe", where: { kind: "private" }, title: " ", link }), `@scribe write this thread up as an artifact (a doc) privately (just for me). Link this thread as the source: ${link}`);
60+ assert.equal(whereWords({ kind: "conversation" }), "shared with this conversation");
61+ assert.equal(whereWords({ kind: "space", id: "s", name: " Product notes " }), "in the Product notes space");
5662 });
+35−13
11 /**
2− * "Write this up in Docs" from chat (docs/WORKSPACE.md, "Docs"): the
3− * spaces a person can pick, the agents that can write it, and the message
4− * that asks one to. Nothing is written behind anyone's back: the person
5− * posts the ask in the thread, as themselves, and the agent answers it the
6− * usual way (a session if it needs one, then `create_page`). Pure, so it
7− * is tested on its own; components/chat/write-up.tsx draws the dialog.
2+ * "Write this up as an artifact" from chat (docs/ARTIFACTS_MODE.md section
3+ * 4.4): where it goes (a space, Private, or shared with the conversation),
4+ * the agents that can write it, and the message that asks one to. Nothing
5+ * is written behind anyone's back: the person posts the ask in the thread,
6+ * as themselves, and the agent answers it the usual way (a session if it
7+ * needs one, then `create_artifact`). The kind is a doc until slides and
8+ * the other kinds ship. Pure, so it is tested on its own;
9+ * components/chat/write-up.tsx draws the dialog.
810 */
911 import type { DocRole, MemberProfile } from "@g1t/contracts";
1012
1921 return `${origin.replace(/\/+$/, "")}${conversationPath}?thread=${encodeURIComponent(rootId)}`;
2022 }
2123
22−export type WritableSpace = { id: string; name: string };
24+export type WritableSpace = { id: string; name: string; is_default: boolean };
2325
24−/** The Docs spaces a person can write in, not archived, the workspace's default first. */
26+/** The spaces a person can add to, not archived, the workspace's General space first. */
2527 export function writableSpaces(
2628 spaces: readonly { id: string; name: string; viewer_role: DocRole; archived_at: string | null; is_default?: boolean }[],
2729 ): WritableSpace[] {
2830 return spaces
2931 .filter((s) => (s.viewer_role === "edit" || s.viewer_role === "manage") && !s.archived_at)
3032 .sort((a, b) => Number(!!b.is_default) - Number(!!a.is_default))
31− .map((s) => ({ id: s.id, name: s.name }));
33+ .map((s) => ({ id: s.id, name: s.name, is_default: !!s.is_default }));
34+}
35+
36+/** Where the artifact goes: a space, the asker's Private, or Private shared with the conversation's people. */
37+export type WriteUpWhere = { kind: "space"; id: string; name: string } | { kind: "private" } | { kind: "conversation" };
38+
39+/**
40+ * Where it goes unless the person says otherwise: shared with the
41+ * conversation in a DM or a private channel; in a public channel, the
42+ * General space when they can add to it, else their Private.
43+ */
44+export function defaultWhere(shared: boolean, spaces: readonly WritableSpace[]): WriteUpWhere {
45+ if (shared) return { kind: "conversation" };
46+ const general = spaces.find((s) => s.is_default);
47+ return general ? { kind: "space", id: general.id, name: general.name } : { kind: "private" };
3248 }
3349
3450 export type WriteUpAgent = { handle: string; name: string };
5571 return text.length > TITLE_MAX ? text.slice(0, TITLE_MAX).trimEnd() : text;
5672 }
5773
58−/** What the person posts in the thread to ask for the page. */
59−export function writeUpMessage(input: { agent: string; space: string; title?: string | null; link: string }): string {
74+/** Where, in the words the agent maps to `create_artifact`'s `where`. */
75+export function whereWords(where: WriteUpWhere): string {
76+ if (where.kind === "space") return `in the ${where.name.replace(/\s+/g, " ").trim()} space`;
77+ if (where.kind === "private") return "privately (just for me)";
78+ return "shared with this conversation";
79+}
80+
81+/** What the person posts in the thread to ask for the artifact. */
82+export function writeUpMessage(input: { agent: string; where: WriteUpWhere; title?: string | null; link: string }): string {
6083 const title = cleanTitle(input.title);
6184 const handle = input.agent.replace(/^@/, "").toLowerCase() || ORCHESTRATOR;
62− const space = input.space.replace(/\s+/g, " ").trim();
63− return `@${handle} write this thread up as a Docs page in ${space}${title ? ` titled "${title}"` : ""}: what was decided, why, and what's next. Link this thread as the source: ${input.link}`;
85+ return `@${handle} write this thread up as an artifact (a doc) ${whereWords(input.where)}${title ? ` titled "${title}"` : ""}. Link this thread as the source: ${input.link}`;
6486 }
+24−22
163163 route("dm/:id", "routes/workspace/chat/channel.tsx", { id: "routes/workspace/chat/dm" }),
164164 route(":channel", "routes/workspace/chat/channel.tsx"),
165165 ]),
166− // Docs mode (docs/WORKSPACE.md, "Docs"): what its pages call as they
167− // run (a page's live socket, JSON, comments, uploads, export), then
168− // Home, search, templates, the trash, a new space, and each space and
169− // page by its address. A page's address ends in its id, so renaming it
170− // keeps links working.
171− route("-/docs/live", "routes/workspace/docs/live.ts"),
172− route("-/docs/api", "routes/workspace/docs/api.ts"),
173− route("-/docs/threads/:page/*", "routes/workspace/docs/threads.ts"),
174− route("-/docs/upload", "routes/workspace/docs/upload.ts"),
175− route("-/docs/export", "routes/workspace/docs/export.ts"),
176− route("-/docs", "routes/workspace/docs/layout.tsx", [
177− index("routes/workspace/docs/home.tsx"),
178− route("search", "routes/workspace/docs/search.tsx"),
179− route("templates", "routes/workspace/docs/templates.tsx"),
180− route("trash", "routes/workspace/docs/trash.tsx"),
181− route("new", "routes/workspace/docs/new-space.tsx"),
182− // Pages possibly out of date, and a project's docs folder, read-only.
183− route("stale", "routes/workspace/docs/stale.tsx"),
184− route("repo/:repoOwner/:repoName/*", "routes/workspace/docs/repo-file.tsx"),
185− route(":space", "routes/workspace/docs/space.tsx"),
186− route(":space/settings", "routes/workspace/docs/space-settings.tsx"),
187− route(":space/:page", "routes/workspace/docs/page.tsx"),
166+ // Artifacts mode (docs/ARTIFACTS_MODE.md; code says "folio"): what its
167+ // pages call as they run (an artifact's live socket, JSON, comments,
168+ // uploads, export), then Home, making one, templates, the trash, the
169+ // spaces, and each artifact by its address. Addresses are flat and end
170+ // in the id, so renaming or moving one keeps links working.
171+ route("-/artifacts/live", "routes/workspace/folios/live.ts"),
172+ route("-/artifacts/api", "routes/workspace/folios/api.ts"),
173+ route("-/artifacts/threads/:folio/*", "routes/workspace/folios/threads.ts"),
174+ route("-/artifacts/upload", "routes/workspace/folios/upload.ts"),
175+ route("-/artifacts/export", "routes/workspace/folios/export.ts"),
176+ route("-/artifacts", "routes/workspace/folios/layout.tsx", [
177+ index("routes/workspace/folios/home.tsx"),
178+ route("new/:kind", "routes/workspace/folios/new.tsx"),
179+ route("templates", "routes/workspace/folios/templates.tsx"),
180+ route("trash", "routes/workspace/folios/trash.tsx"),
181+ // Artifacts possibly out of date, and a project's docs folder, read-only.
182+ route("stale", "routes/workspace/folios/stale.tsx"),
183+ route("spaces", "routes/workspace/folios/spaces.tsx"),
184+ route("spaces/new", "routes/workspace/folios/space-new.tsx"),
185+ route("spaces/:space", "routes/workspace/folios/space.tsx"),
186+ route("spaces/:space/settings", "routes/workspace/folios/space-settings.tsx"),
187+ route("repo/:repoOwner/:repoName/*", "routes/workspace/folios/repo-file.tsx"),
188+ route(":folio", "routes/workspace/folios/folio.tsx"),
189+ route(":folio/history", "routes/workspace/folios/history.tsx"),
188190 ]),
189191 // Home for a member without Code, and what Code's pages say to them
190192 // (docs/WORKSPACE.md, "Members without Code").
+0−243
1−import { data } from "react-router";
2−
3−import type { DocEditTarget, DocMove, DocPageChange, DocRole, DocSpaceChange, NewDocPage, NewDocSpace, Result, User } 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− if (q.has("stale")) return docs.stalePages(slug, viewer, { repo: q.get("stale") || null });
34− // Repositories to cite, describe or show the docs of: the workspace's that the viewer can read.
35− if (q.has("repos")) return repositories(slug, viewer);
36− // A citation's commit: the default branch's head, once the path is there.
37− if (q.get("cite")) return cite(viewer, q.get("cite")!, q.get("path") ?? "");
38− // How member keys show (`user:<id>`, `agent:<id>`): comment authors, people on a page.
39− if (q.get("who")) return who(slug, viewer, q.get("who")!.split(",").slice(0, 100));
40− // A member's key (`user:<id>`) by username, for adding them to a space.
41− if (q.get("person")) {
42− const user = await identity.userByUsername(q.get("person")!.toLowerCase());
43− return user ? { ok: true, value: `user:${user.id}` } : { ok: false, error: { code: "not_found", message: "No such person." } };
44− }
45− return docs.sidebar(slug, viewer);
46− });
47− return Response.json(answer, { headers: { "cache-control": "no-store" } });
48−}
49−
50−type Sent = {
51− intent?: string;
52− page_id?: string;
53− space_id?: string;
54− suggestion_id?: string;
55− version_id?: string;
56− template_id?: string;
57− member?: string;
58− role?: DocRole | null;
59− decision?: "accept" | "reject";
60− on?: boolean;
61− page?: NewDocPage;
62− change?: DocPageChange;
63− move?: DocMove;
64− space?: NewDocSpace;
65− space_change?: DocSpaceChange;
66− name?: string;
67− description?: string | null;
68− target?: DocEditTarget;
69− repo?: string;
70− id?: string;
71−};
72−
73−export async function action({ params, context, request }: Route.ActionArgs) {
74− assertSameOrigin(request);
75− const viewer = requireUser(context, request);
76− if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
77− const slug = params.owner.toLowerCase();
78− const sent = (await request.json().catch(() => ({}))) as Sent;
79− const page = sent.page_id ?? "";
80− const answer = await safely(async (): Promise<Result<unknown>> => {
81− switch (sent.intent) {
82− case "create_page":
83− return docs.createPage(slug, viewer, sent.page ?? ({ space_id: sent.space_id ?? "" } as NewDocPage));
84− case "update_page":
85− return docs.updatePage(slug, page, viewer, sent.change ?? {});
86− case "move_page":
87− return docs.movePage(slug, page, viewer, sent.move ?? { parent_id: null });
88− case "duplicate_page":
89− return docs.duplicatePage(slug, page, viewer);
90− case "archive_page":
91− return docs.archivePage(slug, page, viewer);
92− case "restore_page":
93− return docs.restorePage(slug, page, viewer);
94− case "delete_page":
95− return docs.deletePage(slug, page, viewer);
96− case "favorite":
97− return docs.favorite(slug, page, viewer, !!sent.on);
98− case "restore_version":
99− return docs.restoreVersion(slug, page, sent.version_id ?? "", viewer);
100− case "decide":
101− return docs.decideSuggestion(slug, sent.suggestion_id ?? "", viewer, sent.decision === "accept" ? "accept" : "reject");
102− case "accept_all":
103− return docs.acceptAll(slug, page, viewer);
104− case "save_template":
105− return docs.saveTemplate(slug, viewer, { page_id: page, name: sent.name ?? "", description: sent.description ?? null });
106− case "delete_template":
107− return docs.deleteTemplate(slug, sent.template_id ?? "", viewer);
108− case "create_space":
109− return docs.createSpace(slug, viewer, sent.space ?? ({ name: "", kind: "workspace" } as NewDocSpace));
110− case "update_space":
111− return docs.updateSpace(slug, sent.space_id ?? "", viewer, sent.space_change ?? {});
112− case "set_member":
113− return docs.setSpaceMember(slug, sent.space_id ?? "", viewer, sent.member ?? "", sent.role ?? null);
114− case "mark_current":
115− return docs.markCurrent(slug, page, viewer);
116− case "add_repo_space":
117− return docs.addRepoSpace(slug, viewer, sent.repo ?? "");
118− case "remove_repo_space":
119− return docs.removeRepoSpace(slug, viewer, sent.id ?? "");
120− default:
121− return { ok: false, error: { code: "invalid", message: "Unknown request." } };
122− }
123− });
124− return Response.json(answer, { headers: { "cache-control": "no-store" } });
125−}
126−
127−async function safely<T>(run: () => Promise<Result<T>>): Promise<Result<T>> {
128− try {
129− return await run();
130− } catch (error) {
131− console.error("docs api:", error);
132− return { ok: false, error: { code: "conflict", message: "Docs didn't answer. Try again in a moment." } };
133− }
134−}
135−
136−/**
137− * The card for a g1t address embedded in a page, as this reader may see
138− * it: an issue or pull request with its state, a channel, a project, a
139− * page. Anything they can't read is not found, exactly as one that does
140− * not exist.
141− */
142−async function embed(slug: string, address: string, viewer: Parameters<typeof docs.page>[2], origin: string): Promise<Result<unknown>> {
143− let path: string;
144− try {
145− const url = new URL(address, origin);
146− if (url.origin !== origin) return { ok: true, value: { kind: "link", title: url.hostname + url.pathname, subtitle: url.hostname, state: null, href: url.toString() } };
147− path = url.pathname;
148− } catch {
149− return { ok: false, error: { code: "invalid", message: "That isn't an address." } };
150− }
151− const parts = path.split("/").filter(Boolean).map(decodeURIComponent);
152− const missing = { ok: false as const, error: { code: "not_found" as const, message: "Not found." } };
153− // /<owner>/-/docs/<space>/<page>
154− if (parts[1] === "-" && parts[2] === "docs" && parts[4]) {
155− if (parts[0]!.toLowerCase() !== slug) return missing;
156− const id = pageIdOf(parts[4]);
157− if (!id) return missing;
158− const found = await docs.page(slug, id, viewer);
159− if (!found.ok) return missing;
160− 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 } };
161− }
162− // /<owner>/-/chat/<channel>
163− if (parts[1] === "-" && parts[2] === "chat" && parts[3]) {
164− return { ok: true, value: { kind: "channel", title: parts[3] === "dm" ? "A direct message" : `#${parts[3]}`, subtitle: "Chat", state: null, href: path } };
165− }
166− if (parts.length < 2 || parts[1] === "-") return missing;
167− const repo = { namespace: parts[0]!, name: parts[1]! };
168− // /<owner>/<repo>/issues/<n> and /pull/<n>
169− const number = Number(parts[3]);
170− if ((parts[2] === "issues" || parts[2] === "pull" || parts[2] === "pulls") && Number.isInteger(number) && number > 0) {
171− if (parts[2] === "issues") {
172− const found = await work.getIssue(repo, number, viewer);
173− if (!found.ok) return missing;
174− return { ok: true, value: { kind: "issue", title: `${found.value.issue.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state: found.value.issue.state, href: path } };
175− }
176− const found = await work.getPull(repo, number, viewer);
177− if (!found.ok) return missing;
178− const pull = found.value.pull as { title: string; status: string; mergedAt?: string | null };
179− const state = pull.mergedAt ? "merged" : String(pull.status ?? "open");
180− return { ok: true, value: { kind: "pull", title: `${pull.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state, href: path } };
181− }
182− const found = await repos.get(repo, viewer);
183− if (!found.ok) return missing;
184− 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}` } };
185−}
186−
187−/** The workspace's repositories the viewer can read, newest first: `owner/name` and its default branch. */
188−async function repositories(slug: string, viewer: User): Promise<Result<{ repo: string; default_branch: string; private: boolean }[]>> {
189− const found = await repos.list(viewer, { namespace: slug });
190− return {
191− ok: true,
192− value: found
193− .filter((r) => !r.forkOf)
194− .slice(0, 300)
195− .map((r) => ({ repo: `${r.namespace}/${r.name}`.toLowerCase(), default_branch: r.defaultBranch, private: r.isPrivate })),
196− };
197−}
198−
199−/**
200− * A citation for `path` in `repo`, as the viewer can read it: pinned to
201− * the default branch's head commit, once the file or folder is there (a
202− * glob is taken as written).
203− */
204−async function cite(viewer: User, repo: string, rawPath: string): Promise<Result<{ repo: string; path: string; ref: string | null; kind: "file" | "folder" | "glob" }>> {
205− const [namespace, name, ...rest] = repo.trim().toLowerCase().split("/");
206− const missing = { ok: false as const, error: { code: "not_found" as const, message: "No such repository, or you can't read it." } };
207− if (!namespace || !name || rest.length) return missing;
208− const path = rawPath
209− .trim()
210− .replace(/\\/g, "/")
211− .split("/")
212− .filter((p) => p && p !== ".")
213− .join("/");
214− if (!path || path.split("/").includes("..")) return { ok: false, error: { code: "invalid", message: "Name a file, a folder or a pattern in the repository." } };
215− const where = { namespace, name };
216− const root = await repos.tree(where, viewer, null, "");
217− if (!root.ok) return missing;
218− const head = root.value.head?.hash ?? null;
219− if (/[*?]/.test(path)) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "glob" } };
220− if (!head) return { ok: false, error: { code: "not_found", message: "That repository has no commits yet." } };
221− const [blob, tree] = await Promise.all([repos.blob(where, viewer, head, path).catch(() => null), repos.tree(where, viewer, head, path).catch(() => null)]);
222− if (blob?.ok) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "file" } };
223− if (tree?.ok) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "folder" } };
224− return { ok: false, error: { code: "not_found", message: `There is no ${path} on ${root.value.repo.defaultBranch}.` } };
225−}
226−
227−/** Names and faces for member keys, as the workspace knows them. */
228−async function who(slug: string, viewer: Parameters<typeof docs.page>[2], keys: string[]): Promise<Result<unknown>> {
229− const userIds = keys.filter((k) => k.startsWith("user:")).map((k) => k.slice(5));
230− const agentIds = new Set(keys.filter((k) => k.startsWith("agent:")).map((k) => k.slice(6)));
231− 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)]);
232− const out = keys.map((key) => {
233− if (key.startsWith("agent:")) {
234− const agent = agentIds.has(key.slice(6)) ? agents.find((a) => a.id === key.slice(6)) : undefined;
235− 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 };
236− }
237− const username = names[key.slice(5)]?.toLowerCase();
238− const person = username ? people.find((p) => p.name === username) : undefined;
239− 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 };
240− });
241− return { ok: true, value: out };
242−}
243−
+0−45
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−}
+0−244
1−import type { DocTemplate, DocsHome } from "@g1t/contracts";
2−import { AlertTriangle, BookOpen, FilePlus2, FolderGit2, Plus, Search } from "lucide-react";
3−import { useState } from "react";
4−import { Form, Link, data, useNavigate, useRevalidator, useSubmit } from "react-router";
5−
6−import type { Route } from "./+types/home";
7−import { docsRequest, useDocsData } from "../../../components/docs/actions";
8−import { RepoDocsDialog } from "../../../components/docs/code";
9−import { PageCard, SectionTitle, SpaceCard, TemplateCard } from "../../../components/docs/parts";
10−import { DocsSidebar } from "../../../components/docs/sidebar";
11−import { ButtonLink, EmptyState, ErrorText } from "../../../components/ui";
12−import { SelectField } from "../../../components/ui/select";
13−import { canDo } from "../../../lib/docs";
14−import { page } from "../../../lib/meta";
15−import { docs } from "../../../lib/services.server";
16−import { requireUser, roleIn } from "../../../lib/session.server";
17−import { BUILTIN_TEMPLATE_IDS } from "../../../components/docs/templates";
18−
19−export function meta({ params, ...args }: Route.MetaArgs) {
20− return page(args, { title: `Docs · ${params.owner} · g1t` });
21−}
22−
23−export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ home: DocsHome | null; templates: DocTemplate[] }> {
24− const viewer = requireUser(context, request);
25− if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
26− const slug = params.owner.toLowerCase();
27− const project = new URL(request.url).searchParams.get("project");
28− const [home, templates] = await Promise.all([
29− docs
30− .home(slug, viewer, { project })
31− .then((r) => (r.ok ? r.value : null))
32− .catch(() => null),
33− docs
34− .templates(slug, viewer)
35− .then((r) => (r.ok ? r.value : []))
36− .catch(() => []),
37− ]);
38− return { home, templates };
39−}
40−
41−/** Docs' home: what changed lately, your pages, the spaces, templates to start from; filtered by project. */
42−export default function DocsHomePage({ loaderData, params }: Route.ComponentProps) {
43− const slug = params.owner.toLowerCase();
44− const { home, templates } = loaderData;
45− const layout = useDocsData();
46− const navigate = useNavigate();
47− const submit = useSubmit();
48− const [error, setError] = useState<string | null>(null);
49− const [addingRepo, setAddingRepo] = useState(false);
50− const { revalidate } = useRevalidator();
51− const general = layout?.sidebar?.spaces.find((s) => s.is_default) ?? layout?.sidebar?.spaces.find((s) => canDo(s.viewer_role, "edit"));
52− const spacesById = new Map((home?.spaces ?? []).map((s) => [s.id, s]));
53− // What a new page is being started from ("" for a blank one), while it is.
54− const [starting, setStarting] = useState<string | null>(null);
55− const create = async (template?: string) => {
56− if (!general || starting != null) return;
57− setStarting(template ?? "");
58− setError(null);
59− const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: general.id, template_id: template ?? null } });
60− if (made.ok) {
61− await navigate(made.value.path);
62− return;
63− }
64− setStarting(null);
65− setError(made.error.message);
66− };
67− if (!home) {
68− return (
69− <div className="mx-auto max-w-3xl">
70− <EmptyState title="Docs didn't answer">Try again in a moment.</EmptyState>
71− </div>
72− );
73− }
74− const featured = templates.filter((t) => BUILTIN_TEMPLATE_IDS.includes(t.id)).slice(0, 4);
75− return (
76− <div className="mx-auto max-w-5xl">
77− {/* On a phone, the spaces and pages first: the sidebar as the page. */}
78− <div className="-mx-4 -mt-6 mb-6 border-b border-line md:hidden">
79− <div className="h-[60dvh]">
80− <DocsSidebar slug={slug} />
81− </div>
82− </div>
83− <div className="flex flex-wrap items-end justify-between gap-4">
84− <div>
85− <div className="flex items-center gap-2 text-accent">
86− <BookOpen size={18} />
87− <span className="text-xs font-medium tracking-wide uppercase">Docs</span>
88− </div>
89− <h1 className="mt-2 text-2xl font-semibold tracking-tight">What your workspace knows</h1>
90− <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>
91− </div>
92− <div className="flex items-center gap-2">
93− <button type="button" onClick={() => setAddingRepo(true)} className="inline-flex h-9 items-center gap-1.5 rounded-md border border-line px-3 text-sm text-fg/80 transition-colors hover:border-line-strong hover:bg-surface hover:text-fg">
94− <FolderGit2 size={15} /> Show a project&apos;s docs
95− </button>
96− <ButtonLink to={`/${slug}/-/docs/new`} variant="quiet">
97− <Plus size={15} /> New space
98− </ButtonLink>
99− {general && (
100− <button
101− type="button"
102− disabled={starting != null}
103− aria-busy={starting === ""}
104− onClick={() => create()}
105− 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 disabled:opacity-60"
106− >
107− <FilePlus2 size={15} /> {starting === "" ? "Starting…" : "New page"}
108− </button>
109− )}
110− </div>
111− </div>
112− {error && (
113− <div className="mt-3">
114− <ErrorText>{error}</ErrorText>
115− </div>
116− )}
117−
118− <div className="mt-6 flex flex-wrap items-center gap-2">
119− <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">
120− <Search size={15} className="text-faint" aria-hidden="true" />
121− <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" />
122− {home.project && <input type="hidden" name="project" value={home.project} />}
123− </Form>
124− {home.projects.length > 0 && (
125− <Form method="get" id="docs-project">
126− <SelectField
127− name="project"
128− aria-label="Filter by project"
129− value={home.project ?? ""}
130− afterChange={() => submit(document.getElementById("docs-project") as HTMLFormElement)}
131− options={[{ value: "", label: "All projects" }, ...home.projects.map((p) => ({ value: p, label: p }))]}
132− className="h-9 min-w-48"
133− />
134− </Form>
135− )}
136− </div>
137−
138− {home.stale.length > 0 && (
139− <section className="mt-8">
140− <SectionTitle
141− action={
142− <Link to={`/${slug}/-/docs/stale`} className="text-xs text-muted hover:text-fg">
143− All of them
144− </Link>
145− }
146− >
147− <span className="inline-flex items-center gap-1.5">
148− <AlertTriangle size={13} className="text-warn" /> Possibly out of date
149− </span>
150− </SectionTitle>
151− <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
152− {home.stale.map((p) => (
153− <PageCard key={p.id} page={p} space={spacesById.get(p.space_id)} />
154− ))}
155− </div>
156− </section>
157− )}
158−
159− <section className="mt-8">
160− <SectionTitle>{home.project ? `Recently edited · ${home.project}` : "Recently edited"}</SectionTitle>
161− {home.recent.length ? (
162− <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
163− {home.recent.map((p) => (
164− <PageCard key={p.id} page={p} space={spacesById.get(p.space_id)} />
165− ))}
166− </div>
167− ) : (
168− <EmptyState title={home.project ? "No pages about this project yet" : "No pages yet"}>
169− {home.project ? "Link a space or a page to this project from its settings." : "Start one from a template below, or with New page."}
170− </EmptyState>
171− )}
172− </section>
173−
174− {home.mine.length > 0 && (
175− <section className="mt-10">
176− <SectionTitle>Your pages</SectionTitle>
177− <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
178− {home.mine.map((p) => (
179− <li key={p.id}>
180− <Link to={p.path} prefetch="intent" className="flex items-center gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-raised">
181− <span className="w-5 text-center">{p.icon ?? "📄"}</span>
182− <span className="min-w-0 grow truncate">{p.title || "Untitled"}</span>
183− <span className="hidden text-xs text-faint sm:inline">{spacesById.get(p.space_id)?.name}</span>
184− </Link>
185− </li>
186− ))}
187− </ul>
188− </section>
189− )}
190−
191− <section className="mt-10">
192− <SectionTitle
193− action={
194− <Link to={`/${slug}/-/docs/new`} className="text-xs text-muted hover:text-fg">
195− New space
196− </Link>
197− }
198− >
199− Spaces
200− </SectionTitle>
201− <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
202− {home.spaces.map((s) => (
203− <SpaceCard key={s.id} slug={slug} space={s} />
204− ))}
205− </div>
206− </section>
207−
208− <RepoDocsDialog slug={slug} open={addingRepo} onOpenChange={setAddingRepo} shown={(layout?.sidebar?.repos ?? []).map((r) => r.repo.toLowerCase())} onAdded={() => void revalidate()} />
209−
210− {general && featured.length > 0 && (
211− <section className="mt-10 mb-6">
212− <SectionTitle
213− action={
214− <Link to={`/${slug}/-/docs/templates`} className="text-xs text-muted hover:text-fg">
215− All templates
216− </Link>
217− }
218− >
219− Start from a template
220− </SectionTitle>
221− <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
222− {featured.map((t) => (
223− <TemplateCard
224− key={t.id}
225− template={t}
226− action={
227− <button
228− type="button"
229− disabled={starting != null}
230− aria-busy={starting === t.id}
231− onClick={() => create(t.id)}
232− className="text-xs font-medium text-accent hover:underline disabled:text-faint disabled:no-underline"
233− >
234− {starting === t.id ? "Starting…" : `Use in ${general.name}`}
235− </button>
236− }
237− />
238− ))}
239− </div>
240− </section>
241− )}
242− </div>
243− );
244−}
+0−68
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−}
+0−36
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−}
+0−43
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−}
+0−571
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 { Describes, StaleBanner } from "../../../components/docs/code";
29−import { CoverPicker, Discussion, HistoryDialog, IconPicker, MoveDialog, SuggestionCard, TemplateDialog, type PageThread } from "../../../components/docs/page-parts";
30−import { Crumbs, Face, Faces } from "../../../components/docs/parts";
31−import type { LiveStatus } from "../../../components/docs/provider";
32−import { PageIcon } from "../../../components/docs/sidebar";
33−import { type Who, whoAre } from "../../../components/docs/who";
34−import { Markdown } from "../../../components/markdown";
35−import { ErrorText, TimeAgo } from "../../../components/ui";
36−import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../../../components/ui/dropdown-menu";
37−import { Hint } from "../../../components/ui/hint";
38−import { Popover, PopoverContent, PopoverTrigger } from "../../../components/ui/popover";
39−import { usercontentFrom } from "../../../lib/addresses";
40−import { canDo, coverStyle, pageIdOf, pagePath, readingTime } from "../../../lib/docs";
41−import { page as pageMeta } from "../../../lib/meta";
42−import { docs } from "../../../lib/services.server";
43−import { requireUser, roleIn } from "../../../lib/session.server";
44−
45−/**
46− * The editor and everything it brings, loaded only in the browser, after
47− * the page shows. The server's bundle never includes it: the server
48− * renders the page's Markdown instead.
49− */
50−const DocEditor = lazy(
51− (): Promise<{ default: ComponentType<DocEditorProps> }> => (import.meta.env.SSR ? Promise.resolve({ default: () => null }) : import("../../../components/docs/editor")),
52−);
53−
54−export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
55− const p = loaded?.detail.page;
56− return pageMeta(args, { title: `${p ? `${p.icon ? `${p.icon} ` : ""}${p.title || "Untitled"}` : "Page"} · ${params.owner} · g1t`, description: p?.excerpt, type: "article" });
57−}
58−
59−export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ detail: DocPageDetail }> {
60− const viewer = requireUser(context, request);
61− if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
62− const id = pageIdOf(params.page);
63− if (!id) throw data(null, { status: 404 });
64− const found = await docs.page(params.owner.toLowerCase(), id, viewer);
65− if (!found.ok) throw data(null, { status: found.error.code === "not_found" ? 404 : 403 });
66− const detail = found.value;
67− // An old address (renamed page, moved space) goes to the current one.
68− const url = new URL(request.url);
69− if (url.pathname !== detail.page.path) throw redirect(`${detail.page.path}${url.search}`);
70− return { detail };
71−}
72−
73−function useHydrated() {
74− const [hydrated, setHydrated] = useState(false);
75− useEffect(() => setHydrated(true), []);
76− return hydrated;
77−}
78−
79−/** One page: its header, the live editor (or its Markdown until the editor loads), suggestions, comments, links. */
80−export default function DocPageView({ loaderData, params }: Route.ComponentProps) {
81− const slug = params.owner.toLowerCase();
82− const { detail } = loaderData;
83− const { page, space } = detail;
84− const layout = useDocsData();
85− const root = useRouteLoaderData("root");
86− const usercontent = usercontentFrom(root);
87− const navigate = useNavigate();
88− const revalidator = useRevalidator();
89− const hydrated = useHydrated();
90− const { send, error, setError } = useDocsAction(slug);
91−
92− const [role, setRole] = useState(detail.role);
93− useEffect(() => setRole(detail.role), [detail.role, page.id]);
94− const editable = canDo(role, "edit");
95−
96− // The header follows the page, and anyone renaming it live.
97− const [live, setLive] = useState<DocPage>(page);
98− useEffect(() => setLive(page), [page]);
99− const [title, setTitle] = useState(page.title);
100− const titleFocused = useRef(false);
101− useEffect(() => {
102− if (!titleFocused.current) setTitle(live.title);
103− }, [live.title]);
104− const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
105− const saveTitle = (next: string) => {
106− setTitle(next);
107− if (saveTimer.current) clearTimeout(saveTimer.current);
108− saveTimer.current = setTimeout(() => void send("update_page", { page_id: page.id, change: { title: next } }), 700);
109− };
110−
111− const [suggestions, setSuggestions] = useState<DocSuggestion[]>(detail.suggestions);
112− useEffect(() => setSuggestions(detail.suggestions), [detail.suggestions]);
113− const [presence, setPresence] = useState<Presence[]>([]);
114− const [status, setStatus] = useState<LiveStatus>("connecting");
115− const [archived, setArchived] = useState(!!page.archived_at);
116− useEffect(() => setArchived(!!page.archived_at), [page.archived_at]);
117− const [showComments, setShowComments] = useState(false);
118− const [history, setHistory] = useState(false);
119− const [moving, setMoving] = useState(false);
120− const [templating, setTemplating] = useState(false);
121− const [favorite, setFavorite] = useState(detail.favorite);
122− useEffect(() => setFavorite(detail.favorite), [detail.favorite]);
123− const [copied, setCopied] = useState<string | null>(null);
124− const [threads, setThreads] = useState<PageThread[]>([]);
125− const [names, setNames] = useState<Map<string, Who>>(new Map());
126− useEffect(() => {
127− const keys = threads.flatMap((t) => t.comments.map((c) => c.author));
128− if (keys.some((k) => !names.has(k))) void whoAre(slug, keys).then((found) => setNames((was) => new Map([...was, ...found])));
129− }, [threads, slug, names]);
130−
131− const onEvent = useCallback(
132− (event: DocsLiveEvent) => {
133− if (event.type === "page.updated") setLive(event.page);
134− else if (event.type === "page.archived") setArchived(true);
135− else if (event.type === "access") {
136− if (event.role) setRole(event.role);
137− else navigate(`/${slug}/-/docs`);
138− } else if (event.type === "suggestion.created") setSuggestions((was) => [...was.filter((s) => s.id !== event.suggestion.id), event.suggestion]);
139− 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)));
140− else if (event.type === "version.created" || event.type === "page.staleness") void revalidator.revalidate();
141− },
142− [navigate, slug, revalidator],
143− );
144−
145− const decide = async (id: string, decision: "accept" | "reject") => {
146− setSuggestions((was) => was.filter((s) => s.id !== id));
147− const done = await send("decide", { suggestion_id: id, decision }, { reload: false });
148− if (!done.ok) void revalidator.revalidate();
149− };
150−
151− const people = useCallback(
152− (key: string): Pick<MemberProfile, "kind" | "id" | "name" | "display_name" | "avatar" | "avatar_seed"> => {
153− const at = key.indexOf(":");
154− const kind = key.slice(0, at) === "agent" ? "agent" : "user";
155− const id = key.slice(at + 1);
156− 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 };
157− const found = names.get(key);
158− 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 };
159− },
160− [layout, names],
161− );
162−
163− const threadRequest = async (path: string, method: string, body: unknown) => {
164− try {
165− const r = await fetch(`/${slug}/-/docs/threads/${page.id}${path}`, { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
166− if (!r.ok) {
167− const result = (await r.json().catch(() => null)) as { error?: { message?: string } } | null;
168− setError(result?.error?.message ?? "That comment didn't go through.");
169− return false;
170− }
171− return true;
172− } catch {
173− setError("That comment didn't go through.");
174− return false;
175− }
176− };
177− /** A comment's text as the editor's comments hold it: blocks, with @mentions as mentions. */
178− const commentBody = (text: string) => {
179− const parts: unknown[] = [];
180− for (const piece of text.split(/(@[a-z0-9][a-z0-9-]*)/i)) {
181− const who = piece.startsWith("@") ? layout?.mentionables.find((m) => m.name.toLowerCase() === piece.slice(1).toLowerCase()) : null;
182− if (who) parts.push({ type: "mention", props: { kind: who.kind, id: who.id, name: who.name, href: "" } });
183− else if (piece) parts.push({ type: "text", text: piece, styles: {} });
184− }
185− return [{ type: "paragraph", content: parts, children: [] }];
186− };
187−
188− const copy = async (what: "link" | "markdown") => {
189− try {
190− if (what === "link") await navigator.clipboard.writeText(`${window.location.origin}${pagePath(slug, space.slug, live.title, page.id)}`);
191− else {
192− const r = await fetch(`/${slug}/-/docs/export?page=${encodeURIComponent(page.id)}`);
193− await navigator.clipboard.writeText(await r.text());
194− }
195− setCopied(what);
196− setTimeout(() => setCopied(null), 1600);
197− } catch {
198− setError("Your browser didn't allow copying.");
199− }
200− };
201−
202− const cover = coverStyle(live.cover);
203− const reading = useMemo(() => readingTime(detail.markdown), [detail.markdown]);
204− const others = presence.filter((p) => !p.me);
205− const crumbs = [
206− { label: "Docs", to: `/${slug}/-/docs` },
207− { label: space.name, to: `/${slug}/-/docs/${space.slug}` },
208− ...detail.breadcrumbs.map((b) => ({ label: `${b.icon ? `${b.icon} ` : ""}${b.title || "Untitled"}`, to: b.path })),
209− ];
210−
211− return (
212− <div className="-mt-6">
213− {cover && (
214− <div className="group relative -mx-4 h-44 sm:-mx-6 lg:-mx-8" style={{ background: cover }}>
215− {editable && (
216− <CoverPicker onChange={(c) => send("update_page", { page_id: page.id, change: { cover: c } })}>
217− <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 pointer-coarse:opacity-100">
218− Change cover
219− </button>
220− </CoverPicker>
221− )}
222− </div>
223− )}
224− {/* The bar above the page: where it is, who is here, and what can be done. */}
225− <div className="sticky top-(--topbar-h) 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">
226− <div className="min-w-0 grow">
227− <Crumbs items={crumbs} />
228− </div>
229− {status === "offline" && (
230− <Hint label="Your changes are kept and sync when the connection is back.">
231− <span className="flex items-center gap-1 text-xs text-warn">
232− <WifiOff size={13} /> Offline
233− </span>
234− </Hint>
235− )}
236− {others.length > 0 && (
237− <span className="flex items-center -space-x-1.5" aria-label={`${others.length} others here`}>
238− {others.slice(0, 5).map((p) => (
239− <Hint key={p.key} label={`${p.name}${p.kind === "agent" ? " (agent)" : ""}`}>
240− <span className="rounded-full ring-2" style={{ ["--tw-ring-color" as string]: p.color }}>
241− <Face who={{ kind: p.kind, id: p.key.slice(p.key.indexOf(":") + 1), name: p.name, avatar: p.avatar, avatar_seed: null }} size={24} />
242− </span>
243− </Hint>
244− ))}
245− {others.length > 5 && <span className="pl-3 text-xs text-faint">+{others.length - 5}</span>}
246− </span>
247− )}
248− <Hint label={showComments ? "Hide comments" : "Comments"}>
249− <button type="button" onClick={() => setShowComments(!showComments)} aria-pressed={showComments} aria-label="Comments" className={`flex size-8 items-center justify-center rounded-md max-md:size-10 ${showComments ? "bg-raised text-fg" : "text-faint hover:bg-raised hover:text-fg"}`}>
250− <MessageSquare size={16} />
251− </button>
252− </Hint>
253− <Hint label={favorite ? "Remove from favorites" : "Add to favorites"}>
254− <button
255− type="button"
256− aria-label={favorite ? "Remove from favorites" : "Add to favorites"}
257− aria-pressed={favorite}
258− onClick={async () => {
259− setFavorite(!favorite);
260− await send("favorite", { page_id: page.id, on: !favorite });
261− }}
262− className="flex size-8 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg"
263− >
264− <Star size={16} className={favorite ? "fill-current text-warn" : ""} />
265− </button>
266− </Hint>
267− <Popover>
268− <PopoverTrigger asChild>
269− <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">
270− <Share2 size={13} /> Share
271− </button>
272− </PopoverTrigger>
273− <PopoverContent align="end" className="w-80 p-4 text-sm">
274− <p className="font-medium">Who can see this page</p>
275− <p className="mt-1 text-xs leading-relaxed text-muted">
276− Everyone with access to <span className="text-fg">{space.name}</span>:{" "}
277− {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()}.
278− </p>
279− <p className="mt-2 text-xs text-muted">Your access: {DOC_ROLE_LABELS[role].toLowerCase()}.</p>
280− <div className="mt-3 flex gap-2">
281− <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">
282− {copied === "link" ? <Check size={13} /> : <Link2 size={13} />} {copied === "link" ? "Copied" : "Copy link"}
283− </button>
284− {canDo(space.viewer_role, "manage") && (
285− <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">
286− Space access
287− </Link>
288− )}
289− </div>
290− </PopoverContent>
291− </Popover>
292− <DropdownMenu>
293− <DropdownMenuTrigger asChild>
294− <button type="button" aria-label="More" className="flex size-8 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg">
295− <Ellipsis size={16} />
296− </button>
297− </DropdownMenuTrigger>
298− <DropdownMenuContent align="end" className="w-56">
299− <DropdownMenuItem onSelect={() => setHistory(true)}>
300− <History size={14} /> History
301− </DropdownMenuItem>
302− <DropdownMenuItem onSelect={() => copy("markdown")}>
303− <ClipboardCopy size={14} /> Copy as Markdown
304− </DropdownMenuItem>
305− <DropdownMenuItem asChild>
306− <a href={`/${slug}/-/docs/export?page=${encodeURIComponent(page.id)}`}>
307− <Download size={14} /> Export Markdown
308− </a>
309− </DropdownMenuItem>
310− <DropdownMenuItem onSelect={() => copy("link")}>
311− <Link2 size={14} /> Copy link
312− </DropdownMenuItem>
313− {editable && (
314− <>
315− <DropdownMenuSeparator />
316− <DropdownMenuItem onSelect={() => setMoving(true)}>
317− <ArrowRightLeft size={14} /> Move
318− </DropdownMenuItem>
319− <DropdownMenuItem
320− onSelect={async () => {
321− const made = await docsRequest<DocPage>(slug, "duplicate_page", { page_id: page.id });
322− if (made.ok) navigate(made.value.path);
323− else setError(made.error.message);
324− }}
325− >
326− <Copy size={14} /> Duplicate
327− </DropdownMenuItem>
328− </>
329− )}
330− <DropdownMenuItem onSelect={() => setTemplating(true)}>
331− <LayoutTemplate size={14} /> Save as template
332− </DropdownMenuItem>
333− {editable && (
334− <>
335− <DropdownMenuSeparator />
336− <DropdownMenuItem
337− onSelect={async () => {
338− const done = await send("archive_page", { page_id: page.id });
339− if (done.ok) navigate(`/${slug}/-/docs/${space.slug}`);
340− }}
341− className="text-danger focus:text-danger"
342− >
343− <Trash2 size={14} /> Move to trash
344− </DropdownMenuItem>
345− </>
346− )}
347− </DropdownMenuContent>
348− </DropdownMenu>
349− </div>
350−
351− <article className={`mx-auto pt-10 ${showComments ? "max-w-6xl" : "max-w-3xl"}`}>
352− <div className={showComments ? "xl:max-w-3xl" : ""}>
353− {archived && (
354− <p className="mb-6 rounded-lg border border-warn/30 bg-warn/10 px-3 py-2 text-sm text-warn">
355− This page was moved to the trash.{" "}
356− <Link to={`/${slug}/-/docs/trash`} className="underline">
357− Open the trash
358− </Link>
359− </p>
360− )}
361− {/* Icon, cover, title. */}
362− <div className="group">
363− {live.icon && (
364− <IconPicker value={live.icon} onChange={(icon) => editable && send("update_page", { page_id: page.id, change: { icon } })}>
365− <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">
366− {live.icon}
367− </button>
368− </IconPicker>
369− )}
370− {editable && (
371− <div className="mb-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
372− {!live.icon && (
373− <IconPicker value={null} onChange={(icon) => send("update_page", { page_id: page.id, change: { icon } })}>
374− <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">
375− <SmilePlus size={13} /> Add icon
376− </button>
377− </IconPicker>
378− )}
379− {!cover && (
380− <CoverPicker onChange={(c) => send("update_page", { page_id: page.id, change: { cover: c } })}>
381− <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">
382− <ImagePlus size={13} /> Add cover
383− </button>
384− </CoverPicker>
385− )}
386− </div>
387− )}
388− {editable ? (
389− <textarea
390− value={title}
391− rows={1}
392− placeholder="Untitled"
393− aria-label="Title"
394− onFocus={() => (titleFocused.current = true)}
395− onBlur={() => (titleFocused.current = false)}
396− onChange={(e) => saveTitle(e.target.value.replace(/\n/g, " "))}
397− onKeyDown={(e) => {
398− if (e.key === "Enter") {
399− e.preventDefault();
400− (document.querySelector(".g1t-editor .bn-editor") as HTMLElement | null)?.focus();
401− }
402− }}
403− 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"
404− />
405− ) : (
406− <h1 className="font-display text-4xl leading-tight font-semibold tracking-tight">{live.title || "Untitled"}</h1>
407− )}
408− </div>
409− {/* Who wrote it, who owns it, what it's about. */}
410− <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-faint">
411− {live.updated_by && (
412− <span className="flex items-center gap-1.5">
413− <Face who={live.updated_by} size={16} />
414− Last edited by <span className="text-muted">{live.updated_by.display_name}</span> · <TimeAgo at={live.updated_at} />
415− </span>
416− )}
417− {live.owners.length > 0 && (
418− <span className="flex items-center gap-1.5">
419− Owners <Faces people={live.owners} size={18} />
420− </span>
421− )}
422− <Describes slug={slug} describes={detail.describes} editable={editable} onSave={(describes) => send("update_page", { page_id: page.id, change: { describes } })} />
423− {[...new Set([...live.projects, ...space.projects])].map((p) => (
424− <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">
425− {p}
426− </Link>
427− ))}
428− <span>
429− {reading.words.toLocaleString()} words · {reading.minutes} min read
430− </span>
431− {!editable && <span className="rounded-full bg-raised px-2 py-0.5">{DOC_ROLE_LABELS[role]}</span>}
432− </div>
433− {error && (
434− <div className="mt-3">
435− <ErrorText>{error}</ErrorText>
436− </div>
437− )}
438− {detail.staleness && <StaleBanner staleness={detail.staleness} canMark={editable} onMark={() => send("mark_current", { page_id: page.id })} />}
439−
440− {/* Agents' suggestions, all together on a narrower screen (beside their blocks on a wide one). */}
441− {suggestions.length > 0 && (
442− <div className="mt-6 rounded-xl border border-accent/30 bg-accent/5 p-3 2xl:hidden">
443− <div className="mb-2 flex items-center gap-2">
444− <Sparkles size={14} className="text-accent" />
445− <span className="grow text-sm font-medium">
446− {suggestions.length} {suggestions.length === 1 ? "suggestion" : "suggestions"} waiting
447− </span>
448− {editable && suggestions.length > 1 && (
449− <button type="button" onClick={async () => { setSuggestions([]); await send("accept_all", { page_id: page.id }); }} className="text-xs font-medium text-accent hover:underline">
450− Accept all
451− </button>
452− )}
453− </div>
454− <div className="space-y-2">
455− {suggestions.map((s) => (
456− <SuggestionCard key={s.id} suggestion={s} canDecide={editable} onDecide={decide} compact />
457− ))}
458− </div>
459− </div>
460− )}
461− {suggestions.length > 1 && editable && (
462− <div className="mt-4 hidden justify-end 2xl:flex">
463− <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">
464− <Check size={13} /> Accept all {suggestions.length} suggestions
465− </button>
466− </div>
467− )}
468− </div>
469−
470− {/* The page itself: live once the editor loads; its saved Markdown until then. */}
471− <div className="mt-6">
472− {hydrated && !archived ? (
473− <Suspense fallback={<ReadView markdown={detail.markdown} />}>
474− <DocEditor
475− key={page.id}
476− slug={slug}
477− pageId={page.id}
478− role={role}
479− me={layout?.me ?? { key: "", name: "", display_name: "", avatar: null }}
480− mentionables={layout?.mentionables ?? []}
481− usercontent={usercontent}
482− suggestions={suggestions}
483− showComments={showComments}
484− onPresence={setPresence}
485− onStatus={setStatus}
486− onEvent={onEvent}
487− onPageThreads={setThreads}
488− renderSuggestion={(s) => <SuggestionCard suggestion={s} canDecide={editable} onDecide={decide} />}
489− projects={[...new Set([...live.projects, ...space.projects, ...detail.describes.map((d) => d.repo)])]}
490− />
491− </Suspense>
492− ) : (
493− <ReadView markdown={detail.markdown} />
494− )}
495− </div>
496−
497− <div className={showComments ? "xl:max-w-3xl" : ""}>
498− {(detail.children.length > 0 || detail.backlinks.length > 0) && (
499− <div className="mt-14 grid gap-6 border-t border-line pt-8 sm:grid-cols-2">
500− {detail.children.length > 0 && (
501− <section>
502− <h2 className="mb-2 text-xs font-medium tracking-wide text-faint uppercase">Pages inside</h2>
503− <ul className="space-y-1">
504− {detail.children.map((c) => (
505− <li key={c.id}>
506− <Link to={c.path} className="flex items-center gap-2 rounded-md px-2 py-1 text-sm hover:bg-raised">
507− <PageIcon icon={c.icon} /> {c.title || "Untitled"}
508− </Link>
509− </li>
510− ))}
511− </ul>
512− </section>
513− )}
514− {detail.backlinks.length > 0 && (
515− <section>
516− <h2 className="mb-2 text-xs font-medium tracking-wide text-faint uppercase">Linked from</h2>
517− <ul className="space-y-1">
518− {detail.backlinks.map((c) => (
519− <li key={c.id}>
520− <Link to={c.path} className="flex items-center gap-2 rounded-md px-2 py-1 text-sm hover:bg-raised">
521− <PageIcon icon={c.icon} /> {c.title || "Untitled"}
522− </Link>
523− </li>
524− ))}
525− </ul>
526− </section>
527− )}
528− </div>
529− )}
530− {hydrated && (
531− <Discussion
532− threads={threads}
533− people={people}
534− canComment={canDo(role, "comment")}
535− onPost={(text) => threadRequest("", "POST", { initialComment: { body: commentBody(text) }, page_level: true })}
536− onReply={(thread, text) => threadRequest(`/${encodeURIComponent(thread)}/comments`, "POST", { comment: { body: commentBody(text) } })}
537− onResolve={(thread, resolved) => void threadRequest(`/${encodeURIComponent(thread)}/${resolved ? "resolve" : "unresolve"}`, "POST", {})}
538− />
539− )}
540− </div>
541− </article>
542−
543− <HistoryDialog slug={slug} pageId={page.id} open={history} onOpenChange={setHistory} canRestore={editable} onRestore={(versionId) => send("restore_version", { page_id: page.id, version_id: versionId })} />
544− {layout?.sidebar && (
545− <MoveDialog
546− spaces={layout.sidebar.spaces}
547− pageId={page.id}
548− current={{ space_id: page.space_id, parent_id: page.parent_id }}
549− open={moving}
550− onOpenChange={setMoving}
551− onMove={async (spaceId, parentId) => {
552− const moved = await send<DocPage>("move_page", { page_id: page.id, move: { space_id: spaceId, parent_id: parentId, before_id: null } });
553− setMoving(false);
554− if (moved.ok) navigate(moved.value.path);
555− }}
556− />
557− )}
558− <TemplateDialog open={templating} onOpenChange={setTemplating} title={live.title || "Untitled"} onSave={(name, description) => send("save_template", { page_id: page.id, name, description })} />
559− </div>
560− );
561−}
562−
563−/** The page as Markdown: what shows before the editor loads, and what a reader without script sees. */
564−function ReadView({ markdown }: { markdown: string }) {
565− if (!markdown.trim()) return <EditorSkeleton />;
566− return (
567− <div className="docs-read">
568− <Markdown source={markdown} />
569− </div>
570− );
571−}
+0−101
1−import type { DocRepoPage } from "@g1t/contracts";
2−import { FolderGit2, GitPullRequestArrow, PencilLine, Trash2 } from "lucide-react";
3−import { Link, data, useNavigate } from "react-router";
4−
5−import type { Route } from "./+types/repo-file";
6−import { useDocsAction } from "../../../components/docs/actions";
7−import { Crumbs } from "../../../components/docs/parts";
8−import { Markdown } from "../../../components/markdown";
9−import { ErrorText, TimeAgo } from "../../../components/ui";
10−import { Hint } from "../../../components/ui/hint";
11−import { page as pageMeta } 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− const file = loaded?.found.file;
17− return pageMeta(args, { title: `${file?.title ?? "Docs"} · ${params.repoOwner}/${params.repoName} · ${params.owner} · g1t` });
18−}
19−
20−export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ found: DocRepoPage }> {
21− const viewer = requireUser(context, request);
22− if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
23− const repo = `${params.repoOwner}/${params.repoName}`;
24− const found = await docs.repoPage(params.owner.toLowerCase(), viewer, repo, params["*"] ?? "").catch(() => null);
25− if (!found?.ok) throw data(null, { status: 404 });
26− return { found: found.value };
27−}
28−
29−/**
30− * One file of a project's docs, read-only: rendered as Code renders it,
31− * with its links and pictures pointing into the repository. Changes go
32− * through the repository: Edit in Code opens the file there.
33− */
34−export default function RepoDocFile({ loaderData, params }: Route.ComponentProps) {
35− const slug = params.owner.toLowerCase();
36− const { space, file } = loaderData.found;
37− const navigate = useNavigate();
38− const { send, error } = useDocsAction(slug);
39− const [namespace, name] = space.repo.split("/") as [string, string];
40− const folder = file.path.includes("/") ? file.path.slice(0, file.path.lastIndexOf("/")) : "";
41− const encode = (path: string) => path.split("/").map(encodeURIComponent).join("/");
42− return (
43− <div className="-mt-6">
44− <div className="sticky top-(--topbar-h) 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">
45− <div className="min-w-0 grow">
46− <Crumbs items={[{ label: "Docs", to: `/${slug}/-/docs` }, { label: space.repo }, ...file.path.split("/").map((part) => ({ label: part }))]} />
47− </div>
48− <Link to={file.code_href} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-line px-2.5 text-xs text-fg/85 hover:bg-raised">
49− <PencilLine size={13} /> Edit in Code
50− </Link>
51− {space.can_remove && (
52− <Hint label={`Stop showing ${space.repo}'s docs in Docs`}>
53− <button
54− type="button"
55− aria-label={`Stop showing ${space.repo}'s docs`}
56− onClick={async () => {
57− const done = await send("remove_repo_space", { id: space.id });
58− if (done.ok) navigate(`/${slug}/-/docs`);
59− }}
60− className="flex size-8 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-danger"
61− >
62− <Trash2 size={15} />
63− </button>
64− </Hint>
65− )}
66− </div>
67− <article className="mx-auto max-w-3xl pt-10">
68− <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-faint">
69− <span className="inline-flex items-center gap-1.5">
70− <FolderGit2 size={13} /> {space.repo} · {space.default_branch}
71− </span>
72− <span className="font-mono">{file.path}</span>
73− {space.indexed_at && (
74− <span>
75− Read <TimeAgo at={space.indexed_at} />
76− </span>
77− )}
78− </p>
79− <p className="mt-3 flex items-start gap-2 rounded-lg border border-line bg-surface px-3 py-2 text-xs leading-relaxed text-muted">
80− <GitPullRequestArrow size={14} className="mt-px shrink-0 text-faint" aria-hidden="true" />
81− This page lives in the repository and changes through pull requests. Edit it in Code, or ask an agent to open a pull request for it.
82− </p>
83− {error && (
84− <div className="mt-3">
85− <ErrorText>{error}</ErrorText>
86− </div>
87− )}
88− <div className="docs-read mt-6">
89− <Markdown
90− source={file.markdown}
91− repo={{ namespace, name }}
92− // Relative links point into the repository at its default
93− // branch, and pictures at its files there.
94− base={`/${space.repo}/blob/${encodeURIComponent(space.default_branch)}${folder ? `/${encode(folder)}` : ""}`}
95− rawBase={`/${space.repo}/raw/${encodeURIComponent(space.commit ?? space.default_branch)}${folder ? `/${encode(folder)}` : ""}`}
96− />
97− </div>
98− </article>
99− </div>
100− );
101−}
+0−113
1−import type { DocSearchHit } from "@g1t/contracts";
2−import { FolderGit2, 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, mode: "hybrid" }).catch(() => null)
28− : null;
29− return { q, space, project, hits: found?.ok ? found.value : q.trim() ? null : ([] as DocSearchHit[]) };
30−}
31−
32−/**
33− * Every page the viewer can read, and the projects' docs shown in Docs,
34− * by words and by meaning at once; by space and by project. Each hit shows
35− * the passage that matched, under its heading.
36− */
37−export default function DocsSearch({ loaderData, params }: Route.ComponentProps) {
38− const slug = params.owner.toLowerCase();
39− const { q, space, project, hits } = loaderData;
40− const layout = useDocsData();
41− const submit = useSubmit();
42− const spaces = layout?.sidebar?.spaces ?? [];
43− const projects = [...new Set(spaces.flatMap((s) => s.projects))].sort();
44− const refresh = () => submit(document.getElementById("docs-search") as HTMLFormElement);
45− return (
46− <div className="mx-auto max-w-3xl">
47− <h1 className="text-xl font-semibold tracking-tight">Search docs</h1>
48− <Form method="get" id="docs-search" className="mt-4 flex flex-wrap gap-2">
49− <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">
50− <Search size={16} className="text-faint" aria-hidden="true" />
51− <input name="q" defaultValue={q} autoFocus placeholder="Words, or a question" aria-label="Search" className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint" />
52− </label>
53− <SelectField
54− name="space"
55− aria-label="Space"
56− value={space ?? ""}
57− afterChange={refresh}
58− options={[{ value: "", label: "Every space" }, ...spaces.map((s) => ({ value: s.id, label: s.name }))]}
59− className="h-10 min-w-40"
60− />
61− {projects.length > 0 && (
62− <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" />
63− )}
64− </Form>
65− <div className="mt-6">
66− {hits === null ? (
67− <EmptyState title="Search didn't answer">Try again in a moment.</EmptyState>
68− ) : !q.trim() ? (
69− <p className="text-sm text-muted">
70− Search every space you can read, and the projects&apos; docs shown in Docs, by their words and by what they mean: ask a question and the passage that answers it comes up even when it words it differently. Agents recall from
71− Docs the same way, and only from what everyone they answer can read.
72− </p>
73− ) : hits.length === 0 ? (
74− <EmptyState title="Nothing found">No page you can read matches &ldquo;{q}&rdquo;.</EmptyState>
75− ) : (
76− <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
77− {hits.map((hit) => (
78− <li key={hit.id}>
79− <Link to={hit.path} className="block px-4 py-3 transition-colors hover:bg-raised">
80− <span className="flex items-center gap-2">
81− {hit.repo_file ? <FolderGit2 size={15} className="shrink-0 text-faint" aria-hidden="true" /> : <PageIcon icon={hit.icon} />}
82− <span className="truncate text-sm font-medium">{hit.title || "Untitled"}</span>
83− <span className="ml-auto shrink-0 text-xs text-faint">
84− {hit.space_name} · <TimeAgo at={hit.updated_at} />
85− </span>
86− </span>
87− {hit.heading && <span className="mt-0.5 block truncate text-xs font-medium text-muted">{hit.heading}</span>}
88− <span className="mt-1 line-clamp-2 block text-xs leading-relaxed text-muted">
89− {snippetParts(hit.snippet).map((part, i) =>
90− part.match ? (
91− <mark key={i} className="rounded bg-accent/20 px-0.5 text-fg">
92− {part.text}
93− </mark>
94− ) : (
95− <span key={i}>{part.text}</span>
96− ),
97− )}
98− </span>
99− {hit.projects.length > 0 && <span className="mt-1 block text-[0.6875rem] text-faint">{hit.projects.join(" · ")}</span>}
100− </Link>
101− </li>
102− ))}
103− </ul>
104− )}
105− </div>
106− <p className="mt-4 text-xs text-faint">
107− <Link to={`/${slug}/-/docs`} className="hover:text-fg">
108− Back to Docs
109− </Link>
110− </p>
111− </div>
112− );
113−}
+0−167
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−}
+0−121
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−}
+0−63
1−import type { DocPage } from "@g1t/contracts";
2−import { AlertTriangle } from "lucide-react";
3−import { Link, data } from "react-router";
4−
5−import type { Route } from "./+types/stale";
6−import { useDocsData } from "../../../components/docs/actions";
7−import { Faces } from "../../../components/docs/parts";
8−import { PageIcon } from "../../../components/docs/sidebar";
9−import { EmptyState, TimeAgo } from "../../../components/ui";
10−import { page } from "../../../lib/meta";
11−import { docs } from "../../../lib/services.server";
12−import { requireUser, roleIn } from "../../../lib/session.server";
13−
14−export function meta({ params, ...args }: Route.MetaArgs) {
15− return page(args, { title: `Possibly stale · Docs · ${params.owner} · g1t` });
16−}
17−
18−export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ pages: DocPage[] | null; repo: string | null }> {
19− const viewer = requireUser(context, request);
20− if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
21− const repo = new URL(request.url).searchParams.get("repo") || null;
22− const found = await docs.stalePages(params.owner.toLowerCase(), viewer, { repo }).catch(() => null);
23− return { pages: found?.ok ? found.value : null, repo };
24−}
25−
26−/** Pages whose cited code changed since someone last marked them current, most recently flagged first. */
27−export default function DocsStale({ loaderData }: Route.ComponentProps) {
28− const { pages, repo } = loaderData;
29− const layout = useDocsData();
30− const spaces = new Map((layout?.sidebar?.spaces ?? []).map((s) => [s.id, s.name]));
31− return (
32− <div className="mx-auto max-w-3xl">
33− <h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
34− <AlertTriangle size={18} className="text-warn" /> Possibly stale{repo ? ` · ${repo}` : ""}
35− </h1>
36− <p className="mt-1 text-sm text-muted">A merged pull request or a push to a default branch changed code these pages cite. Read what changed, update the page (or ask an agent to), then mark it current.</p>
37− <div className="mt-6">
38− {pages === null ? (
39− <EmptyState title="Docs didn't answer">Try again in a moment.</EmptyState>
40− ) : pages.length === 0 ? (
41− <EmptyState title="Everything is current">No page you can read cites code that changed since it was last checked.</EmptyState>
42− ) : (
43− <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
44− {pages.map((p) => (
45− <li key={p.id}>
46− <Link to={p.path} prefetch="intent" className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-raised">
47− <PageIcon icon={p.icon} />
48− <span className="min-w-0 grow">
49− <span className="block truncate text-sm">{p.title || "Untitled"}</span>
50− <span className="block text-xs text-faint">
51− {spaces.get(p.space_id) ?? p.space_slug} · edited <TimeAgo at={p.updated_at} />
52− </span>
53− </span>
54− {p.owners.length > 0 && <Faces people={p.owners} size={18} />}
55− </Link>
56− </li>
57− ))}
58− </ul>
59− )}
60− </div>
61− </div>
62− );
63−}
+0−118
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− // The template a page is being started from, while it is.
36− const [starting, setStarting] = useState<string | null>(null);
37− const use = async (template: string) => {
38− if (starting) return;
39− setStarting(template);
40− setError(null);
41− const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: space, template_id: template } });
42− if (made.ok) {
43− await navigate(made.value.path);
44− return;
45− }
46− setStarting(null);
47− setError(made.error.message);
48− };
49− const startButton = (id: string) => (
50− <button
51− type="button"
52− disabled={!space || starting != null}
53− aria-busy={starting === id}
54− onClick={() => use(id)}
55− className="text-xs font-medium text-accent hover:underline disabled:text-faint disabled:no-underline"
56− >
57− {starting === id ? "Starting…" : "Use template"}
58− </button>
59− );
60− const builtin = loaderData.templates.filter((t) => t.builtin);
61− const own = loaderData.templates.filter((t) => !t.builtin);
62− return (
63− <div className="mx-auto max-w-5xl">
64− <div className="flex flex-wrap items-end justify-between gap-4">
65− <div>
66− <h1 className="text-xl font-semibold tracking-tight">Templates</h1>
67− <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>
68− </div>
69− {writable.length > 0 && (
70− <label className="flex items-center gap-2 text-sm text-muted">
71− New pages go in
72− <SelectField aria-label="Space" value={space} onValueChange={setSpace} options={writable.map((s) => ({ value: s.id, label: s.name }))} className="h-9 min-w-40" />
73− </label>
74− )}
75− </div>
76− {(error || actionError) && (
77− <div className="mt-3">
78− <ErrorText>{error ?? actionError}</ErrorText>
79− </div>
80− )}
81− <section className="mt-8">
82− <SectionTitle>From g1t</SectionTitle>
83− <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
84− {builtin.map((t) => (
85− <TemplateCard
86− key={t.id}
87− template={t}
88− action={startButton(t.id)}
89− />
90− ))}
91− </div>
92− </section>
93− <section className="mt-10">
94− <SectionTitle>Your workspace&apos;s</SectionTitle>
95− {own.length === 0 ? (
96− <p className="text-sm text-muted">None yet. Open a page, then choose Save as template from its ⋯ menu.</p>
97− ) : (
98− <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
99− {own.map((t) => (
100− <TemplateCard
101− key={t.id}
102− template={t}
103− action={
104− <>
105− {startButton(t.id)}
106− <button type="button" onClick={() => send("delete_template", { template_id: t.id })} className="ml-auto text-xs text-faint hover:text-danger">
107− Delete
108− </button>
109− </>
110− }
111− />
112− ))}
113− </div>
114− )}
115− </section>
116− </div>
117− );
118−}
+0−69
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−}
+0−89
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−}
+0−44
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−}
+302−0
1+import { data } from "react-router";
2+
3+import {
4+ folioIdFrom,
5+ isFolioKind,
6+ type DocRole,
7+ type DocSpaceChange,
8+ type Folio,
9+ type FolioAccessChange,
10+ type FolioChange,
11+ type FolioListQuery,
12+ type FolioMove,
13+ type NewDocSpace,
14+ type NewFolio,
15+ type Result,
16+ type User,
17+} from "@g1t/contracts";
18+
19+import type { Route } from "./+types/api";
20+import { workspacePeople } from "../../../lib/chat.server";
21+import { listQuery } from "../../../lib/folios";
22+import { docs, folios, identity, repos, work } from "../../../lib/services.server";
23+import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
24+
25+/**
26+ * What Artifacts' pages ask for as they run, as JSON: a page of the home
27+ * list, link search, an artifact's sharing, history and one version,
28+ * templates, cards for embedded g1t things and chat's link cards; and
29+ * everything they change: artifacts, sharing, spaces, members,
30+ * suggestions, templates. Every answer is `{ ok, value }` or
31+ * `{ ok: false, error }`, the services' own shape.
32+ */
33+export async function loader({ params, context, request }: Route.LoaderArgs) {
34+ const viewer = requireUser(context, request);
35+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
36+ const slug = params.owner.toLowerCase();
37+ const url = new URL(request.url);
38+ const q = url.searchParams;
39+ const answer = await safely<unknown>(async () => {
40+ // A page of the home list ("Show more"): the same filters as the page's address.
41+ if (q.has("list")) return folios.list(slug, viewer, await withOwner(listQuery(q)));
42+ // Words only: what `[[` and the move dialog offer as you type.
43+ if (q.has("search")) return folios.search(slug, viewer, { q: q.get("search") ?? "", mode: "words", limit: Number(q.get("limit")) || 10 });
44+ if (q.get("access")) return folios.access(slug, viewer, q.get("access")!);
45+ if (q.get("versions")) return folios.versions(slug, viewer, q.get("versions")!);
46+ if (q.get("version") && q.get("folio")) return folios.version(slug, viewer, q.get("folio")!, q.get("version")!);
47+ if (q.has("templates")) return folios.templates(slug, viewer, isFolioKind(q.get("templates")) ? (q.get("templates") as NewFolio["kind"]) : null);
48+ if (q.get("embed")) return embed(slug, q.get("embed")!, viewer, url.origin);
49+ // Chat's card for an artifact link, as this viewer may see it.
50+ if (q.get("unfurl")) return unfurl(slug, q.get("unfurl")!, viewer);
51+ // Repositories to cite or show the docs of: the workspace's that the viewer can read.
52+ if (q.has("repos")) return repositories(slug, viewer);
53+ // A citation's commit: the default branch's head, once the path is there.
54+ if (q.get("cite")) return cite(viewer, q.get("cite")!, q.get("path") ?? "");
55+ // How member keys show (`user:<id>`, `agent:<id>`): comment authors, people on an artifact.
56+ if (q.get("who")) return who(slug, viewer, q.get("who")!.split(",").slice(0, 100));
57+ // A member's key (`user:<id>`) by username, for sharing with them or adding them to a space.
58+ if (q.get("person")) {
59+ const user = await identity.userByUsername(q.get("person")!.toLowerCase());
60+ return user ? { ok: true, value: `user:${user.id}` } : { ok: false, error: { code: "not_found", message: "No such person." } };
61+ }
62+ // The workspace's teams, to share with.
63+ if (q.has("teams")) {
64+ const teams = await identity.listTeams(viewer, slug).catch(() => null);
65+ return { ok: true, value: teams?.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] };
66+ }
67+ return folios.sidebar(slug, viewer);
68+ });
69+ return Response.json(answer, { headers: { "cache-control": "no-store" } });
70+}
71+
72+type Sent = {
73+ intent?: string;
74+ folio_id?: string;
75+ space_id?: string;
76+ suggestion_id?: string;
77+ version_id?: string;
78+ template_id?: string;
79+ member?: string;
80+ role?: DocRole | null;
81+ decision?: "accept" | "reject";
82+ on?: boolean;
83+ folio?: NewFolio;
84+ change?: FolioChange;
85+ move?: FolioMove;
86+ access?: FolioAccessChange;
87+ message?: string | null;
88+ space?: NewDocSpace;
89+ space_change?: DocSpaceChange;
90+ name?: string;
91+ description?: string | null;
92+ repo?: string;
93+ id?: string;
94+};
95+
96+export async function action({ params, context, request }: Route.ActionArgs) {
97+ assertSameOrigin(request);
98+ const viewer = requireUser(context, request);
99+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
100+ const slug = params.owner.toLowerCase();
101+ const sent = (await request.json().catch(() => ({}))) as Sent;
102+ const id = sent.folio_id ?? "";
103+ const answer = await safely(async (): Promise<Result<unknown>> => {
104+ switch (sent.intent) {
105+ case "create":
106+ return folios.create(slug, viewer, sent.folio ?? { kind: "doc" });
107+ case "update":
108+ return folios.update(slug, viewer, id, sent.change ?? {});
109+ case "move":
110+ return folios.move(slug, viewer, id, sent.move ?? { space_id: null, parent_id: null });
111+ case "duplicate":
112+ return folios.duplicate(slug, viewer, id);
113+ case "trash":
114+ return folios.trash(slug, viewer, id);
115+ case "restore":
116+ return folios.restore(slug, viewer, id);
117+ case "delete":
118+ return folios.delete(slug, viewer, id);
119+ case "favorite":
120+ return folios.favorite(slug, viewer, id, !!sent.on);
121+ case "access":
122+ if (!sent.access) return { ok: false, error: { code: "invalid", message: "What should change?" } };
123+ return folios.changeAccess(slug, viewer, id, sent.access);
124+ case "request_access":
125+ return folios.requestAccess(slug, viewer, id, sent.message ?? null);
126+ case "join_space":
127+ return folios.joinSpace(slug, viewer, sent.space_id ?? "");
128+ case "leave_space":
129+ return folios.leaveSpace(slug, viewer, sent.space_id ?? "");
130+ case "restore_version":
131+ return folios.restoreVersion(slug, viewer, id, sent.version_id ?? "");
132+ case "decide":
133+ return folios.decideSuggestion(slug, viewer, sent.suggestion_id ?? "", sent.decision === "accept" ? "accept" : "reject");
134+ case "save_template":
135+ return folios.saveTemplate(slug, viewer, { folio_id: id, name: sent.name ?? "", description: sent.description ?? null });
136+ case "delete_template":
137+ return folios.deleteTemplate(slug, viewer, sent.template_id ?? "");
138+ case "mark_current":
139+ return folios.markCurrent(slug, viewer, id);
140+ // Spaces are the docs service's own, shared with Artifacts.
141+ case "create_space":
142+ return docs.createSpace(slug, viewer, sent.space ?? ({ name: "", kind: "workspace" } as NewDocSpace));
143+ case "update_space":
144+ return docs.updateSpace(slug, sent.space_id ?? "", viewer, sent.space_change ?? {});
145+ case "set_member":
146+ return docs.setSpaceMember(slug, sent.space_id ?? "", viewer, sent.member ?? "", sent.role ?? null);
147+ case "add_repo_space":
148+ return docs.addRepoSpace(slug, viewer, sent.repo ?? "");
149+ case "remove_repo_space":
150+ return docs.removeRepoSpace(slug, viewer, sent.id ?? "");
151+ default:
152+ return { ok: false, error: { code: "invalid", message: "Unknown request." } };
153+ }
154+ });
155+ return Response.json(answer, { headers: { "cache-control": "no-store" } });
156+}
157+
158+/** The owner filter names a person by username; the service keys people by id. */
159+async function withOwner(query: FolioListQuery): Promise<FolioListQuery> {
160+ if (!query.owner) return query;
161+ const user = await identity.userByUsername(query.owner.toLowerCase()).catch(() => null);
162+ return { ...query, owner: user ? `user:${user.id}` : "user:nobody" };
163+}
164+
165+async function safely<T>(run: () => Promise<Result<T>>): Promise<Result<T>> {
166+ try {
167+ return await run();
168+ } catch (error) {
169+ console.error("artifacts api:", error);
170+ return { ok: false, error: { code: "conflict", message: "Artifacts didn't answer. Try again in a moment." } };
171+ }
172+}
173+
174+/**
175+ * Chat's card for an artifact link, for this viewer only (plan section
176+ * 4.3 rule 3): its kind, title, space and last edit when they can read it;
177+ * `null` (no title, nothing else) when they can't or it doesn't exist.
178+ * Looking never records a visit: a card is not opening the link.
179+ */
180+async function unfurl(slug: string, address: string, viewer: User): Promise<Result<FolioUnfurl | null>> {
181+ const id = folioIdFrom(address.split(/[?#]/)[0]!.replace(/\/+$/, ""));
182+ if (!id) return { ok: false, error: { code: "invalid", message: "That isn't an artifact's address." } };
183+ const found = await folios.folio(slug, viewer, id, { peek: true });
184+ if (!found.ok) return { ok: true, value: null };
185+ const f = found.value;
186+ return {
187+ ok: true,
188+ value: { id: f.id, kind: f.kind, title: f.title, icon: f.icon, path: f.path, space: f.space?.name ?? null, private: f.private, excerpt: f.excerpt, edited_at: f.edited_at, edited_by: f.edited_by?.display_name ?? null },
189+ };
190+}
191+
192+/** What chat's card for an artifact link shows to someone who can read it. */
193+export type FolioUnfurl = Pick<Folio, "id" | "kind" | "title" | "icon" | "path" | "private" | "excerpt" | "edited_at"> & { space: string | null; edited_by: string | null };
194+
195+/**
196+ * The card for a g1t address embedded in a doc, as this reader may see
197+ * it: an issue or pull request with its state, a channel, a project, an
198+ * artifact. Anything they can't read is not found, exactly as one that
199+ * does not exist.
200+ */
201+async function embed(slug: string, address: string, viewer: User, origin: string): Promise<Result<unknown>> {
202+ let path: string;
203+ try {
204+ const url = new URL(address, origin);
205+ if (url.origin !== origin) return { ok: true, value: { kind: "link", title: url.hostname + url.pathname, subtitle: url.hostname, state: null, href: url.toString() } };
206+ path = url.pathname;
207+ } catch {
208+ return { ok: false, error: { code: "invalid", message: "That isn't an address." } };
209+ }
210+ const parts = path.split("/").filter(Boolean).map(decodeURIComponent);
211+ const missing = { ok: false as const, error: { code: "not_found" as const, message: "Not found." } };
212+ // /<owner>/-/artifacts/<slug>-<id>
213+ if (parts[1] === "-" && parts[2] === "artifacts" && parts[3]) {
214+ if (parts[0]!.toLowerCase() !== slug) return missing;
215+ const id = folioIdFrom(parts[3]);
216+ if (!id) return missing;
217+ const found = await folios.folio(slug, viewer, id, { peek: true });
218+ if (!found.ok) return missing;
219+ const f = found.value;
220+ return { ok: true, value: { kind: "page", title: `${f.icon ? `${f.icon} ` : ""}${f.title || "Untitled"}`, subtitle: f.space?.name ?? "Private", state: null, href: f.path } };
221+ }
222+ // /<owner>/-/chat/<channel>
223+ if (parts[1] === "-" && parts[2] === "chat" && parts[3]) {
224+ return { ok: true, value: { kind: "channel", title: parts[3] === "dm" ? "A direct message" : `#${parts[3]}`, subtitle: "Chat", state: null, href: path } };
225+ }
226+ if (parts.length < 2 || parts[1] === "-") return missing;
227+ const repo = { namespace: parts[0]!, name: parts[1]! };
228+ // /<owner>/<repo>/issues/<n> and /pull/<n>
229+ const number = Number(parts[3]);
230+ if ((parts[2] === "issues" || parts[2] === "pull" || parts[2] === "pulls") && Number.isInteger(number) && number > 0) {
231+ if (parts[2] === "issues") {
232+ const found = await work.getIssue(repo, number, viewer);
233+ if (!found.ok) return missing;
234+ return { ok: true, value: { kind: "issue", title: `${found.value.issue.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state: found.value.issue.state, href: path } };
235+ }
236+ const found = await work.getPull(repo, number, viewer);
237+ if (!found.ok) return missing;
238+ const pull = found.value.pull as { title: string; status: string; mergedAt?: string | null };
239+ const state = pull.mergedAt ? "merged" : String(pull.status ?? "open");
240+ return { ok: true, value: { kind: "pull", title: `${pull.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state, href: path } };
241+ }
242+ const found = await repos.get(repo, viewer);
243+ if (!found.ok) return missing;
244+ 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}` } };
245+}
246+
247+/** The workspace's repositories the viewer can read, newest first: `owner/name` and its default branch. */
248+async function repositories(slug: string, viewer: User): Promise<Result<{ repo: string; default_branch: string; private: boolean }[]>> {
249+ const found = await repos.list(viewer, { namespace: slug });
250+ return {
251+ ok: true,
252+ value: found
253+ .filter((r) => !r.forkOf)
254+ .slice(0, 300)
255+ .map((r) => ({ repo: `${r.namespace}/${r.name}`.toLowerCase(), default_branch: r.defaultBranch, private: r.isPrivate })),
256+ };
257+}
258+
259+/**
260+ * A citation for `path` in `repo`, as the viewer can read it: pinned to
261+ * the default branch's head commit, once the file or folder is there (a
262+ * glob is taken as written).
263+ */
264+async function cite(viewer: User, repo: string, rawPath: string): Promise<Result<{ repo: string; path: string; ref: string | null; kind: "file" | "folder" | "glob" }>> {
265+ const [namespace, name, ...rest] = repo.trim().toLowerCase().split("/");
266+ const missing = { ok: false as const, error: { code: "not_found" as const, message: "No such repository, or you can't read it." } };
267+ if (!namespace || !name || rest.length) return missing;
268+ const path = rawPath
269+ .trim()
270+ .replace(/\\/g, "/")
271+ .split("/")
272+ .filter((p) => p && p !== ".")
273+ .join("/");
274+ if (!path || path.split("/").includes("..")) return { ok: false, error: { code: "invalid", message: "Name a file, a folder or a pattern in the repository." } };
275+ const where = { namespace, name };
276+ const root = await repos.tree(where, viewer, null, "");
277+ if (!root.ok) return missing;
278+ const head = root.value.head?.hash ?? null;
279+ if (/[*?]/.test(path)) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "glob" } };
280+ if (!head) return { ok: false, error: { code: "not_found", message: "That repository has no commits yet." } };
281+ const [blob, tree] = await Promise.all([repos.blob(where, viewer, head, path).catch(() => null), repos.tree(where, viewer, head, path).catch(() => null)]);
282+ if (blob?.ok) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "file" } };
283+ if (tree?.ok) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "folder" } };
284+ return { ok: false, error: { code: "not_found", message: `There is no ${path} on ${root.value.repo.defaultBranch}.` } };
285+}
286+
287+/** Names and faces for member keys, as the workspace knows them. */
288+async function who(slug: string, viewer: User, keys: string[]): Promise<Result<unknown>> {
289+ const userIds = keys.filter((k) => k.startsWith("user:")).map((k) => k.slice(5));
290+ const agentIds = new Set(keys.filter((k) => k.startsWith("agent:")).map((k) => k.slice(6)));
291+ 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)]);
292+ const out = keys.map((key) => {
293+ if (key.startsWith("agent:")) {
294+ const agent = agentIds.has(key.slice(6)) ? agents.find((a) => a.id === key.slice(6)) : undefined;
295+ 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 };
296+ }
297+ const username = names[key.slice(5)]?.toLowerCase();
298+ const person = username ? people.find((p) => p.name === username) : undefined;
299+ 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 };
300+ });
301+ return { ok: true, value: out };
302+}
+29−0
1+import { data } from "react-router";
2+
3+import type { Route } from "./+types/export";
4+import { folios } from "../../../lib/services.server";
5+import { requireUser, roleIn } from "../../../lib/session.server";
6+
7+/**
8+ * An artifact as a file: `?folio=<id>` downloads a doc as `<title>.md`
9+ * (`&format=json` for its agent form). `&inline=1` answers the text
10+ * itself, for "Copy as Markdown". Only what the viewer can read.
11+ */
12+export async function loader({ params, context, request }: Route.LoaderArgs) {
13+ const viewer = requireUser(context, request);
14+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
15+ const url = new URL(request.url);
16+ const id = url.searchParams.get("folio");
17+ if (!id) throw data("Which artifact?", { status: 400 });
18+ const format = url.searchParams.get("format") === "json" ? "json" : null;
19+ const found = await folios.export(params.owner.toLowerCase(), viewer, id, format);
20+ if (!found.ok) throw data(found.error.message, { status: found.error.code === "not_found" ? 404 : found.error.code === "forbidden" ? 403 : 422 });
21+ const inline = url.searchParams.get("inline") === "1";
22+ return new Response(found.value.body, {
23+ headers: {
24+ "content-type": found.value.content_type,
25+ ...(inline ? {} : { "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(found.value.filename)}` }),
26+ "cache-control": "no-store",
27+ },
28+ });
29+}
+136−0
1+import { FOLIO_KIND_NOUNS, folioIdFrom, type DocRole, type Folio, type FolioPage } from "@g1t/contracts";
2+import { Lock } from "lucide-react";
3+import { Suspense, useCallback, useEffect, useState } from "react";
4+import { Link, data, redirect, useNavigate } from "react-router";
5+
6+import type { Route } from "./+types/folio";
7+import { foliosRequest } from "../../../components/folios/actions";
8+import { EditorSkeleton } from "../../../components/folios/doc/editor-skeleton";
9+import { FOLIO_KIND_UI } from "../../../components/folios/kinds";
10+import type { LiveStatus } from "../../../components/folios/provider";
11+import { FolioHeader, type Presence } from "../../../components/folios/shell";
12+import { ErrorText } from "../../../components/ui";
13+import { page as pageMeta } from "../../../lib/meta";
14+import { folios } from "../../../lib/services.server";
15+import { requireUser, roleIn } from "../../../lib/session.server";
16+
17+export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
18+ const f = loaded && "page" in loaded ? loaded.page.folio : null;
19+ return pageMeta(args, { title: `${f ? `${f.icon ? `${f.icon} ` : ""}${f.title || "Untitled"}` : "Artifact"} · ${params.owner} · g1t`, description: f?.excerpt, type: "article" });
20+}
21+
22+type Loaded = { page: FolioPage } | { denied: string };
23+
24+export async function loader({ params, context, request }: Route.LoaderArgs) {
25+ const viewer = requireUser(context, request);
26+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
27+ const id = folioIdFrom(params.folio);
28+ if (!id) throw data(null, { status: 404 });
29+ const found = await folios.page(params.owner.toLowerCase(), viewer, id);
30+ if (!found.ok) {
31+ // One they can't read and one that doesn't exist look the same: both can be asked for.
32+ if (found.error.code === "not_found" || found.error.code === "forbidden") return data<Loaded>({ denied: id }, { status: 403 });
33+ throw data(null, { status: 503 });
34+ }
35+ // An old address (renamed since) goes to the current one.
36+ const url = new URL(request.url);
37+ if (url.pathname !== found.value.folio.path) throw redirect(`${found.value.folio.path}${url.search}`);
38+ return { page: found.value } as Loaded;
39+}
40+
41+/** One artifact: the shared header, then its kind's page (a doc's editor, for now). */
42+export default function FolioView({ loaderData, params }: Route.ComponentProps) {
43+ const slug = params.owner.toLowerCase();
44+ if ("denied" in loaderData) return <NeedAccess slug={slug} id={loaderData.denied} />;
45+ return <Opened slug={slug} page={loaderData.page} />;
46+}
47+
48+function Opened({ slug, page }: { slug: string; page: FolioPage }) {
49+ const navigate = useNavigate();
50+ const [folio, setFolio] = useState<Folio>(page.folio);
51+ useEffect(() => setFolio(page.folio), [page.folio]);
52+ const [role, setRole] = useState<DocRole>(page.folio.viewer_role);
53+ useEffect(() => setRole(page.folio.viewer_role), [page.folio.viewer_role, page.folio.id]);
54+ const [presence, setPresence] = useState<Presence[]>([]);
55+ const [status, setStatus] = useState<LiveStatus>("connecting");
56+ const [showComments, setShowComments] = useState(false);
57+ const [error, setError] = useState<string | null>(null);
58+ const onRole = useCallback((next: DocRole | null) => next && setRole(next), []);
59+ const ui = FOLIO_KIND_UI[folio.kind];
60+ const Body = ui.Body;
61+ return (
62+ <div>
63+ <FolioHeader
64+ slug={slug}
65+ folio={{ ...folio, viewer_role: role }}
66+ breadcrumbs={page.breadcrumbs}
67+ presence={presence}
68+ status={status}
69+ comments={folio.kind === "doc" ? { open: showComments, onToggle: () => setShowComments(!showComments) } : null}
70+ onError={setError}
71+ onTrashed={() => navigate(`/${slug}/-/artifacts`)}
72+ />
73+ {error && (
74+ <div className="mx-auto mt-4 max-w-3xl">
75+ <ErrorText>{error}</ErrorText>
76+ </div>
77+ )}
78+ {Body ? (
79+ <Suspense
80+ fallback={
81+ <div className="mx-auto max-w-3xl pt-10">
82+ <EditorSkeleton />
83+ </div>
84+ }
85+ >
86+ <Body slug={slug} page={page} folio={folio} role={role} showComments={showComments} onPresence={setPresence} onStatus={setStatus} onFolio={setFolio} onRole={onRole} onError={setError} />
87+ </Suspense>
88+ ) : (
89+ <p className="mx-auto mt-16 max-w-md text-center text-sm text-muted">This is a {FOLIO_KIND_NOUNS[folio.kind]}. Opening {ui.label.toLowerCase()} here is coming soon.</p>
90+ )}
91+ </div>
92+ );
93+}
94+
95+/** "You need access": ask the owner and the people with full access, at most once a day. */
96+function NeedAccess({ slug, id }: { slug: string; id: string }) {
97+ const [state, setState] = useState<"idle" | "busy" | "sent">("idle");
98+ const [message, setMessage] = useState("");
99+ const [error, setError] = useState<string | null>(null);
100+ return (
101+ <div className="mx-auto mt-16 flex max-w-md flex-col items-center text-center">
102+ <span className="flex size-12 items-center justify-center rounded-full bg-raised text-muted">
103+ <Lock size={20} />
104+ </span>
105+ <h1 className="mt-4 text-xl font-semibold tracking-tight">You need access</h1>
106+ <p className="mt-1 text-sm text-muted">Ask for access, and its owner hears of it. If it was deleted, nobody can open it any more.</p>
107+ {state === "sent" ? (
108+ <p className="mt-6 text-sm text-success">Asked. You&apos;ll get an inbox item when they let you in.</p>
109+ ) : (
110+ <form
111+ className="mt-6 flex w-full flex-col gap-2"
112+ onSubmit={async (e) => {
113+ e.preventDefault();
114+ setState("busy");
115+ setError(null);
116+ const done = await foliosRequest(slug, "request_access", { folio_id: id, message: message.trim() || null });
117+ if (done.ok) setState("sent");
118+ else {
119+ setState("idle");
120+ setError(done.error.message);
121+ }
122+ }}
123+ >
124+ <input aria-label="Message" placeholder="Add a message (optional)" value={message} onChange={(e) => setMessage(e.target.value)} maxLength={500} className="h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none focus:border-accent/60" />
125+ <button type="submit" disabled={state === "busy"} className="inline-flex h-9 items-center justify-center rounded-md bg-accent px-4 text-sm font-medium text-bg hover:bg-accent-hover disabled:opacity-60">
126+ {state === "busy" ? "Asking…" : "Ask for access"}
127+ </button>
128+ {error && <ErrorText>{error}</ErrorText>}
129+ </form>
130+ )}
131+ <Link to={`/${slug}/-/artifacts`} className="mt-6 text-xs text-muted hover:text-fg">
132+ Back to Artifacts
133+ </Link>
134+ </div>
135+ );
136+}
+133−0
1+import { folioIdFrom, type DocDiffLine, type Folio, type FolioVersion, type FolioVersionDetail } from "@g1t/contracts";
2+import { ArrowLeft, History, RotateCcw } from "lucide-react";
3+import { useEffect, useState } from "react";
4+import { Link, data, redirect, useNavigate } from "react-router";
5+
6+import type { Route } from "./+types/history";
7+import { foliosQuery, foliosRequest } from "../../../components/folios/actions";
8+import { FolioGlyph } from "../../../components/folios/kinds";
9+import { Face } from "../../../components/folios/parts";
10+import { Button, EmptyState, ErrorText } from "../../../components/ui";
11+import { canDo } from "../../../lib/folios";
12+import { page } from "../../../lib/meta";
13+import { folios } from "../../../lib/services.server";
14+import { requireUser, roleIn } from "../../../lib/session.server";
15+
16+export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
17+ return page(args, { title: `History · ${loaded?.folio.title || "Untitled"} · ${params.owner} · g1t` });
18+}
19+
20+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ folio: Folio; versions: FolioVersion[] }> {
21+ const viewer = requireUser(context, request);
22+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
23+ const slug = params.owner.toLowerCase();
24+ const id = folioIdFrom(params.folio);
25+ if (!id) throw data(null, { status: 404 });
26+ const [folio, versions] = await Promise.all([folios.folio(slug, viewer, id), folios.versions(slug, viewer, id)]);
27+ if (!folio.ok || !versions.ok) throw data(null, { status: 404 });
28+ const url = new URL(request.url);
29+ if (url.pathname !== `${folio.value.path}/history`) throw redirect(`${folio.value.path}/history`);
30+ return { folio: folio.value, versions: versions.value };
31+}
32+
33+const KIND_LABELS: Record<FolioVersion["kind"], string> = { created: "Created", edit: "Edited", agent: "Edited by an agent", suggestion: "Suggestion accepted", proposal: "Proposal applied", restore: "Restored" };
34+
35+function DiffView({ lines }: { lines: DocDiffLine[] }) {
36+ if (!lines.length) return <p className="p-4 text-xs text-faint">No changes in the text.</p>;
37+ return (
38+ <div className="font-mono text-[0.75rem] leading-relaxed">
39+ {lines.map((l, i) => (
40+ <div key={i} className={`flex gap-2 px-3 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"}`}>
41+ <span className="w-3 shrink-0 text-faint select-none">{l.op === "add" ? "+" : l.op === "del" ? "−" : " "}</span>
42+ <span className="min-w-0">{l.text || " "}</span>
43+ </div>
44+ ))}
45+ </div>
46+ );
47+}
48+
49+/** An artifact's history: every version, who made it, what changed against the one before, and restoring one (a new version; nothing is lost). */
50+export default function FolioHistory({ loaderData, params }: Route.ComponentProps) {
51+ const slug = params.owner.toLowerCase();
52+ const { folio, versions } = loaderData;
53+ const navigate = useNavigate();
54+ const [chosen, setChosen] = useState<string | null>(versions[0]?.id ?? null);
55+ const [detail, setDetail] = useState<FolioVersionDetail | null>(null);
56+ const [error, setError] = useState<string | null>(null);
57+ const [restoring, setRestoring] = useState(false);
58+ useEffect(() => {
59+ if (!chosen) return setDetail(null);
60+ setDetail(null);
61+ void foliosQuery<FolioVersionDetail>(slug, { version: chosen, folio: folio.id }).then((r) => (r.ok ? setDetail(r.value) : setError(r.error.message)));
62+ }, [chosen, slug, folio.id]);
63+ const canRestore = canDo(folio.viewer_role, "edit") && !folio.trashed_at;
64+ return (
65+ <div className="mx-auto max-w-6xl">
66+ <Link to={folio.path} className="inline-flex items-center gap-1.5 text-xs text-muted hover:text-fg">
67+ <ArrowLeft size={13} /> Back to <FolioGlyph folio={folio} size={12} /> {folio.title || "Untitled"}
68+ </Link>
69+ <div className="mt-3 flex flex-wrap items-center gap-3">
70+ <h1 className="flex grow items-center gap-2 text-xl font-semibold tracking-tight">
71+ <History size={18} className="text-faint" /> History
72+ </h1>
73+ {canRestore && chosen && versions[0]?.id !== chosen && (
74+ <Button
75+ type="button"
76+ variant="accent"
77+ disabled={restoring}
78+ onClick={async () => {
79+ setRestoring(true);
80+ const done = await foliosRequest(slug, "restore_version", { folio_id: folio.id, version_id: chosen });
81+ setRestoring(false);
82+ if (done.ok) navigate(folio.path);
83+ else setError(done.error.message);
84+ }}
85+ >
86+ <RotateCcw size={14} /> {restoring ? "Restoring…" : "Restore this version"}
87+ </Button>
88+ )}
89+ </div>
90+ <p className="mt-1 text-sm text-muted">Every version, who made it, and what changed. Restoring makes a new version; nothing is lost.</p>
91+ {error && (
92+ <div className="mt-3">
93+ <ErrorText>{error}</ErrorText>
94+ </div>
95+ )}
96+ {versions.length === 0 ? (
97+ <div className="mt-6">
98+ <EmptyState title="No versions yet">Versions are saved as people and agents edit.</EmptyState>
99+ </div>
100+ ) : (
101+ <div className="mt-6 grid gap-4 md:grid-cols-[17rem_minmax(0,1fr)]">
102+ <ul className="space-y-px md:max-h-[calc(100dvh-14rem)] md:overflow-y-auto md:[scrollbar-width:thin]">
103+ {versions.map((v) => (
104+ <li key={v.id}>
105+ <button type="button" onClick={() => setChosen(v.id)} aria-current={chosen === 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"}`}>
106+ <span className="block text-xs font-medium text-fg" suppressHydrationWarning>
107+ {new Date(v.created_at).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}
108+ </span>
109+ <span className="mt-0.5 block text-[0.6875rem] text-faint">{v.note ?? KIND_LABELS[v.kind]}</span>
110+ <span className="mt-1 flex items-center gap-1">
111+ {v.authors.slice(0, 4).map((a) => (
112+ <Face key={`${a.kind}:${a.id}`} who={a} size={14} />
113+ ))}
114+ <span className="ml-1 truncate text-[0.6875rem] text-muted">{v.authors.map((a) => a.display_name).join(", ")}</span>
115+ </span>
116+ </button>
117+ </li>
118+ ))}
119+ </ul>
120+ <div className="min-w-0 overflow-hidden rounded-xl border border-line bg-surface">
121+ {detail ? (
122+ <div className="max-h-[calc(100dvh-14rem)] overflow-auto py-2 [scrollbar-width:thin]">
123+ <DiffView lines={detail.diff} />
124+ </div>
125+ ) : (
126+ <p className="p-4 text-xs text-faint">{chosen ? "Loading…" : "Choose a version."}</p>
127+ )}
128+ </div>
129+ </div>
130+ )}
131+ </div>
132+ );
133+}
+379−0
1+import { FOLIO_KINDS, isFolioKind, type Folio, type FolioKind, type FolioList } from "@g1t/contracts";
2+import { ChevronDown, LayoutGrid, LayoutTemplate, List, Search, SlidersHorizontal, Users } from "lucide-react";
3+import { type ReactNode, useEffect, useRef, useState } from "react";
4+import { Form, Link, data, useNavigation, useSearchParams } from "react-router";
5+
6+import type { Route } from "./+types/home";
7+
8+import { foliosQuery, useFoliosData, useViewerZone } from "../../../components/folios/actions";
9+import { FOLIO_KIND_UI, KindIcon } from "../../../components/folios/kinds";
10+import { FolioDays, FolioGrid } from "../../../components/folios/list";
11+import { FoliosSidebar } from "../../../components/folios/sidebar";
12+import { BottomSheet } from "../../../components/mobile";
13+import { EmptyState, ErrorText } from "../../../components/ui";
14+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../../../components/ui/dialog";
15+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../../components/ui/dropdown-menu";
16+import { Hint } from "../../../components/ui/hint";
17+import { SelectField } from "../../../components/ui/select";
18+import { TabStrip } from "../../../components/ui/tab-strip";
19+import { canDo, listQuery } from "../../../lib/folios";
20+import { page } from "../../../lib/meta";
21+import { folios, identity, repos } from "../../../lib/services.server";
22+import { requireUser, roleIn } from "../../../lib/session.server";
23+
24+export function meta({ params, ...args }: Route.MetaArgs) {
25+ return page(args, { title: `Artifacts · ${params.owner} · g1t` });
26+}
27+
28+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ list: FolioList | null; projects: string[] }> {
29+ const viewer = requireUser(context, request);
30+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
31+ const slug = params.owner.toLowerCase();
32+ const search = new URL(request.url).searchParams;
33+ const query = listQuery(search);
34+ // The owner filter is a username in the address; the service keys people by id.
35+ const ownerName = search.get("owner");
36+ if (ownerName) {
37+ const user = await identity.userByUsername(ownerName.toLowerCase()).catch(() => null);
38+ query.owner = user ? `user:${user.id}` : "user:nobody";
39+ }
40+ const [list, projects] = await Promise.all([
41+ folios
42+ .list(slug, viewer, query)
43+ .then((r) => (r.ok ? r.value : null))
44+ .catch(() => null),
45+ repos
46+ .list(viewer, { namespace: slug })
47+ .then((found) => found.filter((r) => !r.forkOf).map((r) => `${r.namespace}/${r.name}`.toLowerCase()))
48+ .catch(() => [] as string[]),
49+ ]);
50+ return { list, projects };
51+}
52+
53+const TABS = [
54+ { key: "all", label: "All" },
55+ { key: "yours", label: "Yours" },
56+ { key: "shared", label: "Shared with you" },
57+] as const;
58+
59+const EMPTY: Record<string, { title: string; body: string }> = {
60+ all: { title: "No artifacts yet", body: "Start a doc above. Everything you and your workspace make shows up here." },
61+ yours: { title: "Nothing of yours yet", body: "What you make is yours, in Private until you share it or move it to a space." },
62+ shared: { title: "Nothing shared with you yet", body: "When someone shares an artifact with you, it shows up here." },
63+};
64+
65+/**
66+ * Artifacts' home (docs/ARTIFACTS_MODE.md section 6.2): search and
67+ * filters, All / Yours / Shared with you, tiles to make something new,
68+ * and everything you can open, grouped by the day it was last edited, or
69+ * as cards.
70+ */
71+export default function FoliosHome({ loaderData, params }: Route.ComponentProps) {
72+ const slug = params.owner.toLowerCase();
73+ const { list, projects } = loaderData;
74+ const layout = useFoliosData();
75+ const zone = useViewerZone();
76+ const [search, setSearch] = useSearchParams();
77+ const tab = search.get("tab") === "yours" || search.get("tab") === "shared" ? search.get("tab")! : "all";
78+ const view = search.get("view") === "grid" ? "grid" : "list";
79+ const q = search.get("q") ?? "";
80+ const filtered = ["kind", "space", "owner", "project"].some((k) => search.get(k));
81+ const [items, setItems] = useState<Folio[]>(list?.items ?? []);
82+ const [cursor, setCursor] = useState<string | null>(list?.next_cursor ?? null);
83+ const [more, setMore] = useState(false);
84+ const [error, setError] = useState<string | null>(null);
85+ const [filtersOpen, setFiltersOpen] = useState(false);
86+ const searchBox = useRef<HTMLInputElement>(null);
87+ useEffect(() => {
88+ setItems(list?.items ?? []);
89+ setCursor(list?.next_cursor ?? null);
90+ }, [list]);
91+ // `/` goes to the search box, as everywhere a page has one.
92+ useEffect(() => {
93+ const onKey = (e: KeyboardEvent) => {
94+ const target = e.target as HTMLElement | null;
95+ if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey || target?.closest("input, textarea, [contenteditable=true]")) return;
96+ e.preventDefault();
97+ searchBox.current?.focus();
98+ };
99+ window.addEventListener("keydown", onKey);
100+ return () => window.removeEventListener("keydown", onKey);
101+ }, []);
102+ const href = (changes: Record<string, string | null>) => {
103+ const next = new URLSearchParams(search);
104+ for (const [k, v] of Object.entries(changes)) {
105+ if (v) next.set(k, v);
106+ else next.delete(k);
107+ }
108+ next.delete("cursor");
109+ const s = next.toString();
110+ return s ? `?${s}` : ".";
111+ };
112+ const set = (changes: Record<string, string | null>) =>
113+ setSearch(
114+ (was) => {
115+ const next = new URLSearchParams(was);
116+ for (const [k, v] of Object.entries(changes)) {
117+ if (v) next.set(k, v);
118+ else next.delete(k);
119+ }
120+ next.delete("cursor");
121+ return next;
122+ },
123+ { preventScrollReset: true },
124+ );
125+ const showMore = async () => {
126+ if (!cursor) return;
127+ setMore(true);
128+ const next = new URLSearchParams(search);
129+ next.set("list", "1");
130+ next.set("cursor", cursor);
131+ const page = await foliosQuery<FolioList>(slug, Object.fromEntries(next));
132+ setMore(false);
133+ if (!page.ok) return setError(page.error.message);
134+ setItems((was) => [...was, ...page.value.items.filter((f) => !was.some((w) => w.id === f.id))]);
135+ setCursor(page.value.next_cursor);
136+ };
137+ const spaces = layout?.sidebar?.spaces ?? [];
138+ const filters = (
139+ <>
140+ <SelectField
141+ aria-label="Kind"
142+ value={search.get("kind") ?? "any"}
143+ onValueChange={(v) => set({ kind: v === "any" ? null : v })}
144+ options={[{ value: "any", label: "Any kind" }, ...FOLIO_KINDS.map((k) => ({ value: k, label: FOLIO_KIND_UI[k].label }))]}
145+ className="h-9 min-w-32"
146+ />
147+ <SelectField
148+ aria-label="Space"
149+ value={search.get("space") ?? "any"}
150+ onValueChange={(v) => set({ space: v === "any" ? null : v })}
151+ options={[{ value: "any", label: "Any space" }, { value: "private", label: "Private" }, ...spaces.map((s) => ({ value: s.id, label: s.name }))]}
152+ className="h-9 min-w-32"
153+ />
154+ <SelectField
155+ aria-label="Owner"
156+ value={search.get("owner") ?? "any"}
157+ onValueChange={(v) => set({ owner: v === "any" ? null : v })}
158+ options={[{ value: "any", label: "Anyone" }, ...(layout?.mentionables ?? []).filter((m) => m.kind === "user").map((m) => ({ value: m.name, label: m.name === layout?.me.name ? "You" : m.display_name }))]}
159+ className="h-9 min-w-32"
160+ />
161+ {projects.length > 0 && (
162+ <SelectField
163+ aria-label="Project"
164+ value={search.get("project") ?? "any"}
165+ onValueChange={(v) => set({ project: v === "any" ? null : v })}
166+ options={[{ value: "any", label: "Any project" }, ...projects.map((p) => ({ value: p, label: p }))]}
167+ className="h-9 min-w-40"
168+ />
169+ )}
170+ </>
171+ );
172+ return (
173+ <div className="mx-auto max-w-5xl">
174+ <div className="flex flex-wrap items-center gap-3">
175+ <h1 className="grow text-2xl font-semibold tracking-tight">Artifacts</h1>
176+ <Hint label="Templates">
177+ <Link to={`/${slug}/-/artifacts/templates`} aria-label="Templates" className="flex size-9 items-center justify-center rounded-md border border-line text-muted transition-colors hover:border-line-strong hover:bg-surface hover:text-fg">
178+ <LayoutTemplate size={16} />
179+ </Link>
180+ </Hint>
181+ </div>
182+
183+ <MakeTiles slug={slug} spaces={spaces.filter((s) => canDo(s.viewer_role, "edit")).map((s) => ({ id: s.id, name: s.name }))} />
184+
185+ <div className="mt-8 flex flex-wrap items-center gap-2">
186+ <Form method="get" 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-sm" onSubmit={(e) => (e.preventDefault(), set({ q: (new FormData(e.currentTarget).get("q") as string).trim() || null }))}>
187+ <Search size={15} className="text-faint" aria-hidden="true" />
188+ <input ref={searchBox} key={q} name="q" defaultValue={q} placeholder="Search artifacts" aria-label="Search artifacts" className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint" />
189+ <kbd className="hidden rounded border border-line px-1.5 font-mono text-[0.625rem] text-faint sm:inline">/</kbd>
190+ </Form>
191+ <div className="hidden flex-wrap items-center gap-2 md:flex">{filters}</div>
192+ <button type="button" onClick={() => setFiltersOpen(true)} className={`inline-flex h-9 items-center gap-1.5 rounded-md border px-3 text-sm md:hidden ${filtered ? "border-accent/50 text-accent" : "border-line text-muted"}`}>
193+ <SlidersHorizontal size={14} /> Filters
194+ </button>
195+ <div className="ml-auto hidden items-center rounded-md border border-line p-0.5 md:flex" role="group" aria-label="Show as">
196+ <ViewButton active={view === "list"} label="List" to={href({ view: null })}>
197+ <List size={15} />
198+ </ViewButton>
199+ <ViewButton active={view === "grid"} label="Grid" to={href({ view: "grid" })}>
200+ <LayoutGrid size={15} />
201+ </ViewButton>
202+ </div>
203+ </div>
204+ <BottomSheet open={filtersOpen} onOpenChange={setFiltersOpen} title="Filters">
205+ <div className="flex flex-col gap-2 px-3 pb-4 [&>*]:w-full">{filters}</div>
206+ </BottomSheet>
207+
208+ <TabStrip label="Which artifacts" className="mt-5 gap-1 border-b border-line">
209+ {TABS.map((t) => (
210+ <Link
211+ key={t.key}
212+ to={href({ tab: t.key === "all" ? null : t.key })}
213+ preventScrollReset
214+ data-active={tab === t.key}
215+ aria-current={tab === t.key ? "page" : undefined}
216+ className={`-mb-px flex items-center gap-2 border-b-2 px-3 pb-3 text-sm whitespace-nowrap transition-colors ${tab === t.key ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg"}`}
217+ >
218+ {t.key === "shared" && <Users size={14} />}
219+ {t.label}
220+ </Link>
221+ ))}
222+ </TabStrip>
223+
224+ {error && (
225+ <div className="mt-3">
226+ <ErrorText>{error}</ErrorText>
227+ </div>
228+ )}
229+
230+ <div className="mt-5 mb-8">
231+ {!list ? (
232+ <EmptyState title="Artifacts didn't answer">Try again in a moment.</EmptyState>
233+ ) : items.length === 0 ? (
234+ q || filtered ? (
235+ <EmptyState title="Nothing matches">
236+ <Link to={href({ q: null, kind: null, space: null, owner: null, project: null })} className="text-accent hover:underline">
237+ Clear the search and filters
238+ </Link>
239+ </EmptyState>
240+ ) : (
241+ <EmptyState title={EMPTY[tab]!.title}>{EMPTY[tab]!.body}</EmptyState>
242+ )
243+ ) : view === "grid" ? (
244+ <>
245+ <div className="hidden md:block">
246+ <FolioGrid slug={slug} items={items} onError={setError} />
247+ </div>
248+ <div className="md:hidden">
249+ <FolioDays slug={slug} items={items} zone={zone} onError={setError} />
250+ </div>
251+ </>
252+ ) : (
253+ <FolioDays slug={slug} items={items} zone={zone} onError={setError} />
254+ )}
255+ {cursor && (
256+ <div className="mt-4 flex justify-center">
257+ <button type="button" onClick={showMore} disabled={more} className="inline-flex h-9 items-center rounded-md border border-line px-4 text-sm text-muted hover:border-line-strong hover:text-fg disabled:opacity-60">
258+ {more ? "Loading…" : "Show more"}
259+ </button>
260+ </div>
261+ )}
262+ </div>
263+
264+ {/* On a phone, the sidebar's places below the list: the mode's sheet has them too. */}
265+ <div className="-mx-4 border-t border-line md:hidden">
266+ <div className="h-[60dvh]">
267+ <FoliosSidebar slug={slug} />
268+ </div>
269+ </div>
270+ </div>
271+ );
272+}
273+
274+function ViewButton({ active, label, to, children }: { active: boolean; label: string; to: string; children: ReactNode }) {
275+ return (
276+ <Hint label={label}>
277+ <Link to={to} preventScrollReset aria-label={label} aria-pressed={active} className={`flex size-7 items-center justify-center rounded ${active ? "bg-raised text-fg" : "text-faint hover:text-fg"}`}>
278+ {children}
279+ </Link>
280+ </Hint>
281+ );
282+}
283+
284+/**
285+ * "Make something new": a tile per kind. Docs makes one at once (a form
286+ * post, so it can't go twice), its chevron starts from a template or in a
287+ * space; the kinds still to come say so.
288+ */
289+function MakeTiles({ slug, spaces }: { slug: string; spaces: { id: string; name: string }[] }) {
290+ const navigation = useNavigation();
291+ const starting = navigation.state !== "idle" && navigation.formMethod?.toUpperCase() === "POST" && navigation.formAction?.includes("/-/artifacts/new/");
292+ const [choosing, setChoosing] = useState(false);
293+ return (
294+ <div className="-mx-4 mt-5 flex snap-x gap-3 overflow-x-auto px-4 pb-1 [scrollbar-width:none] sm:mx-0 sm:grid sm:grid-cols-4 sm:overflow-visible sm:px-0">
295+ {FOLIO_KINDS.map((kind) => {
296+ const ui = FOLIO_KIND_UI[kind];
297+ const body = (
298+ <>
299+ <KindIcon kind={kind} size={18} box={36} />
300+ <span className="mt-3 flex items-center gap-1.5 text-sm font-medium text-fg">
301+ {ui.label}
302+ {ui.beta && <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">Beta</span>}
303+ </span>
304+ <span className="mt-0.5 text-xs text-faint">{ui.ready ? (starting ? "Starting…" : tileLine(kind)) : "Coming soon"}</span>
305+ </>
306+ );
307+ if (!ui.ready) {
308+ return (
309+ <Hint key={kind} label={`${COMING[kind]} are coming soon.`}>
310+ <div tabIndex={0} aria-disabled="true" className="flex w-40 shrink-0 snap-start flex-col rounded-xl border border-dashed border-line bg-surface/40 p-4 opacity-60 sm:w-auto">
311+ {body}
312+ </div>
313+ </Hint>
314+ );
315+ }
316+ return (
317+ <div key={kind} className="relative flex w-40 shrink-0 snap-start sm:w-auto">
318+ <Form method="post" action={`/${slug}/-/artifacts/new/${kind}`} className="flex grow">
319+ <button type="submit" disabled={starting} aria-busy={starting} className="flex grow flex-col rounded-xl border border-line bg-surface p-4 text-left transition-colors hover:border-accent/50 hover:bg-raised/40 disabled:opacity-70">
320+ {body}
321+ </button>
322+ </Form>
323+ <DropdownMenu>
324+ <DropdownMenuTrigger asChild>
325+ <button type="button" aria-label={`More ways to make ${ui.label.toLowerCase()}`} className="absolute top-2 right-2 flex size-7 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg">
326+ <ChevronDown size={14} />
327+ </button>
328+ </DropdownMenuTrigger>
329+ <DropdownMenuContent align="end" className="w-48">
330+ <DropdownMenuItem asChild>
331+ <Link to={`/${slug}/-/artifacts/templates?kind=${kind}`}>From a template…</Link>
332+ </DropdownMenuItem>
333+ <DropdownMenuItem disabled={spaces.length === 0} onSelect={() => setChoosing(true)}>
334+ In a space…
335+ </DropdownMenuItem>
336+ </DropdownMenuContent>
337+ </DropdownMenu>
338+ <SpaceChooser slug={slug} kind={kind} spaces={spaces} open={choosing} onOpenChange={setChoosing} />
339+ </div>
340+ );
341+ })}
342+ </div>
343+ );
344+}
345+
346+/** What each kind is for, under its tile. */
347+const TILE_LINES: Record<FolioKind, string> = { doc: "Notes, specs, decisions", slides: "Decks to present", design: "Screens and layouts", dashboard: "Numbers to track" };
348+
349+/** The kinds still to come, as the Hint names them. */
350+const COMING: Record<FolioKind, string> = { doc: "Docs", slides: "Slides", design: "Designs", dashboard: "Dashboards" };
351+
352+function tileLine(kind: FolioKind): string {
353+ return TILE_LINES[kind];
354+}
355+
356+function SpaceChooser({ slug, kind, spaces, open, onOpenChange }: { slug: string; kind: FolioKind; spaces: { id: string; name: string }[]; open: boolean; onOpenChange: (o: boolean) => void }) {
357+ const [space, setSpace] = useState(spaces[0]?.id ?? "");
358+ const navigation = useNavigation();
359+ const busy = navigation.state !== "idle";
360+ if (!isFolioKind(kind)) return null;
361+ return (
362+ <Dialog open={open} onOpenChange={onOpenChange}>
363+ <DialogContent>
364+ <DialogHeader>
365+ <DialogTitle>New {FOLIO_KIND_UI[kind].label.toLowerCase().replace(/s$/, "")} in a space</DialogTitle>
366+ <DialogDescription>It follows the space&apos;s access: everyone in the space can open it.</DialogDescription>
367+ </DialogHeader>
368+ <Form method="post" action={`/${slug}/-/artifacts/new/${kind}`} className="space-y-4">
369+ <SelectField name="space" aria-label="Space" value={space} onValueChange={setSpace} options={spaces.map((s) => ({ value: s.id, label: s.name }))} className="h-9 w-full" />
370+ <div className="flex justify-end">
371+ <button type="submit" disabled={busy || !space} className="inline-flex h-9 items-center rounded-md bg-accent px-3.5 text-sm font-medium text-bg hover:bg-accent-hover disabled:opacity-60">
372+ {busy ? "Starting…" : "Make it"}
373+ </button>
374+ </div>
375+ </Form>
376+ </DialogContent>
377+ </Dialog>
378+ );
379+}
+75−0
1+import { Outlet, data, type ShouldRevalidateFunctionArgs } from "react-router";
2+
3+import { type FoliosSidebar as Sidebar, shownUsername } from "@g1t/contracts";
4+
5+import type { Route } from "./+types/layout";
6+import type { DocMentionable } from "../../../components/folios/doc/editor";
7+import { workspacePeople } from "../../../lib/chat.server";
8+import { readCookie } from "../../../lib/mission";
9+import { folios } from "../../../lib/services.server";
10+import { requireUser, roleIn } from "../../../lib/session.server";
11+import { knownTimeZone } from "../../../lib/time-zone";
12+
13+/**
14+ * Artifacts mode (docs/ARTIFACTS_MODE.md; code says "folio"): what its
15+ * sidebar shows (the shell draws it from this data: favorites, spaces and
16+ * their trees, Private, Shared, projects' docs), who can be mentioned or
17+ * shared with, and the viewer's time zone for the home list's days. Each
18+ * part is empty rather than an error when its service does not answer.
19+ */
20+export type FoliosLayoutData = {
21+ slug: string;
22+ role: string;
23+ me: { key: string; name: string; display_name: string; avatar: string | null };
24+ sidebar: Sidebar | null;
25+ mentionables: DocMentionable[];
26+ /** The viewer's zone, as their browser last told the site (Home sets it); null until then. */
27+ zone: string | null;
28+};
29+
30+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<FoliosLayoutData> {
31+ const viewer = requireUser(context, request);
32+ const role = roleIn(viewer, params.owner);
33+ if (!role) throw data(null, { status: 404 });
34+ const slug = params.owner.toLowerCase();
35+ const [sidebar, { people, agents }] = await Promise.all([
36+ folios
37+ .sidebar(slug, viewer)
38+ .then((r) => (r.ok ? r.value : null))
39+ .catch(() => null),
40+ workspacePeople(slug, viewer).catch(() => ({ people: [], agents: [] })),
41+ ]);
42+ const self = people.find((p) => p.name === viewer.username.toLowerCase());
43+ const zone = readCookie(request.headers.get("cookie"), "g1t_tz");
44+ return {
45+ slug,
46+ role,
47+ me: {
48+ key: `user:${viewer.id}`,
49+ name: viewer.username.toLowerCase(),
50+ display_name: self?.display_name ?? shownUsername({ username: viewer.username, display_username: viewer.display_username ?? null }),
51+ avatar: viewer.avatar ?? null,
52+ },
53+ sidebar,
54+ mentionables: [
55+ ...people.map((p): DocMentionable => ({ kind: "user", id: p.name, name: p.name, display_name: p.display_name, avatar: p.avatar })),
56+ ...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 })),
57+ ],
58+ zone: zone && knownTimeZone(zone) ? zone : null,
59+ };
60+}
61+
62+/**
63+ * The sidebar changes when something is made, renamed, moved, shared or
64+ * trashed (a submission, or a page asking again with `useRevalidator`);
65+ * not when moving between pages.
66+ */
67+export function shouldRevalidate({ currentParams, nextParams, currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
68+ if (formMethod && formMethod !== "GET") return defaultShouldRevalidate;
69+ if (currentUrl.href === nextUrl.href) return defaultShouldRevalidate;
70+ return currentParams.owner !== nextParams.owner;
71+}
72+
73+export default function FoliosLayout() {
74+ return <Outlet />;
75+}
+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+ * An artifact's live socket: `wss://<site>/<workspace>/-/artifacts/live?folio=<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 folio and gives the socket to its room (one Durable
13+ * Object per folio, any kind), 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 Artifacts.", { 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 folio = new URL(request.url).searchParams.get("folio");
25+ if (!folio) return new Response("Which artifact?", { 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?folio=${encodeURIComponent(folio)}&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("artifacts: the live socket could not be handed over", error);
34+ return new Response("Artifacts didn't answer.", { status: 503 });
35+ }
36+}
+67−0
1+import { FOLIO_KIND_NOUNS, isFolioKind, type FolioKind } from "@g1t/contracts";
2+import { Form, data, redirect, useNavigation } from "react-router";
3+
4+import type { Route } from "./+types/new";
5+import { FOLIO_KIND_UI, KindIcon } from "../../../components/folios/kinds";
6+import { ErrorText } from "../../../components/ui";
7+import { page } from "../../../lib/meta";
8+import { folios } from "../../../lib/services.server";
9+import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
10+
11+export function meta({ params, ...args }: Route.MetaArgs) {
12+ return page(args, { title: `New ${isFolioKind(params.kind) ? FOLIO_KIND_NOUNS[params.kind] : "artifact"} · ${params.owner} · g1t` });
13+}
14+
15+/** The kinds that can be made now; the rest aren't found until their phase ships. */
16+function kindOf(value: string | undefined): FolioKind {
17+ if (!isFolioKind(value) || !FOLIO_KIND_UI[value].ready) throw data(null, { status: 404 });
18+ return value;
19+}
20+
21+export async function loader({ params, context, request }: Route.LoaderArgs) {
22+ const viewer = requireUser(context, request);
23+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
24+ return { kind: kindOf(params.kind) };
25+}
26+
27+/**
28+ * Makes an artifact, then goes to it: `POST -/artifacts/new/<kind>` with
29+ * optional `space`, `parent` and `template` (form fields, or the
30+ * address's search). Only a post makes one, so a prefetched or repeated
31+ * link never does; the form's button is busy until it lands.
32+ */
33+export async function action({ params, context, request }: Route.ActionArgs) {
34+ assertSameOrigin(request);
35+ const viewer = requireUser(context, request);
36+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
37+ const kind = kindOf(params.kind);
38+ const form = await request.formData().catch(() => new FormData());
39+ const search = new URL(request.url).searchParams;
40+ const field = (name: string) => String(form.get(name) ?? search.get(name) ?? "").trim() || null;
41+ const made = await folios.create(params.owner.toLowerCase(), viewer, { kind, space_id: field("space"), parent_id: field("parent"), template_id: field("template") });
42+ if (!made.ok) return { error: made.error.message };
43+ throw redirect(made.value.path);
44+}
45+
46+/** Opened as a link (from outside a page that posts): one button, so nothing is made by just visiting. */
47+export default function NewFolio({ loaderData, actionData }: Route.ComponentProps) {
48+ const { kind } = loaderData;
49+ const busy = useNavigation().state !== "idle";
50+ return (
51+ <div className="mx-auto mt-10 flex max-w-md flex-col items-center text-center">
52+ <KindIcon kind={kind} size={22} box={48} />
53+ <h1 className="mt-4 text-xl font-semibold tracking-tight">New {FOLIO_KIND_NOUNS[kind]}</h1>
54+ <p className="mt-1 text-sm text-muted">It starts in your Private, where only you can see it, unless you chose a space.</p>
55+ {actionData?.error && (
56+ <div className="mt-4">
57+ <ErrorText>{actionData.error}</ErrorText>
58+ </div>
59+ )}
60+ <Form method="post" className="mt-6">
61+ <button type="submit" disabled={busy} className="inline-flex h-9 items-center rounded-md bg-accent px-4 text-sm font-medium text-bg hover:bg-accent-hover disabled:opacity-60">
62+ {busy ? "Starting…" : `Start the ${FOLIO_KIND_NOUNS[kind]}`}
63+ </button>
64+ </Form>
65+ </div>
66+ );
67+}
+101−0
1+import type { DocRepoPage } from "@g1t/contracts";
2+import { FolderGit2, GitPullRequestArrow, PencilLine, Trash2 } from "lucide-react";
3+import { Link, data, useNavigate } from "react-router";
4+
5+import type { Route } from "./+types/repo-file";
6+import { useFoliosAction } from "../../../components/folios/actions";
7+import { Crumbs } from "../../../components/folios/parts";
8+import { Markdown } from "../../../components/markdown";
9+import { ErrorText, TimeAgo } from "../../../components/ui";
10+import { Hint } from "../../../components/ui/hint";
11+import { page as pageMeta } 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+ const file = loaded?.found.file;
17+ return pageMeta(args, { title: `${file?.title ?? "Project docs"} · ${params.repoOwner}/${params.repoName} · ${params.owner} · g1t` });
18+}
19+
20+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ found: DocRepoPage }> {
21+ const viewer = requireUser(context, request);
22+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
23+ const repo = `${params.repoOwner}/${params.repoName}`;
24+ const found = await docs.repoPage(params.owner.toLowerCase(), viewer, repo, params["*"] ?? "").catch(() => null);
25+ if (!found?.ok) throw data(null, { status: 404 });
26+ return { found: found.value };
27+}
28+
29+/**
30+ * One file of a project's docs, read-only: rendered as Code renders it,
31+ * with its links and pictures pointing into the repository. Changes go
32+ * through the repository: Edit in Code opens the file there.
33+ */
34+export default function RepoDocFile({ loaderData, params }: Route.ComponentProps) {
35+ const slug = params.owner.toLowerCase();
36+ const { space, file } = loaderData.found;
37+ const navigate = useNavigate();
38+ const { send, error } = useFoliosAction(slug);
39+ const [namespace, name] = space.repo.split("/") as [string, string];
40+ const folder = file.path.includes("/") ? file.path.slice(0, file.path.lastIndexOf("/")) : "";
41+ const encode = (path: string) => path.split("/").map(encodeURIComponent).join("/");
42+ return (
43+ <div className="-mt-6">
44+ <div className="sticky top-(--topbar-h) 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">
45+ <div className="min-w-0 grow">
46+ <Crumbs items={[{ label: "Artifacts", to: `/${slug}/-/artifacts` }, { label: space.repo }, ...file.path.split("/").map((part) => ({ label: part }))]} />
47+ </div>
48+ <Link to={file.code_href} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-line px-2.5 text-xs text-fg/85 hover:bg-raised">
49+ <PencilLine size={13} /> Edit in Code
50+ </Link>
51+ {space.can_remove && (
52+ <Hint label={`Stop showing ${space.repo}'s docs in Artifacts`}>
53+ <button
54+ type="button"
55+ aria-label={`Stop showing ${space.repo}'s docs`}
56+ onClick={async () => {
57+ const done = await send("remove_repo_space", { id: space.id });
58+ if (done.ok) navigate(`/${slug}/-/artifacts`);
59+ }}
60+ className="flex size-8 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-danger"
61+ >
62+ <Trash2 size={15} />
63+ </button>
64+ </Hint>
65+ )}
66+ </div>
67+ <article className="mx-auto max-w-3xl pt-10">
68+ <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-faint">
69+ <span className="inline-flex items-center gap-1.5">
70+ <FolderGit2 size={13} /> {space.repo} · {space.default_branch}
71+ </span>
72+ <span className="font-mono">{file.path}</span>
73+ {space.indexed_at && (
74+ <span>
75+ Read <TimeAgo at={space.indexed_at} />
76+ </span>
77+ )}
78+ </p>
79+ <p className="mt-3 flex items-start gap-2 rounded-lg border border-line bg-surface px-3 py-2 text-xs leading-relaxed text-muted">
80+ <GitPullRequestArrow size={14} className="mt-px shrink-0 text-faint" aria-hidden="true" />
81+ This file lives in the repository and changes through pull requests. Edit it in Code, or ask an agent to open a pull request for it.
82+ </p>
83+ {error && (
84+ <div className="mt-3">
85+ <ErrorText>{error}</ErrorText>
86+ </div>
87+ )}
88+ <div className="docs-read mt-6">
89+ <Markdown
90+ source={file.markdown}
91+ repo={{ namespace, name }}
92+ // Relative links point into the repository at its default
93+ // branch, and pictures at its files there.
94+ base={`/${space.repo}/blob/${encodeURIComponent(space.default_branch)}${folder ? `/${encode(folder)}` : ""}`}
95+ rawBase={`/${space.repo}/raw/${encodeURIComponent(space.commit ?? space.default_branch)}${folder ? `/${encode(folder)}` : ""}`}
96+ />
97+ </div>
98+ </article>
99+ </div>
100+ );
101+}
+44−0
1+import { data, useNavigate } from "react-router";
2+
3+import type { Route } from "./+types/space-new";
4+import { useFoliosAction } from "../../../components/folios/actions";
5+import { SpaceForm, initialSpace, spaceInput } from "../../../components/folios/space-form";
6+import { spacePath } from "../../../lib/folios";
7+import { page } from "../../../lib/meta";
8+import { identity } from "../../../lib/services.server";
9+import { requireUser, roleIn } from "../../../lib/session.server";
10+
11+export function meta({ params, ...args }: Route.MetaArgs) {
12+ return page(args, { title: `New space · Artifacts · ${params.owner} · g1t` });
13+}
14+
15+export async function loader({ params, context, request }: Route.LoaderArgs) {
16+ const viewer = requireUser(context, request);
17+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
18+ const teams = await identity.listTeams(viewer, params.owner.toLowerCase()).catch(() => null);
19+ return { teams: teams?.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] };
20+}
21+
22+/** A new space: for everyone, a team, or only its members. Whoever makes it has full access. */
23+export default function NewSpace({ loaderData, params }: Route.ComponentProps) {
24+ const slug = params.owner.toLowerCase();
25+ const navigate = useNavigate();
26+ const { send, busy, error } = useFoliosAction(slug);
27+ return (
28+ <div className="mx-auto max-w-2xl">
29+ <h1 className="text-xl font-semibold tracking-tight">New space</h1>
30+ <p className="mt-1 mb-8 text-sm text-muted">A space holds artifacts for a team, a project or a topic. What goes in it follows its access.</p>
31+ <SpaceForm
32+ initial={initialSpace()}
33+ teams={loaderData.teams}
34+ submitLabel="Create space"
35+ busy={busy}
36+ error={error}
37+ onSubmit={async (value) => {
38+ const made = await send<{ slug: string }>("create_space", { space: spaceInput(value) });
39+ if (made.ok) navigate(spacePath(slug, made.value.slug));
40+ }}
41+ />
42+ </div>
43+ );
44+}
+182−0
1+import { DOC_ROLES, DOC_ROLE_LABELS, DOC_ROLE_SUMMARIES, type DocRole, type DocSpace, type DocSpaceMember } from "@g1t/contracts";
2+import { Archive, Share2 } 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 { memberKey } from "../../../components/folios/share-dialog";
8+import { useFoliosAction, useFoliosData } from "../../../components/folios/actions";
9+import { Face, SectionTitle } from "../../../components/folios/parts";
10+import { SpaceForm, initialSpace, spaceInput } from "../../../components/folios/space-form";
11+import { Button, ErrorText } from "../../../components/ui";
12+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../../components/ui/alert-dialog";
13+import { SelectField } from "../../../components/ui/select";
14+import { Switch } from "../../../components/ui/switch";
15+import { spacePath } from "../../../lib/folios";
16+import { page } from "../../../lib/meta";
17+import { docs, identity } from "../../../lib/services.server";
18+import { requireUser, roleIn } from "../../../lib/session.server";
19+
20+export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
21+ return page(args, { title: `Settings · ${loaded?.space.name ?? "Space"} · Artifacts · ${params.owner} · g1t` });
22+}
23+
24+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ space: DocSpace; members: DocSpaceMember[]; teams: { slug: string; name: string }[] }> {
25+ const viewer = requireUser(context, request);
26+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
27+ const slug = params.owner.toLowerCase();
28+ const [found, teams] = await Promise.all([docs.space(slug, params.space, viewer), identity.listTeams(viewer, slug).catch(() => null)]);
29+ if (!found.ok) throw data(null, { status: 404 });
30+ if (found.value.space.viewer_role !== "manage") throw data(null, { status: 403 });
31+ return { space: found.value.space, members: found.value.members, teams: teams?.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] };
32+}
33+
34+const ROLE_OPTIONS = DOC_ROLES.map((r) => ({ value: r, label: DOC_ROLE_LABELS[r], description: DOC_ROLE_SUMMARIES[r] }));
35+
36+/** A space's settings: what it is, who it's for, its members and their roles, whether editors can share, and how agents work in it. */
37+export default function SpaceSettings({ loaderData, params }: Route.ComponentProps) {
38+ const slug = params.owner.toLowerCase();
39+ const { space, members, teams } = loaderData;
40+ const layout = useFoliosData();
41+ const navigate = useNavigate();
42+ const settings = useFoliosAction(slug);
43+ const people = useFoliosAction(slug);
44+ const sharing = useFoliosAction(slug);
45+ const [adding, setAdding] = useState("");
46+ const [addRole, setAddRole] = useState<DocRole>("edit");
47+ const [archiving, setArchiving] = useState(false);
48+ const listed = new Set(members.map((m) => m.key));
49+ const candidates = [
50+ ...(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})` })),
51+ ...teams.map((t) => ({ value: `team:${t.slug}`, label: `${t.name} (team)` })),
52+ ].filter((c) => !listed.has(c.value));
53+ return (
54+ <div className="mx-auto max-w-3xl">
55+ <p className="text-xs text-faint">
56+ <Link to={spacePath(slug, space.slug)} className="hover:text-fg">
57+ {space.name}
58+ </Link>{" "}
59+ / Settings
60+ </p>
61+ <h1 className="mt-1 text-xl font-semibold tracking-tight">Space settings</h1>
62+ <div className="mt-8">
63+ <SpaceForm
64+ initial={initialSpace(space)}
65+ teams={teams}
66+ isDefault={space.is_default}
67+ submitLabel="Save"
68+ busy={settings.busy}
69+ error={settings.error}
70+ onSubmit={async (value) => {
71+ const saved = await settings.send<DocSpace>("update_space", { space_id: space.id, space_change: spaceInput(value) });
72+ if (saved.ok && saved.value.slug !== space.slug) navigate(`${spacePath(slug, saved.value.slug)}/settings`, { replace: true });
73+ }}
74+ />
75+ </div>
76+
77+ <section className="mt-12">
78+ <SectionTitle>Sharing</SectionTitle>
79+ <label className="flex items-start gap-3 rounded-xl border border-line bg-surface px-4 py-3">
80+ <Share2 size={16} className="mt-0.5 shrink-0 text-faint" />
81+ <span className="min-w-0 grow">
82+ <span className="block text-sm text-fg">Editors can share</span>
83+ <span className="mt-0.5 block text-xs leading-relaxed text-faint">People who can edit what's in this space can also share it with others, up to Can edit. Off: only people with full access can share.</span>
84+ </span>
85+ <Switch
86+ checked={space.editors_can_share}
87+ disabled={sharing.busy}
88+ onCheckedChange={(on) => sharing.send("update_space", { space_id: space.id, space_change: { editors_can_share: on } })}
89+ aria-label="Editors can share"
90+ />
91+ </label>
92+ {sharing.error && (
93+ <div className="mt-2">
94+ <ErrorText>{sharing.error}</ErrorText>
95+ </div>
96+ )}
97+ </section>
98+
99+ <section className="mt-12">
100+ <SectionTitle>Members</SectionTitle>
101+ <p className="mb-4 text-xs leading-relaxed text-faint">
102+ {space.kind === "private"
103+ ? "Only the people, agents and teams here can open this space."
104+ : "Everyone the space is for already has its base role; add people here to give them more. Workspace owners always have full access."}{" "}
105+ An agent added here still never does more than the person it works for.
106+ </p>
107+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
108+ {members.map((m) => (
109+ <li key={m.key} className="flex items-center gap-3 px-4 py-2.5">
110+ {m.kind === "team" ? (
111+ <span className="flex size-6 items-center justify-center rounded-full bg-raised text-xs">@</span>
112+ ) : (
113+ <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} />
114+ )}
115+ <span className="min-w-0 grow">
116+ <span className="block truncate text-sm">{m.display_name}</span>
117+ <span className="block text-xs text-faint">{m.kind === "team" ? "Team" : m.kind === "agent" ? "Agent" : `@${m.name}`}</span>
118+ </span>
119+ <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" />
120+ <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">
121+ Remove
122+ </button>
123+ </li>
124+ ))}
125+ {members.length === 0 && <li className="px-4 py-3 text-sm text-muted">Nobody added yet.</li>}
126+ </ul>
127+ <div className="mt-3 flex flex-wrap items-center gap-2">
128+ <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" />
129+ <SelectField aria-label="Their role" value={addRole} onValueChange={(r) => setAddRole(r as DocRole)} options={ROLE_OPTIONS} className="h-9 w-40" />
130+ <Button
131+ type="button"
132+ variant="quiet"
133+ disabled={!adding || people.busy}
134+ onClick={async () => {
135+ // People are named by username here; the service keys them by id.
136+ const member = adding.startsWith("username:") ? await memberKey(slug, adding.slice(9)) : adding;
137+ if (!member) return people.setError("That person isn't in the workspace.");
138+ const done = await people.send("set_member", { space_id: space.id, member, role: addRole });
139+ if (done.ok) setAdding("");
140+ }}
141+ >
142+ Add
143+ </Button>
144+ </div>
145+ {people.error && (
146+ <div className="mt-2">
147+ <ErrorText>{people.error}</ErrorText>
148+ </div>
149+ )}
150+ </section>
151+
152+ {!space.is_default && (
153+ <section className="mt-12 rounded-xl border border-danger/30 p-5">
154+ <h2 className="text-sm font-semibold">Archive this space</h2>
155+ <p className="mt-1 text-xs leading-relaxed text-muted">It leaves the sidebar and search. What's in it, its history and comments are kept.</p>
156+ <Button type="button" variant="danger" onClick={() => setArchiving(true)} className="mt-3">
157+ <Archive size={14} /> Archive space
158+ </Button>
159+ </section>
160+ )}
161+ <AlertDialog open={archiving} onOpenChange={setArchiving}>
162+ <AlertDialogContent>
163+ <AlertDialogHeader>
164+ <AlertDialogTitle>Archive {space.name}?</AlertDialogTitle>
165+ <AlertDialogDescription>Nobody will see it in Artifacts until it is brought back.</AlertDialogDescription>
166+ </AlertDialogHeader>
167+ <AlertDialogFooter>
168+ <AlertDialogCancel>Keep it</AlertDialogCancel>
169+ <AlertDialogAction
170+ onClick={async () => {
171+ const done = await settings.send("update_space", { space_id: space.id, space_change: { archived: true } });
172+ if (done.ok) navigate(`/${slug}/-/artifacts`);
173+ }}
174+ >
175+ Archive
176+ </AlertDialogAction>
177+ </AlertDialogFooter>
178+ </AlertDialogContent>
179+ </AlertDialog>
180+ </div>
181+ );
182+}
+100−0
1+import { DOC_ROLE_LABELS, type DocSpace, type FolioList } from "@g1t/contracts";
2+import { FilePlus2, Settings } from "lucide-react";
3+import { useState } from "react";
4+import { Form, Link, data, useNavigation } from "react-router";
5+
6+import type { Route } from "./+types/space";
7+import { useFoliosAction, useFoliosData, useViewerZone } from "../../../components/folios/actions";
8+import { FolioDays } from "../../../components/folios/list";
9+import { SpaceIcon, spaceKindLabel } from "../../../components/folios/parts";
10+import { EmptyState, ErrorText } from "../../../components/ui";
11+import { Hint } from "../../../components/ui/hint";
12+import { canDo, spacePath } from "../../../lib/folios";
13+import { page } from "../../../lib/meta";
14+import { docs, folios } 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: `${loaded?.space.name ?? "Space"} · Artifacts · ${params.owner} · g1t`, description: loaded?.space.description ?? undefined });
19+}
20+
21+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ space: DocSpace; list: FolioList | null }> {
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 = await docs.space(slug, params.space, viewer).catch(() => null);
26+ if (!found?.ok || found.value.space.archived_at) throw data(null, { status: 404 });
27+ const list = await folios
28+ .list(slug, viewer, { tab: "all", space_id: found.value.space.id, limit: 100 })
29+ .then((r) => (r.ok ? r.value : null))
30+ .catch(() => null);
31+ return { space: found.value.space, list };
32+}
33+
34+/** A space: who it's for, joining it (an open one), making a doc in it, and everything in it the viewer can open, by day. */
35+export default function SpacePage({ loaderData, params }: Route.ComponentProps) {
36+ const slug = params.owner.toLowerCase();
37+ const { space, list } = loaderData;
38+ const layout = useFoliosData();
39+ const zone = useViewerZone();
40+ const { send, busy, error } = useFoliosAction(slug);
41+ const [failed, setFailed] = useState<string | null>(null);
42+ const starting = useNavigation().state !== "idle";
43+ const joined = (layout?.sidebar?.spaces ?? []).some((s) => s.id === space.id);
44+ const joinable = space.kind === "workspace" && !space.is_default;
45+ const who = space.kind === "workspace" ? `Everyone in the 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 it` : "Only its members";
46+ return (
47+ <div className="mx-auto max-w-5xl">
48+ <div className="flex flex-wrap items-start gap-4">
49+ <span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-raised text-2xl">
50+ <SpaceIcon space={space} size={22} />
51+ </span>
52+ <div className="min-w-0 grow">
53+ <h1 className="truncate text-2xl font-semibold tracking-tight">{space.name}</h1>
54+ <p className="mt-1 text-sm text-muted">{space.description || "No description."}</p>
55+ <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-faint">
56+ <span className="rounded-full px-2 py-0.5 ring-1 ring-line">{spaceKindLabel(space.kind)}</span>
57+ <span>{who}</span>
58+ <span>Agents here: {space.agent_mode === "edit" ? "edit directly" : "suggest changes"}</span>
59+ </p>
60+ </div>
61+ <div className="flex items-center gap-2">
62+ {joinable && (
63+ <button type="button" disabled={busy} onClick={() => send(joined ? "leave_space" : "join_space", { space_id: space.id })} className="inline-flex h-9 items-center rounded-md border border-line px-3 text-sm text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg disabled:opacity-60">
64+ {joined ? "Leave" : "Join"}
65+ </button>
66+ )}
67+ {canDo(space.viewer_role, "manage") && (
68+ <Hint label="Space settings">
69+ <Link to={`${spacePath(slug, space.slug)}/settings`} aria-label="Space settings" className="flex size-9 items-center justify-center rounded-md border border-line text-muted hover:border-line-strong hover:bg-surface hover:text-fg">
70+ <Settings size={16} />
71+ </Link>
72+ </Hint>
73+ )}
74+ {canDo(space.viewer_role, "edit") && (
75+ <Form method="post" action={`/${slug}/-/artifacts/new/doc`}>
76+ <input type="hidden" name="space" value={space.id} />
77+ <button type="submit" disabled={starting} 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 disabled:opacity-60">
78+ <FilePlus2 size={15} /> {starting ? "Starting…" : "New doc"}
79+ </button>
80+ </Form>
81+ )}
82+ </div>
83+ </div>
84+ {(error || failed) && (
85+ <div className="mt-3">
86+ <ErrorText>{error ?? failed}</ErrorText>
87+ </div>
88+ )}
89+ <div className="mt-8 mb-8">
90+ {!list ? (
91+ <EmptyState title="Artifacts didn't answer">Try again in a moment.</EmptyState>
92+ ) : list.items.length === 0 ? (
93+ <EmptyState title="Nothing here yet">{canDo(space.viewer_role, "edit") ? "Start a doc with New doc, or move something here from its ⋯ menu." : "When someone adds an artifact here, it shows up for you."}</EmptyState>
94+ ) : (
95+ <FolioDays slug={slug} items={list.items} zone={zone} onError={setFailed} />
96+ )}
97+ </div>
98+ </div>
99+ );
100+}
+98−0
1+import type { DocSpace } from "@g1t/contracts";
2+import { Check, Plus } from "lucide-react";
3+import { Link, data } from "react-router";
4+
5+import type { Route } from "./+types/spaces";
6+import { useFoliosAction, useFoliosData } from "../../../components/folios/actions";
7+import { SpaceIcon, spaceKindLabel } from "../../../components/folios/parts";
8+import { EmptyState, ErrorText } from "../../../components/ui";
9+import { spacePath } from "../../../lib/folios";
10+import { page } from "../../../lib/meta";
11+import { docs } from "../../../lib/services.server";
12+import { requireUser, roleIn } from "../../../lib/session.server";
13+
14+export function meta({ params, ...args }: Route.MetaArgs) {
15+ return page(args, { title: `Spaces · Artifacts · ${params.owner} · g1t` });
16+}
17+
18+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ spaces: DocSpace[] | null }> {
19+ const viewer = requireUser(context, request);
20+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
21+ const found = await docs.sidebar(params.owner.toLowerCase(), viewer).catch(() => null);
22+ return { spaces: found?.ok ? found.value.spaces.filter((s) => !s.archived_at).map(({ pages: _pages, ...space }) => space) : null };
23+}
24+
25+/**
26+ * Every space the viewer can open. Open spaces are joined to show in the
27+ * sidebar, so it doesn't fill up; team and members-only spaces show
28+ * there already.
29+ */
30+export default function BrowseSpaces({ loaderData, params }: Route.ComponentProps) {
31+ const slug = params.owner.toLowerCase();
32+ const layout = useFoliosData();
33+ const { send, busy, error } = useFoliosAction(slug);
34+ const shown = new Set((layout?.sidebar?.spaces ?? []).map((s) => s.id));
35+ const spaces = loaderData.spaces;
36+ return (
37+ <div className="mx-auto max-w-4xl">
38+ <div className="flex flex-wrap items-end justify-between gap-4">
39+ <div>
40+ <h1 className="text-xl font-semibold tracking-tight">Spaces</h1>
41+ <p className="mt-1 text-sm text-muted">A space holds artifacts for a team, a project or a topic. Join an open space to see it in your sidebar.</p>
42+ </div>
43+ <Link to={`/${slug}/-/artifacts/spaces/new`} className="inline-flex h-9 items-center gap-1.5 rounded-md border border-line px-3 text-sm text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg">
44+ <Plus size={15} /> New space
45+ </Link>
46+ </div>
47+ {error && (
48+ <div className="mt-3">
49+ <ErrorText>{error}</ErrorText>
50+ </div>
51+ )}
52+ <div className="mt-6">
53+ {spaces === null ? (
54+ <EmptyState title="Artifacts didn't answer">Try again in a moment.</EmptyState>
55+ ) : spaces.length === 0 ? (
56+ <EmptyState title="No spaces yet">Make one for a team, a project or a topic.</EmptyState>
57+ ) : (
58+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
59+ {spaces.map((s) => {
60+ const joined = shown.has(s.id);
61+ const joinable = s.kind === "workspace" && !s.is_default;
62+ return (
63+ <li key={s.id} className="flex items-center gap-3 px-4 py-3">
64+ <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-raised text-base">
65+ <SpaceIcon space={s} size={16} />
66+ </span>
67+ <span className="min-w-0 grow">
68+ <Link to={spacePath(slug, s.slug)} className="block truncate text-sm font-medium text-fg hover:text-accent">
69+ {s.name}
70+ </Link>
71+ <span className="block truncate text-xs text-faint">
72+ {spaceKindLabel(s.kind)}
73+ {s.description ? ` · ${s.description}` : ""}
74+ </span>
75+ </span>
76+ {joinable ? (
77+ <button
78+ type="button"
79+ disabled={busy}
80+ onClick={() => send(joined ? "leave_space" : "join_space", { space_id: s.id })}
81+ className={`inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md px-3 text-xs font-medium disabled:opacity-60 ${joined ? "border border-line text-muted hover:border-danger/40 hover:text-danger" : "bg-accent text-bg hover:bg-accent-hover"}`}
82+ >
83+ {joined ? "Leave" : "Join"}
84+ </button>
85+ ) : (
86+ <span className="inline-flex shrink-0 items-center gap-1 text-xs text-faint">
87+ <Check size={13} /> In your sidebar
88+ </span>
89+ )}
90+ </li>
91+ );
92+ })}
93+ </ul>
94+ )}
95+ </div>
96+ </div>
97+ );
98+}
+49−0
1+import type { Folio } from "@g1t/contracts";
2+import { AlertTriangle } from "lucide-react";
3+import { data } from "react-router";
4+
5+import type { Route } from "./+types/stale";
6+import { useViewerZone } from "../../../components/folios/actions";
7+import { FolioDays } from "../../../components/folios/list";
8+import { EmptyState } from "../../../components/ui";
9+import { page } from "../../../lib/meta";
10+import { folios } from "../../../lib/services.server";
11+import { requireUser, roleIn } from "../../../lib/session.server";
12+
13+export function meta({ params, ...args }: Route.MetaArgs) {
14+ return page(args, { title: `Possibly out of date · Artifacts · ${params.owner} · g1t` });
15+}
16+
17+/** The most recently edited artifacts looked through for ones marked possibly out of date. */
18+const LOOK_AT = 100;
19+
20+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ items: Folio[] | null }> {
21+ const viewer = requireUser(context, request);
22+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
23+ const found = await folios.list(params.owner.toLowerCase(), viewer, { tab: "all", limit: LOOK_AT }).catch(() => null);
24+ return { items: found?.ok ? found.value.items.filter((f) => f.stale) : null };
25+}
26+
27+/** Artifacts whose cited code changed since someone last marked them current. */
28+export default function FoliosStale({ loaderData, params }: Route.ComponentProps) {
29+ const slug = params.owner.toLowerCase();
30+ const zone = useViewerZone();
31+ const { items } = loaderData;
32+ return (
33+ <div className="mx-auto max-w-5xl">
34+ <h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
35+ <AlertTriangle size={18} className="text-warn" /> Possibly out of date
36+ </h1>
37+ <p className="mt-1 text-sm text-muted">A merged pull request or a push to a default branch changed code these artifacts cite. Read what changed, update it (or ask an agent to), then mark it current.</p>
38+ <div className="mt-6">
39+ {items === null ? (
40+ <EmptyState title="Artifacts didn't answer">Try again in a moment.</EmptyState>
41+ ) : items.length === 0 ? (
42+ <EmptyState title="Everything is current">Nothing you can open cites code that changed since it was last checked.</EmptyState>
43+ ) : (
44+ <FolioDays slug={slug} items={items} zone={zone} />
45+ )}
46+ </div>
47+ </div>
48+ );
49+}
+127−0
1+import { FOLIO_KINDS, isFolioKind, type FolioKind, type FolioTemplate } from "@g1t/contracts";
2+import { useState } from "react";
3+import { Form, Link, data, useNavigation, useSearchParams } from "react-router";
4+
5+import type { Route } from "./+types/templates";
6+import { useFoliosAction, useFoliosData } from "../../../components/folios/actions";
7+import { FOLIO_KIND_UI } from "../../../components/folios/kinds";
8+import { SectionTitle, TemplateCard } from "../../../components/folios/parts";
9+import { EmptyState, ErrorText } from "../../../components/ui";
10+import { SelectField } from "../../../components/ui/select";
11+import { canDo } from "../../../lib/folios";
12+import { page } from "../../../lib/meta";
13+import { folios } from "../../../lib/services.server";
14+import { requireUser, roleIn } from "../../../lib/session.server";
15+
16+export function meta({ params, ...args }: Route.MetaArgs) {
17+ return page(args, { title: `Templates · Artifacts · ${params.owner} · g1t` });
18+}
19+
20+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ templates: FolioTemplate[] | null }> {
21+ const viewer = requireUser(context, request);
22+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
23+ const found = await folios.templates(params.owner.toLowerCase(), viewer).catch(() => null);
24+ return { templates: found?.ok ? found.value : null };
25+}
26+
27+/** g1t's templates and the workspace's own, by kind; start an artifact from one in Private or a space you can add to. */
28+export default function FolioTemplates({ loaderData, params }: Route.ComponentProps) {
29+ const slug = params.owner.toLowerCase();
30+ const layout = useFoliosData();
31+ const { send, error } = useFoliosAction(slug);
32+ const [search, setSearch] = useSearchParams();
33+ const kind: FolioKind | null = isFolioKind(search.get("kind")) ? (search.get("kind") as FolioKind) : null;
34+ const writable = (layout?.sidebar?.spaces ?? []).filter((s) => canDo(s.viewer_role, "edit"));
35+ const [space, setSpace] = useState("private");
36+ const navigation = useNavigation();
37+ // The template an artifact is being started from, while it is.
38+ const starting = navigation.state !== "idle" && navigation.formAction?.includes("/-/artifacts/new/") ? String(navigation.formData?.get("template") ?? "") : null;
39+ const all = (loaderData.templates ?? []).filter((t) => !kind || t.kind === kind);
40+ const builtin = all.filter((t) => t.builtin);
41+ const own = all.filter((t) => !t.builtin);
42+ const startButton = (t: FolioTemplate) =>
43+ FOLIO_KIND_UI[t.kind].ready ? (
44+ <Form method="post" action={`/${slug}/-/artifacts/new/${t.kind}`}>
45+ <input type="hidden" name="template" value={t.id} />
46+ {space !== "private" && <input type="hidden" name="space" value={space} />}
47+ <button type="submit" disabled={starting != null} aria-busy={starting === t.id} className="text-xs font-medium text-accent hover:underline disabled:text-faint disabled:no-underline">
48+ {starting === t.id ? "Starting…" : "Use template"}
49+ </button>
50+ </Form>
51+ ) : (
52+ <span className="text-xs text-faint">Coming soon</span>
53+ );
54+ return (
55+ <div className="mx-auto max-w-5xl">
56+ <div className="flex flex-wrap items-end justify-between gap-4">
57+ <div>
58+ <h1 className="text-xl font-semibold tracking-tight">Templates</h1>
59+ <p className="mt-1 text-sm text-muted">Start with the structure already there. Save any artifact as a template from its ⋯ menu.</p>
60+ </div>
61+ <div className="flex flex-wrap items-center gap-2">
62+ <SelectField
63+ aria-label="Kind"
64+ value={kind ?? "any"}
65+ onValueChange={(v) => setSearch(v === "any" ? {} : { kind: v }, { preventScrollReset: true })}
66+ options={[{ value: "any", label: "Every kind" }, ...FOLIO_KINDS.map((k) => ({ value: k, label: FOLIO_KIND_UI[k].label }))]}
67+ className="h-9 min-w-32"
68+ />
69+ <label className="flex items-center gap-2 text-sm text-muted">
70+ Goes in
71+ <SelectField aria-label="Where it goes" value={space} onValueChange={setSpace} options={[{ value: "private", label: "Private" }, ...writable.map((s) => ({ value: s.id, label: s.name }))]} className="h-9 min-w-36" />
72+ </label>
73+ </div>
74+ </div>
75+ {error && (
76+ <div className="mt-3">
77+ <ErrorText>{error}</ErrorText>
78+ </div>
79+ )}
80+ {loaderData.templates === null ? (
81+ <div className="mt-8">
82+ <EmptyState title="Artifacts didn't answer">Try again in a moment.</EmptyState>
83+ </div>
84+ ) : (
85+ <>
86+ <section className="mt-8">
87+ <SectionTitle>From g1t</SectionTitle>
88+ {builtin.length === 0 ? (
89+ <p className="text-sm text-muted">
90+ None for {kind ? FOLIO_KIND_UI[kind].label.toLowerCase() : "this kind"} yet. <Link to="?" className="text-accent hover:underline">See every kind</Link>
91+ </p>
92+ ) : (
93+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
94+ {builtin.map((t) => (
95+ <TemplateCard key={t.id} template={t} action={startButton(t)} />
96+ ))}
97+ </div>
98+ )}
99+ </section>
100+ <section className="mt-10 mb-6">
101+ <SectionTitle>Your workspace&apos;s</SectionTitle>
102+ {own.length === 0 ? (
103+ <p className="text-sm text-muted">None yet. Open an artifact, then choose Save as template from its ⋯ menu.</p>
104+ ) : (
105+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
106+ {own.map((t) => (
107+ <TemplateCard
108+ key={t.id}
109+ template={t}
110+ action={
111+ <>
112+ {startButton(t)}
113+ <button type="button" onClick={() => send("delete_template", { template_id: t.id })} className="ml-auto text-xs text-faint hover:text-danger">
114+ Delete
115+ </button>
116+ </>
117+ }
118+ />
119+ ))}
120+ </div>
121+ )}
122+ </section>
123+ </>
124+ )}
125+ </div>
126+ );
127+}
+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 { folios } 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 doc's live document, where
12+ * the folio'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 doc 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 folios.thread(slug, viewer, params.folio, 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("artifacts threads:", error);
67+ return Response.json({ ok: false, error: { code: "conflict", message: "Artifacts didn't answer." } }, { status: 503 });
68+ }
69+}
+92−0
1+import type { Folio } 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 { useFoliosAction } from "../../../components/folios/actions";
8+import { KindIcon } from "../../../components/folios/kinds";
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 { canDo } from "../../../lib/folios";
12+import { page } from "../../../lib/meta";
13+import { folios } from "../../../lib/services.server";
14+import { requireUser, roleIn } from "../../../lib/session.server";
15+
16+export function meta({ params, ...args }: Route.MetaArgs) {
17+ return page(args, { title: `Trash · Artifacts · ${params.owner} · g1t` });
18+}
19+
20+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ items: Folio[] | null }> {
21+ const viewer = requireUser(context, request);
22+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
23+ const found = await folios.trashed(params.owner.toLowerCase(), viewer).catch(() => null);
24+ return { items: found?.ok ? found.value : null };
25+}
26+
27+/** Artifacts moved to the trash that the viewer can change: restore them, or delete them for good. After 30 days they go on their own. */
28+export default function FoliosTrash({ loaderData, params }: Route.ComponentProps) {
29+ const slug = params.owner.toLowerCase();
30+ const { send, error } = useFoliosAction(slug);
31+ const [forever, setForever] = useState<Folio | null>(null);
32+ const items = loaderData.items;
33+ return (
34+ <div className="mx-auto max-w-3xl">
35+ <h1 className="text-xl font-semibold tracking-tight">Trash</h1>
36+ <p className="mt-1 text-sm text-muted">Artifacts come back with what was inside them. After 30 days in the trash they are deleted for good.</p>
37+ {error && (
38+ <div className="mt-3">
39+ <ErrorText>{error}</ErrorText>
40+ </div>
41+ )}
42+ <div className="mt-6">
43+ {items === null ? (
44+ <EmptyState title="Artifacts didn't answer">Try again in a moment.</EmptyState>
45+ ) : items.length === 0 ? (
46+ <EmptyState title="The trash is empty" />
47+ ) : (
48+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
49+ {items.map((f) => (
50+ <li key={f.id} className="flex items-center gap-3 px-3 py-2.5 sm:px-4">
51+ <KindIcon kind={f.kind} />
52+ <span className="min-w-0 grow">
53+ <span className="block truncate text-sm">{f.title || "Untitled"}</span>
54+ <span className="block truncate text-xs text-faint">
55+ {f.space?.name ?? "Private"} · trashed {f.trashed_at ? <TimeAgo at={f.trashed_at} /> : null}
56+ </span>
57+ </span>
58+ <button type="button" onClick={() => send("restore", { folio_id: f.id })} className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-line px-2.5 text-xs text-fg hover:bg-raised">
59+ <RotateCcw size={13} /> Restore
60+ </button>
61+ {canDo(f.viewer_role, "manage") && (
62+ <button type="button" onClick={() => setForever(f)} className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-danger/40 px-2.5 text-xs text-danger hover:bg-danger/10">
63+ <Trash2 size={13} /> <span className="max-sm:hidden">Delete</span>
64+ </button>
65+ )}
66+ </li>
67+ ))}
68+ </ul>
69+ )}
70+ </div>
71+ <AlertDialog open={!!forever} onOpenChange={(open) => !open && setForever(null)}>
72+ <AlertDialogContent>
73+ <AlertDialogHeader>
74+ <AlertDialogTitle>Delete &ldquo;{forever?.title || "Untitled"}&rdquo; for good?</AlertDialogTitle>
75+ <AlertDialogDescription>It goes with its history, comments and everything inside it. This can&apos;t be undone.</AlertDialogDescription>
76+ </AlertDialogHeader>
77+ <AlertDialogFooter>
78+ <AlertDialogCancel>Keep it</AlertDialogCancel>
79+ <AlertDialogAction
80+ onClick={() => {
81+ if (forever) void send("delete", { folio_id: forever.id });
82+ setForever(null);
83+ }}
84+ >
85+ Delete for good
86+ </AlertDialogAction>
87+ </AlertDialogFooter>
88+ </AlertDialogContent>
89+ </AlertDialog>
90+ </div>
91+ );
92+}
+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 an artifact (an image, a PDF, an attachment):
11+ * `POST <site>/<workspace>/-/artifacts/upload?folio=<id>&name=<file name>`
12+ * with the file as the body. The docs service checks the viewer can edit
13+ * the folio, 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("folio", url.searchParams.get("folio") ?? "");
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("artifacts: upload", error);
42+ return Response.json({ ok: false, error: { code: "conflict", message: "Artifacts didn't answer. Try again." } });
43+ }
44+}
+8−3
673673 // ── The site, and the API acting for a person ───────────────────────
674674 list(workspace: string, viewer: User, query: FolioListQuery): Promise<Result<FolioList>>;
675675 sidebar(workspace: string, viewer: User): Promise<Result<FoliosSidebar>>;
676− /** A folio and the viewer's role in it; records the visit (which is what makes a link folio readable). Not found when they can't read it. */
677− folio(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
676+ /**
677+ * A folio and the viewer's role in it; records the visit (which is what
678+ * makes a link folio readable). Not found when they can't read it. A
679+ * `peek` (chat's card for a link) records nothing, so it finds a link
680+ * folio only once they have opened it, and never one in the trash.
681+ */
682+ folio(workspace: string, viewer: User, folioId: string, options?: { peek?: boolean }): Promise<Result<Folio>>;
678683 /** As `folio`, with what its page shows around it. Records the visit too. */
679684 page(workspace: string, viewer: User, folioId: string): Promise<Result<FolioPage>>;
680685 create(workspace: string, viewer: User, input: NewFolio): Promise<Result<Folio>>;
767772 return {
768773 list: (workspace, viewer, query) => call("folio_list", { workspace, viewer, query }),
769774 sidebar: (workspace, viewer) => call("folio_sidebar", { workspace, viewer }),
770− folio: (workspace, viewer, folioId) => call("folio", { workspace, viewer, folio_id: folioId }),
775+ folio: (workspace, viewer, folioId, options) => call("folio", { workspace, viewer, folio_id: folioId, ...(options?.peek ? { peek: true } : {}) }),
771776 page: (workspace, viewer, folioId) => call("folio_page", { workspace, viewer, folio_id: folioId }),
772777 create: (workspace, viewer, input) => call("create_folio", { workspace, viewer, input }),
773778 update: (workspace, viewer, folioId, change) => call("update_folio", { workspace, viewer, folio_id: folioId, change }),
+12−2
510510 });
511511 }
512512
513− async folio(a: Args & { folio_id: string }): Promise<Result<Folio>> {
513+ /**
514+ * A folio for the viewer. Reading it opens it, which records the visit
515+ * that makes a link folio readable; a `peek` (chat's link card) does
516+ * neither, so a link folio they never opened is not found.
517+ */
518+ async folio(a: Args & { folio_id: string; peek?: boolean | null }): Promise<Result<Folio>> {
514519 const found = await this.ctx(a.workspace, a.viewer);
515520 if (!found.ok) return found;
516521 const ctx = found.value;
517− const opened = await this.open(ctx, a.folio_id, "view", { trashed: true, opening: true });
522+ const peek = a.peek === true;
523+ const opened = await this.open(ctx, a.folio_id, "view", { trashed: !peek, opening: !peek });
518524 if (!opened.ok) return opened;
525+ if (peek) {
526+ const [folio] = await this.toFolios(ctx, [opened.value.row], { roles: new Map([[opened.value.row.id, opened.value.role]]), found: opened.value.found });
527+ return ok(folio!);
528+ }
519529 const at = now();
520530 this.defer(
521531 this.db