diff --git a/.claude/skills/verify-berthd/features/README.md b/.claude/skills/verify-berthd/features/README.md index 11a07f637..c59ee3f85 100644 --- a/.claude/skills/verify-berthd/features/README.md +++ b/.claude/skills/verify-berthd/features/README.md @@ -17,6 +17,7 @@ The maintained list of what a person can do in the app, and how to prove each on | Shortcuts in the keyboard's layout (⌘Å and ⌘¨ for Back and Forward on Norwegian) in tooltips, the Keyboard shortcuts sheet, Settings and ⌘K; the sheet's descriptions fit their lines | [navigation-history.md](navigation-history.md) (`history-layout`) | `keyboard-layout`, `keyboard-layout-us`; `app/e2e/back-forward.spec.ts` asserts it | | Stopping agents from the Agent Dashboard: one, the picked ones, a column, Clean up; archiving their worktrees where it's safe | [dashboard-stop.md](dashboard-stop.md) | `dashboard-stop-archive`; `app/e2e/dashboard-stop-archive.spec.ts` asserts it | | Plugins: built-in and your own, what they add (screens, sidebar items, widgets, panels) | [plugins.md](plugins.md) | `plugin-nav-badges` (sidebar items); `plugin-attach-live` (`api.attach`, a real box, `--agent`) | +| Inbox, a listed plugin: what every agent needs from you in one list, answered on its card (forms, permission prompts, Close session), with walk-through, snooze and a peek at the session | [inbox.md](inbox.md) | `inbox-mock`; `inbox-live` (a real box, `--agent`, both drivers) | | Home's widget grid | [home-widgets.md](home-widgets.md) | none yet; `app/e2e/home-widgets.spec.ts` asserts it | | Questions in chat: an agent's form of questions as a card, answered on a real box; kept with Retry and Copy when it can't be | [chat-questions.md](chat-questions.md) | `chat-questions-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` on the mock | | Chat's foot and reply box: a live turn's spinner, timer, Stop and Queue; a turn that ended while its work runs on shows an hourglass and Send | [chat-turn-state.md](chat-turn-state.md) | `chat-turn-state`; `chat-turn-state-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` asserts it | diff --git a/.claude/skills/verify-berthd/features/inbox.md b/.claude/skills/verify-berthd/features/inbox.md new file mode 100644 index 000000000..4ea4fec0a --- /dev/null +++ b/.claude/skills/verify-berthd/features/inbox.md @@ -0,0 +1,71 @@ +# Inbox (listed plugin) + +`plugins/inbox` is a plugin in the listing (`"marketplace": true`), not built in: one list of what every agent needs from the person, grouped by what it needs (decision, action, ready to review or merge, blocked or crashed), with each item's card answering back to the session that asked. It reads what the app already has (sessions, their asks, transcripts, runs) through `@berth/plugin`. `src/model.ts` is the rules, `src/store.ts` talks to the boxes, the rest draws. Stable hooks are its `inbox-*` and `peek-*` test ids. + +## Sub-features + +| Feature | What a person does | Proof | +|---|---|---| +| The list | Opens Inbox, sees the groups, oldest first, with a count equal to the list | `inbox-mock` | +| Moving around | `j`/`k`, click a row. A new item never replaces the card you're on | `inbox-mock`; rule in `plugins/inbox/src/model.test.ts` | +| Walk-through | `w`: one card at a time, "N left", "1 of N" | `inbox-mock` | +| Answering a form | Number keys pick, recommended is marked, Enter sends. Reaches the agent | `inbox-mock` (payload), `inbox-live` (both drivers) | +| A pick with a note | The note goes with the pick as one line, double quotes survive | `inbox-live` (both drivers) | +| Permission prompts | Allow, Always allow, Deny, by key or click. The command runs | `inbox-live` (both drivers) | +| Close session | A few seconds to undo. Nothing unsaved: it stops at once, with no agent turn. Unsaved work: the agent saves it and closes itself | `inbox-mock` (undo, stop), `inbox-live` (clean and form on both drivers; permission on the chat driver, and on the terminal only the deny: see Gotchas) | +| Snooze | `h` then 1/2/3. Leaves the count, shows "N snoozed", returns when due | `inbox-mock`; return in `model.test.ts` | +| Peek | `p`: the session beside the card, following along, work folded into one line that opens on a click. The draft stays | `inbox-mock`, `inbox-live` | +| How a turn ended | An interrupted or failed agent says so on the card and in the peek, whatever its title still claims | `inbox-mock`; rule in `model.test.ts` | +| Drafts | Walking away and back keeps what you wrote | `inbox-mock` | +| Typing is safe | No shortcut fires in the note, so a dictation tool can type there | `inbox-mock` | +| Dismiss | A secret request or a session already gone leaves the list | Not driven yet | +| Agents on their own screen | Trust prompt and similar: shows the screen, offers Open session, no reply box | By hand on a fresh dev box. No scenario yet | +| Run gates | Approve or Reject on the card | Shown in `inbox-mock`. Deciding isn't driven yet | +| Secret requests | Listed as an action, filled in the session | `model.test.ts`. Not driven yet | +| Crashed or exited sessions | Blocked group, Resume | `model.test.ts`. Resume isn't driven yet | +| In the listing | Settings → Plugins → Browse lists Inbox; Install puts it in `~/.berth/plugins/inbox`, off | `pnpm -C app check:marketplace`; `TestTheCommittedListingInstalls` installs every listed plugin | + +## How to get to it (user POV) + +- Settings → Plugins → Browse → Install beside Inbox, then turn it on under Installed. It shows under More, or pinned from More → Customize sidebar. +- The scenarios skip the install: `drive.mjs` loads `plugins/inbox/dist/index.js` as an extra built-in, so build it first. + +## Driving it with drive.mjs + +```sh +(cd plugins/inbox && pnpm install && pnpm test && pnpm build) +node .claude/skills/verify-berthd/scripts/drive.mjs inbox-mock --port $PORT +``` + +`inbox-mock` runs on the mock with `?structured=1`. The mock has no transcripts for a plugin's own requests, so the scenario wraps the plugin's `berth.api.request` with synthetic ones ("acme"), adds one interrupted session whose title still asks, and keeps what the plugin sends on `window.__inboxSent`. It checks the exact answer payload, and that Close stops a clean session with a `DELETE`. + +Real Claude Code, once per driver. It costs a few real turns on the signed-in account: + +```sh +d=/tmp/vb-inbox-$$; export VERIFY_BOX_DIR=$d +for p in 7544 9377 9378; do ss -ltn | grep -q ":$p " && echo "port $p is taken: another dev box is up"; done +env -u BERTH_DRIVER -u BERTH_SESSION .claude/skills/verify-berthd/scripts/dev-box.sh up +eval "$(.claude/skills/verify-berthd/scripts/dev-box.sh env)" # the token stays in env +# Claude can stop at its trust prompt on a fresh folder: if the "ask" session shows it, accept it once. Then remove "ask". +tm() { tmux -L berth "$@"; } +for _ in $(seq 20); do tm capture-pane -p -t ask | grep -qE 'trust this folder|^❯ *$' && break; sleep 1; done +if tm capture-pane -p -t ask | grep -q 'trust this folder'; then + for _ in 1 2 3; do tm capture-pane -p -t ask | grep -q '❯ .*Yes, I trust' && break; tm send-keys -t ask Down; sleep 0.5; done + tm send-keys -t ask Enter +fi +curl -s -X DELETE -H "Authorization: Bearer $VERIFY_BERTH_TOKEN" http://127.0.0.1:9378/v1/boxes/devq/api/sessions/ask +VERIFY_INBOX_DRIVER=terminal node .claude/skills/verify-berthd/scripts/drive.mjs inbox-live --port $PORT --agent http://127.0.0.1:9378 +VERIFY_INBOX_DRIVER=claude node .claude/skills/verify-berthd/scripts/drive.mjs inbox-live --port $PORT --agent http://127.0.0.1:9378 +.claude/skills/verify-berthd/scripts/dev-box.sh down; sleep 3; rm -rf "$d" +``` + +`inbox-live` names the driver (and bypass mode) on every session it starts and checks they have it: the app puts its own agent defaults on the box when it connects, so a scenario that set the box's defaults would race it. On the terminal driver the box ignores `mode` and runs `claude ''`, which takes its permission mode from the machine's Claude settings (often bypass), so the two sessions that must ask, and the one that must not, start from a command naming `--permission-mode default` or `--dangerously-skip-permissions`. Second views: each agent's own transcript (`GET …/sessions//transcript`) says the answer back word for word; an allowed command's file is in the dev box's `acme-shop` and a denied one's isn't; before Close over unsaved work `git status` lists that file, and the agent's transcript gets the Inbox's "commit and push, then close" before the session is gone from `GET …/sessions`. It starts from nothing unsaved: it gives `acme-shop` a local remote (`acme-shop.git` beside it) and commits and pushes whatever an earlier run left, because the Inbox counts commits on no remote as unsaved too. Its files are named per run, so a second driver's run makes fresh unsaved work. It removes its sessions at the end. It doesn't check what the agent chose to commit: that's the agent's call. + +## Gotchas + +- Answers run through the plugin's `answerFor`. A change there needs `inbox-live` on both drivers: a box refuses a single choice with both a pick and words, so a pick with a note goes as one line, and a straight `"` in an answer is cut short on the chat driver unless it's turned into a curly one (the scenario checks `”calm”`). +- The app's floating card for a running loop (devl's "Agent working · Round 2" on the mock) covers the right end of the Inbox's bottom bar, where Close session's **Undo** is. `inbox-mock` presses Undo with Enter on the button, as `dashboard-stop-archive` does for the Worktrees bulk bar. A person with a loop running has to fold the card first: a known gap in the plugin, not in the scenario. +- **Close on a terminal agent's permission prompt doesn't finish.** The Inbox presses Deny, Claude stops at "What should Claude do instead?", and the Inbox's "commit and push, then close" goes with `when: "idle"`. On the terminal driver the box still has the session `waiting` after that interrupt (no hook fires), so the message stays queued (`queued: 1`) and the agent never closes. `inbox-live` checks the deny there (the interrupt in the transcript, no file written) and not the close; on the chat driver it checks both. +- Without sub-states of Needs you on the box, every waiting session sorts as a decision; the scenarios don't depend on the split. +- Live screenshots show only the dev box's synthetic sessions. Never drive it against a real fleet for a PR. +- Run `dev-box.sh` with `BERTH_DRIVER` unset when you are an agent yourself, or its agents inherit your driver. diff --git a/.claude/skills/verify-berthd/scripts/drive.mjs b/.claude/skills/verify-berthd/scripts/drive.mjs index 4917aae96..1722d9e47 100755 --- a/.claude/skills/verify-berthd/scripts/drive.mjs +++ b/.claude/skills/verify-berthd/scripts/drive.mjs @@ -61,8 +61,13 @@ await context.route(/^https?:\/\/(?:[^/]*\.)?(?:localhost|127\.0\.0\.1|\[::1\]): if (scenario.init) await context.addInitScript(scenario.init, scenario.initArg); const page = await context.newPage(); page.on("console", (m) => { - log.push(redact(`[${m.type()}] ${m.text()}`)); - if (m.type() === "error" && !allow.some((re) => re.test(m.text()))) failures.push(redact(`console error: ${m.text()}`)); + // "Failed to load resource" doesn't say which: its address is the + // message's location, so it goes with the text, and allowErrors can name + // one request. + const where = m.text().startsWith("Failed to load resource") ? m.location().url : ""; + const text = where ? `${m.text()} (${where})` : m.text(); + log.push(redact(`[${m.type()}] ${text}`)); + if (m.type() === "error" && !allow.some((re) => re.test(text))) failures.push(redact(`console error: ${text}`)); }); page.on("pageerror", (e) => { log.push(redact(`[pageerror] ${e.message}`)); diff --git a/.claude/skills/verify-berthd/scripts/scenarios/inbox-live.mjs b/.claude/skills/verify-berthd/scripts/scenarios/inbox-live.mjs new file mode 100644 index 000000000..6dede8a1a --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/inbox-live.mjs @@ -0,0 +1,200 @@ +// inbox-live: real Claude Code on a dev box asks with AskUserQuestion. The Inbox +// brings it up, a pick with a note goes back and lands in the agent's own record, +// the peek shows the real session beside the card, and Close session stops a clean +// session at once or has one with unsaved work close itself. Run it once per +// driver: VERIFY_INBOX_DRIVER=terminal (the default) and VERIFY_INBOX_DRIVER=claude +// (chat). See ../../features/inbox.md. +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; + +const dist = new URL("../../../../../plugins/inbox/dist/index.js", import.meta.url); + +export const live = true; +// The Inbox lists a box's secret requests when the box has them +// (GET secrets/requests). A box without that route answers 404, which the +// plugin reads as none; the browser still logs each one. +export const allowErrors = [/^Failed to load resource: .*404.*\/secrets\/requests\)$/]; +export const params = { labs: "0", sidebar: "list" }; +export const plugins = [{ id: "inbox", name: "Inbox", source: readFileSync(dist, "utf8") }]; + +const form = (question, a, b) => ({ questions: [{ header: "Pick", question, multiSelect: false, options: [{ label: `${a} (Recommended)`, description: "The usual one." }, { label: b, description: "The other one." }] }] }); + +const prompt = (f, then) => `Call the AskUserQuestion tool once with exactly this input JSON: ${JSON.stringify(f)}. ${then}`; + +export async function run({ page, shot, step, expect, call }) { + const box = (process.env.VERIFY_WORKTREE ?? "devq/acme-shop").split("/")[0]; + const api = `/v1/boxes/${box}/api`; + const json = { "Content-Type": "application/json" }; + const driver = process.env.VERIFY_INBOX_DRIVER ?? "terminal"; + // Each session names its driver and mode. The box's defaults aren't the + // scenario's to set: the app puts its own on the box when it connects. + // On the terminal the box starts `claude ''` whatever the mode, and + // Claude takes its permission mode from the machine's own settings (bypass + // on many), so a session whose mode matters says it on its command line. + const shq = (text) => `'${text.replaceAll("'", `'\\''`)}'`; + const flag = { default: "--permission-mode default", bypassPermissions: "--dangerously-skip-permissions" }; + const start = (name, title, text, mode) => { + const body = driver === "terminal" && mode ? { command: `claude ${flag[mode]} ${shq(text)}` } : { agent: "claude", prompt: text, driver, mode: mode ?? "bypassPermissions" }; + return call(`${api}/sessions`, { method: "POST", headers: json, body: JSON.stringify({ location: "acme-shop", name, title, ...body }) }); + }; + const lastQuestion = async (name) => { + const t = await call(`${api}/sessions/${name}/transcript?since=0`); + return { items: t.items ?? [], q: (t.items ?? []).filter((i) => i.kind === "question").pop() }; + }; + const row = (title) => page.getByTestId("inbox-item").filter({ hasText: title }); + + const A = { name: "inbox-live-a", title: "Inbox live: header colour" }; + const B = { name: "inbox-live-b", title: "Inbox live: footer colour" }; + + // The dev box's repo, on this machine: what the commands did is read from + // its files. The Inbox counts uncommitted files and commits on no remote as + // unsaved, so the run starts with both at nothing: a local remote, and + // whatever an earlier run left committed and pushed there. + const repo = `${process.env.VERIFY_BOX_DIR ?? "/tmp/verify-berthd-box"}/acme-shop`; + const git = (...args) => execFileSync("git", ["-C", repo, "-c", "user.name=acme", "-c", "user.email=acme@example.com", ...args], { encoding: "utf8" }); + if (!existsSync(`${repo}.git`)) execFileSync("git", ["init", "-q", "--bare", `${repo}.git`]); + if (!git("remote").includes("origin")) git("remote", "add", "origin", `${repo}.git`); + git("add", "-A"); + if (git("status", "--porcelain")) git("commit", "-qm", "left by an earlier run"); + git("push", "-q", "origin", "HEAD:main"); + if (git("status", "--porcelain") || git("log", "--oneline", "HEAD", "--not", "--remotes")) throw new Error("acme-shop should start with nothing unsaved"); + + step("two real agents start, and each asks with a form"); + for (const s of [A, B]) await call(`${api}/sessions/${s.name}`, { method: "DELETE" }).catch(() => {}); + await start(A.name, A.title, prompt(form('Which colour should the acme "header" use?', "Blue harbour", "Red lighthouse"), "After I answer, repeat my answer back word for word on its own line, then stop.")); + await start(B.name, B.title, prompt(form("Which colour should the acme footer use?", "Green quay", "Grey pier"), "After I answer, repeat it back and stop.")); + + const started = (await call(`${api}/sessions`)).find((x) => x.name === A.name); + if ((started.driver === "claude") !== (driver !== "terminal")) throw new Error(`wanted the ${driver} driver, the session has ${started.driver}`); + + step("the Inbox brings them up by itself, under decisions"); + await page.getByTestId("nav-more").click(); + await page.getByRole("menu").getByRole("menuitem", { name: /^Inbox/ }).click(); + await expect(row(A.title)).toHaveCount(1, { timeout: 180_000 }); + await expect(row(B.title)).toHaveCount(1, { timeout: 180_000 }); + await expect(page.getByTestId("inbox-group-decision")).toContainText(A.title); + + step("the real form shows, with its recommended option marked"); + await row(A.title).click(); + const title = page.getByTestId("inbox-card-title"); + await expect(title).toHaveText(A.title); + await expect(page.getByTestId("inbox-question")).toContainText("Which colour should the acme", { timeout: 120_000 }); + await expect(page.getByTestId("inbox-recommended")).toHaveText("Recommended"); + await shot("1-real-form"); + + step("the peek shows the real session beside the card"); + await page.keyboard.press("p"); + const peek = page.getByTestId("inbox-peek"); + await expect(peek).toBeVisible(); + await expect(peek.getByTestId("peek-user").first()).toContainText("AskUserQuestion", { timeout: 30_000 }); + await shot("2-peek"); + + step("pick with a number key, add a note with a double quote in it, and send"); + await page.keyboard.press("1"); + const note = page.getByTestId("inbox-note"); + await note.fill('keep it "calm" please'); + await note.press("Control+Enter"); + await expect(row(A.title)).toHaveCount(0, { timeout: 30_000 }); + await shot("3-sent"); + + step("second view: the agent gets the whole answer, not cut at the quote, and says it back"); + const wanted = "Blue harbour. keep it ”calm” please"; + await expect.poll(async () => { const { items } = await lastQuestion(A.name); const at = items.findLastIndex((i) => i.kind === "question"); return items.slice(at + 1).some((i) => i.kind === "text" && i.text.includes(wanted)); }, { timeout: 180_000, intervals: [2000] }).toBe(true); + await expect.poll(async () => (await lastQuestion(A.name)).q?.done, { timeout: 30_000 }).toBe(true); + + const alive = async (name) => (await call(`${api}/sessions`)).some((x) => x.name === name); + + step("Close session with nothing unsaved stops it at once, and the agent takes no turn"); + await row(B.title).click(); + await expect(title).toHaveText(B.title); + await expect(page.getByTestId("inbox-question")).toContainText("footer", { timeout: 120_000 }); + const before = (await lastQuestion(B.name)).items.length; + await page.getByTestId("inbox-close").click(); + await expect(page.getByTestId("inbox-undo")).toBeVisible(); + await shot("4-undo"); + await expect(page.getByTestId("inbox-undo")).toBeHidden({ timeout: 15_000 }); + await expect.poll(() => alive(B.name), { timeout: 30_000 }).toBe(false); + await expect(row(B.title)).toHaveCount(0, { timeout: 60_000 }); + if (before < 1) throw new Error("the form's session had no record to compare"); + await shot("5-closed-clean"); + + // A real permission prompt, allowed from the card and then resolved from it. + const allow = { name: "inbox-live-c", title: "Inbox live: allow a command" }; + const deny = { name: "inbox-live-d", title: "Inbox live: leave a command" }; + const dirty = { name: "inbox-live-e", title: "Inbox live: unsaved form" }; + const run = `${driver}-${Date.now()}`; + const allowFile = `inbox-allow-${run}.txt`; + const denyFile = `inbox-deny-${run}.txt`; + const unsaved = () => git("status", "--porcelain"); + // What the Inbox tells an agent with unsaved work, as an answer or a message. + const toldToClose = async (name) => JSON.stringify((await call(`${api}/sessions/${name}/transcript?since=0`).catch(() => ({}))).items ?? []).includes("being closed from the Inbox"); + const askRun = (file) => `Use the Bash tool to run exactly this command: python3 -c "open('${file}','w').write('ok')" Then say DONE and stop.`; + for (const s of [allow, deny, dirty]) await call(`${api}/sessions/${s.name}`, { method: "DELETE" }).catch(() => {}); + + step("a real permission prompt comes up with plain choices"); + await start(allow.name, allow.title, askRun(allowFile), "default"); + await expect(row(allow.title)).toHaveCount(1, { timeout: 180_000 }); + await row(allow.title).click(); + await expect(title).toHaveText(allow.title); + const choices = page.getByTestId("inbox-choices"); + await expect(choices).toContainText("Allow", { timeout: 60_000 }); + await expect(choices).toContainText("Deny"); + await shot("6-permission"); + + step("Allow from the card, and the command runs"); + await choices.getByRole("button", { name: /^Allow/ }).first().click(); + await expect(row(allow.title)).toHaveCount(0, { timeout: 30_000 }); + await expect.poll(async () => { const { items } = await lastQuestion(allow.name); return items.some((i) => i.kind === "text" && i.text.includes("DONE")); }, { timeout: 180_000, intervals: [2000] }).toBe(true); + if (readFileSync(`${repo}/${allowFile}`, "utf8") !== "ok") throw new Error(`the allowed command didn't write ${allowFile}`); + + step("the allowed command left an unsaved file: Close over a form has the agent close itself"); + await start(dirty.name, dirty.title, prompt(form("Which colour should the acme sidebar use?", "Teal dock", "Plum buoy"), "Then do what my answer says."), "bypassPermissions"); + await expect(row(dirty.title)).toHaveCount(1, { timeout: 180_000 }); + await row(dirty.title).click(); + await expect(title).toHaveText(dirty.title); + await expect(page.getByTestId("inbox-question")).toContainText("sidebar", { timeout: 120_000 }); + if (!unsaved().includes(allowFile)) throw new Error(`${allowFile} should be unsaved before the close: ${unsaved()}`); + await page.getByTestId("inbox-close").click(); + await expect(page.getByTestId("inbox-undo")).toBeHidden({ timeout: 15_000 }); + // Second view: the agent itself was told to save and close, not just stopped. + let told = false; + await expect.poll(async () => { told ||= await toldToClose(dirty.name); return alive(dirty.name); }, { timeout: 240_000, intervals: [3000] }).toBe(false); + if (!told) throw new Error("the session ended without being told to save its work"); + await expect(row(dirty.title)).toHaveCount(0, { timeout: 60_000 }); + await shot("7-closed-itself"); + + step(driver === "terminal" ? "Close on a permission prompt denies it (the agent closing itself isn't checked on the terminal: features/inbox.md, Gotchas)" : "Close on a permission prompt denies it, and the agent closes itself"); + await start(deny.name, deny.title, askRun(denyFile), "default"); + await expect(row(deny.title)).toHaveCount(1, { timeout: 180_000 }); + await row(deny.title).click(); + await expect(title).toHaveText(deny.title); + await expect(page.getByTestId("inbox-choices")).toContainText("Deny", { timeout: 60_000 }); + await page.getByTestId("inbox-close").click(); + await expect(page.getByTestId("inbox-undo")).toBeHidden({ timeout: 15_000 }); + if (driver === "terminal") { + // The deny key goes; the close message after it waits for an idle the box + // doesn't see on the terminal after an interrupt, so it isn't checked here. + await expect.poll(async () => (await lastQuestion(deny.name)).items.some((i) => i.kind === "notice" && i.notice === "interrupted"), { timeout: 60_000, intervals: [2000] }).toBe(true); + } else { + // In this mode the agent has to ask to run its own close, and that's a new card. + await expect + .poll( + async () => { + if (!(await alive(deny.name))) return "closed"; + if ((await row(deny.title).count()) > 0) { + await row(deny.title).click(); + const yes = page.getByTestId("inbox-choices").getByRole("button", { name: /^Allow/ }).first(); + if (await yes.isVisible().catch(() => false)) await yes.click(); + } + return "open"; + }, + { timeout: 240_000, intervals: [4000] }, + ) + .toBe("closed"); + } + if (existsSync(`${repo}/${denyFile}`)) throw new Error(`the denied command ran: ${denyFile} exists`); + await shot("8-permission-closed"); + + step("clean up the test sessions"); + for (const s of [A, B, allow, deny, dirty]) await call(`${api}/sessions/${s.name}`, { method: "DELETE" }).catch(() => {}); +} diff --git a/.claude/skills/verify-berthd/scripts/scenarios/inbox-mock.mjs b/.claude/skills/verify-berthd/scripts/scenarios/inbox-mock.mjs new file mode 100644 index 000000000..980a022a6 --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/inbox-mock.mjs @@ -0,0 +1,191 @@ +// inbox-mock: the Inbox on Shipyard's mock fixtures (?mock=1, synthetic "acme" +// data) in a real browser: the sidebar count equals the list, the groups show, +// the walk-through steps one item at a time, a form is answered by keys, and the +// peek shows the session beside the card without losing a draft note. +import { readFileSync } from "node:fs"; + +const dist = new URL("../../../../../plugins/inbox/dist/index.js", import.meta.url); + +// The mock box has no transcripts for a plugin's own requests, so this wraps the +// plugin's berth.api.request with synthetic ones and keeps what it's asked to send +// on window.__inboxSent. Real agents are inbox-live.mjs. +const stub = ` +const FORM = [ + { kind: "user", id: "u1", text: "Plan the release of the checkout fix." }, + { kind: "tools", id: "t1", verb: "Read webhook.ts" }, + { kind: "text", id: "x1", text: "I read the **webhook** code. Two choices left." }, + { kind: "question", id: "q1", tool: "mock-tool", questions: [ + { question: "Which day should acme release?", options: [{ label: "Tuesday (Recommended)", description: "Quiet traffic." }, { label: "Friday", description: "Before the weekend." }] }, + { question: "Who should watch it?", options: [{ label: "On-call" }, { label: "Author" }] }, + ] }, +]; +const wrap = (plugin) => (berth) => { + window.__inboxSent = []; + const api = Object.create(berth.api); + api.request = (box, method, path, body) => { + if (method === "GET" && path === "sessions/shop-claude/transcript") return Promise.resolve({ source: "mock", items: FORM }); + if (method === "GET" && path === "sessions/old-q/transcript") return Promise.resolve({ source: "mock", items: [ + { kind: "user", id: "u1", text: "Pick a colour for acme." }, + { kind: "question", id: "q1", tool: "t", done: true, questions: [{ question: "Which colour?", options: [{ label: "Blue" }] }], answers: ["Blue"] }, + { kind: "tools", id: "t1", verb: "Run", items: [{ verb: "Run", target: "pnpm build" }] }, + { kind: "text", id: "x1", text: "Now the docs." }, + { kind: "notice", id: "n1", notice: "interrupted", text: "Interrupted" }, + ] }); + if (method === "GET" && path.endsWith("/transcript")) return Promise.resolve({ source: "mock", items: [{ kind: "text", id: "m", text: "Should acme keep the old flag?" }] }); + if (method === "DELETE" && path.startsWith("sessions/")) { window.__inboxSent.push({ method, path }); return Promise.resolve({}); } + if (method === "POST" && path.endsWith("/answer")) { window.__inboxSent.push({ path, body }); return Promise.resolve({ answered: [] }); } + return berth.api.request(box, method, path, body); + }; + // A session interrupted earlier, whose title still asks. Its time is fixed, as + // a real session's is: an item's identity follows it. + const stoppedAt = new Date(Date.now() - 30_000).toISOString(); + api.sessions = async (box) => { + const list = await berth.api.sessions(box); + return box === "devl" ? [...list, { name: "old-q", agent: "claude", agent_state: "finished", title: "❓ Pick a colour", location: "shop", state_since: stoppedAt }] : list; + }; + // Nothing unsaved anywhere, as far as the check can tell. + const orchestrate = Object.create(berth.orchestrate); + orchestrate.exec = () => Promise.resolve({ exit_code: 0, output: "" }); + const ctx = new Proxy(berth, { get: (t, k) => (k === "api" ? api : k === "orchestrate" ? orchestrate : t[k]) }); + return plugin(ctx); +}; +`; +const source = readFileSync(dist, "utf8").replace(/export \{\s*(\w+) as default\s*\};?/, (_, name) => `${stub}\nexport default wrap(${name});`); +if (!source.includes("export default wrap")) throw new Error("can't find the plugin's default export in dist/index.js"); + +export const plugins = [{ id: "inbox", name: "Inbox", source }]; +export const params = { labs: "0", sidebar: "list", structured: "1" }; + +export async function run({ page, shot, step, expect }) { + step("open the Inbox from More"); + await page.getByTestId("nav-more").click(); + await page.getByRole("menu").getByRole("menuitem", { name: /^Inbox/ }).click(); + const screen = page.getByTestId("inbox-screen"); + await expect(screen).toBeVisible(); + const left = page.getByTestId("inbox-left"); + await expect(left).toContainText(" left"); + await shot("1-list"); + + step("the count is the length of the list"); + const n = Number((await left.innerText()).split(" ")[0]); + await expect(page.getByTestId("inbox-item")).toHaveCount(n); + if (n < 2) throw new Error(`the fixtures should leave at least 2 items, got ${n}`); + console.log("items:", await page.getByTestId("inbox-item").allInnerTexts()); + + step("j moves to the next card, k back"); + const title = page.getByTestId("inbox-card-title"); + const current = page.locator('[data-testid=inbox-item][aria-current]'); + const first = await current.textContent(); + await page.keyboard.press("j"); + await expect(current).not.toHaveText(first); + await page.keyboard.press("k"); + await expect(current).toHaveText(first); + + step("walk-through hides the list and says where you are"); + await page.keyboard.press("w"); + await expect(page.getByTestId("inbox-list")).toBeHidden(); + await expect(page.getByTestId("inbox-position")).toHaveText(`1 of ${n}`); + await shot("2-walk"); + await page.keyboard.press("w"); + await expect(page.getByTestId("inbox-list")).toBeVisible(); + + step("snooze takes a card off, and the count follows"); + await page.keyboard.press("h"); + await expect(page.getByTestId("inbox-snooze")).toBeVisible(); + await shot("3-snooze"); + await page.keyboard.press("1"); + await expect(left).toContainText(`${n - 1} left`); + await expect(page.getByTestId("inbox-item")).toHaveCount(n - 1); + await expect(page.getByTestId("inbox-snoozed-count")).toHaveText("1 snoozed"); + + step("a session's form shows on its card, with its options and number keys"); + await page.getByTestId("inbox-item").filter({ hasText: "Plan the checkout release" }).click(); + await expect(title).toHaveText("Plan the checkout release"); + const questions = page.getByTestId("inbox-question"); + await expect(questions.first()).toBeVisible({ timeout: 15_000 }); + const asked = await questions.count(); + await expect(page.getByTestId("inbox-recommended")).toHaveText("Recommended"); + await expect(page.getByTestId("inbox-option").first()).toContainText("Tuesday"); + await expect(page.getByTestId("inbox-option").first()).not.toContainText("(Recommended)"); + await shot("4-form"); + + step("typing a note presses nothing"); + const note = page.getByTestId("inbox-note"); + await note.click(); + await page.keyboard.type("hjkwpx 12"); + await expect(note).toHaveValue("hjkwpx 12"); + await expect(title).toHaveText("Plan the checkout release"); + await page.keyboard.press("Escape"); + + step("the peek opens beside the card, with the session's own words, and the draft stays"); + await page.keyboard.press("p"); + const peek = page.getByTestId("inbox-peek"); + await expect(peek).toBeVisible(); + await expect(peek.getByTestId("peek-user").first()).toContainText("Plan the release of the checkout fix."); + await expect(note).toHaveValue("hjkwpx 12"); + await shot("5-peek"); + await page.keyboard.press("p"); + await expect(peek).toBeHidden(); + await expect(note).toHaveValue("hjkwpx 12"); + + step("work folds into one line that opens on a click"); + await page.keyboard.press("p"); + const work = page.getByTestId("inbox-peek").getByTestId("peek-work").first(); + await expect(work).toContainText("Worked · read 1 file"); + await expect(page.getByTestId("inbox-peek")).not.toContainText("Read webhook.ts"); + await work.getByRole("button").click(); + await expect(page.getByTestId("inbox-peek")).toContainText("Read webhook.ts"); + await page.keyboard.press("p"); + + step("walking away and back keeps the draft"); + await page.keyboard.press("j"); + await page.keyboard.press("k"); + await expect(note).toHaveValue("hjkwpx 12"); + await note.fill(""); + await page.keyboard.press("Escape"); + + step("number keys answer every question, Enter sends, and the card leaves"); + const send = page.getByTestId("inbox-send"); + await expect(send).toBeDisabled(); + for (let i = 0; i < asked; i++) await page.keyboard.press("1"); + await expect(send).toBeEnabled(); + await shot("6-picked"); + const was = Number((await left.innerText()).split(" ")[0]); + await page.keyboard.press("Enter"); + await expect(left).toContainText(`${was - 1} left`); + await expect(page.getByTestId("inbox-item").filter({ hasText: "Plan the checkout release" })).toHaveCount(0); + const sent = await page.evaluate(() => window.__inboxSent); + const want = JSON.stringify([{ path: "sessions/shop-claude/answer", body: { tool: "mock-tool", answers: [{ picks: ["Tuesday (Recommended)"] }, { picks: ["On-call"] }] } }]); + if (JSON.stringify(sent) !== want) throw new Error(`answer sent: ${JSON.stringify(sent)}`); + await shot("7-answered-next"); + + step("a stale asking title doesn't claim a question: the card says it was interrupted, and so does the peek"); + await page.getByTestId("inbox-item").filter({ hasText: "Pick a colour" }).click(); + await expect(title).toHaveText("Pick a colour"); + await expect(page.getByTestId("inbox-why")).toHaveText("It was interrupted, and waits for what to do instead."); + await page.keyboard.press("p"); + await expect(page.getByTestId("peek-notice")).toHaveText("It was interrupted, and waits for what to do instead."); + await shot("7b-interrupted"); + await page.keyboard.press("p"); + + step("close session can be undone, and the card comes back"); + await page.getByTestId("inbox-close").click(); + const undo = page.getByTestId("inbox-undo"); + await expect(undo).toBeVisible(); + await expect(left).toContainText(`${was - 2} left`); + await shot("8-undo"); + // The mock's running loop card floats over the bar's right end, where Undo + // is, so it's pressed from the keyboard (features/inbox.md, Gotchas). + await undo.getByRole("button", { name: "Undo" }).press("Enter"); + await expect(undo).toBeHidden(); + await expect(left).toContainText(`${was - 1} left`); + await expect(page.getByTestId("inbox-item").filter({ hasText: "Pick a colour" })).toHaveCount(1); + + step("with nothing unsaved it stops the session, after the few seconds"); + await page.getByTestId("inbox-item").filter({ hasText: "Pick a colour" }).click(); + await page.getByTestId("inbox-close").click(); + await expect(undo).toBeHidden({ timeout: 15_000 }); + await expect.poll(() => page.evaluate(() => window.__inboxSent.some((c) => c.method === "DELETE" && c.path === "sessions/old-q"))).toBe(true); + await expect(page.getByTestId("inbox-item").filter({ hasText: "Pick a colour" })).toHaveCount(0); + await shot("9-closed"); +} diff --git a/docs/changelog.mdx b/docs/changelog.mdx index cf4216d07..c7fd88c1e 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -10,6 +10,11 @@ upgrades with `berth upgrade BOX`, or by running the again. ## Unreleased +- **Inbox is in the plugin listing.** Settings → Plugins → Browse now has + Inbox: one list of what every agent needs from you, grouped by what it + needs, where you answer a question or a permission prompt on its card and + the answer goes straight back to the session that asked. It installs off, + like any listed plugin, until you turn it on. - **Boxes are ready for the Shipyard phone app.** With phone access on, a box now also lets a paired phone read an agent's recent conversation (never tool output or files), follow which agents and runs change as it diff --git a/docs/guides/plugins.mdx b/docs/guides/plugins.mdx index b2bff0618..3a5f3c013 100644 --- a/docs/guides/plugins.mdx +++ b/docs/guides/plugins.mdx @@ -32,6 +32,11 @@ do and a hash of its code, and it runs only once you choose **Allow and turn on** (see [Trust and consent](#trust-and-consent)). Installing never turns a plugin on. +The listing has [Leadline](/guides/leadline), [Database](/guides/database), +Inbox (one list of what every agent needs from you, answered on its card) +and the `hello-ports` example. Their sources are in +[`plugins/`](https://github.com/cosscom/shipyard/tree/main/plugins). + - **Update** shows when the listing has a newer version. It replaces the plugin's whole folder. The new code is not the code you allowed, so the plugin is off until you review it again, and the app asks straight away diff --git a/plugins/README.md b/plugins/README.md index 4fc2f96c1..f12696ca1 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -3,7 +3,7 @@ Each folder here is a Shipyard plugin. The ones whose `berth-plugin.json` says `"builtin": true` ship inside the app; `hello-ports` is the smallest example of the SDK and is not built in. The ones that say `"marketplace": true` -(`hello-ports`, and [`leadline`](leadline) and [`database`](database), which +(`hello-ports`, [`inbox`](inbox), and [`leadline`](leadline) and [`database`](database), which each also carry a program for boxes) are in the listing people browse and install from in Settings → Plugins: see "How the listing ships" below. diff --git a/plugins/inbox/README.md b/plugins/inbox/README.md new file mode 100644 index 000000000..f713cd815 --- /dev/null +++ b/plugins/inbox/README.md @@ -0,0 +1,37 @@ +# Inbox + +One inbox across all your agents. It shows what needs you, grouped by what it needs, and your answer goes straight back to the session that asked. + +- **Four groups:** needs my decision, needs an action from me, ready to review or merge, blocked or crashed. +- **Question cards:** options with the recommended one marked, a note, Snooze, and Close session. A session with nothing unsaved stops at once; one with unsaved work saves it and closes itself. +- **Walk-through:** press `w` to go through items one at a time. +- **Peek:** press `p` to read the session beside the card, without losing your answer. +- **Keyboard first:** `1`–`9` pick, `↵` sends, `j`/`k` move, `h` snoozes, `x` closes, `n` writes a note, `o` opens the session. No shortcut fires while you're typing, so dictation tools work in the note. + +It needs nothing from your agents. It reads the signals Shipyard already has: question forms, permission prompts, run gates and exits. Nothing is sent anywhere but your own boxes. + +## Install + +**Settings → Plugins → Browse**, then **Install** beside Inbox and turn it on under Installed. To keep it at hand, pin it from **More → Customize sidebar**. + +## Limits for now + +- Snooze and Dismiss marks stay on the computer you made them on. +- "Ready to review or merge" fills once agents can post to the Inbox, which needs a change in Shipyard. +- Resume works for a box's chat sessions, which keep a conversation record. +- Sessions sort into decision and action on a Shipyard with sub-states of Needs you. Without them, every waiting session is a decision. + +## Develop + +`src/model.ts` holds the rules and is pure, `src/store.ts` talks to the boxes, the rest draws. It uses only `@berth/plugin`. + +```sh +pnpm install +pnpm test && pnpm typecheck && pnpm build +mkdir -p ~/.berth/plugins && ln -s "$PWD" ~/.berth/plugins/inbox # to try it in the app +``` + +- Use classes the app already has, or inline styles: a plugin's own Tailwind classes aren't generated. +- A key shortcut must never fire while a text field has focus. +- Raise `version` in `berth-plugin.json` and `package.json` when the code changes, then run `pnpm -C app build:marketplace`. +- To prove a change in the real app, run `inbox-mock` and `inbox-live` from [verify-berthd](../../.claude/skills/verify-berthd/features/inbox.md). Anything that changes what's sent to an agent needs `inbox-live` on both drivers. diff --git a/plugins/inbox/berth-plugin.json b/plugins/inbox/berth-plugin.json new file mode 100644 index 000000000..b271021d1 --- /dev/null +++ b/plugins/inbox/berth-plugin.json @@ -0,0 +1,10 @@ +{ + "id": "inbox", + "name": "Inbox", + "version": "0.1.2", + "description": "One inbox across all your agents, grouped by what they need from you. Answer on the card and it goes straight back to the session that asked.", + "main": "dist/index.js", + "author": "Stener1", + "homepage": "https://github.com/cosscom/shipyard/tree/main/plugins/inbox", + "marketplace": true +} diff --git a/plugins/inbox/build.mjs b/plugins/inbox/build.mjs new file mode 100644 index 000000000..5a9676863 --- /dev/null +++ b/plugins/inbox/build.mjs @@ -0,0 +1,14 @@ +// Bundles the plugin as one ES module. React and the Shipyard SDK stay external: +// the app provides one shared copy of each at runtime. +import { build } from "esbuild"; + +await build({ + entryPoints: ["src/index.tsx"], + bundle: true, + format: "esm", + jsx: "automatic", + target: "es2022", + external: ["react", "react/jsx-runtime", "react-dom", "@berth/plugin", "@berth/plugin/ui"], + outfile: "dist/index.js", + logLevel: "info", +}); diff --git a/plugins/inbox/package.json b/plugins/inbox/package.json new file mode 100644 index 000000000..3e07a9f1c --- /dev/null +++ b/plugins/inbox/package.json @@ -0,0 +1,18 @@ +{ + "name": "berth-plugin-inbox", + "private": true, + "version": "0.1.2", + "type": "module", + "scripts": { + "build": "node build.mjs", + "typecheck": "tsc --noEmit", + "test": "node --experimental-strip-types --no-warnings --test src/*.test.ts" + }, + "devDependencies": { + "@berth/plugin": "file:../../packages/plugin-sdk", + "@types/node": "^22.0.0", + "@types/react": "^19.1.8", + "esbuild": "^0.25.0", + "typescript": "~6.0.3" + } +} diff --git a/plugins/inbox/pnpm-lock.yaml b/plugins/inbox/pnpm-lock.yaml new file mode 100644 index 000000000..703eda543 --- /dev/null +++ b/plugins/inbox/pnpm-lock.yaml @@ -0,0 +1,337 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@berth/plugin': + specifier: file:../../packages/plugin-sdk + version: file:../../packages/plugin-sdk(@types/react@19.3.0) + '@types/node': + specifier: ^22.0.0 + version: 22.20.5 + '@types/react': + specifier: ^19.1.8 + version: 19.3.0 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + typescript: + specifier: ~6.0.3 + version: 6.0.3 + +packages: + + '@berth/plugin@file:../../packages/plugin-sdk': + resolution: {directory: ../../packages/plugin-sdk, type: directory} + peerDependencies: + '@types/react': ^19 + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@types/node@22.20.5': + resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@berth/plugin@file:../../packages/plugin-sdk(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@types/node@22.20.5': + dependencies: + undici-types: 6.21.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + csstype@3.2.3: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + typescript@6.0.3: {} + + undici-types@6.21.0: {} diff --git a/plugins/inbox/pnpm-workspace.yaml b/plugins/inbox/pnpm-workspace.yaml new file mode 100644 index 000000000..5ed0b5af0 --- /dev/null +++ b/plugins/inbox/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/plugins/inbox/src/card.tsx b/plugins/inbox/src/card.tsx new file mode 100644 index 000000000..02ff00476 --- /dev/null +++ b/plugins/inbox/src/card.tsx @@ -0,0 +1,357 @@ +import type { BerthPluginContext } from "@berth/plugin"; +import { Alert, AlertDescription, Badge, Button, Kbd, Skeleton, Textarea, cn } from "@berth/plugin/ui"; +import type React from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; + +import { Markdown } from "./markdown.tsx"; +import { ago, closeKind, answerFor, type Choice, complete, didntLand, type GateLike, GROUPS, isRecommended, type Item, oneLine, type Pick, type Question, shownLabel, snoozeTimes } from "./model.ts"; +import { answerForm, decideGate, type Detail, errText, getState, loadDetail, openSession, closeSession, pressKey, reply, resume, snooze, subscribe } from "./store.ts"; + +// A key is the Inbox's only when you're not typing and no dialog or menu has the +// keyboard, so Wispr Flow and friends can type into the note. Same rule as the +// app's own lib/view-keys.ts, until the SDK exports it. +export function viewKey(e: KeyboardEvent): boolean { + if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return false; + const t = e.target as HTMLElement | null; + return !(t?.closest?.("input, textarea, select, [contenteditable=true], .xterm, [data-terminal]") || document.querySelector("[data-slot=dialog-popup], [data-slot=alert-dialog-popup], [data-slot=sheet-popup], [data-slot=menu-popup], [role=menu]")); +} + +// What you wrote stays while you walk around, until the card is answered. +const drafts = new Map; note: string }>(); +const draftOf = (key: string) => drafts.get(key) ?? { picks: {}, note: "" }; + +const Key = ({ k }: { k: string }) => {k}; + +interface CardProps { + berth: BerthPluginContext; + item: Item; + peekOpen: boolean; + onPeek(): void; +} + +export function Card({ berth, item, peekOpen, onPeek }: CardProps) { + const state = useSyncExternalStore(subscribe, getState); + const detail: Detail | undefined = state.details[item.key]; + const [error, setError] = useState(); + const [busy, setBusy] = useState(false); + const [snoozing, setSnoozing] = useState(false); + const [draft, setDraftState] = useState(() => draftOf(item.key)); + const noteRef = useRef(null); + // Keys can come faster than a render, so each reads the draft as it is now. + const latest = useRef(draft); + latest.current = draft; + + const setDraft = useCallback( + (d: { picks: Record; note: string }) => { + latest.current = d; + drafts.set(item.key, d); + setDraftState(d); + }, + [item.key], + ); + + useEffect(() => { + setDraftState(draftOf(item.key)); + setError(undefined); + setSnoozing(false); + void loadDetail(item); + }, [item.key]); + + const run = useCallback(async (act: () => Promise) => { + setBusy(true); + setError(undefined); + try { + await act(); + drafts.delete(item.key); + } catch (err) { + setError(errText(err)); + } finally { + setBusy(false); + } + }, [item.key]); + + const form = detail?.form; + const questions: Question[] = form?.questions ?? []; + const last = questions.length - 1; + const picksOf = (i: number): Pick => ({ picks: draft.picks[i] ?? [], note: i === last ? draft.note : "" }); + const formReady = questions.length > 0 && questions.every((_, i) => complete(picksOf(i))); + // The first question still open takes the number keys. + const firstOpen = (picks: Record) => Math.max(0, questions.findIndex((_, i) => !picks[i]?.length)); + const focusQ = firstOpen(draft.picks); + + const toggle = useCallback( + (i: number, label: string) => { + const q = questions[i]; + const now = latest.current; + const was = now.picks[i] ?? []; + const next = q.multi ? (was.includes(label) ? was.filter((l) => l !== label) : [...was, label]) : was[0] === label ? [] : [label]; + setDraft({ ...now, picks: { ...now.picks, [i]: next } }); + }, + [questions, setDraft], + ); + + const submitForm = useCallback(() => { + if (!form || !formReady || busy) return; + void run(() => answerForm(item, form.tool, questions.map((q, i) => answerFor(q, picksOf(i))))); + }, [form, formReady, busy, item, questions, draft]); + + const choices: Choice[] = (item.kind === "wait" && !form && detail?.choices) || []; + const onItsScreen = item.kind === "wait" && detail?.screen !== undefined; + const canReply = !form && !choices.length && !onItsScreen && !!item.session && (item.kind === "wait" || item.kind === "marked"); + const noteText = oneLine(draft.note); + + const send = useCallback(() => { + if (form) return submitForm(); + if (canReply && noteText && !busy) void run(() => reply(item, noteText)); + }, [form, submitForm, canReply, noteText, busy, item]); + + const closing = closeKind(item.kind); + const doClose = useCallback(() => { + if (!closing) return; + drafts.delete(item.key); + closeSession(item, draft.note); + }, [item, closing, draft.note]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && (e.target as HTMLElement | null)?.tagName === "TEXTAREA") return (e.target as HTMLElement).blur(); + if (!viewKey(e)) return; + if (snoozing) { + const t = snoozeTimes(Date.now())[Number(e.key) - 1]; + if (t) { + e.preventDefault(); + snooze(item, t.until); + } else if (e.key === "Escape") setSnoozing(false); + return; + } + if (/^[1-9]$/.test(e.key)) { + const n = Number(e.key) - 1; + if (form) { + const at = firstOpen(latest.current.picks); + const o = questions[at]?.options[n]; + if (o) { + e.preventDefault(); + toggle(at, o.label); + } + } else if (choices[n] && !busy) { + e.preventDefault(); + void run(() => pressKey(item, choices[n].key)); + } + return; + } + if (e.key === "Enter" && !(e.target as HTMLElement | null)?.closest?.("button, a")) { + e.preventDefault(); + return send(); + } + if (e.key === "h") { + e.preventDefault(); + setSnoozing(true); + } else if (e.key === "n") { + e.preventDefault(); + noteRef.current?.focus(); + } else if (e.key === "x") { + e.preventDefault(); + doClose(); + } else if (e.key === "o") { + e.preventDefault(); + openSession(item); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [snoozing, item, form, questions, focusQ, toggle, choices, busy, send, doClose]); + + const groupTitle = useMemo(() => GROUPS.find((g) => g.id === item.group)?.title, [item.group]); + const where = [item.location, item.box].filter(Boolean).join(" · "); + const waited = ago(item.since, state.now); + const gate = item.kind === "gate" ? (item.source as GateLike).gate : undefined; + const loading = detail?.loading && !form; + + return ( +
+
+
+ {groupTitle} + {where && · {where}} + {waited && · waited {waited}} +
+

+ {item.title} +

+ {(detail?.ended ?? item.why) && ( +

+ {detail?.ended ?? item.why} +

+ )} +
+ + {didntLand(item, state.marks, state.now) && ( + + You answered this a while ago and it's still waiting. Peek at the session to see what happened. + + )} + + {loading && } + {detail?.error && ( + + Couldn't read this session: {detail.error} + + )} + + {detail?.message && !form && ( +
+ berth.openUrl(u)} /> +
+ )} + + {onItsScreen && ( +
+

It's waiting on a screen of its own, which only keys in the session can answer.

+
{detail?.screen}
+
+ +
+
+ )} + + {questions.map((q, i) => ( +
+ {q.question} + {q.options.map((o, n) => { + const on = (draft.picks[i] ?? []).includes(o.label); + return ( + + ); + })} +
+ ))} + + {choices.length > 0 && ( +
+ {choices.map((c, n) => ( + + ))} +
+ )} + + {gate && ( +
+ + +
+ )} + + {(item.kind === "gone" || item.kind === "exited") && ( +
+ +
+ )} + + {item.kind === "secret" && ( +

+ Open the session to fill in the masked card. The value goes to the box's secret store, never through this Inbox. +

+ )} + + {item.kind !== "gone" && item.kind !== "exited" && item.kind !== "secret" && !onItsScreen && ( +