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

The studio's app row is one function in its own module #121

Merged opened this

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

Merged

mason/forge:mason/111-the-studio-s-app-row-is-one-function-in → main 1 commit fork

  • Merged. mason/111-the-studio-s-app-row-is-one-function-in was merged into main as fe5dbd2.

Review

Sign in to review this change.
5 files changed +193 −114
CLAUDE.md +3 −2
@@ -120,8 +120,9 @@ src/
120120 pages/{site,repo,issues,pulls,pull,thread,commits,refs,search,
121121 org,account,settings}, and studio/ — the app-as-the-unit
122122 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)
125126 ssh/ the SSH server: transport (kex/ciphers), auth (publickey),
126127 channels (session only), keys, wire — zero deps, no forwarding
127128 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.
234234
235235 `/` in the studio: what asks you across every app you can land, what is on
236236 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
239242 the toggle in the top bar — it switches this browser to dev from then on;
240243 `/?mode=studio` switches it back, the same way.
241244
src/web/studio/home.ts +28 −110
@@ -1,134 +1,42 @@
11 /**
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.
88 */
99
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";
1312 import { getUsersByIds, type User } from "../../core/users.ts";
14 import { repoDir } from "../../git/repos.ts";
1513 import type { Ctx } from "../../http/context.ts";
1614 import { repoBase } from "../chrome.ts";
1715 import { asksJs, liveRegionsJs } from "../client.ts";
1816 import { html, when } from "../html.ts";
1917 import { page } from "../layout.ts";
2018 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";
3320 import { onYouCard } from "../pages/site.ts";
3421 import { appsOf, asksPanel, decisionsFor } from "./asks.ts";
3522 import { withLikely } from "./leans.ts";
3623 import { stopForm } from "./run.ts";
24 import { shelfRows } from "./shelf.ts";
3725 import { studioCss } from "./style.ts";
38 import { runWord } from "./words.ts";
3926
4027 /**
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.
4632 */
4733 export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
4834 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.
6938 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 });
13240
13341 const apps =
13442 appRows.length === 0
@@ -158,6 +66,16 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
15866 running.map((r) => r.actorId).filter((id): id is string => id !== null),
15967 );
16068 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 );
16179 // The board: the same runs as a wall for a second monitor.
16280 const nowHead = html`${icon("activity")}
16381 <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", () => {
187187 expect(bad.headers.get("set-cookie")).toContain("forge_mode=;");
188188 });
189189
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
190210 test("an app's home is the app in the studio, and the code in dev", async () => {
191211 const studio = await (await get("/alice/notes")).text();
192212 expect(studio).toContain("Tell the crew");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs