From a7e41068419c1240f77f89f6b02648574e8efb88 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Tue, 20 Oct 2026 09:58:01 -0700 Subject: [PATCH 1/2] fix(sink): clone from the remote's refs on a cold start, and support deletes Artifacts can report lastPushAt as null after git pushes, so a sink that started cold (a new or evicted Durable Object) ran git init instead of cloning, built a new root commit, and every push after that was rejected as not a fast-forward. The sink now asks the remote whether the branch exists (listServerRefs) instead of trusting lastPushAt. Commits can also delete files now: a null file in SinkCommit removes that path. The smoke test covers both against a real repo. Co-Authored-By: Claude --- packages/codemode-git/src/index.ts | 2 +- packages/codemode-git/src/sink.ts | 39 +++++++++++++------ .../codemode-git/test/artifacts.smoke.test.ts | 7 +++- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/packages/codemode-git/src/index.ts b/packages/codemode-git/src/index.ts index 4bc1520..91f2c16 100644 --- a/packages/codemode-git/src/index.ts +++ b/packages/codemode-git/src/index.ts @@ -1,5 +1,5 @@ export { GitHistoryConnector, type GitHistoryOptions } from "./connector.js"; -export { ArtifactsSink, type ArtifactsSinkOptions, type GitSink } from "./sink.js"; +export { ArtifactsSink, type ArtifactsSinkOptions, type GitSink, type SinkCommit } from "./sink.js"; export { FILES, TRAILERS, diff --git a/packages/codemode-git/src/sink.ts b/packages/codemode-git/src/sink.ts index cb52ad7..c5ea3f6 100644 --- a/packages/codemode-git/src/sink.ts +++ b/packages/codemode-git/src/sink.ts @@ -3,10 +3,13 @@ import http from "isomorphic-git/http/web"; import type { CommitContent } from "./format.js"; import { MemoryFS } from "./memory-fs.js"; +/** A commit to write: like CommitContent, but a `null` file deletes that path. */ +export type SinkCommit = Omit & { files: Record }; + /** Where commits go. Swappable so the plugin isn't tied to one git host. */ export interface GitSink { /** Commit `content` on top of the branch head and publish it. Returns the commit sha. */ - commit(content: CommitContent): Promise; + commit(content: SinkCommit): Promise; /** Read a file at a ref, or null if it doesn't exist. */ read(ref: string, path: string): Promise; } @@ -63,7 +66,7 @@ export class ArtifactsSink implements GitSink { }; } - commit(content: CommitContent): Promise { + commit(content: SinkCommit): Promise { const run = this.#queue.then(async () => { try { return await this.#commitOnce(content); @@ -87,9 +90,14 @@ export class ArtifactsSink implements GitSink { } } - async #commitOnce({ message, files, timestamp }: CommitContent): Promise { + async #commitOnce({ message, files, timestamp }: SinkCommit): Promise { const fs = await this.#checkout(); for (const [path, contents] of Object.entries(files)) { + if (contents === null) { + await fs.promises.unlink(`${DIR}/${path}`).catch(() => {}); + await git.remove({ fs, dir: DIR, filepath: path }); + continue; + } await fs.promises.writeFile(`${DIR}/${path}`, contents); await git.add({ fs, dir: DIR, filepath: path }); } @@ -117,9 +125,13 @@ export class ArtifactsSink implements GitSink { if (this.#fs) return this.#fs; const { branch } = this.#options; const fs = new MemoryFS(); - const pushedBefore = await this.#ensureRepo(); - if (pushedBefore) { - const auth = await this.#auth(); + const created = await this.#ensureRepo(); + const auth = await this.#auth(); + // Ask the remote whether the branch exists rather than trusting the repo's + // lastPushAt, which Artifacts can leave null after git pushes: a cold + // start would then init a new root commit and every push would be rejected. + const hasBranch = !created && (await this.#remoteHasBranch(auth)); + if (hasBranch) { await git.clone({ fs, http, @@ -138,7 +150,13 @@ export class ArtifactsSink implements GitSink { return fs; } - /** Make sure the repo exists and we know its remote. Returns whether it has ever been pushed to. */ + async #remoteHasBranch(auth: { username: string; password: string }): Promise { + const ref = `refs/heads/${this.#options.branch}`; + const refs = await git.listServerRefs({ http, url: this.#remote!, prefix: ref, onAuth: () => auth }); + return refs.some((r) => r.ref === ref); + } + + /** Make sure the repo exists and we know its remote. Returns whether it was just created (and so is empty). */ async #ensureRepo(): Promise { const { artifacts, repo: name, description, branch } = this.#options; let repo: ArtifactsRepo; @@ -149,12 +167,11 @@ export class ArtifactsSink implements GitSink { const created = await artifacts.create(name, { description, setDefaultBranch: branch }); this.#remote = created.remote; this.#setToken(created.token); - return false; + return true; } try { - const info = await repo.info(); - this.#remote = info.remote; - return info.lastPushAt !== null; + this.#remote = (await repo.info()).remote; + return false; } finally { repo[Symbol.dispose](); } diff --git a/packages/codemode-git/test/artifacts.smoke.test.ts b/packages/codemode-git/test/artifacts.smoke.test.ts index aff7616..50df9fc 100644 --- a/packages/codemode-git/test/artifacts.smoke.test.ts +++ b/packages/codemode-git/test/artifacts.smoke.test.ts @@ -41,9 +41,14 @@ describe.skipIf(!remote || !token)("ArtifactsSink against a real repo", () => { const sha2 = await first.commit(toCommit(exec("two"), "completed", "smoke two")); expect(sha1).not.toBe(sha2); - state.lastPushAt = new Date().toISOString(); + // Artifacts can leave lastPushAt null after git pushes; a cold sink must still clone. const cold = new ArtifactsSink({ artifacts: fakeBinding(state), repo: "smoke" }); const sha3 = await cold.commit(toCommit(exec("three"), "error", "smoke three")); expect(sha3).toMatch(/^[0-9a-f]{40}$/); + + // A null file deletes it. + const added = await cold.commit({ message: "smoke add", files: { "scratch.txt": "x" }, timestamp: Date.now() }); + const removed = await cold.commit({ message: "smoke delete", files: { "scratch.txt": null }, timestamp: Date.now() }); + expect(removed).not.toBe(added); }, 60_000); }); From de1b5dc22dd236244a13a28a521fb157e1e443db Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Tue, 20 Oct 2026 09:58:08 -0700 Subject: [PATCH 2/2] feat(demo): a slide deck that a code mode agent builds and edits demo/deck is the demo as slides. A deck agent (Code Mode plus codemode-git) keeps one program per slide in a deck-slides Artifacts repo, and the presenter renders slides from it. You chat with the agent to add, change, remove or reorder slides; every write runs the program in a fresh Dynamic Worker, checks the layout, and commits. The agent's own runs go to a deck-agent repo through GitHistoryConnector. Viewing is public. Editing goes through Cloudflare Access on /edit and /api/agent, and the Worker verifies the Access JWT itself. Design tokens live in src/design/tokens.css. npm run pdf saves an offline copy. Co-Authored-By: Claude --- demo/deck/.gitignore | 5 + demo/deck/README.md | 56 +++ demo/deck/index.html | 13 + demo/deck/outline.md | 84 ++++ demo/deck/package.json | 30 ++ demo/deck/scripts/build-slides.mjs | 27 + demo/deck/scripts/pdf.mjs | 27 + demo/deck/src/client/Deck.tsx | 253 ++++++++++ demo/deck/src/client/SlideView.tsx | 158 ++++++ demo/deck/src/client/main.tsx | 7 + demo/deck/src/design/deck.css | 48 ++ demo/deck/src/design/slide.css | 55 +++ demo/deck/src/design/tokens.css | 63 +++ demo/deck/src/design/tokens.ts | 11 + demo/deck/src/outline.ts | 20 + demo/deck/src/spec.ts | 138 ++++++ demo/deck/src/worker/access.ts | 45 ++ demo/deck/src/worker/agent.ts | 205 ++++++++ demo/deck/src/worker/index.ts | 158 ++++++ demo/deck/src/worker/slides.ts | 219 +++++++++ demo/deck/tsconfig.json | 9 + demo/deck/vite.config.ts | 11 + demo/deck/wrangler.jsonc | 29 ++ package-lock.json | 757 ++++++++++++++++++++++++++++- package.json | 3 +- 25 files changed, 2418 insertions(+), 13 deletions(-) create mode 100644 demo/deck/.gitignore create mode 100644 demo/deck/README.md create mode 100644 demo/deck/index.html create mode 100644 demo/deck/outline.md create mode 100644 demo/deck/package.json create mode 100644 demo/deck/scripts/build-slides.mjs create mode 100644 demo/deck/scripts/pdf.mjs create mode 100644 demo/deck/src/client/Deck.tsx create mode 100644 demo/deck/src/client/SlideView.tsx create mode 100644 demo/deck/src/client/main.tsx create mode 100644 demo/deck/src/design/deck.css create mode 100644 demo/deck/src/design/slide.css create mode 100644 demo/deck/src/design/tokens.css create mode 100644 demo/deck/src/design/tokens.ts create mode 100644 demo/deck/src/outline.ts create mode 100644 demo/deck/src/spec.ts create mode 100644 demo/deck/src/worker/access.ts create mode 100644 demo/deck/src/worker/agent.ts create mode 100644 demo/deck/src/worker/index.ts create mode 100644 demo/deck/src/worker/slides.ts create mode 100644 demo/deck/tsconfig.json create mode 100644 demo/deck/vite.config.ts create mode 100644 demo/deck/wrangler.jsonc diff --git a/demo/deck/.gitignore b/demo/deck/.gitignore new file mode 100644 index 0000000..c5301df --- /dev/null +++ b/demo/deck/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +out/ +.wrangler/ +.dev.vars diff --git a/demo/deck/README.md b/demo/deck/README.md new file mode 100644 index 0000000..2224cac --- /dev/null +++ b/demo/deck/README.md @@ -0,0 +1,56 @@ +# Demo deck + +The demo as a slide deck that a Code Mode agent builds and edits. Each slide is a small program in a Cloudflare Artifacts git repo. The presenter renders slides from that repo, and you change them by chatting with the deck agent, which writes code against the repo. + +- **`deck-slides` repo:** `slides/.js` holds the program that draws a slide and `slides/.json` holds what it returned, which is what the presenter renders. `deck.json` holds the slide order, and defaults to [outline.md](outline.md). +- **`deck-agent` repo:** the agent's own Code Mode history. `GitHistoryConnector` commits every program the agent runs, the same as for any codemode-git agent. +- **The agent:** a Workers AI tool-calling loop ([src/worker/agent.ts](src/worker/agent.ts)) around the Code Mode runtime, with a `slides` connector ([src/worker/slides.ts](src/worker/slides.ts)): `outline`, `list`, `read`, `write`, `remove`, `order`, `data`. `slides.write` runs the slide's program in a fresh Dynamic Worker, checks the layout ([src/spec.ts](src/spec.ts)), and commits only when it passes. +- **Slides:** a spec of text, boxes, arrows, agents, charts and code panels on a 1920×1080 canvas, drawn in the demo video's style ([src/client/SlideView.tsx](src/client/SlideView.tsx)). Charts read the real numbers from [../video/src/data.ts](../video/src/data.ts), and speaker notes come from [../video/transcript.md](../video/transcript.md). + +A slide that has a program renders straight from the repo. Only a slide without one shows its outline prompt and waits for you to press **G**. + +## Run it + +It uses the same Artifacts namespace as the example agent. Create the namespace first if you haven't: + +```bash +npx cf artifacts namespaces create --namespace codemode-git +``` + +```bash +npm install # from the repo root: the deck is an npm workspace +npm run dev -w demo/deck +``` + +Open http://localhost:5173. Locally anyone can edit; no sign-in. + +```bash +npm run deploy -w demo/deck +``` + +The deployed deck is public to view at https://deck.clay.gripe. Editing goes through **Cloudflare Access**: an Access app covers `deck.clay.gripe/edit` and `deck.clay.gripe/api/agent` with the "only-me" policy. Press **E** (or click "sign in to edit"), sign in, and you're back on the same slide in edit mode. The Worker also checks the Access JWT itself ([src/worker/access.ts](src/worker/access.ts)): its signature, the app's audience (`ACCESS_AUD` in [wrangler.jsonc](wrangler.jsonc)) and expiry. Without those vars, only localhost can edit. Access can't scope itself to paths on `workers.dev`, which is why the deck needs a hostname on a zone. The `workers.dev` URL stays up as a view-only mirror. + +`npm run build-slides -w demo/deck` asks the agent to build every slide that has no program yet, one message per slide, at high reasoning effort (a few minutes each). It talks to local dev, since the deployed agent is behind Access. Chat edits use low effort, so they take under a minute. + +The local and deployed decks share the `deck-slides` repo, but each keeps its own copy. After committing slides from one, `POST /api/agent/reset` on the other reloads them. Avoid having both write at once: the repo is meant to have one writer. + +## Presenting + +| Key | | +|---|---| +| → / space / click, ← | next, back | +| N | speaker notes and the slide's prompt | +| F | fullscreen | +| E | sign in to edit (Cloudflare Access) | +| C | chat with the deck agent: "make the numbers bigger", "add a slide after this one about pricing", "delete this slide", "move the credits before close" | +| G | have the agent build or rewrite the current slide from its outline prompt | + +The agent adds a slide by writing a program under a new id, removes one with `slides.remove` (the files leave the repo, and git history keeps them), and reorders with `slides.order`. + +## Offline + +`npm run pdf -w demo/deck` saves every built slide to `demo/deck/out/deck.pdf`, one slide per page, from the `?print` view (it needs `npm run dev` running, or set `DECK_URL`). Keep a copy in case the network goes down. + +## Editing the outline + +[outline.md](outline.md) has one `## ` section per slide, and the prompt goes under it. The agent reads it through `slides.outline()`. A changed prompt doesn't rewrite a slide by itself: press **G** on that slide, or ask the agent. diff --git a/demo/deck/index.html b/demo/deck/index.html new file mode 100644 index 0000000..cd68c07 --- /dev/null +++ b/demo/deck/index.html @@ -0,0 +1,13 @@ + + + + + + codemode-git deck + + + +
+ + + diff --git a/demo/deck/outline.md b/demo/deck/outline.md new file mode 100644 index 0000000..c18c4aa --- /dev/null +++ b/demo/deck/outline.md @@ -0,0 +1,84 @@ +# Deck outline + + + +## thesis + +Title slide. Ask the question "What if Cloudflare Artifacts could make Code Mode in Workers self-improving?" over three big lines, with "self-improving?" in amber. Under it, the title "Self-Improving Code Mode Workers, with Artifacts Memory" and the package name codemode-git in mono. + +## hook + +The headline result: DABstep accuracy went from 32.2% to 46.2% with the same model, prompt and 450 tasks. Two huge numbers with an arrow between them, the new one in orange, and the hard-task numbers smaller underneath. The only change was "+ memory". + +## vanish + +Today every Code Mode agent writes code, runs it and throws it away. Show a grid of agents, each with a little code snippet, and the caption "every agent starts from zero". + +## code-mode + +Before and after Code Mode. Left: model and tools with many round-trip arrows, one tool call at a time. Right: the model writes one program (show a few lines of code in a dashed "isolate" box) that calls the tools. + +## gap + +The isolate is thrown away after every run, and the code goes with it. Show the code box fading out and "the code is gone" in red. + +## code-memory + +Phase 1, code memory: four agents (michelle, matthew, lena, omar), each with an arrow to its own Artifacts git repo shown as a row of commit dots. Caption: one repo per agent, one writer per repo, zero merges; 1,000 agents made 1,000 commits in 27s with 0 errors. + +## under-the-hood + +Under the hood, as a left-to-right pipeline of four boxes: execution ends (disposeExecution()), Durable Object (SQLite outbox), isomorphic-git (push, in order), Artifacts repo (one commit per run). Below it, a mono `git clone` line. + +## repo + +Every attempt, in git: a terminal panel with `git log --oneline` showing three commits for "Count flowing lava lamps", the oldest one an error, the newest two completed. + +## recall + +Phase 2, learning: michelle's repo feeds Workers AI (embed question + code), which feeds Vectorize (nearest runs), which hands "unverified examples" to the next agent, matthew. + +## walkthrough + +One real DABstep task (use deck.data("walkthrough")): without memory, 10 programs, 279k tokens, wrong; with memory, 2 programs, 8k tokens, right. Two lanes, one per run, comparing them. + +## provenance + +Provenance: matthew's commit names the exact runs it learned from. A terminal panel with `git show` output that has a `Codemode-Examples:` trailer pointing at agent-michelle@3f90b41. + +## curve + +The learning curve (use deck.data("learningCurve")): median tokens per task by position in the run, baseline vs with memory. A line chart; call out that memory costs about a third of the tokens once it has a few dozen runs. + +## accuracy + +Accuracy by DABstep's official scorer (use deck.data("accuracy")): a bar chart of baseline vs with memory, for Overall and Hard tasks. + +## contagion + +Which memory works: a shared library of verified skills spread one wrong skill to every agent (show a hub with red spokes), while raw runs labeled unverified worked better (separate agents, mostly green). + +## next-steps + +Next steps, as three cards: record outcomes (a git note on each commit), index on push (Artifacts events), and learn between tasks (agents review their own history). + +## new-worker + +Getting started in about a minute: a terminal panel with `npx cf init lava-agent`, `npm install @cloudflare/codemode @smithclay/codemode-git`, the connector plus ARTIFACTS, LOADER and AGENT bindings, then `npx cf deploy --dry-run`. + +## close + +Close: `npm install @smithclay/codemode-git` in green mono, then exactly these three lines as code (don't change them): `const history = new GitHistoryConnector(ctx, env, { sink });`, `const runtime = history.bind(createCodemodeRuntime({`, ` ctx, executor, connectors: [lamps, history] }));`. Then the tagline "Keep the code." in big amber with "Your Workers get smarter over time." under it. + +## credits + +Built by Clay Smith, on Cloudflare. A big name, then the tech stack in three short columns: Code (Workers, Code Mode, Artifacts, Durable Objects, Vectorize), Benchmark (DABstep by Adyen, GLM-5.3 Flash), and Presentation (this deck, written by Code Mode). End with github.com/smithclay/codemode-git. diff --git a/demo/deck/package.json b/demo/deck/package.json new file mode 100644 index 0000000..5debd30 --- /dev/null +++ b/demo/deck/package.json @@ -0,0 +1,30 @@ +{ + "name": "codemode-git-deck", + "private": true, + "type": "module", + "description": "The demo as a slide deck: a Code Mode deck agent keeps one program per slide in an Artifacts repo, the presenter renders from it, and you chat with the agent to change slides.", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "deploy": "vite build && wrangler deploy", + "typecheck": "tsc -p .", + "pdf": "node scripts/pdf.mjs", + "build-slides": "node scripts/build-slides.mjs" + }, + "dependencies": { + "@cloudflare/codemode": "^0.5.3", + "@smithclay/codemode-git": "^0.1.0", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.60.0", + "@cloudflare/workers-types": "^5.20261011.1", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.2.0", + "typescript": "^5.9.0", + "vite": "^7.3.7", + "wrangler": "^4.149.0" + } +} diff --git a/demo/deck/scripts/build-slides.mjs b/demo/deck/scripts/build-slides.mjs new file mode 100644 index 0000000..99b94ff --- /dev/null +++ b/demo/deck/scripts/build-slides.mjs @@ -0,0 +1,27 @@ +// Asks the deck agent to build every slide that has no program yet, one +// message per slide, the same as pressing G on each. Slides that already +// exist are left alone; pass ids to rebuild specific ones. Builds use high +// reasoning effort: slower, but low effort cuts corners on a blank slide. +// +// npm run build-slides (npm run dev in another terminal) +// npm run build-slides -- hook curve (rebuild these) +// It talks to local dev: a deployed deck's agent is behind Cloudflare Access. +// Both write to the same deck-slides repo; reload the deployed deck after. + +const base = (process.env.DECK_URL ?? "http://localhost:5173").replace(/\/$/, ""); +const headers = { "content-type": "application/json" }; +const only = process.argv.slice(2); + +const deck = await (await fetch(`${base}/api/deck`)).json(); +const todo = deck.slides.filter((s) => (only.length ? only.includes(s.id) : !s.slide)); +console.log(`${todo.length} slides to build: ${todo.map((s) => s.id).join(", ") || "none"}`); + +for (const s of todo) { + const started = Date.now(); + const verb = s.slide ? "Rewrite" : "Build"; + const res = await fetch(`${base}/api/agent`, { method: "POST", headers, body: JSON.stringify({ message: `${verb} slide ${s.id} from its outline prompt.`, current: s.id, effort: "high" }) }); + const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + const secs = Math.round((Date.now() - started) / 1000); + if (body.error) console.log(`✗ ${s.id} (${secs}s): ${body.error}`); + else console.log(`${body.changed.includes(s.id) ? "✓" : "·"} ${s.id} (${secs}s, ${body.steps} steps, ${body.tokens} tokens): ${body.reply}`); +} diff --git a/demo/deck/scripts/pdf.mjs b/demo/deck/scripts/pdf.mjs new file mode 100644 index 0000000..d87615a --- /dev/null +++ b/demo/deck/scripts/pdf.mjs @@ -0,0 +1,27 @@ +// Saves the deck as a PDF, one slide per 16:9 page, from the ?print view, for +// presenting offline. Uses a local Chrome in headless mode. +// +// npm run pdf (npm run dev in another terminal) +// DECK_URL=https://… npm run pdf (a deployed deck) +// CHROME=/path/to/chrome npm run pdf + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync } from "node:fs"; + +const base = (process.env.DECK_URL ?? "http://localhost:5173").replace(/\/$/, ""); +const chrome = + process.env.CHROME ?? + ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/usr/bin/google-chrome", "/usr/bin/chromium"].find((p) => existsSync(p)); +if (!chrome) throw new Error("no Chrome found; set CHROME=/path/to/chrome"); + +const out = new URL("../out/deck.pdf", import.meta.url).pathname; +mkdirSync(new URL("../out/", import.meta.url), { recursive: true }); +execFileSync(chrome, [ + "--headless", + "--disable-gpu", + "--no-pdf-header-footer", + "--virtual-time-budget=15000", + `--print-to-pdf=${out}`, + `${base}/?print` +], { stdio: "ignore" }); // Chrome logs harmless GPU noise on stderr; a failure still throws. +console.log(`wrote ${out}`); diff --git a/demo/deck/src/client/Deck.tsx b/demo/deck/src/client/Deck.tsx new file mode 100644 index 0000000..0c76777 --- /dev/null +++ b/demo/deck/src/client/Deck.tsx @@ -0,0 +1,253 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { Turn } from "../worker/agent"; +import type { AgentReply, Deck, DeckSlide } from "../worker/index"; +import { SlideView } from "./SlideView"; + +// The presenter. Slides render from the deck-slides repo. Anyone can view; +// someone signed in through Cloudflare Access (or anyone, in local dev) can +// edit: the chat panel talks to the deck agent, which changes slides with +// Code Mode programs. +// +// Keys: → / space / click next · ← back · N notes · F fullscreen · E sign in +// to edit · C chat · G build/rewrite this slide. Add ?print for a +// one-slide-per-page view to save as PDF (npm run pdf). + +const fetchDeck = () => fetch("/api/deck").then((r) => (r.ok ? (r.json() as Promise) : null)).catch(() => null); +const printing = new URLSearchParams(location.search).has("print"); + +export function DeckApp() { + const [deck, setDeck] = useState(undefined); + useEffect(() => { + void fetchDeck().then(setDeck); + }, []); + if (deck === undefined) return
; + if (!deck) return
Couldn't load /api/deck.
; + return printing ? : ; +} + +/** Every built slide, one per page, without animation: for the PDF. */ +function Print({ deck }: { deck: Deck }) { + useEffect(() => { + document.body.classList.add("print"); + // A hook for scripts/pdf.mjs: printing waits until the slides are drawn. + (window as unknown as { deckReady?: boolean }).deckReady = true; + }, []); + return ( + <> + {deck.slides.filter((s) => s.slide).map((s) => ( +
+ +
+ ))} + + ); +} + +function Presenter({ deck, setDeck }: { deck: Deck; setDeck: (d: Deck) => void }) { + const slides = deck.slides; + const [i, setI] = useState(() => Math.min(Math.max(0, Number(location.hash.slice(1)) - 1 || 0), Math.max(0, slides.length - 1))); + const [notes, setNotes] = useState(false); + const [chatOpen, setChatOpen] = useState(false); + const [toast, setToast] = useState(null); + const [turns, setTurns] = useState([]); + const [working, setWorking] = useState<{ since: number; message: string } | null>(null); + const current: DeckSlide | undefined = slides[i]; + + const say = useCallback((text: string) => { + setToast(text); + setTimeout(() => setToast((t) => (t === text ? null : t)), 4000); + }, []); + + // Editing is on when /api/agent answers. Signed out, Cloudflare Access + // redirects the request to its login page instead (seen here as an opaque + // redirect), and the deck stays view-only. + const [editor, setEditor] = useState(null); + const signIn = useCallback(() => location.assign(`/edit?slide=${i + 1}`), [i]); + useEffect(() => { + void (async () => { + const me = await fetch("/api/agent/me", { redirect: "manual" }).catch(() => null); + if (!me?.ok) return; + setEditor(((await me.json()) as { editor: string }).editor); + const res = await fetch("/api/agent", { redirect: "manual" }).catch(() => null); + if (res?.ok) setTurns((await res.json()) as Turn[]); + })(); + }, []); + + const send = useCallback( + async (message: string, effort: "low" | "high" = "low") => { + if (!editor) return say("Press E to sign in and edit"); + if (working) return say("The agent is still working"); + setWorking({ since: Date.now(), message }); + setTurns((t) => [...t, { role: "user", content: message }]); + try { + const res = await fetch("/api/agent", { + method: "POST", + redirect: "manual", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message, current: current?.id, effort }) + }); + // The Access session expired: Access redirects instead of answering. + if (res.type === "opaqueredirect" || res.status === 401) { + setEditor(null); + setTurns((t) => [...t, { role: "assistant", content: "⚠ Your sign-in expired. Press E to sign in again." }]); + return; + } + const body = (await res.json().catch(() => ({ error: `HTTP ${res.status}` }))) as AgentReply | { error: string }; + if ("error" in body) { + setTurns((t) => [...t, { role: "assistant", content: `⚠ ${body.error}` }]); + return; + } + setTurns((t) => [...t, { role: "assistant", content: body.reply }]); + // Slides may have been added, edited, removed or reordered. + const next = await fetchDeck(); + if (next) setDeck(next); + if (body.changed.length) say(`Changed ${body.changed.join(", ")}`); + } catch (err) { + setTurns((t) => [...t, { role: "assistant", content: `⚠ ${err instanceof Error ? err.message : String(err)}` }]); + } finally { + setWorking(null); + } + }, + [editor, working, current, say, setDeck] + ); + + // Keep the position in range when slides are removed. + useEffect(() => { + if (i > slides.length - 1) setI(Math.max(0, slides.length - 1)); + }, [i, slides.length]); + useEffect(() => { + history.replaceState(null, "", `#${i + 1}`); + }, [i]); + useEffect(() => { + const onHash = () => setI(Math.min(Math.max(0, Number(location.hash.slice(1)) - 1 || 0), Math.max(0, slides.length - 1))); + window.addEventListener("hashchange", onHash); + return () => window.removeEventListener("hashchange", onHash); + }, [slides.length]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + if ((e.target as HTMLElement).closest("textarea, input")) { + if (e.key === "Escape") setChatOpen(false); + return; + } + const k = e.key.toLowerCase(); + if (k === "arrowright" || k === " " || k === "pagedown") setI((n) => Math.min(n + 1, slides.length - 1)); + else if (k === "arrowleft" || k === "pageup") setI((n) => Math.max(n - 1, 0)); + else if (k === "home") setI(0); + else if (k === "end") setI(slides.length - 1); + else if (k === "n") setNotes((v) => !v); + else if (k === "e" && !editor) signIn(); + else if (k === "c") { + if (editor) setChatOpen((v) => !v); + else say("Press E to sign in and edit"); + } + else if (k === "f") void (document.fullscreenElement ? document.exitFullscreen() : document.documentElement.requestFullscreen()); + else if (k === "g" && current) { + if (!editor) return say("Press E to sign in and edit"); + setChatOpen(true); + void send(current.slide ? `Rewrite slide ${current.id} from its outline prompt.` : `Build slide ${current.id} from its outline prompt.`, "high"); + } else return; + e.preventDefault(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [slides.length, current, editor, send, say, signIn]); + + if (!current) return
No slides yet: {editor ? "press C and ask the agent to build the deck." : "sign in (E) to have the agent build it."}
; + + return ( +
setI((n) => Math.min(n + 1, slides.length - 1))}> + {current.slide ? ( + + ) : ( +
+
NOT BUILT YET{editor ? " · PRESS G TO HAVE THE AGENT WRITE IT" : ""}
+

{current.prompt}

+
+ )} +
+ + {i + 1} / {slides.length} + + {current.sha && ( + + {deck.repo}@{current.sha.slice(0, 7)} + + )} + {working && } + {editor ? ( + editing{editor.includes("@") ? ` as ${editor}` : ""} · C chat · G rewrite + ) : ( + e.stopPropagation()} + > + sign in to edit + + )} +
+ {notes && ( + + )} + {chatOpen && editor && } + {toast &&
{toast}
} +
+ ); +} + +function Working({ since }: { since: number }) { + const [, tick] = useState(0); + useEffect(() => { + const id = setInterval(() => tick((n) => n + 1), 1000); + return () => clearInterval(id); + }, []); + return agent working · {Math.floor((Date.now() - since) / 1000)}s; +} + +function Chat({ turns, working, onSend, slide }: { turns: Turn[]; working: boolean; onSend: (m: string) => unknown; slide: string }) { + const [draft, setDraft] = useState(""); + const log = useRef(null); + useEffect(() => { + log.current?.scrollTo(0, log.current.scrollHeight); + }, [turns, working]); + const submit = () => { + const m = draft.trim(); + if (!m || working) return; + onSend(m); + setDraft(""); + }; + return ( +