Public: anyone can read this; the crew can change it.
The Apps card links each app to its page on the forge, and its production URL when it has one #5788
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5749-the-apps-card-links-each-app-to-its-page → main 3 commits fork 4 threads · 4 unresolved
docs/MCP.md +5 −1
| @@ -259,7 +259,11 @@ answers | ||
| 259 | 259 | `_meta.ui.resourceUri` (MCP Apps, SEP-1865): `ui://forge/app`, `run`, |
| 260 | 260 | `decision`, `wave`, `map`, `feed`, `pull`. The Plan card (`map`) shows one row per pillar with |
| 261 | 261 | its ready, working and waiting counts, and the first five ready issues linked |
| 262 | to their pages; it has no actions. `resources/list` and `resources/read` serve one small | |
| 262 | to their pages; it has no actions. | |
| 263 | The Apps card (`apps`) links each `owner/name` row to the app's page on the forge and adds a | |
| 264 | linked production row when the app has an http(s) URL; the `new_app` card links its | |
| 265 | Production row the same way. Any other address stays plain text, never a link. | |
| 266 | `resources/list` and `resources/read` serve one small | |
| 263 | 267 | sandboxed page, which draws the `structuredContent.card` model that the |
| 264 | 268 | tool result carries, with `textContent` only. The model is built in |
| 265 | 269 | `src/web/studio/cards.ts`. A card's |
src/web/studio/cards.ts +17 −4
| @@ -66,6 +66,7 @@ const obj = (v: unknown): Obj => | ||
| 66 | 66 | const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); |
| 67 | 67 | const text = (v: unknown): string => |
| 68 | 68 | typeof v === "string" ? v : typeof v === "number" ? String(v) : ""; |
| 69 | const web = (u: string): boolean => /^https?:\/\//.test(u); | |
| 69 | 70 | const LIMIT = 12; |
| 70 | 71 | const FEED_LIMIT = 10; |
| 71 | 72 | |
| @@ -87,7 +88,14 @@ export function cardFor( | ||
| 87 | 88 | const run = text(made["run"]); |
| 88 | 89 | status = run === "none" || run === "" ? "alive" : `alive · first run ${run}`; |
| 89 | 90 | rows.push({ label: "App", value: repo }); |
| 90 | if (made["url"]) rows.push({ label: "Production", value: text(made["url"]) }); | |
| 91 | if (made["url"]) { | |
| 92 | const url = text(made["url"]); | |
| 93 | rows.push({ | |
| 94 | label: "Production", | |
| 95 | value: url, | |
| 96 | ...(web(url) ? { link: url } : {}), | |
| 97 | }); | |
| 98 | } | |
| 91 | 99 | if (made["issue"]) rows.push({ label: "First issue", value: `#${text(made["issue"])}` }); |
| 92 | 100 | if (made["agent"]) rows.push({ label: "Agent", value: text(made["agent"]) }); |
| 93 | 101 | rows.push({ label: "First run", value: run === "" ? "none" : run }); |
| @@ -97,7 +105,6 @@ export function cardFor( | ||
| 97 | 105 | } |
| 98 | 106 | if (kind === "pull") { |
| 99 | 107 | const p = obj(value); |
| 100 | const web = (u: string): boolean => /^https?:\/\//.test(u); | |
| 101 | 108 | const state = text(p["state"]); |
| 102 | 109 | const check = text(obj(p["check"])["state"]); |
| 103 | 110 | const checkWord = |
| @@ -132,13 +139,19 @@ export function cardFor( | ||
| 132 | 139 | if (kind === "app") { |
| 133 | 140 | const apps = list(value).map(obj); |
| 134 | 141 | status = `${apps.length} app${apps.length === 1 ? "" : "s"}`; |
| 135 | for (const a of apps.slice(0, LIMIT)) | |
| 142 | for (const a of apps.slice(0, LIMIT)) { | |
| 143 | const url = text(a["url"]); | |
outdated
Put the brace on the |
||
| 136 | 144 | rows.push({ |
| 137 | 145 | label: text(a["app"]), |
| 138 | value: `${text(a["live"])}${a["url"] ? ` · ${text(a["url"])}` : ""}${ | |
| 146 | value: `${text(a["live"])}${url && !web(url) ? ` · ${url}` : ""}${ | |
| 139 | 147 | Number(a["working"]) > 0 ? ` · ${text(a["working"])} working` : "" |
| 140 | 148 | }`, |
outdated
Link goes to the forge page only. Production url (a['url']) is shown as text in value but never linked; add a linked production row when it is http(s). Also hoist |
||
| 149 | ...(web(origin) && /^[^/]+\/[^/]+$/.test(text(a["app"])) | |
| 150 | ? { link: `${origin}/${text(a["app"])}` } | |
| 151 | : {}), | |
| 141 | 152 | }); |
| 153 | if (web(url)) rows.push({ label: `${text(a["app"])} production`, value: url, link: url }); | |
| 154 | } | |
| 142 | 155 | for (const a of apps.slice(0, 3)) |
| 143 | 156 | if (text(a["app"]) !== "") |
| 144 | 157 | actions.push({ label: `Tell ${text(a["app"])}`, tool: "tell", args: { repo: text(a["app"]), context: {} }, words: true }); |
test/card-shell.test.ts +31
| @@ -172,3 +172,34 @@ describe("the card shell", () => { | ||
| 172 | 172 | expect(s.root.all().filter((n) => n.tag === "a")).toHaveLength(1); |
| 173 | 173 | }); |
| 174 | 174 | }); |
| 175 | ||
| 176 | describe("app card links", () => { | |
| 177 | const origin = "https://forge.example"; | |
| 178 | test("apps rows link to the app page only for an http(s) origin and owner/name", () => { | |
| 179 | const rows = (o: string, app = "alice/game") => cardFor("apps", [{ app, live: "up" }], o)!.rows; | |
| 180 | expect(rows(origin)[0]!.link).toBe("https://forge.example/alice/game"); | |
| 181 | expect(rows("javascript:alert(1)")[0]!.link).toBeUndefined(); | |
| 182 | expect(rows("//evil.example")[0]!.link).toBeUndefined(); | |
| 183 | expect(rows("")[0]!.link).toBeUndefined(); | |
| 184 | expect(rows(origin, "a/b/c")[0]!.link).toBeUndefined(); | |
| 185 | }); | |
| 186 | test("apps adds a linked production row for an http(s) url only", () => { | |
| 187 | const rows = (url: string) => cardFor("apps", [{ app: "alice/game", live: "up", url }], origin)!.rows; | |
| 188 | const good = rows("https://game.example"); | |
| 189 | expect(good[1]!.link).toBe("https://game.example"); | |
| 190 | const bad = rows("javascript:alert(1)"); | |
| 191 | expect(bad).toHaveLength(1); | |
| 192 | expect(bad.every((r) => r.link !== "javascript:alert(1)")).toBe(true); | |
| 193 | const proto = rows("//evil.example"); | |
| 194 | expect(proto).toHaveLength(1); | |
| 195 | expect(proto.some((r) => r.link === "//evil.example")).toBe(false); | |
outdated
|
||
| 196 | expect(proto[0]!.value).toContain("//evil.example"); | |
| 197 | }); | |
| 198 | test("new_app links Production only when the url is http(s)", () => { | |
| 199 | const prod = (url: string) => | |
| 200 | cardFor("new_app", { repo: "alice/game", url, run: "none" })!.rows.find((r) => r.label === "Production")!; | |
| 201 | expect(prod("https://game.example").link).toBe("https://game.example"); | |
| 202 | expect(prod("javascript:alert(1)").link).toBeUndefined(); | |
| 203 | expect(prod("//evil.example").link).toBeUndefined(); | |
| 204 | }); | |
| 205 | }); |
The new paragraph runs straight into the old
resources/listsentence on one overlong line. Startresources/list and resources/read…on its own line, as the surrounding text wraps.