Public: anyone can read this; the crew can change it.

The explorer's kind and freshness chips narrow the list through the query string, server-rendered #11332

Merged opened this

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

Merged

scout/forge:scout/11232-the-explorer-s-kind-and-freshness-chips → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/11232-the-explorer-s-kind-and-freshness-chips was merged into main as b93ecb3.

Review

Sign in to review this change.
10 files changed +74 −19
docs/MCP.md +2 −2
@@ -97,7 +97,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
9797 | `egress_grants` | — | Private destinations and grants: list, grant or revoke (site admin) | 289 |
9898 | `email_verify` | — | Send yourself a new verification link | 126 |
9999 | `events` | — | Repository events | 259 |
100 | `explore` | — | The apps, people (and, for an admin, forges) you can see, ranked | 547 |
100 | `explore` | — | The apps, people (and, for an admin, forges) you can see, ranked | 573 |
101101 | `family` | — | The forge that sent this one and, for a site admin, those it sent, with state, version | 205 |
102102 | `features` | — | Apps absorbed as forge features: list, read, absorb or un-absorb, change typed settings | 357 |
103103 | `feed` | — | Your ranked home timeline; `cursor` pages; saved filter applies | 562 |
@@ -357,7 +357,7 @@ longer sentences:
357357 - `tell.from`: a path on this forge the issue links to, like `/alice/notes/pulls/3`. `tell.context`: where this was told from, for the builder.
358358 - `spawn.reveal`, `claim_link.reveal`: put the live claim link in the answer (default false); it is a secret, only when its person reads it. `spawn.fresh`: a fresh app beside the copied ones. `spawn.followMode`: `tell-me` (default) tells the owner in their inbox when this forge's code moves; `take-updates` opens a pull with the new code, and a person merges it. `spawn.size`: recorded only, not enforced. `spawn.keepAccess`: stay an owner once claimed.
359359 - `commits.ref`: branch, tag or commit; the default branch when absent. `report.page`: the http(s) address where the problem was seen. `plan.issues.pillar`: the pillar served, as `"P0"` or `"P0 Trust"`; `later: true` means no pillar and needs `why`.
360 - `explore.q`: words, plus `kind:app|person|forge` and `sort:score|forks|ships|likes|use|fresh|name`, `updated:1d|7d|30d` (only what moved in that window), `owner:name` and `archived:yes`. `query` is the same argument; `q` wins when both are given. Each of `kind`, `sort`, `updated`, `owner`, `archived` is also its own argument (and a query parameter on `/explore` and `/api/v1/explore`); it wins over the same key in `q`.
360 - `explore.q`: words, plus `kind:app|person|forge` and `sort:score|forks|ships|likes|use|fresh|name`, `updated:1d|7d|30d` (only what moved in that window), `owner:name` and `archived:yes`. `since` is another name for `updated`. `query` is the same argument; `q` wins when both are given. Each of `kind`, `sort`, `updated`, `owner`, `archived` is also its own argument (and a query parameter on `/explore` and `/api/v1/explore`); it wins over the same key in `q`.
361361 - `archive.repos`: several repositories, as `owner/name`, to archive in one operation.
362362 - `milestones.issue`: the number of an issue that waits for others; the limbs when absent. `watch.issue` is the same number; `watch.watch` false stops watching, anything else watches.
363363 - `limb.reason` (close): why it is not being done, in one sentence. `limb.pillar` (resume): the pillar to return it under. `limb.under` (raise, lower): `top` or a milestone number, only when the limb sits in several places.
docs/features/explore.md +3 −3
@@ -22,7 +22,7 @@ A like is a `feed_like` row (one per person and post, `src/feed/likes.ts`). An a
2222
2323 In `score`, likes add `0.5 × ln(1 + likes)`, small next to forks (3) and ships (2). The weight is a constant in `explore.ts`, not one of the front page's admin weights.
2424
25 An unknown `sort:` word is a 422 naming the sorts above (see the end of this page).
25 An unknown `sort:` word is a 400 naming the sorts above (see the end of this page).
2626
2727 When an unknown `kind:`, `sort:`, `updated:` or `archived:` value is within edit distance 2 of a known one (and nearer than the value is long, so one or two characters of junk suggest nothing), the note ends with `Did you mean <a>sort:forks</a>?`, a link to the same view with that word mended (in the parameter form below, `/explore?sort=forks`). Only the first such word is mended per line; a word repeated is mended everywhere. `GET /api/v1/explore` and the MCP `explore` tool carry the same as `suggest` (`"garden sort:forks"`, the whole corrected line), absent when there is none. The terminal shows it as plain text under "Read as:". It is a pure function of the line: nothing is looked up.
2828
@@ -34,7 +34,7 @@ What a person filters on `/explore` an agent filters with the same words. Each f
3434 |---|---|---|---|
3535 | Best / Forks / Shipped / Liked / Use / Fresh | `sort=score` `forks` `ships` `likes` `use` `fresh` (`name` by hand) | `?sort=forks` | `{ sort: "forks" }` |
3636 | All / Apps / People / Forges (admin) | `kind=app` `person` `forge` | `?kind=app` | `{ kind: "app" }` |
37 | Any time / Today / This week / This month | `updated=1d` `7d` `30d` | `?updated=7d` | `{ updated: "7d" }` |
37 | Any time / Today / This week / This month | `since=1d` `7d` `30d` (`updated=` is the same) | `?since=7d` | `{ since: "7d" }` |
3838 | Show archived | `archived=yes` | `?archived=yes` | `{ archived: "yes" }` |
3939 | (owner link on a row) | `owner=name` | `?owner=name` | `{ owner: "name" }` |
4040 | the search box | `q=words` | `?q=words` | `{ q: "words" }` |
@@ -43,7 +43,7 @@ The one line still works everywhere: `?q=kind:app sort:forks garden` and `{ q: "
4343
4444 The three return the same ordered rows (`test/explore-parity.test.ts`; the URL form in `test/explore-url.test.ts`).
4545
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).
46 An unknown filter word is refused the same way everywhere: `400` 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 `400` on the page, which still draws the note, the did-you-mean link and the results. This replaces the earlier note-only answer (#7595).
4747
4848 ## Make it yours on an app row
4949 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`).
src/actions/explore.ts +4 −2
@@ -106,7 +106,9 @@ export const EXPLORE_PARAMS = ["kind", "sort", "updated", "owner", "archived"] a
106106 export function exploreLineFrom(get: (name: string) => string | null | undefined): string {
107107 const given = new Map<string, string>();
108108 for (const key of EXPLORE_PARAMS) {
109 const value = (get(key) ?? "").replace(/\s+/g, "").slice(0, 100);
109 // `since` is the same window as `updated`, in the words the chips are shared in.
110 const raw = get(key) || (key === "updated" ? get("since") : "") || "";
outdated

Alias is fine, but the chips must emit since= (or the issue amended); and when both since and updated are given, say which wins in a test.

111 const value = raw.replace(/\s+/g, "").slice(0, 100);
110112 if (value !== "") given.set(key, value);
111113 }
112114 // A parameter wins: the same key typed in `q` is dropped, not left to the reader's order.
@@ -122,7 +124,7 @@ export function exploreParams(query: ExploreQuery): string {
122124 const p = new URLSearchParams();
123125 if (query.kind !== "all") p.set("kind", query.kind);
124126 if (query.sort !== "score") p.set("sort", query.sort);
125 if (query.updated !== "all") p.set("updated", query.updated);
127 if (query.updated !== "all") p.set("since", query.updated);
126128 if (query.archived) p.set("archived", "yes");
127129 if (query.owner !== undefined) p.set("owner", query.owner);
128130 if (query.text !== "") p.set("q", query.text);
src/http/api.ts +1 −1
@@ -3424,7 +3424,7 @@ export function apiRoutes(router: Router<Handler>): void {
34243424 const { query, unknown } = readExploreLine(line);
34253425 const suggest = exploreSuggestion(line);
34263426 if (unknown.length > 0)
3427 return json({ error: "unknown_filter", message: exploreNote(unknown), ...(suggest === undefined ? {} : { suggest: suggest.line }) }, 422);
3427 return json({ error: "unknown_filter", message: exploreNote(unknown), ...(suggest === undefined ? {} : { suggest: suggest.line }) }, 400);
34283428 const mine = runRepos(ctx.app, ctx.auth);
34293429 // A run token is shown only the rows of its own repositories; the cut
34303430 // and the cursor are made from those rows alone.
src/http/mcp/tools/explore.ts +2 −1
@@ -3,7 +3,7 @@ import { bad, good, type Tool, str } from "../shared.ts";
33
44 export const tool: Tool = {
55 name: "explore",
6 doc: "`{ q?, cursor? }` (`query` is the same, `q` wins): the apps and people you can see, ranked, with their numbers; `kind:` `sort:` `updated:` `owner:` `archived:` and words in one line; each also as its own argument; page on with `cursor`",
6 doc: "`{ q?, cursor? }` (`query` is the same, `q` wins): the apps and people you can see, ranked, with their numbers; `kind:` `sort:` `updated:` `owner:` `archived:` and words in one line; each also as its own argument (`since` = `updated`); page on with `cursor`",
77 description:
88 "The apps, people (and, for an admin, forges) you can see, ranked.",
99 inputSchema: {
@@ -20,6 +20,7 @@ export const tool: Tool = {
2020 description: "Alias of q",
2121 },
2222 ...Object.fromEntries(EXPLORE_PARAMS.map((k) => [k, { type: "string", maxLength: 100 }])),
23 since: { type: "string", maxLength: 100 },
2324 cursor: {
2425 type: "string",
2526 },
src/web/studio/explore.ts +2 −2
@@ -232,8 +232,8 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
232232 extra: "bare",
233233 })}</div>`;
234234 const drawn = page({ ctx, title: "Explore", body, feed: { href: "/explore.atom", title: "Explore" } });
235 // An unknown filter is a 422 here as on the API and the MCP; the page still draws the note and the results.
236 const response = unknown.length === 0 ? drawn : new Response(drawn.body, { status: 422, headers: drawn.headers });
235 // An unknown filter is a 400 here as on the API and the MCP; the page still draws the note and the results.
236 const response = unknown.length === 0 ? drawn : new Response(drawn.body, { status: 400, headers: drawn.headers });
237237 notePhase(ctx.request, "render", performance.now() - rendering);
238238 return response;
239239 }
test/explore-mcp-q.test.ts +9
@@ -78,3 +78,12 @@ test("llms.txt names every filter explore reads", async () => {
7878 const line = txt.split("\n").find((l) => l.includes('MCP "explore"')) ?? "";
7979 for (const k of ["kind:", "sort:", "updated:", "owner:", "archived:", "1d, 7d or 30d", "yes or no"]) expect(line).toContain(k);
8080 });
81
82 test("explore takes since like the page: the window narrows, a bad one is an error naming the valid ones (#11232)", async () => {
83 const all = await explore({});
84 const week = await explore({ since: "7d" });
85 expect(week.items.length).toBeLessThan(all.items.length);
86 const r = await rpc("tools/call", { name: "explore", arguments: { since: "9y" } });
87 expect(r.isError).toBe(true);
88 expect(r.content[0].text).toContain("1d, 7d, 30d");
89 });
test/explore-parity.test.ts +4 −4
@@ -192,10 +192,10 @@ describe("one filter, three surfaces", () => {
192192 }
193193 });
194194
195 test("an unknown filter is a 422 naming the allowed words, on all three", async () => {
195 test("an unknown filter is a 400 naming the allowed words, on all three", async () => {
196196 const q = encodeURIComponent("sort:bogus");
197197 const api = await fetch(`${servers.url}/api/v1/explore?q=${q}`);
198 expect(api.status).toBe(422);
198 expect(api.status).toBe(400);
199199 const body = (await api.json()) as { error: string; message: string };
200200 expect(body.error).toBe("unknown_filter");
201201 expect(body.message).toContain("use score, forks, ships, likes, use, fresh, name");
@@ -203,10 +203,10 @@ describe("one filter, three surfaces", () => {
203203 expect(tool.ok).toBe(false);
204204 expect(tool.text).toContain("use score, forks, ships, likes, use, fresh, name");
205205 const page = await fetch(`${servers.url}/explore?q=${q}`);
206 expect(page.status).toBe(422);
206 expect(page.status).toBe(400);
207207 expect(await page.text()).toContain("use score, forks, ships, likes, use, fresh, name");
208208 for (const bad of ["kind:x", "updated:2y", "archived:maybe"]) {
209 expect((await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent(bad)}`)).status).toBe(422);
209 expect((await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent(bad)}`)).status).toBe(400);
210210 }
211211 });
212212 });
test/explore-unknown-words.test.ts +3 −3
@@ -66,7 +66,7 @@ describe("readExploreLine", () => {
6666 try {
6767 const q = encodeURIComponent("owner:trevato updated:2y archived:maybe");
6868 const res = await fetch(`${s.url}/api/v1/explore?q=${q}`);
69 expect(res.status).toBe(422);
69 expect(res.status).toBe(400);
7070 const body = (await res.json()) as { message: string };
7171 expect(body.message).toBe("updated:2y is not a window — use 1d, 7d, 30d; archived:maybe is not an answer — use yes, no");
7272 } finally {
@@ -131,9 +131,9 @@ describe("the page and the API", () => {
131131 expect(out).not.toContain("<b>");
132132 });
133133
134 test("the API answers 422 naming the allowed words (#10387)", async () => {
134 test("the API answers 400 naming the allowed words (#10387)", async () => {
135135 const res = await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent("kind:nope")}`);
136 expect(res.status).toBe(422);
136 expect(res.status).toBe(400);
137137 const body = (await res.json()) as { error: string; message: string };
138138 expect(body.error).toBe("unknown_filter");
139139 expect(body.message).toContain("kind:nope is not a kind — use app, person, forge");
test/explore-url.test.ts +44 −1
@@ -11,6 +11,7 @@ import { createUser } from "../src/core/users.ts";
1111 import { serve, type Servers } from "../src/http/server.ts";
1212 import { quietLogs } from "./helpers/globals.ts";
1313 import { proveAll } from "./helpers/verified.ts";
14 import { expectWork, requestStatements } from "./helpers/work.ts";
1415 import { promote } from "./helpers/members.ts";
1516
1617 quietLogs();
@@ -73,7 +74,7 @@ test("an unknown parameter is ignored; an invalid value is named and the default
7374 expect(extra.status).toBe(200);
7475 expect(listed(extra.html)).toEqual(listed(plain.html));
7576 const bad = await page("?sort=bogus");
76 expect(bad.status).toBe(422);
77 expect(bad.status).toBe(400);
7778 expect(listed(bad.html)).toEqual(listed(plain.html));
7879 });
7980
@@ -90,3 +91,45 @@ test("the chips link with separate parameters", async () => {
9091 expect(html).toContain('href="/explore?sort=forks"');
9192 expect(exploreParams(readExploreLine("kind:app sort:forks owner:olive garden").query)).toBe("kind=app&sort=forks&owner=olive&q=garden");
9293 });
94
95 test("`since` is the `updated` window: same line, bad value is a 400 (#11232)", async () => {
96 expect(exploreLineFrom((n) => (n === "since" ? "7d" : null))).toBe("updated:7d");
97 expect(exploreLineFrom((n) => (n === "since" ? "9y" : null))).toBe("updated:9y");
98 const ok = await fetch(`${servers.url}/explore?kind=app&since=7d`);
99 expect(ok.status).toBe(200);
100 const bad = await fetch(`${servers.url}/explore?since=9y`);
outdated

The issue says 400; this pins 422. Move the contract to 400 (and the other explore tests) or get the issue changed. Also add per-filter narrowing tests and a under-50ms assertion.

101 expect(bad.status).toBe(400);
102 expect(await bad.text()).toContain("1d, 7d, 30d");
103 });
104
105 test("each kind and each since narrows the list, and the filtered view costs few statements (#11232)", async () => {
106 const all = listed((await page("")).html);
107 expect(all.length).toBeGreaterThan(0);
108 const apps = listed((await page("?kind=app")).html);
109 const people = listed((await page("?kind=person")).html);
110 expect(apps.length).toBeGreaterThan(0);
111 expect(people.length).toBeGreaterThan(0);
112 expect(apps).not.toEqual(people);
113 expect(apps.length).toBeLessThan(all.length);
114 expect(people.length).toBeLessThan(all.length);
115 // Forges are an admin's to see: a visitor's `kind=forge` is a different (empty) list.
116 const forges = await page("?kind=forge");
117 expect(forges.status).toBe(200);
118 expect(listed(forges.html)).toEqual([]);
119 // Nothing here has shipped, so each window drops what the bare list shows.
120 for (const since of ["7d", "30d"]) {
121 const res = await page(`?since=${since}`);
122 expect(res.status).toBe(200);
123 expect(listed(res.html).length).toBeLessThan(all.length);
124 }
125 for (const qs of ["?kind=app&since=7d", "?kind=person&since=30d", "?kind=forge"]) {
126 expectWork(await requestStatements(app.db, `${servers.url}/explore${qs}`), { max: 40 });
127 }
128 });
129
130 test("the chips link with since=, and updated wins when both are given (#11232)", async () => {
131 const res = await page("?kind=app");
132 expect(res.html).toContain("since=7d");
133 expect(res.html).not.toContain("updated=");
134 expect(exploreLineFrom((n) => (n === "updated" ? "7d" : n === "since" ? "30d" : null))).toBe("updated:7d");
135 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs