Public: anyone can read this; the crew can change it.
The `map` tool's answer draws a ui://forge/map card: ready now, being worked on, waiting, by pillar #4178
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3343-the-map-tool-s-answer-draws-a-ui-forge-m → main 11 commits fork 5 threads · 5 unresolved
docs/MCP.md +5 −3
| @@ -61,7 +61,7 @@ for a tunnel and a forge that sleeps. | ||
| 61 | 61 | | `feed` | `{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed` | | |
| 62 | 62 | | `make_it_yours` | `{ repo, name?, visibility?, dryRun? }`: a fork of an app you can see, as your own app, following the original; code and history copied, data and secrets not; private unless `visibility` is public (a public original only); at most 5 copies an account a day; plans unless `dryRun` is false | yes | |
| 63 | 63 | | `front_page` | `{ pinned?, hidden?, weights?, dryRun? }`: the front page as a stranger sees it, ranked, with numbers; a site admin sets what is pinned (first public one featured), hidden, and what counts (weights of forks, ships, use, fresh, 0 to 10) | yes | |
| 64 | | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`) | | | |
| 64 | | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`); draws the `ui://forge/map` card | | | |
| 65 | 65 | | `milestones` | `{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, and for an owner `spend`: dollars, changes landed ever, the costliest three issues) | | |
| 66 | 66 | | `limb` | `{ app, issue, action, reason?, pillar?, operation?, dryRun? }`: defer, resume or close a whole limb as not doing, or undo an operation; plans unless `dryRun` is false, and applying takes an admin token | yes | |
| 67 | 67 | | `watch` | `{ app, issue, watch? }`: watch a milestone, or stop with `watch: false`: one item in your feed that follows it, and one push to your phone when it lands | yes | |
| @@ -178,9 +178,11 @@ answers | ||
| 178 | 178 | |
| 179 | 179 | ## Cards, the contract and the plugins (#1116) |
| 180 | 180 | |
| 181 | - **Cards.** `apps`, `new_app`, `now`, `asks`, `milestones` and `feed` name a `ui://` resource in | |
| 181 | - **Cards.** `apps`, `new_app`, `now`, `asks`, `milestones`, `map` and `feed` name a `ui://` resource in | |
| 182 | 182 | `_meta.ui.resourceUri` (MCP Apps, SEP-1865): `ui://forge/app`, `run`, |
| 183 | `decision`, `wave`, `feed`. `resources/list` and `resources/read` serve one small | |
| 183 | `decision`, `wave`, `map`, `feed`. The Plan card (`map`) shows one row per pillar with | |
| 184 | its ready, working and waiting counts, and the first five ready issues linked | |
| 185 | to their pages; it has no actions. `resources/list` and `resources/read` serve one small | |
| 184 | 186 | sandboxed page, which draws the `structuredContent.card` model that the |
| 185 | 187 | tool result carries, with `textContent` only. The model is built in |
| 186 | 188 | `src/web/studio/cards.ts`. A card's |
src/http/mcp.ts +1 −1
| @@ -2494,7 +2494,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2494 | 2494 | { |
| 2495 | 2495 | name: "map", |
| 2496 | 2496 | description: |
| 2497 | "An app's plan: open issues as ready or waiting, the critical path, every edge, where the crew is, and `parkedTwice` issues.", | |
| 2497 | "An app's plan: open issues ready or waiting, critical path, every edge, where the crew is, `parkedTwice` issues.", | |
| 2498 | 2498 | inputSchema: { |
| 2499 | 2499 | type: "object", |
| 2500 | 2500 | properties: { app: APP_ARG }, |
src/web/studio/cards.ts +32 −3
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | import { html, raw } from "../html.ts"; |
| 11 | 11 | import { timeAgo } from "../ui.ts"; |
| 12 | 12 | |
| 13 | export type CardKind = "app" | "run" | "decision" | "wave" | "feed"; | |
| 13 | export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed"; | |
| 14 | 14 | |
| 15 | 15 | export interface CardRow { |
| 16 | 16 | readonly label: string; |
| @@ -36,7 +36,7 @@ export interface Card { | ||
| 36 | 36 | |
| 37 | 37 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 38 | 38 | export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`; |
| 39 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "feed"]; | |
| 39 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed"]; | |
| 40 | 40 | |
| 41 | 41 | /** The tool whose answer each card draws. */ |
| 42 | 42 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| @@ -44,6 +44,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 44 | 44 | now: "run", |
| 45 | 45 | asks: "decision", |
| 46 | 46 | milestones: "wave", |
| 47 | map: "map", | |
| 47 | 48 | feed: "feed", |
| 48 | 49 | new_app: "app", |
| 49 | 50 | }; |
| @@ -53,6 +54,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 53 | 54 | run: "Working now", |
| 54 | 55 | decision: "Waiting on you", |
| 55 | 56 | wave: "Wave", |
| 57 | map: "Plan", | |
| 56 | 58 | feed: "Feed", |
| 57 | 59 | }; |
| 58 | 60 | |
| @@ -136,6 +138,33 @@ export function cardFor(tool: string, value: unknown, origin = ""): Card | undef | ||
| 136 | 138 | if (repo !== "" && Number.isInteger(number) && number > 0) |
| 137 | 139 | actions.push({ label: "Redirect", tool: "say", args: { repo, number }, words: true }); |
| 138 | 140 | } |
| 141 | } else if (kind === "map") { | |
| 142 | const v = obj(value); | |
| 143 | const issues = list(v["issues"]).map(obj); | |
| 144 | const pillars = list(v["pillars"]).map(obj); | |
| 145 | // The plan's umbrellas are furniture, not work: `pillarRows` skips them, so does this. | |
| 146 | const furniture = new Set(pillars.map((p) => text(p["umbrella"]))); | |
| 147 | const work = issues.filter((i) => i["state"] === "open" && !furniture.has(text(i["number"]))); | |
| 148 | const count = (word: string): number => work.filter((i) => i["word"] === word).length; | |
outdated
High: the status line counts readyNow (graph) and blockedOn, but the pillar rows below count by |
||
| 149 | status = `${count("ready")} ready · ${count("working")} working · ${count("blocked")} waiting`; | |
| 150 | for (const p of pillars.slice(0, LIMIT)) { | |
| 151 | const name = p["pillar"] === null ? "" : text(p["pillar"]); | |
| 152 | const working = work.filter((i) => i["word"] === "working" && (i["pillar"] ?? null) === (p["pillar"] ?? null)).length; | |
outdated
Count |
||
| 153 | rows.push({ | |
| 154 | label: name === "" ? "No pillar" : name, | |
| 155 | value: `${text(p["ready"])} ready · ${working} working · ${text(p["blocked"])} waiting`, | |
| 156 | }); | |
| 157 | } | |
| 158 | const repo = obj(v["repo"]); | |
| 159 | const base = `${origin}/${text(repo["owner"])}/${text(repo["name"])}/issues/`; | |
| 160 | for (const r of list(v["ready"]).map(obj).filter((r) => r["word"] === "ready").slice(0, 5)) { | |
outdated
Filter to |
||
| 161 | const number = text(r["number"]); | |
| 162 | rows.push({ | |
| 163 | label: `#${number}`, | |
| 164 | value: text(r["title"]), | |
| 165 | ...(/^https?:\/\//.test(origin) && number !== "" ? { link: base + number } : {}), | |
| 166 | }); | |
| 167 | } | |
| 139 | 168 | } else if (kind === "feed") { |
| 140 | 169 | const fresh = Number(obj(value)["new"]); |
| 141 | 170 | status = Number.isInteger(fresh) && fresh > 0 ? `${fresh} new since you last looked` : "Latest"; |
| @@ -177,7 +206,7 @@ function draw(card) { | ||
| 177 | 206 | const dl = document.createElement("dl"); |
| 178 | 207 | for (const r of card.rows) { |
| 179 | 208 | const dt = el("dt", r.label); |
| 180 | if (r.link && (r.link.startsWith("https://") || r.link.startsWith("http://"))) { const a = el("a", r.label); a.href = r.link; a.onclick = (ev) => { ev.preventDefault(); parent.postMessage({ jsonrpc: "2.0", id: ++n, method: "ui/open-link", params: { url: r.link } }, "*"); }; dt.replaceChildren(a); } | |
| 209 | if (r.link && (r.link.startsWith("https://") || r.link.startsWith("http://"))) { const a = el("a", r.label); a.href = r.link; a.target = "_blank"; a.rel = "noopener noreferrer"; a.onclick = (ev) => { ev.preventDefault(); parent.postMessage({ jsonrpc: "2.0", id: ++n, method: "ui/open-link", params: { url: r.link } }, "*"); }; dt.replaceChildren(a); } | |
| 181 | 210 | dl.append(dt, el("dd", r.value)); |
| 182 | 211 | } |
| 183 | 212 | root.append(dl); |
test/card-shell.test.ts +67 −1
| @@ -1,17 +1,20 @@ | ||
| 1 | 1 | import { describe, expect, test } from "bun:test"; |
| 2 | 2 | import { SHELL_JS, cardFor } from "../src/web/studio/cards.ts"; |
| 3 | import { NO_MARKS, mapJson } from "../src/web/pages/map.ts"; | |
| 3 | 4 | |
| 4 | 5 | class Node { |
| 5 | 6 | children: Node[] = []; |
| 6 | 7 | textContent = ""; |
| 7 | 8 | className = ""; |
| 8 | 9 | value = ""; |
| 9 | href = ""; | |
| 10 | 10 | onclick: ((ev?: any) => void) | undefined; |
| 11 | 11 | focused = false; |
| 12 | 12 | constructor(readonly tag: string) {} |
| 13 | 13 | append(...n: Node[]) { this.children.push(...n); } |
| 14 | 14 | replaceChildren(...n: Node[]) { this.children = n; } |
| 15 | href = ""; | |
| 16 | target = ""; | |
| 17 | rel = ""; | |
| 15 | 18 | setAttribute() {} |
| 16 | 19 | focus() { this.focused = true; } |
| 17 | 20 | all(): Node[] { return [this, ...this.children.flatMap((c) => c.all())]; } |
| @@ -87,6 +90,69 @@ describe("the card shell", () => { | ||
| 87 | 90 | expect(card.actions.some((a) => a.tool === "say")).toBe(false); |
| 88 | 91 | }); |
| 89 | 92 | |
| 93 | test("the map draws a Plan card from what mapJson returns: one count per issue, agreeing everywhere", () => { | |
| 94 | const mk = (number: number, state = "open") => | |
| 95 | ({ id: `i${number}`, number, title: `T<${number}>`, state, closeReason: null, assigneeName: null, createdAt: `2026-01-${String(number).padStart(2, "0")}` }) as never; | |
| 96 | // 1 umbrella (P0), 2-8 P0 work (3 is the crew's, 4 held), 9-10 P1 (10 waits on 9), 11 done. | |
| 97 | const issues = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((n) => mk(n, n === 11 ? "closed" : "open")); | |
| 98 | const graph = { | |
| 99 | issues, | |
| 100 | edges: [{ issueId: "i10", blockedById: "i9" }], | |
outdated
The fixture encodes the contradiction: #3 is in |
||
| 101 | held: [], | |
| 102 | unadopted: new Set(), | |
| 103 | structural: new Set(["i1"]), | |
| 104 | deferred: new Map(), | |
| 105 | } as never; | |
| 106 | const marks = { ...NO_MARKS, crew: new Map([["i3", {}]]), onHold: new Map([["i4", "a pull"]]) } as never; | |
| 107 | const of = new Map<string, string>([...[2, 3, 4, 5, 6, 7, 8].map((n) => [`i${n}`, "P0"] as const), ["i9", "P1"], ["i10", "P1"]]); | |
| 108 | const pillars = { | |
| 109 | set: { pillars: [{ name: "P0", umbrella: issues[0] }, { name: "P1", umbrella: mk(12) }], gate: undefined, later: undefined }, | |
| 110 | names: ["P0", "P1"], of, merged: [], | |
| 111 | } as never; | |
| 112 | const json = mapJson({ ownerName: "a", name: "b", crewOutsiders: false } as never, graph, marks, pillars); | |
| 113 | const card = cardFor("map", json, "https://f.example")!; | |
| 114 | expect(card.title).toBe("Plan"); | |
| 115 | expect(card.status).toBe("6 ready · 1 working · 1 waiting"); | |
| 116 | expect(card.rows.slice(0, 2)).toEqual([ | |
| 117 | { label: "P0", value: "5 ready · 1 working · 0 waiting" }, | |
| 118 | { label: "P1", value: "1 ready · 0 working · 1 waiting" }, | |
| 119 | ]); | |
| 120 | // The headline is the sum of the rows. | |
| 121 | const sum = (i: number) => card.rows.slice(0, 2).reduce((n, r) => n + Number(r.value.split(" ")[i === 0 ? 0 : i === 1 ? 3 : 6]), 0); | |
| 122 | expect(`${sum(0)} ready · ${sum(1)} working · ${sum(2)} waiting`).toBe(card.status); | |
| 123 | // Nothing the crew is on, or a person holds, is offered as ready; five at most. | |
| 124 | const links = card.rows.slice(2); | |
| 125 | expect(links.map((r) => r.label)).toEqual(["#9", "#2", "#5", "#6", "#7"]); | |
| 126 | expect(links[0]).toEqual({ label: "#9", value: "T<9>", link: "https://f.example/a/b/issues/9" }); | |
| 127 | expect(cardFor("map", json, "javascript:x")!.rows[2]!.link).toBeUndefined(); | |
| 128 | }); | |
| 129 | ||
| 130 | test("a row's https link draws an anchor that asks the host to open it; any other scheme stays text", () => { | |
| 131 | const s = shell(); | |
| 132 | s.send(s.result({ | |
| 133 | kind: "map", title: "Plan", status: "", actions: [], | |
| 134 | rows: [ | |
| 135 | { label: "#1", value: "ok", link: "https://f.example/a/b/issues/1" }, | |
| 136 | { label: "#2", value: "bad", link: "javascript:alert(1)" }, | |
| 137 | ], | |
| 138 | })); | |
| 139 | const dts = s.root.all().filter((n) => n.tag === "dt"); | |
| 140 | expect(dts.length).toBe(2); | |
| 141 | const a = dts[0]!.children[0]!; | |
| 142 | expect(a.tag).toBe("a"); | |
| 143 | expect(a.textContent).toBe("#1"); | |
| 144 | expect(a.href).toBe("https://f.example/a/b/issues/1"); | |
| 145 | expect(a.rel).toBe("noopener noreferrer"); | |
| 146 | expect(a.target).toBe("_blank"); | |
| 147 | let prevented = false; | |
| 148 | a.onclick!({ preventDefault: () => (prevented = true) }); | |
| 149 | expect(prevented).toBe(true); | |
| 150 | expect(s.posted[1]).toMatchObject({ method: "ui/open-link", params: { url: "https://f.example/a/b/issues/1" } }); | |
| 151 | expect(dts[1]!.children.length).toBe(0); | |
| 152 | expect(dts[1]!.textContent).toBe("#2"); | |
| 153 | expect(s.root.all().filter((n) => n.tag === "a").length).toBe(1); | |
| 154 | }); | |
| 155 | ||
| 90 | 156 | test("a feed row with a forge link is an anchor that asks the host to open it; no link, or a javascript: one, stays text", () => { |
| 91 | 157 | const s = shell(); |
| 92 | 158 | const feed = cardFor("feed", { items: [{ what: "x", link: "/a/b/pulls/1" }, { what: "y" }] }, "https://forge.test")!; |
test/mcp.test.ts +2
| @@ -1024,6 +1024,7 @@ describe("the forge over MCP", () => { | ||
| 1024 | 1024 | "ui://forge/app", |
| 1025 | 1025 | "ui://forge/decision", |
| 1026 | 1026 | "ui://forge/feed", |
| 1027 | "ui://forge/map", | |
| 1027 | 1028 | "ui://forge/run", |
| 1028 | 1029 | "ui://forge/wave", |
| 1029 | 1030 | ]); |
| @@ -1041,6 +1042,7 @@ describe("the forge over MCP", () => { | ||
| 1041 | 1042 | expect(meta("now")).toBe("ui://forge/run"); |
| 1042 | 1043 | expect(meta("asks")).toBe("ui://forge/decision"); |
| 1043 | 1044 | expect(meta("milestones")).toBe("ui://forge/wave"); |
| 1045 | expect(meta("map")).toBe("ui://forge/map"); | |
| 1044 | 1046 | expect(meta("feed")).toBe("ui://forge/feed"); |
| 1045 | 1047 | }); |
| 1046 | 1048 |
Low: the headline leaves out only the pillar umbrellas, but
pillarRowsalso leaves out the gate (isStructural) andrepo.directionIssueId. On a repo with a gate or a direction issue, the headline will be one or two higher than the sum of the rows. The same happens when there are more thanLIMITpillars. The fix is inmapJson: have it return afurniture: number[](or aworkflag on each brief), and count from that here. Then the card can't drift from the page.