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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/5749-the-apps-card-links-each-app-to-its-page → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/5749-the-apps-card-links-each-app-to-its-page was merged into main as c020640.

Review

Sign in to review this change.
3 files changed +53 −5
docs/MCP.md +5 −1
@@ -259,7 +259,11 @@ answers
259259 `_meta.ui.resourceUri` (MCP Apps, SEP-1865): `ui://forge/app`, `run`,
260260 `decision`, `wave`, `map`, `feed`, `pull`. The Plan card (`map`) shows one row per pillar with
261261 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.
outdated

The new paragraph runs straight into the old resources/list sentence on one overlong line. Start resources/list and resources/read… on its own line, as the surrounding text wraps.

266 `resources/list` and `resources/read` serve one small
263267 sandboxed page, which draws the `structuredContent.card` model that the
264268 tool result carries, with `textContent` only. The model is built in
265269 `src/web/studio/cards.ts`. A card's
src/web/studio/cards.ts +17 −4
@@ -66,6 +66,7 @@ const obj = (v: unknown): Obj =>
6666 const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
6767 const text = (v: unknown): string =>
6868 typeof v === "string" ? v : typeof v === "number" ? String(v) : "";
69 const web = (u: string): boolean => /^https?:\/\//.test(u);
6970 const LIMIT = 12;
7071 const FEED_LIMIT = 10;
7172
@@ -87,7 +88,14 @@ export function cardFor(
8788 const run = text(made["run"]);
8889 status = run === "none" || run === "" ? "alive" : `alive · first run ${run}`;
8990 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 }
9199 if (made["issue"]) rows.push({ label: "First issue", value: `#${text(made["issue"])}` });
92100 if (made["agent"]) rows.push({ label: "Agent", value: text(made["agent"]) });
93101 rows.push({ label: "First run", value: run === "" ? "none" : run });
@@ -97,7 +105,6 @@ export function cardFor(
97105 }
98106 if (kind === "pull") {
99107 const p = obj(value);
100 const web = (u: string): boolean => /^https?:\/\//.test(u);
101108 const state = text(p["state"]);
102109 const check = text(obj(p["check"])["state"]);
103110 const checkWord =
@@ -132,13 +139,19 @@ export function cardFor(
132139 if (kind === "app") {
133140 const apps = list(value).map(obj);
134141 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 for line, as everywhere else in this file (for (...) {). The odd placement reads as a merge leftover.

136144 rows.push({
137145 label: text(a["app"]),
138 value: `${text(a["live"])}${a["url"] ? ` · ${text(a["url"])}` : ""}${
146 value: `${text(a["live"])}${url && !web(url) ? ` · ${url}` : ""}${
139147 Number(a["working"]) > 0 ? ` · ${text(a["working"])} working` : ""
140148 }`,
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 web rather than repeating the regex.

149 ...(web(origin) && /^[^/]+\/[^/]+$/.test(text(a["app"]))
150 ? { link: `${origin}/${text(a["app"])}` }
151 : {}),
141152 });
153 if (web(url)) rows.push({ label: `${text(a["app"])} production`, value: url, link: url });
154 }
142155 for (const a of apps.slice(0, 3))
143156 if (text(a["app"]) !== "")
144157 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", () => {
172172 expect(s.root.all().filter((n) => n.tag === "a")).toHaveLength(1);
173173 });
174174 });
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

r.link === undefined || r.link === origin/alice/game accepts nearly anything, so it would pass even if //evil.example were linked. Assert exactly: expect(rows("//evil.example")).toHaveLength(1) and that no row has link //evil.example.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs