Public: anyone can read this; the crew can change it.
The studio's app row is one function in its own module #121
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/111-the-studio-s-app-row-is-one-function-in → main 1 commit fork
5 files
changed +193
−114
CLAUDE.md +3 −2
| @@ -120,8 +120,9 @@ src/ | ||
| 120 | 120 | pages/{site,repo,issues,pulls,pull,thread,commits,refs,search, |
| 121 | 121 | org,account,settings}, and studio/ — the app-as-the-unit |
| 122 | 122 | surface, one module per concept: words (the vocabulary every |
| 123 | page says it in), asks, leans, previews, home, app, change, | |
| 124 | tell, run, lists, actions, style (the page sheet, assembled) | |
| 123 | page says it in), asks, leans, previews, shelf (an app as one | |
| 124 | row, for any page that lists apps), home, app, change, tell, | |
| 125 | run, lists, actions, style (the page sheet, assembled) | |
| 125 | 126 | ssh/ the SSH server: transport (kex/ciphers), auth (publickey), |
| 126 | 127 | channels (session only), keys, wire — zero deps, no forwarding |
| 127 | 128 | tui/ the terminal UI. term/ is the transport boundary (`Terminal`); |
docs/STUDIO.md +5 −2
| @@ -234,8 +234,11 @@ change's size, unlike the page it links to. | ||
| 234 | 234 | |
| 235 | 235 | `/` in the studio: what asks you across every app you can land, what is on |
| 236 | 236 | you (issues and pulls a person assigned to you), and your apps — each with |
| 237 | whether it is live, what the crew last did there, and a way in. **New app** | |
| 238 | is the same flow as ever. `/?mode=dev` is the repositories list, and — like | |
| 237 | whether it is live, what the crew last did there, and a way in. That row is | |
| 238 | a *shelf row* (`src/web/studio/shelf.ts`): one function turns repositories | |
| 239 | into rows, from grouped reads, so every page that lists apps lists them the | |
| 240 | same way and the home only decides the card around them. **New app** is the | |
| 241 | same flow as ever. `/?mode=dev` is the repositories list, and — like | |
| 239 | 242 | the toggle in the top bar — it switches this browser to dev from then on; |
| 240 | 243 | `/?mode=studio` switches it back, the same way. |
| 241 | 244 |
src/web/studio/home.ts +28 −110
| @@ -1,134 +1,42 @@ | ||
| 1 | 1 | /** |
| 2 | * The home in the studio: what asks you first, then every app as one row | |
| 3 | * carrying a real signal — live or not, what the crew last did there, how | |
| 4 | * many things wait on you — and beside it what is happening right now across | |
| 5 | * all of them, and what a person put on you. Built from the same rows and | |
| 6 | * cards as the rest of the forge, from grouped reads only, so the page does | |
| 7 | * not get slower as the apps grow. | |
| 2 | * The home in the studio: what asks you first, then every app as a shelf | |
| 3 | * row (`shelf.ts` draws those — the row is not this page's to decide), and | |
| 4 | * beside it what is happening right now across all of them, and what a | |
| 5 | * person put on you. Built from the same rows and cards as the rest of the | |
| 6 | * forge, from grouped reads only, so the page does not get slower as the | |
| 7 | * apps grow. | |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 11 | import { canSeeOwnerFacts } from "../../core/repos.ts"; | |
| 12 | import { latestAgentRunsForRepos, listActiveRuns } from "../../core/runs.ts"; | |
| 10 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; | |
| 11 | import { listActiveRuns } from "../../core/runs.ts"; | |
| 13 | 12 | import { getUsersByIds, type User } from "../../core/users.ts"; |
| 14 | import { repoDir } from "../../git/repos.ts"; | |
| 15 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 16 | 14 | import { repoBase } from "../chrome.ts"; |
| 17 | 15 | import { asksJs, liveRegionsJs } from "../client.ts"; |
| 18 | 16 | import { html, when } from "../html.ts"; |
| 19 | 17 | import { page } from "../layout.ts"; |
| 20 | 18 | import { liveClientJs } from "../live.ts"; |
| 21 | import { | |
| 22 | btn, | |
| 23 | card, | |
| 24 | dot, | |
| 25 | empty, | |
| 26 | icon, | |
| 27 | listCard, | |
| 28 | pill, | |
| 29 | row, | |
| 30 | time, | |
| 31 | type Tone, | |
| 32 | } from "../ui.ts"; | |
| 19 | import { btn, card, empty, icon, listCard, row, time } from "../ui.ts"; | |
| 33 | 20 | import { onYouCard } from "../pages/site.ts"; |
| 34 | 21 | import { appsOf, asksPanel, decisionsFor } from "./asks.ts"; |
| 35 | 22 | import { withLikely } from "./leans.ts"; |
| 36 | 23 | import { stopForm } from "./run.ts"; |
| 24 | import { shelfRows } from "./shelf.ts"; | |
| 37 | 25 | import { studioCss } from "./style.ts"; |
| 38 | import { runWord } from "./words.ts"; | |
| 39 | 26 | |
| 40 | 27 | /** |
| 41 | * The home in the studio: what asks you first, then every app as one row | |
| 42 | * that carries a real signal — live or not, what the crew last did there, | |
| 43 | * how many things wait on you — and beside it what is happening right now | |
| 44 | * across all of them, and what a person put on you. Built from the same | |
| 45 | * rows and cards as the rest of the forge, so it reads as the forge. | |
| 28 | * The home in the studio: what asks you first, then your apps as a shelf of | |
| 29 | * rows — and beside it what is happening right now across all of them, and | |
| 30 | * what a person put on you. Built from the same rows and cards as the rest | |
| 31 | * of the forge, so it reads as the forge. | |
| 46 | 32 | */ |
| 47 | 33 | export async function studioHome(ctx: Ctx, user: User): Promise<Response> { |
| 48 | 34 | const db = ctx.app.db; |
| 49 | const { repos: appRepos, records } = appsOf(ctx, user); | |
| 50 | const runtime = appsRuntime(ctx.app); | |
| 51 | // A public app you can only read appears here too, but who is on its | |
| 52 | // crew is its owner's fact, not yours to see — same predicate as the | |
| 53 | // app's own page. | |
| 54 | const ownerFacts = new Set( | |
| 55 | appRepos.filter((r) => canSeeOwnerFacts(db, r, user)).map((r) => r.id), | |
| 56 | ); | |
| 57 | // One read for the newest run of every app, one for who ran them: the | |
| 58 | // home must not get slower as the apps grow. | |
| 59 | const latestRuns = latestAgentRunsForRepos( | |
| 60 | db, | |
| 61 | appRepos.map((r) => r.id), | |
| 62 | ); | |
| 63 | const actors = getUsersByIds( | |
| 64 | db, | |
| 65 | [...latestRuns.values()] | |
| 66 | .map((r) => r.actorId) | |
| 67 | .filter((id): id is string => id !== null), | |
| 68 | ); | |
| 35 | const { repos: appRepos } = appsOf(ctx, user); | |
| 36 | // Read once, shown twice: the panel below is these same asks, and the | |
| 37 | // shelf counts them into each row's pill. | |
| 69 | 38 | const decisions = withLikely(ctx, decisionsFor(ctx, appRepos)); |
| 70 | const asksByRepo = new Map<string, number>(); | |
| 71 | for (const d of decisions) | |
| 72 | asksByRepo.set(d.repo.id, (asksByRepo.get(d.repo.id) ?? 0) + 1); | |
| 73 | ||
| 74 | const appRows = await Promise.all(appRepos.map(async (repo) => { | |
| 75 | const record = records.get(repo.id)!; | |
| 76 | const envs = runtime === undefined ? [] : runtime.status(record); | |
| 77 | const production = envs.find((e) => e.env === "production"); | |
| 78 | const state = production?.state ?? "stopped"; | |
| 79 | const latest = latestRuns.get(repo.id); | |
| 80 | const word = | |
| 81 | latest === undefined | |
| 82 | ? undefined | |
| 83 | : await runWord( | |
| 84 | { | |
| 85 | db, | |
| 86 | dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), | |
| 87 | runtime, | |
| 88 | record, | |
| 89 | }, | |
| 90 | latest, | |
| 91 | ); | |
| 92 | const who = | |
| 93 | latest?.actorId == null || !ownerFacts.has(repo.id) | |
| 94 | ? undefined | |
| 95 | : actors.get(latest.actorId)?.username; | |
| 96 | const asks = asksByRepo.get(repo.id) ?? 0; | |
| 97 | const tone: Tone = | |
| 98 | state === "running" | |
| 99 | ? "success" | |
| 100 | : state === "failed" | |
| 101 | ? "danger" | |
| 102 | : "default"; | |
| 103 | const happening = | |
| 104 | latest === undefined || word === undefined | |
| 105 | ? html`<span class="muted">quiet — tell the crew something</span>` | |
| 106 | : html`<span class="studio-word" data-word="${word.word}" | |
| 107 | >${word.word}</span | |
| 108 | > | |
| 109 | ${who ? html`· ${who}` : null} | |
| 110 | ${word.word === "working" | |
| 111 | ? html`· now` | |
| 112 | : html`· ${time(latest.finishedAt ?? latest.createdAt)}`}`; | |
| 113 | return row({ | |
| 114 | href: repoBase(repo), | |
| 115 | nav: true, | |
| 116 | lead: dot(tone), | |
| 117 | title: repo.name, | |
| 118 | titleAttr: repo.description || repo.name, | |
| 119 | meta: html`${repo.description | |
| 120 | ? html`${repo.description} · ` | |
| 121 | : null}${happening}`, | |
| 122 | trail: html`${asks > 0 ? pill(`${asks} asks you`, "accent") : null} | |
| 123 | <span class="badge" data-tone="${tone}" | |
| 124 | >${state === "running" | |
| 125 | ? "live" | |
| 126 | : state === "stopped" | |
| 127 | ? "not running" | |
| 128 | : state}</span | |
| 129 | >`, | |
| 130 | }); | |
| 131 | })); | |
| 39 | const appRows = await shelfRows(ctx, appRepos, { decisions }); | |
| 132 | 40 | |
| 133 | 41 | const apps = |
| 134 | 42 | appRows.length === 0 |
| @@ -158,6 +66,16 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 158 | 66 | running.map((r) => r.actorId).filter((id): id is string => id !== null), |
| 159 | 67 | ); |
| 160 | 68 | const byId = new Map(appRepos.map((r) => [r.id, r])); |
| 69 | // Who is working is the owner's fact, not a stranger's to read — the same | |
| 70 | // predicate the shelf row uses, asked only of the apps with a run in | |
| 71 | // flight. | |
| 72 | const ownerFacts = new Set( | |
| 73 | [...new Set(running.map((r) => r.repoId))] | |
| 74 | .map((id) => byId.get(id)) | |
| 75 | .filter((r): r is Repo => r !== undefined) | |
| 76 | .filter((r) => canSeeOwnerFacts(db, r, user)) | |
| 77 | .map((r) => r.id), | |
| 78 | ); | |
| 161 | 79 | // The board: the same runs as a wall for a second monitor. |
| 162 | 80 | const nowHead = html`${icon("activity")} |
| 163 | 81 | <h2>Happening now</h2> |
src/web/studio/shelf.ts added +137
| @@ -0,0 +1,137 @@ | ||
| 1 | /** | |
| 2 | * An app as one row on a shelf: its name and the one line under it, a dot | |
| 3 | * and a badge for whether it is live, what the crew last did there and when, | |
| 4 | * a pill for how many things it asks of you, and the way in — the row the | |
| 5 | * studio's home draws its *Your apps* card from, and the one every other | |
| 6 | * page that lists apps draws too, so an app looks the same wherever it is | |
| 7 | * shelved. Hand over repositories, get rows; nothing here knows what card | |
| 8 | * they land in. | |
| 9 | * | |
| 10 | * Every read is made once for the whole shelf and never once per row: the | |
| 11 | * app records in one query, the newest run of every app in one, who ran them | |
| 12 | * in one, the asks in one pass. The words are gathered together because | |
| 13 | * `runWord` reads git. A caller that has already read what asks the viewer — | |
| 14 | * the home draws its asks panel from the same list — hands that list in | |
| 15 | * rather than paying for it twice. | |
| 16 | */ | |
| 17 | ||
| 18 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 19 | import { appsForRepos } from "../../core/apps.ts"; | |
| 20 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; | |
| 21 | import { latestAgentRunsForRepos } from "../../core/runs.ts"; | |
| 22 | import { getUsersByIds } from "../../core/users.ts"; | |
| 23 | import { repoDir } from "../../git/repos.ts"; | |
| 24 | import type { Ctx } from "../../http/context.ts"; | |
| 25 | import { repoBase } from "../chrome.ts"; | |
| 26 | import { html, type Html } from "../html.ts"; | |
| 27 | import { dot, pill, row, time, type Tone } from "../ui.ts"; | |
| 28 | import { decisionsFor, type Decision } from "./asks.ts"; | |
| 29 | import { withLikely } from "./leans.ts"; | |
| 30 | import { runWord } from "./words.ts"; | |
| 31 | ||
| 32 | /** Reads the caller has already made, so the shelf does not make them again. */ | |
| 33 | export interface ShelfReads { | |
| 34 | /** What asks the viewer across these apps — the asks pill counts them. */ | |
| 35 | readonly decisions?: readonly Decision[] | undefined; | |
| 36 | } | |
| 37 | ||
| 38 | /** | |
| 39 | * These repositories as shelf rows, in the order they were handed in. | |
| 40 | */ | |
| 41 | export async function shelfRows( | |
| 42 | ctx: Ctx, | |
| 43 | repos: readonly Repo[], | |
| 44 | reads: ShelfReads = {}, | |
| 45 | ): Promise<Html[]> { | |
| 46 | const db = ctx.app.db; | |
| 47 | const user = ctx.auth.user; | |
| 48 | const runtime = appsRuntime(ctx.app); | |
| 49 | const records = appsForRepos( | |
| 50 | db, | |
| 51 | repos.map((r) => r.id), | |
| 52 | ); | |
| 53 | // A public app you can only read appears here too, but who is on its | |
| 54 | // crew is its owner's fact, not yours to see — same predicate as the | |
| 55 | // app's own page. | |
| 56 | const ownerFacts = new Set( | |
| 57 | repos.filter((r) => canSeeOwnerFacts(db, r, user)).map((r) => r.id), | |
| 58 | ); | |
| 59 | // One read for the newest run of every app, one for who ran them: the | |
| 60 | // shelf must not get slower as the apps grow. | |
| 61 | const latestRuns = latestAgentRunsForRepos( | |
| 62 | db, | |
| 63 | repos.map((r) => r.id), | |
| 64 | ); | |
| 65 | const actors = getUsersByIds( | |
| 66 | db, | |
| 67 | [...latestRuns.values()] | |
| 68 | .map((r) => r.actorId) | |
| 69 | .filter((id): id is string => id !== null), | |
| 70 | ); | |
| 71 | const decisions = reads.decisions ?? withLikely(ctx, decisionsFor(ctx, repos)); | |
| 72 | const asksByRepo = new Map<string, number>(); | |
| 73 | for (const d of decisions) | |
| 74 | asksByRepo.set(d.repo.id, (asksByRepo.get(d.repo.id) ?? 0) + 1); | |
| 75 | ||
| 76 | return await Promise.all(repos.map(async (repo) => { | |
| 77 | const record = records.get(repo.id); | |
| 78 | const envs = | |
| 79 | runtime === undefined || record === undefined | |
| 80 | ? [] | |
| 81 | : runtime.status(record); | |
| 82 | const production = envs.find((e) => e.env === "production"); | |
| 83 | const state = production?.state ?? "stopped"; | |
| 84 | const latest = latestRuns.get(repo.id); | |
| 85 | const word = | |
| 86 | latest === undefined | |
| 87 | ? undefined | |
| 88 | : await runWord( | |
| 89 | { | |
| 90 | db, | |
| 91 | dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), | |
| 92 | runtime, | |
| 93 | record, | |
| 94 | }, | |
| 95 | latest, | |
| 96 | ); | |
| 97 | const who = | |
| 98 | latest?.actorId == null || !ownerFacts.has(repo.id) | |
| 99 | ? undefined | |
| 100 | : actors.get(latest.actorId)?.username; | |
| 101 | const asks = asksByRepo.get(repo.id) ?? 0; | |
| 102 | const tone: Tone = | |
| 103 | state === "running" | |
| 104 | ? "success" | |
| 105 | : state === "failed" | |
| 106 | ? "danger" | |
| 107 | : "default"; | |
| 108 | const happening = | |
| 109 | latest === undefined || word === undefined | |
| 110 | ? html`<span class="muted">quiet — tell the crew something</span>` | |
| 111 | : html`<span class="studio-word" data-word="${word.word}" | |
| 112 | >${word.word}</span | |
| 113 | > | |
| 114 | ${who ? html`· ${who}` : null} | |
| 115 | ${word.word === "working" | |
| 116 | ? html`· now` | |
| 117 | : html`· ${time(latest.finishedAt ?? latest.createdAt)}`}`; | |
| 118 | return row({ | |
| 119 | href: repoBase(repo), | |
| 120 | nav: true, | |
| 121 | lead: dot(tone), | |
| 122 | title: repo.name, | |
| 123 | titleAttr: repo.description || repo.name, | |
| 124 | meta: html`${repo.description | |
| 125 | ? html`${repo.description} · ` | |
| 126 | : null}${happening}`, | |
| 127 | trail: html`${asks > 0 ? pill(`${asks} asks you`, "accent") : null} | |
| 128 | <span class="badge" data-tone="${tone}" | |
| 129 | >${state === "running" | |
| 130 | ? "live" | |
| 131 | : state === "stopped" | |
| 132 | ? "not running" | |
| 133 | : state}</span | |
| 134 | >`, | |
| 135 | }); | |
| 136 | })); | |
| 137 | } |
test/studio.test.ts +20
| @@ -187,6 +187,26 @@ describe("the studio", () => { | ||
| 187 | 187 | expect(bad.headers.get("set-cookie")).toContain("forge_mode=;"); |
| 188 | 188 | }); |
| 189 | 189 | |
| 190 | /** | |
| 191 | * The shelf row, byte for byte: whoever draws it (`studio/shelf.ts`) and | |
| 192 | * from wherever, an app on a shelf is a dot, its name, its one line, what | |
| 193 | * the crew last did there, the badge for whether it is live, and a link | |
| 194 | * into it. This pins the rendering so a move of the code cannot quietly | |
| 195 | * move the page. | |
| 196 | */ | |
| 197 | test("an app is one row on the shelf: a dot, its name and line, what the crew last did, the way in", async () => { | |
| 198 | const home = await (await get("/")).text(); | |
| 199 | const at = home.indexOf('href="/alice/notes"'); | |
| 200 | expect(at).toBeGreaterThan(-1); | |
| 201 | const start = home.lastIndexOf("<li>", at); | |
| 202 | const shelfRow = home.slice(start, home.indexOf("</li>", at) + 5); | |
| 203 | // The bytes the home rendered before the row moved into its own | |
| 204 | // module, escaped so no formatter can reflow them. | |
| 205 | expect(shelfRow).toBe( | |
| 206 | "<li>\n <a href=\"/alice/notes\" class=\"row\"\n \n data-nav tabindex=\"-1\"><span class=\"row-lead\"><span\n class=\"dot\"\n \n ></span></span>\n <span class=\"row-main\">\n <span\n class=\"row-title\"\n title=\"a place for notes\"\n >notes</span\n >\n <span class=\"row-meta\">a place for notes · <span class=\"muted\">quiet — tell the crew something</span></span>\n </span>\n <span class=\"row-trail\">\n <span class=\"badge\" data-tone=\"default\"\n >not running</span\n ></span></a>\n </li>", | |
| 207 | ); | |
| 208 | }); | |
| 209 | ||
| 190 | 210 | test("an app's home is the app in the studio, and the code in dev", async () => { |
| 191 | 211 | const studio = await (await get("/alice/notes")).text(); |
| 192 | 212 | expect(studio).toContain("Tell the crew"); |
new above
offline