diff --git a/canvas/canvas.html b/canvas/canvas.html index d9dd0218..99dec487 100644 --- a/canvas/canvas.html +++ b/canvas/canvas.html @@ -2,6 +2,23 @@ + + Super Prototyping diff --git a/canvas/home.html b/canvas/home.html index 244ac6a7..ead562d2 100644 --- a/canvas/home.html +++ b/canvas/home.html @@ -2,6 +2,23 @@ + + diff --git a/canvas/index.html b/canvas/index.html index 3700b0ee..9d647701 100644 --- a/canvas/index.html +++ b/canvas/index.html @@ -2,6 +2,23 @@ + + diff --git a/canvas/server/agent.ts b/canvas/server/agent.ts index 9aa1dee3..a565655d 100644 --- a/canvas/server/agent.ts +++ b/canvas/server/agent.ts @@ -14,7 +14,7 @@ import fs from "node:fs"; import type { IncomingMessage, ServerResponse } from "node:http"; import os from "node:os"; import path from "node:path"; -import { CANVASES } from "./boards.ts"; +import { CANVASES, hashBoards } from "./boards.ts"; import { command, stop } from "./command.ts"; import { AGENT_SKILLS, installSkills } from "./skills.ts"; import { canvasFile, folderOf, sameOrigin } from "./sp.ts"; @@ -102,11 +102,28 @@ export function createAgentServer(options: { // above have moved on. The panel's own copy, not the agent's transcript: Claude Code's and // Codex's files are theirs, change shape between releases, and hold no attachments by number. const keptOf = (id: string) => path.join(workspaces, ".runs", id); - const say = (run: Run, event: string, data: unknown) => + // The boards each live run's project held as it started, so its end can say which it made + // or changed: the panel puts a button under the reply for each, which finds it on the canvas. + // Off the files rather than the tool lines, which cannot say what a generator wrote. + const boardsBefore = new Map }>(); + const say = (run: Run, event: string, data: unknown) => { + const before = event === "end" && boardsBefore.get(run.id); + if (before) { + boardsBefore.delete(run.id); + const made = [...hashBoards(before.dir)] + .filter(([board, hash]) => before.hashes.get(board) !== hash) + .map(([board]) => ({ + board, + status: before.hashes.has(board) ? "updated" : "new", + })) + .sort((a, b) => a.board.localeCompare(b.board)); + data = { ...(data as object), made }; + } fs.appendFileSync( path.join(keptOf(run.id), "events.jsonl"), JSON.stringify(emit(run, event, data)) + "\n", ); + }; const picture = (id: string, name: string, type: string, data: Buffer) => { const file = path.join(keptOf(id), `${name}.${type.slice(6)}`); fs.writeFileSync(file, data); @@ -549,6 +566,7 @@ export function createAgentServer(options: { }; }, ); + const hashes = hashBoards(boards); const c = command( def.bin, def.args({ @@ -576,6 +594,7 @@ export function createAgentServer(options: { dir, }); runs.set(run.id, run); + boardsBefore.set(run.id, { dir: boards, hashes }); record.runs.push(run.id); record.updated = Date.now(); save(record); diff --git a/canvas/server/boards.ts b/canvas/server/boards.ts index c2e4be4a..02cbc7f5 100644 --- a/canvas/server/boards.ts +++ b/canvas/server/boards.ts @@ -3,10 +3,41 @@ * `/__sp/index.json`. The server scans on every request, and the build writes the same shape * into `dist` once. `src/canvasIndex.ts` declares the shape the client reads. */ +import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { svgSignature } from "../src/svgSignature.ts"; +export const boardHash = (file: string) => + createHash("sha1").update(fs.readFileSync(file)).digest("hex"); + +/** + * What each board in a boards directory holds, by `/.html`: a generator writes every + * board on each run, and only the ones whose bytes changed are news. An unreadable or missing + * folder holds none, and a dot-file or a folder named like a board is no board. + */ +export function hashBoards(canvasesDir: string) { + const list = (dir: string) => { + try { + return fs.readdirSync(dir); + } catch { + return []; + } + }; + const hashes = new Map(); + for (const slug of list(canvasesDir)) + for (const name of list(path.join(canvasesDir, slug))) { + const file = path.join(canvasesDir, slug, name); + if ( + name.endsWith(".html") && + !name.startsWith(".") && + fs.statSync(file, { throwIfNoEntry: false })?.isFile() + ) + hashes.set(`${slug}/${name}`.normalize("NFC"), boardHash(file)); + } + return hashes; +} + /** * A project's boards: this folder under it, one subfolder per canvas. The same folder under the * plugin root is the examples every project is shown beside its own. diff --git a/canvas/server/sp.ts b/canvas/server/sp.ts index aa2661bd..f57003e4 100644 --- a/canvas/server/sp.ts +++ b/canvas/server/sp.ts @@ -18,7 +18,9 @@ import { CANVASES, DOCS, IMAGE_MIME, + boardHash, boardIndex, + hashBoards, readDocs, canvasesNamespace, readJson, @@ -75,7 +77,10 @@ export function folderOf( fs.statSync(own, { throwIfNoEntry: false })?.isDirectory() && fs .readdirSync(own) - .some((f) => f === "layout.json" || (!f.startsWith(".") && f.endsWith(".html"))); + .some( + (f) => + f === "layout.json" || (!f.startsWith(".") && f.endsWith(".html")), + ); const example = path.join(examplesDir, slug); return !isCanvas && fs.existsSync(example) ? example : own; } @@ -266,7 +271,9 @@ export function shoot(board: string, size: number[], res: ServerResponse) { // running without it, and Chrome is its own ask. fail( res, - (error as NodeJS.ErrnoException | null)?.code === "ENOENT" ? 503 : 500, + (error as NodeJS.ErrnoException | null)?.code === "ENOENT" + ? 503 + : 500, error ? `could not render the board: ${error.message}` : "refkit wrote no image", @@ -390,7 +397,8 @@ export function createSpServer(options: { .map((b) => ({ ...b, example: true })), ].sort((a, b) => (a.slug < b.slug ? -1 : 1)); const docs = projectDir === undefined ? undefined : readDocs(projectDir); - const title = projectDir === undefined ? undefined : readProjectJson(projectDir).name; + const title = + projectDir === undefined ? undefined : readProjectJson(projectDir).name; return { ...index, boards, project: projectName(), title, docs }; }; @@ -432,7 +440,11 @@ export function createSpServer(options: { updated: Math.max( fs.statSync(dir).mtimeMs, ...canvases.map((c) => c.updated), - ...DOCS.map((doc) => fs.statSync(path.join(dir, doc), { throwIfNoEntry: false })?.mtimeMs ?? 0), + ...DOCS.map( + (doc) => + fs.statSync(path.join(dir, doc), { throwIfNoEntry: false }) + ?.mtimeMs ?? 0, + ), ), canvases, cover: projectCover(boards, json.cover), @@ -527,8 +539,11 @@ export function createSpServer(options: { const size = fs.statSync(file).size; const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? ""); if (range && (range[1] || range[2])) { - const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2])); - const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1; + const start = range[1] + ? Number(range[1]) + : Math.max(0, size - Number(range[2])); + const end = + range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1; if (start > end) { res.setHeader("Content-Range", `bytes */${size}`); return send(416, "range not satisfiable"); @@ -673,7 +688,8 @@ export function createSpServer(options: { try { const { slug, name } = JSON.parse(body || "{}"); if (!SAFE_NAME.test(slug ?? "")) return send(400, "bad canvas name"); - if (typeof name !== "string" || !name.trim()) return send(400, "empty name"); + if (typeof name !== "string" || !name.trim()) + return send(400, "empty name"); if (isExample(slug)) return send(403, READ_ONLY); if (!fs.existsSync(path.join(canvasesDir, slug))) return send(404, `no canvas folder named ${slug}`); @@ -717,7 +733,8 @@ export function createSpServer(options: { if (isExample(slug)) return send(403, READ_ONLY); trash(folder).then( () => send(204, ""), - (error: Error) => send(500, `“${slug}” is still there: ${error.message}`), + (error: Error) => + send(500, `“${slug}” is still there: ${error.message}`), ); }); }); @@ -1017,20 +1034,26 @@ export function createSpServer(options: { res.statusCode = code; res.end(message); }; - if (projectDir === undefined) return send(409, "Open a project to write its documents."); + if (projectDir === undefined) + return send(409, "Open a project to write its documents."); let name: unknown, text: unknown, base: unknown; try { ({ name, text, base } = JSON.parse(body || "{}")); } catch { return send(400, "bad json"); } - if (typeof name !== "string" || !DOCS.includes(name)) return send(400, "not a document"); - if (typeof text !== "string" || typeof base !== "string") return send(400, "no text"); + if (typeof name !== "string" || !DOCS.includes(name)) + return send(400, "not a document"); + if (typeof text !== "string" || typeof base !== "string") + return send(400, "no text"); const file = path.join(projectDir, name); // The text the edit began from, so a rewrite since, an agent's, is not overwritten unseen. try { if (fs.readFileSync(file, "utf8") !== base) - return send(409, `${name} changed since you began editing. Copy your text, then reopen it.`); + return send( + 409, + `${name} changed since you began editing. Copy your text, then reopen it.`, + ); fs.writeFileSync(file, text); } catch (error) { return send(500, String(error)); @@ -1114,9 +1137,10 @@ export function createSpServer(options: { 0, ...list(canvasesDir).map( (had) => - (readJson(path.join(canvasesDir, had, "layout.json")) as - | { order?: number } - | undefined)?.order ?? 0, + ( + readJson(path.join(canvasesDir, had, "layout.json")) as + { order?: number } | undefined + )?.order ?? 0, ), ) + 1; fs.mkdirSync(path.join(canvasesDir, slug)); @@ -1146,15 +1170,7 @@ export function createSpServer(options: { // index says so on its own. const boardKey = (file: string) => path.relative(canvasesDir, file).split(path.sep).join("/").normalize("NFC"); - const boardHash = (file: string) => - createHash("sha1").update(fs.readFileSync(file)).digest("hex"); - const boardHashes = new Map(); - for (const slug of list(canvasesDir)) - for (const name of list(path.join(canvasesDir, slug))) - if (name.endsWith(".html")) { - const file = path.join(canvasesDir, slug, name); - boardHashes.set(boardKey(file), boardHash(file)); - } + const boardHashes = hashBoards(canvasesDir); /** * One settled batch of writes, answered once. @@ -1289,7 +1305,9 @@ export function createSpServer(options: { let docWatcher: fs.FSWatcher | undefined; let docBatch: ReturnType | undefined; if (projectDir !== undefined) { - let names = readDocs(projectDir).map((doc) => doc.name).join(); + let names = readDocs(projectDir) + .map((doc) => doc.name) + .join(); let title = readProjectJson(projectDir).name; docWatcher = fs.watch(projectDir, (_event, name) => { if (!name || ![...DOCS, PROJECT_JSON].includes(name.toString())) return; diff --git a/canvas/src/App.tsx b/canvas/src/App.tsx index 458cb004..fd1623bf 100644 --- a/canvas/src/App.tsx +++ b/canvas/src/App.tsx @@ -1,10 +1,4 @@ -import { - useCallback, - useEffect, - useReducer, - useRef, - useState, -} from "react"; +import { useCallback, useEffect, useReducer, useRef, useState } from "react"; import { Ellipse2d, ImageShapeUtil, @@ -21,6 +15,7 @@ import { renderPlaintextFromRichText, toRichText, type Editor, + type TLPage, type TLPageId, type TLAsset, type TLAssetStore, @@ -94,9 +89,10 @@ import { readCanvasLibrary, refetchBoards, } from "./canvasLibrary"; -import { BOARDS_CHANGED, canvasIndex } from "./canvasIndex"; +import { BOARDS_CHANGED, FOCUS_BOARD, canvasIndex } from "./canvasIndex"; import { lockedOverlayUtils } from "./lockedIndicator"; import { installCanvasComments, readCommentUser } from "./canvasComments"; +import { storedTheme, THEME_KEY } from "./theme"; import { CanvasLinkPaste, agentBoardPaths, @@ -808,7 +804,6 @@ function initializeCanvasLibrary(editor: Editor) { }, })), ); - } // What the pass did not place is what the folder no longer has: a board renamed or removed, @@ -1217,6 +1212,39 @@ export default function App() { if (page) editor.setCurrentPage(page.id); }; + // A board a reply in the chat made or changed, from the button under it: its canvas brought + // forward and the board fitted to it, as a double-click on it would. Heard on the window, which + // holds the chat. + useEffect(() => { + if (!editor) return; + const focus = (event: Event) => { + const board = (event as CustomEvent).detail; + // By its path rather than the library's id: an agent places boards under ids of its own + // (canvas.json). Its own canvas first, where the library puts it. None when it has gone + // since the reply, deleted or renamed by a later one. + const slug = board.split("/")[0]; + const own = (page: TLPage) => + Number(String(page.meta.canvasSlug).normalize("NFC") === slug); + const pages = editor.getPages().sort((a, b) => own(b) - own(a)); + for (const page of pages) + for (const id of editor.getPageShapeIds(page)) { + const shape = editor.getShape(id)!; + if ( + shape.type === CANVAS_FILE_SHAPE_TYPE && + (shape.props as { path: string }).path + .normalize("NFC") + .endsWith(`canvases/${board}`) + ) { + openTab({ kind: "canvas", slug: page.meta.canvasSlug as string }); + zoomToFill(editor, id); + return; + } + } + }; + window.parent.addEventListener(FOCUS_BOARD, focus); + return () => window.parent.removeEventListener(FOCUS_BOARD, focus); + }); + // A layout.json edit moves boards: a row reordered, a label changed, a size override added. // The pass reconciles, so the boards that stay keep their shapes and only what moved is // touched. @@ -1274,9 +1302,14 @@ export default function App() { function handleMount(editor: Editor) { setEditor(editor); - // tldraw's own dark theme, to match the panel's. A dark rail against tldraw's near-white ground - // looks like two apps in one window, and the ground is most of the window. - editor.user.updateUserPreferences({ colorScheme: "dark" }); + // tldraw's scheme is the page's (tokens.css), or a dark rail sits against a near-white ground + // and the window looks like two apps. tldraw's "system" and the page's follow the same OS + // preference. Another page picking one reaches this one as a storage event (theme.ts). + editor.user.updateUserPreferences({ colorScheme: storedTheme() }); + const scheme = (e: StorageEvent) => + e.key === THEME_KEY && + editor.user.updateUserPreferences({ colorScheme: storedTheme() }); + window.addEventListener("storage", scheme); initializeCanvas(editor); // After the library, which is what creates the pages both are keyed to, and content first, // since a comment can be pinned to a shape the person put there. @@ -1300,6 +1333,7 @@ export default function App() { const disposeZoom = installDoubleClickZoom(editor); const disposeHover = installBoardHover(editor); return () => { + window.removeEventListener("storage", scheme); disposeHover(); disposeContent(); disposeComments(); diff --git a/canvas/src/AppShell.tsx b/canvas/src/AppShell.tsx index be943a08..fd62214b 100644 --- a/canvas/src/AppShell.tsx +++ b/canvas/src/AppShell.tsx @@ -30,11 +30,13 @@ import { Onboarding } from "./Onboarding"; declare global { interface Window { - /** The desktop app's own (desktop/preload.ts), absent in a browser: the onboarding's answer - * and its update check, both about the app and not the project. */ + /** The desktop app's own (desktop/preload.ts), absent in a browser: the onboarding's answer, + * its update check and the theme, all about the app and not the project. */ startup?: { agent(id: string): Promise; check(): Promise; + /** The top bar's theme, for the window's own title bar (ThemeSwitcher.tsx). */ + theme(theme: string): void; }; /** The window's side of the frame (here): what the canvas has in front, at what address. */ spShell?: { diff --git a/canvas/src/CanvasStrip.tsx b/canvas/src/CanvasStrip.tsx index 172cc7ae..bbab0838 100644 --- a/canvas/src/CanvasStrip.tsx +++ b/canvas/src/CanvasStrip.tsx @@ -3,7 +3,12 @@ import { CanvasChromeContext } from "./canvasChrome"; import { shortName } from "./canvasLibrary"; import { docsOf, ownCanvases, pageOf, tabFor, tabUrl } from "./canvasTabs"; import { canvasIndex, LAYOUT_CHANGED } from "./canvasIndex"; -import { groundEditable, setGround, useGround } from "./canvasGround"; +import { + DEFAULT_GROUND, + groundEditable, + setGround, + useGround, +} from "./canvasGround"; import { ViewIcon } from "./CanvasTabBar"; import { shareUrl, sheetPageUrl, type CanvasTab } from "./canvasUrl"; import { @@ -205,7 +210,11 @@ export function CanvasStrip() {