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

Merged opened this

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

Merged

scout/forge:scout/10951-small-an-explore-row-s-make-it-yours-is → main 6 commits fork 4 threads · 4 unresolved

  • Merged. scout/10951-small-an-explore-row-s-make-it-yours-is was merged into main as 61367d2.

Review

Sign in to review this change.
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: "
4444 The three return the same ordered rows (`test/explore-parity.test.ts`; the URL form in `test/explore-url.test.ts`).
4545
4646 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 {
283283 .join(" ");
284284 }
285285
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
286289 export interface ExploreRow {
287290 readonly kind: ExploreKind;
288291 readonly name: string;
@@ -310,6 +313,8 @@ export interface ExploreRow {
310313 * private one they enter as its own. The same rules Make it yours checks.
311314 */
312315 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;
313318 }
314319
315320 /**
@@ -512,6 +517,7 @@ function rank(
512517 const appRows: ExploreRow[] = appRepos.map((repo) => {
513518 const s = told(repo);
514519 const liked = likes.get(repo.id) ?? 0;
520 const canCopy = copyable(repo);
515521 return {
516522 kind: "app",
517523 name: `${repo.ownerName}/${repo.name}`,
@@ -527,7 +533,8 @@ function rank(
527533 shippedAt: s.shippedAt,
528534 score: frontScore(s, now, weights) + LIKE_WEIGHT * Math.log1p(liked),
529535 repo: { owner: repo.ownerName, name: repo.name },
530 copyable: copyable(repo),
536 copyable: canCopy,
outdated

Compute copyable(repo) once into a const and use it for both fields.

537 ...(canCopy ? { action: MAKE_IT_YOURS } : {}),
531538 };
532539 });
533540
src/web/studio/explore.ts +3 −2
@@ -13,6 +13,7 @@ import {
1313 exploreParams,
1414 exploreNote,
1515 exploreSuggestion,
16 MAKE_IT_YOURS,
1617 readExploreLine,
1718 type ExploreQuery,
1819 type ExploreRow,
@@ -71,9 +72,9 @@ function rowHtml(row: ExploreRow, signedIn: boolean): Html {
7172 trail: signedIn
7273 ? html`<form method="post" action="/${row.repo.owner}/${row.repo.name}/make-it-yours" data-make-it-yours>
7374 <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" })}
7576 </form>`
76 : btn("Make it yours", {
77 : btn(MAKE_IT_YOURS.label, {
7778 href: `/${row.repo.owner}/${row.repo.name}/make-it-yours?receipt=1`,
7879 size: "sm",
7980 icon: "fork",
test/explorer-make-it-yours.test.ts +57
@@ -192,3 +192,60 @@ describe("explorer: make it yours", () => {
192192 expect(getRepo(app.db, "bob", "garden")).toBeDefined();
193193 });
194194 });
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 action key; "exactly when copyable" is documented but only the present side is tested.

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 data-make-it-yours form/button so a changed page button fails it.

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

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs