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

Home is the living pipeline: KPIs, what is moving, what shipped, your apps, and one box to tell the crew #8050

Merged opened this in answer to

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

Merged

scout/forge:scout/8015-home-is-the-living-pipeline-kpis-what-is → main 14 commits fork

  • Merged. scout/8015-home-is-the-living-pipeline-kpis-what-is was merged into main as 595d3e3.

Review

Sign in to review this change.
63 files changed +750 −159
docs/design/areas/home.md added +4
@@ -0,0 +1,4 @@
1 # Home
2
3 - `.moving` — the living home's two lists (Being built, Just shipped), drawn by `movingRegion` in `src/web/studio/living.ts`: a one-column grid on a phone, two equal columns from 700px. Each list always holds three rows, so a quiet list is as tall as a busy one and nothing below it moves.
4 - `.tell-box` — home's one-line box that tells the crew: a `bar` whose text input takes the free width (`flex: 1 1 14rem`) and wraps the app picker and Tell under it on a phone.
docs/design/audit.md +1
@@ -54,6 +54,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
5454 - `/board/returns` — `pages/returns.ts` · what the window's dollars bought · `/settings/subscriptions` spend tables, `/settings/usage` · none seen.
5555 - `/big` — `pages/board.ts` (`bigRedirect`) · a redirect kept for old addresses into the board's wall mode · it is `/board` · none (no page of its own).
5656 - `/boards` — `pages/boards-view.ts` · saved queries as panels · `/board` · none seen.
57 - `/feed` — `studio/home.ts` · the composer and one timeline, what home was before it became the living pipeline (#8015) · `/` · none seen.
5758 - `/boards/data` — `pages/boards.ts` · the boards' query/data surface · `/board` · none seen.
5859 - `/asks` — `studio/asks_page.ts` · what the crew is waiting on me for · `/` "needs me" list · colours 2.
5960 - `/asked` — `studio/asked_page.ts` · what I answered, as a record · `/asks` · none seen.
docs/features/living-home.md added +17
@@ -0,0 +1,17 @@
1 # The living home (#8015)
2
3 Signed in, `/` is five things and nothing else:
4
5 1. **The box** — one line that tells the crew (`POST /tell`, the same action the feed's composer posts to). A phone's share sheet fills it.
6 2. **The figures** (admins) — the KPI tiles and any alarm, one calm line each. Everyone else gets the live version line.
7 3. **Now** (site admins) — `check › review › land › live`, each the sum of the `/boards` stages it names (`nowOf` in `src/flow/moving.ts`), each a link. The counts take in every repository, private ones too, so no one else is shown them.
8 4. **Being built / Just shipped** — three rows each, every row a link to its issue or pull, read for the viewer (a repository they cannot read, and a sealed report, are never named). One live region, `#live-moving`, redrawn from `/live/moving` when a run or a merge moves; it always holds six rows, so nothing below it shifts.
9 5. **Your apps** — a chip per app and a plus; `#live-chips`, redrawn from `/live/chips`.
10
11 The terminal's home carries the same lines (`movingLines` in `src/tui/screens/moving.ts`): Now for a site admin, then being built and just shipped, read for the viewer; its tell is the box.
12
13 The feed — the composer, the timeline, what is on you, since you were here, pinned panels — is `/feed`, linked from home.
14
15 Left out on purpose: the waiting-on-you line, the clog, vitals and keeper lines (the boards and the Flow dialog still show them), and "slowest right now" (it needs a timing read the home does not have). The box posts to `/tell` today; it becomes the `/talk` entry (#7652) when that lands.
16
17 The two scans behind the lists are read once per write (`writeStamp`), so a warm home reads no tables for them.
forge.json +1 −1
@@ -3,7 +3,7 @@
33 "api.anthropic.com"
44 ],
55 "app": {
6 "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/asked", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/contribute", "/resilience", "/settings"],
6 "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/asked", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/contribute", "/resilience", "/settings"],
77 "run": "bun run src/main.ts",
88 "install": "",
99 "health": "/healthz",
src/core/reserved.ts +1
@@ -109,6 +109,7 @@ export const SYSTEM_NAMES: readonly string[] = [
109109 "recover",
110110 "www",
111111 "asked",
112 "feed",
112113 "review",
113114 "claim",
114115 "first-admin",
src/flow/moving.ts added +122
@@ -0,0 +1,122 @@
1 /**
2 * What is moving through the forge, for the home (#8015): the builds running
3 * now, the changes that just shipped, and the pipeline in four words.
4 * Plain data out. Every row is read for a viewer: a repository they cannot
5 * read, and a sealed report, are never named.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { canRead, getReposByIds, type Repo } from "../core/repos.ts";
10 import type { User } from "../core/users.ts";
11 import { writeStamp } from "../core/write_clock.ts";
12 import type { PipelineStage } from "./pipeline.ts";
13
14 /** One line of a list: who, what, where it goes, and when. */
15 export interface MovingRow {
16 readonly who: string;
17 readonly title: string;
18 readonly href: string;
19 readonly at: string;
20 /** `owner/name`, for the row's title attribute. */
21 readonly repo: string;
22 }
23
24 export const MOVING_ROWS = 3;
25 /** Rows read before the viewer's access filters them; bounded, so the read is. */
26 const SCAN = 40;
27
28 interface Raw {
29 repo_id: string;
30 number: number;
31 title: string;
32 sealed: number;
33 who: string | null;
34 at: string;
35 }
36
37 const RAW_MAX_AGE_MS = 30_000;
38 const kept = new WeakMap<Database, Map<string, { stamp: string; at: number; raw: Raw[] }>>();
39
40 /** A scan read once per write: the same rows serve every viewer until anything is written (or 30 s pass). */
41 function scanned(db: Database, key: string, read: () => Raw[], now = Date.now()): Raw[] {
42 const stamp = writeStamp(db);
43 if (stamp === undefined) return read();
44 let byKey = kept.get(db);
45 if (byKey === undefined) kept.set(db, (byKey = new Map()));
46 const had = byKey.get(key);
47 if (had !== undefined && had.stamp === stamp && now - had.at < RAW_MAX_AGE_MS) return had.raw;
48 const raw = read();
49 byKey.set(key, { stamp, at: now, raw });
50 return raw;
51 }
52
53 function visible(db: Database, viewer: User, raw: readonly Raw[], path: string, limit: number): MovingRow[] {
54 const repos = getReposByIds(db, raw.map((r) => r.repo_id));
55 const readable = new Map<string, Repo | undefined>();
56 const out: MovingRow[] = [];
57 for (const r of raw) {
58 if (r.sealed !== 0) continue;
59 if (!readable.has(r.repo_id)) {
60 const repo = repos.get(r.repo_id);
61 readable.set(r.repo_id, repo !== undefined && canRead(db, repo, viewer) ? repo : undefined);
62 }
63 const repo = readable.get(r.repo_id);
64 if (repo === undefined) continue;
65 out.push({
66 who: r.who ?? "",
67 title: r.title,
68 href: `/${repo.ownerName}/${repo.name}/${path}/${r.number}`,
69 at: r.at,
70 repo: `${repo.ownerName}/${repo.name}`,
71 });
72 if (out.length === limit) break;
73 }
74 return out;
75 }
76
77 /** Agent builds running now (reviews are not building), newest first, each on the issue it works. */
78 export function readBuilding(db: Database, viewer: User, limit = MOVING_ROWS): MovingRow[] {
79 const raw = scanned(db, "building", () => db
80 .query<Raw, [number]>(
81 `SELECT r.repo_id, i.number, i.title, i.sealed, u.username AS who, COALESCE(r.started_at, r.created_at) AS at
82 FROM run r JOIN issue i ON i.id = r.issue_id LEFT JOIN user u ON u.id = r.actor_id
83 WHERE r.kind = 'agent' AND r.state = 'running' AND r.reason != 'review'
84 ORDER BY at DESC LIMIT ?1`,
85 )
86 .all(SCAN));
87 return visible(db, viewer, raw, "issues", limit);
88 }
89
90 /** Changes merged most recently, newest first, each linking to its pull. */
91 export function readShipped(db: Database, viewer: User, limit = MOVING_ROWS): MovingRow[] {
92 const raw = scanned(db, "shipped", () => db
93 .query<Raw, [number]>(
94 `SELECT i.repo_id, i.number, i.title, i.sealed, u.username AS who, p.merged_at AS at
95 FROM pull p JOIN issue i ON i.id = p.issue_id LEFT JOIN user u ON u.id = p.merged_by
96 WHERE p.merged_at IS NOT NULL
97 ORDER BY p.merged_at DESC LIMIT ?1`,
98 )
99 .all(SCAN));
100 return visible(db, viewer, raw, "pulls", limit);
101 }
102
103 /** The pipeline in the four words the forge uses; each count is the sum of the `/boards` stages it names. */
104 export interface Now {
105 readonly check: number;
106 readonly review: number;
107 readonly land: number;
108 readonly live: number;
109 }
110
111 export function nowOf(stages: readonly PipelineStage[]): Now {
112 const n = (key: PipelineStage["key"]): number => {
113 const s = stages.find((x) => x.key === key);
114 return s === undefined ? 0 : s.waiting + s.active;
115 };
116 return {
117 check: n("check-queue") + n("checking"),
118 review: n("review"),
119 land: n("approved"),
120 live: n("live"),
121 };
122 }
src/http/server.ts +5
@@ -288,6 +288,7 @@ import {
288288 import { keeperAnswer } from "../web/studio/keeper_row.ts";
289289 import { homeArchive, homeArchived, homeArchiveUndo } from "../web/studio/home_archive.ts";
290290 import { liveFeedFragment } from "../web/studio/feed.ts";
291 import { liveChipsFragment, liveMovingFragment } from "../web/studio/living.ts";
291292 import { HOME_REGIONS } from "../web/studio/home_last.ts";
292293 import { serviceWorker } from "../web/sw.ts";
293294 import { explorePage } from "../web/studio/explore.ts";
@@ -297,6 +298,7 @@ import {
297298 } from "../web/studio/make_it_yours.ts";
298299 import {
299300 frontUpdate,
301 feedPage,
300302 liveAppsFragment,
301303 liveClientAppsFragment,
302304 liveHomeFragment,
@@ -853,6 +855,9 @@ export function buildRouter(): Router<Handler> {
853855 router.add("GET", "/live/on-you", liveOnYouFragment);
854856 router.add("GET", "/live/client-apps", liveClientAppsFragment);
855857 router.add("GET", "/live/feed", liveFeedFragment);
858 router.add("GET", "/live/moving", liveMovingFragment);
859 router.add("GET", "/live/chips", liveChipsFragment);
860 router.add("GET", "/feed", feedPage);
856861 // The home's late regions, each drawn alone (#7660).
857862 for (const name of HOME_REGIONS) router.add("GET", `/live/home/${name}`, (ctx) => liveHomeFragment(ctx, name));
858863 // Whether a hostname is an app's verified domain — what Caddy's
src/tui/screens/dashboard.ts +12
@@ -28,6 +28,8 @@ import { agentsLine } from "../../web/studio/client_home.ts";
2828 import { lastVisit } from "../../core/home_visit.ts";
2929 import { sinceDigest } from "../../web/studio/since.ts";
3030 import { askForSomething } from "./tell.ts";
31 import { movingLines } from "./moving.ts";
32 import { pipelineNow } from "../../web/studio/living.ts";
3133 import { askForEveryday, everydayOf, EVERYDAY_WHY, keepAdmin } from "../../core/everyday.ts";
3234 import { mayView } from "../../core/sealed.ts";
3335 import { applyFleetIds, previewFleetIds, undoFleetOp } from "../../actions/fleet.ts";
@@ -199,6 +201,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
199201 let clientLines: string[] = [];
200202 /** "Since you were here" and its lines: read from the web home's last-visit row, never written here. */
201203 let sinceLines: string[] = [];
204 /** The web home's living pipeline (#8015): Now for a site admin, then being built and just shipped. */
205 let moving: string[] = [];
202206 /** The ids of those apps, so the key that asks for something stays on them. */
203207 let clientRepoIds = new Set<string>();
204208 /** The live address of each of those apps that is running, by repo id; `o` shows it. */
@@ -370,6 +374,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
370374 lane = forgeLane(f.app, f.user);
371375 flaky = forgeFlakes(f.app, f.user);
372376 decider = deciderLine(f.app, f.user);
377 moving = f.user ? movingLines(f.db, f.user, f.user.isAdmin ? pipelineNow(f.db) : undefined) : [];
373378 const commit = running().commit;
374379 const cleared = lastClearedNote(f.db, now);
375380 figures =
@@ -1033,6 +1038,13 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
10331038 );
10341039 body = rest!;
10351040 }
1041 if (moving.length > 0) {
1042 const [strip, rest] = split(body, "v", [len(moving.length), fill(1)]);
1043 moving.forEach((line, i) =>
1044 s.text(strip!.x, strip!.y + i, truncate(line, strip!.w), style(c.mutedFg, c.bg), strip!.x + strip!.w),
1045 );
1046 body = rest!;
1047 }
10361048 // A short terminal drops the last figures before it drops the list.
10371049 const shown = figures.slice(0, Math.max(0, body.h - FIGURES_ROOM));
10381050 if (shown.length > 0) {
src/tui/screens/moving.ts added +23
@@ -0,0 +1,23 @@
1 /**
2 * The terminal home's living pipeline (#8015), the web home's in lines: the
3 * pipeline in four words for a site admin, then what is being built and what
4 * just shipped, each filtered to what the viewer reads by the same reads the
5 * web uses. The tell box is the home's own tell, already there.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import type { User } from "../../core/users.ts";
10 import { readBuilding, readShipped, type MovingRow, type Now } from "../../flow/moving.ts";
11 import { plain } from "./boards.ts";
12
13 const listLine = (label: string, rows: readonly MovingRow[], empty: string): string =>
14 `${label} ${rows.length === 0 ? empty : rows.map((r) => `${plain(r.title)} (${plain(r.repo)})`).join(" · ")}`;
15
16 /** `now` is passed only for a site admin: the counts take in every repository, private ones too. */
17 export function movingLines(db: Database, viewer: User, now: Now | undefined): string[] {
18 return [
19 ...(now === undefined ? [] : [`Now check ${now.check} › review ${now.review} › land ${now.land} › ${now.live} live`]),
20 listLine("Being built", readBuilding(db, viewer), "nothing right now"),
21 listLine("Just shipped", readShipped(db, viewer), "nothing yet"),
22 ];
23 }
src/web/studio/actions.ts +6 −4
@@ -172,14 +172,16 @@ export async function composerSubmit(ctx: Ctx): Promise<Response> {
172172 const form = await readForm(ctx, "rw");
173173 if (form instanceof Response) return form;
174174 const target = field(form, "app");
175 // The feed's composer asks to come back to the feed; any other box, to home.
176 const home = backTo(form, "/");
175177 // "the forge" is no app: the words are kept as a thought, exactly as typed.
176178 if (target === FORGE_TARGET) {
177179 if (field(form, "text").trim() === "")
178 return flashOrJson(ctx, "/", { tone: "danger", message: SAY_SOMETHING_WORDS });
180 return flashOrJson(ctx, home, { tone: "danger", message: SAY_SOMETHING_WORDS });
179181 const kept = keepThought(ctx.app, ctx.auth.user, field(form, "text"), "composer");
180182 return flashOrJson(
181183 ctx,
182 "/",
184 home,
183185 kept.ok
184186 ? { message: "Received." }
185187 : {
@@ -187,13 +189,13 @@ export async function composerSubmit(ctx: Ctx): Promise<Response> {
187189 message:
188190 kept.error === "too_long" ? "That is too long to keep." : "That could not be kept.",
189191 },
190 { href: "/" },
192 { href: home },
191193 );
192194 }
193195 const [owner = "", name = "", ...extra] = target.split("/");
194196 const repo = extra.length === 0 ? getRepo(ctx.app.db, owner, name) : undefined;
195197 if (!repo || !canRead(ctx.app.db, repo, ctx.auth.user)) return notFoundPage(ctx);
196 return tellFrom(ctx, repo, form, "/");
198 return tellFrom(ctx, repo, form, home);
197199 }
198200
199201
src/web/studio/feed.ts +7 −3
@@ -226,6 +226,7 @@ export function composer(ctx: Ctx, user: User): Html {
226226 })}`;
227227 const form = html`<form id="composer" class="card" method="post" action="/tell">
228228 <div class="card-body stack">
229 <input type="hidden" name="back" value="/feed" />
229230 <textarea class="input" name="text" rows="2" maxlength="2000" required placeholder="Tell the crew what should change" aria-label="Tell the crew" data-draft="${user.id}">${sharedDraft(ctx.url.searchParams)}</textarea>
230231 <div class="bar">
231232 ${repos.length === 0
@@ -341,14 +342,14 @@ export function caughtUp(items: readonly FeedItem[], next: string | null, cursor
341342 // Ranked by score, not time: the last card is not always the oldest.
342343 const oldest = items.reduce<string | undefined>((min, item) => (min === undefined || item.at < min ? item.at : min), undefined);
343344 if (oldest === undefined) return null;
344 const newest = cursor === undefined ? html`` : html`<a href="/">Newest</a>`;
345 const newest = cursor === undefined ? html`` : html`<a href="/feed">Newest</a>`;
345346 return next === null
346347 ? html`<p class="bar muted small" data-caught-up="end"><span>That's everything</span><span class="spacer"></span>${newest}</p>`
347348 : html`<p class="bar muted small" data-caught-up="more">
348349 <span>You're caught up to ${time(oldest)}</span>
349350 <span class="spacer"></span>
350351 ${newest}
351 <a href="/?cursor=${encodeURIComponent(next)}&day=${encodeURIComponent(items[items.length - 1]!.at)}">Older posts</a>
352 <a href="/feed?cursor=${encodeURIComponent(next)}&day=${encodeURIComponent(items[items.length - 1]!.at)}">Older posts</a>
352353 </p>`;
353354 }
354355
@@ -403,6 +404,9 @@ export function liveFeedFragment(ctx: Ctx): Response {
403404 return response;
404405 }
405406
407 /** Offline: the worker keeps the last home and the page that shows it is stale (`sw.ts`). */
408 export const registerWorkerJs = `if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(function () {});`;
409
406410 /**
407411 * When a card is hidden, the day divider just above it goes with it, unless
408412 * a later card of that day is still showing — then the divider moves down to
@@ -599,7 +603,7 @@ ${dayKeepJs}
599603 if (navigator.serviceWorker && navigator.serviceWorker.controller) navigator.serviceWorker.controller.postMessage('clear');
600604 }
601605 });
602 if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(function () {});
606 ${registerWorkerJs}
603607 paint();
604608 flush();
605609 })();
src/web/studio/feed_style.ts +7
@@ -12,6 +12,13 @@ export const feedCss = /* css */ `
1212 @media (max-width: 899px) {
1313 #live-apps { display: none; }
1414 }
15 /* Home's living pipeline (#8015): two lists side by side on a desk. Each list always holds three rows, so nothing below moves. */
16 .moving { display: grid; grid-template-columns: 1fr; gap: 12px; }
17 @media (min-width: 700px) {
18 .moving { grid-template-columns: 1fr 1fr; }
19 }
20 .moving .row { min-height: 44px; }
21 .tell-box > input[name="text"] { flex: 1 1 14rem; min-width: 0; }
1522 @media (max-width: 600px) {
1623 .feed .bar .btn { padding: 8px 10px; }
1724 .feed .disclosure > summary { min-height: 44px; display: flex; align-items: center; }
src/web/studio/home.ts +48 −27
@@ -51,10 +51,10 @@ import {
5151 import { writableAppsOf } from "./asks.ts";
5252 import { needsYouSection } from "./asks_page.ts";
5353 import { flowCards } from "./flow_card.ts";
54 import { composer, feedJs, feedNote, feedRegion } from "./feed.ts";
54 import { composer, feedJs, feedNote, feedRegion, registerWorkerJs } from "./feed.ts";
55 import { appChips, movingRegion, tellBox } from "./living.ts";
5556 import { alarmLines, cachedAlarms } from "./alarm_lines.ts";
56 import { keeperRow } from "./keeper_row.ts";
57 import { clogRow, kpiRow, vitalsRow, waitingLine } from "./kpi_row.ts";
57 import { kpiRow, waitingLine } from "./kpi_row.ts";
5858 import { feedCandidateCache } from "../../feed/feed.ts";
5959 import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
6060 import { idleReason } from "../../crew/idle_reason.ts";
@@ -228,7 +228,7 @@ export function liveOnYouFragment(ctx: Ctx): Response {
228228 * How each late region of home is drawn for `user` (#7660): the page serves
229229 * the last drawing from memory and `/live/home/<name>` draws it fresh.
230230 */
231 function homeDraws(ctx: Ctx, user: User): Record<HomeRegion, () => Html | Promise<Html>> {
231 export function homeDraws(ctx: Ctx, user: User): Record<HomeRegion, () => Html | Promise<Html>> {
232232 const { db } = ctx.app;
233233 const request = ctx.request;
234234 return {
@@ -237,8 +237,6 @@ function homeDraws(ctx: Ctx, user: User): Record<HomeRegion, () => Html | Promis
237237 const kpiTime: KpiTimer = (name, fn) => timePhase(request, name, fn);
238238 const kpiOpts = { idle: () => idleReason(ctx.app, new Date()), dataDir: ctx.app.config.dataDir, time: kpiTime, ...(ceilingUsd === null ? {} : { ceilingUsd }) };
239239 return html`${timePhase(request, "kpis", () => kpiRow(db, kpiOpts), () => kpiCache(db))}
240 ${timePhase(request, "vitals", () => vitalsRow(db))} ${clogRow(db)}
241 ${keeperRow(db, true)}
242240 ${alarmLines(cachedAlarms(db, new Date(), ceilingUsd))}`;
243241 },
244242 waiting: () => timePhase(request, "waiting", () => waitingLine(db, user), () => waitingCache(db)),
@@ -316,9 +314,46 @@ export async function studioHome(
316314 }
317315 // The client's first screen names its own phases (`clientApps`).
318316 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
319 const { repos: appRepos } = timePhase(ctx.request, "repos", () => writableAppsOf(ctx, user));
320317 // Render is what is left of the page once the named reads are taken out.
321318 const rendering = performance.now();
319 const access = timePhase(ctx.request, "access", () => accessFingerprint(ctx.app.db, user));
320 const ask = firstSteps ?? everydayCard(ctx, user);
321 const figures = user.isAdmin && steps === undefined;
322 const kpis = figures
323 ? await homeRegion(ctx.app.db, ctx.request, user.id, "kpis", homeVariant(ctx.url), access, homeDraws(ctx, user).kpis)
324 : liveLine(ctx);
325 const body = html`<h1 class="sr-only">Home</h1>
326 ${tellBox(ctx, user)}
327 ${ask} ${address}
328 <section class="stack" aria-label="The forge">${kpis}</section>
329 ${timePhase(ctx.request, "moving", () => movingRegion(ctx.app.db, user))}
330 ${appChips(ctx, user)}
331 ${when(
332 steps,
333 () => html`<p class="small muted" id="lean-home"><a href="/">Just my apps</a></p>`,
334 )}
335 <p class="small muted"><a href="/feed">The feed</a></p>`;
336 const res = page({
337 ctx,
338 title: "Home",
339 body,
340 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${refreshJs}\n${registerWorkerJs}${ask ? `\n${passkeyJs}` : ""}`,
341 });
342 notePhaseRest(ctx.request, "render", rendering, ["kpis", "moving", "access"]);
343 return res;
344 }
345
346 /**
347 * The feed (#8015), kept off home: the composer, then one timeline — what
348 * home was before it became the living pipeline — with your apps and what
349 * is on you beside it on a desk.
350 */
351 export async function feedPage(ctx: Ctx): Promise<Response> {
352 const user = ctx.auth.user;
353 if (!user) return signInFirst(ctx);
354 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
355 const { repos: appRepos } = timePhase(ctx.request, "repos", () => writableAppsOf(ctx, user));
356 const rendering = performance.now();
322357 const visitedAt = Date.now();
323358 const before = timePhase(ctx.request, "visit", () =>
324359 isNavigation(ctx.request) ? noteVisit(ctx.app.db, user.id, visitedAt) : lastVisit(ctx.app.db, user.id),
@@ -328,18 +363,9 @@ export async function studioHome(
328363 const access = timePhase(ctx.request, "access", () => accessFingerprint(ctx.app.db, user));
329364 const regionOf = (name: HomeRegion): Promise<Html> =>
330365 homeRegion(ctx.app.db, ctx.request, user.id, name, homeVariant(ctx.url), access, draws[name]);
331 const ask = firstSteps ?? everydayCard(ctx, user);
332 const figures = user.isAdmin && steps === undefined;
333 const body = html`<h1 class="sr-only">Home</h1>
334 ${ask} ${address} ${figures ? await regionOf("kpis") : liveLine(ctx)}
366 const body = html`<h1 class="sr-only">Feed</h1>
335367 ${await regionOf("waiting")}
336 ${figures ? await regionOf("panels") : null}
337 ${when(
338 steps,
339 () => html`<p class="small muted" id="lean-home">
340 <a href="/">Just my apps</a>
341 </p>`,
342 )}
368 ${user.isAdmin ? await regionOf("panels") : null}
343369 ${since}
344370 ${await regionOf("asked")}
345371 <div class="split" data-rail="wide">
@@ -350,12 +376,7 @@ export async function studioHome(
350376 ${await regionOf("feed")}
351377 </div>
352378 <aside class="stack">
353 ${
354 // The crew's missing key is the owner's to fix, not the first
355 // thing above every post: beside the timeline on a desk, after it
356 // on a phone.
357 user.isAdmin || appRepos.length > 0 ? crewKeyNote(ctx) : null
358 }
379 ${user.isAdmin || appRepos.length > 0 ? crewKeyNote(ctx) : null}
359380 ${appsRegion(ctx, user)}
360381 ${terminalCard(ctx)} ${downloadsCard(ctx, user)}
361382 ${orgsCard(ctx, user)}
@@ -363,11 +384,11 @@ export async function studioHome(
363384 </div>`;
364385 const res = page({
365386 ctx,
366 title: "Home",
387 title: "Feed",
367388 body,
368 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}\n${refreshJs}${ask ? `\n${passkeyJs}` : ""}`,
389 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}\n${refreshJs}`,
369390 });
370 notePhaseRest(ctx.request, "render", rendering, ["kpis", "vitals", "waiting", "panels", "feed", "visit", "since", "access"]);
391 notePhaseRest(ctx.request, "render", rendering, ["waiting", "feed", "asked", "visit", "since", "access", "repos"]);
371392 return res;
372393 }
373394
src/web/studio/home_tabs.ts +1 −1
@@ -27,7 +27,7 @@ export function homeTabs(current: HomeTab, unshipped = 0): Html {
2727 });
2828 return html`<div class="mb">
2929 ${tabs(
30 [item("feed", "/", "Feed"), item("apps", "/apps", "Your apps"), item("asked", "/asked", "Asked", unshipped)],
30 [item("feed", "/feed", "Feed"), item("apps", "/apps", "Your apps"), item("asked", "/asked", "Asked", unshipped)],
3131 "Home",
3232 )}
3333 </div>`;
src/web/studio/kpi_row.ts +1 −17
@@ -11,9 +11,7 @@ import type { Database } from "bun:sqlite";
1111 import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, reactNote, speedWords, spendCeilingWords, spendFull, spendValue, usersValue, visitorsNote, visitorsValue, waitingWords } from "../../flow/kpi_words.ts";
1212 import type { IdleReason } from "../../crew/idle_reason.ts";
1313 import type { KpiTimer, Kpis } from "../../flow/kpis.ts";
14 import { clogLines, readClog } from "../../flow/clog_read.ts";
15 import { vitalsLine } from "../../flow/vitals.ts";
16 import { cachedKpis, cachedVitals, cachedWaiting } from "./home_cache.ts";
14 import { cachedKpis, cachedWaiting } from "./home_cache.ts";
1715 import { escapeHtml, html, raw, type Html } from "../html.ts";
1816 import { BOOTED } from "../../release/gate.ts";
1917 import { running } from "../../kernel/version.ts";
@@ -192,17 +190,3 @@ export function waitingLine(db: Database, user: User, now: Date = new Date()): H
192190 // The fold is a form, which cannot sit in the paragraph: it follows it.
193191 return html`${words === null ? null : html`<p class="small muted break stack stack-tight"><span>${words}</span><a href="/review">Review them</a></p>`}${stale.length === 0 ? null : staleArchive(stale)}`;
194192 }
195
196 /** Home's flow vitals for an admin (#5935): one muted line of counts; the open count links to the boards. */
197 export function vitalsRow(db: Database, now: Date = new Date()): Html {
198 const v = cachedVitals(db, now);
199 const rest = vitalsLine(v).split(" · ").slice(1).join(" · ");
200 return html`<p class="small muted"><a href="/boards">${v.open} open pulls</a> · ${rest}</p>`;
201 }
202
203 /** Home's clog lines for an admin (#6757): the clog standing now and the last cleared note; nothing when there is neither. */
204 export function clogRow(db: Database, now: Date = new Date()): Html {
205 const lines = clogLines(readClog(db, now), now.getTime());
206 if (lines.length === 0) return html``;
207 return html`${lines.map((l) => html`<p class="small muted break">${l}</p>`)}`;
208 }
src/web/studio/living.ts added +138
@@ -0,0 +1,138 @@
1 /**
2 * The home's living pipeline (#8015): the pipeline in four words, what is
3 * being built, what just shipped, and the box that tells the crew. Each part
4 * is plain rows with a link to the thing it names. Being built and just
5 * shipped are one live region (`/live/moving`) that the shared stream
6 * redraws when a run or a merge moves; it always holds the same number of
7 * rows, so nothing below it shifts.
8 */
9
10 import type { Database } from "bun:sqlite";
11 import { MOVING_ROWS, nowOf, readBuilding, readShipped, type MovingRow, type Now } from "../../flow/moving.ts";
12 import { readPipeline } from "../../flow/pipeline.ts";
13 import type { User } from "../../core/users.ts";
14 import type { Ctx } from "../../http/context.ts";
15 import { html, type Html } from "../html.ts";
16 import { fragment } from "../layout.ts";
17 import { btn, listCard, row, timeAgo } from "../ui.ts";
18 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
19 import { writableAppsOf } from "./asks.ts";
20 import { FORGE_TARGET, sharedDraft } from "./feed.ts";
21 import { repoBase } from "../chrome.ts";
22
23 /** The events that move a build or a shipment. */
24 const MOVING_LIVE = "run.started,run.finished,pull.merged,pull.opened,pull.closed,app.serving";
25 const NOW_TTL_MS = 3_000;
26 // One entry per forge, not per viewer: the counts are forge-wide, so only a site admin is ever shown them.
27 const nows = new WeakMap<Database, { at: number; now: Now }>();
28
29 /** Admin only: the caller has checked. The pipeline counts, read at most every few seconds: the same stages `/boards` draws. */
30 export function pipelineNow(db: Database, at = Date.now()): Now {
31 const kept = nows.get(db);
32 if (kept !== undefined && at - kept.at < NOW_TTL_MS) return kept.now;
33 const now = nowOf(readPipeline(db, new Date(at)));
34 nows.set(db, { at, now });
35 return now;
36 }
37
38 /** `check 10 › review 1 › land 3 › live`: each word links to the boards where the same stage is. */
39 export function nowLine(now: Now, boards = true): Html {
40 // An agent admin has no boards to open: the word is then plain.
41 const part = (word: string, n: number) =>
42 boards ? html`<a href="/boards"><strong>${String(n)}</strong> ${word}</a>` : html`<span><strong>${String(n)}</strong> ${word}</span>`;
43 return html`<section aria-label="Now">
44 <h2 class="small muted">Now</h2>
45 <p class="bar" id="now-line">
46 ${part("check", now.check)} <span class="muted" aria-hidden="true">›</span>
47 ${part("review", now.review)} <span class="muted" aria-hidden="true">›</span>
48 ${part("land", now.land)} <span class="muted" aria-hidden="true">›</span>
49 <a href="/releases"><strong>${String(now.live)}</strong> live</a>
50 </p>
51 </section>`;
52 }
53
54 function listOf(label: string, rows: readonly MovingRow[], empty: string): Html {
55 const shown = rows.map((r) =>
56 row({
57 href: r.href,
58 ...(r.who === "" ? {} : { lead: html`<span class="muted small">${r.who}</span>` }),
59 title: html`<span class="truncate" title="${r.repo}">${r.title}</span>`,
60 trail: html`<span class="muted small">${timeAgo(r.at)}</span>`,
61 }),
62 );
63 // Always MOVING_ROWS rows: a quiet slot is as tall as a busy one.
64 for (let i = shown.length; i < MOVING_ROWS; i++)
65 shown.push(row({ title: html`<span class="muted small">${i === 0 ? empty : " "}</span>` }));
66 return html`<section aria-label="${label}">
67 <h2 class="small muted">${label}</h2>
68 ${listCard(shown)}
69 </section>`;
70 }
71
72 /**
73 * The pipeline, then being built and just shipped side by side on a desk and
74 * stacked on a phone; one live region. The pipeline counts every repository,
75 * private ones too, so only a site admin, who reads them all, sees the line.
76 */
77 export function movingRegion(db: Database, viewer: User): Html {
78 const building = readBuilding(db, viewer);
79 const shipped = readShipped(db, viewer);
80 return html`<div id="live-moving" class="stack" data-live="${MOVING_LIVE}" data-live-src="/live/moving">
81 ${viewer.isAdmin ? nowLine(pipelineNow(db), viewer.kind !== "agent") : null}
82 <div class="moving">
83 ${listOf("Being built", building, "Nothing is being built right now")}
84 ${listOf("Just shipped", shipped, "Nothing has shipped yet")}
85 </div>
86 </div>`;
87 }
88
89 // GET /live/moving — the two lists alone, for the region that shows them.
90 export function liveMovingFragment(ctx: Ctx): Response {
91 const user = ctx.auth.user;
92 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
93 if (!user) {
94 notePhase(ctx.request, "render", 0);
95 return fragment(html``, { status: 404 });
96 }
97 const body = timePhase(ctx.request, "moving", () => movingRegion(ctx.app.db, user));
98 notePhase(ctx.request, "render", 0);
99 return fragment(body, { request: ctx.request });
100 }
101
102 /** The box that tells the crew: one line, the app it goes to, Tell. Posts to `/tell` as the composer does. */
103 export function tellBox(ctx: Ctx, user: User): Html {
104 const { repos } = writableAppsOf(ctx, user);
105 const slug = (r: { ownerName: string; name: string }) => `${r.ownerName}/${r.name}`;
106 return html`<form id="tell-box" class="bar tell-box" method="post" action="/tell">
107 <input class="input" name="text" maxlength="2000" required placeholder="Tell the crew…" aria-label="Tell the crew" data-draft="${user.id}" value="${sharedDraft(ctx.url.searchParams).replace(/\s+/g, " ")}" />
108 ${repos.length === 0
109 ? html`<input type="hidden" name="app" value="${FORGE_TARGET}" />`
110 : html`<select name="app" class="input" aria-label="Send to">
111 ${repos.map((r) => html`<option value="${slug(r)}">${slug(r)}</option>`)}
112 <option value="${FORGE_TARGET}">the forge</option>
113 </select>`}
114 ${btn("Tell", { variant: "primary" })}
115 </form>`;
116 }
117
118 /** Your apps as chips, each a link to the app, and a plus for a new one. */
119 export function appChips(ctx: Ctx, user: User): Html {
120 const { repos } = writableAppsOf(ctx, user);
121 return html`<section aria-label="Your apps" id="live-chips" data-live="app.serving,app.stopped,app.created,app.deleted" data-live-src="/live/chips">
122 <h2 class="small muted">Your apps</h2>
123 <div class="bar">
124 ${repos.slice(0, 12).map((r) => btn(r.name, { href: repoBase(r), size: "sm" }))}
125 ${btn("New app", { href: "/apps/new", size: "sm", icon: "plus" })}
126 ${repos.length > 12 ? btn(`All ${repos.length}`, { href: "/apps", size: "sm" }) : null}
127 </div>
128 </section>`;
129 }
130
131 // GET /live/chips — the chips alone.
132 export function liveChipsFragment(ctx: Ctx): Response {
133 const user = ctx.auth.user;
134 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
135 notePhase(ctx.request, "render", 0);
136 if (!user) return fragment(html``, { status: 404 });
137 return fragment(appChips(ctx, user), { request: ctx.request });
138 }
test/asked-tab-count.test.ts +3 −3
@@ -72,7 +72,7 @@ test("homeTabs reads plain Asked at 0 or when not given, and the count otherwise
7272
7373 test("with no asks the Asked tab is plain on all three pages", async () => {
7474 forgetHome(app.db);
75 for (const path of ["/?all=1", "/apps", "/asked"]) expect(await askedTab(path)).toBe("Asked");
75 for (const path of ["/feed?all=1", "/apps", "/asked"]) expect(await askedTab(path)).toBe("Asked");
7676 });
7777
7878 test("three unshipped asks read Asked 3 on `/`, `/apps` and `/asked`", async () => {
@@ -81,8 +81,8 @@ test("three unshipped asks read Asked 3 on `/`, `/apps` and `/asked`", async ()
8181 if (!ask.ok) throw new Error(ask.error);
8282 }
8383 forgetHome(app.db);
84 for (const path of ["/?all=1", "/apps", "/asked"]) expect(await askedTab(path)).toBe("Asked 3");
85 const res = await fetch(`${servers.url}/?all=1`, { headers: { cookie } });
84 for (const path of ["/feed?all=1", "/apps", "/asked"]) expect(await askedTab(path)).toBe("Asked 3");
85 const res = await fetch(`${servers.url}/feed?all=1`, { headers: { cookie } });
8686 await res.arrayBuffer();
8787 expect(res.headers.get("server-timing") ?? "").toContain("asked;");
8888 });
test/asked.test.ts +1 −1
@@ -279,7 +279,7 @@ describe("what you asked for", () => {
279279 expect(page.indexOf("Fix the gate")).toBeLessThan(page.indexOf("Plant tomatoes"));
280280 // Reached from the board and the feed's header, not only the asks page.
281281 expect(await (await fetch(`${servers.url}/board`, { headers: { cookie } })).text()).toContain('href="/asked"');
282 expect(await (await fetch(`${servers.url}/`, { headers: { cookie } })).text()).toContain('href="/asked"');
282 expect(await (await fetch(`${servers.url}/feed`, { headers: { cookie } })).text()).toContain('href="/asked"');
283283 const flagged = (
284284 await (
285285 await fetch(`${servers.url}/asked?show=flagged`, {
test/audit.test.ts +1 −1
@@ -432,7 +432,7 @@ describe("the audit reads every app's policy from git", () => {
432432
433433 test("the way in: the studio home links the audit, and an app's page says its policy in one line", async () => {
434434 const home = await (
435 await fetch(`${servers.url}/`, {
435 await fetch(`${servers.url}/feed`, {
436436 headers: { cookie: `${cookie}; forge_mode=studio` },
437437 })
438438 ).text();
test/board-pins.test.ts +1 −1
@@ -26,7 +26,7 @@ const post = (path: string, fields: Record<string, string>, who = "alice", heade
2626 headers: { authorization: `Bearer ${tok[who]}`, ...headers },
2727 });
2828 const home = async (who: string) =>
29 (await fetch(servers.url + "/", { headers: { authorization: `Bearer ${tok[who]}` } })).text();
29 (await fetch(servers.url + "/feed", { headers: { authorization: `Bearer ${tok[who]}` } })).text();
3030 const titles = (h: string) => [...h.matchAll(/<h2>(Pin [ABC])<\/h2>/g)].map((m) => m[1]);
3131
3232 beforeAll(async () => {
test/client-home.test.ts +2 −2
@@ -176,7 +176,7 @@ describe("a client's first screen", () => {
176176 expect(apps).toContain('href="/marias/bakery"');
177177 expect(apps).toMatch(/data-agents="(working|waiting)"/);
178178 expect(apps).toContain("agents are waiting");
179 expect(home).not.toContain('id="live-apps"');
179 expect(home).not.toContain('id="live-chips"');
180180 for (const gone of ['href="/map"', 'href="/board"', 'href="/audit"', 'href="/settings"'])
181181 expect(home).not.toContain(gone);
182182 expect(home).toContain('href="/?all=1"');
@@ -359,7 +359,7 @@ describe("a client's first screen", () => {
359359
360360 test("asked for, the whole home is still there", async () => {
361361 const home = await page(owner, "/?all=1");
362 expect(home).toContain('id="live-apps"');
362 expect(home).toContain('id="live-chips"');
363363 expect(home).not.toContain('id="client-apps"');
364364 });
365365
test/composer-draft.test.ts +21 −4
@@ -47,7 +47,7 @@ beforeAll(async () => {
4747 afterAll(() => servers.stop());
4848
4949 test("the composer names its user for the draft key, and the script stays small", async () => {
50 const res = await fetch(`${servers.url}/`, { headers: { cookie: `forge_session=${cookie}; forge_mode=studio` } });
50 const res = await fetch(`${servers.url}/feed`, { headers: { cookie: `forge_session=${cookie}; forge_mode=studio` } });
5151 const page = await res.text();
5252 expect(page).toContain(`data-draft="${alice.id}"`);
5353 expect(feedJs).toContain("forgeDraft(");
@@ -153,7 +153,7 @@ test.skipIf(noChromium)("in a real browser: type, reload, find it; send, reload,
153153 const load = async () => {
154154 const loaded = once("Page.loadEventFired");
155155 loaded.catch(() => {});
156 await send("Page.navigate", { url: `${servers.url}/` });
156 await send("Page.navigate", { url: `${servers.url}/feed` });
157157 await loaded;
158158 await waitFor(`!!document.getElementById("composer")`);
159159 };
@@ -176,7 +176,7 @@ test.skipIf(noChromium)("in a real browser: type, reload, find it; send, reload,
176176
177177 await load();
178178 await type("Water the tomatoes at dawn");
179 await waitFor(`localStorage.getItem(${JSON.stringify(`draft.${alice.id}/`)}) === "Water the tomatoes at dawn"`);
179 await waitFor(`localStorage.getItem(${JSON.stringify(`draft.${alice.id}/feed`)}) === "Water the tomatoes at dawn"`);
180180 await load();
181181 expect(await run(`${box}.value`)).toBe("Water the tomatoes at dawn");
182182
@@ -203,7 +203,7 @@ test.skipIf(noChromium)("in a real browser: type, reload, find it; send, reload,
203203 await type("Mulch first");
204204 await run(`(() => { const c = document.getElementById("composer"); const o = document.createElement("option"); o.value = "alice/nowhere"; c.elements.app.append(o); c.elements.app.value = "alice/nowhere"; c.requestSubmit(); })()`);
205205 await waitFor(`!localStorage.getItem("forge.outbox") || localStorage.getItem("forge.outbox") === "[]"`);
206 await waitFor(`localStorage.getItem(${JSON.stringify(`draft.${alice.id}/`)}) === "Mulch first"`);
206 await waitFor(`localStorage.getItem(${JSON.stringify(`draft.${alice.id}/feed`)}) === "Mulch first"`);
207207 expect(await run(`${box}.value`)).toBe("Mulch first");
208208 await load();
209209 expect(await run(`${box}.value`)).toBe("Mulch first");
@@ -215,3 +215,20 @@ test.skipIf(noChromium)("in a real browser: type, reload, find it; send, reload,
215215 }, 90000);
216216
217217 test.skipIf(!noChromium)("no chromium on this machine: the composer draft browser test did not run", () => {});
218
219 test("a tell sent from the feed's composer comes back to the feed, the forge's too", async () => {
220 // Its own session: the browser test before it signs its session out.
221 const cookie = createSession(app.db, alice.id).token;
222 const page = await (await fetch(`${servers.url}/feed`, { headers: { cookie: `forge_session=${cookie}; forge_mode=studio` } })).text();
223 expect(page).toContain('name="back" value="/feed"');
224 for (const app of ["forge", "alice/garden"]) {
225 const res = await fetch(`${servers.url}/tell`, {
226 method: "POST",
227 redirect: "manual",
228 headers: { cookie: `forge_session=${cookie}; forge_mode=studio`, origin: servers.url, "content-type": "application/x-www-form-urlencoded" },
229 body: new URLSearchParams({ app, text: "Plant beans", back: "/feed" }),
230 });
231 expect(res.status).toBeLessThan(400);
232 expect(new URL(res.headers.get("location") ?? "", servers.url).pathname).toBe("/feed");
233 }
234 });
test/digest-doors.test.ts +1 −1
@@ -47,7 +47,7 @@ afterAll(async () => {
4747
4848 test("web, terminal and MCP give identical digest lines for one seeded forge", async () => {
4949 const cookie = `forge_session=${createSession(app.db, alice.id).token}; forge_mode=studio`;
50 const page = await fetch(`${servers.url}/`, { headers: { cookie } }).then((r) => r.text());
50 const page = await fetch(`${servers.url}/feed`, { headers: { cookie } }).then((r) => r.text());
5151 const d = sinceDigest(app.db, alice, lastVisit(app.db, alice.id), Date.now())!;
5252 expect(d).toBeDefined();
5353 const lines = d.lines;
test/feed-caught-up.test.ts +6 −6
@@ -26,7 +26,7 @@ describe("the timeline's caught-up line", () => {
2626 for (let i = 0; i < n; i++)
2727 app.bus.append({ kind: "run.finished", repoId: repo.id, actorId: alice.id, payload: { kind: "agent", state: "ok", summary: `work ${i}`, runId: `r${i}` } });
2828 };
29 const home = (path = "/") => freshHome(servers.url, cookie, path);
29 const home = (path = "/feed") => freshHome(servers.url, cookie, path);
3030
3131 beforeAll(async () => {
3232 dataDir = await mkdtemp(join(tmpdir(), "forge-caught-up-"));
@@ -62,18 +62,18 @@ describe("the timeline's caught-up line", () => {
6262 const text = await home();
6363 expect(text).toContain("You're caught up to");
6464 expect(text).not.toContain("That's everything");
65 const href = /href="(\/\?cursor=[^"]+)"[^>]*>Older posts/.exec(text);
65 const href = /href="(\/feed\?cursor=[^"]+)"[^>]*>Older posts/.exec(text);
6666 expect(href).not.toBeNull();
6767 const older = await home(href![1]!.replaceAll("&amp;", "&"));
6868 expect(older).toContain("data-item=");
6969 expect(older).toContain("That's everything");
70 expect(older).toContain('<a href="/">Newest</a>');
70 expect(older).toContain('<a href="/feed">Newest</a>');
7171 expect(text).not.toContain(">Newest<");
7272 });
7373
7474 test("a page after the first carries its own address, so a chip refetch stays on it", async () => {
75 const href = /href="(\/\?cursor=[^"]+)"[^>]*>Older posts/.exec(await home())![1]!.replaceAll("&amp;", "&");
76 const cursor = href.slice("/?cursor=".length).split("&day=")[0];
75 const href = /href="(\/feed\?cursor=[^"]+)"[^>]*>Older posts/.exec(await home())![1]!.replaceAll("&amp;", "&");
76 const cursor = href.slice("/feed?cursor=".length).split("&day=")[0];
7777 const older = await home(href);
7878 expect(older).toContain(`data-live-src="/live/feed?cursor=${cursor}&amp;day=`);
7979 expect(await home()).toContain('data-live-src="/live/feed"');
@@ -88,7 +88,7 @@ describe("the timeline's caught-up line", () => {
8888 });
8989
9090 test("a cursor not issued here is the first page", async () => {
91 const text = await home("/?cursor=garbage");
91 const text = await home("/feed?cursor=garbage");
9292 expect(text).toContain("You're caught up to");
9393 });
9494 });
test/feed-chips.test.ts +1 −1
@@ -171,7 +171,7 @@ describe("the chips of a forge", () => {
171171 test("the home draws the chips, pressed unless switched off", async () => {
172172 const session = createSession(app.db, bob.id);
173173 setChip(app.db, bob.id, { kind: "kind", value: "crew" }, true);
174 const page = await (await fetch(`${servers.url}/`, { headers: { cookie: `forge_session=${session.token}` } })).text();
174 const page = await (await fetch(`${servers.url}/feed`, { headers: { cookie: `forge_session=${session.token}` } })).text();
175175 expect(page).toContain('data-value="crew"');
176176 expect(page).toMatch(/data-chip="kind"\s+data-value="crew"\s+aria-pressed="false"/);
177177 expect(page).toMatch(/data-chip="kind"\s+data-value="reviews"\s+aria-pressed="true"/);
test/feed-day-divider.test.ts +4 −4
@@ -57,7 +57,7 @@ describe("day dividers on the home timeline", () => {
5757 });
5858
5959 test("three days give three dividers in order, once each", async () => {
60 const labels = dividerLabels(await get("/"));
60 const labels = dividerLabels(await get("/feed"));
6161 expect(labels).toHaveLength(3);
6262 expect(labels.slice(0, 2)).toEqual(["Today", "Yesterday"]);
6363 });
@@ -67,13 +67,13 @@ describe("day dividers on the home timeline", () => {
6767 const e = app.bus.append({ kind: "run.finished", repoId: repoId, actorId: aliceId, payload: { kind: "agent", state: "ok", summary: `more ${i}`, runId: `m${i}` } });
6868 app.db.query("UPDATE event SET created_at = ?1 WHERE id = ?2").run(new Date(Date.now() - i * 1000).toISOString(), e.id);
6969 }
70 const first = await get("/");
70 const first = await get("/feed");
7171 expect(dividerLabels(first)[0]).toBe("Today");
72 const href = /href="(\/\?cursor=[^"]+)"[^>]*>Older posts/.exec(first)![1]!.replaceAll("&amp;", "&");
72 const href = /href="(\/feed\?cursor=[^"]+)"[^>]*>Older posts/.exec(first)![1]!.replaceAll("&amp;", "&");
7373 const older = await get(href);
7474 expect(older).toContain("data-item=");
7575 expect(dividerLabels(older)).not.toContain("Today");
76 const live = await get(href.replace("/?", "/live/feed?"));
76 const live = await get(href.replace("/feed?", "/live/feed?"));
7777 expect(dividerLabels(live)).toEqual(dividerLabels(older));
7878 });
7979
test/feed-failed-run.test.ts +2 −2
@@ -78,7 +78,7 @@ describe("a failed run's feed post", () => {
7878
7979 test("home reads the words and links to the line", async () => {
8080 expect(ranOn).toBe(1);
81 const text = await (await fetch(`${servers.url}/`, { headers: { cookie: `${cookie}; forge_mode=studio` } })).text();
81 const text = await (await fetch(`${servers.url}/feed`, { headers: { cookie: `${cookie}; forge_mode=studio` } })).text();
8282 expect(text).toContain("an agent run on #1 failed: error: Cannot find module");
8383 expect(text).toContain(`/alice/garden/runs/${runId}?all=1#L3`);
8484 });
@@ -97,7 +97,7 @@ describe("a failed run's feed post", () => {
9797 });
9898
9999 test("a reader who is not crew gets the summary and the plain link, never the log line", async () => {
100 const home = await (await fetch(`${servers.url}/`, { headers: { cookie: `${bobCookie}; forge_mode=studio` } })).text();
100 const home = await (await fetch(`${servers.url}/feed`, { headers: { cookie: `${bobCookie}; forge_mode=studio` } })).text();
101101 expect(home).toContain("failed");
102102 expect(home).not.toContain("Cannot find module");
103103 expect(home).not.toContain("#L3");
test/feed-page.test.ts +4 −4
@@ -78,7 +78,7 @@ describe("the feed on the home page", () => {
7878 });
7979
8080 test("/ renders the ranked feed on first paint, escaped", async () => {
81 const res = await get("/", aliceCookie);
81 const res = await get("/feed", aliceCookie);
8282 expect(res.status).toBe(200);
8383 const text = await res.text();
8484 expect(text).toContain('id="live-feed"');
@@ -91,7 +91,7 @@ describe("the feed on the home page", () => {
9191 });
9292
9393 test("a card links into the thing and steers or tells from where it is", async () => {
94 const text = await (await get("/", aliceCookie)).text();
94 const text = await (await get("/feed", aliceCookie)).text();
9595 expect(text).toContain('href="/alice/open/runs/r1"');
9696 expect(text).toContain('action="/alice/open/runs/r1/steer"');
9797 const item = readFeed(app.db, alice).items[0]!;
@@ -119,7 +119,7 @@ describe("the feed on the home page", () => {
119119 });
120120
121121 test("a viewer sees only their own feed: no leak from a private app", async () => {
122 const text = await (await get("/", bobCookie)).text();
122 const text = await (await get("/feed", bobCookie)).text();
123123 expect(text).not.toContain("alice/secret");
124124 expect(text).not.toContain("hidden work");
125125 expect(text).toContain("alice/open");
@@ -151,7 +151,7 @@ describe("the feed on the home page", () => {
151151 });
152152
153153 test("the page carries the feed script and a card is focusable for j/k", async () => {
154 const text = await (await get("/", aliceCookie)).text();
154 const text = await (await get("/feed", aliceCookie)).text();
155155 expect(text).toContain("data-nav");
156156 expect(text).toContain("/api/v1/feed/interactions");
157157 });
test/feed-reviewed.test.ts +1 −1
@@ -126,7 +126,7 @@ test("each verdict is said in the post, asked-for-changes first, on / and in the
126126 .filter((w) => w.startsWith("approved ") || w.startsWith("asked for changes"));
127127 expect(posts.sort()).toEqual([APPROVED, CHANGES].sort());
128128
129 const home = await fetch(`${servers.url}/`, { headers: { cookie } });
129 const home = await fetch(`${servers.url}/feed`, { headers: { cookie } });
130130 const html = (await home.text()).replace(/\s+/g, " ");
131131 const at = (s: string): number => html.indexOf(s);
132132 expect(at(APPROVED)).toBeGreaterThan(-1);
test/feed-two-taps.test.ts +2 −2
@@ -210,7 +210,7 @@ const COVERED = ["pull", "run", "look", "release", "ask", "decision"];
210210 const NOT_COVERED = ["milestone", "map", "cost", "tell", "reply", "choose"];
211211
212212 let home: string[] | undefined;
213 const homeHrefs = async (): Promise<string[]> => (home ??= hrefs(await (await get("/")).text()));
213 const homeHrefs = async (): Promise<string[]> => (home ??= hrefs(await (await get("/feed")).text()));
214214
215215 /**
216216 * Tap 1: the post's `link` is an href on the home page a person sees, and it
@@ -236,7 +236,7 @@ const one = (items: Item[], kind: string, says: RegExp): Item => {
236236 };
237237
238238 test("the feed carries a post of every covered kind and no kind unaccounted for", async () => {
239 expect((await get("/")).status).toBe(200);
239 expect((await get("/feed")).status).toBe(200);
240240 const kinds = new Set((await feed()).map((i) => i.kind));
241241 for (const k of COVERED) expect(kinds.has(k), k).toBe(true);
242242 for (const k of kinds) expect([...COVERED, ...NOT_COVERED], `kind ${k} needs a two-taps test`).toContain(k);
test/flow-card.test.ts +1 −1
@@ -83,7 +83,7 @@ describe("stalled steps on the home", () => {
8383 .run(r.id, hoursAgo(hours), state);
8484 };
8585 const home = async (): Promise<string> =>
86 (await fetch(`${servers.url}/`, { headers: { cookie: `${cookie}; forge_mode=studio` } })).text();
86 (await fetch(`${servers.url}/feed`, { headers: { cookie: `${cookie}; forge_mode=studio` } })).text();
8787 const stalled = (user = alice) => readStalled(app.db, user, new Date());
8888
8989 beforeAll(async () => {
test/flow-clog-door.test.ts +2 −4
@@ -130,13 +130,11 @@ test("clogLines: the standing clog, then the last cleared note", () => {
130130 expect(clogLines({ standing: null, last: null }, T0.getTime())).toEqual([]);
131131 });
132132
133 test("the admin sees the clog on home and in the Flow dialog; a guest and an agent see neither", async () => {
133 test("the clog is in the Flow dialog for the admin, never on home (#8015), never to a guest or an agent", async () => {
134134 reset();
135135 app.db.run("INSERT INTO clog_state (id, since, started_at, first_reasons, quiet) VALUES (1, ?1, ?1, ?2, 0)", [Date.now(), JSON.stringify(["31 pulls conflict <b>"])]);
136136 const home = async (cookie: string): Promise<string> => (await fetch(`${servers.url}/`, { headers: { cookie } })).text();
137 const mine = await home(adminCookie);
138 expect(mine).toContain("31 pulls conflict &lt;b&gt;");
139 expect(mine).not.toContain("<b>");
137 expect(await home(adminCookie)).not.toContain("pulls conflict");
140138 expect(await home(guestCookie)).not.toContain("pulls conflict");
141139 expect(flowLines(app.db, new Date(), [], undefined, []).join("\n")).toContain("31 pulls conflict");
142140 expect(flowLines(app.db, new Date(), [], { queues: [] }, []).join("\n")).not.toContain("31 pulls conflict");
test/flow-keeper.test.ts +4 −3
@@ -92,12 +92,13 @@ test("2026-11-01: ready issues wait and no builder runs: it names the cause and
9292 expect(swept).toBe(1);
9393 });
9494
95 test("the cause and last act show on home, the boards panel, and the MCP flow report", async () => {
95 test("the cause and last act show on the boards panel and the MCP flow report, not on home (#8015)", async () => {
9696 const home = await (await fetch(`${servers.url}/`, { headers: cookie() })).text();
97 expect(home).toContain('id="keeper"');
98 expect(home).toContain("Binding landings:");
97 expect(home).not.toContain('id="keeper"');
9998 const boards = await (await fetch(`${servers.url}/boards`, { headers: cookie() })).text();
10099 expect(boards).toContain("unstuck assigned issues");
100 expect(boards).toContain('id="keeper"');
101 expect(boards).toContain("Binding landings:");
101102 const report = flowFor(app, { kind: "owner" });
102103 expect(report.keeper.cause).toContain("no builder is running");
103104 expect(report.keeper.last).toContain("unstuck assigned issues");
test/flow-vitals.test.ts +2 −2
@@ -71,8 +71,8 @@ describe("flow vitals", () => {
7171 redirect: "manual",
7272 });
7373 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
74 const home = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text();
75 expect(home).toContain(">3 open pulls</a> · 1 opened, 1 landed in 1 h · 3 opened, 2 landed in 6 h · 1 conflicted · 0 red, 0 queued · $0.00 stranded");
74 // Home no longer carries the line (#8015): it is the tick line and the flow tool.
75 expect(await (await fetch(`${servers.url}/`, { headers: { cookie } })).text()).not.toContain("open pulls</a>");
7676 } finally {
7777 await servers.stop();
7878 app.close();
test/front-door-timing.test.ts +2 −2
@@ -27,7 +27,7 @@ const parts = (res: Response): string[] => (res.headers.get("server-timing") ??
2727
2828 test("the signed-in home names its session, reads and render and a cold feed read", async () => {
2929 const get = async (): Promise<Response> => {
30 const res = await fetch(`${f.base}/`, { headers: { cookie: f.ownerCookie } });
30 const res = await fetch(`${f.base}/feed`, { headers: { cookie: f.ownerCookie } });
3131 await res.arrayBuffer();
3232 return res;
3333 };
@@ -39,7 +39,7 @@ test("the signed-in home names its session, reads and render and a cold feed rea
3939 const res = await get();
4040 const all = parts(res);
4141 const names = all.map((p) => p.split(";")[0]!);
42 for (const n of ["session", "steps", "repos", "kpis", "panels", "feed", "render"])
42 for (const n of ["session", "repos", "panels", "feed", "render"])
4343 expect(names.filter((x) => x === n).length).toBe(1);
4444 // A miss is a bare `feed;dur=…`: a hit would carry `desc="hit"`.
4545 expect(all.find((p) => p.startsWith("feed;"))).toMatch(/^feed;dur=\d+(\.\d+)?$/);
test/home-200.test.ts +3 −3
@@ -39,12 +39,12 @@ test("the second look at home as the owner costs a bounded number of statements,
3939 expectWork(await statements(f.app.db, async () => void (await look())), { max: 70 });
4040 // The page's own total is the sum of named phases: nothing over 20 ms hides in "app" alone.
4141 // Every phase home.ts and the feed time: one missing here fails as a plain expect, which the gate's budget retry never re-runs.
42 const known = new Set(["app", "spawned", "steps", "session", "repos", "asked", "kpis", "vitals", "waiting", "panels", "feed", "settled", "visit", "since", "render"]);
42 const known = new Set(["app", "spawned", "steps", "session", "repos", "asked", "kpis", "vitals", "waiting", "panels", "feed", "settled", "visit", "since", "moving", "render"]);
4343 for (const p of slow) expect(known.has(p.name)).toBe(true);
4444 });
4545
4646 const waiting = async (): Promise<number> => {
47 const text = await freshHome(f.base, f.ownerCookie);
47 const text = await freshHome(f.base, f.ownerCookie, "/feed");
4848 return Number(/(\d+) waiting on you/.exec(text)?.[1] ?? 0);
4949 };
5050
@@ -52,7 +52,7 @@ test("the waiting line is a hit on the second look and a write puts it back", as
5252 const first = await waiting();
5353 expect(first).toBeGreaterThan(0);
5454 expect(await waiting()).toBe(first);
55 const timing = (await fetch(`${f.base}/`, { headers: { cookie: f.ownerCookie } })).headers.get("server-timing") ?? "";
55 const timing = (await fetch(`${f.base}/feed`, { headers: { cookie: f.ownerCookie } })).headers.get("server-timing") ?? "";
5656 expect(timing).toMatch(/waiting;dur=[\d.]+;desc="hit"/);
5757 // Every ask is put down in one write: the line is gone at once, not in 30 seconds.
5858 f.app.db.query("UPDATE issue SET assignee_id = NULL").run();
test/home-archive.test.ts +1 −1
@@ -82,7 +82,7 @@ describe("home archive", () => {
8282
8383 const body = (...ids: string[]) => ids.map((id) => `repo=${id}`).join("&");
8484 const home = async (cookie: string) => (await get("/live/apps", cookie)).text();
85 const full = async (cookie: string) => (await get("/", cookie)).text();
85 const full = async (cookie: string) => (await get("/feed", cookie)).text();
8686
8787 test("an admin archives two, the answer lists them, Undo brings them back", async () => {
8888 const before = await full(rootCookie);
test/home-buttons-rest.test.ts +3 −3
@@ -2,7 +2,7 @@
22 * No button spins at rest on Home (#3067, the browser half of #3039).
33 *
44 * `test/button-rest.test.ts` reads the source for `icon: "loader"`; this loads
5 * `/` as an admin in a real headless Chromium, with a stalled step's card on
5 * `/feed` as an admin in a real headless Chromium, with a stalled step's card on
66 * the page, and asks the computed style: no `button` or `.btn` is
77 * `aria-busy`, and nothing inside one runs an animation. It goes red if
88 * `flow_card.ts` goes back to the loader icon.
@@ -99,14 +99,14 @@ describe.skipIf(chromium === undefined)("Home's buttons rest", () => {
9999 test(`${label}, ${scheme}: with a stalled step on the page, no button is busy or animating`, async () => {
100100 const page = await findBusyButtons(
101101 chromium!,
102 `${servers.url}/`,
102 `${servers.url}/feed`,
103103 { width, height: 800 },
104104 cookies,
105105 scheme,
106106 );
107107 // The stalled step's button is on the page, so the check has teeth.
108108 const html = await (
109 await fetch(`${servers.url}/`, {
109 await fetch(`${servers.url}/feed`, {
110110 headers: { cookie: cookies.map((c) => `${c.name}=${c.value}`).join("; ") },
111111 })
112112 ).text();
test/home-cache.test.ts +15 −5
@@ -14,7 +14,18 @@ import { kpiCache, panelCache } from "../src/web/studio/home_cache.ts";
1414 import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts";
1515
1616 let f: SnappyFixture;
17 const home = (cookie?: string): Promise<string> => freshHome(f.base, cookie);
17 /** Home and the feed page, which together hold what home was: the figures, then the panels and the timeline. */
18 const home = async (cookie?: string): Promise<string> => (await freshHome(f.base, cookie)) + (await freshHome(f.base, cookie, "/feed"));
19 /** Both pages' answers: the worst status and every phase either named. */
20 async function both(cookie: string): Promise<{ status: number; timing: string }> {
21 const answers = [];
22 for (const path of ["/", "/feed"]) {
23 const res = await fetch(`${f.base}${path}`, { headers: { cookie } });
24 await res.arrayBuffer();
25 answers.push(res);
26 }
27 return { status: Math.max(...answers.map((r) => r.status)), timing: answers.map((r) => r.headers.get("server-timing") ?? "").join(", ") };
28 }
1829
1930 beforeAll(async () => {
2031 f = await snappyFixture();
@@ -42,7 +53,7 @@ test("a warm home reads neither the KPIs nor the panels again, even after a writ
4253 await home(f.ownerCookie);
4354 expect([kpi.misses, panel.misses]).toEqual([km, pm]);
4455 // The second look is a hit one layer in front of those caches (`home_last.ts`): both phases say so.
45 const timing = (await fetch(`${f.base}/`, { headers: { cookie: f.ownerCookie } })).headers.get("server-timing") ?? "";
56 const { timing } = await both(f.ownerCookie);
4657 expect(timing).toMatch(/kpis;dur=[\d.]+;desc="hit"/);
4758 expect(timing).toMatch(/panels;dur=[\d.]+;desc="hit"/);
4859
@@ -84,10 +95,9 @@ interface Counted {
8495 async function counted(): Promise<Counted> {
8596 const before = writeStamp(f.app.db);
8697 f.counter.reset();
87 const res = await fetch(`${f.base}/`, { headers: { cookie: f.ownerCookie } });
88 await res.arrayBuffer();
98 const res = await both(f.ownerCookie);
8999 const ran = new Map(f.counter.top(10_000).map((s) => [s.sql, s.times]));
90 return { status: res.status, hits: phases(res.headers.get("server-timing")), ran, before, after: writeStamp(f.app.db) };
100 return { status: res.status, hits: phases(res.timing), ran, before, after: writeStamp(f.app.db) };
91101 }
92102
93103 /**
test/home-eval.test.ts +1 −1
@@ -84,7 +84,7 @@ test("the real home never contradicts its own figures; with one figure changed i
8484 const honest = await run([servers.url]);
8585 expect(honest.out).toMatch(/errors 0 ·/);
8686 expect(honest.out).not.toMatch(/^wrong /m);
87 const home = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text();
87 const home = await (await fetch(`${servers.url}/feed`, { headers: { cookie } })).text();
8888 expect(home).toContain("3 waiting on you");
8989 // A stand-in in front of the forge: the real home with one figure wrong, every API read passed through.
9090 stub = Bun.serve({
test/home-feed.test.ts +13 −13
@@ -125,13 +125,13 @@ describe("home is the feed", () => {
125125 });
126126
127127 test("the composer is a §5 card, and no .composer class remains", async () => {
128 const text = await (await get("/", aliceCookie)).text();
128 const text = await (await get("/feed", aliceCookie)).text();
129129 expect(text).toMatch(/<form id="composer" class="card" method="post" action="\/tell">\s*<div class="card-body/);
130130 expect(text).not.toContain('class="composer');
131131 });
132132
133133 test("the composer's action bar is §5's bar, not .composer-bar", async () => {
134 const text = await (await get("/", aliceCookie)).text();
134 const text = await (await get("/feed", aliceCookie)).text();
135135 const form = text.slice(text.indexOf('id="composer"'), text.indexOf("</form>", text.indexOf('id="composer"')));
136136 expect(form).not.toContain("composer-bar");
137137 expect(form).toContain('<div class="bar">');
@@ -142,7 +142,7 @@ describe("home is the feed", () => {
142142 });
143143
144144 test("signed in, / is the composer above one timeline; your apps one step away", async () => {
145 const text = await (await get("/", aliceCookie)).text();
145 const text = await (await get("/feed", aliceCookie)).text();
146146 const composer = text.indexOf('id="composer"');
147147 const feed = text.indexOf('id="live-feed"');
148148 expect(composer).toBeGreaterThan(-1);
@@ -183,7 +183,7 @@ describe("home is the feed", () => {
183183 const asked = await (await get("/asked", aliceCookie)).text();
184184 expect(asked).toMatch(/class="tabs"[\s\S]*?href="\/asked"\s+aria-current="page"/);
185185 expect(asked).toContain('href="/"');
186 expect(text).toMatch(/class="tabs"[\s\S]*?href="\/"\s+aria-current="page"/);
186 expect(text).toMatch(/class="tabs"[\s\S]*?href="\/feed"\s+aria-current="page"/);
187187 for (const page of [apps, asked]) {
188188 expect(page).not.toContain("home-tabs");
189189 for (const w of ["Feed", "Your apps", "Asked"]) expect(page).toContain(w);
@@ -191,7 +191,7 @@ describe("home is the feed", () => {
191191 });
192192
193193 test("a reader's timeline has the public repository's posts and nothing private or configured", async () => {
194 const text = await (await get("/", bobCookie)).text();
194 const text = await (await get("/feed", bobCookie)).text();
195195 expect(text).toContain("Water the tomatoes at dawn");
196196 expect(text).not.toContain("The combination is 4-8-15");
197197 expect(text).not.toContain("alice/vault");
@@ -222,7 +222,7 @@ describe("home is the feed", () => {
222222 text: 'Look at this </textarea><script>alert(1)</script>',
223223 url: "https://example.com/gauge",
224224 });
225 const res = await get(`/?${q}`, aliceCookie);
225 const res = await get(`/feed?${q}`, aliceCookie);
226226 const text = await res.text();
227227 const box = /<textarea[^>]*name="text"[^>]*>([\s\S]*?)<\/textarea>/.exec(text);
228228 expect(box?.[1]).toContain("Rain gauges\nLook at this &lt;/textarea&gt;&lt;script&gt;");
@@ -323,13 +323,13 @@ describe("home is the feed", () => {
323323 )
324324 .get(alice.id);
325325 expect(row).toEqual({ text, source: "composer" });
326 const page = await freshHome(servers.url, `${aliceCookie}; forge_mode=studio`);
326 const page = await freshHome(servers.url, `${aliceCookie}; forge_mode=studio`, "/feed");
327327 expect(page).toContain("&lt;script&gt;alert(1)&lt;/script&gt; &amp; more");
328328 expect(page).not.toContain("<script>alert(1)</script>");
329329 expect(page).toContain("Received");
330330 expect(page).toContain('<option value="forge">the forge</option>');
331331 // It is the author's alone, and another site's form is refused.
332 const bobs = await freshHome(servers.url, `${bobCookie}; forge_mode=studio`);
332 const bobs = await freshHome(servers.url, `${bobCookie}; forge_mode=studio`, "/feed");
333333 expect(bobs).not.toContain("alert(1)");
334334 const forged = await fetch(`${servers.url}/tell`, {
335335 method: "POST",
@@ -342,21 +342,21 @@ describe("home is the feed", () => {
342342 });
343343
344344 test("a person with no apps can still send to the forge, and a day-old thought leaves the feed", async () => {
345 const empty = await freshHome(servers.url, `${bobCookie}; forge_mode=studio`);
345 const empty = await freshHome(servers.url, `${bobCookie}; forge_mode=studio`, "/feed");
346346 expect(empty).toContain('name="app" value="forge"');
347347 expect(empty).toContain("No apps yet");
348348 const old = new Date(Date.now() - 2 * 86_400_000).toISOString();
349349 app.db
350350 .query("INSERT INTO thought (id, author, text, source, received_at) VALUES ('old1', ?1, 'ancient musing', 'composer', ?2)")
351351 .run(bob.id, old);
352 expect(await freshHome(servers.url, `${bobCookie}; forge_mode=studio`)).not.toContain("ancient musing");
352 expect(await freshHome(servers.url, `${bobCookie}; forge_mode=studio`, "/feed")).not.toContain("ancient musing");
353353 await fetch(`${servers.url}/tell`, {
354354 method: "POST",
355355 headers: { cookie: bobCookie, origin: servers.url },
356356 body: new URLSearchParams({ app: "forge", text: "fresh musing" }),
357357 redirect: "manual",
358358 });
359 const after = await freshHome(servers.url, `${bobCookie}; forge_mode=studio`);
359 const after = await freshHome(servers.url, `${bobCookie}; forge_mode=studio`, "/feed");
360360 expect(after).toContain("fresh musing");
361361 expect(after).not.toContain("ancient musing");
362362 });
@@ -366,7 +366,7 @@ describe("home is the feed", () => {
366366 const serving = (payload: Record<string, unknown>) =>
367367 app.bus.append({ kind: "app.serving", repoId: garden.id, payload: { app: "garden", env: "production", commitOid: oid, ...payload } });
368368 serving({ version: "0.7.251", pulls: [{ title: "Water <b>early</b>", number: 1 }, { title: "Mulch", number: 2 }, { title: "Weed", number: 3 }] });
369 const page = await freshHome(servers.url, `${aliceCookie}; forge_mode=studio`);
369 const page = await freshHome(servers.url, `${aliceCookie}; forge_mode=studio`, "/feed");
370370 expect(page).toContain("0.7.251 is serving: Water &lt;b&gt;early&lt;/b&gt; and 2 more");
371371 expect(page).not.toContain("<b>early</b>");
372372 expect(page).toContain(`href="/alice/garden/commit/${oid}"`);
@@ -424,7 +424,7 @@ describe("home is the feed", () => {
424424 if (!made.ok) throw new Error("issue");
425425 expect(assignIssue(app, alice, garden, made.value, alice).ok).toBe(true);
426426 }
427 const page = await (await get("/", aliceCookie)).text();
427 const page = await (await get("/feed", aliceCookie)).text();
428428 // The timeline may still tell of a filed issue as a post; the asks are
429429 // what must be gone, so the page is read up to the timeline.
430430 const home = page.slice(0, page.indexOf('id="live-feed"'));
test/home-frame.test.ts +7 −3
@@ -24,7 +24,7 @@ afterAll(async () => {
2424 await f.close();
2525 });
2626
27 const look = async (cookie: string, path = "/") => {
27 const look = async (cookie: string, path = "/feed") => {
2828 const res = await fetch(`${f.base}${path}`, { headers: { cookie } });
2929 const text = await res.text();
3030 const app = /app;dur=([\d.]+)/.exec(res.headers.get("server-timing") ?? "");
@@ -66,11 +66,14 @@ test("after a write, signed-in home is answered from memory within the budget an
6666 median = times[Math.floor(times.length / 2)]!;
6767 }
6868 expect(median).toBeLessThan(APP_BUDGET_MS);
69 for (const name of ["kpis", "waiting", "panels", "asked", "feed"]) {
69 for (const name of ["waiting", "panels", "asked", "feed"]) {
7070 expect(marked).toContain(`id="home-${name}"`);
7171 }
7272 expect(marked).toContain('data-refresh="/live/home/feed"');
73 expect(marked).toContain('data-refresh="/live/home/kpis"');
73 // The figures are on home itself, the feed's page holds the rest.
74 await look(f.ownerCookie, "/");
75 write(99);
76 expect((await look(f.ownerCookie, "/")).text).toContain('data-refresh="/live/home/kpis"');
7477 });
7578
7679 test("the first response stays under its byte limit", async () => {
@@ -96,6 +99,7 @@ test("each late region answers alone, to its own viewer, and to no one else", as
9699
97100 test("one viewer's remembered regions never reach another", async () => {
98101 await look(f.ownerCookie);
102 await look(f.ownerCookie, "/");
99103 write(99);
100104 const peer = await look(f.peerCookie);
101105 const owners = await look(f.ownerCookie);
test/home-kpi-row.test.ts +1 −1
@@ -379,7 +379,7 @@ describe("home waiting line", () => {
379379 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
380380 const review = await (await fetch(`${servers.url}/review`, { headers: { cookie } })).text();
381381 expect(review.split("<li").filter((r) => r.includes('class="row"')).length).toBe(stale.length);
382 return await (await fetch(`${servers.url}/`, { headers: { cookie } })).text();
382 return await (await fetch(`${servers.url}/feed`, { headers: { cookie } })).text();
383383 } finally {
384384 await servers.stop();
385385 app.close();
test/home-living.test.ts added +218
@@ -0,0 +1,218 @@
1 /**
2 * Home is the living pipeline (#8015): the box, the figures, the pipeline in
3 * four words, what is being built and what just shipped (each row a link),
4 * your apps; the feed is its own page. Rows are read for the viewer.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { createIssue } from "../src/core/issues.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { MOVING_ROWS, nowOf, readBuilding, readShipped } from "../src/flow/moving.ts";
17 import type { PipelineStage } from "../src/flow/pipeline.ts";
18 import { expectWork, requestStatements } from "./helpers/work.ts";
19 import { statements } from "./helpers/statements.ts";
20 import { promote } from "./helpers/members.ts";
21 import { createForgeApp } from "../src/tui/forge.ts";
22 import { dashboardScreen } from "../src/tui/screens/dashboard.ts";
23 import { headlessTerminal } from "../src/tui/term/terminal.ts";
24 import { settleApp } from "./helpers/tui.ts";
25
26 let dataDir: string;
27 let app: App;
28 let servers: Servers;
29 let alice: User;
30 let bob: User;
31 let aliceCookie: string;
32 let bobCookie: string;
33 let rootCookie: string;
34 let root: User;
35 let openRepoId: string;
36 let secretRepoId: string;
37
38 const get = (path: string, cookie?: string) => fetch(`${servers.url}${path}`, { headers: cookie ? { cookie } : {}, redirect: "manual" });
39 const home = async (cookie?: string) => (await get("/", cookie)).text();
40
41 /** The terminal's home, as the viewer's session draws it. */
42 async function terminalHome(user: User): Promise<string> {
43 const { app: tui, session } = createForgeApp(
44 app,
45 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
46 { terminal: headlessTerminal(200, 40), exitHooks: false },
47 );
48 tui.start();
49 tui.ctx.push(dashboardScreen(session, tui.ctx));
50 await settleApp(tui);
51 const text = tui.screen.toText();
52 tui.stop?.();
53 return text;
54 }
55
56 function merged(repoId: string, title: string, minutesAgo: number) {
57 const made = createIssue(app.db, { repoId, authorId: alice.id, title, kind: "pull" });
58 if (!made.ok) throw new Error(made.error);
59 app.db
60 .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at, merged_by) VALUES (?, 'main', ?, 'b', 'h', ?, ?)")
61 .run(made.value.id, `h-${title}`, new Date(Date.now() - minutesAgo * 60_000).toISOString(), alice.id);
62 return made.value;
63 }
64
65 function building(repoId: string, title: string) {
66 const made = createIssue(app.db, { repoId, authorId: alice.id, title, kind: "issue" });
67 if (!made.ok) throw new Error(made.error);
68 app.db
69 .query("INSERT INTO run (id, repo_id, kind, issue_id, commit_oid, state, actor_id, started_at) VALUES (?, ?, 'agent', ?, 'c', 'running', ?, ?)")
70 .run(`zzzz-${crypto.randomUUID()}`, repoId, made.value.id, alice.id, new Date().toISOString());
71 return made.value;
72 }
73
74 beforeAll(async () => {
75 dataDir = await mkdtemp(join(tmpdir(), "forge-home-living-"));
76 app = createApp({ dataDir, port: 0 });
77 servers = await serve(app);
78 const mk = async (username: string, first = false) => {
79 const r = await createUser(app.db, { origin: "seed", trusted: true, username, allowReserved: true, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
80 if (!r.ok) throw new Error(r.error);
81 promote(app.db, r.value);
82 return r.value;
83 };
84 root = await mk("root", true);
85 rootCookie = `forge_session=${createSession(app.db, root.id).token}`;
86 alice = await mk("alice");
87 bob = await mk("bob");
88 aliceCookie = `forge_session=${createSession(app.db, alice.id).token}`;
89 bobCookie = `forge_session=${createSession(app.db, bob.id).token}`;
90 const open = await createRepo(app, alice, { name: "open", visibility: "public" });
91 const secret = await createRepo(app, alice, { name: "secret", visibility: "private" });
92 if (!open.ok || !secret.ok) throw new Error("repos");
93 openRepoId = open.value.id;
94 secretRepoId = secret.value.id;
95 });
96
97 afterAll(async () => {
98 servers.stop();
99 app.close();
100 await rm(dataDir, { recursive: true, force: true });
101 });
102
103 describe("home", () => {
104 test("holds the box, both lists and your apps, and no feed", async () => {
105 const page = await home(aliceCookie);
106 expect(page).toContain('id="tell-box"');
107 expect(page).toContain('action="/tell"');
108 expect(page).toContain("Being built");
109 expect(page).toContain("Just shipped");
110 expect(page).toContain('id="live-chips"');
111 expect(page).toContain('href="/apps/new"');
112 expect(page).toContain('href="/feed"');
113 for (const gone of ['id="live-feed"', 'id="composer"', "waiting on you", "id=\"keeper\""]) expect(page).not.toContain(gone);
114 });
115
116 test("a site admin sees the pipeline in four words, each linking to where it is", async () => {
117 const page = await home(rootCookie);
118 expect(page).toContain('id="now-line"');
119 for (const word of ["check", "review", "land"]) expect(page).toContain(`</strong> ${word}</a>`);
120 expect(page).toMatch(/href="\/releases"><strong>\d+<\/strong> live<\/a>/);
121 });
122
123 test("the forge-wide pipeline counts never reach a viewer who is not a site admin", async () => {
124 building(secretRepoId, "Counted build");
125 for (const cookie of [aliceCookie, bobCookie]) {
126 expect(await home(cookie)).not.toContain('id="now-line"');
127 expect(await (await get("/live/moving", cookie)).text()).not.toContain('id="now-line"');
128 }
129 expect(await (await get("/live/moving", rootCookie)).text()).toContain('id="now-line"');
130 });
131
132 test("the terminal home carries the same lines: Now for a site admin, being built and just shipped as the viewer reads them", async () => {
133 building(openRepoId, "Terminal build");
134 building(secretRepoId, "Terminal secret");
135 const mine = await terminalHome(alice);
136 expect(mine).toContain("Being built");
137 expect(mine).toContain("Just shipped");
138 expect(mine).toContain("Terminal build");
139 expect(mine).not.toContain("Now check");
140 const theirs = await terminalHome(bob);
141 expect(theirs).toContain("Being built");
142 expect(theirs).not.toContain("Terminal secret");
143 expect(await terminalHome(root)).toMatch(/Now {2}check \d+ › review \d+ › land \d+ › \d+ live/);
144 });
145
146 test("a stranger gets the door, not the box", async () => {
147 const page = await home();
148 expect(page).not.toContain('id="tell-box"');
149 expect(page).not.toContain("Being built");
150 });
151
152 test("being built and just shipped are links to what they name, escaped, and always hold the same number of rows", async () => {
153 const quiet = await (await get("/live/moving", aliceCookie)).text();
154 const rows = (t: string) => t.split('class="row"').length - 1;
155 const base = rows(quiet);
156 expect(base).toBe(2 * MOVING_ROWS);
157 const issue = building(openRepoId, "Teach <b>trails</b>");
158 const pull = merged(openRepoId, "Instant home", 2);
159 const page = await home(aliceCookie);
160 expect(page).toContain(`href="/alice/open/issues/${issue.number}"`);
161 expect(page).toContain(`href="/alice/open/pulls/${pull.number}"`);
162 expect(page).toContain("Teach &lt;b&gt;trails&lt;/b&gt;");
163 expect(page).not.toContain("<b>trails");
164 const busy = await (await get("/live/moving", aliceCookie)).text();
165 expect(rows(busy)).toBe(base);
166 });
167
168 test("a row in a repository the viewer cannot read is never named", async () => {
169 building(secretRepoId, "Hidden build");
170 merged(secretRepoId, "Hidden ship", 1);
171 expect(await home(aliceCookie)).toContain("Hidden ship");
172 for (const cookie of [bobCookie]) {
173 const page = await home(cookie);
174 expect(page).not.toContain("Hidden ship");
175 expect(page).not.toContain("Hidden build");
176 expect(page).not.toContain("alice/secret");
177 }
178 expect(readShipped(app.db, bob).map((r) => r.title)).not.toContain("Hidden ship");
179 expect(readBuilding(app.db, bob).map((r) => r.title)).not.toContain("Hidden build");
180 });
181
182 test("the live fragment is for the signed in, and the region is wired to the stream", async () => {
183 expect((await get("/live/moving")).status).toBe(404);
184 expect((await get("/live/chips")).status).toBe(404);
185 const chips = await get("/live/chips", aliceCookie);
186 expect(chips.status).toBe(200);
187 expect(await chips.text()).toContain('id="live-chips"');
188 expect(await home(aliceCookie)).toMatch(/id="live-moving"[^>]*data-live="[^"]*pull\.merged[^"]*"[^>]*data-live-src="\/live\/moving"/);
189 });
190
191 test("a warm home costs a bounded number of statements, the two scans read once per write", async () => {
192 await home(aliceCookie);
193 const warm = await requestStatements(app.db, `${servers.url}/`, { headers: { cookie: aliceCookie } });
194 expectWork(warm, { max: 40 });
195 const scans = await statements(app.db, async () => void (await get("/live/moving", aliceCookie).then((r) => r.text())));
196 const again = await statements(app.db, async () => void (await get("/live/moving", aliceCookie).then((r) => r.text())));
197 expect(again).toBeLessThanOrEqual(scans);
198 });
199 });
200
201 describe("the pipeline in four words", () => {
202 const stage = (key: PipelineStage["key"], waiting: number, active: number) => ({ key, waiting, active }) as PipelineStage;
203 test("each word is the sum of the boards' stages it names", () => {
204 const now = nowOf([stage("check-queue", 4, 0), stage("checking", 0, 6), stage("review", 1, 0), stage("approved", 3, 0), stage("live", 0, 0), stage("ready", 9, 9)]);
205 expect(now).toEqual({ check: 10, review: 1, land: 3, live: 0 });
206 });
207 });
208
209 describe("the feed page", () => {
210 test("holds the composer and the timeline; a stranger is sent to sign in", async () => {
211 const page = await (await get("/feed", aliceCookie)).text();
212 expect(page).toContain('id="composer"');
213 expect(page).toContain('id="live-feed"');
214 expect(page).not.toContain("Being built");
215 const stranger = await get("/feed");
216 expect(stranger.status).not.toBe(200);
217 });
218 });
test/home-since.test.ts +4 −4
@@ -67,7 +67,7 @@ test("home heads the digest with the window, lists only landed pulls the viewer
6767 db.query("DELETE FROM pull WHERE issue_id = ?1").run(`since-${priv.id}-3`);
6868
6969 db.query("INSERT INTO home_visit (user_id, at) VALUES (?1, ?2) ON CONFLICT(user_id) DO UPDATE SET at = excluded.at").run(f.owner.id, Date.now() - 3 * H);
70 const res = await fetch(`${f.base}/?all=1`, { headers: { cookie: f.ownerCookie } });
70 const res = await fetch(`${f.base}/feed?all=1`, { headers: { cookie: f.ownerCookie } });
7171 const text = await res.text();
7272 expect(text).toContain("Since you were here · 3 hours");
7373 expect(text).toContain("1 landed");
@@ -80,7 +80,7 @@ test("home heads the digest with the window, lists only landed pulls the viewer
8080 test("a first-time user, with no home_visit row, gets the 24 hour heading", async () => {
8181 const db = f.app.db;
8282 db.query("DELETE FROM home_visit WHERE user_id = ?1").run(f.owner.id);
83 const text = await (await fetch(`${f.base}/?all=1`, { headers: { cookie: f.ownerCookie } })).text();
83 const text = await (await fetch(`${f.base}/feed?all=1`, { headers: { cookie: f.ownerCookie } })).text();
8484 expect(text).toContain("Since you were here · 1 day<");
8585 expect(text).toContain("1 landed");
8686 expect(lastVisit(db, f.owner.id)).toBeDefined();
@@ -118,12 +118,12 @@ test("a prefetch, a preview or a non-document fetch leaves home_visit unchanged;
118118 { "sec-fetch-dest": "iframe" },
119119 ]) {
120120 set();
121 const text = await (await fetch(`${f.base}/?all=1`, { headers: { cookie: f.ownerCookie, ...headers } })).text();
121 const text = await (await fetch(`${f.base}/feed?all=1`, { headers: { cookie: f.ownerCookie, ...headers } })).text();
122122 expect(text).toContain("Since you were here · 3 hours");
123123 expect(lastVisit(db, f.owner.id)).toBe(at);
124124 }
125125 set();
126 await (await fetch(`${f.base}/?all=1`, { headers: { cookie: f.ownerCookie, "sec-fetch-dest": "document" } })).text();
126 await (await fetch(`${f.base}/feed?all=1`, { headers: { cookie: f.ownerCookie, "sec-fetch-dest": "document" } })).text();
127127 expect(lastVisit(db, f.owner.id)).toBeGreaterThan(at);
128128 });
129129
test/home-timing.test.ts +7 −5
@@ -13,11 +13,13 @@ afterAll(async () => {
1313 await f.close();
1414 });
1515
16 async function timing(cookie?: string): Promise<string[]> {
17 const res = await fetch(`${f.base}/`, { headers: cookie ? { cookie } : {} });
16 async function timing(cookie?: string, path = "/"): Promise<string[]> {
17 const res = await fetch(`${f.base}${path}`, { headers: cookie ? { cookie } : {} });
1818 await res.arrayBuffer();
1919 return (res.headers.get("server-timing") ?? "").split(", ");
2020 }
21 /** Home's phases and the feed page's together: what home was before the feed moved off it. */
22 const timings = async (cookie?: string): Promise<string[]> => [...(await timing(cookie)), ...(await timing(cookie, "/feed"))];
2123 const names = (parts: string[]): string[] => parts.map((p) => p.split(";")[0]!);
2224
2325 test("a cold look at the owner's home names each part of the kpis read", async () => {
@@ -27,8 +29,8 @@ test("a cold look at the owner's home names each part of the kpis read", async (
2729 });
2830
2931 test("the owner's home names feed, kpis and panels; a warm look is a hit", async () => {
30 await timing(f.ownerCookie);
31 const warm = await timing(f.ownerCookie);
32 await timings(f.ownerCookie);
33 const warm = await timings(f.ownerCookie);
3234 expect(names(warm)).toEqual(expect.arrayContaining(["feed", "kpis", "panels"]));
3335 expect(names(warm)).not.toContain("shelf");
3436 expect(warm.find((p) => p.startsWith("kpis;"))).toMatch(/^kpis;dur=\d+\.\d;desc="hit"$/);
@@ -36,7 +38,7 @@ test("the owner's home names feed, kpis and panels; a warm look is a hit", async
3638 });
3739
3840 test("a signed-in non-admin gets the feed and none of the admin-only phases", async () => {
39 const got = names(await timing(f.peerCookie));
41 const got = names(await timings(f.peerCookie));
4042 expect(got).toContain("feed");
4143 expect(got).not.toContain("kpis");
4244 for (const part of KPI_PARTS) expect(got).not.toContain(part);
test/home-walk.test.ts +1 −1
@@ -58,7 +58,7 @@ test("composer, timeline, a tell, a crew member's run and pull, a share, the app
5858 if (!repo.ok) throw new Error("repo");
5959 expect(addToCrew(app, root, repo.value, "scribe").ok).toBe(true);
6060 const cookie = (u: User) => `forge_session=${createSession(app.db, u.id).token}; forge_mode=studio`;
61 const home = (u: User) => freshHome(servers.url, cookie(u));
61 const home = (u: User) => freshHome(servers.url, cookie(u), "/feed");
6262
6363 // The composer comes first, then the timeline.
6464 const first = await home(alice);
test/live-pages.test.ts +1 −1
@@ -156,7 +156,7 @@ describe("live browsing pages", () => {
156156 ).split(";")[0]!;
157157
158158 const html = await (
159 await fetch(`${base}/`, {
159 await fetch(`${base}/feed`, {
160160 headers: { cookie: `${cookie}; forge_mode=studio` },
161161 })
162162 ).text();
test/live.test.ts +1 −1
@@ -127,7 +127,7 @@ describe("the viewer's stream", () => {
127127 });
128128
129129 test("the studio pages carry live regions and the script that keeps them fresh", async () => {
130 const res = await fetch(`${servers.url}/`, {
130 const res = await fetch(`${servers.url}/feed`, {
131131 headers: { cookie: `${cookie}; forge_mode=studio` },
132132 });
133133 const page = await res.text();
test/offline.test.ts +2 −2
@@ -156,7 +156,7 @@ describe("the service worker and the server", () => {
156156 });
157157
158158 test("the home page registers the worker, has the note, and allows workers", async () => {
159 const res = await fetch(`${servers.url}/`, { headers: { cookie } });
159 const res = await fetch(`${servers.url}/feed`, { headers: { cookie } });
160160 const text = await res.text();
161161 expect(text).toContain("serviceWorker");
162162 expect(text).toContain('id="feed-note"');
@@ -165,7 +165,7 @@ describe("the service worker and the server", () => {
165165 });
166166
167167 test("the live client resumes /events from the last seen id", async () => {
168 const text = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text();
168 const text = await (await fetch(`${servers.url}/feed`, { headers: { cookie } })).text();
169169 expect(text).toContain("/events?since=");
170170 expect(text).toContain("'online'");
171171 });
test/page-text.test.ts +2 −2
@@ -56,7 +56,7 @@ test("the real home reads as text: heading and waiting count, no CSS, script or
5656 .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) VALUES (?1, 'main', ?2, ?3, ?3, ?4)`)
5757 .run(issueId, `b/${i}`, "0".repeat(40), repo.value.id);
5858 }
59 const raw = await get("/");
59 const raw = await get("/feed");
6060 expect(raw).toContain("<script");
6161 expect(raw).toContain("<style");
6262 const text = pageText(raw);
@@ -92,7 +92,7 @@ const run = async (args: string[], env: Record<string, string> = {}) => {
9292 };
9393
9494 test("scripts/home-read.ts prints the text and the stats line, sends FORGE_COOKIE and never prints it", async () => {
95 const r = await run([`${servers.url}/`], { FORGE_COOKIE: cookie });
95 const r = await run([`${servers.url}/feed`], { FORGE_COOKIE: cookie });
9696 expect(r.code).toBe(0);
9797 expect(r.out.trimEnd().split("\n").pop()).toMatch(/^chars \d+ · words \d+ · fetched in \d+ ms$/);
9898 expect(r.out).toContain("Feed Your apps Asked");
test/releases-page.test.ts +2 −2
@@ -170,8 +170,8 @@ describe("the releases page", () => {
170170 const cookie = await login("alice", "a-good-passphrase");
171171 const body = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text();
172172 expect(body).toContain('href="/releases"');
173 expect(body).toContain("live:");
174 expect(body).toContain("aaaaaaa");
173 // An admin's tile, or the plain line anyone else gets: both name the live version.
174 expect(body).toMatch(/live: <span class="sha">aaaaaaa|Live version: a{7}/);
175175 });
176176
177177 test("a signed-out visitor is sent to sign in", async () => {
test/review-stale.test.ts +1 −1
@@ -63,7 +63,7 @@ describe("one count on Home, /review and the MCP", () => {
6363 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
6464 const get = async (path: string) => await (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
6565
66 const home = await get("/");
66 const home = await get("/feed");
6767 const said = Number(/(\d+) waiting on you/.exec(home)?.[1]);
6868 expect(home).toContain("2 stale, archive?");
6969 expect(home).toContain('action="/asks/bulk"');
test/ssh-site.test.ts +2 −2
@@ -71,7 +71,7 @@ describe("the terminal on the website", () => {
7171 });
7272
7373 test("the dashboard has a Terminal card, with the host key", async () => {
74 const page = await fetch(`${base}/`, { headers: { cookie } }).then((r) =>
74 const page = await fetch(`${base}/feed`, { headers: { cookie } }).then((r) =>
7575 r.text(),
7676 );
7777 expect(page).toContain("<h2>Terminal</h2>");
@@ -91,7 +91,7 @@ describe("the terminal on the website", () => {
9191 });
9292
9393 test("the dashboard's Terminal card has no terminal-line, keeps the keys link", async () => {
94 const page = await fetch(`${base}/`, { headers: { cookie } }).then((r) =>
94 const page = await fetch(`${base}/feed`, { headers: { cookie } }).then((r) =>
9595 r.text(),
9696 );
9797 expect(page).not.toContain("terminal-line");
test/steering-four-places.test.ts +1 −1
@@ -92,7 +92,7 @@ describe("a tell from the four places", () => {
9292 expect(pill.status).toBe(200);
9393
9494 // 3. the share sheet: land on the composer with a draft, then post it
95 const landed = await fetch(`${servers.url}/?text=${encodeURIComponent("Tell from share")}`, {
95 const landed = await fetch(`${servers.url}/feed?text=${encodeURIComponent("Tell from share")}`, {
9696 headers: { authorization: `Bearer ${token}` },
9797 });
9898 expect(landed.status).toBe(200);
test/studio-changes.test.ts +1 −1
@@ -1129,7 +1129,7 @@ describe("the studio, continued", () => {
11291129
11301130 // Erin's front door: the empty state and New app, never alice's public
11311131 // app — #69's case, finally reachable.
1132 const erinHome = await (await erinGet("/")).text();
1132 const erinHome = await (await erinGet("/feed")).text();
11331133 expect(erinHome).toContain("No apps yet");
11341134 expect(erinHome).toContain('href="/apps/new"');
11351135 expect(erinHome).toContain('href="/erin"');
test/studio-door.test.ts +1 −1
@@ -527,7 +527,7 @@ describe("the studio home keeps itself current", () => {
527527 let readme: Issue;
528528
529529 const get = (extraCookie = "") =>
530 fetch(`${base}/`, {
530 fetch(`${base}/feed`, {
531531 headers: {
532532 cookie: `${cookie}${extraCookie}; forge_mode=studio`,
533533 },
test/studio.test.ts +2 −2
@@ -174,7 +174,7 @@ describe("the studio", () => {
174174 description: "",
175175 });
176176 if (!later.ok) throw new Error(String(later.error));
177 const html = await (await get("/")).text();
177 const html = await (await get("/feed")).text();
178178 const block = html.match(
179179 /<script type="application\/json" id="palette-index">([\s\S]*?)<\/script>/,
180180 )?.[1];
@@ -276,7 +276,7 @@ describe("the studio", () => {
276276 });
277277
278278 test("the home and an app's page lay main and rail with .split's wide rail; no dash class is left (#4748)", async () => {
279 for (const path of ["/", "/alice/notes"]) {
279 for (const path of ["/feed", "/alice/notes"]) {
280280 const page = await (await get(path)).text();
281281 expect(page).not.toMatch(/class="[^"]*\bdash(?:-main|-rail)?\b/);
282282 expect(page).toContain('<div class="split" data-rail="wide">');
test/surfaces-adversarial.test.ts +1 −1
@@ -230,7 +230,7 @@ describe("surfaces adversarial: web", () => {
230230 },
231231 );
232232 expect(assignedToAlice.status).toBe(303);
233 const dash = await bodyOf(alice, "/");
233 const dash = await bodyOf(alice, "/feed");
234234 expect(dash).not.toContain(payload);
235235 expect(dash).toContain(escaped);
236236
test/web-apps.test.ts +1 −1
@@ -291,7 +291,7 @@ describe("apps, runs and the crew on the web", () => {
291291
292292 test("the dashboard and /apps list the app with its state and URL", async () => {
293293 // The home's rail names your apps and offers New app (#625).
294 const dash = await bodyOf(alice, "/");
294 const dash = await bodyOf(alice, "/feed");
295295 expect(dash).toContain("<h2>Your apps</h2>");
296296 expect(dash).toContain("alice/hello");
297297 expect(dash).toContain('href="/apps/new"');
test/web-browse.test.ts +1 −1
@@ -607,7 +607,7 @@ describe("browsing an origin repository on the web", () => {
607607 const pages = {
608608 repo: await bodyOf(alice, "/alice/widgets"),
609609 picker: await bodyOf(alice, `/settings/origins/${originId}/repos`),
610 home: await bodyOf(alice, "/"),
610 home: await bodyOf(alice, "/feed"),
611611 search: await bodyOf(alice, "/search?q=widgets"),
612612 };
613613 const strip = stripOf(pages.repo);
test/web-origins.test.ts +1 −1
@@ -599,7 +599,7 @@ describe("origins on the web", () => {
599599 expect(picker).toContain("mirrored as");
600600 expect(picker).toContain("this machine");
601601
602 const dash = await (await asUser(`${alice}; forge_mode=dev`, "/")).text();
602 const dash = await (await asUser(`${alice}; forge_mode=dev`, "/feed")).text();
603603 expect(dash).toContain("On your machine");
604604 expect(dash).toContain("forge sync");
605605 expect(dash).toContain('href="/alice/widgets"');
test/xss-everywhere.test.ts +1 −1
@@ -138,7 +138,7 @@ describe("one hostile string, every surface", () => {
138138 });
139139
140140 const pages: [string, () => string][] = [
141 ["the home feed", () => "/"],
141 ["the home feed", () => "/feed"],
142142 ["the issue", () => `/alice/game/issues/${issueNo}`],
143143 ["the pull", () => `/alice/game/pulls/${pullNo}`],
144144 ["the issue list", () => "/alice/game/issues"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs