diff --git a/README.md b/README.md index fac9441a5..2b7a4d997 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i - **Other maps.** Turn the whole building into a castle: sit on a throne of iron blades while your workers line up before you when they're done, send new ones off through the Hand of the King, and watch their beards grow long and grey as they toil. Send one home and the Kingsguard runs up from the dungeon, marches it down the stairs and throws it in a cell, where it starves, dies and rots down to a skeleton. Or into a space station in orbit, the Earth turning outside its windows: you run it from the captain's chair on the bridge, and a worker sent home is marched to the airlock and blown out into space, to drift off past the observation windows with everyone who went before it. Or make a map of your own, with its own way of seeing workers off in JSON ([docs/maps.md](docs/maps.md)). -There's a lot more (a rooftop bar, an office dog, an arcade, supercars in the garage to drive round a scenic loop past a farm, pines, mountains and a beach): see [docs/features.md](docs/features.md). +There's a lot more (a rooftop bar, an office dog, an arcade, supercars in the garage to drive round a scenic loop past a farm, pines, mountains and a beach): see [docs/features.md](docs/features.md). The bookshelf also renders Mermaid diagrams in project Markdown docs. ## Requirements diff --git a/docs/features.md b/docs/features.md index f320d9b99..72f66155a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -58,7 +58,7 @@ Everything in the office, room by room. Back to the [README](../README.md). - **Services board.** When a worker starts a web server (`npm run dev`, a preview build, `python -m http.server`), it appears within a few seconds on the **🌐 Services** board, which hangs on the wall by the lounge and is also in the **☰** menu. Each entry shows the worker, its branch and the page's title. Click a row to copy one command that opens that server on your own computer. Or run `agent-office tunnel` on your computer once and leave it running: every server a worker starts opens there on the same port, by itself, and closes when the worker stops it ([Workers' servers on your own computer](tunnel.md)). Hire a worker with its own worktree, ask it to run the dev server, and your designer can review the branch in their own browser. - **Pictures on the walls.** Press **F** (or **🖼️ Hang a picture** in the **☰** menu) and paste a link to any image online: a team photo, a diagram, a meme. Pick one of six frames, then aim at a wall and click to hang it. Scroll to size it first. Where it can't go, over a window or a board, the preview turns red. Everyone sees it right away, and it stays up across restarts. Look at a picture and press **E** for a closer look, or to move, edit or take it down. - **A whiteboard to draw on together.** A whiteboard on wheels stands out on the floor between the desks and the lounge. Press **E** at it (or **📝 Whiteboard** in the **☰** menu) and [Excalidraw](https://excalidraw.com) opens, with shapes, arrows, freehand, text and pasted pictures. Everyone on the floor who opens it draws on the same board at once: you see their strokes as they draw, their cursors with their names, and what they have selected. The top of the window shows who's drawing, and so does the hint at the board. Whatever is drawn shows on the whiteboard in the office for everyone walking past, and stays there across restarts. Each floor has its own. Press Esc once to finish what you're doing and again to close it. -- **A bookshelf of the project's docs.** A bookshelf stands against the south wall, between the balcony doors and the window. Press **E** at it to read every Markdown file in the project: the README, the docs folder, each package's notes. Type in the filter box to narrow the list down. The letters only need to be in order, so `gs` finds `getting-started.md`, and each extra word narrows it again. Use **↑ ↓** and **Enter** to open one, and **Page Up/Down** to read on without leaving the box. Docs look the way GitHub shows them, with tables, code, callouts, task lists and the project's own pictures. A link to another doc opens it right there, **☰ Contents** jumps to any heading, and the shelf reopens on the doc you last read. While you read, your character holds an open book, turning a page now and then. Everyone else sees it, along with what you're reading under your name tag. A page turns with a soft swish when you open a doc or scroll a screenful. Turn the swish off with the 🔈 at the top of the bookshelf, or under **⚙️ → Sound & voice**. +- **A bookshelf of the project's docs.** A bookshelf stands against the south wall, between the balcony doors and the window. Press **E** at it to read every Markdown file in the project: the README, the docs folder, each package's notes. Type in the filter box to narrow the list down. The letters only need to be in order, so `gs` finds `getting-started.md`, and each extra word narrows it again. Use **↑ ↓** and **Enter** to open one, and **Page Up/Down** to read on without leaving the box. Docs look the way GitHub shows them, with tables, code, callouts, task lists, the project's own pictures, and rendered Mermaid diagrams. Mermaid stays optional and is loaded only for a document that contains a Mermaid code block; invalid diagrams keep their source visible. A link to another doc opens it right there, **☰ Contents** jumps to any heading, and the shelf reopens on the doc you last read. While you read, your character holds an open book, turning a page now and then. Everyone else sees it, along with what you're reading under your name tag. A page turns with a soft swish when you open a doc or scroll a screenful. Turn the swish off with the 🔈 at the top of the bookshelf, or under **⚙️ → Sound & voice**. - **An office dog.** Every floor has a dog. It naps under the desks of workers who are busy, trots after people for a while, sniffs around and hangs out on the lounge rug. When a worker needs input, it drops everything, runs to that desk and barks until someone opens that worker's terminal, so you can see from across the room who's waiting. It stays there until the worker is answered. Walk up and press **E** to pet it, and everyone sees it wag. Name it under **⚙️** → *Office dog*, and pick its breed (a pup, a corgi, a dachshund, a pug, a shiba or a pomeranian) and its coat there too: they're for everyone on that floor. Until someone picks them, each floor's dog is a breed and coat of its own. - **Sent home with a box.** A worker you send home (**X** at its desk) doesn't just vanish. Its light goes out, its face falls, and its things go into a cardboard box: a plant, a framed photo, its mug and a rubber duck. Its laptop snaps shut, and it hops off its chair and trudges out with the box in its arms, out the exit door, down the steps and off along the sidewalk. Floors above the bottom one have no exit door, so there it walks out onto the balcony instead, climbs up on the railing and jumps: its parachute pops open and it circles down onto the lot out front, box and all, then walks off along the sidewalk. The desk is free straight away, so you can hire someone new while the last one is still on the way out. In the castle it goes to the dungeon instead and on the space station out of the airlock, and a map of your own can script its own way of seeing workers off ([Sending workers home](maps.md#sending-workers-home)). - **Voice.** Browser-to-browser WebRTC voice. Volume depends on how close you stand, but people are never fully silent. **V** joins, and once you're in, **V** is push to talk: hold it to talk, and you're muted when you let go. **M** mutes and unmutes. Pick **Push to talk** under **⚙️** to join muted, or keep an open mic. Leave voice from the **☰** menu. diff --git a/package-lock.json b/package-lock.json index e3f643997..7c689d8db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "concurrently": "^10.0.5", "dompurify": "^3.4.16", "marked": "^18.0.14", + "mermaid": "^11.17.2", "playwright-core": "^1.63.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/package.json b/package.json index 66e7bd2e9..fb563d82b 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "concurrently": "^10.0.5", "dompurify": "^3.4.16", "marked": "^18.0.14", + "mermaid": "^11.17.2", "playwright-core": "^1.63.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/client/features/bookshelf/ui.ts b/src/client/features/bookshelf/ui.ts index 091bebec1..10897ce6b 100644 --- a/src/client/features/bookshelf/ui.ts +++ b/src/client/features/bookshelf/ui.ts @@ -1,7 +1,7 @@ import './ui.css'; import { isDocPath, resolveDocLink, type DocFile, type DocList, type DocText } from '../../../shared/docs'; import { clip, h, openModal, setDoing, timeAgo, toast } from '../../ui/dom'; -import { markdownFile } from '../../ui/markdown'; +import { markdownFile, renderMermaidDiagrams } from '../../ui/markdown'; // The bookshelf: every Markdown file in the floor's project, to read without leaving the office. // The filter box over the list picks docs out as you type (the letters in order, not necessarily @@ -322,6 +322,7 @@ export function openBookshelf(deps: ShelfDeps) { const body = doc.text.trim() ? markdownFile(doc.text) : h('div.md', {}, h('p.none', {}, 'This file is empty.')); wire(body, path); page.replaceChildren(body); + void renderMermaidDiagrams(body); const dir = path.slice(0, path.length - nameOf(path).length); crumbs.replaceChildren(dir ? h('span.dir', {}, dir) : '', nameOf(path)); crumbs.title = path; diff --git a/src/client/ui/markdown.css b/src/client/ui/markdown.css index e9bfc34e6..6d1f1d5dd 100644 --- a/src/client/ui/markdown.css +++ b/src/client/ui/markdown.css @@ -15,6 +15,9 @@ .md code { font: 12.5px ui-monospace, 'SF Mono', Menlo, Consolas, monospace; background: #f3ebe0; border-radius: 6px; padding: 1px 5px; } .md pre { background: #1e1f2e; color: #e9ecef; border: 2px solid var(--ink); border-radius: 10px; padding: 12px 14px; overflow: auto; } .md pre code { background: none; padding: 0; color: inherit; font-size: 12.5px; line-height: 1.5; } +.md .mermaid-diagram { margin: 0 0 12px; padding: 12px; overflow: auto; border: 1.5px solid #ddd0bf; border-radius: 10px; background: #fffaf3; } +.md .mermaid-diagram svg { display: block; width: max-content; min-width: 100%; height: auto; margin: 0 auto; } +.md .mermaid-error-note { margin: 0 0 6px; color: var(--muted); font-size: 12px; font-style: italic; } .md blockquote { margin-left: 0; margin-right: 0; padding: 2px 14px; border-left: 4px solid #d9cbb8; color: var(--muted); } .md ul, .md ol { padding-left: 26px; } .md li + li { margin-top: 3px; } diff --git a/src/client/ui/markdown.ts b/src/client/ui/markdown.ts index c8737e225..f8847ab56 100644 --- a/src/client/ui/markdown.ts +++ b/src/client/ui/markdown.ts @@ -9,6 +9,9 @@ import { h } from './dom'; // In issue and PR comments GitHub turns a single newline into a line break, unlike in .md files. const md = new Marked({ gfm: true, breaks: true }); const mdFile = new Marked({ gfm: true }); +let mermaid: Promise | undefined; +let mermaidInitialized = false; +let mermaidId = 0; const purify = DOMPurify(window); purify.addHook('afterSanitizeAttributes', (node) => { @@ -117,6 +120,50 @@ export function markdownFile(src: string): HTMLElement { return el; } +/** Render fenced Mermaid blocks in project docs; ordinary Markdown stays synchronous and lightweight. */ +export async function renderMermaidDiagrams(root: HTMLElement): Promise { + const blocks = [...root.querySelectorAll('pre > code.language-mermaid')]; + if (!blocks.length) return; + + try { + const load = (mermaid ??= import('mermaid').then((module) => module.default)); + const renderer = await load; + if (!mermaidInitialized) { + renderer.initialize({ + startOnLoad: false, + securityLevel: 'strict', + theme: 'default', + htmlLabels: false, + maxTextSize: 100_000, + maxEdges: 500, + suppressErrorRendering: true, + }); + mermaidInitialized = true; + } + + for (const code of blocks) { + const pre = code.parentElement; + if (!pre) continue; + try { + const { svg } = await renderer.render(`agent-office-mermaid-${++mermaidId}`, code.textContent ?? ''); + const figure = h('figure.mermaid-diagram', { role: 'img', 'aria-label': 'Mermaid diagram' }); + figure.append(purify.sanitize(svg, { + RETURN_DOM_FRAGMENT: true, + USE_PROFILES: { svg: true, svgFilters: true }, + FORBID_TAGS: ['script', 'foreignObject'], + FORBID_ATTR: ['onload', 'onclick'], + })); + pre.replaceWith(figure); + } catch { + pre.classList.add('mermaid-error'); + pre.before(h('p.mermaid-error-note', {}, 'This diagram could not be rendered. Its source is shown below.')); + } + } + } catch { + // If Mermaid cannot load, the sanitized fenced source remains readable in the document. + } +} + /** https://github.com/owner/repo from an issue or PR URL. */ export function repoUrlOf(itemUrl: string): string { return itemUrl.replace(/\/(pull|issues)\/\d+.*$/, '');