Public: anyone can read this; the crew can change it.
small: an Explore row's "make it yours" is one tap and the same words in the MCP row #10976
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10951-small-an-explore-row-s-make-it-yours-is → main 6 commits fork 4 threads · 4 unresolved
4 files
changed +72
−3
docs/features/explore.md +4
| @@ -44,3 +44,7 @@ The one line still works everywhere: `?q=kind:app sort:forks garden` and `{ q: " | ||
| 44 | 44 | The three return the same ordered rows (`test/explore-parity.test.ts`; the URL form in `test/explore-url.test.ts`). |
| 45 | 45 | |
| 46 | 46 | An unknown filter word is refused the same way everywhere: `422` from the API (`{ "error": "unknown_filter", "message": "sort:bogus is not a sort — use score, forks, …", "suggest"? }`), an `unknown_filter` error from the MCP tool, and `422` on the page, which still draws the note, the did-you-mean link and the results. This replaces the earlier note-only answer (#7595). |
| 47 | ||
| 48 | ## Make it yours on an app row | |
| 49 | An app's row carries `action: { do: "make_it_yours", label: "Make it yours" }` exactly when `copyable` is true (`MAKE_IT_YOURS` in `actions/explore.ts`). | |
| 50 | `label` is the page button's text and `do` is the name of the MCP tool that forks; it plans unless called with `dryRun: false`. |
src/actions/explore.ts +8 −1
| @@ -283,6 +283,9 @@ export function exploreLine(query: ExploreQuery): string { | ||
| 283 | 283 | .join(" "); |
| 284 | 284 | } |
| 285 | 285 | |
| 286 | /** The one fork action an app row offers: the page's button and the MCP row's `action` say these words. */ | |
| 287 | export const MAKE_IT_YOURS = { do: "make_it_yours", label: "Make it yours" } as const; | |
| 288 | ||
| 286 | 289 | export interface ExploreRow { |
| 287 | 290 | readonly kind: ExploreKind; |
| 288 | 291 | readonly name: string; |
| @@ -310,6 +313,8 @@ export interface ExploreRow { | ||
| 310 | 313 | * private one they enter as its own. The same rules Make it yours checks. |
| 311 | 314 | */ |
| 312 | 315 | readonly copyable?: boolean; |
| 316 | /** Present exactly when `copyable`: the action name (an MCP tool) and label the page shows. */ | |
| 317 | readonly action?: typeof MAKE_IT_YOURS; | |
| 313 | 318 | } |
| 314 | 319 | |
| 315 | 320 | /** |
| @@ -512,6 +517,7 @@ function rank( | ||
| 512 | 517 | const appRows: ExploreRow[] = appRepos.map((repo) => { |
| 513 | 518 | const s = told(repo); |
| 514 | 519 | const liked = likes.get(repo.id) ?? 0; |
| 520 | const canCopy = copyable(repo); | |
| 515 | 521 | return { |
| 516 | 522 | kind: "app", |
| 517 | 523 | name: `${repo.ownerName}/${repo.name}`, |
| @@ -527,7 +533,8 @@ function rank( | ||
| 527 | 533 | shippedAt: s.shippedAt, |
| 528 | 534 | score: frontScore(s, now, weights) + LIKE_WEIGHT * Math.log1p(liked), |
| 529 | 535 | repo: { owner: repo.ownerName, name: repo.name }, |
| 530 | copyable: copyable(repo), | |
| 536 | copyable: canCopy, | |
| 537 | ...(canCopy ? { action: MAKE_IT_YOURS } : {}), | |
| 531 | 538 | }; |
| 532 | 539 | }); |
| 533 | 540 |
src/web/studio/explore.ts +3 −2
| @@ -13,6 +13,7 @@ import { | ||
| 13 | 13 | exploreParams, |
| 14 | 14 | exploreNote, |
| 15 | 15 | exploreSuggestion, |
| 16 | MAKE_IT_YOURS, | |
| 16 | 17 | readExploreLine, |
| 17 | 18 | type ExploreQuery, |
| 18 | 19 | type ExploreRow, |
| @@ -71,9 +72,9 @@ function rowHtml(row: ExploreRow, signedIn: boolean): Html { | ||
| 71 | 72 | trail: signedIn |
| 72 | 73 | ? html`<form method="post" action="/${row.repo.owner}/${row.repo.name}/make-it-yours" data-make-it-yours> |
| 73 | 74 | <input type="hidden" name="receipt" value="1" /> |
| 74 | ${btn("Make it yours", { size: "sm", icon: "fork" })} | |
| 75 | ${btn(MAKE_IT_YOURS.label, { size: "sm", icon: "fork" })} | |
| 75 | 76 | </form>` |
| 76 | : btn("Make it yours", { | |
| 77 | : btn(MAKE_IT_YOURS.label, { | |
| 77 | 78 | href: `/${row.repo.owner}/${row.repo.name}/make-it-yours?receipt=1`, |
| 78 | 79 | size: "sm", |
| 79 | 80 | icon: "fork", |
test/explorer-make-it-yours.test.ts +57
| @@ -192,3 +192,60 @@ describe("explorer: make it yours", () => { | ||
| 192 | 192 | expect(getRepo(app.db, "bob", "garden")).toBeDefined(); |
| 193 | 193 | }); |
| 194 | 194 | }); |
| 195 | ||
| 196 | describe("explorer: page and MCP words match", () => { | |
| 197 | test("the explore row's action is the page's label and the tool that forks", async () => { | |
| 198 | const dataDir = await mkdtemp(join(tmpdir(), "forge-explorer-words-")); | |
| 199 | const app = createApp({ dataDir, port: 0 }); | |
| 200 | const servers = await serve(app); | |
| 201 | try { | |
| 202 | const mk = async (username: string, first: boolean) => { | |
| 203 | const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first }); | |
| 204 | if (!r.ok) throw new Error(r.error); | |
| 205 | promote(app.db, r.value); | |
| 206 | return r.value; | |
| 207 | }; | |
| 208 | const alice = await mk("alice", true); | |
| 209 | const bob = await mk("bob", false); | |
| 210 | const made = await newApp(app, alice, { name: "garden", description: "garden", visibility: "public" }); | |
| 211 | if (!made.ok) throw new Error(String(made.error)); | |
| 212 | const token = createToken(app.db, bob.id, "rw", "rw").token; | |
| 213 | const gardenRow = async (as: string) => { | |
| 214 | const res = await fetch(`${servers.url}/mcp`, { | |
| 215 | method: "POST", | |
| 216 | headers: { authorization: `Bearer ${as}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, | |
| 217 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "explore", arguments: { q: "kind:app" } } }), | |
| 218 | }); | |
| 219 | return JSON.parse(((await res.json()) as any).result.content[0].text).items.find((i: any) => i.name === "alice/garden"); | |
| 220 | }; | |
outdated
Also assert that a non-copyable row carries no |
||
| 221 | const row = await gardenRow(token); | |
| 222 | expect(row.copyable).toBe(true); | |
| 223 | expect(row.action).toEqual({ do: "make_it_yours", label: "Make it yours" }); | |
| 224 | // Not copyable, no action: alice's own app is not hers to make hers. | |
| 225 | const own = await gardenRow(createToken(app.db, alice.id, "rw", "rw").token); | |
outdated
Too loose: the label may appear elsewhere on the page. Scope the assertion to the row's |
||
| 226 | expect(own.copyable).toBe(false); | |
| 227 | expect("action" in own).toBe(false); | |
| 228 | // The page's own button for this row, as bob sees it, says the same words. | |
outdated
This only proves the tool does not error. Assert it previewed (plan wording, and bob has no garden fork afterwards), as the comment above claims. |
||
| 229 | const bobCookie = `forge_session=${createSession(app.db, bob.id).token}`; | |
| 230 | const page = await (await fetch(`${servers.url}/explore?q=kind%3Aapp`, { headers: { cookie: bobCookie } })).text(); | |
| 231 | const form = page.match(/<form method="post" action="\/alice\/garden\/make-it-yours" data-make-it-yours>[\s\S]*?<\/form>/)?.[0]; | |
| 232 | expect(form).toBeDefined(); | |
| 233 | expect(form!.match(/<button[\s\S]*?<\/button>/)?.[0]).toContain(row.action.label); | |
| 234 | // The named action is a tool that answers, and previews first. | |
| 235 | const prev = await fetch(`${servers.url}/mcp`, { | |
| 236 | method: "POST", | |
| 237 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, | |
| 238 | body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: row.action.do, arguments: { repo: "alice/garden" } } }), | |
| 239 | }); | |
| 240 | const plan = JSON.parse(((await prev.json()) as any).result.content[0].text); | |
| 241 | expect(plan.dryRun).toBe(true); | |
| 242 | expect(plan.from).toBe("alice/garden"); | |
| 243 | expect(plan.says).toBeUndefined(); | |
| 244 | expect(getRepo(app.db, "bob", "garden")).toBeUndefined(); | |
| 245 | } finally { | |
| 246 | servers.stop(); | |
| 247 | app.close(); | |
| 248 | await rm(dataDir, { recursive: true, force: true }); | |
| 249 | } | |
| 250 | }, 60_000); | |
| 251 | }); |
Compute
copyable(repo)once into a const and use it for both fields.