Public: anyone can read this; the crew can change it.
Cards: a words field so the app card can Tell and the decision card can Redirect, and a test of the shell's drawing #1885
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1535-cards-a-words-field-so-the-app-card-can → main 4 commits fork
6 files
changed +125
−7
docs/MCP.md +6 −1
| @@ -171,7 +171,12 @@ answers | ||
| 171 | 171 | tool result carries, with `textContent` only. The model is built in |
| 172 | 172 | `src/web/studio/cards.ts`. A card's |
| 173 | 173 | buttons ask the host to call a tool (`asks_act`, `say`, `stop`, `tell`); the |
| 174 | host decides, and every call is checked as any other. | |
| 174 | host decides, and every call is checked as any other. A card with a | |
| 175 | words action shows one text field: **Tell <app>** (app card) sends the | |
| 176 | words to `tell` as `text` with `repo` and `context.said`; **Redirect** | |
| 177 | (decision card) sends them to `say` with the first ask's `repo`, `number` | |
| 178 | and `text`. Empty words send nothing. `asks` rows carry `repo` | |
| 179 | (`owner/name`) beside `number` for this. | |
| 175 | 180 | - **Contract.** `GET /contract/openapi.json` is OpenAPI 3.1 for the routes |
| 176 | 181 | the manifest (`here`) names, plus `POST /mcp`. `test/openapi.test.ts` fails |
| 177 | 182 | when the manifest names a route it lacks. |
scripts/gate.ts +1
| @@ -137,6 +137,7 @@ export const GATE: readonly string[] = [ | ||
| 137 | 137 | "test/planner-declined.test.ts", |
| 138 | 138 | "test/feed.test.ts", |
| 139 | 139 | "test/feed-page.test.ts", |
| 140 | "test/card-shell.test.ts", | |
| 140 | 141 | "test/offline.test.ts", |
| 141 | 142 | "test/share.test.ts", |
| 142 | 143 | "test/cloud.test.ts", |
src/web/studio/asks.ts +3
| @@ -687,6 +687,8 @@ export function askLinesFor( | ||
| 687 | 687 | line: string; |
| 688 | 688 | kind: DecisionKind; |
| 689 | 689 | repoId: string; |
| 690 | /** The app, as owner/name: what `say` and `tell` take as `repo`. */ | |
| 691 | repo: string; | |
| 690 | 692 | /** Where a person decides it: the pull or issue, or the row on the home. */ |
| 691 | 693 | link: string; |
| 692 | 694 | /** The pull's or issue's number, for a row of one that is about one. */ |
| @@ -704,6 +706,7 @@ export function askLinesFor( | ||
| 704 | 706 | id: row.id, |
| 705 | 707 | kind: row.kind, |
| 706 | 708 | repoId: row.repo.id, |
| 709 | repo: `${row.repo.ownerName}/${row.repo.name}`, | |
| 707 | 710 | items: row.items.map((c) => c.id), |
| 708 | 711 | words: ASK_WORDS[row.kind], |
| 709 | 712 | consequence: row.consequence, |
src/web/studio/cards.ts +24 −4
| @@ -20,6 +20,8 @@ export interface CardAction { | ||
| 20 | 20 | /** The tool the host calls when it is pressed, and with what. */ |
| 21 | 21 | readonly tool: string; |
| 22 | 22 | readonly args: Record<string, unknown>; |
| 23 | /** Set when the press sends the person's typed words as `text` (and into `context.said`, if the args carry a `context`). */ | |
| 24 | readonly words?: true; | |
| 23 | 25 | } |
| 24 | 26 | export interface Card { |
| 25 | 27 | readonly kind: CardKind; |
| @@ -73,6 +75,9 @@ export function cardFor(tool: string, value: unknown): Card | undefined { | ||
| 73 | 75 | Number(a["working"]) > 0 ? ` · ${text(a["working"])} working` : "" |
| 74 | 76 | }`, |
| 75 | 77 | }); |
| 78 | for (const a of apps.slice(0, 3)) | |
| 79 | if (text(a["app"]) !== "") | |
| 80 | actions.push({ label: `Tell ${text(a["app"])}`, tool: "tell", args: { repo: text(a["app"]), context: {} }, words: true }); | |
| 76 | 81 | } else if (kind === "run") { |
| 77 | 82 | const runs = list(value).map(obj); |
| 78 | 83 | status = runs.length === 0 ? "nothing running" : `${runs.length} going`; |
| @@ -100,11 +105,15 @@ export function cardFor(tool: string, value: unknown): Card | undefined { | ||
| 100 | 105 | const items = list(first["items"]).map(text); |
| 101 | 106 | const kindOf = text(first["kind"]); |
| 102 | 107 | // No `dryRun`: the press is the dry run `asks_act` defaults to, and the |
| 103 | // host shows what would change before anything ships or closes. Tell and | |
| 104 | // Redirect are absent: the card has no field for the person's words. | |
| 108 | // host shows what would change before anything ships or closes. Redirect | |
| 109 | // sends the typed words to `say` on the row's own pull or issue. | |
| 105 | 110 | if (kindOf === "look" || kindOf === "review") |
| 106 | 111 | actions.push({ label: "Approve", tool: "asks_act", args: { action: "ship", items } }); |
| 107 | 112 | actions.push({ label: "Decline", tool: "asks_act", args: { action: "close", items } }); |
| 113 | const repo = text(first["repo"]); | |
| 114 | const number = Number(first["number"]); | |
| 115 | if (repo !== "" && Number.isInteger(number) && number > 0) | |
| 116 | actions.push({ label: "Redirect", tool: "say", args: { repo, number }, words: true }); | |
| 108 | 117 | } |
| 109 | 118 | } else { |
| 110 | 119 | const limbs = list(obj(value)["limbs"]).map(obj); |
| @@ -125,7 +134,7 @@ export function cardFor(tool: string, value: unknown): Card | undefined { | ||
| 125 | 134 | * structured content and draws it with `textContent`. A button asks the |
| 126 | 135 | * host to call the tool; the host decides whether to. |
| 127 | 136 | */ |
| 128 | const SHELL_JS = ` | |
| 137 | export const SHELL_JS = ` | |
| 129 | 138 | const root = document.getElementById("card"); |
| 130 | 139 | const el = (tag, text, cls) => { const e = document.createElement(tag); e.textContent = text; if (cls) e.className = cls; return e; }; |
| 131 | 140 | let n = 0; |
| @@ -134,9 +143,20 @@ function draw(card) { | ||
| 134 | 143 | const dl = document.createElement("dl"); |
| 135 | 144 | for (const r of card.rows) dl.append(el("dt", r.label), el("dd", r.value)); |
| 136 | 145 | root.append(dl); |
| 146 | const field = card.actions.some((a) => a.words) ? document.createElement("input") : null; | |
| 147 | if (field) { field.type = "text"; field.maxLength = 2000; field.placeholder = "Your words"; field.setAttribute("aria-label", "Your words"); root.append(field); } | |
| 137 | 148 | for (const a of card.actions) { |
| 138 | 149 | const b = el("button", a.label); |
| 139 | b.onclick = () => parent.postMessage({ jsonrpc: "2.0", id: ++n, method: "tools/call", params: { name: a.tool, arguments: a.args } }, "*"); | |
| 150 | b.onclick = () => { | |
| 151 | let args = a.args; | |
| 152 | if (a.words) { | |
| 153 | const said = field.value.trim(); | |
| 154 | if (said === "") { field.focus(); return; } | |
| 155 | args = { ...a.args, text: said }; | |
| 156 | if (a.args.context && typeof a.args.context === "object") args.context = { ...a.args.context, said }; | |
| 157 | } | |
| 158 | parent.postMessage({ jsonrpc: "2.0", id: ++n, method: "tools/call", params: { name: a.tool, arguments: args } }, "*"); | |
| 159 | }; | |
| 140 | 160 | root.append(b); |
| 141 | 161 | } |
| 142 | 162 | } |
test/card-shell.test.ts added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { SHELL_JS, cardFor } from "../src/web/studio/cards.ts"; | |
| 3 | ||
| 4 | class Node { | |
| 5 | children: Node[] = []; | |
| 6 | textContent = ""; | |
| 7 | className = ""; | |
| 8 | value = ""; | |
| 9 | onclick: (() => void) | undefined; | |
| 10 | focused = false; | |
| 11 | constructor(readonly tag: string) {} | |
| 12 | append(...n: Node[]) { this.children.push(...n); } | |
| 13 | replaceChildren(...n: Node[]) { this.children = n; } | |
| 14 | setAttribute() {} | |
| 15 | focus() { this.focused = true; } | |
| 16 | all(): Node[] { return [this, ...this.children.flatMap((c) => c.all())]; } | |
| 17 | } | |
| 18 | ||
| 19 | function shell() { | |
| 20 | const root = new Node("div"); | |
| 21 | const posted: any[] = []; | |
| 22 | const parent = { postMessage: (m: unknown) => posted.push(m) }; | |
| 23 | let listener: (e: any) => void = () => {}; | |
| 24 | const document = { getElementById: () => root, createElement: (t: string) => new Node(t) }; | |
| 25 | new Function("document", "parent", "addEventListener", SHELL_JS)( | |
| 26 | document, parent, (_: string, f: (e: any) => void) => (listener = f), | |
| 27 | ); | |
| 28 | const send = (data: unknown, source: unknown = parent) => listener({ source, data }); | |
| 29 | const result = (card: unknown) => | |
| 30 | ({ method: "ui/notifications/tool-result", params: { structuredContent: { card } } }); | |
| 31 | return { root, posted, parent, send, result }; | |
| 32 | } | |
| 33 | ||
| 34 | describe("the card shell", () => { | |
| 35 | test("announces itself, draws with textContent, ignores other sources, posts tools/call", () => { | |
| 36 | const s = shell(); | |
| 37 | expect(s.posted[0].method).toBe("ui/notifications/initialized"); | |
| 38 | const card = { | |
| 39 | kind: "run", title: "<b>T</b>", status: "ok", | |
| 40 | rows: [{ label: "<img src=x onerror=1>", value: "v" }], | |
| 41 | actions: [{ label: "Stop", tool: "stop", args: { repo: "a/b", run: "r1" } }], | |
| 42 | }; | |
| 43 | s.send(s.result(card), {}); | |
| 44 | expect(s.root.children.length).toBe(0); | |
| 45 | s.send(s.result(card)); | |
| 46 | const nodes = s.root.all(); | |
| 47 | expect(nodes.find((n) => n.tag === "h2")!.textContent).toBe("<b>T</b>"); | |
| 48 | expect(nodes.find((n) => n.tag === "dt")!.textContent).toBe("<img src=x onerror=1>"); | |
| 49 | nodes.find((n) => n.tag === "button")!.onclick!(); | |
| 50 | expect(s.posted[1]).toMatchObject({ | |
| 51 | method: "tools/call", | |
| 52 | params: { name: "stop", arguments: { repo: "a/b", run: "r1" } }, | |
| 53 | }); | |
| 54 | }); | |
| 55 | ||
| 56 | test("Tell and Redirect send the typed words, and nothing when empty", () => { | |
| 57 | const s = shell(); | |
| 58 | const app = cardFor("apps", [{ app: "alice/game", live: "up" }])!; | |
| 59 | s.send(s.result(app)); | |
| 60 | const input = s.root.all().find((n) => n.tag === "input")!; | |
| 61 | const tell = s.root.all().find((n) => n.tag === "button")!; | |
| 62 | tell.onclick!(); | |
| 63 | expect(s.posted.length).toBe(1); | |
| 64 | expect(input.focused).toBe(true); | |
| 65 | input.value = " make it blue "; | |
| 66 | tell.onclick!(); | |
| 67 | expect(s.posted[1].params).toEqual({ | |
| 68 | name: "tell", | |
| 69 | arguments: { repo: "alice/game", context: { said: "make it blue" }, text: "make it blue" }, | |
| 70 | }); | |
| 71 | const ask = cardFor("asks", { | |
| 72 | rows: [{ line: "a", kind: "look", items: ["x:1"], repo: "alice/game", number: 4, words: {} }], | |
| 73 | })!; | |
| 74 | s.send(s.result(ask)); | |
| 75 | const redirect = s.root.all().find((n) => n.tag === "button" && n.textContent === "Redirect")!; | |
| 76 | s.root.all().find((n) => n.tag === "input")!.value = "not like that"; | |
| 77 | redirect.onclick!(); | |
| 78 | expect(s.posted[2].params).toEqual({ | |
| 79 | name: "say", | |
| 80 | arguments: { repo: "alice/game", number: 4, text: "not like that" }, | |
| 81 | }); | |
| 82 | }); | |
| 83 | ||
| 84 | test("an ask without a number offers no Redirect", () => { | |
| 85 | const card = cardFor("asks", { rows: [{ line: "a", kind: "paused", items: ["x"], repo: "a/b", words: {} }] })!; | |
| 86 | expect(card.actions.some((a) => a.tool === "say")).toBe(false); | |
| 87 | }); | |
| 88 | }); |
test/mcp.test.ts +3 −2
| @@ -591,7 +591,7 @@ describe("the forge over MCP", () => { | ||
| 591 | 591 | cardFor("now", [{ who: "scout", app: "alice/game", run: "r1", on: "#1", state: "going" }]), |
| 592 | 592 | cardFor("asks", { |
| 593 | 593 | rows: [ |
| 594 | { line: "a", kind: "look", items: ["x:1"], words: {} }, | |
| 594 | { line: "a", kind: "look", items: ["x:1"], repo: "alice/game", number: 3, words: {} }, | |
| 595 | 595 | { line: "b", kind: "review", items: ["x:2"], words: {} }, |
| 596 | 596 | ], |
| 597 | 597 | }), |
| @@ -602,7 +602,8 @@ describe("the forge over MCP", () => { | ||
| 602 | 602 | actions++; |
| 603 | 603 | expect(a.args["dryRun"]).not.toBe(false); |
| 604 | 604 | const t = tools.find((x: any) => x.name === a.tool); |
| 605 | for (const f of t.inputSchema.required ?? []) { | |
| 605 | // A words action gets `text` from the shell's field when pressed. | |
| 606 | for (const f of (t.inputSchema.required ?? []).filter((f: string) => !(a.words && f === "text"))) { | |
| 606 | 607 | expect(a.args[f]).toBeDefined(); |
| 607 | 608 | expect(a.args[f]).not.toBe(""); |
| 608 | 609 | } |
new above
offline