From b45d2f4db3bdb7fccab79c7abf4ab36987ef9734 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 2 Oct 2026 14:11:38 +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 Work an app queues for an agent now has to be visible in the View until it is done, and the default path makes that the easy thing to do (#13). - validate: new "agent work shown in the View" step. A stack-agnostic static scan finds trigger(...) calls that name a capability in app-owned code (JS, Go, Ruby, Python, Rust spellings; comments, strings, definitions, system files, deps and tests ignored) and fails when no View code renders the agent-task component or reads /api/_a2app/tasks/. The component's own file does not count. - UI kit: an agent-task panel, badge and useAgentTask hook in every blueprint with a queue and a View (AgentTask.jsx for react-node/go/rust; Vue components + agentTask.js for rails-vue). One shared poller per task, every state rendered (waiting with elapsed time and a no-listener hint, working with step, done with a readable full-width result, failed with the reason in words and Ask again), record-written progress for multi-user apps, and the run's printed output when the bridge closed the task without a summary. - Starters: "Ask an agent" button, agentTask field set by request-triage, and the previous task id in the payload so asking again is a new request. - Skills: creator states the result-rendering rule and the gate; walk-verify gets check 6c for queued work; modify points at both. Co-Authored-By: Claude Opus 5.5 --- framework/cli/package.json | 2 +- framework/cli/src/lib/agentState.ts | 341 ++++++++++++++ framework/cli/src/lib/gate.ts | 18 +- framework/cli/test/agent-state.test.mjs | 188 ++++++++ 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/AGENT_APP.md | 4 +- .../template/lib/a2app_schema.rb | 20 +- .../template/reference/blueprint.md | 49 +- .../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 + 39 files changed, 3140 insertions(+), 82 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/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/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/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/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 }) {