Public: anyone can read this; the crew can change it.
Improve the Forge MCP UI as it appears inside ChatGPT. #11959
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11956-improve-the-forge-mcp-ui-as-it-appears-i → main 4 commits fork 2 threads · 2 unresolved
8 files
changed +166
−27
docs/MCP.md +4 −1
| @@ -113,7 +113,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 113 | 113 | | `follow_mode` | — | How a fork follows its parent: take updates, tell me, or don't follow | 281 | |
| 114 | 114 | | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 | |
| 115 | 115 | | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 | |
| 116 | | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 189 | | |
| 116 | | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 239 | | |
| 117 | 117 | | `forges` | — | Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant | 387 | |
| 118 | 118 | | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 | |
| 119 | 119 | | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 397 | |
| @@ -533,6 +533,7 @@ answers | ||
| 533 | 533 | | `ui://forge/scorecard` | `scorecard` | The crew's scorecard, one row per agent: landed and red rate all time, then the week (forge admin). | |
| 534 | 534 | | `ui://forge/crew` | `now`, `crew` | `now`: what the crew is doing right now, with Stop buttons. `crew` (listing): one row per seat, who fills it and what runs and waits, or what it costs to leave it empty; linked to Settings › Crew. | |
| 535 | 535 | | `ui://forge/health` | `health` | Green or red with the version, then one row per cause: draining, a blocked rollout, degraded lanes, incidents, apps (an admin sees all; a stranger only the verdict). | |
| 536 | | `ui://forge/forge` | `forge_view` | The forge in one card: healthy or not and what is running, then the apps with work going, busiest first; quiet apps and what waits on you are counted. | | |
| 536 | 537 | | `ui://forge/capacity` | `capacity` (listing, admin) | One row per setting with its live value (set ones marked), linked to Settings › Capacity; says when the forge cannot swap itself. | |
| 537 | 538 | | `ui://forge/board` | `boards` | One board's panels (person admins). | |
| 538 | 539 | | `ui://forge/forks` | `fork_tree`, `forks` | The fork tree around a repository, each row with how it follows; `forks` a row per fork with its commits ahead and behind, `kind: chain` its chain (a severed chain says so); each linked to the repository, at most 30 and one More row. | |
| @@ -547,6 +548,8 @@ answers | ||
| 547 | 548 | | `ui://forge/reports` | `reports` (listing) | Waiting reports from outsiders: one row each, its state and who filed it, linked to the repository's reports. | |
| 548 | 549 | | `ui://forge/usage` | `usage` | The window's figures as rows: runs, cost and tokens, by agent for a repo, or today and last 24 hours with each account's headroom. | |
| 549 | 550 | |
| 551 | **Who controls what (#11956).** The host (ChatGPT, Claude) controls the frame around a card: its size limits, border, theme variables, whether it opens a `ui://` page at all, and the tool's name and description in its picker (so a generic line such as "Find issues, pulls…" is the tool's `description`, which is for the model and is held to the size ceiling; the card cannot change it). Forge controls what is inside the frame: `structuredContent.card` (`title`, `status`, `rows` of `label`, `value`, `link`, `depth`, `actions`) and the one page that draws it (`CARD_SHELL`). A host that does not render `ui://` pages shows only the text answer, so the text always carries the full data; the card is a summary of it. Not promised: fonts, per-row colour, truncation by the host, or any widget beyond title, status, rows, links and buttons. Not tested in the live ChatGPT connector from the build sandbox; the wording was checked against the card model only. | |
| 552 | **Card vocabulary.** *title*: what this is (`Pulls`, or `#7 the pull's title` for one thing, cut at 90 characters on a word with `…`; the whole title is in the text answer). *status*: the one line every row shares, counts first (`5 open · 2 red · 3 green`, `healthy · 3 running · 1 queued`). *row label*: the identity (`#7 title`, `owner/name`), linked when it has a page. *row value*: only what differs from the other rows, `·`-separated, state words in plain language (`check green`, `no check`, `approved`), never a state the status line already said. *actions*: buttons that call a tool. | |
| 550 | 553 | The Runs card (`runs`) is one row per run, newest first: glyph, kind, the issue or pull and its title, then the state as a word with how long it ran (or has been running), linked to the run page; failed runs lead the status line. The Plan card (`map`) shows one row per pillar with |
| 551 | 554 | its ready, working and waiting counts, and the first five ready issues linked |
| 552 | 555 | to their pages; it has no actions. The asks card (`ui://forge/asks`, drawn by `asks`) is one row per waiting decision: |
src/web/studio/cards.ts +64 −15
| @@ -13,7 +13,7 @@ import { timeAgo } from "../ui.ts"; | ||
| 13 | 13 | import { VERB_WORDS } from "../../actions/asks_words.ts"; |
| 14 | 14 | import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts"; |
| 15 | 15 | |
| 16 | export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release" | "releases" | "search" | "issues" | "forges" | "held" | "drill" | "reports" | "usage" | "health" | "capacity"; | |
| 16 | export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release" | "releases" | "search" | "issues" | "forges" | "held" | "drill" | "reports" | "usage" | "health" | "capacity" | "forge"; | |
| 17 | 17 | |
| 18 | 18 | export interface CardRow { |
| 19 | 19 | readonly label: string; |
| @@ -46,7 +46,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?! | ||
| 46 | 46 | |
| 47 | 47 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 48 | 48 | export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`; |
| 49 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "runs", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release", "releases", "search", "issues", "held", "forges", "drill", "reports", "usage", "health", "capacity"]; | |
| 49 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "runs", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release", "releases", "search", "issues", "held", "forges", "drill", "reports", "usage", "health", "capacity", "forge"]; | |
| 50 | 50 | |
| 51 | 51 | /** The tool whose answer each card draws. */ |
| 52 | 52 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| @@ -80,6 +80,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 80 | 80 | reports: "reports", |
| 81 | 81 | usage: "usage", |
| 82 | 82 | health: "health", |
| 83 | forge_view: "forge", | |
| 83 | 84 | capacity: "capacity", |
| 84 | 85 | crew: "crew", |
| 85 | 86 | }; |
| @@ -114,6 +115,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 114 | 115 | usage: "Spend", |
| 115 | 116 | health: "Forge health", |
| 116 | 117 | capacity: "Capacity", |
| 118 | forge: "The forge", | |
| 117 | 119 | }; |
| 118 | 120 | |
| 119 | 121 | type Obj = Record<string, unknown>; |
| @@ -123,6 +125,19 @@ const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); | ||
| 123 | 125 | const text = (v: unknown): string => |
| 124 | 126 | typeof v === "string" ? v : typeof v === "number" ? String(v) : ""; |
| 125 | 127 | const LIMIT = 12; |
| 128 | /** Longest title a row carries; the whole title stays in the tool's text. */ | |
| 129 | export const TITLE_CHARS = 90; | |
| 130 | /** One line: whitespace folded, cut at `max` on a word where it can, with an ellipsis. */ | |
| 131 | export function clip(v: unknown, max = TITLE_CHARS): string { | |
| 132 | const s = text(v).replace(/\s+/g, " ").trim(); | |
| 133 | if (s.length <= max) return s; | |
| 134 | const cut = s.slice(0, max - 1); | |
| 135 | const at = cut.lastIndexOf(" "); | |
| 136 | return `${(at > max / 2 ? cut.slice(0, at) : cut).trimEnd()}…`; | |
| 137 | } | |
| 138 | /** "3 red · 2 green": the counts of `words` that occur, in the order given, zero left out. */ | |
| 139 | const tally = (words: readonly string[], order: readonly string[]): string => | |
| 140 | order.map((w) => [w, words.filter((x) => x === w).length] as const).filter(([, n]) => n > 0).map(([w, n]) => `${n} ${w}`).join(" · "); | |
| 126 | 141 | const FEED_LIMIT = 20; |
| 127 | 142 | |
| 128 | 143 | /** A pull's review word: the pile's `verdict`, or the counts a repository's list carries. */ |
| @@ -486,6 +501,33 @@ export function cardFor( | ||
| 486 | 501 | const ver = text(v["version"]); |
| 487 | 502 | return { kind, title: CARD_TITLE[kind], status: `${v["ok"] ? "green" : "red"}${ver === "" ? "" : ` · ${ver}`}`, rows, actions }; |
| 488 | 503 | } |
| 504 | if (kind === "forge") { | |
| 505 | // `forge_view` answers `{ counts, apps: [{ app, running, queued }], runs, health }`: | |
| 506 | // the verdict first, | |
| 507 | // then the apps with work going, busiest first; the quiet ones are counted, not listed. | |
| 508 | const v = obj(value); | |
| 509 | const c = obj(v["counts"]); | |
| 510 | if (!("counts" in v)) return undefined; | |
| 511 | const link = web(origin) ? { link: `${origin}/` } : {}; | |
| 512 | const ok = obj(v["health"])["ok"]; | |
| 513 | const busy = list(v["apps"]).map(obj) | |
| 514 | .map((a) => ({ app: text(a["app"]), running: Number(a["running"]) || 0, queued: Number(a["queued"]) || 0 })) | |
| 515 | .filter((a) => a.running + a.queued > 0) | |
| 516 | .sort((a, b) => b.running + b.queued - (a.running + a.queued)); | |
| 517 | for (const a of busy.slice(0, LIMIT)) | |
| 518 | rows.push({ | |
| 519 | label: a.app, | |
| 520 | value: [a.running > 0 ? `${a.running} running` : "", a.queued > 0 ? `${a.queued} queued` : ""].filter((w) => w !== "").join(" · "), | |
| 521 | ...(web(origin) && slugOf(a.app) ? { link: `${origin}/${a.app}` } : {}), | |
| 522 | }); | |
| 523 | if (busy.length > LIMIT) rows.push({ label: "More", value: `${busy.length - LIMIT} more busy apps not shown`, ...link }); | |
| 524 | const apps = Number(c["apps"]) || 0; | |
| 525 | rows.push({ label: "Apps", value: `${apps} in all${busy.length === 0 ? " · none busy" : ""}`, ...link }); | |
| 526 | const waiting = Number(c["waiting"]) || 0; | |
| 527 | if (waiting > 0) rows.push({ label: "Waiting", value: `${waiting} waiting on you`, ...link }); | |
| 528 | const going = [Number(c["running"]) > 0 ? `${Number(c["running"])} running` : "", Number(c["queued"]) > 0 ? `${Number(c["queued"])} queued` : ""].filter((w) => w !== "").join(" · "); | |
| 529 | return { kind, title: CARD_TITLE[kind], status: `${ok === true ? "healthy" : ok === false ? "needs attention" : "health unknown"} · ${going === "" ? "crew idle" : going}`, rows, actions }; | |
| 530 | } | |
| 489 | 531 | if (kind === "capacity") { |
| 490 | 532 | // `listCapacity`: `{ settings: [{ label, value, source }], egress, selfSwap }`; a plan or a change has no `settings`. |
| 491 | 533 | const v = obj(value); |
| @@ -630,7 +672,7 @@ export function cardFor( | ||
| 630 | 672 | rows.push({ label: "Conflicts", value: conflicts.slice(0, LIMIT).join(", ") }); |
| 631 | 673 | const preview = text(extra["preview"]); |
| 632 | 674 | if (web(preview)) rows.push({ label: "Preview", value: preview, link: preview }); |
| 633 | return { kind, title: `#${number} ${text(p["title"])}`.trim(), status, rows, actions }; | |
| 675 | return { kind, title: `#${number} ${clip(p["title"])}`.trim(), status, rows, actions }; | |
| 634 | 676 | } |
| 635 | 677 | if (kind === "search") { |
| 636 | 678 | // `search` answers `{ repos, issues, code, … }`; the query comes in `extra`. One row per hit: |
| @@ -649,7 +691,7 @@ export function cardFor( | ||
| 649 | 691 | const ext = text(r["htmlUrl"]); |
| 650 | 692 | hits.push({ |
| 651 | 693 | label, |
| 652 | value: text(r["description"]) || `${text(r["openIssues"])} open issues`, | |
| 694 | value: clip(r["description"], SAYS_WORDS) || `${text(r["openIssues"])} open issues`, | |
| 653 | 695 | ...(local ? page(text(r["owner"]), text(r["name"])) : web(ext) ? { link: ext } : {}), |
| 654 | 696 | }); |
| 655 | 697 | } |
| @@ -660,7 +702,7 @@ export function cardFor( | ||
| 660 | 702 | const at = obj(i["repo"]); |
| 661 | 703 | const ext = text(i["htmlUrl"]); |
| 662 | 704 | hits.push({ |
| 663 | label: `${pull ? "pull" : "issue"} #${n} ${text(i["title"])}`.trim(), | |
| 705 | label: `${pull ? "pull" : "issue"} #${n} ${clip(i["title"])}`.trim(), | |
| 664 | 706 | value: [text(i["state"]), ...(local && untrusted.has(`${text(at["owner"])}/${text(at["name"])}#${n}`) ? ["untrusted"] : [])].join(" · "), |
| 665 | 707 | ...(local && /^[1-9]\d*$/.test(n) |
| 666 | 708 | ? page(text(at["owner"]), text(at["name"]), `/${pull ? "pulls" : "issues"}/${n}`) |
| @@ -689,10 +731,12 @@ export function cardFor( | ||
| 689 | 731 | const slug = text(extra["repo"]); |
| 690 | 732 | const pillars = obj(extra["pillars"]); |
| 691 | 733 | const waits = obj(extra["waits"]); |
| 734 | // A state every row shares is the status line's; a row keeps it only as the odd one out. | |
| 735 | const mixed = new Set(items.map((i) => text(i["state"]))).size > 1; | |
| 692 | 736 | for (const i of items.slice(0, ISSUES_ROWS)) { |
| 693 | 737 | const n = text(i["number"]); |
| 694 | 738 | const path = `/${slug}/issues/${n}`; |
| 695 | const parts = [text(i["state"])]; | |
| 739 | const parts = mixed ? [text(i["state"])] : []; | |
| 696 | 740 | const pillar = text(pillars[n]); |
| 697 | 741 | if (pillar !== "") parts.push(pillar); |
| 698 | 742 | // A blocker here is its number; one in another repository is `owner/name#number`. |
| @@ -700,13 +744,16 @@ export function cardFor( | ||
| 700 | 744 | if (on.length > 0) parts.push(`waits on ${on.map((x) => (x.includes("#") ? x : `#${x}`)).join(" ")}`); |
| 701 | 745 | if (i["untrusted"] === true) parts.push("untrusted"); |
| 702 | 746 | rows.push({ |
| 703 | label: `#${n} ${text(i["title"])}`.trim(), | |
| 747 | label: `#${n} ${clip(i["title"])}`.trim(), | |
| 704 | 748 | value: parts.join(" · "), |
| 705 | 749 | ...(web(origin) && slugOf(slug) && /^[1-9]\d*$/.test(n) && here(origin, path) ? { link: origin + path } : {}), |
| 706 | 750 | }); |
| 707 | 751 | } |
| 708 | 752 | const more = text(obj(value)["next_cursor"]) !== "" ? "+" : ""; |
| 709 | status = `${items.length}${more} issue${items.length === 1 && more === "" ? "" : "s"}`; | |
| 753 | const one = text(items[0]?.["state"]); | |
| 754 | status = mixed | |
| 755 | ? `${items.length}${more} issues · ${tally(items.map((i) => text(i["state"])), ["open", "closed"])}` | |
| 756 | : `${items.length}${more} ${one === "" ? "" : `${one} `}issue${items.length === 1 && more === "" ? "" : "s"}`; | |
| 710 | 757 | return { kind, title: CARD_TITLE[kind], status, rows, actions }; |
| 711 | 758 | } |
| 712 | 759 | if (kind === "pulls") { |
| @@ -715,8 +762,10 @@ export function cardFor( | ||
| 715 | 762 | const pulls = list(obj(value)["pulls"]).map(obj); |
| 716 | 763 | // The tool stops at `limit`: a full answer may be the first of more. |
| 717 | 764 | const cut = typeof extra["limit"] === "number" && pulls.length >= extra["limit"]; |
| 718 | status = `${pulls.length}${cut ? "+" : ""} ${extra["state"] === "closed" ? "closed" : "open"}`; | |
| 719 | 765 | const state = extra["state"] === "closed" ? "closed" : "open"; |
| 766 | // The state is the status line's, said once; a row says only what differs between rows. | |
| 767 | const checks = pulls.map((p) => checkWord(text(p["check"]))); | |
| 768 | status = [`${pulls.length}${cut ? "+" : ""} ${state}`, state === "open" ? tally(checks, ["red", "running", "green"]) : ""].filter((w) => w !== "").join(" · "); | |
| 720 | 769 | for (const p of pulls.slice(0, PULLS_LIMIT)) { |
| 721 | 770 | const number = text(p["number"]); |
| 722 | 771 | const check = text(p["check"]); |
| @@ -725,8 +774,8 @@ export function cardFor( | ||
| 725 | 774 | const verdict = text(p["verdict"]); |
| 726 | 775 | const review = reviewWord(verdict, Number(p["approvals"]), Number(p["changesRequested"])); |
| 727 | 776 | rows.push({ |
| 728 | label: `#${number} ${text(p["title"])}`.trim(), | |
| 729 | value: `${state} · ${checkWord(check)} · ${review}`, | |
| 777 | label: `#${number} ${clip(p["title"])}`.trim(), | |
| 778 | value: `${checkWord(check) === "none" ? "no check" : `check ${checkWord(check)}`} · ${review}`, | |
| 730 | 779 | ...(web(origin) && slugOf(slug) && /^[1-9]\d*$/.test(number) && here(origin, path) |
| 731 | 780 | ? { link: origin + path } |
| 732 | 781 | : {}), |
| @@ -758,7 +807,7 @@ export function cardFor( | ||
| 758 | 807 | : ` · ${span(ended - started)}`; |
| 759 | 808 | const path = `/${slug}/runs/${text(r["id"])}`; |
| 760 | 809 | rows.push({ |
| 761 | label: `${RUN_GLYPH[state] ?? "·"} ${text(r["kind"])}${number === "" ? "" : ` #${number}`}${title === "" ? "" : ` ${title}`}`, | |
| 810 | label: `${RUN_GLYPH[state] ?? "·"} ${text(r["kind"])}${number === "" ? "" : ` #${number}`}${title === "" ? "" : ` ${clip(title)}`}`, | |
| 762 | 811 | value: `${state}${length}`, |
| 763 | 812 | ...(web(origin) && slugOf(slug) && RUN_ID.test(text(r["id"])) && here(origin, path) |
| 764 | 813 | ? { link: origin + path } |
| @@ -848,7 +897,7 @@ export function cardFor( | ||
| 848 | 897 | const base = web(origin) && /^[^/\s]+\/[^/\s]+$/.test(slug) ? `${origin}/${slug}/issues/` : ""; |
| 849 | 898 | const at = (n: string): { link?: string } => (base !== "" && /^\d+$/.test(n) ? { link: base + n } : {}); |
| 850 | 899 | status = text(v["state"]) + (v["closeReason"] ? ` · ${text(v["closeReason"])}` : ""); |
| 851 | rows.push({ label: `#${number}`, value: text(v["title"]), ...at(number) }); | |
| 900 | rows.push({ label: "On the forge", value: `${slug} #${number}`.trim(), ...at(number) }); | |
| 852 | 901 | // The title is an outsider's when the answer says so; the card says so too. |
| 853 | 902 | if (v["untrusted"] === true) rows.push({ label: "Author", value: "untrusted: not a writer on this repository" }); |
| 854 | 903 | rows.push({ label: "Assignee", value: text(v["assignee"]) || "nobody" }); |
| @@ -856,7 +905,7 @@ export function cardFor( | ||
| 856 | 905 | if (labels.length > 0) rows.push({ label: "Labels", value: labels.join(", ") }); |
| 857 | 906 | for (const b of list(v["blockedBy"]).map(obj).slice(0, LIMIT)) |
| 858 | 907 | rows.push({ label: "Waits for", value: `#${text(b["number"])} · ${text(b["state"])}`, ...at(text(b["number"])) }); |
| 859 | return { kind, title: `Issue #${number}`, status, rows, actions }; | |
| 908 | return { kind, title: `#${number} ${clip(v["title"])}`.trim(), status, rows, actions }; | |
| 860 | 909 | } |
| 861 | 910 | if (kind === "app") { |
| 862 | 911 | const apps = list(value).map(obj); |
| @@ -1145,7 +1194,7 @@ report(); | ||
| 1145 | 1194 | `; |
| 1146 | 1195 | |
| 1147 | 1196 | const pal = (t: "light" | "dark"): string => `--c-bg:${CARD_PALETTE[t].bg};--c-fg:${CARD_PALETTE[t].fg};--c-link:${CARD_PALETTE[t].link};color-scheme:${t}`; |
| 1148 | export const CARD_CSS = `:root{${pal("light")}}@media(prefers-color-scheme:dark){:root{${pal("dark")}}}:root[data-theme=light]{${pal("light")}}:root[data-theme=dark]{${pal("dark")}}body{font:14px/1.4 var(--font-sans,system-ui,sans-serif);margin:0;padding:12px;background:var(--c-bg);color:var(--c-fg)}h2{margin:0 0 4px;font-size:16px}.status{margin:0 0 8px}dl{margin:0}dt{font-weight:600;margin-top:6px}dd{margin:0}a{color:var(--c-link);text-underline-offset:2px;display:inline-flex;align-items:center;min-height:44px}button,input{font:inherit;color:var(--c-fg);border:0;background:color-mix(in srgb,var(--c-fg) 12%,var(--c-bg));border-radius:var(--border-radius-sm,6px);min-height:44px;box-sizing:border-box}button{margin:8px 6px 0 0;padding:4px 12px}input{padding:4px 8px;width:100%;margin-top:8px}`; | |
| 1197 | export const CARD_CSS = `:root{${pal("light")}}@media(prefers-color-scheme:dark){:root{${pal("dark")}}}:root[data-theme=light]{${pal("light")}}:root[data-theme=dark]{${pal("dark")}}body{font:14px/1.4 var(--font-sans,system-ui,sans-serif);margin:0;padding:12px;background:var(--c-bg);color:var(--c-fg)}h2{margin:0 0 4px;font-size:16px}.status{margin:0 0 8px}dl{margin:0}h2,dt,dd{overflow-wrap:anywhere}.status{opacity:.85}dt{font-weight:600;margin-top:10px;padding-top:8px;border-top:1px solid color-mix(in srgb,var(--c-fg) 15%,var(--c-bg))}dt:first-child{border-top:0;padding-top:0}dd{margin:0;opacity:.85}a{color:var(--c-link);text-underline-offset:2px;display:inline-flex;align-items:center;min-height:44px}button,input{font:inherit;color:var(--c-fg);border:0;background:color-mix(in srgb,var(--c-fg) 12%,var(--c-bg));border-radius:var(--border-radius-sm,6px);min-height:44px;box-sizing:border-box}button{margin:8px 6px 0 0;padding:4px 12px}input{padding:4px 8px;width:100%;margin-top:8px}`; | |
| 1149 | 1198 | |
| 1150 | 1199 | export const CARD_SHELL = (kind: CardKind): string => |
| 1151 | 1200 | html`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><meta name="color-scheme" content="light dark"><title>${CARD_TITLE[kind]}</title><style>${raw(CARD_CSS)}</style><div id="card"><h2>${CARD_TITLE[kind]}</h2></div><script>${raw(SHELL_JS)}</script>`.text; |
test/mcp-card-vocabulary.test.ts added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | /** | |
| 2 | * The compact card vocabulary (#11956): status line says what the rows share, a row says what | |
| 3 | * differs, a long title is cut with an ellipsis, and `forge_view` has a card. | |
| 4 | */ | |
| 5 | import { expect, test } from "bun:test"; | |
| 6 | import { cardFor, clip, TITLE_CHARS, CARD_OF_TOOL } from "../src/web/studio/cards.ts"; | |
| 7 | ||
| 8 | const O = "https://forge.test"; | |
| 9 | const LONG = "word ".repeat(60).trim(); | |
| 10 | const EVIL = `<img src=x onerror=alert(1)>`; | |
| 11 | ||
| 12 | test("clip keeps short titles, folds whitespace, and cuts long ones on a word with an ellipsis", () => { | |
| 13 | expect(clip(" a \n b ")).toBe("a b"); | |
| 14 | expect(clip(undefined)).toBe(""); | |
| 15 | const c = clip(LONG); | |
| 16 | expect(c.length).toBeLessThanOrEqual(TITLE_CHARS); | |
| 17 | expect(c.endsWith("…")).toBe(true); | |
| 18 | expect(c).not.toMatch(/\s…$/); | |
| 19 | expect(clip("x".repeat(300)).length).toBe(TITLE_CHARS); | |
| 20 | }); | |
| 21 | ||
| 22 | test("pulls: the status line counts checks once; rows do not repeat the state", () => { | |
| 23 | const pulls = [ | |
| 24 | { number: 1, title: LONG, check: "failed" }, | |
| 25 | { number: 2, title: "b", check: "ok", verdict: "approved" }, | |
| 26 | { number: 3, title: "c", check: "ok" }, | |
| 27 | ]; | |
| 28 | const c = cardFor("pulls", { pulls }, O, { repo: "a/b", state: "open" })!; | |
| 29 | expect(c.status).toBe("3 open · 1 red · 2 green"); | |
| 30 | expect(c.rows[0]!.label.length).toBeLessThanOrEqual(TITLE_CHARS + 3); | |
| 31 | expect(c.rows.every((r) => !r.value.startsWith("open"))).toBe(true); | |
| 32 | expect(c.rows[1]!.value).toBe("check green · approved"); | |
| 33 | }); | |
| 34 | ||
| 35 | test("issues: a shared state moves to the status line; a mixed list keeps it per row", () => { | |
| 36 | const same = cardFor("issues", { items: [{ number: 1, title: "a", state: "open" }, { number: 2, title: "b", state: "open" }] }, O, { repo: "a/b" })!; | |
| 37 | expect(same.status).toBe("2 open issues"); | |
| 38 | expect(same.rows.map((r) => r.value)).toEqual(["", ""]); | |
| 39 | const mixed = cardFor("issues", { items: [{ number: 1, title: "a", state: "open" }, { number: 2, title: "b", state: "closed" }] }, O, { repo: "a/b" })!; | |
| 40 | expect(mixed.status).toBe("2 issues · 1 open · 1 closed"); | |
| 41 | expect(mixed.rows.map((r) => r.value)).toEqual(["open", "closed"]); | |
| 42 | const none = cardFor("issues", { items: [] }, O, { repo: "a/b" })!; | |
| 43 | expect(none.status).toBe("0 issues"); | |
| 44 | }); | |
| 45 | ||
| 46 | test("issue: the title is the card's title, clipped, and escaping is left to the shell's textContent", () => { | |
| 47 | const c = cardFor("issue", { number: 7, title: LONG, state: "open", labels: [] }, O, { repo: "a/b" })!; | |
| 48 | expect(c.title.startsWith("#7 ")).toBe(true); | |
| 49 | expect(c.title.endsWith("…")).toBe(true); | |
| 50 | expect(c.rows[0]).toEqual({ label: "On the forge", value: "a/b #7", link: `${O}/a/b/issues/7` }); | |
| 51 | expect(cardFor("issue", { number: 8, title: EVIL, state: "open" }, O, { repo: "a/b" })!.title).toBe(`#8 ${EVIL}`); | |
| 52 | }); | |
| 53 | ||
| 54 | test("pull and runs titles are clipped too; a missing title leaves no dangling space", () => { | |
| 55 | const p = cardFor("pull", { number: 3, title: LONG, state: "open", head: "x", base: "main" }, O, { repo: "a/b" })!; | |
| 56 | expect(p.title.length).toBeLessThanOrEqual(TITLE_CHARS + 3); | |
| 57 | expect(cardFor("pull", { number: 3, state: "open" }, O, { repo: "a/b" })!.title).toBe("#3"); | |
| 58 | const r = cardFor("runs", { runs: [{ id: "x", kind: "build", state: "ok", number: 2, title: LONG }] }, O, { repo: "a/b" })!; | |
| 59 | expect(r.rows[0]!.label.endsWith("…")).toBe(true); | |
| 60 | }); | |
| 61 | ||
| 62 | test("forge_view has a card: verdict and load in the status, busy apps first, quiet ones counted", () => { | |
| 63 | expect(CARD_OF_TOOL["forge_view"]).toBe("forge"); | |
| 64 | const view = { | |
| 65 | counts: { apps: 5, running: 3, queued: 1, waiting: 2 }, | |
| 66 | apps: [ | |
| 67 | { app: "a/quiet", running: 0, queued: 0 }, | |
| 68 | { app: "a/one", running: 1, queued: 0 }, | |
| 69 | { app: "a/two", running: 2, queued: 1 }, | |
| 70 | ], | |
| 71 | runs: [], | |
| 72 | health: { ok: true }, | |
| 73 | }; | |
| 74 | const c = cardFor("forge_view", view, O)!; | |
| 75 | expect(c.kind).toBe("forge"); | |
| 76 | expect(c.status).toBe("healthy · 3 running · 1 queued"); | |
| 77 | expect(c.rows.map((r) => r.label)).toEqual(["a/two", "a/one", "Apps", "Waiting"]); | |
| 78 | expect(c.rows[0]).toEqual({ label: "a/two", value: "2 running · 1 queued", link: `${O}/a/two` }); | |
| 79 | expect(c.rows[2]!.value).toBe("5 in all"); | |
| 80 | expect(c.rows[3]!.value).toBe("2 waiting on you"); | |
| 81 | const idle = cardFor("forge_view", { counts: { apps: 0, running: 0, queued: 0, waiting: 0 }, apps: [], runs: [], health: { ok: false } }, "")!; | |
| 82 | expect(idle.status).toBe("needs attention · crew idle"); | |
| 83 | expect(idle.rows).toEqual([{ label: "Apps", value: "0 in all · none busy" }]); | |
| 84 | expect(cardFor("forge_view", { nope: 1 }, O)).toBeUndefined(); | |
| 85 | }); |
test/mcp-cards.test.ts +3 −3
| @@ -105,7 +105,7 @@ describe("explore, pulls and feed cards", () => { | ||
| 105 | 105 | expect(c.kind).toBe("pulls"); |
| 106 | 106 | const row = c.rows.find((r: any) => r.label.includes("pull of openapp")); |
| 107 | 107 | expect(row).toBeDefined(); |
| 108 | expect(row.value).toBe("open · none · no review"); | |
| 108 | expect(row.value).toBe("no check · no review"); | |
| 109 | 109 | expect(row.link).toBe(`${servers.url}/alice/openapp/pulls/1`); |
| 110 | 110 | expect(JSON.stringify(c)).not.toContain("hiddenapp"); |
| 111 | 111 | expect(c.rows).toHaveLength(1); |
| @@ -113,8 +113,8 @@ describe("explore, pulls and feed cards", () => { | ||
| 113 | 113 | |
| 114 | 114 | test("a pull row says state, check and review, linked to its pull", () => { |
| 115 | 115 | const c = cardFor("pulls", { pulls: [{ repo: "alice/game", number: 3, title: EVIL, check: "ok", verdict: "approved" }, { repo: "alice/game", number: 4, title: "t", check: "failed", verdict: "changes_requested" }] }, "https://forge.test", {})!; |
| 116 | expect(c.rows[0]).toEqual({ label: `#3 ${EVIL}`, value: "open · green · approved", link: "https://forge.test/alice/game/pulls/3" }); | |
| 117 | expect(c.rows[1]!.value).toBe("open · red · changes requested"); | |
| 116 | expect(c.rows[0]).toEqual({ label: `#3 ${EVIL}`, value: "check green · approved", link: "https://forge.test/alice/game/pulls/3" }); | |
| 117 | expect(c.rows[1]!.value).toBe("check red · changes requested"); | |
| 118 | 118 | }); |
| 119 | 119 | |
| 120 | 120 | test("each card stops at 20 rows", () => { |
test/mcp-pulls-card.test.ts +5 −5
| @@ -85,8 +85,8 @@ describe("MCP pulls card", () => { | ||
| 85 | 85 | expect(card.status).toBe("2 open"); |
| 86 | 86 | const [two, one] = [numbers[1]!, numbers[0]!]; |
| 87 | 87 | expect(card.rows).toEqual([ |
| 88 | { label: `#${two} ${EVIL}`, value: "open · none · no review", link: `${servers.url}/alice/game/pulls/${two}` }, | |
| 89 | { label: `#${one} First`, value: "open · none · no review", link: `${servers.url}/alice/game/pulls/${one}` }, | |
| 88 | { label: `#${two} ${EVIL}`, value: "no check · no review", link: `${servers.url}/alice/game/pulls/${two}` }, | |
| 89 | { label: `#${one} First`, value: "no check · no review", link: `${servers.url}/alice/game/pulls/${one}` }, | |
| 90 | 90 | ]); |
| 91 | 91 | const text = JSON.parse(body.result.content[0].text); |
| 92 | 92 | expect(text.pulls.map((p: any) => p.number)).toEqual([two, one]); |
| @@ -107,14 +107,14 @@ describe("MCP pulls card", () => { | ||
| 107 | 107 | number: i + 1, title: `P${i + 1}`, check, |
| 108 | 108 | })); |
| 109 | 109 | const card = cardFor("pulls", { pulls }, "https://forge.test", { repo: "alice/game", state: "open" })!; |
| 110 | expect(card.rows.map((r) => r.value)).toEqual(["green", "red", "running", "running", "none", "cancelled"].map((w) => `open · ${w} · no review`)); | |
| 110 | expect(card.rows.map((r) => r.value)).toEqual(["green", "red", "running", "running", "none", "cancelled"].map((w) => w === "none" ? "no check · no review" : `check ${w} · no review`)); | |
| 111 | 111 | expect(card.rows[0]!.link).toBe("https://forge.test/alice/game/pulls/1"); |
| 112 | 112 | |
| 113 | 113 | const many = Array.from({ length: 30 }, (_, i) => ({ number: i + 1, title: "t", check: "ok" })); |
| 114 | 114 | const big = cardFor("pulls", { pulls: many }, "https://forge.test", { repo: "alice/game" })!; |
| 115 | expect(big.status).toBe("30 open"); | |
| 115 | expect(big.status).toBe("30 open · 30 green"); | |
| 116 | 116 | const capped = cardFor("pulls", { pulls: many.slice(0, 20) }, "https://forge.test", { repo: "alice/game", limit: 20 })!; |
| 117 | expect(capped.status).toBe("20+ open"); | |
| 117 | expect(capped.status).toBe("20+ open · 20 green"); | |
| 118 | 118 | expect(big.rows.length).toBe(20); |
| 119 | 119 | |
| 120 | 120 | // No origin, a repo that is not `owner/name`, or a number that is not one: no link. |
test/mcp-search-issues-card.test.ts +1 −1
| @@ -44,6 +44,6 @@ describe("issues card", () => { | ||
| 44 | 44 | }, origin, { repo: "a/b", pillars: { "5": "P1" }, waits: { "5": [2, 3] } })!; |
| 45 | 45 | expect(card.rows[0]).toEqual({ label: "#5 t", value: "open · P1 · waits on #2 #3 · untrusted", link: "https://forge.test/a/b/issues/5" }); |
| 46 | 46 | expect(card.rows[1]!.value).toBe("closed"); |
| 47 | expect(card.status).toBe("2+ issues"); | |
| 47 | expect(card.status).toBe("2+ issues · 1 open · 1 closed"); | |
| 48 | 48 | }); |
| 49 | 49 | }); |
test/mcp.test.ts +3 −1
| @@ -1299,6 +1299,7 @@ describe("the forge over MCP", () => { | ||
| 1299 | 1299 | "ui://forge/explore", |
| 1300 | 1300 | "ui://forge/feed", |
| 1301 | 1301 | "ui://forge/flow", |
| 1302 | "ui://forge/forge", | |
Indentation: align with the neighbouring entries. |
||
| 1302 | 1303 | "ui://forge/forges", |
| 1303 | 1304 | "ui://forge/forks", |
| 1304 | 1305 | "ui://forge/health", |
| @@ -1436,7 +1437,8 @@ describe("the forge over MCP", () => { | ||
| 1436 | 1437 | const card = cardFor("issue", answer, "https://forge.test", { repo: "alice/game" })!; |
| 1437 | 1438 | expect(card.kind).toBe("issue"); |
| 1438 | 1439 | expect(card.status).toBe("open"); |
| 1439 | expect(card.rows[0]).toEqual({ label: "#7", value: "<b>Fix</b>", link: "https://forge.test/alice/game/issues/7" }); | |
| 1440 | expect(card.title).toBe("#7 <b>Fix</b>"); | |
| 1441 | expect(card.rows[0]).toEqual({ label: "On the forge", value: "alice/game #7", link: "https://forge.test/alice/game/issues/7" }); | |
| 1440 | 1442 | expect(card.rows.find((r) => r.label === "Assignee")!.value).toBe("scout"); |
| 1441 | 1443 | expect(card.rows.find((r) => r.label === "Labels")!.value).toBe("bug"); |
| 1442 | 1444 | expect(card.rows.find((r) => r.label === "Waits for")!.link).toBe("https://forge.test/alice/game/issues/3"); |
test/xss-everywhere.test.ts +1 −1
| @@ -208,7 +208,7 @@ describe("one hostile string, every surface", () => { | ||
| 208 | 208 | expect(card.rows.map((r: { label: string }) => r.label).join("\n")).toContain(EVIL); |
| 209 | 209 | expect(JSON.stringify(card)).not.toContain("<"); |
| 210 | 210 | const issue = JSON.parse(await mcp("issue", { repo: "alice/game", number: issueNo })).result; |
| 211 | expect(issue.structuredContent.card.rows[0].value).toBe(EVIL); | |
| 211 | expect(issue.structuredContent.card.title).toContain(EVIL); | |
| 212 | 212 | expect(JSON.stringify(issue.structuredContent.card)).not.toContain("<"); |
| 213 | 213 | expect(JSON.parse(issue.content[0].text).title).toBe(EVIL); |
| 214 | 214 | const shell = CARD_SHELL("feed"); |
new above
offline
Principle 6: a border-top on every dt boxes each row; use the existing margin for rhythm (principle 3). Replace opacity with a muted colour token.
.statusis also declared twice.