From 05c88a8293602ae52afd11843268e5a7bed91fac Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 2 Oct 2026 15:03:47 +0100 Subject: [PATCH] =?UTF-8?q?Enforce=20agent-state=20UX=20for=20app=E2=86=92?= =?UTF-8?q?agent=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- framework/cli/package.json | 2 +- framework/cli/src/lib/agentState.ts | 341 ++++++++++++++ framework/cli/src/lib/bridge.ts | 50 +- framework/cli/src/lib/gate.ts | 18 +- framework/cli/src/lib/harness.ts | 112 ++++- framework/cli/test/agent-state.test.mjs | 188 ++++++++ framework/cli/test/bridge.test.mjs | 157 +++++++ skills/creator/SKILL.md | 39 +- skills/modify/SKILL.md | 6 +- skills/walk-verify/SKILL.md | 34 ++ .../blueprint-go-react/template/AGENT_APP.md | 3 +- .../blueprint-go-react/template/public/ui.css | 60 ++- .../template/reference/blueprint.md | 46 +- .../blueprint-go-react/template/schema.go | 21 +- .../template/src/AgentTask.jsx | 439 ++++++++++++++++++ .../blueprint-go-react/template/src/App.jsx | 64 ++- .../blueprint-go-react/template/src/Icon.jsx | 1 + .../template/reference/blueprint.md | 18 +- .../template/schema.py | 15 +- .../blueprint-rails-vue/template/.gitignore | 5 +- .../blueprint-rails-vue/template/AGENT_APP.md | 4 +- .../template/lib/a2app_schema.rb | 20 +- .../template/reference/blueprint.md | 49 +- .../template/ui/public/tokens.css | 232 +++++++++ .../template/ui/public/ui.css | 208 +++++++++ .../template/ui/src/App.vue | 72 ++- .../template/ui/src/agentTask.css | 65 +++ .../template/ui/src/agentTask.js | 263 +++++++++++ .../ui/src/components/AgentTaskBadge.vue | 30 ++ .../ui/src/components/AgentTaskPanel.vue | 148 ++++++ .../template/ui/src/components/Icon.vue | 1 + .../template/AGENT_APP.md | 3 +- .../template/a2app.schema.mjs | 18 +- .../template/public/ui.css | 60 ++- .../template/reference/blueprint.md | 51 +- .../template/src/AgentTask.jsx | 439 ++++++++++++++++++ .../blueprint-react-node/template/src/App.jsx | 64 ++- .../template/src/Icon.jsx | 1 + .../template/AGENT_APP.md | 3 +- .../template/public/ui.css | 60 ++- .../template/reference/blueprint.md | 46 +- .../template/src/schema.rs | 26 +- .../template/view/AgentTask.jsx | 439 ++++++++++++++++++ .../template/view/App.jsx | 64 ++- .../template/view/Icon.jsx | 1 + 45 files changed, 3883 insertions(+), 103 deletions(-) create mode 100644 framework/cli/src/lib/agentState.ts create mode 100644 framework/cli/test/agent-state.test.mjs create mode 100644 toolkits/blueprint-go-react/template/src/AgentTask.jsx create mode 100644 toolkits/blueprint-rails-vue/template/ui/public/tokens.css create mode 100644 toolkits/blueprint-rails-vue/template/ui/public/ui.css create mode 100644 toolkits/blueprint-rails-vue/template/ui/src/agentTask.css create mode 100644 toolkits/blueprint-rails-vue/template/ui/src/agentTask.js create mode 100644 toolkits/blueprint-rails-vue/template/ui/src/components/AgentTaskBadge.vue create mode 100644 toolkits/blueprint-rails-vue/template/ui/src/components/AgentTaskPanel.vue create mode 100644 toolkits/blueprint-react-node/template/src/AgentTask.jsx create mode 100644 toolkits/blueprint-rust-react/template/view/AgentTask.jsx diff --git a/framework/cli/package.json b/framework/cli/package.json index 497f6e9..f676470 100644 --- a/framework/cli/package.json +++ b/framework/cli/package.json @@ -35,7 +35,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "tsc -p tsconfig.json && node test/copy-tree.test.mjs && node test/manifest-merge.test.mjs && node test/containment.test.mjs && node test/connect.test.mjs && node test/bridge.test.mjs", + "test": "tsc -p tsconfig.json && node test/copy-tree.test.mjs && node test/manifest-merge.test.mjs && node test/containment.test.mjs && node test/connect.test.mjs && node test/bridge.test.mjs && node test/agent-state.test.mjs", "bundle-blueprints": "node scripts/bundle-blueprints.mjs", "bundle-skills": "node scripts/bundle-skills.mjs", "bundle": "node scripts/bundle-blueprints.mjs && node scripts/bundle-skills.mjs", diff --git a/framework/cli/src/lib/agentState.ts b/framework/cli/src/lib/agentState.ts new file mode 100644 index 0000000..740629b --- /dev/null +++ b/framework/cli/src/lib/agentState.ts @@ -0,0 +1,341 @@ +/** + * The agent-state gate: work an app queues for an agent has to be visible in + * the View until it is done. + * + * An agent run takes seconds to minutes. A button that queues one and then goes + * quiet leaves the person looking at a screen with no sign anything is + * happening, and that is the failure this step exists to catch. The creator + * skill has always said so, but advice is followed unreliably and nothing used + * to notice when it was not. + * + * The check is static and stack-agnostic, so it runs on every app whatever its + * blueprint: + * + * 1. Find each `trigger(...)` call in app-owned code that names a capability, + * which is what puts a task on the queue. A trigger without one only + * announces an event, and no agent is ever handed it. + * 2. If any exists and the app has a human View, some View code must follow + * the task: it reads `/api/_a2app/tasks/...`, or it renders the + * blueprint's agent-task component. The component file itself does not + * count (it carries an `@a2app-kit agent-task` marker), because shipping a + * component nobody renders shows nothing. + * + * Comments are stripped before either search, so a note saying "TODO: poll + * /api/_a2app/tasks" proves nothing. System-owned files are skipped: they are + * the framework's code, not the app's, and the adapter's own `trigger` + * implementation is not a feature that queues work. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { extname, join, relative, sep } from "node:path"; +import { canonPaths } from "./canon.js"; + +/** One place app code puts work on the agent queue. */ +export interface QueuingTrigger { + /** project-relative, forward slashes */ + file: string; + line: number; + /** the capability argument as written, e.g. `"triage"` */ + capability: string; +} + +export interface AgentStateReport { + triggers: QueuingTrigger[]; + /** project-relative View files (markup or component sources) */ + viewFiles: string[]; + /** project-relative files whose code follows a queued task */ + followers: string[]; +} + +/** The marker a kit component carries, so its own task polling is not counted as use. */ +export const KIT_MARKER = "@a2app-kit agent-task"; + +/** Where the skill states the rule. Named in every failure so the fix is one read away. */ +export const RULE_POINTER = + 'creator skill, "The UI that queues work must show that work until it is done"'; + +const SKIP_DIRS = new Set([ + "node_modules", ".git", ".a2app", ".lui", "dist", "build", "out", "target", "public", + "data", "pb_data", "vendor", "tmp", "log", "logs", "coverage", "__pycache__", ".venv", + "venv", ".next", ".nuxt", ".svelte-kit", ".turbo", ".cache", + "test", "tests", "__tests__", "spec", +]); + +const MAX_FILE_BYTES = 512 * 1024; + +type Lang = "c" | "hash" | "markup"; + +const LANG_BY_EXT: Record = { + ".js": "c", ".mjs": "c", ".cjs": "c", ".jsx": "c", ".ts": "c", ".mts": "c", ".cts": "c", ".tsx": "c", + ".go": "c", ".rs": "c", ".java": "c", ".kt": "c", ".cs": "c", ".swift": "c", ".dart": "c", ".php": "c", + ".py": "hash", ".rb": "hash", ".ex": "hash", ".exs": "hash", + ".vue": "markup", ".svelte": "markup", ".html": "markup", ".htm": "markup", ".erb": "markup", +}; + +/** Files that ARE a View — markup, or a component a bundler compiles into one. */ +const VIEW_EXTS = new Set([".jsx", ".tsx", ".vue", ".svelte", ".html", ".htm", ".erb"]); + +const FOLLOW_RE = /_a2app\/tasks|\buseAgentTask\b|\bAgentTask\w*| p.replace(/\\/g, "/"))); + const report: AgentStateReport = { triggers: [], viewFiles: [], followers: [] }; + + for (const abs of walk(projectDir)) { + const rel = relative(projectDir, abs).split(sep).join("/"); + if (skip.has(rel) || TEST_FILE_RE.test(rel)) continue; + const ext = extname(rel).toLowerCase(); + const lang = LANG_BY_EXT[ext]; + if (!lang) continue; + + let raw: string; + try { + if (statSync(abs).size > MAX_FILE_BYTES) continue; + raw = readFileSync(abs, "utf8"); + } catch { + continue; + } + + if (VIEW_EXTS.has(ext)) report.viewFiles.push(rel); + + const { noComments, codeOnly } = lang === "markup" ? stripMarkup(raw) : strip(raw, lang); + for (const t of findQueuingTriggers(noComments, codeOnly)) { + report.triggers.push({ file: rel, ...t }); + } + if (!raw.includes(KIT_MARKER) && FOLLOW_RE.test(noComments)) report.followers.push(rel); + } + return report; +} + +/** The gate's verdict: null when the View shows the work (or there is none to show). */ +export function agentStateProblem(report: AgentStateReport): string | null { + if (report.triggers.length === 0 || report.viewFiles.length === 0) return null; + if (report.followers.length > 0) return null; + + const sites = report.triggers.map((t) => ` ${t.file}:${t.line} queues work for capability ${t.capability}`); + return [ + "This app queues work for an agent, but no View code shows that work:", + ...sites, + "", + "An agent run takes seconds to minutes. A control that queues one and then goes quiet looks", + "broken. Keep the task id the trigger returns on the record, and render it in the View with the", + "blueprint's AgentTask component (see reference/blueprint.md), or follow", + "GET /api/_a2app/tasks/{id} yourself and show submitted (with elapsed time, and a \"no agent is", + "listening\" hint after ~20 s), working (progress.step), completed (result.summary in its own", + "full-width block) and failed (reason, and a way to ask again). On a multi-user app that read", + "answers 401: have the agent write its progress onto the record and render that through the", + "same component.", + `See the ${RULE_POINTER}.`, + ].join("\n"); +} + +/* ------------------------------------------------------------------ walking */ + +function* walk(dir: string): Generator { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (e.isDirectory()) { + if (SKIP_DIRS.has(e.name) || e.name.startsWith(".")) continue; + yield* walk(join(dir, e.name)); + } else if (e.isFile()) { + yield join(dir, e.name); + } + } +} + +/* ---------------------------------------------------------------- stripping */ + +interface Stripped { + /** source with comments blanked to spaces (newlines kept, so offsets and lines hold) */ + noComments: string; + /** source with comments AND string contents blanked: only code structure is left */ + codeOnly: string; +} + +/** + * Blank comments (and, for `codeOnly`, string contents) without moving a + * single character, so an offset found in one view indexes the others. + * + * Deliberately a lexer, not a parser: it knows quotes and comment markers and + * nothing else. Single- and double-quoted strings end at a newline whatever + * the language says, so a stray apostrophe (JSX text, a Rust lifetime) can + * mislead it for one line at most. + */ +export function strip(src: string, lang: "c" | "hash"): Stripped { + const nc = src.split(""); + const co = src.split(""); + const blank = (arr: string[], i: number) => { + if (arr[i] !== "\n" && arr[i] !== "\r") arr[i] = " "; + }; + const n = src.length; + let i = 0; + while (i < n) { + const c = src[i]!; + const next = src[i + 1]; + + // comments + if (lang === "c" && c === "/" && next === "/") { + while (i < n && src[i] !== "\n") (blank(nc, i), blank(co, i), i++); + continue; + } + if (lang === "c" && c === "/" && next === "*") { + const end = src.indexOf("*/", i + 2); + const stop = end === -1 ? n : end + 2; + for (; i < stop; i++) (blank(nc, i), blank(co, i)); + continue; + } + if (lang === "hash" && c === "#") { + while (i < n && src[i] !== "\n") (blank(nc, i), blank(co, i), i++); + continue; + } + + // strings + if (lang === "hash" && (src.startsWith('"""', i) || src.startsWith("'''", i))) { + const q = src.slice(i, i + 3); + const end = src.indexOf(q, i + 3); + const stop = end === -1 ? n : end + 3; + for (let j = i + 3; j < stop - 3; j++) blank(co, j); + i = stop; + continue; + } + if (c === '"' || c === "'" || (c === "`" && lang === "c")) { + const multiline = c === "`"; + let j = i + 1; + while (j < n && src[j] !== c && (multiline || src[j] !== "\n")) { + if (src[j] === "\\") j++; + j++; + } + for (let k = i + 1; k < j && k < n; k++) blank(co, k); + i = src[j] === c ? j + 1 : j; + continue; + } + i++; + } + return { noComments: nc.join(""), codeOnly: co.join("") }; +} + +/** + * Markup (HTML, Vue, Svelte, ERB): blank `` comments, then lex each + * ``, + "ui/src/App.vue": `\n`, + }); + check("Vue: a commented-out component does not count", agentStateProblem(r) !== null, true); + } + { + const r = app("vue-ok", { + "lib/schema.rb": `fired = store.trigger("x.y", { "task" => id }, "triage")\n`, + "ui/src/App.vue": `\n`, + }); + check("Vue: rendering the component passes", agentStateProblem(r), null); + } + { + const r = app("no-view", { "schema.py": `fired = store.trigger("x.y", {"task": 1}, capability="triage")\n`, "main.py": "app = 1\n" }); + check("no human View: nothing to show it in, so no verdict", [r.triggers.length, r.viewFiles.length, agentStateProblem(r)], [1, 0, null]); + } + { + const r = app("announce-only", { "schema.mjs": `trigger("x.y", { id })\n`, "src/App.jsx": "export default () =>

;" }); + check("triggers without a capability need no display", agentStateProblem(r), null); + } + { + const r = app("system-owned", { + ".a2app/system-hashes.json": JSON.stringify({ "server.mjs": "sha256:x" }), + "server.mjs": `const t = a2app.trigger({ type, payload, capability: "c" });\n`, + "src/App.jsx": "export default () =>

;", + }); + check("system-owned files are the framework's, not the app's", r.triggers.length, 0); + } + { + const r = app("deps-and-tests", { + "node_modules/pkg/index.js": RUNNER, + "dist/assets/app.js": RUNNER, + "test/ask.test.mjs": RUNNER, + "src/App.jsx": "export default () =>

;", + }); + check("dependencies, build output and tests are not scanned", r.triggers.length, 0); + } + + /* ------------------------------- the shipped starters pass their own gate */ + + for (const [id, wantFollowers] of [ + ["blueprint-react-node", true], + ["blueprint-go-react", true], + ["blueprint-rust-react", true], + ["blueprint-rails-vue", true], + ["blueprint-python-fastapi", false], + ]) { + // A raw template has no ownership canon yet; the toolkit's systemPaths are + // what scaffolding records in it. + const tk = JSON.parse(readFileSync(join(repoRoot, "toolkits", id, "a2app.toolkit.json"), "utf8")); + const r = scanAgentState(join(repoRoot, "toolkits", id, "template"), tk.systemPaths ?? []); + check(`${id}: the starter queues work`, r.triggers.length > 0, true); + check(`${id}: …and ${wantFollowers ? "its View shows it" : "has no View to show it in"}`, [r.followers.length > 0, agentStateProblem(r)], [wantFollowers, null]); + } +} finally { + rmSync(base, { recursive: true, force: true }); +} + +if (failures.length > 0) { + console.error(`\nagent-state gate: ${failures.length} check(s) failed\n`); + for (const f of failures) console.error(` FAIL ${f}`); + process.exit(1); +} +console.log("\nagent-state gate: all checks passed"); diff --git a/framework/cli/test/bridge.test.mjs b/framework/cli/test/bridge.test.mjs index a5ee2fe..7875da3 100644 --- a/framework/cli/test/bridge.test.mjs +++ b/framework/cli/test/bridge.test.mjs @@ -935,6 +935,163 @@ await withApp([], async ({ port }) => { remove(dir); }); +/* --------------------- Windows: an npm .cmd shim is read, never run by cmd.exe */ + +// npm puts every CLI it installs on Windows (claude, codex, gemini) behind a +// .cmd wrapper. Node refuses to spawn one without a shell, and a shell would +// interpret the prompt. So the bridge reads what the shim starts and runs that. +{ + const { shimTarget } = await import(pathToFileURL(resolve(here, "..", "dist", "lib", "harness.js")).href); + const NODE_SHIM = [ + "@ECHO off", + "IF EXIST \"%dp0%\\node.exe\" (", + " SET \"_prog=%dp0%\\node.exe\"", + ") ELSE (", + " SET \"_prog=node\"", + ")", + 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\@anthropic-ai\\claude-code\\cli.js" %*', + ].join("\r\n"); + const dir = join(tmpdir(), "npm-bin"); + check( + "an npm node shim runs its script with this node", + shimTarget(NODE_SHIM, dir, "NODE", () => false), + { file: "NODE", prefixArgs: [join(dir, "node_modules", "@anthropic-ai", "claude-code", "cli.js")] }, + ); + check( + "…or with the node.exe bundled beside it", + shimTarget(NODE_SHIM, dir, "NODE", () => true)?.file, + join(dir, "node.exe"), + ); + check( + "an npm binary shim runs the binary", + shimTarget('@ECHO off\r\n"%dp0%\\node_modules\\x\\bin\\x.exe" %*\r\n', dir, "NODE", () => false), + { file: join(dir, "node_modules", "x", "bin", "x.exe"), prefixArgs: [] }, + ); + check("any other batch file is not a shim", shimTarget("@echo off\r\ncall other.bat %*\r\n", dir, "NODE", () => false), null); + // Pi's pi.cmd, verbatim: a one-line node wrapper an installer wrote by hand. + check( + "a one-line node wrapper runs its script with this node", + shimTarget('@ECHO off\r\nnode "%~dp0pi-launcher.js" %*\r\n', dir, "NODE", () => false), + { file: "NODE", prefixArgs: [join(dir, "pi-launcher.js")] }, + ); + check( + "…and may name a script below it", + shimTarget('@echo off\r\nsetlocal\r\nrem launcher\r\nnode.exe "%~dp0lib\\cli.js" %*\r\n', dir, "NODE", () => false)?.prefixArgs, + [join(dir, "lib", "cli.js")], + ); + check( + "a wrapper that does anything else is not one", + shimTarget('@echo off\r\nset FOO=1\r\nnode "%~dp0pi-launcher.js" %*\r\n', dir, "NODE", () => false), + null, + ); +} + +if (process.platform === "win32") { + const PAYLOAD = '" & echo PWNED > pwned.txt & | ^ %PATH% "'; + + await withApp([task("tsk_shim", "summarize", { title: PAYLOAD })], async ({ port, state }) => { + const dir = makeAppDir(port); + makeHarness(dir); + // The shape npm writes, pointing at the fake harness script. + const shim = join(dir, "fakeharness.cmd"); + writeFileSync( + shim, + '@ECHO off\r\nSETLOCAL\r\nSET "_prog=node"\r\nendLocal & "%_prog%" "%dp0%\\fake-harness.mjs" %*\r\n', + ); + const home = makeHome([{ id: "fake", routes: [{ mode: "headless", command: shim, args: ["{prompt}"] }] }], "fake"); + + const status = firstJson((await cli(AGENT_APP, [dir, "bridge"], { A2APP_HOME: home })).stdout); + ok("an npm shim is reported as startable", status?.ladder?.[0]?.available === true); + + const res = await cli(AGENT_APP, [dir, "bridge", "start", "--once"], { A2APP_HOME: home }); + check("a harness behind an npm .cmd shim is delivered to", res.code, 0); + check("…and the task completes", state.get("tsk_shim").status, "completed"); + const argv = existsSync(join(dir, "argv.json")) ? JSON.parse(readFileSync(join(dir, "argv.json"), "utf8")) : []; + check("…with the prompt still exactly one argument", argv.length, 1); + // The prompt renders the payload as JSON, so its quotes arrive escaped. + ok("…carrying cmd.exe metacharacters verbatim", String(argv[0]).includes(JSON.stringify(PAYLOAD).slice(1, -1))); + ok("…and no shell ran them", !existsSync(join(dir, "pwned.txt")) && !existsSync("pwned.txt")); + rmSync(home, { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); + }); + + await withApp([task("tsk_bat", "summarize", {})], async ({ port, state }) => { + const dir = makeAppDir(port); + const bat = join(dir, "wrapper.bat"); + writeFileSync(bat, "@echo off\r\ncall something-else %*\r\n"); + const home = makeHome([{ id: "fake", routes: [{ mode: "headless", command: bat, args: ["{prompt}"] }] }], "fake"); + + const status = firstJson((await cli(AGENT_APP, [dir, "bridge"], { A2APP_HOME: home })).stdout); + ok("a batch file that is not a shim is reported as not startable", status?.ladder?.[0]?.available === false); + + // Refused before anything is claimed, so the task stays free for a listener + // that can run it, instead of sitting claimed until redelivery runs out. + const res = await cli(AGENT_APP, [dir, "bridge", "start", "--once"], { A2APP_HOME: home }); + ok("the bridge refuses to start", res.code !== 0 && firstJson(res.stdout)?.supported === false); + check("…and never claims the task", state.get("tsk_bat").status, "submitted"); + rmSync(home, { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); + }); +} + +/* ------------------------ the heartbeat keeps the claim, not the conversation */ + +// An agent that reports its own step is telling the person watching the app +// what it is doing. A heartbeat that overwrote it every 20 s with "running in +// " made the app flip between the two for the whole run. +{ + const { deliverAndSettle } = await import(pathToFileURL(resolve(here, "..", "dist", "lib", "bridge.js")).href); + const dir = mkdtempSync(join(tmpdir(), "a2app-bridge-beat-")); + const slow = join(dir, "slow-harness.mjs"); + writeFileSync(slow, "setTimeout(() => process.exit(0), 700);\n"); + const progress = []; + const client = { + progressTask: async (_id, body) => (progress.push(body), { ok: true, status: 200, json: {} }), + getTask: async () => ({ ok: true, status: 200, json: { status: "working" } }), + completeTask: async () => ({ ok: true, status: 200, json: {} }), + }; + await deliverAndSettle( + { + project: { dir }, + client, + profile: { id: "fake", name: "Fake", routes: [] }, + route: { mode: "headless", command: process.execPath, args: [slow, "{prompt}"] }, + prompt: { appRef: dir, appName: "Beat", appId: "beat", closesOnExit: true, cwdIsApp: true }, + taskTimeoutMs: 10_000, + heartbeatMs: 100, + capabilities: null, + dryRun: false, + }, + task("tsk_beat", "summarize", {}), + ); + ok("the run announced who picked it up", String(progress[0]?.step ?? "").startsWith("delivered to fake")); + const beats = progress.slice(1); + ok("the claim was kept alive while it ran", beats.length >= 2); + check("…by heartbeats that carry no step", beats.filter((b) => "step" in b).length, 0); + rmSync(dir, { recursive: true, force: true }); +} + +/* ------------------- a harness that cannot be started fails the task at once */ + +// spawn() throws synchronously for some failures (EINVAL on Windows batch +// files, a NUL byte in an argument everywhere), before any 'error' event +// exists. That used to escape the delivery and leave the task claimed with +// nobody running it. +await withApp([task("tsk_throw", "summarize", {})], async ({ port, state }) => { + const dir = makeAppDir(port); + const harness = makeHarness(dir); + const home = makeHome( + [{ id: "fake", routes: [{ mode: "headless", command: process.execPath, args: [harness, "{prompt}", "bad\u0000arg"] }] }], + "fake", + ); + const res = await cli(AGENT_APP, [dir, "bridge", "start", "--once"], { A2APP_HOME: home }); + check("a harness that cannot be started fails the pass", res.code, 1); + check("…and the task, at once", state.get("tsk_throw").status, "failed"); + check("…saying it could not be started", state.get("tsk_throw").reason, "harness_spawn_failed"); + rmSync(home, { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); +}); + /* -------------------------------------------------------------------- report */ if (failures.length > 0) { diff --git a/skills/creator/SKILL.md b/skills/creator/SKILL.md index cc2fac8..3aea000 100644 --- a/skills/creator/SKILL.md +++ b/skills/creator/SKILL.md @@ -253,8 +253,17 @@ Store the queue task id the trigger returns on the record, and have the View follow it (`GET /api/_a2app/tasks/{id}`, same origin, polled at its `pollAfterMs` only while unfinished). That read needs no credential on a single-user app only. On a multi-user app it answers 401, so there, have the -agent write its progress onto the record and render that instead. Render -every state, where the person asked: +agent write its progress onto the record and render that instead. + +Blueprints that have a queue and a View ship this display as a shared piece: +an agent-task **panel** and **badge** that follow the task and render every +state below. `reference/blueprint.md` names the files, and the starter's +"Ask an agent" button is the worked example. Use them rather than writing +your own. **`validate` enforces this:** an app whose code queues work +(`trigger(…)` with a capability) and whose View neither renders that component +nor reads `/api/_a2app/tasks/` fails the gate. + +Render every state, where the person asked: - `submitted` — waiting to be picked up, with elapsed time. After ~20 s with no claim, say that no agent is listening and name `agent-app

bridge start`. @@ -264,10 +273,23 @@ every state, where the person asked: - `completed` — done, with `result.summary` if there is one. Re-read the data here, because the agent's writes are the answer. - `failed` / `canceled` — the `reason` in words, and a way to ask again. + Identical triggers dedupe to one task even after it has finished, so a + retry that sends the same payload gets the old failure back. Put the + previous task id in the payload (`{ task, previous }`) so asking again is a + new request, and disable the control while a run is open. A badge wherever the record appears in a list ("Agent working") is what lets someone leave the screen and come back. +**Give the result room to be read.** An agent's answer is prose of any length: +several sentences, line breaks, links. It gets its own full-width block, under +the row or on the record's screen. Keep its line breaks, make its links links, +and clamp long text behind "Show more" rather than letting it take over the +page. Never append it to a title cell or put it in an existing narrow column. +There it wraps a word or two per line and stretches the row down the screen. +If the result matters after the run, have the agent write it to a field of its +own and render that field the same way. + ## Finish: boot the candidate, gate, then verify 1. **`agent-app dev`** boots your build on a hidden port with a fresh @@ -276,12 +298,13 @@ someone leave the screen and come back. prints. While this instance is up, every `a2app` operate command and `validate` target it automatically. Then **`agent-app validate`** runs the gate (app-part consistency → build → migrations-on-a-fresh-db → - operations resolve → ownership canon → describe budget, measured on the dev - instance). On errors: read ALL of them, fix ALL of them, re-run `dev` (a - fresh boot picks up backend edits) and `validate` again. A step the gate - reports **UNCHECKED** did not pass — it could not run; with the dev instance - up, nothing should be unchecked. `validate` records the gate pass `promote` - will demand. Never start servers by hand. + operations resolve → agent work shown in the View → ownership canon → + describe budget, measured on the dev instance). On errors: read ALL of + them, fix ALL of them, re-run `dev` (a fresh boot picks up backend edits) + and `validate` again. A step the gate reports **UNCHECKED** did not pass — + it could not run; with the dev instance up, nothing should be unchecked. + `validate` records the gate pass `promote` will demand. Never start servers + by hand. 2. **REALITY CHECK — look at what actually exists, not at what you wrote.** Success messages lie by omission; stored state does not. While the app runs: - `a2app ` → does the root show the modules you declared, with the entity diff --git a/skills/modify/SKILL.md b/skills/modify/SKILL.md index a7cc718..06f5d46 100644 --- a/skills/modify/SKILL.md +++ b/skills/modify/SKILL.md @@ -74,8 +74,10 @@ modification: use the **operator** skill directly, no rebuild. A **code change** operation implementations, non-system `operations.json`, your app's declared event types, and `AGENT_APP.md`. Adding an app→agent trigger: declare the event type first, where your blueprint declares them — an undeclared type is refused - at the moment of firing, inside the operation, in front of a user. Removing one - is a breaking change for anything polling that capability. + at the moment of firing, inside the operation, in front of a user. A trigger + that queues work also needs the View to show that work until it is done (the + creator skill's rule, and a `validate` step). Removing one is a breaking + change for anything polling that capability. - **Schema changes are additive migrations.** The user's data is live — never delete it, never drop-and-recreate collections that hold data. To alter a collection, write a new migration that loads and updates it. (Migration API and diff --git a/skills/walk-verify/SKILL.md b/skills/walk-verify/SKILL.md index 807ff32..0fab69d 100644 --- a/skills/walk-verify/SKILL.md +++ b/skills/walk-verify/SKILL.md @@ -85,6 +85,40 @@ verified by nobody. For each one, walk to it and confirm it is really there: A declared operation that cannot be walked to, or whose blocked reason contradicts the record, is a defect — report it like any other. +**6c. Agent work (app→agent features)** — any control that queues work for an +agent (an operation whose runner calls `trigger(…, capability)`; `validate` +names each one) gets this walk, in the browser, without reloading the page +between steps. You play the agent from a terminal, so nothing depends on a +harness being installed: + +1. **Nobody listening.** With no bridge running, use the control. Within ~1 s + the screen shows the work as waiting, with an elapsed time that ticks. + Wait ~25 s: it now says that no agent is listening and how to start one + (`agent-app bridge start`). "Waiting…" with no end is a defect. +2. **In progress.** `a2app tasks claim `, then + `a2app tasks progress --step "Reading the record"`. The screen + shows the agent working, the step text, and how long it has been running, + with no reload. +3. **Done, with a readable result.** Complete it with a long, realistic + summary: several sentences, a line break and a URL + (`a2app tasks complete --result '{"summary":"…"}'`). The result + appears in its own full-width block that reads like prose. Line breaks are + kept, the link is a link, and long text is clamped behind a "Show more" + (or similar). A result squeezed into an existing narrow column or appended + to a title cell, so it wraps a word or two per line or stretches the row, is + a defect. Any data the agent wrote is shown without a reload. +4. **Failed.** Queue it again and fail it + (`a2app tasks complete --reason "…"`). The reason shows in + words, and there is a way to ask again. Asking again must queue NEW work: + if the screen jumps straight back to the old failure, the retry is a no-op + (identical triggers dedupe to one task). +5. **Come back later.** Leave the screen (or reload) mid-run. Wherever the + record appears in a list, a badge still says an agent is on it. + +Every state must be visible as it happens. One that is missing, frozen, or +only appears after a refresh is a defect against Q5.7. Cite the step above +and what the screen showed instead. + **7. No fabricated data** — an unreachable external source shows an honest empty/offline state, never generated or random values standing in for real data. diff --git a/toolkits/blueprint-go-react/template/AGENT_APP.md b/toolkits/blueprint-go-react/template/AGENT_APP.md index 2b3a17e..5969376 100644 --- a/toolkits/blueprint-go-react/template/AGENT_APP.md +++ b/toolkits/blueprint-go-react/template/AGENT_APP.md @@ -34,7 +34,8 @@ exactly one, and describe's root screen lists them. They are declared in duration where a token exists. - **Shared pieces live in `src/`** — `Icon.jsx` (the icon set), `toast.jsx` (`useToast`), `ConfirmDialog.jsx` (`useConfirm` — never `window.confirm`), - `format.js`, `api.js` (the one fetch wrapper). Screens compose them — one + `format.js`, `api.js` (the one fetch wrapper), `AgentTask.jsx` (work queued + for an agent, shown until it is done). Screens compose them — one implementation per widget, no per-screen copies, no native browser dialogs. Component styles live in `public/ui.css`. - **Every screen renders all of its states**: loading skeletons (sized so diff --git a/toolkits/blueprint-go-react/template/public/ui.css b/toolkits/blueprint-go-react/template/public/ui.css index d795803..ffc9490 100644 --- a/toolkits/blueprint-go-react/template/public/ui.css +++ b/toolkits/blueprint-go-react/template/public/ui.css @@ -114,7 +114,7 @@ body { /* ---------------------------------------------------------------- task list */ .task-list { list-style: none; margin: 0; padding: var(--sp-1) 0; } .task { - display: flex; align-items: center; gap: var(--sp-3); + display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; min-height: var(--row-h); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-subtle); transition: background var(--dur-fast) var(--ease-std), opacity var(--dur-mid) var(--ease-std); @@ -126,6 +126,8 @@ body { .task.done .title { color: var(--text-tertiary); text-decoration: line-through; } .task .due { font-size: var(--fs-xs); color: var(--text-tertiary); font-variant-numeric: tabular-nums; white-space: nowrap; } .task .due.overdue { color: var(--danger-text); font-weight: var(--fw-medium); } +/* agent work gets its own full-width line under the row, never a squeeze into the title */ +.task > .agent-task { flex-basis: 100%; margin-bottom: var(--sp-1); } /* status: a labelled 3-state control; colour is never the only channel (text names the state) */ .status-btn { @@ -196,12 +198,68 @@ body { @keyframes fade-in { from { opacity: 0; } } @keyframes dialog-in { from { opacity: 0; transform: translateY(var(--sp-2)) scale(.98); } } +/* ---------------------------------------------------------------- agent work (AgentTask.jsx) + Work queued for an agent stays visible until it is done. The panel takes the + full width of its container; an agent's result is prose of any length and + never goes in a title cell. */ +.agent-badge { + display: inline-flex; align-items: center; gap: var(--sp-1); + font-size: var(--fs-xs); font-weight: var(--fw-medium); white-space: nowrap; + padding: 2px var(--sp-2); border-radius: var(--r-full); border: 1px solid; +} +.agent-dot { width: 8px; height: 8px; border-radius: var(--r-full); background: currentColor; flex: none; } +.live .agent-dot { animation: pulse 1.2s var(--ease-std) infinite; } + +.tone-neutral { --tone-bg: var(--neutral-bg); --tone-text: var(--neutral-text); --tone-border: var(--neutral-border); } +.tone-info { --tone-bg: var(--info-bg); --tone-text: var(--info-text); --tone-border: var(--info-border); } +.tone-warning { --tone-bg: var(--warning-bg); --tone-text: var(--warning-text); --tone-border: var(--warning-border); } +.tone-success { --tone-bg: var(--success-bg); --tone-text: var(--success-text); --tone-border: var(--success-border); } +.tone-danger { --tone-bg: var(--danger-bg); --tone-text: var(--danger-text); --tone-border: var(--danger-border); } +.agent-badge { background: var(--tone-bg); color: var(--tone-text); border-color: var(--tone-border); } + +.agent-task { + display: flex; flex-direction: column; gap: var(--sp-2); + width: 100%; min-width: 0; padding: var(--sp-3) var(--sp-4); + border: 1px solid var(--tone-border); border-left-width: 3px; border-radius: var(--r-lg); + background: var(--bg-surface); +} +.agent-task-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; color: var(--tone-text); } +.agent-task-state { font-size: var(--fs-sm); font-weight: var(--fw-medium); min-width: 0; overflow-wrap: anywhere; } +.agent-task-step { font-weight: var(--fw-regular); color: var(--text-secondary); } +.agent-task-elapsed { margin-left: auto; font-size: var(--fs-xs); color: var(--text-tertiary); font-variant-numeric: tabular-nums; white-space: nowrap; } +.agent-task-note { margin: 0; font-size: var(--fs-sm); color: var(--text-secondary); overflow-wrap: anywhere; } +.agent-task-note code { font-family: var(--font-mono); font-size: var(--fs-xs); background: var(--bg-sunken); padding: 1px var(--sp-1); border-radius: var(--r-sm); } +.agent-task-failure { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; } +.agent-task-failure .agent-task-note { flex: 1; min-width: 12ch; color: var(--danger-text); } +.agent-task-code { font-family: var(--font-mono); font-size: var(--fs-xs); color: var(--text-tertiary); } +.agent-task-bar { height: 4px; border-radius: var(--r-full); background: var(--bg-sunken); overflow: hidden; } +.agent-task-bar span { display: block; height: 100%; background: var(--info-solid); transition: width var(--dur-mid) var(--ease-std); } + +.agent-result { display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-1); } +.agent-result-text { + margin: 0; max-width: 72ch; white-space: pre-wrap; overflow-wrap: anywhere; + font-size: var(--fs-base); line-height: var(--lh-normal); color: var(--text-primary); +} +.agent-result-text.clamped { display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } +.agent-result-text a { color: var(--accent-text); text-decoration: underline; text-underline-offset: 2px; } +.agent-result-toggle { + font: inherit; font-size: var(--fs-sm); color: var(--accent-text); background: none; border: 0; + padding: var(--sp-1) 0; cursor: pointer; min-height: 24px; +} +.agent-result-toggle:hover { text-decoration: underline; } +@media (prefers-reduced-motion: reduce) { + .live .agent-dot { animation: none; } + .agent-task-bar span { transition: none; } +} + /* ---------------------------------------------------------------- narrow screens */ @media (max-width: 480px) { .app { padding: var(--sp-5) var(--sp-3) var(--sp-10); } .add-form { flex-wrap: wrap; } .add-form .field { flex-basis: 100%; } .task .due { display: none; } + /* the panel under the row says the same thing; the title keeps the room */ + .task .agent-badge { display: none; } /* touch-first: primary controls grow toward the platform norm */ .btn, .input, .select { min-height: 44px; } .btn-icon { width: 40px; height: 40px; } diff --git a/toolkits/blueprint-go-react/template/reference/blueprint.md b/toolkits/blueprint-go-react/template/reference/blueprint.md index 6b8e277..1169ae5 100644 --- a/toolkits/blueprint-go-react/template/reference/blueprint.md +++ b/toolkits/blueprint-go-react/template/reference/blueprint.md @@ -184,7 +184,9 @@ unbounded collection — page with `perPage`. **Shared pieces — `src/`** (one implementation each; import, don't rebuild): `` · `useToast()` → `toast(kind, message)` · `useConfirm()` → `[confirm, confirmElement]` (in-app dialog — never -`window.confirm`) · `api(path, init)` · `fmtDay(dayKey)` · `isPastDay(dayKey)`. +`window.confirm`) · `api(path, init)` · `fmtDay(dayKey)` · `isPastDay(dayKey)` · +`` / `` / `useAgentTask(id)` (`AgentTask.jsx` — +work queued for an agent, shown until it is done; see **App→agent**). Component styles are plain CSS classes in `public/ui.css`. **Tokens — `public/tokens.css`:** the kit sheet — `--agent-app-*` foundation → @@ -261,13 +263,39 @@ The starter's `request-triage` operation is the worked example. The queue only moves when something is listening: `agent-app bridge start`, or a harness polling `a2app tasks next --wait`. -**Show the queued work in the View.** Keep the returned `taskId` on the record -and follow it from the UI with a same-origin `GET /api/_a2app/tasks/{id}`, polled -only while it is unfinished. That read needs no credential only on a -single-user app. On a multi-user app it answers 401, so show what the agent -writes to the record instead. Render queued → working (`progress.step`) → done or -failed (`reason`, plus a way to ask again). The creator skill lists the states. -A button that goes quiet after it queues work looks broken. +**Show the queued work in the View — with `src/AgentTask.jsx`.** An agent run +takes seconds to minutes, so the control that queued it has to show where it is +until it is done. Keep the returned `taskId` on the record (a `readOnly` string +field the runner sets, like the starter's `agentTask`), then render it: + +```jsx +import { AgentTaskBadge, AgentTaskPanel } from "./AgentTask.jsx"; + + // in the list row, beside the title + +``` + +The panel follows `GET /api/_a2app/tasks/{id}` while the run is unfinished and +renders every state the creator skill lists: waiting with elapsed time (and "no +agent is listening" after ~20 s), the agent's step and running time, the +`result.summary` in its own readable block, and the failure `reason` with "Ask +again". `useAgentTask(id)` gives you the same state to disable a control while a +run is open. The starter's "Ask an agent" button is the worked example. + +Never put the result in a title cell or a narrow column. It is prose of any +length, and the panel is where it goes. + +On a single-user app (`authMode: "none"`) the View needs no credential for the +task read. **On a multi-user app it answers 401**, and the View has no other +path to the queue yet. There, have the agent write its progress onto the record +and pass it as `progress={{ status, step, summary, reason }}`; the panel and +badge render it the same way. A 404 means the task has been pruned, so the +indicator disappears. + +**`validate` checks this.** Its "agent work shown in the View" step fails an +app whose code queues work (`trigger(…)` with a capability) when no View code +renders the component or reads `/api/_a2app/tasks/`. ## Build, run, gate @@ -278,7 +306,7 @@ hand. ```bash agent-app dev # boot the candidate on a hidden port: fresh seeded store, prints the dev URL -agent-app validate # framework files → build → go vet + self-test → operations resolve → ownership canon → describe budget (on dev) +agent-app validate # framework files → build → go vet + self-test → operations resolve → agent work shown in View → ownership canon → describe budget (on dev) agent-app serve # launch LIVE as a managed, health-polled background process; prints the URL agent-app promote # requires the gate pass; backup, go run . --promote-check, destroys the dev instance ``` diff --git a/toolkits/blueprint-go-react/template/schema.go b/toolkits/blueprint-go-react/template/schema.go index f175082..763bb9a 100644 --- a/toolkits/blueprint-go-react/template/schema.go +++ b/toolkits/blueprint-go-react/template/schema.go @@ -30,6 +30,10 @@ var ENTITIES = map[string]M{ {"name": "due", "type": "string", "max": 10, "dayKey": true}, {"name": "notes", "type": "string", "max": 2000}, {"name": "created", "type": "datetime", "readOnly": true}, + // The queue task an agent is (or was last) working on for this + // record. The runner sets it; the View follows it, so the person can + // see the work they asked for until it is done. + {"name": "agentTask", "type": "string", "max": 64, "readOnly": true}, }, }, } @@ -144,15 +148,30 @@ var OPERATION_RUNNERS = map[string]OperationRunner{ // never a copy of the data, which would be stale by the time it is read. // Nothing here can widen what the agent may do: the payload is data on the // other side, and the capability names the kind of work, not a command. + // + // Identical triggers dedupe to ONE task, even after it has finished, so + // asking again with the same payload would hand back the old failure. + // Naming the previous task makes each request a new occurrence. The View + // disables the control while a run is open, so a double click cannot + // queue two. + // + // The task id goes on the record so the View can show the work until it is + // done (src/AgentTask.jsx). The validate gate checks that it does. "request-triage": func(args M, _ M, store *Store) (any, error) { task := store.getRecord("tasks", getStr(args, "task")) if task == nil { return M{"ok": false, "reason": "no such task"}, nil } - fired, err := store.trigger("task.needs_triage", M{"task": task["id"]}, "triage") + payload := M{"task": task["id"]} + if prev := getStr(task, "agentTask"); prev != "" { + payload["previous"] = prev + } + fired, err := store.trigger("task.needs_triage", payload, "triage") if err != nil { return nil, err } + task["agentTask"] = fired["taskId"] + store.putRecord("tasks", task) return M{"ok": true, "task": task["id"], "queued": fired["taskId"]}, nil }, } diff --git a/toolkits/blueprint-go-react/template/src/AgentTask.jsx b/toolkits/blueprint-go-react/template/src/AgentTask.jsx new file mode 100644 index 0000000..d23ca67 --- /dev/null +++ b/toolkits/blueprint-go-react/template/src/AgentTask.jsx @@ -0,0 +1,439 @@ +/** + * Agent work, shown until it is done (AGENT-OWNED shared piece — @a2app-kit agent-task). + * + * An agent run takes seconds to minutes. Whatever control queued it has to + * keep showing where it is, or the person is left with no sign anything is + * happening. This is that display, so a feature that queues work does not + * rebuild it: + * + * in a list row, beside the title + * + * + * Both follow `GET /api/_a2app/tasks/{id}` at the task's own `pollAfterMs`, + * only while it is unfinished, and share one poller per id, so a badge and a + * panel for the same task cost one request. Every state renders: + * + * submitted waiting, with elapsed time; after ~20 s unclaimed, says no + * agent is listening and how to start one + * working `progress.step` (and `percent` as a bar) and running time + * input-required the agent is waiting on someone + * completed `result.summary` in its own full-width block: line breaks + * kept, links clickable, long text clamped behind "Show more". + * No summary (the agent never closed the task itself, so the + * bridge did)? The run's printed output, labelled as such. + * failed/canceled the `reason` in words (the queue's and bridge's own codes + * are translated), and "Ask again" when `onRetry` is given + * + * A 404 means the task was pruned, so the indicator disappears. On a + * multi-user app the read answers 401 — the View has no credential for the + * queue. There, have the agent write its progress onto the record and pass it + * as `progress={{ status, step, summary, reason }}`; it renders the same way, + * with or without a `taskId`. + * + * Never put `result.summary` in a title cell or a table column. It is prose of + * any length and belongs in the panel. + */ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { api } from "./api.js"; +import Icon from "./Icon.jsx"; + +const FINISHED = new Set(["completed", "failed", "canceled"]); +/** How long a task may sit unclaimed before we say nobody is listening. */ +export const NO_LISTENER_MS = 20_000; +const DEFAULT_POLL_MS = 2000; +const RETRY_MS = 5000; +/** Slower polling when nothing is likely to change soon: an unclaimed task, or a hidden tab. */ +const SLOW_POLL_MS = 10_000; +const CLAMP_CHARS = 280; +const CLAMP_LINES = 4; + +const LABEL = { + submitted: "Waiting for an agent", + unheard: "No agent is listening", + working: "Agent working", + "input-required": "Agent needs input", + completed: "Agent done", + failed: "Agent failed", + canceled: "Agent run canceled", +}; +const TONE = { + submitted: "neutral", + unheard: "warning", + working: "info", + "input-required": "warning", + completed: "success", + failed: "danger", + canceled: "neutral", +}; + +/** + * Reasons the queue and the bridge write themselves, in words. An agent's own + * reason is prose already; these are codes, and a code is not an explanation. + */ +const REASON_WORDS = { + redelivery_exhausted: + "It was handed to an agent several times and never finished. Check that the bridge can start your agent, then ask again.", + harness_unavailable: "The bridge could not find an agent to run on this machine.", + harness_spawn_failed: "The bridge could not start the agent.", + harness_exited_nonzero: "The agent stopped with an error before reporting back.", + harness_timeout: "The agent took too long and was stopped.", + unspecified: "The agent did not say why.", +}; + +/** A failure reason a person can read, plus the raw code when it was one. */ +export function reasonInWords(reason, canceled = false) { + if (!reason) return { text: canceled ? "The run was canceled." : "The agent did not say why.", code: null }; + if (REASON_WORDS[reason]) return { text: REASON_WORDS[reason], code: reason }; + // An unknown code (snake_case, no spaces) still gets a sentence around it. + if (/^[a-z0-9]+(?:_[a-z0-9]+)+$/.test(reason)) return { text: "The run stopped before it finished.", code: reason }; + return { text: reason, code: null }; +} + +/* ------------------------------------------------------------- the poller */ + +// id -> { snap, listeners, timer, stopped }. `snap` is replaced, never +// mutated, so useSyncExternalStore sees each change. +const watchers = new Map(); +const IDLE = { phase: "idle", task: null }; +const LOADING = { phase: "loading", task: null }; + +function watcherFor(id) { + let w = watchers.get(id); + if (!w) { + w = { snap: LOADING, listeners: new Set(), timer: null, stopped: false }; + watchers.set(id, w); + poll(id, w); + } + return w; +} + +function publish(w, snap) { + w.snap = snap; + for (const l of w.listeners) l(); +} + +async function poll(id, w) { + if (w.stopped) return; + let next = DEFAULT_POLL_MS; + try { + const task = await api(`/api/_a2app/tasks/${encodeURIComponent(id)}`); + if (w.stopped) return; + publish(w, { phase: "ok", task }); + if (FINISHED.has(task.status)) return; // settled: nothing more will change + next = task.pollAfterMs ?? DEFAULT_POLL_MS; + const waited = Date.now() - (ms(task.createdAt) ?? Date.now()); + if (task.status === "submitted" && waited > NO_LISTENER_MS) next = Math.max(next, SLOW_POLL_MS); + } catch (err) { + if (w.stopped) return; + if (err?.status === 404) return publish(w, { phase: "gone", task: null }); + if (err?.status === 401 || err?.status === 403) return publish(w, { phase: "unauthorized", task: w.snap.task }); + // Network trouble or rate limiting: keep what we last knew and try again later. + publish(w, { phase: "stale", task: w.snap.task, error: err?.message }); + next = err?.status === 429 ? SLOW_POLL_MS : RETRY_MS; + } + if (typeof document !== "undefined" && document.hidden) next = Math.max(next, SLOW_POLL_MS); + w.timer = setTimeout(() => poll(id, w), next); +} + +function subscribe(id, listener) { + if (!id) return () => {}; + const w = watcherFor(id); + w.listeners.add(listener); + return () => { + w.listeners.delete(listener); + // Stop only once nobody has re-subscribed by the next tick: a re-render + // (or a row moving between lists) unsubscribes and subscribes again at + // once, and tearing down in between would restart polling from scratch. + setTimeout(() => { + if (w.listeners.size > 0 || watchers.get(id) !== w) return; + w.stopped = true; + clearTimeout(w.timer); + watchers.delete(id); + }, 0); + }; +} + +/** + * Follow one queued task. Returns `{ phase, task }`: phase is loading · ok · + * stale (last known, the read is failing) · gone (404) · unauthorized (401) · + * idle (no id). + */ +export function useAgentTask(taskId) { + const sub = useCallback((l) => subscribe(taskId, l), [taskId]); + const snap = useCallback(() => (taskId ? (watchers.get(taskId)?.snap ?? LOADING) : IDLE), [taskId]); + return useSyncExternalStore(sub, snap); +} + +/* ---------------------------------------------------------- shared logic */ + +/** Seconds ticking while something is unfinished; frozen once it settles. */ +function useNow(running) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!running) return undefined; + const t = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(t); + }, [running]); + return now; +} + +/** "8s" · "1m 05s" · "1h 02m". */ +export function fmtElapsed(ms) { + const s = Math.max(0, Math.floor(ms / 1000)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`; + return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`; +} + +const ms = (iso) => { + const t = iso ? Date.parse(iso) : NaN; + return Number.isNaN(t) ? null : t; +}; + +/** + * What a harness printed, when the agent closed no task itself: the bridge then + * completes it with the run's output instead of a summary. Terminal colour + * codes are noise in a page. + */ +function printedOutput(result) { + if (typeof result?.output !== "string") return null; + // eslint-disable-next-line no-control-regex + const text = result.output.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").trim(); + return text || null; +} + +/** + * One view of a task, whichever way it arrived: the queue's own record, or the + * progress an agent wrote onto the app's record. + */ +function viewOf(state, progress, now) { + const task = state.task; + if (task) { + const created = ms(task.createdAt); + const claimed = ms(task.claim?.claimedAt); + const updated = ms(task.updatedAt); + let key = task.status; + let elapsed = null; + if (key === "submitted") { + elapsed = created !== null ? now - created : null; + if (elapsed !== null && elapsed >= NO_LISTENER_MS) key = "unheard"; + } else if (key === "working" || key === "input-required") { + const since = claimed ?? created; + elapsed = since !== null ? now - since : null; + } else if (created !== null && updated !== null) { + elapsed = updated - created; + } + return { + key, + elapsed, + step: task.progress?.step ?? null, + // The queue reports 0 until the agent says otherwise; only real progress draws a bar. + percent: task.progress?.percent > 0 ? task.progress.percent : null, + summary: task.result?.summary ?? null, + output: printedOutput(task.result), + reason: task.reason ?? null, + }; + } + if (progress?.status) { + return { + key: progress.status, + elapsed: null, + step: progress.step ?? null, + percent: progress.percent > 0 ? progress.percent : null, + summary: progress.summary ?? null, + output: null, + reason: progress.reason ?? null, + }; + } + return null; +} + +/** Calls `fn(task)` once when a task we watched while unfinished settles. */ +function useSettled(task, fn) { + const prev = useRef(task?.status); + const fnRef = useRef(fn); + fnRef.current = fn; + useEffect(() => { + const was = prev.current; + prev.current = task?.status; + if (task && FINISHED.has(task.status) && was !== undefined && !FINISHED.has(was)) fnRef.current?.(task); + }, [task?.status, task]); +} + +/* ------------------------------------------------------------- the badge */ + +/** A compact state pill for a list row, so someone can leave the screen and come back. */ +export function AgentTaskBadge({ taskId, progress }) { + const state = useAgentTask(taskId); + const live = state.task && !FINISHED.has(state.task.status); + const now = useNow(Boolean(live)); + if (state.phase === "gone") return null; + const v = viewOf(state, progress, now); + if (!v) return null; + const label = LABEL[v.key] ?? v.key; + return ( + + + ); +} + +/* ------------------------------------------------------------- the panel */ + +/** + * The full display: state, elapsed time, progress, and the result in a + * readable block. Give it the whole width of its container — under a list + * row, not inside one of its cells. + */ +export function AgentTaskPanel({ + taskId, + progress, + title = "Agent", + onSettled, + onRetry, + retrying = false, + bridgeHint = "agent-app bridge start", +}) { + const state = useAgentTask(taskId); + const live = state.task && !FINISHED.has(state.task.status); + const now = useNow(Boolean(live)); + useSettled(state.task, onSettled); + + if (state.phase === "gone") return null; + if (state.phase === "loading" && !progress?.status) { + return ( +
+
+
+
+ ); + } + + const v = viewOf(state, progress, now); + if (!v) { + if (state.phase === "unauthorized") { + return ( +
+

+ This app needs sign-in to read the agent queue, so progress shows here only once the agent records it. +

+
+ ); + } + return null; + } + + const tone = TONE[v.key] ?? "neutral"; + const label = LABEL[v.key] ?? v.key; + const finished = FINISHED.has(v.key); + const elapsedText = + v.elapsed === null ? null : finished ? `took ${fmtElapsed(v.elapsed)}` : fmtElapsed(v.elapsed); + + return ( +
+
+
+ + {v.percent !== null && !finished && ( +
+ +
+ )} + + {v.key === "unheard" && ( +

+ Nothing has picked this up. Work is only delivered while an agent is listening — start one with{" "} + {bridgeHint}. +

+ )} + {v.key === "input-required" && ( +

The agent is waiting for someone to answer it before it can continue.

+ )} + + {v.key === "completed" && v.summary && } + {v.key === "completed" && !v.summary && v.output && ( + + )} + + {(v.key === "failed" || v.key === "canceled") && ( +
+ + {onRetry && ( + + )} +
+ )} +
+ ); +} + +function FailureReason({ reason, canceled }) { + const { text, code } = reasonInWords(reason, canceled); + return ( +

+ {text} + {code && ({code})} +

+ ); +} + +/* ------------------------------------------------------------ the result */ + +const URL_RE = /(https?:\/\/[^\s<>"']+)/g; + +/** Plain text with its line breaks kept and its links clickable. */ +function Linkified({ text }) { + return text.split(URL_RE).map((part, i) => { + if (i % 2 === 0) return part; + // Sentence punctuation after a link is not part of it. + const m = part.match(/^(.*?)([.,;:!?)\]]*)$/); + const href = m ? m[1] : part; + return ( + + + {href} + + {m ? m[2] : ""} + + ); + }); +} + +/** An agent's answer: its own block, readable at any length. */ +export function AgentResult({ text, note }) { + const long = text.length > CLAMP_CHARS || text.split("\n").length > CLAMP_LINES; + const [open, setOpen] = useState(false); + return ( +
+ {note &&

{note}

} +

+ +

+ {long && ( + + )} +
+ ); +} diff --git a/toolkits/blueprint-go-react/template/src/App.jsx b/toolkits/blueprint-go-react/template/src/App.jsx index b6b6417..5ddf4be 100644 --- a/toolkits/blueprint-go-react/template/src/App.jsx +++ b/toolkits/blueprint-go-react/template/src/App.jsx @@ -11,6 +11,11 @@ * staleness. A CODE change it handles itself (reload when nothing is unsaved). * A DATA change it only announces (`a2app:datachange`) — because only this * file knows how to re-read without discarding a form someone is filling in. + * + * Agent work: "Ask an agent" queues a triage task (the `request-triage` + * operation). The row then shows it until it is done — a badge beside the + * title, and the full-width AgentTaskPanel under the row with the state, the + * elapsed time and, at the end, the agent's answer. */ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "./api.js"; @@ -18,6 +23,7 @@ import { hasUnsavedInput } from "./updater.js"; import Icon from "./Icon.jsx"; import { useToast } from "./toast.jsx"; import { useConfirm } from "./ConfirmDialog.jsx"; +import { AgentTaskBadge, AgentTaskPanel, useAgentTask } from "./AgentTask.jsx"; import { STATUS_LABEL, NEXT_STATUS, fmtDay, isPastDay } from "./format.js"; const API = "/api/collections/tasks/records"; @@ -42,6 +48,7 @@ export default function App() { const [phase, setPhase] = useState("loading"); // loading | ready | error const [errorMessage, setErrorMessage] = useState(""); const [busy, setBusy] = useState(() => new Set()); // record ids with a write in flight + const [asking, setAsking] = useState(() => new Set()); // record ids with an agent request in flight const [adding, setAdding] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [title, setTitle] = useState(""); @@ -192,6 +199,33 @@ export default function App() { } }; + /** Re-read one record and put the stored version in place. */ + const reloadOne = async (id) => { + try { + const stored = await api(`${API}/${encodeURIComponent(id)}`); + setItems((prev) => prev.map((t) => (t.id === id ? stored : t))); + } catch { + /* the list's next refresh picks it up */ + } + }; + + const askAgent = async (task) => { + setAsking((prev) => new Set(prev).add(task.id)); + try { + const res = await api("/api/ops/request-triage", { method: "POST", body: JSON.stringify({ task: task.id }) }); + if (res?.ok === false) throw new Error(res.reason ?? "The app refused the request."); + await reloadOne(task.id); // the stored record carries the task id the panel follows + } catch (err) { + toast("error", `Could not ask an agent about "${task.title}". ${err.message}`); + } finally { + setAsking((prev) => { + const next = new Set(prev); + next.delete(task.id); + return next; + }); + } + }; + const deleteTask = async (task) => { const confirmed = await confirm({ title: "Delete this task?", @@ -317,7 +351,10 @@ export default function App() { key={task.id} task={task} busy={busy.has(task.id)} + asking={asking.has(task.id)} onAdvance={() => advanceStatus(task)} + onAsk={() => askAgent(task)} + onAgentSettled={() => reloadOne(task.id)} onDelete={() => deleteTask(task)} /> ))} @@ -351,9 +388,12 @@ export default function App() { /* ------------------------------------------------------------ components */ -function TaskRow({ task, busy, onAdvance, onDelete }) { +function TaskRow({ task, busy, asking, onAdvance, onAsk, onAgentSettled, onDelete }) { const st = task.status ?? "todo"; const overdue = task.due && isPastDay(task.due) && st !== "done"; + // Shares the panel's poller: knowing whether a run is in flight costs no extra request. + const agent = useAgentTask(task.agentTask); + const agentBusy = asking || (agent.task && !["completed", "failed", "canceled"].includes(agent.task.status)); return (
  • {/* status: a labelled 3-state control; colour is never the only channel */} @@ -369,14 +409,36 @@ function TaskRow({ task, busy, onAdvance, onDelete }) { {task.title} + {task.agentTask && } {task.due && ( {overdue ? `Overdue · ${fmtDay(task.due)}` : `Due ${fmtDay(task.due)}`} )} + {st !== "done" && ( + + )} + {task.agentTask && ( + + )}
  • ); } diff --git a/toolkits/blueprint-go-react/template/src/Icon.jsx b/toolkits/blueprint-go-react/template/src/Icon.jsx index 6525eea..f97e6cc 100644 --- a/toolkits/blueprint-go-react/template/src/Icon.jsx +++ b/toolkits/blueprint-go-react/template/src/Icon.jsx @@ -11,6 +11,7 @@ const ICON_PATHS = { info: "M8 7.5V11m0-5.5v-.01M14.5 8a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0Z", inbox: "M1.5 9.5h3l1 2h5l1-2h3M2.5 3.5h11l1 6v3a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-3l1-6Z", refresh: "M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2.5v2.6h-2.6", + spark: "M8 1.5v3M8 11.5v3M1.5 8h3M11.5 8h3M3.4 3.4l2.1 2.1M10.5 10.5l2.1 2.1M3.4 12.6l2.1-2.1M10.5 5.5l2.1-2.1", }; export default function Icon({ name, size = 16 }) { diff --git a/toolkits/blueprint-python-fastapi/template/reference/blueprint.md b/toolkits/blueprint-python-fastapi/template/reference/blueprint.md index ab52fe8..345523a 100644 --- a/toolkits/blueprint-python-fastapi/template/reference/blueprint.md +++ b/toolkits/blueprint-python-fastapi/template/reference/blueprint.md @@ -200,13 +200,17 @@ something — nothing is queued and no agent is handed it. The starter's The queue only moves when something is listening: `agent-app bridge start`, or a harness polling `a2app tasks next --wait`. -**Show the queued work in the View.** Keep the returned `taskId` on the record -and follow it from the UI with a same-origin `GET /api/_a2app/tasks/{id}`, polled -only while it is unfinished. That read needs no credential only on a -single-user app. On a multi-user app it answers 401, so show what the agent -writes to the record instead. Render queued → working (`progress.step`) → done or -failed (`reason`, plus a way to ask again). The creator skill lists the states. -A button that goes quiet after it queues work looks broken. +**Show the queued work in the View, if you add one.** This stack ships no human +View, so the starter only keeps the returned `taskId` on the record (its +`agentTask` field) for an agent or a later View to follow. If you add a View, +it has to show that work until it is done: follow +`GET /api/_a2app/tasks/{id}` while it is unfinished and render queued (with +elapsed time, and "no agent is listening" after ~20 s) → working +(`progress.step`) → done (`result.summary` in its own full-width block, never a +title cell) or failed (`reason`, plus a way to ask again). The creator skill +lists the states. On a multi-user app that read answers 401, so show what the +agent writes to the record instead. `validate`'s "agent work shown in the View" +step fails an app whose View does neither. ## Build, run, gate diff --git a/toolkits/blueprint-python-fastapi/template/schema.py b/toolkits/blueprint-python-fastapi/template/schema.py index 53c72e5..2924a08 100644 --- a/toolkits/blueprint-python-fastapi/template/schema.py +++ b/toolkits/blueprint-python-fastapi/template/schema.py @@ -20,6 +20,10 @@ {"name": "due", "type": "string", "max": 10, "dayKey": True}, {"name": "notes", "type": "string", "max": 2000}, {"name": "created", "type": "datetime", "readOnly": True}, + # The queue task an agent is (or was last) working on for this + # record. The runner sets it, so a View added later can follow it + # and show the work until it is done. + {"name": "agentTask", "type": "string", "max": 64, "readOnly": True}, ], }, } @@ -104,11 +108,20 @@ def _complete_task(args, ctx, store): # copy of the data, which would be stale by the time it is read. Nothing here # can widen what the agent may do: the payload is data on the other side, and # the capability names the kind of work, not a command to run. +# +# Identical triggers dedupe to ONE task, even after it has finished, so asking +# again with the same payload would hand back the old failure. Naming the +# previous task makes each request a new occurrence. def _request_triage(args, ctx, store): task = store.get_record("tasks", args.get("task")) if task is None: return {"ok": False, "reason": "no such task"} - fired = store.trigger("task.needs_triage", {"task": task["id"]}, capability="triage") + payload = {"task": task["id"]} + if task.get("agentTask"): + payload["previous"] = task["agentTask"] + fired = store.trigger("task.needs_triage", payload, capability="triage") + task["agentTask"] = fired["taskId"] + store.put_record("tasks", task) return {"ok": True, "task": task["id"], "queued": fired["taskId"]} diff --git a/toolkits/blueprint-rails-vue/template/.gitignore b/toolkits/blueprint-rails-vue/template/.gitignore index 626016c..ee5465a 100644 --- a/toolkits/blueprint-rails-vue/template/.gitignore +++ b/toolkits/blueprint-rails-vue/template/.gitignore @@ -19,8 +19,9 @@ data/ node_modules/ # The built View (the Vite build output of ui/) — rebuilt by the pipeline, -# never committed, never hand-edited. ui/ is the source. -public/ +# never committed, never hand-edited. ui/ is the source. Anchored to this +# directory: ui/public/ (tokens.css, ui.css) is source, not build output. +/public/ # Rails runtime artifacts (the minted secret lives in tmp/). log/ diff --git a/toolkits/blueprint-rails-vue/template/AGENT_APP.md b/toolkits/blueprint-rails-vue/template/AGENT_APP.md index 615c2dd..8c2de60 100644 --- a/toolkits/blueprint-rails-vue/template/AGENT_APP.md +++ b/toolkits/blueprint-rails-vue/template/AGENT_APP.md @@ -34,7 +34,9 @@ exactly one, and describe's root screen lists them. They are declared in - **Shared pieces live in `ui/src/`** — `components/Icon.vue` (the icon set), `toast.js` + `components/ToastRegion.vue` (`useToast`), `confirm.js` + `components/ConfirmDialog.vue` (`useConfirm` — never `window.confirm`), - `format.js`, `api.js` (the one fetch wrapper). Screens compose them — one + `format.js`, `api.js` (the one fetch wrapper), `agentTask.js` + + `components/AgentTask{Panel,Badge}.vue` (work queued for an agent, shown + until it is done). Screens compose them — one implementation per widget, no per-screen copies, no native browser dialogs. Component styles live in `ui/public/ui.css`. - **Every screen renders all of its states**: loading skeletons (sized so diff --git a/toolkits/blueprint-rails-vue/template/lib/a2app_schema.rb b/toolkits/blueprint-rails-vue/template/lib/a2app_schema.rb index 38d49b8..c53e934 100644 --- a/toolkits/blueprint-rails-vue/template/lib/a2app_schema.rb +++ b/toolkits/blueprint-rails-vue/template/lib/a2app_schema.rb @@ -21,6 +21,10 @@ module A2appSchema { "name" => "due", "type" => "string", "max" => 10, "dayKey" => true }, { "name" => "notes", "type" => "string", "max" => 2000 }, { "name" => "created", "type" => "datetime", "readOnly" => true }, + # The queue task an agent is (or was last) working on for this record. + # The runner sets it; the View follows it, so the person can see the + # work they asked for until it is done. + { "name" => "agentTask", "type" => "string", "max" => 64, "readOnly" => true }, ], }, }.freeze @@ -123,10 +127,24 @@ module A2appSchema # read. Nothing here can widen what the agent may do: the payload is data # on the other side, and the capability names the kind of work, not a # command to run. + # + # Identical triggers dedupe to ONE task, even after it has finished, so + # asking again with the same payload would hand back the old failure. + # Naming the previous task makes each request a new occurrence. The View + # disables the control while a run is open, so a double click cannot + # queue two. + # + # The task id goes on the record so the View can show the work until it + # is done (ui/src/components/AgentTaskPanel.vue). The validate gate checks + # that it does. "request-triage" => lambda do |args, _ctx, store| task = store.get_record("tasks", args["task"]) next { "ok" => false, "reason" => "no such task" } if task.nil? - fired = store.trigger("task.needs_triage", { "task" => task["id"] }, "triage") + payload = { "task" => task["id"] } + payload["previous"] = task["agentTask"] if task["agentTask"].is_a?(String) && !task["agentTask"].empty? + fired = store.trigger("task.needs_triage", payload, "triage") + task["agentTask"] = fired["taskId"] + store.put_record("tasks", task) { "ok" => true, "task" => task["id"], "queued" => fired["taskId"] } end, }.freeze diff --git a/toolkits/blueprint-rails-vue/template/reference/blueprint.md b/toolkits/blueprint-rails-vue/template/reference/blueprint.md index 5cd1738..e83e8a6 100644 --- a/toolkits/blueprint-rails-vue/template/reference/blueprint.md +++ b/toolkits/blueprint-rails-vue/template/reference/blueprint.md @@ -191,7 +191,10 @@ unbounded collection — page with `perPage`. **Shared pieces — `ui/src/`** (one implementation each; import, don't rebuild): `` · `useToast()` → `toast(kind, message)` · `useConfirm()` → `{ request, confirm, done }` (in-app dialog — never -`window.confirm`) · `api(path, init)` · `fmtDay(dayKey)` · `isPastDay(dayKey)`. +`window.confirm`) · `api(path, init)` · `fmtDay(dayKey)` · `isPastDay(dayKey)` · +`` / `` / `useAgentTask(() => id)` +(`components/AgentTask*.vue` + `agentTask.js` — work queued for an agent, shown +until it is done; see **App→agent**). Component styles are plain CSS classes in `ui/public/ui.css`. **Tokens — `ui/public/tokens.css`:** the kit sheet — `--agent-app-*` foundation @@ -265,13 +268,41 @@ starter's `request-triage` operation is the worked example. The queue only moves when something is listening: `agent-app bridge start`, or a harness polling `a2app tasks next --wait`. -**Show the queued work in the View.** Keep the returned `taskId` on the record -and follow it from the UI with a same-origin `GET /api/_a2app/tasks/{id}`, polled -only while it is unfinished. That read needs no credential only on a -single-user app. On a multi-user app it answers 401, so show what the agent -writes to the record instead. Render queued → working (`progress.step`) → done or -failed (`reason`, plus a way to ask again). The creator skill lists the states. -A button that goes quiet after it queues work looks broken. +**Show the queued work in the View — with the agent-task components.** An +agent run takes seconds to minutes, so the control that queued it has to show +where it is until it is done. Keep the returned `taskId` on the record (a +`readOnly` string field the runner sets, like the starter's `agentTask`), then +render it: + +```vue + + + :can-retry="true" :retrying="asking" + @settled="reloadRecord" @retry="askAgain" @status="trackOpenRun" /> +``` + +`ui/src/components/AgentTaskPanel.vue` and `AgentTaskBadge.vue` are built on +`ui/src/agentTask.js` (the shared poller and `useAgentTask(() => id)`) and style +themselves from `ui/src/agentTask.css`. The panel follows +`GET /api/_a2app/tasks/{id}` while the run is unfinished and renders every state +the creator skill lists: waiting with elapsed time (and "no agent is listening" +after ~20 s), the agent's step and running time, the `result.summary` in its own +readable block, and the failure `reason` with "Ask again". It emits `status` so +the parent can disable a control while a run is open. The starter's "Ask an +agent" button is the worked example. + +Never put the result in a title cell or a narrow column. It is prose of any +length, and the panel is where it goes. + +That read needs no credential only on a single-user app. **On a multi-user app +it answers 401**: have the agent write its progress onto the record and pass it +as `:progress="{ status, step, summary, reason }"`; the panel and badge render +it the same way. A 404 means the task has been pruned, so the indicator +disappears. + +**`validate` checks this.** Its "agent work shown in the View" step fails an +app whose code queues work (`trigger(…)` with a capability) when no View code +renders the components or reads `/api/_a2app/tasks/`. ## Build, run, gate @@ -280,7 +311,7 @@ health at `/api/_a2app`). Never start a server by hand. ```bash agent-app dev # boot the candidate on a hidden port: fresh seeded store, prints the dev URL -agent-app validate # framework files → build (vite + ruby -c) → adapter self-test → operations resolve → ownership canon → describe budget (on dev) +agent-app validate # framework files → build (vite + ruby -c) → adapter self-test → operations resolve → agent work shown in View → ownership canon → describe budget (on dev) agent-app serve # launch LIVE as a managed, health-polled background process; prints the URL agent-app promote # requires the gate pass; backup, scripts/promote_check.rb, destroys the dev instance ``` diff --git a/toolkits/blueprint-rails-vue/template/ui/public/tokens.css b/toolkits/blueprint-rails-vue/template/ui/public/tokens.css new file mode 100644 index 0000000..60258ba --- /dev/null +++ b/toolkits/blueprint-rails-vue/template/ui/public/tokens.css @@ -0,0 +1,232 @@ +/* ============================================================================ + Agent App design tokens — shared visual foundation (AGENT-OWNED to re-point, + ships in lockstep with @craftos/agent-app-kit's DESIGN_TOKENS_CSS — keep this body identical). + + The kit's warm-neutral --agent-app-* system. Components and app CSS read ONLY + semantic tokens, never hardcoded colors, so light/dark AND every [data-style] + pack keep working with no per-component edits. Three layers: + + 1. BASE globals — app font smoothing, selection color, themed scrollbars. + 2. --agent-app-* FOUNDATION — brand identity: light + dark primitives, + color-mix-derived surfaces, and the named style packs. + 3. SEMANTIC + STRUCTURAL BRIDGE — surfaces, state tones, and the + spacing/type/radius/elevation/motion scales, all re-pointed onto the + foundation so a theme is a re-point (or a [data-style] switch), never a + component edit. + + Theme by re-pointing the FOUNDATION or selecting a [data-style]; never + hardcode a color in a component. + ============================================================================ */ + +/* 1. BASE globals ---------------------------------------------------------- */ +::selection { background-color: color-mix(in srgb, var(--agent-app-accent) 26%, transparent); } +* { scrollbar-width: thin; scrollbar-color: var(--agent-app-border) transparent; } +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background-color: var(--agent-app-border); border-radius: 9999px; + border: 2px solid transparent; background-clip: padding-box; +} +::-webkit-scrollbar-thumb:hover { background-color: var(--agent-app-muted); } + +/* 2. --agent-app-* FOUNDATION --------------------------------------------- */ +:root, +:root[data-theme='light'] { + color-scheme: light; + + /* Primitives (light) */ + --agent-app-bg: #f6f5f2; + --agent-app-surface: #ffffff; + --agent-app-text: #1f1e1b; + --agent-app-muted: #6f6d67; + --agent-app-border: #e7e5e0; + --agent-app-accent: #ff4f18; + --agent-app-accent-contrast: #ffffff; + + /* Shape + type */ + --agent-app-radius: 0.5rem; + --agent-app-font: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + + /* Derived: declared once, resolved per element so they track theme + pack. */ + --agent-app-fg: var(--agent-app-text); + --agent-app-surface-2: color-mix(in srgb, var(--agent-app-surface), var(--agent-app-text) 4%); + --agent-app-hover: color-mix(in srgb, var(--agent-app-surface), var(--agent-app-text) 7%); + --agent-app-selected: color-mix(in srgb, var(--agent-app-surface), var(--agent-app-accent) 12%); + --agent-app-ring: var(--agent-app-accent); +} + +:root[data-theme='dark'] { + color-scheme: dark; + --agent-app-bg: #191919; + --agent-app-surface: #202020; + --agent-app-text: #e6e6e4; + --agent-app-muted: #9b9a97; + --agent-app-border: #2e2d2b; + --agent-app-accent: #ff4f18; + --agent-app-accent-contrast: #ffffff; +} + +/* Standalone dark: with no host bridge to set data-theme, follow the OS scheme + unless the app has explicitly opted into light. */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + color-scheme: dark; + --agent-app-bg: #191919; + --agent-app-surface: #202020; + --agent-app-text: #e6e6e4; + --agent-app-muted: #9b9a97; + --agent-app-border: #2e2d2b; + } +} + +/* ---- Style packs ([data-style]; brand default 'craftbot' == base) -------- */ +:root[data-style='normal'] { --agent-app-accent: #2563eb; } +:root[data-style='normal'][data-theme='dark'] { --agent-app-accent: #3b82f6; } + +:root[data-style='ocean'] { --agent-app-accent: #0284c7; --agent-app-bg: #f0f7fb; --agent-app-border: #d3e5ef; } +:root[data-style='ocean'][data-theme='dark'] { --agent-app-accent: #38bdf8; --agent-app-bg: #0b1b26; --agent-app-surface: #102635; --agent-app-border: #1e3a4d; } + +:root[data-style='forest'] { --agent-app-accent: #16a34a; --agent-app-bg: #f2f8f2; --agent-app-border: #d6e7d6; } +:root[data-style='forest'][data-theme='dark'] { --agent-app-accent: #4ade80; --agent-app-bg: #0e1a12; --agent-app-surface: #14261a; --agent-app-border: #22402c; } + +:root[data-style='pastel'] { --agent-app-accent: #a855f7; --agent-app-bg: #faf7fd; --agent-app-surface: #fffdfa; --agent-app-border: #eadff5; } +:root[data-style='pastel'][data-theme='dark'] { --agent-app-accent: #c084fc; --agent-app-bg: #1a1420; --agent-app-surface: #251c2e; --agent-app-border: #3a2d47; } + +:root[data-style='glass'] { --agent-app-bg: #eef1f8; --agent-app-surface: rgba(255, 255, 255, 0.72); --agent-app-border: rgba(120, 130, 160, 0.25); --agent-app-accent: #6366f1; } +:root[data-style='glass'][data-theme='dark'] { --agent-app-bg: #10131c; --agent-app-surface: rgba(30, 36, 54, 0.72); --agent-app-border: rgba(140, 150, 190, 0.22); --agent-app-accent: #818cf8; } + +:root[data-style='classic'] { --agent-app-bg: #f5f2ea; --agent-app-surface: #fffdf7; --agent-app-border: #ddd6c5; --agent-app-accent: #b8860b; --agent-app-radius: 0.25rem; } +:root[data-style='classic'][data-theme='dark'] { --agent-app-bg: #1c1a14; --agent-app-surface: #26231b; --agent-app-border: #3d3828; --agent-app-accent: #d4a017; } + +:root[data-style='velvet'] { --agent-app-bg: #f8f2f6; --agent-app-surface: #fffbfe; --agent-app-border: #e8d8e4; --agent-app-accent: #9d174d; } +:root[data-style='velvet'][data-theme='dark'] { --agent-app-bg: #1c1018; --agent-app-surface: #281826; --agent-app-border: #43263c; --agent-app-accent: #ec4899; } + +:root[data-style='ink'] { --agent-app-bg: #ffffff; --agent-app-surface: #ffffff; --agent-app-border: #111111; --agent-app-accent: #111111; --agent-app-accent-contrast: #ffffff; --agent-app-radius: 0; } +:root[data-style='ink'][data-theme='dark'] { --agent-app-bg: #0a0a0a; --agent-app-surface: #0a0a0a; --agent-app-border: #f5f5f5; --agent-app-accent: #f5f5f5; --agent-app-accent-contrast: #0a0a0a; } + +:root[data-style='acid'] { --agent-app-bg: #fafff2; --agent-app-surface: #ffffff; --agent-app-border: #d9f99d; --agent-app-accent: #65a30d; } +:root[data-style='acid'][data-theme='dark'] { --agent-app-bg: #131a0c; --agent-app-surface: #1b2513; --agent-app-border: #365314; --agent-app-accent: #a3e635; --agent-app-accent-contrast: #1a2e05; } + +:root[data-style='blueprint'] { --agent-app-bg: #eef4fb; --agent-app-surface: #ffffff; --agent-app-border: #93c5fd; --agent-app-accent: #1d4ed8; --agent-app-radius: 0.125rem; } +:root[data-style='blueprint'][data-theme='dark'] { --agent-app-bg: #0b1526; --agent-app-surface: #102039; --agent-app-border: #1e40af; --agent-app-accent: #60a5fa; } + +:root[data-style='modern'] { --agent-app-bg: #f4f5fa; --agent-app-surface: #ffffff; --agent-app-border: #e2e4f0; --agent-app-accent: #6366f1; --agent-app-radius: 0.75rem; } +:root[data-style='modern'][data-theme='dark'] { --agent-app-bg: #12141d; --agent-app-surface: #1a1d2a; --agent-app-border: #2a2e42; --agent-app-accent: #7c8aff; } + +:root[data-style='brutalist'] { --agent-app-bg: #ffffff; --agent-app-surface: #ffffff; --agent-app-text: #0a0a0a; --agent-app-border: #0a0a0a; --agent-app-accent: #7c3aed; --agent-app-radius: 0; } +:root[data-style='brutalist'][data-theme='dark'] { --agent-app-bg: #0a0a0a; --agent-app-surface: #0a0a0a; --agent-app-text: #fafafa; --agent-app-border: #fafafa; --agent-app-accent: #a78bfa; --agent-app-accent-contrast: #0a0a0a; } + +:root[data-style='drafting'] { --agent-app-bg: #e9ede4; --agent-app-surface: #e9ede4; --agent-app-text: #2e3528; --agent-app-muted: #5b6552; --agent-app-border: #2e3528; --agent-app-accent: #3a4232; --agent-app-radius: 0.25rem; } +:root[data-style='drafting'][data-theme='dark'] { --agent-app-bg: #232920; --agent-app-surface: #232920; --agent-app-text: #dde3d6; --agent-app-muted: #9aa590; --agent-app-border: #c8d0c0; --agent-app-accent: #aab5a0; --agent-app-accent-contrast: #232920; } + +:root[data-style='clay'] { --agent-app-bg: #e4e6ec; --agent-app-surface: #e4e6ec; --agent-app-text: #3a3f4c; --agent-app-border: #c9cdd8; --agent-app-accent: #5b7cfa; --agent-app-radius: 0.875rem; } +:root[data-style='clay'][data-theme='dark'] { --agent-app-bg: #23262e; --agent-app-surface: #23262e; --agent-app-text: #d5d8e0; --agent-app-border: #343947; --agent-app-accent: #7c96ff; } + +:root[data-style='atelier'] { --agent-app-bg: #edeff2; --agent-app-surface: #f8f9fb; --agent-app-text: #1c1e22; --agent-app-border: #d8dbe1; --agent-app-accent: #17181b; --agent-app-accent-contrast: #f8f9fb; --agent-app-radius: 0.375rem; } +:root[data-style='atelier'][data-theme='dark'] { --agent-app-bg: #17181b; --agent-app-surface: #202227; --agent-app-text: #e8e9ec; --agent-app-border: #33363d; --agent-app-accent: #f2f2f4; --agent-app-accent-contrast: #17181b; } + +/* 3. SEMANTIC + STRUCTURAL BRIDGE ----------------------------------------- */ +:root { + /* type */ + --font-sans: var(--agent-app-font); + --font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; + --fs-xs: 12px; --fs-sm: 13px; --fs-base: 14px; --fs-md: 15px; --fs-lg: 16px; --fs-xl: 20px; --fs-2xl: 24px; + --lh-tight: 1.25; --lh-normal: 1.5; + --fw-regular: 400; --fw-medium: 500; --fw-semibold: 600; --fw-bold: 700; + + /* spacing (4px grid) */ + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 20px; --sp-6: 24px; + --sp-8: 32px; --sp-10: 40px; --sp-12: 48px; --sp-16: 64px; + + /* radius — scaled off the foundation so style packs (ink/brutalist=0, + clay/modern=larger) reshape the whole app */ + --r-sm: calc(var(--agent-app-radius) * 0.5); + --r-md: var(--agent-app-radius); + --r-lg: calc(var(--agent-app-radius) * 1.25); + --r-xl: calc(var(--agent-app-radius) * 1.75); + --r-full: 9999px; + + /* elevation */ + --sh-1: 0 1px 2px rgb(16 24 40 / .06), 0 1px 3px rgb(16 24 40 / .10); + --sh-2: 0 2px 4px rgb(16 24 40 / .06), 0 4px 8px rgb(16 24 40 / .08); + --sh-3: 0 4px 6px rgb(16 24 40 / .05), 0 10px 20px rgb(16 24 40 / .12); + --sh-4: 0 8px 16px rgb(16 24 40 / .10), 0 16px 32px rgb(16 24 40 / .16); + + /* motion */ + --dur-fast: 120ms; --dur-mid: 200ms; --dur-slow: 300ms; + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-std: cubic-bezier(0.4, 0, 0.2, 1); + + /* controls */ + --control-h: 36px; --row-h: 44px; + + /* surfaces + text re-pointed onto the foundation */ + --bg-canvas: var(--agent-app-bg); + --bg-surface: var(--agent-app-surface); + --bg-raised: var(--agent-app-surface); + --bg-sunken: var(--agent-app-surface-2); + --bg-hover: var(--agent-app-hover); + --bg-active: var(--agent-app-selected); + --bg-selected: var(--agent-app-selected); + --text-primary: var(--agent-app-fg); + --text-secondary: var(--agent-app-muted); + --text-tertiary: var(--agent-app-muted); + --text-disabled: color-mix(in srgb, var(--agent-app-muted), var(--agent-app-surface) 45%); + --text-on-accent: var(--agent-app-accent-contrast); + --border-subtle: color-mix(in srgb, var(--agent-app-border), var(--agent-app-surface) 45%); + --border-default: var(--agent-app-border); + --border-strong: color-mix(in srgb, var(--agent-app-border), var(--agent-app-text) 25%); + --accent-solid: var(--agent-app-accent); + --accent-hover: color-mix(in srgb, var(--agent-app-accent), var(--agent-app-text) 14%); + --accent-text: var(--agent-app-accent); + --accent-bg: color-mix(in srgb, var(--agent-app-accent), var(--agent-app-surface) 88%); + --accent-border: color-mix(in srgb, var(--agent-app-accent), var(--agent-app-surface) 68%); + --focus-ring: var(--agent-app-ring); + + /* state tones — solid + text carry the hue; bg + border derive off the solid + against the current surface, so they follow theme AND every style pack */ + --success-solid: #16a34a; --success-text: #15803d; + --warning-solid: #d97706; --warning-text: #b45309; + --danger-solid: #dc2626; --danger-text: #b91c1c; + --info-solid: #2563eb; --info-text: #1d4ed8; + --success-bg: color-mix(in srgb, var(--success-solid) 12%, var(--agent-app-surface)); + --success-border: color-mix(in srgb, var(--success-solid) 28%, var(--agent-app-surface)); + --warning-bg: color-mix(in srgb, var(--warning-solid) 12%, var(--agent-app-surface)); + --warning-border: color-mix(in srgb, var(--warning-solid) 28%, var(--agent-app-surface)); + --danger-bg: color-mix(in srgb, var(--danger-solid) 12%, var(--agent-app-surface)); + --danger-border: color-mix(in srgb, var(--danger-solid) 28%, var(--agent-app-surface)); + --info-bg: color-mix(in srgb, var(--info-solid) 12%, var(--agent-app-surface)); + --info-border: color-mix(in srgb, var(--info-solid) 28%, var(--agent-app-surface)); + --neutral-text: var(--agent-app-muted); + --neutral-bg: color-mix(in srgb, var(--agent-app-text) 6%, var(--agent-app-surface)); + --neutral-border: var(--agent-app-border); +} + +/* Dark: brighten state solids/text + deepen elevation. bg/border derive off the + solids above, so they update automatically. Applied for explicit dark and for + OS-dark when the app has not opted into light. */ +:root[data-theme='dark'] { + --success-solid: #22c55e; --success-text: #4ade80; + --warning-solid: #f59e0b; --warning-text: #fbbf24; + --danger-solid: #ef4444; --danger-text: #f87171; + --info-solid: #3b82f6; --info-text: #60a5fa; + --sh-1: 0 1px 2px rgb(0 0 0 / .4); + --sh-2: 0 2px 8px rgb(0 0 0 / .45); + --sh-3: 0 8px 20px rgb(0 0 0 / .5); + --sh-4: 0 16px 32px rgb(0 0 0 / .55); +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + --success-solid: #22c55e; --success-text: #4ade80; + --warning-solid: #f59e0b; --warning-text: #fbbf24; + --danger-solid: #ef4444; --danger-text: #f87171; + --info-solid: #3b82f6; --info-text: #60a5fa; + --sh-1: 0 1px 2px rgb(0 0 0 / .4); + --sh-2: 0 2px 8px rgb(0 0 0 / .45); + --sh-3: 0 8px 20px rgb(0 0 0 / .5); + --sh-4: 0 16px 32px rgb(0 0 0 / .55); + } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } +} diff --git a/toolkits/blueprint-rails-vue/template/ui/public/ui.css b/toolkits/blueprint-rails-vue/template/ui/public/ui.css new file mode 100644 index 0000000..d795803 --- /dev/null +++ b/toolkits/blueprint-rails-vue/template/ui/public/ui.css @@ -0,0 +1,208 @@ +/* ============================================================================ + UI components (AGENT-OWNED). Consumes ONLY semantic tokens from tokens.css. + Every interactive element carries cursor + hover + focus-visible + active + + disabled states; every screen region has loading / empty / error styling. + ============================================================================ */ + +/* ---------------------------------------------------------------- base */ +*, *::before, *::after { box-sizing: border-box; } +html { height: 100%; } +body { + margin: 0; min-height: 100%; + font-family: var(--font-sans); font-size: var(--fs-md); line-height: var(--lh-normal); + color: var(--text-primary); background: var(--bg-canvas); + -webkit-font-smoothing: antialiased; +} +:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; border-radius: var(--r-sm); } +::selection { background: var(--accent-bg); } + +.sr-only { + position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; + overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; +} + +/* ---------------------------------------------------------------- layout */ +.app { max-width: 640px; margin: 0 auto; padding: var(--sp-8) var(--sp-4) var(--sp-12); } +.app-header { margin-bottom: var(--sp-6); } +.app-header h1 { margin: 0; font-size: var(--fs-2xl); font-weight: var(--fw-bold); line-height: var(--lh-tight); } +.app-header .subtitle { margin: var(--sp-1) 0 0; color: var(--text-secondary); font-size: var(--fs-base); } +.app-footer { margin-top: var(--sp-8); color: var(--text-tertiary); font-size: var(--fs-sm); display: flex; justify-content: space-between; gap: var(--sp-3); flex-wrap: wrap; } +.card { + background: var(--bg-surface); border: 1px solid var(--border-subtle); + border-radius: var(--r-xl); box-shadow: var(--sh-1); +} + +/* ---------------------------------------------------------------- buttons */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-2); + height: var(--control-h); padding: 0 var(--sp-4); + font: inherit; font-size: var(--fs-base); font-weight: var(--fw-medium); + border-radius: var(--r-md); border: 1px solid transparent; + cursor: pointer; user-select: none; white-space: nowrap; + transition: background var(--dur-fast) var(--ease-std), border-color var(--dur-fast) var(--ease-std), + color var(--dur-fast) var(--ease-std), box-shadow var(--dur-fast) var(--ease-std); +} +.btn:disabled { cursor: not-allowed; opacity: .55; } +.btn:active:not(:disabled) { transform: translateY(0.5px); } + +.btn-primary { background: var(--accent-solid); color: var(--text-on-accent); } +.btn-primary:hover:not(:disabled) { background: var(--accent-hover); } + +.btn-ghost { background: transparent; color: var(--text-secondary); border-color: var(--border-default); } +.btn-ghost:hover:not(:disabled) { background: var(--bg-hover); color: var(--text-primary); } + +.btn-danger { background: transparent; color: var(--danger-text); border-color: var(--danger-border); } +.btn-danger:hover:not(:disabled) { background: var(--danger-bg); } +.btn-danger-solid { background: var(--danger-solid); color: var(--text-on-accent); } +.btn-danger-solid:hover:not(:disabled) { filter: brightness(1.08); } + +/* icon-only buttons stay >= 24px pointer targets (comfortably above, at 32px) */ +.btn-icon { + width: 32px; height: 32px; min-width: 32px; padding: 0; + border-radius: var(--r-md); background: transparent; border: none; + color: var(--text-tertiary); cursor: pointer; display: inline-flex; + align-items: center; justify-content: center; + transition: background var(--dur-fast) var(--ease-std), color var(--dur-fast) var(--ease-std); +} +.btn-icon:hover:not(:disabled) { background: var(--bg-hover); color: var(--text-primary); } +.btn-icon.danger:hover:not(:disabled) { background: var(--danger-bg); color: var(--danger-text); } +.btn-icon:disabled { cursor: not-allowed; opacity: .5; } + +/* pending: the control acknowledges immediately and blocks double submission */ +.btn.pending { position: relative; color: transparent !important; pointer-events: none; } +.btn.pending::after { + content: ""; position: absolute; width: 16px; height: 16px; + border: 2px solid currentColor; border-right-color: transparent; border-radius: var(--r-full); + color: var(--text-on-accent); animation: spin 600ms linear infinite; +} +.btn-ghost.pending::after, .btn-danger.pending::after { color: var(--text-secondary); } +@keyframes spin { to { transform: rotate(360deg); } } + +/* ---------------------------------------------------------------- forms */ +.field { display: flex; flex-direction: column; gap: var(--sp-1); } +.field label { font-size: var(--fs-sm); font-weight: var(--fw-medium); color: var(--text-secondary); } +.input, .select { + height: var(--control-h); padding: 0 var(--sp-3); + font: inherit; font-size: var(--fs-base); color: var(--text-primary); + background: var(--bg-surface); border: 1px solid var(--border-default); border-radius: var(--r-md); + transition: border-color var(--dur-fast) var(--ease-std), box-shadow var(--dur-fast) var(--ease-std); +} +.input::placeholder { color: var(--text-tertiary); } +.input:hover, .select:hover { border-color: var(--border-strong); } +.input:focus, .select:focus { outline: none; border-color: var(--focus-ring); box-shadow: 0 0 0 3px var(--accent-bg); } +.input[aria-invalid="true"] { border-color: var(--danger-solid); } +.input[aria-invalid="true"]:focus { box-shadow: 0 0 0 3px var(--danger-bg); } +.select { cursor: pointer; } +.field-error { color: var(--danger-text); font-size: var(--fs-sm); } + +.add-form { display: flex; gap: var(--sp-2); padding: var(--sp-4); align-items: flex-start; } +.add-form .field { flex: 1; } + +/* ---------------------------------------------------------------- filters */ +.toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); margin: var(--sp-5) 0 var(--sp-3); flex-wrap: wrap; } +.seg { display: inline-flex; background: var(--bg-sunken); border: 1px solid var(--border-subtle); border-radius: var(--r-lg); padding: 2px; gap: 2px; } +.seg button { + font: inherit; font-size: var(--fs-sm); font-weight: var(--fw-medium); + color: var(--text-secondary); background: transparent; border: none; cursor: pointer; + padding: var(--sp-1) var(--sp-3); border-radius: var(--r-md); min-height: 28px; + transition: background var(--dur-fast) var(--ease-std), color var(--dur-fast) var(--ease-std); +} +.seg button:hover { color: var(--text-primary); } +.seg button[aria-pressed="true"] { background: var(--bg-surface); color: var(--text-primary); box-shadow: var(--sh-1); } +.seg .count { color: var(--text-tertiary); font-weight: var(--fw-regular); margin-left: var(--sp-1); } + +/* ---------------------------------------------------------------- task list */ +.task-list { list-style: none; margin: 0; padding: var(--sp-1) 0; } +.task { + display: flex; align-items: center; gap: var(--sp-3); + min-height: var(--row-h); padding: var(--sp-2) var(--sp-4); + border-bottom: 1px solid var(--border-subtle); + transition: background var(--dur-fast) var(--ease-std), opacity var(--dur-mid) var(--ease-std); +} +.task:last-child { border-bottom: none; } +.task:hover { background: var(--bg-hover); } +.task.busy { opacity: .55; pointer-events: none; } +.task .title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.task.done .title { color: var(--text-tertiary); text-decoration: line-through; } +.task .due { font-size: var(--fs-xs); color: var(--text-tertiary); font-variant-numeric: tabular-nums; white-space: nowrap; } +.task .due.overdue { color: var(--danger-text); font-weight: var(--fw-medium); } + +/* status: a labelled 3-state control; colour is never the only channel (text names the state) */ +.status-btn { + font: inherit; font-size: var(--fs-xs); font-weight: var(--fw-medium); + border-radius: var(--r-full); border: 1px solid; cursor: pointer; + padding: 2px var(--sp-3); min-height: 24px; min-width: 56px; white-space: nowrap; + transition: filter var(--dur-fast) var(--ease-std), background var(--dur-fast) var(--ease-std); +} +.status-btn:hover:not(:disabled) { filter: brightness(.96); } +.status-btn:disabled { cursor: not-allowed; opacity: .6; } +.status-todo { background: var(--neutral-bg); color: var(--neutral-text); border-color: var(--neutral-border); } +.status-doing { background: var(--info-bg); color: var(--info-text); border-color: var(--info-border); } +.status-done { background: var(--success-bg); color: var(--success-text); border-color: var(--success-border); } + +.load-more { display: flex; justify-content: center; padding: var(--sp-3); border-top: 1px solid var(--border-subtle); } + +/* ---------------------------------------------------------------- states */ +.skel-row { display: flex; align-items: center; gap: var(--sp-3); min-height: var(--row-h); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-subtle); } +.skel-row:last-child { border-bottom: none; } +.skel { border-radius: var(--r-sm); background: var(--bg-active); animation: pulse 1.2s var(--ease-std) infinite; } +@keyframes pulse { 50% { opacity: .5; } } + +.empty { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); padding: var(--sp-12) var(--sp-6); text-align: center; } +.empty .empty-icon { color: var(--text-disabled); } +.empty h2 { margin: 0; font-size: var(--fs-lg); font-weight: var(--fw-semibold); } +.empty p { margin: 0; color: var(--text-secondary); font-size: var(--fs-base); max-width: 36ch; } + +.banner { + display: flex; align-items: center; gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); border-radius: var(--r-lg); + background: var(--danger-bg); border: 1px solid var(--danger-border); color: var(--danger-text); + margin-bottom: var(--sp-4); +} +.banner .msg { flex: 1; } + +/* ---------------------------------------------------------------- toasts */ +.toast-region { position: fixed; bottom: var(--sp-6); left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: var(--sp-2); z-index: 60; width: max-content; max-width: min(92vw, 420px); } +.toast { + display: flex; align-items: center; gap: var(--sp-2); + padding: var(--sp-3) var(--sp-4); border-radius: var(--r-lg); box-shadow: var(--sh-3); + background: var(--bg-raised); border: 1px solid var(--border-subtle); + color: var(--text-primary); font-size: var(--fs-base); + animation: toast-in var(--dur-mid) var(--ease-out); +} +.toast.success { border-color: var(--success-border); } +.toast.success .toast-icon { color: var(--success-solid); } +.toast.error { border-color: var(--danger-border); } +.toast.error .toast-icon { color: var(--danger-solid); } +.toast.leaving { opacity: 0; transition: opacity var(--dur-mid) var(--ease-std); } +@keyframes toast-in { from { opacity: 0; transform: translateY(var(--sp-2)); } } + +/* ---------------------------------------------------------------- dialog */ +.dialog-backdrop { + position: fixed; inset: 0; z-index: 50; + background: rgb(2 6 23 / .45); + display: flex; align-items: center; justify-content: center; padding: var(--sp-4); + animation: fade-in var(--dur-fast) var(--ease-std); +} +.dialog { + width: 100%; max-width: 400px; + background: var(--bg-raised); border: 1px solid var(--border-subtle); + border-radius: var(--r-xl); box-shadow: var(--sh-4); padding: var(--sp-6); + animation: dialog-in var(--dur-mid) var(--ease-out); +} +.dialog h2 { margin: 0 0 var(--sp-2); font-size: var(--fs-lg); font-weight: var(--fw-semibold); } +.dialog p { margin: 0 0 var(--sp-5); color: var(--text-secondary); overflow-wrap: break-word; } +.dialog .dialog-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); } +@keyframes fade-in { from { opacity: 0; } } +@keyframes dialog-in { from { opacity: 0; transform: translateY(var(--sp-2)) scale(.98); } } + +/* ---------------------------------------------------------------- narrow screens */ +@media (max-width: 480px) { + .app { padding: var(--sp-5) var(--sp-3) var(--sp-10); } + .add-form { flex-wrap: wrap; } + .add-form .field { flex-basis: 100%; } + .task .due { display: none; } + /* touch-first: primary controls grow toward the platform norm */ + .btn, .input, .select { min-height: 44px; } + .btn-icon { width: 40px; height: 40px; } +} diff --git a/toolkits/blueprint-rails-vue/template/ui/src/App.vue b/toolkits/blueprint-rails-vue/template/ui/src/App.vue index 0681e60..b18f495 100644 --- a/toolkits/blueprint-rails-vue/template/ui/src/App.vue +++ b/toolkits/blueprint-rails-vue/template/ui/src/App.vue @@ -11,7 +11,12 @@ staleness. A CODE change it handles itself (reload when nothing is unsaved). A DATA change it only announces (`a2app:datachange`) — because only this file knows how to re-read without discarding a form someone is - filling in. --> + filling in. + + Agent work: "Ask an agent" queues a triage task (the `request-triage` + operation). The row then shows it until it is done — a badge beside the + title, and the full-width AgentTaskPanel under the row with the state, + the elapsed time and, at the end, the agent's answer. --> + + diff --git a/toolkits/blueprint-rails-vue/template/ui/src/components/AgentTaskPanel.vue b/toolkits/blueprint-rails-vue/template/ui/src/components/AgentTaskPanel.vue new file mode 100644 index 0000000..94b4785 --- /dev/null +++ b/toolkits/blueprint-rails-vue/template/ui/src/components/AgentTaskPanel.vue @@ -0,0 +1,148 @@ + + + + diff --git a/toolkits/blueprint-rails-vue/template/ui/src/components/Icon.vue b/toolkits/blueprint-rails-vue/template/ui/src/components/Icon.vue index 1f989c6..8ae8cc9 100644 --- a/toolkits/blueprint-rails-vue/template/ui/src/components/Icon.vue +++ b/toolkits/blueprint-rails-vue/template/ui/src/components/Icon.vue @@ -10,6 +10,7 @@ const ICON_PATHS = { info: "M8 7.5V11m0-5.5v-.01M14.5 8a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0Z", inbox: "M1.5 9.5h3l1 2h5l1-2h3M2.5 3.5h11l1 6v3a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-3l1-6Z", refresh: "M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2.5v2.6h-2.6", + spark: "M8 1.5v3M8 11.5v3M1.5 8h3M11.5 8h3M3.4 3.4l2.1 2.1M10.5 10.5l2.1 2.1M3.4 12.6l2.1-2.1M10.5 5.5l2.1-2.1", }; const props = defineProps({ diff --git a/toolkits/blueprint-react-node/template/AGENT_APP.md b/toolkits/blueprint-react-node/template/AGENT_APP.md index 78009ea..0841eaa 100644 --- a/toolkits/blueprint-react-node/template/AGENT_APP.md +++ b/toolkits/blueprint-react-node/template/AGENT_APP.md @@ -33,7 +33,8 @@ exactly one, and describe's root screen lists them. They are declared in duration where a token exists. - **Shared pieces live in `src/`** — `Icon.jsx` (the icon set), `toast.jsx` (`useToast`), `ConfirmDialog.jsx` (`useConfirm` — never `window.confirm`), - `format.js`, `api.js` (the one fetch wrapper). Screens compose them — one + `format.js`, `api.js` (the one fetch wrapper), `AgentTask.jsx` (work queued + for an agent, shown until it is done). Screens compose them — one implementation per widget, no per-screen copies, no native browser dialogs. Component styles live in `public/ui.css`. - **Every screen renders all of its states**: loading skeletons (sized so diff --git a/toolkits/blueprint-react-node/template/a2app.schema.mjs b/toolkits/blueprint-react-node/template/a2app.schema.mjs index 80b3a27..6a1fdf3 100644 --- a/toolkits/blueprint-react-node/template/a2app.schema.mjs +++ b/toolkits/blueprint-react-node/template/a2app.schema.mjs @@ -42,6 +42,10 @@ export const schema = { { name: "due", type: "string", max: 10, dayKey: true }, { name: "notes", type: "string", max: 2000 }, { name: "created", type: "datetime", readOnly: true }, + // The queue task an agent is (or was last) working on for this record. + // The runner sets it; the View follows it, so the person can see the + // work they asked for until it is done. + { name: "agentTask", type: "string", max: 64, readOnly: true }, ], seed: [ { id: "task_welcome", title: "Welcome — edit or delete me", status: "todo", created: "2026-01-01T00:00:00.000Z" }, @@ -125,10 +129,22 @@ export const schema = { // read. Nothing here can widen what the agent may do: the payload is data // on the other side, and the capability names the kind of work, not a // command to run. + // + // Identical triggers dedupe to ONE task, even after it has finished, so + // asking again with the same payload would hand back the old failure. + // Naming the previous task makes each request a new occurrence. The View + // disables the control while a run is open, so a double click cannot + // queue two. + // + // The task id goes on the record so the View can show the work until it is + // done (src/AgentTask.jsx). The validate gate checks that it does. "request-triage": (args, _ctx, { store, trigger }) => { const task = store.get("tasks", args?.task); if (!task) return { ok: false, reason: "no such task" }; - const { taskId } = trigger("task.needs_triage", { task: task.id }, "triage"); + const payload = task.agentTask ? { task: task.id, previous: task.agentTask } : { task: task.id }; + const { taskId } = trigger("task.needs_triage", payload, "triage"); + task.agentTask = taskId; + store.put("tasks", task); return { ok: true, task: task.id, queued: taskId }; }, }, diff --git a/toolkits/blueprint-react-node/template/public/ui.css b/toolkits/blueprint-react-node/template/public/ui.css index d795803..ffc9490 100644 --- a/toolkits/blueprint-react-node/template/public/ui.css +++ b/toolkits/blueprint-react-node/template/public/ui.css @@ -114,7 +114,7 @@ body { /* ---------------------------------------------------------------- task list */ .task-list { list-style: none; margin: 0; padding: var(--sp-1) 0; } .task { - display: flex; align-items: center; gap: var(--sp-3); + display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; min-height: var(--row-h); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-subtle); transition: background var(--dur-fast) var(--ease-std), opacity var(--dur-mid) var(--ease-std); @@ -126,6 +126,8 @@ body { .task.done .title { color: var(--text-tertiary); text-decoration: line-through; } .task .due { font-size: var(--fs-xs); color: var(--text-tertiary); font-variant-numeric: tabular-nums; white-space: nowrap; } .task .due.overdue { color: var(--danger-text); font-weight: var(--fw-medium); } +/* agent work gets its own full-width line under the row, never a squeeze into the title */ +.task > .agent-task { flex-basis: 100%; margin-bottom: var(--sp-1); } /* status: a labelled 3-state control; colour is never the only channel (text names the state) */ .status-btn { @@ -196,12 +198,68 @@ body { @keyframes fade-in { from { opacity: 0; } } @keyframes dialog-in { from { opacity: 0; transform: translateY(var(--sp-2)) scale(.98); } } +/* ---------------------------------------------------------------- agent work (AgentTask.jsx) + Work queued for an agent stays visible until it is done. The panel takes the + full width of its container; an agent's result is prose of any length and + never goes in a title cell. */ +.agent-badge { + display: inline-flex; align-items: center; gap: var(--sp-1); + font-size: var(--fs-xs); font-weight: var(--fw-medium); white-space: nowrap; + padding: 2px var(--sp-2); border-radius: var(--r-full); border: 1px solid; +} +.agent-dot { width: 8px; height: 8px; border-radius: var(--r-full); background: currentColor; flex: none; } +.live .agent-dot { animation: pulse 1.2s var(--ease-std) infinite; } + +.tone-neutral { --tone-bg: var(--neutral-bg); --tone-text: var(--neutral-text); --tone-border: var(--neutral-border); } +.tone-info { --tone-bg: var(--info-bg); --tone-text: var(--info-text); --tone-border: var(--info-border); } +.tone-warning { --tone-bg: var(--warning-bg); --tone-text: var(--warning-text); --tone-border: var(--warning-border); } +.tone-success { --tone-bg: var(--success-bg); --tone-text: var(--success-text); --tone-border: var(--success-border); } +.tone-danger { --tone-bg: var(--danger-bg); --tone-text: var(--danger-text); --tone-border: var(--danger-border); } +.agent-badge { background: var(--tone-bg); color: var(--tone-text); border-color: var(--tone-border); } + +.agent-task { + display: flex; flex-direction: column; gap: var(--sp-2); + width: 100%; min-width: 0; padding: var(--sp-3) var(--sp-4); + border: 1px solid var(--tone-border); border-left-width: 3px; border-radius: var(--r-lg); + background: var(--bg-surface); +} +.agent-task-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; color: var(--tone-text); } +.agent-task-state { font-size: var(--fs-sm); font-weight: var(--fw-medium); min-width: 0; overflow-wrap: anywhere; } +.agent-task-step { font-weight: var(--fw-regular); color: var(--text-secondary); } +.agent-task-elapsed { margin-left: auto; font-size: var(--fs-xs); color: var(--text-tertiary); font-variant-numeric: tabular-nums; white-space: nowrap; } +.agent-task-note { margin: 0; font-size: var(--fs-sm); color: var(--text-secondary); overflow-wrap: anywhere; } +.agent-task-note code { font-family: var(--font-mono); font-size: var(--fs-xs); background: var(--bg-sunken); padding: 1px var(--sp-1); border-radius: var(--r-sm); } +.agent-task-failure { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; } +.agent-task-failure .agent-task-note { flex: 1; min-width: 12ch; color: var(--danger-text); } +.agent-task-code { font-family: var(--font-mono); font-size: var(--fs-xs); color: var(--text-tertiary); } +.agent-task-bar { height: 4px; border-radius: var(--r-full); background: var(--bg-sunken); overflow: hidden; } +.agent-task-bar span { display: block; height: 100%; background: var(--info-solid); transition: width var(--dur-mid) var(--ease-std); } + +.agent-result { display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-1); } +.agent-result-text { + margin: 0; max-width: 72ch; white-space: pre-wrap; overflow-wrap: anywhere; + font-size: var(--fs-base); line-height: var(--lh-normal); color: var(--text-primary); +} +.agent-result-text.clamped { display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } +.agent-result-text a { color: var(--accent-text); text-decoration: underline; text-underline-offset: 2px; } +.agent-result-toggle { + font: inherit; font-size: var(--fs-sm); color: var(--accent-text); background: none; border: 0; + padding: var(--sp-1) 0; cursor: pointer; min-height: 24px; +} +.agent-result-toggle:hover { text-decoration: underline; } +@media (prefers-reduced-motion: reduce) { + .live .agent-dot { animation: none; } + .agent-task-bar span { transition: none; } +} + /* ---------------------------------------------------------------- narrow screens */ @media (max-width: 480px) { .app { padding: var(--sp-5) var(--sp-3) var(--sp-10); } .add-form { flex-wrap: wrap; } .add-form .field { flex-basis: 100%; } .task .due { display: none; } + /* the panel under the row says the same thing; the title keeps the room */ + .task .agent-badge { display: none; } /* touch-first: primary controls grow toward the platform norm */ .btn, .input, .select { min-height: 44px; } .btn-icon { width: 40px; height: 40px; } diff --git a/toolkits/blueprint-react-node/template/reference/blueprint.md b/toolkits/blueprint-react-node/template/reference/blueprint.md index 57f0cdd..6dc6593 100644 --- a/toolkits/blueprint-react-node/template/reference/blueprint.md +++ b/toolkits/blueprint-react-node/template/reference/blueprint.md @@ -178,7 +178,9 @@ unbounded collection — page with `perPage`. **Shared pieces — `src/`** (one implementation each; import, don't rebuild): `` · `useToast()` → `toast(kind, message)` · `useConfirm()` → `[confirm, confirmElement]` (in-app dialog — never -`window.confirm`) · `api(path, init)` · `fmtDay(dayKey)` · `isPastDay(dayKey)`. +`window.confirm`) · `api(path, init)` · `fmtDay(dayKey)` · `isPastDay(dayKey)` · +`` / `` / `useAgentTask(id)` (`AgentTask.jsx` — +work queued for an agent, shown until it is done; see **App→agent triggers**). Component styles are plain CSS classes in `public/ui.css`. **Tokens — `public/tokens.css`:** the kit sheet — `--agent-app-*` foundation → @@ -250,24 +252,39 @@ something — nothing is queued and no agent is ever handed it. The queue only moves when something is listening: `agent-app bridge start`, or a harness polling `a2app tasks next --wait`. -**Show the queued work in the View.** An agent run takes seconds to minutes, so -the control that queued it needs to show where it is (see the creator skill for -the states to render). Keep `taskId` on the record (a `readOnly` string field -the runner sets), then follow it from `src/`: +**Show the queued work in the View — with `src/AgentTask.jsx`.** An agent run +takes seconds to minutes, so the control that queued it has to show where it is +until it is done. Keep the returned `taskId` on the record (a `readOnly` string +field the runner sets, like the starter's `agentTask`), then render it: -```js -// poll only while unfinished; stop at completed / failed / canceled -const t = await api(`/api/_a2app/tasks/${encodeURIComponent(record.agentTask)}`); -// t.status · t.progress?.step · t.claim?.claimedAt · t.result?.summary · t.reason · t.pollAfterMs +```jsx +import { AgentTaskBadge, AgentTaskPanel } from "./AgentTask.jsx"; + + // in the list row, beside the title + ``` -On a single-user app (`authMode: "none"`) the View needs no credential for this -read. **On a multi-user app it answers 401.** A browser sends no `Origin` on a -same-origin GET, so the adapter treats the read as an uncredentialled program, -and the View has no other path to the queue yet. There, show what the agent -writes to the record instead (a status or assignee it sets on claim and on -finish). A 404 means the task has been pruned, so drop the indicator. Don't -retry it. +The panel follows `GET /api/_a2app/tasks/{id}` while the run is unfinished and +renders every state the creator skill lists: waiting with elapsed time (and "no +agent is listening" after ~20 s), the agent's step and running time, the +`result.summary` in its own readable block, and the failure `reason` with "Ask +again". `useAgentTask(id)` gives you the same state to disable a control while a +run is open. The starter's "Ask an agent" button is the worked example. + +Never put the result in a title cell or a narrow column. It is prose of any +length, and the panel is where it goes. + +On a single-user app (`authMode: "none"`) the View needs no credential for the +task read. **On a multi-user app it answers 401**, and the View has no other +path to the queue yet. There, have the agent write its progress onto the record +and pass it as `progress={{ status, step, summary, reason }}`; the panel and +badge render it the same way. A 404 means the task has been pruned, so the +indicator disappears. + +**`validate` checks this.** Its "agent work shown in the View" step fails an +app whose code queues work (`trigger(…)` with a capability) when no View code +renders the component or reads `/api/_a2app/tasks/`. ## Build, run, gate @@ -276,7 +293,7 @@ health at `/api/_a2app`). Never start a server by hand. ```bash agent-app dev # boot the candidate on a hidden port: fresh seeded store, prints the dev URL -agent-app validate # framework files → build (vite) → schema loads → operations resolve → ownership canon → describe budget (on dev) +agent-app validate # framework files → build (vite) → schema loads → operations resolve → agent work shown in View → ownership canon → describe budget (on dev) agent-app serve # launch LIVE as a managed, health-polled background process; prints the URL agent-app promote # requires the gate pass; backup, scripts/promote-apply.mjs, destroys the dev instance ``` diff --git a/toolkits/blueprint-react-node/template/src/AgentTask.jsx b/toolkits/blueprint-react-node/template/src/AgentTask.jsx new file mode 100644 index 0000000..d23ca67 --- /dev/null +++ b/toolkits/blueprint-react-node/template/src/AgentTask.jsx @@ -0,0 +1,439 @@ +/** + * Agent work, shown until it is done (AGENT-OWNED shared piece — @a2app-kit agent-task). + * + * An agent run takes seconds to minutes. Whatever control queued it has to + * keep showing where it is, or the person is left with no sign anything is + * happening. This is that display, so a feature that queues work does not + * rebuild it: + * + * in a list row, beside the title + * + * + * Both follow `GET /api/_a2app/tasks/{id}` at the task's own `pollAfterMs`, + * only while it is unfinished, and share one poller per id, so a badge and a + * panel for the same task cost one request. Every state renders: + * + * submitted waiting, with elapsed time; after ~20 s unclaimed, says no + * agent is listening and how to start one + * working `progress.step` (and `percent` as a bar) and running time + * input-required the agent is waiting on someone + * completed `result.summary` in its own full-width block: line breaks + * kept, links clickable, long text clamped behind "Show more". + * No summary (the agent never closed the task itself, so the + * bridge did)? The run's printed output, labelled as such. + * failed/canceled the `reason` in words (the queue's and bridge's own codes + * are translated), and "Ask again" when `onRetry` is given + * + * A 404 means the task was pruned, so the indicator disappears. On a + * multi-user app the read answers 401 — the View has no credential for the + * queue. There, have the agent write its progress onto the record and pass it + * as `progress={{ status, step, summary, reason }}`; it renders the same way, + * with or without a `taskId`. + * + * Never put `result.summary` in a title cell or a table column. It is prose of + * any length and belongs in the panel. + */ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { api } from "./api.js"; +import Icon from "./Icon.jsx"; + +const FINISHED = new Set(["completed", "failed", "canceled"]); +/** How long a task may sit unclaimed before we say nobody is listening. */ +export const NO_LISTENER_MS = 20_000; +const DEFAULT_POLL_MS = 2000; +const RETRY_MS = 5000; +/** Slower polling when nothing is likely to change soon: an unclaimed task, or a hidden tab. */ +const SLOW_POLL_MS = 10_000; +const CLAMP_CHARS = 280; +const CLAMP_LINES = 4; + +const LABEL = { + submitted: "Waiting for an agent", + unheard: "No agent is listening", + working: "Agent working", + "input-required": "Agent needs input", + completed: "Agent done", + failed: "Agent failed", + canceled: "Agent run canceled", +}; +const TONE = { + submitted: "neutral", + unheard: "warning", + working: "info", + "input-required": "warning", + completed: "success", + failed: "danger", + canceled: "neutral", +}; + +/** + * Reasons the queue and the bridge write themselves, in words. An agent's own + * reason is prose already; these are codes, and a code is not an explanation. + */ +const REASON_WORDS = { + redelivery_exhausted: + "It was handed to an agent several times and never finished. Check that the bridge can start your agent, then ask again.", + harness_unavailable: "The bridge could not find an agent to run on this machine.", + harness_spawn_failed: "The bridge could not start the agent.", + harness_exited_nonzero: "The agent stopped with an error before reporting back.", + harness_timeout: "The agent took too long and was stopped.", + unspecified: "The agent did not say why.", +}; + +/** A failure reason a person can read, plus the raw code when it was one. */ +export function reasonInWords(reason, canceled = false) { + if (!reason) return { text: canceled ? "The run was canceled." : "The agent did not say why.", code: null }; + if (REASON_WORDS[reason]) return { text: REASON_WORDS[reason], code: reason }; + // An unknown code (snake_case, no spaces) still gets a sentence around it. + if (/^[a-z0-9]+(?:_[a-z0-9]+)+$/.test(reason)) return { text: "The run stopped before it finished.", code: reason }; + return { text: reason, code: null }; +} + +/* ------------------------------------------------------------- the poller */ + +// id -> { snap, listeners, timer, stopped }. `snap` is replaced, never +// mutated, so useSyncExternalStore sees each change. +const watchers = new Map(); +const IDLE = { phase: "idle", task: null }; +const LOADING = { phase: "loading", task: null }; + +function watcherFor(id) { + let w = watchers.get(id); + if (!w) { + w = { snap: LOADING, listeners: new Set(), timer: null, stopped: false }; + watchers.set(id, w); + poll(id, w); + } + return w; +} + +function publish(w, snap) { + w.snap = snap; + for (const l of w.listeners) l(); +} + +async function poll(id, w) { + if (w.stopped) return; + let next = DEFAULT_POLL_MS; + try { + const task = await api(`/api/_a2app/tasks/${encodeURIComponent(id)}`); + if (w.stopped) return; + publish(w, { phase: "ok", task }); + if (FINISHED.has(task.status)) return; // settled: nothing more will change + next = task.pollAfterMs ?? DEFAULT_POLL_MS; + const waited = Date.now() - (ms(task.createdAt) ?? Date.now()); + if (task.status === "submitted" && waited > NO_LISTENER_MS) next = Math.max(next, SLOW_POLL_MS); + } catch (err) { + if (w.stopped) return; + if (err?.status === 404) return publish(w, { phase: "gone", task: null }); + if (err?.status === 401 || err?.status === 403) return publish(w, { phase: "unauthorized", task: w.snap.task }); + // Network trouble or rate limiting: keep what we last knew and try again later. + publish(w, { phase: "stale", task: w.snap.task, error: err?.message }); + next = err?.status === 429 ? SLOW_POLL_MS : RETRY_MS; + } + if (typeof document !== "undefined" && document.hidden) next = Math.max(next, SLOW_POLL_MS); + w.timer = setTimeout(() => poll(id, w), next); +} + +function subscribe(id, listener) { + if (!id) return () => {}; + const w = watcherFor(id); + w.listeners.add(listener); + return () => { + w.listeners.delete(listener); + // Stop only once nobody has re-subscribed by the next tick: a re-render + // (or a row moving between lists) unsubscribes and subscribes again at + // once, and tearing down in between would restart polling from scratch. + setTimeout(() => { + if (w.listeners.size > 0 || watchers.get(id) !== w) return; + w.stopped = true; + clearTimeout(w.timer); + watchers.delete(id); + }, 0); + }; +} + +/** + * Follow one queued task. Returns `{ phase, task }`: phase is loading · ok · + * stale (last known, the read is failing) · gone (404) · unauthorized (401) · + * idle (no id). + */ +export function useAgentTask(taskId) { + const sub = useCallback((l) => subscribe(taskId, l), [taskId]); + const snap = useCallback(() => (taskId ? (watchers.get(taskId)?.snap ?? LOADING) : IDLE), [taskId]); + return useSyncExternalStore(sub, snap); +} + +/* ---------------------------------------------------------- shared logic */ + +/** Seconds ticking while something is unfinished; frozen once it settles. */ +function useNow(running) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!running) return undefined; + const t = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(t); + }, [running]); + return now; +} + +/** "8s" · "1m 05s" · "1h 02m". */ +export function fmtElapsed(ms) { + const s = Math.max(0, Math.floor(ms / 1000)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`; + return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`; +} + +const ms = (iso) => { + const t = iso ? Date.parse(iso) : NaN; + return Number.isNaN(t) ? null : t; +}; + +/** + * What a harness printed, when the agent closed no task itself: the bridge then + * completes it with the run's output instead of a summary. Terminal colour + * codes are noise in a page. + */ +function printedOutput(result) { + if (typeof result?.output !== "string") return null; + // eslint-disable-next-line no-control-regex + const text = result.output.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").trim(); + return text || null; +} + +/** + * One view of a task, whichever way it arrived: the queue's own record, or the + * progress an agent wrote onto the app's record. + */ +function viewOf(state, progress, now) { + const task = state.task; + if (task) { + const created = ms(task.createdAt); + const claimed = ms(task.claim?.claimedAt); + const updated = ms(task.updatedAt); + let key = task.status; + let elapsed = null; + if (key === "submitted") { + elapsed = created !== null ? now - created : null; + if (elapsed !== null && elapsed >= NO_LISTENER_MS) key = "unheard"; + } else if (key === "working" || key === "input-required") { + const since = claimed ?? created; + elapsed = since !== null ? now - since : null; + } else if (created !== null && updated !== null) { + elapsed = updated - created; + } + return { + key, + elapsed, + step: task.progress?.step ?? null, + // The queue reports 0 until the agent says otherwise; only real progress draws a bar. + percent: task.progress?.percent > 0 ? task.progress.percent : null, + summary: task.result?.summary ?? null, + output: printedOutput(task.result), + reason: task.reason ?? null, + }; + } + if (progress?.status) { + return { + key: progress.status, + elapsed: null, + step: progress.step ?? null, + percent: progress.percent > 0 ? progress.percent : null, + summary: progress.summary ?? null, + output: null, + reason: progress.reason ?? null, + }; + } + return null; +} + +/** Calls `fn(task)` once when a task we watched while unfinished settles. */ +function useSettled(task, fn) { + const prev = useRef(task?.status); + const fnRef = useRef(fn); + fnRef.current = fn; + useEffect(() => { + const was = prev.current; + prev.current = task?.status; + if (task && FINISHED.has(task.status) && was !== undefined && !FINISHED.has(was)) fnRef.current?.(task); + }, [task?.status, task]); +} + +/* ------------------------------------------------------------- the badge */ + +/** A compact state pill for a list row, so someone can leave the screen and come back. */ +export function AgentTaskBadge({ taskId, progress }) { + const state = useAgentTask(taskId); + const live = state.task && !FINISHED.has(state.task.status); + const now = useNow(Boolean(live)); + if (state.phase === "gone") return null; + const v = viewOf(state, progress, now); + if (!v) return null; + const label = LABEL[v.key] ?? v.key; + return ( + + + ); +} + +/* ------------------------------------------------------------- the panel */ + +/** + * The full display: state, elapsed time, progress, and the result in a + * readable block. Give it the whole width of its container — under a list + * row, not inside one of its cells. + */ +export function AgentTaskPanel({ + taskId, + progress, + title = "Agent", + onSettled, + onRetry, + retrying = false, + bridgeHint = "agent-app bridge start", +}) { + const state = useAgentTask(taskId); + const live = state.task && !FINISHED.has(state.task.status); + const now = useNow(Boolean(live)); + useSettled(state.task, onSettled); + + if (state.phase === "gone") return null; + if (state.phase === "loading" && !progress?.status) { + return ( +
    +
    +
    +
    + ); + } + + const v = viewOf(state, progress, now); + if (!v) { + if (state.phase === "unauthorized") { + return ( +
    +

    + This app needs sign-in to read the agent queue, so progress shows here only once the agent records it. +

    +
    + ); + } + return null; + } + + const tone = TONE[v.key] ?? "neutral"; + const label = LABEL[v.key] ?? v.key; + const finished = FINISHED.has(v.key); + const elapsedText = + v.elapsed === null ? null : finished ? `took ${fmtElapsed(v.elapsed)}` : fmtElapsed(v.elapsed); + + return ( +
    +
    +
    + + {v.percent !== null && !finished && ( +
    + +
    + )} + + {v.key === "unheard" && ( +

    + Nothing has picked this up. Work is only delivered while an agent is listening — start one with{" "} + {bridgeHint}. +

    + )} + {v.key === "input-required" && ( +

    The agent is waiting for someone to answer it before it can continue.

    + )} + + {v.key === "completed" && v.summary && } + {v.key === "completed" && !v.summary && v.output && ( + + )} + + {(v.key === "failed" || v.key === "canceled") && ( +
    + + {onRetry && ( + + )} +
    + )} +
    + ); +} + +function FailureReason({ reason, canceled }) { + const { text, code } = reasonInWords(reason, canceled); + return ( +

    + {text} + {code && ({code})} +

    + ); +} + +/* ------------------------------------------------------------ the result */ + +const URL_RE = /(https?:\/\/[^\s<>"']+)/g; + +/** Plain text with its line breaks kept and its links clickable. */ +function Linkified({ text }) { + return text.split(URL_RE).map((part, i) => { + if (i % 2 === 0) return part; + // Sentence punctuation after a link is not part of it. + const m = part.match(/^(.*?)([.,;:!?)\]]*)$/); + const href = m ? m[1] : part; + return ( + + + {href} + + {m ? m[2] : ""} + + ); + }); +} + +/** An agent's answer: its own block, readable at any length. */ +export function AgentResult({ text, note }) { + const long = text.length > CLAMP_CHARS || text.split("\n").length > CLAMP_LINES; + const [open, setOpen] = useState(false); + return ( +
    + {note &&

    {note}

    } +

    + +

    + {long && ( + + )} +
    + ); +} diff --git a/toolkits/blueprint-react-node/template/src/App.jsx b/toolkits/blueprint-react-node/template/src/App.jsx index b6b6417..5ddf4be 100644 --- a/toolkits/blueprint-react-node/template/src/App.jsx +++ b/toolkits/blueprint-react-node/template/src/App.jsx @@ -11,6 +11,11 @@ * staleness. A CODE change it handles itself (reload when nothing is unsaved). * A DATA change it only announces (`a2app:datachange`) — because only this * file knows how to re-read without discarding a form someone is filling in. + * + * Agent work: "Ask an agent" queues a triage task (the `request-triage` + * operation). The row then shows it until it is done — a badge beside the + * title, and the full-width AgentTaskPanel under the row with the state, the + * elapsed time and, at the end, the agent's answer. */ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "./api.js"; @@ -18,6 +23,7 @@ import { hasUnsavedInput } from "./updater.js"; import Icon from "./Icon.jsx"; import { useToast } from "./toast.jsx"; import { useConfirm } from "./ConfirmDialog.jsx"; +import { AgentTaskBadge, AgentTaskPanel, useAgentTask } from "./AgentTask.jsx"; import { STATUS_LABEL, NEXT_STATUS, fmtDay, isPastDay } from "./format.js"; const API = "/api/collections/tasks/records"; @@ -42,6 +48,7 @@ export default function App() { const [phase, setPhase] = useState("loading"); // loading | ready | error const [errorMessage, setErrorMessage] = useState(""); const [busy, setBusy] = useState(() => new Set()); // record ids with a write in flight + const [asking, setAsking] = useState(() => new Set()); // record ids with an agent request in flight const [adding, setAdding] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [title, setTitle] = useState(""); @@ -192,6 +199,33 @@ export default function App() { } }; + /** Re-read one record and put the stored version in place. */ + const reloadOne = async (id) => { + try { + const stored = await api(`${API}/${encodeURIComponent(id)}`); + setItems((prev) => prev.map((t) => (t.id === id ? stored : t))); + } catch { + /* the list's next refresh picks it up */ + } + }; + + const askAgent = async (task) => { + setAsking((prev) => new Set(prev).add(task.id)); + try { + const res = await api("/api/ops/request-triage", { method: "POST", body: JSON.stringify({ task: task.id }) }); + if (res?.ok === false) throw new Error(res.reason ?? "The app refused the request."); + await reloadOne(task.id); // the stored record carries the task id the panel follows + } catch (err) { + toast("error", `Could not ask an agent about "${task.title}". ${err.message}`); + } finally { + setAsking((prev) => { + const next = new Set(prev); + next.delete(task.id); + return next; + }); + } + }; + const deleteTask = async (task) => { const confirmed = await confirm({ title: "Delete this task?", @@ -317,7 +351,10 @@ export default function App() { key={task.id} task={task} busy={busy.has(task.id)} + asking={asking.has(task.id)} onAdvance={() => advanceStatus(task)} + onAsk={() => askAgent(task)} + onAgentSettled={() => reloadOne(task.id)} onDelete={() => deleteTask(task)} /> ))} @@ -351,9 +388,12 @@ export default function App() { /* ------------------------------------------------------------ components */ -function TaskRow({ task, busy, onAdvance, onDelete }) { +function TaskRow({ task, busy, asking, onAdvance, onAsk, onAgentSettled, onDelete }) { const st = task.status ?? "todo"; const overdue = task.due && isPastDay(task.due) && st !== "done"; + // Shares the panel's poller: knowing whether a run is in flight costs no extra request. + const agent = useAgentTask(task.agentTask); + const agentBusy = asking || (agent.task && !["completed", "failed", "canceled"].includes(agent.task.status)); return (
  • {/* status: a labelled 3-state control; colour is never the only channel */} @@ -369,14 +409,36 @@ function TaskRow({ task, busy, onAdvance, onDelete }) { {task.title} + {task.agentTask && } {task.due && ( {overdue ? `Overdue · ${fmtDay(task.due)}` : `Due ${fmtDay(task.due)}`} )} + {st !== "done" && ( + + )} + {task.agentTask && ( + + )}
  • ); } diff --git a/toolkits/blueprint-react-node/template/src/Icon.jsx b/toolkits/blueprint-react-node/template/src/Icon.jsx index 6525eea..f97e6cc 100644 --- a/toolkits/blueprint-react-node/template/src/Icon.jsx +++ b/toolkits/blueprint-react-node/template/src/Icon.jsx @@ -11,6 +11,7 @@ const ICON_PATHS = { info: "M8 7.5V11m0-5.5v-.01M14.5 8a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0Z", inbox: "M1.5 9.5h3l1 2h5l1-2h3M2.5 3.5h11l1 6v3a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-3l1-6Z", refresh: "M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2.5v2.6h-2.6", + spark: "M8 1.5v3M8 11.5v3M1.5 8h3M11.5 8h3M3.4 3.4l2.1 2.1M10.5 10.5l2.1 2.1M3.4 12.6l2.1-2.1M10.5 5.5l2.1-2.1", }; export default function Icon({ name, size = 16 }) { diff --git a/toolkits/blueprint-rust-react/template/AGENT_APP.md b/toolkits/blueprint-rust-react/template/AGENT_APP.md index f53e885..91b8f84 100644 --- a/toolkits/blueprint-rust-react/template/AGENT_APP.md +++ b/toolkits/blueprint-rust-react/template/AGENT_APP.md @@ -34,7 +34,8 @@ exactly one, and describe's root screen lists them. They are declared in duration where a token exists. - **Shared pieces live in `view/`** — `Icon.jsx` (the icon set), `toast.jsx` (`useToast`), `ConfirmDialog.jsx` (`useConfirm` — never `window.confirm`), - `format.js`, `api.js` (the one fetch wrapper). Screens compose them — one + `format.js`, `api.js` (the one fetch wrapper), `AgentTask.jsx` (work queued + for an agent, shown until it is done). Screens compose them — one implementation per widget, no per-screen copies, no native browser dialogs. Component styles live in `public/ui.css`. The React sources live in `view/` because `src/` belongs to cargo. diff --git a/toolkits/blueprint-rust-react/template/public/ui.css b/toolkits/blueprint-rust-react/template/public/ui.css index d795803..ffc9490 100644 --- a/toolkits/blueprint-rust-react/template/public/ui.css +++ b/toolkits/blueprint-rust-react/template/public/ui.css @@ -114,7 +114,7 @@ body { /* ---------------------------------------------------------------- task list */ .task-list { list-style: none; margin: 0; padding: var(--sp-1) 0; } .task { - display: flex; align-items: center; gap: var(--sp-3); + display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; min-height: var(--row-h); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-subtle); transition: background var(--dur-fast) var(--ease-std), opacity var(--dur-mid) var(--ease-std); @@ -126,6 +126,8 @@ body { .task.done .title { color: var(--text-tertiary); text-decoration: line-through; } .task .due { font-size: var(--fs-xs); color: var(--text-tertiary); font-variant-numeric: tabular-nums; white-space: nowrap; } .task .due.overdue { color: var(--danger-text); font-weight: var(--fw-medium); } +/* agent work gets its own full-width line under the row, never a squeeze into the title */ +.task > .agent-task { flex-basis: 100%; margin-bottom: var(--sp-1); } /* status: a labelled 3-state control; colour is never the only channel (text names the state) */ .status-btn { @@ -196,12 +198,68 @@ body { @keyframes fade-in { from { opacity: 0; } } @keyframes dialog-in { from { opacity: 0; transform: translateY(var(--sp-2)) scale(.98); } } +/* ---------------------------------------------------------------- agent work (AgentTask.jsx) + Work queued for an agent stays visible until it is done. The panel takes the + full width of its container; an agent's result is prose of any length and + never goes in a title cell. */ +.agent-badge { + display: inline-flex; align-items: center; gap: var(--sp-1); + font-size: var(--fs-xs); font-weight: var(--fw-medium); white-space: nowrap; + padding: 2px var(--sp-2); border-radius: var(--r-full); border: 1px solid; +} +.agent-dot { width: 8px; height: 8px; border-radius: var(--r-full); background: currentColor; flex: none; } +.live .agent-dot { animation: pulse 1.2s var(--ease-std) infinite; } + +.tone-neutral { --tone-bg: var(--neutral-bg); --tone-text: var(--neutral-text); --tone-border: var(--neutral-border); } +.tone-info { --tone-bg: var(--info-bg); --tone-text: var(--info-text); --tone-border: var(--info-border); } +.tone-warning { --tone-bg: var(--warning-bg); --tone-text: var(--warning-text); --tone-border: var(--warning-border); } +.tone-success { --tone-bg: var(--success-bg); --tone-text: var(--success-text); --tone-border: var(--success-border); } +.tone-danger { --tone-bg: var(--danger-bg); --tone-text: var(--danger-text); --tone-border: var(--danger-border); } +.agent-badge { background: var(--tone-bg); color: var(--tone-text); border-color: var(--tone-border); } + +.agent-task { + display: flex; flex-direction: column; gap: var(--sp-2); + width: 100%; min-width: 0; padding: var(--sp-3) var(--sp-4); + border: 1px solid var(--tone-border); border-left-width: 3px; border-radius: var(--r-lg); + background: var(--bg-surface); +} +.agent-task-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; color: var(--tone-text); } +.agent-task-state { font-size: var(--fs-sm); font-weight: var(--fw-medium); min-width: 0; overflow-wrap: anywhere; } +.agent-task-step { font-weight: var(--fw-regular); color: var(--text-secondary); } +.agent-task-elapsed { margin-left: auto; font-size: var(--fs-xs); color: var(--text-tertiary); font-variant-numeric: tabular-nums; white-space: nowrap; } +.agent-task-note { margin: 0; font-size: var(--fs-sm); color: var(--text-secondary); overflow-wrap: anywhere; } +.agent-task-note code { font-family: var(--font-mono); font-size: var(--fs-xs); background: var(--bg-sunken); padding: 1px var(--sp-1); border-radius: var(--r-sm); } +.agent-task-failure { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; } +.agent-task-failure .agent-task-note { flex: 1; min-width: 12ch; color: var(--danger-text); } +.agent-task-code { font-family: var(--font-mono); font-size: var(--fs-xs); color: var(--text-tertiary); } +.agent-task-bar { height: 4px; border-radius: var(--r-full); background: var(--bg-sunken); overflow: hidden; } +.agent-task-bar span { display: block; height: 100%; background: var(--info-solid); transition: width var(--dur-mid) var(--ease-std); } + +.agent-result { display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-1); } +.agent-result-text { + margin: 0; max-width: 72ch; white-space: pre-wrap; overflow-wrap: anywhere; + font-size: var(--fs-base); line-height: var(--lh-normal); color: var(--text-primary); +} +.agent-result-text.clamped { display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } +.agent-result-text a { color: var(--accent-text); text-decoration: underline; text-underline-offset: 2px; } +.agent-result-toggle { + font: inherit; font-size: var(--fs-sm); color: var(--accent-text); background: none; border: 0; + padding: var(--sp-1) 0; cursor: pointer; min-height: 24px; +} +.agent-result-toggle:hover { text-decoration: underline; } +@media (prefers-reduced-motion: reduce) { + .live .agent-dot { animation: none; } + .agent-task-bar span { transition: none; } +} + /* ---------------------------------------------------------------- narrow screens */ @media (max-width: 480px) { .app { padding: var(--sp-5) var(--sp-3) var(--sp-10); } .add-form { flex-wrap: wrap; } .add-form .field { flex-basis: 100%; } .task .due { display: none; } + /* the panel under the row says the same thing; the title keeps the room */ + .task .agent-badge { display: none; } /* touch-first: primary controls grow toward the platform norm */ .btn, .input, .select { min-height: 44px; } .btn-icon { width: 40px; height: 40px; } diff --git a/toolkits/blueprint-rust-react/template/reference/blueprint.md b/toolkits/blueprint-rust-react/template/reference/blueprint.md index 563ac57..0ecf976 100644 --- a/toolkits/blueprint-rust-react/template/reference/blueprint.md +++ b/toolkits/blueprint-rust-react/template/reference/blueprint.md @@ -181,7 +181,9 @@ unbounded collection — page with `perPage`. **Shared pieces — `view/`** (one implementation each; import, don't rebuild): `` · `useToast()` → `toast(kind, message)` · `useConfirm()` → `[confirm, confirmElement]` (in-app dialog — never -`window.confirm`) · `api(path, init)` · `fmtDay(dayKey)` · `isPastDay(dayKey)`. +`window.confirm`) · `api(path, init)` · `fmtDay(dayKey)` · `isPastDay(dayKey)` · +`` / `` / `useAgentTask(id)` (`AgentTask.jsx` — +work queued for an agent, shown until it is done; see **App→agent**). Component styles are plain CSS classes in `public/ui.css`. **Tokens — `public/tokens.css`:** the kit sheet — `--agent-app-*` foundation → @@ -257,13 +259,39 @@ queued and no agent is handed it. An undeclared type is an `Err`. The starter's The queue only moves when something is listening: `agent-app bridge start`, or a harness polling `a2app tasks next --wait`. -**Show the queued work in the View.** Keep the returned `taskId` on the record -and follow it from the UI with a same-origin `GET /api/_a2app/tasks/{id}`, polled -only while it is unfinished. That read needs no credential only on a -single-user app. On a multi-user app it answers 401, so show what the agent -writes to the record instead. Render queued → working (`progress.step`) → done or -failed (`reason`, plus a way to ask again). The creator skill lists the states. -A button that goes quiet after it queues work looks broken. +**Show the queued work in the View — with `view/AgentTask.jsx`.** An agent run +takes seconds to minutes, so the control that queued it has to show where it is +until it is done. Keep the returned `taskId` on the record (a `readOnly` string +field the runner sets, like the starter's `agentTask`), then render it: + +```jsx +import { AgentTaskBadge, AgentTaskPanel } from "./AgentTask.jsx"; + + // in the list row, beside the title + +``` + +The panel follows `GET /api/_a2app/tasks/{id}` while the run is unfinished and +renders every state the creator skill lists: waiting with elapsed time (and "no +agent is listening" after ~20 s), the agent's step and running time, the +`result.summary` in its own readable block, and the failure `reason` with "Ask +again". `useAgentTask(id)` gives you the same state to disable a control while a +run is open. The starter's "Ask an agent" button is the worked example. + +Never put the result in a title cell or a narrow column. It is prose of any +length, and the panel is where it goes. + +On a single-user app (`authMode: "none"`) the View needs no credential for the +task read. **On a multi-user app it answers 401**, and the View has no other +path to the queue yet. There, have the agent write its progress onto the record +and pass it as `progress={{ status, step, summary, reason }}`; the panel and +badge render it the same way. A 404 means the task has been pruned, so the +indicator disappears. + +**`validate` checks this.** Its "agent work shown in the View" step fails an +app whose code queues work (`trigger(…)` with a capability) when no View code +renders the component or reads `/api/_a2app/tasks/`. ## Build, run, gate @@ -272,7 +300,7 @@ health at `/api/_a2app`). Never start a server by hand. ```bash agent-app dev # boot the candidate on a hidden port: fresh seeded store, prints the dev URL -agent-app validate # framework files → build → adapter self-test → operations resolve → ownership canon → describe budget (on dev) +agent-app validate # framework files → build → adapter self-test → operations resolve → agent work shown in View → ownership canon → describe budget (on dev) agent-app serve # launch LIVE as a managed, health-polled background process; prints the URL agent-app promote # requires the gate pass; backup, cargo run -- --promote-check, destroys the dev instance ``` diff --git a/toolkits/blueprint-rust-react/template/src/schema.rs b/toolkits/blueprint-rust-react/template/src/schema.rs index 6d0c993..eefb1c8 100644 --- a/toolkits/blueprint-rust-react/template/src/schema.rs +++ b/toolkits/blueprint-rust-react/template/src/schema.rs @@ -32,6 +32,10 @@ pub fn entities() -> Value { { "name": "due", "type": "string", "max": 10, "dayKey": true }, { "name": "notes", "type": "string", "max": 2000 }, { "name": "created", "type": "datetime", "readOnly": true }, + // The queue task an agent is (or was last) working on for this + // record. The runner sets it; the View follows it, so the person + // can see the work they asked for until it is done. + { "name": "agentTask", "type": "string", "max": 64, "readOnly": true }, ], }, }) @@ -173,11 +177,27 @@ fn run_complete_task(args: &Value, _ctx: &Value, store: &mut Store) -> Result Result { let task_id = args.get("task").and_then(Value::as_str).unwrap_or(""); - if store.get_record("tasks", task_id).is_none() { - return Ok(json!({ "ok": false, "reason": "no such task" })); + let mut task = match store.get_record("tasks", task_id) { + Some(t) => t, + None => return Ok(json!({ "ok": false, "reason": "no such task" })), + }; + let mut payload = json!({ "task": task_id }); + if let Some(prev) = task.get("agentTask").and_then(Value::as_str).filter(|p| !p.is_empty()) { + payload["previous"] = json!(prev); } - let fired = store.trigger("task.needs_triage", &json!({ "task": task_id }), Some("triage"))?; + let fired = store.trigger("task.needs_triage", &payload, Some("triage"))?; + task["agentTask"] = fired["taskId"].clone(); + store.put_record("tasks", task); Ok(json!({ "ok": true, "task": task_id, "queued": fired["taskId"] })) } diff --git a/toolkits/blueprint-rust-react/template/view/AgentTask.jsx b/toolkits/blueprint-rust-react/template/view/AgentTask.jsx new file mode 100644 index 0000000..d23ca67 --- /dev/null +++ b/toolkits/blueprint-rust-react/template/view/AgentTask.jsx @@ -0,0 +1,439 @@ +/** + * Agent work, shown until it is done (AGENT-OWNED shared piece — @a2app-kit agent-task). + * + * An agent run takes seconds to minutes. Whatever control queued it has to + * keep showing where it is, or the person is left with no sign anything is + * happening. This is that display, so a feature that queues work does not + * rebuild it: + * + * in a list row, beside the title + * + * + * Both follow `GET /api/_a2app/tasks/{id}` at the task's own `pollAfterMs`, + * only while it is unfinished, and share one poller per id, so a badge and a + * panel for the same task cost one request. Every state renders: + * + * submitted waiting, with elapsed time; after ~20 s unclaimed, says no + * agent is listening and how to start one + * working `progress.step` (and `percent` as a bar) and running time + * input-required the agent is waiting on someone + * completed `result.summary` in its own full-width block: line breaks + * kept, links clickable, long text clamped behind "Show more". + * No summary (the agent never closed the task itself, so the + * bridge did)? The run's printed output, labelled as such. + * failed/canceled the `reason` in words (the queue's and bridge's own codes + * are translated), and "Ask again" when `onRetry` is given + * + * A 404 means the task was pruned, so the indicator disappears. On a + * multi-user app the read answers 401 — the View has no credential for the + * queue. There, have the agent write its progress onto the record and pass it + * as `progress={{ status, step, summary, reason }}`; it renders the same way, + * with or without a `taskId`. + * + * Never put `result.summary` in a title cell or a table column. It is prose of + * any length and belongs in the panel. + */ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { api } from "./api.js"; +import Icon from "./Icon.jsx"; + +const FINISHED = new Set(["completed", "failed", "canceled"]); +/** How long a task may sit unclaimed before we say nobody is listening. */ +export const NO_LISTENER_MS = 20_000; +const DEFAULT_POLL_MS = 2000; +const RETRY_MS = 5000; +/** Slower polling when nothing is likely to change soon: an unclaimed task, or a hidden tab. */ +const SLOW_POLL_MS = 10_000; +const CLAMP_CHARS = 280; +const CLAMP_LINES = 4; + +const LABEL = { + submitted: "Waiting for an agent", + unheard: "No agent is listening", + working: "Agent working", + "input-required": "Agent needs input", + completed: "Agent done", + failed: "Agent failed", + canceled: "Agent run canceled", +}; +const TONE = { + submitted: "neutral", + unheard: "warning", + working: "info", + "input-required": "warning", + completed: "success", + failed: "danger", + canceled: "neutral", +}; + +/** + * Reasons the queue and the bridge write themselves, in words. An agent's own + * reason is prose already; these are codes, and a code is not an explanation. + */ +const REASON_WORDS = { + redelivery_exhausted: + "It was handed to an agent several times and never finished. Check that the bridge can start your agent, then ask again.", + harness_unavailable: "The bridge could not find an agent to run on this machine.", + harness_spawn_failed: "The bridge could not start the agent.", + harness_exited_nonzero: "The agent stopped with an error before reporting back.", + harness_timeout: "The agent took too long and was stopped.", + unspecified: "The agent did not say why.", +}; + +/** A failure reason a person can read, plus the raw code when it was one. */ +export function reasonInWords(reason, canceled = false) { + if (!reason) return { text: canceled ? "The run was canceled." : "The agent did not say why.", code: null }; + if (REASON_WORDS[reason]) return { text: REASON_WORDS[reason], code: reason }; + // An unknown code (snake_case, no spaces) still gets a sentence around it. + if (/^[a-z0-9]+(?:_[a-z0-9]+)+$/.test(reason)) return { text: "The run stopped before it finished.", code: reason }; + return { text: reason, code: null }; +} + +/* ------------------------------------------------------------- the poller */ + +// id -> { snap, listeners, timer, stopped }. `snap` is replaced, never +// mutated, so useSyncExternalStore sees each change. +const watchers = new Map(); +const IDLE = { phase: "idle", task: null }; +const LOADING = { phase: "loading", task: null }; + +function watcherFor(id) { + let w = watchers.get(id); + if (!w) { + w = { snap: LOADING, listeners: new Set(), timer: null, stopped: false }; + watchers.set(id, w); + poll(id, w); + } + return w; +} + +function publish(w, snap) { + w.snap = snap; + for (const l of w.listeners) l(); +} + +async function poll(id, w) { + if (w.stopped) return; + let next = DEFAULT_POLL_MS; + try { + const task = await api(`/api/_a2app/tasks/${encodeURIComponent(id)}`); + if (w.stopped) return; + publish(w, { phase: "ok", task }); + if (FINISHED.has(task.status)) return; // settled: nothing more will change + next = task.pollAfterMs ?? DEFAULT_POLL_MS; + const waited = Date.now() - (ms(task.createdAt) ?? Date.now()); + if (task.status === "submitted" && waited > NO_LISTENER_MS) next = Math.max(next, SLOW_POLL_MS); + } catch (err) { + if (w.stopped) return; + if (err?.status === 404) return publish(w, { phase: "gone", task: null }); + if (err?.status === 401 || err?.status === 403) return publish(w, { phase: "unauthorized", task: w.snap.task }); + // Network trouble or rate limiting: keep what we last knew and try again later. + publish(w, { phase: "stale", task: w.snap.task, error: err?.message }); + next = err?.status === 429 ? SLOW_POLL_MS : RETRY_MS; + } + if (typeof document !== "undefined" && document.hidden) next = Math.max(next, SLOW_POLL_MS); + w.timer = setTimeout(() => poll(id, w), next); +} + +function subscribe(id, listener) { + if (!id) return () => {}; + const w = watcherFor(id); + w.listeners.add(listener); + return () => { + w.listeners.delete(listener); + // Stop only once nobody has re-subscribed by the next tick: a re-render + // (or a row moving between lists) unsubscribes and subscribes again at + // once, and tearing down in between would restart polling from scratch. + setTimeout(() => { + if (w.listeners.size > 0 || watchers.get(id) !== w) return; + w.stopped = true; + clearTimeout(w.timer); + watchers.delete(id); + }, 0); + }; +} + +/** + * Follow one queued task. Returns `{ phase, task }`: phase is loading · ok · + * stale (last known, the read is failing) · gone (404) · unauthorized (401) · + * idle (no id). + */ +export function useAgentTask(taskId) { + const sub = useCallback((l) => subscribe(taskId, l), [taskId]); + const snap = useCallback(() => (taskId ? (watchers.get(taskId)?.snap ?? LOADING) : IDLE), [taskId]); + return useSyncExternalStore(sub, snap); +} + +/* ---------------------------------------------------------- shared logic */ + +/** Seconds ticking while something is unfinished; frozen once it settles. */ +function useNow(running) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!running) return undefined; + const t = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(t); + }, [running]); + return now; +} + +/** "8s" · "1m 05s" · "1h 02m". */ +export function fmtElapsed(ms) { + const s = Math.max(0, Math.floor(ms / 1000)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`; + return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`; +} + +const ms = (iso) => { + const t = iso ? Date.parse(iso) : NaN; + return Number.isNaN(t) ? null : t; +}; + +/** + * What a harness printed, when the agent closed no task itself: the bridge then + * completes it with the run's output instead of a summary. Terminal colour + * codes are noise in a page. + */ +function printedOutput(result) { + if (typeof result?.output !== "string") return null; + // eslint-disable-next-line no-control-regex + const text = result.output.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").trim(); + return text || null; +} + +/** + * One view of a task, whichever way it arrived: the queue's own record, or the + * progress an agent wrote onto the app's record. + */ +function viewOf(state, progress, now) { + const task = state.task; + if (task) { + const created = ms(task.createdAt); + const claimed = ms(task.claim?.claimedAt); + const updated = ms(task.updatedAt); + let key = task.status; + let elapsed = null; + if (key === "submitted") { + elapsed = created !== null ? now - created : null; + if (elapsed !== null && elapsed >= NO_LISTENER_MS) key = "unheard"; + } else if (key === "working" || key === "input-required") { + const since = claimed ?? created; + elapsed = since !== null ? now - since : null; + } else if (created !== null && updated !== null) { + elapsed = updated - created; + } + return { + key, + elapsed, + step: task.progress?.step ?? null, + // The queue reports 0 until the agent says otherwise; only real progress draws a bar. + percent: task.progress?.percent > 0 ? task.progress.percent : null, + summary: task.result?.summary ?? null, + output: printedOutput(task.result), + reason: task.reason ?? null, + }; + } + if (progress?.status) { + return { + key: progress.status, + elapsed: null, + step: progress.step ?? null, + percent: progress.percent > 0 ? progress.percent : null, + summary: progress.summary ?? null, + output: null, + reason: progress.reason ?? null, + }; + } + return null; +} + +/** Calls `fn(task)` once when a task we watched while unfinished settles. */ +function useSettled(task, fn) { + const prev = useRef(task?.status); + const fnRef = useRef(fn); + fnRef.current = fn; + useEffect(() => { + const was = prev.current; + prev.current = task?.status; + if (task && FINISHED.has(task.status) && was !== undefined && !FINISHED.has(was)) fnRef.current?.(task); + }, [task?.status, task]); +} + +/* ------------------------------------------------------------- the badge */ + +/** A compact state pill for a list row, so someone can leave the screen and come back. */ +export function AgentTaskBadge({ taskId, progress }) { + const state = useAgentTask(taskId); + const live = state.task && !FINISHED.has(state.task.status); + const now = useNow(Boolean(live)); + if (state.phase === "gone") return null; + const v = viewOf(state, progress, now); + if (!v) return null; + const label = LABEL[v.key] ?? v.key; + return ( + + + ); +} + +/* ------------------------------------------------------------- the panel */ + +/** + * The full display: state, elapsed time, progress, and the result in a + * readable block. Give it the whole width of its container — under a list + * row, not inside one of its cells. + */ +export function AgentTaskPanel({ + taskId, + progress, + title = "Agent", + onSettled, + onRetry, + retrying = false, + bridgeHint = "agent-app bridge start", +}) { + const state = useAgentTask(taskId); + const live = state.task && !FINISHED.has(state.task.status); + const now = useNow(Boolean(live)); + useSettled(state.task, onSettled); + + if (state.phase === "gone") return null; + if (state.phase === "loading" && !progress?.status) { + return ( +
    +
    +
    +
    + ); + } + + const v = viewOf(state, progress, now); + if (!v) { + if (state.phase === "unauthorized") { + return ( +
    +

    + This app needs sign-in to read the agent queue, so progress shows here only once the agent records it. +

    +
    + ); + } + return null; + } + + const tone = TONE[v.key] ?? "neutral"; + const label = LABEL[v.key] ?? v.key; + const finished = FINISHED.has(v.key); + const elapsedText = + v.elapsed === null ? null : finished ? `took ${fmtElapsed(v.elapsed)}` : fmtElapsed(v.elapsed); + + return ( +
    +
    +
    + + {v.percent !== null && !finished && ( +
    + +
    + )} + + {v.key === "unheard" && ( +

    + Nothing has picked this up. Work is only delivered while an agent is listening — start one with{" "} + {bridgeHint}. +

    + )} + {v.key === "input-required" && ( +

    The agent is waiting for someone to answer it before it can continue.

    + )} + + {v.key === "completed" && v.summary && } + {v.key === "completed" && !v.summary && v.output && ( + + )} + + {(v.key === "failed" || v.key === "canceled") && ( +
    + + {onRetry && ( + + )} +
    + )} +
    + ); +} + +function FailureReason({ reason, canceled }) { + const { text, code } = reasonInWords(reason, canceled); + return ( +

    + {text} + {code && ({code})} +

    + ); +} + +/* ------------------------------------------------------------ the result */ + +const URL_RE = /(https?:\/\/[^\s<>"']+)/g; + +/** Plain text with its line breaks kept and its links clickable. */ +function Linkified({ text }) { + return text.split(URL_RE).map((part, i) => { + if (i % 2 === 0) return part; + // Sentence punctuation after a link is not part of it. + const m = part.match(/^(.*?)([.,;:!?)\]]*)$/); + const href = m ? m[1] : part; + return ( + + + {href} + + {m ? m[2] : ""} + + ); + }); +} + +/** An agent's answer: its own block, readable at any length. */ +export function AgentResult({ text, note }) { + const long = text.length > CLAMP_CHARS || text.split("\n").length > CLAMP_LINES; + const [open, setOpen] = useState(false); + return ( +
    + {note &&

    {note}

    } +

    + +

    + {long && ( + + )} +
    + ); +} diff --git a/toolkits/blueprint-rust-react/template/view/App.jsx b/toolkits/blueprint-rust-react/template/view/App.jsx index b6b6417..5ddf4be 100644 --- a/toolkits/blueprint-rust-react/template/view/App.jsx +++ b/toolkits/blueprint-rust-react/template/view/App.jsx @@ -11,6 +11,11 @@ * staleness. A CODE change it handles itself (reload when nothing is unsaved). * A DATA change it only announces (`a2app:datachange`) — because only this * file knows how to re-read without discarding a form someone is filling in. + * + * Agent work: "Ask an agent" queues a triage task (the `request-triage` + * operation). The row then shows it until it is done — a badge beside the + * title, and the full-width AgentTaskPanel under the row with the state, the + * elapsed time and, at the end, the agent's answer. */ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "./api.js"; @@ -18,6 +23,7 @@ import { hasUnsavedInput } from "./updater.js"; import Icon from "./Icon.jsx"; import { useToast } from "./toast.jsx"; import { useConfirm } from "./ConfirmDialog.jsx"; +import { AgentTaskBadge, AgentTaskPanel, useAgentTask } from "./AgentTask.jsx"; import { STATUS_LABEL, NEXT_STATUS, fmtDay, isPastDay } from "./format.js"; const API = "/api/collections/tasks/records"; @@ -42,6 +48,7 @@ export default function App() { const [phase, setPhase] = useState("loading"); // loading | ready | error const [errorMessage, setErrorMessage] = useState(""); const [busy, setBusy] = useState(() => new Set()); // record ids with a write in flight + const [asking, setAsking] = useState(() => new Set()); // record ids with an agent request in flight const [adding, setAdding] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [title, setTitle] = useState(""); @@ -192,6 +199,33 @@ export default function App() { } }; + /** Re-read one record and put the stored version in place. */ + const reloadOne = async (id) => { + try { + const stored = await api(`${API}/${encodeURIComponent(id)}`); + setItems((prev) => prev.map((t) => (t.id === id ? stored : t))); + } catch { + /* the list's next refresh picks it up */ + } + }; + + const askAgent = async (task) => { + setAsking((prev) => new Set(prev).add(task.id)); + try { + const res = await api("/api/ops/request-triage", { method: "POST", body: JSON.stringify({ task: task.id }) }); + if (res?.ok === false) throw new Error(res.reason ?? "The app refused the request."); + await reloadOne(task.id); // the stored record carries the task id the panel follows + } catch (err) { + toast("error", `Could not ask an agent about "${task.title}". ${err.message}`); + } finally { + setAsking((prev) => { + const next = new Set(prev); + next.delete(task.id); + return next; + }); + } + }; + const deleteTask = async (task) => { const confirmed = await confirm({ title: "Delete this task?", @@ -317,7 +351,10 @@ export default function App() { key={task.id} task={task} busy={busy.has(task.id)} + asking={asking.has(task.id)} onAdvance={() => advanceStatus(task)} + onAsk={() => askAgent(task)} + onAgentSettled={() => reloadOne(task.id)} onDelete={() => deleteTask(task)} /> ))} @@ -351,9 +388,12 @@ export default function App() { /* ------------------------------------------------------------ components */ -function TaskRow({ task, busy, onAdvance, onDelete }) { +function TaskRow({ task, busy, asking, onAdvance, onAsk, onAgentSettled, onDelete }) { const st = task.status ?? "todo"; const overdue = task.due && isPastDay(task.due) && st !== "done"; + // Shares the panel's poller: knowing whether a run is in flight costs no extra request. + const agent = useAgentTask(task.agentTask); + const agentBusy = asking || (agent.task && !["completed", "failed", "canceled"].includes(agent.task.status)); return (
  • {/* status: a labelled 3-state control; colour is never the only channel */} @@ -369,14 +409,36 @@ function TaskRow({ task, busy, onAdvance, onDelete }) { {task.title} + {task.agentTask && } {task.due && ( {overdue ? `Overdue · ${fmtDay(task.due)}` : `Due ${fmtDay(task.due)}`} )} + {st !== "done" && ( + + )} + {task.agentTask && ( + + )}
  • ); } diff --git a/toolkits/blueprint-rust-react/template/view/Icon.jsx b/toolkits/blueprint-rust-react/template/view/Icon.jsx index 6525eea..f97e6cc 100644 --- a/toolkits/blueprint-rust-react/template/view/Icon.jsx +++ b/toolkits/blueprint-rust-react/template/view/Icon.jsx @@ -11,6 +11,7 @@ const ICON_PATHS = { info: "M8 7.5V11m0-5.5v-.01M14.5 8a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0Z", inbox: "M1.5 9.5h3l1 2h5l1-2h3M2.5 3.5h11l1 6v3a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-3l1-6Z", refresh: "M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2.5v2.6h-2.6", + spark: "M8 1.5v3M8 11.5v3M1.5 8h3M11.5 8h3M3.4 3.4l2.1 2.1M10.5 10.5l2.1 2.1M3.4 12.6l2.1-2.1M10.5 5.5l2.1-2.1", }; export default function Icon({ name, size = 16 }) {