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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11232-the-explorer-s-kind-and-freshness-chips → main 5 commits fork 2 threads · 2 unresolved
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`; | ||
| 97 | 97 | | `egress_grants` | — | Private destinations and grants: list, grant or revoke (site admin) | 289 | |
| 98 | 98 | | `email_verify` | — | Send yourself a new verification link | 126 | |
| 99 | 99 | | `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 | | |
| 101 | 101 | | `family` | — | The forge that sent this one and, for a site admin, those it sent, with state, version | 205 | |
| 102 | 102 | | `features` | — | Apps absorbed as forge features: list, read, absorb or un-absorb, change typed settings | 357 | |
| 103 | 103 | | `feed` | — | Your ranked home timeline; `cursor` pages; saved filter applies | 562 | |
| @@ -357,7 +357,7 @@ longer sentences: | ||
| 357 | 357 | - `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. |
| 358 | 358 | - `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. |
| 359 | 359 | - `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`. | |
| 361 | 361 | - `archive.repos`: several repositories, as `owner/name`, to archive in one operation. |
| 362 | 362 | - `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. |
| 363 | 363 | - `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 | ||
| 22 | 22 | |
| 23 | 23 | 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. |
| 24 | 24 | |
| 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). | |
| 26 | 26 | |
| 27 | 27 | 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. |
| 28 | 28 | |
| @@ -34,7 +34,7 @@ What a person filters on `/explore` an agent filters with the same words. Each f | ||
| 34 | 34 | |---|---|---|---| |
| 35 | 35 | | Best / Forks / Shipped / Liked / Use / Fresh | `sort=score` `forks` `ships` `likes` `use` `fresh` (`name` by hand) | `?sort=forks` | `{ sort: "forks" }` | |
| 36 | 36 | | 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" }` | | |
| 38 | 38 | | Show archived | `archived=yes` | `?archived=yes` | `{ archived: "yes" }` | |
| 39 | 39 | | (owner link on a row) | `owner=name` | `?owner=name` | `{ owner: "name" }` | |
| 40 | 40 | | 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: " | ||
| 43 | 43 | |
| 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 | 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). | |
| 47 | 47 | |
| 48 | 48 | ## Make it yours on an app row |
| 49 | 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`). |
src/actions/explore.ts +4 −2
| @@ -106,7 +106,9 @@ export const EXPLORE_PARAMS = ["kind", "sort", "updated", "owner", "archived"] a | ||
| 106 | 106 | export function exploreLineFrom(get: (name: string) => string | null | undefined): string { |
| 107 | 107 | const given = new Map<string, string>(); |
| 108 | 108 | 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") : "") || ""; | |
| 111 | const value = raw.replace(/\s+/g, "").slice(0, 100); | |
| 110 | 112 | if (value !== "") given.set(key, value); |
| 111 | 113 | } |
| 112 | 114 | // 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 { | ||
| 122 | 124 | const p = new URLSearchParams(); |
| 123 | 125 | if (query.kind !== "all") p.set("kind", query.kind); |
| 124 | 126 | 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); | |
| 126 | 128 | if (query.archived) p.set("archived", "yes"); |
| 127 | 129 | if (query.owner !== undefined) p.set("owner", query.owner); |
| 128 | 130 | 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 { | ||
| 3424 | 3424 | const { query, unknown } = readExploreLine(line); |
| 3425 | 3425 | const suggest = exploreSuggestion(line); |
| 3426 | 3426 | 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); | |
| 3428 | 3428 | const mine = runRepos(ctx.app, ctx.auth); |
| 3429 | 3429 | // A run token is shown only the rows of its own repositories; the cut |
| 3430 | 3430 | // 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"; | ||
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | 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`", | |
| 7 | 7 | description: |
| 8 | 8 | "The apps, people (and, for an admin, forges) you can see, ranked.", |
| 9 | 9 | inputSchema: { |
| @@ -20,6 +20,7 @@ export const tool: Tool = { | ||
| 20 | 20 | description: "Alias of q", |
| 21 | 21 | }, |
| 22 | 22 | ...Object.fromEntries(EXPLORE_PARAMS.map((k) => [k, { type: "string", maxLength: 100 }])), |
| 23 | since: { type: "string", maxLength: 100 }, | |
| 23 | 24 | cursor: { |
| 24 | 25 | type: "string", |
| 25 | 26 | }, |
src/web/studio/explore.ts +2 −2
| @@ -232,8 +232,8 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 232 | 232 | extra: "bare", |
| 233 | 233 | })}</div>`; |
| 234 | 234 | 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 }); | |
| 237 | 237 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 238 | 238 | return response; |
| 239 | 239 | } |
test/explore-mcp-q.test.ts +9
| @@ -78,3 +78,12 @@ test("llms.txt names every filter explore reads", async () => { | ||
| 78 | 78 | const line = txt.split("\n").find((l) => l.includes('MCP "explore"')) ?? ""; |
| 79 | 79 | for (const k of ["kind:", "sort:", "updated:", "owner:", "archived:", "1d, 7d or 30d", "yes or no"]) expect(line).toContain(k); |
| 80 | 80 | }); |
| 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", () => { | ||
| 192 | 192 | } |
| 193 | 193 | }); |
| 194 | 194 | |
| 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 () => { | |
| 196 | 196 | const q = encodeURIComponent("sort:bogus"); |
| 197 | 197 | const api = await fetch(`${servers.url}/api/v1/explore?q=${q}`); |
| 198 | expect(api.status).toBe(422); | |
| 198 | expect(api.status).toBe(400); | |
| 199 | 199 | const body = (await api.json()) as { error: string; message: string }; |
| 200 | 200 | expect(body.error).toBe("unknown_filter"); |
| 201 | 201 | expect(body.message).toContain("use score, forks, ships, likes, use, fresh, name"); |
| @@ -203,10 +203,10 @@ describe("one filter, three surfaces", () => { | ||
| 203 | 203 | expect(tool.ok).toBe(false); |
| 204 | 204 | expect(tool.text).toContain("use score, forks, ships, likes, use, fresh, name"); |
| 205 | 205 | const page = await fetch(`${servers.url}/explore?q=${q}`); |
| 206 | expect(page.status).toBe(422); | |
| 206 | expect(page.status).toBe(400); | |
| 207 | 207 | expect(await page.text()).toContain("use score, forks, ships, likes, use, fresh, name"); |
| 208 | 208 | 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); | |
| 210 | 210 | } |
| 211 | 211 | }); |
| 212 | 212 | }); |
test/explore-unknown-words.test.ts +3 −3
| @@ -66,7 +66,7 @@ describe("readExploreLine", () => { | ||
| 66 | 66 | try { |
| 67 | 67 | const q = encodeURIComponent("owner:trevato updated:2y archived:maybe"); |
| 68 | 68 | const res = await fetch(`${s.url}/api/v1/explore?q=${q}`); |
| 69 | expect(res.status).toBe(422); | |
| 69 | expect(res.status).toBe(400); | |
| 70 | 70 | const body = (await res.json()) as { message: string }; |
| 71 | 71 | expect(body.message).toBe("updated:2y is not a window — use 1d, 7d, 30d; archived:maybe is not an answer — use yes, no"); |
| 72 | 72 | } finally { |
| @@ -131,9 +131,9 @@ describe("the page and the API", () => { | ||
| 131 | 131 | expect(out).not.toContain("<b>"); |
| 132 | 132 | }); |
| 133 | 133 | |
| 134 | test("the API answers 422 naming the allowed words (#10387)", async () => { | |
| 134 | test("the API answers 400 naming the allowed words (#10387)", async () => { | |
| 135 | 135 | 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); | |
| 137 | 137 | const body = (await res.json()) as { error: string; message: string }; |
| 138 | 138 | expect(body.error).toBe("unknown_filter"); |
| 139 | 139 | 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"; | ||
| 11 | 11 | import { serve, type Servers } from "../src/http/server.ts"; |
| 12 | 12 | import { quietLogs } from "./helpers/globals.ts"; |
| 13 | 13 | import { proveAll } from "./helpers/verified.ts"; |
| 14 | import { expectWork, requestStatements } from "./helpers/work.ts"; | |
| 14 | 15 | import { promote } from "./helpers/members.ts"; |
| 15 | 16 | |
| 16 | 17 | quietLogs(); |
| @@ -73,7 +74,7 @@ test("an unknown parameter is ignored; an invalid value is named and the default | ||
| 73 | 74 | expect(extra.status).toBe(200); |
| 74 | 75 | expect(listed(extra.html)).toEqual(listed(plain.html)); |
| 75 | 76 | const bad = await page("?sort=bogus"); |
| 76 | expect(bad.status).toBe(422); | |
| 77 | expect(bad.status).toBe(400); | |
| 77 | 78 | expect(listed(bad.html)).toEqual(listed(plain.html)); |
| 78 | 79 | }); |
| 79 | 80 | |
| @@ -90,3 +91,45 @@ test("the chips link with separate parameters", async () => { | ||
| 90 | 91 | expect(html).toContain('href="/explore?sort=forks"'); |
| 91 | 92 | expect(exploreParams(readExploreLine("kind:app sort:forks owner:olive garden").query)).toBe("kind=app&sort=forks&owner=olive&q=garden"); |
| 92 | 93 | }); |
| 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 | }); |
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.