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

Milestones at the front door: how close the big things are, what moved this week, and the wave that moved them #10987

Merged opened this in answer to

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

Merged

scout/forge:scout/1083-milestones-at-the-front-door-how-close-t → main 9 commits fork 5 threads · 5 unresolved

  • Merged. scout/1083-milestones-at-the-front-door-how-close-t was merged into main as ffa67cc.

Review

Sign in to review this change.
17 files changed +675 −16
docs/design/areas/milestones.md +4
@@ -25,3 +25,7 @@ a `ul.list.setting-list` with no `.card` around it, and a folded tail is a
2525 `details` under the rows, not a card foot. How far it is stands as a plain
2626 `#ms-progress` stack. No new component class; `limb.ts` and the re-plan page
2727 share the same rows.
28
29 ## Front door lines
30
31 The few biggest open milestones on the home and an app's page are plain `row`s in a `ul.list.setting-list`, the same rows as the milestone list; the "Last moved in wave" line on a milestone is one such row. They paint nothing when nothing moved. No new component class.
outdated

Contradicts 'No new component class' earlier in this file. Better: remove the class and reuse rows.

docs/features/front-milestones.md added +10
@@ -0,0 +1,10 @@
1 # Milestones at the front door (#1083)
2
3 The few biggest open milestones sit on the home, on the signed-out door and on an app's page, as plain rows: the name, how far it is, what landed in 7 days (or "quiet this week"), and the estimate when there is one. A region paints nothing when none of them moved this week. No spend is ever shown: that is the owner's, on the milestones page.
4
5 - Read: `biggestMilestones` in `src/web/studio/milestone_lines.ts`, over the plan the milestones pages use (`planFor`). At most 3 milestones from at most 30 apps.
6 - Whose apps: `frontApps`. A signed-in person gets their own apps (an admin, every one); a guest gets the public ones. An app's page reads only that app, for whoever may read it.
7 - Live: `GET /live/milestones` and `GET /:owner/:name/app/milestones` redraw the region on `issue.*`, `pull.merged` and `pull.closed`.
8 - The wave that moved a milestone: its page says "Last moved in wave X", and its JSON has `lastWave` and `movedThisWeek`. The newest 8 wave files are read from git once per repository's latest event (`src/story/wave_last.ts`), then kept for a minute.
9 - Terminal: the palette entry **Milestones** (`src/tui/screens/frontms.ts`) lists the same rows, gated by the same `frontApps`; `enter` opens one on the web.
10 - Tests: `test/milestone-front-door.test.ts`, `test/tui-front-milestones.test.ts`.

Non-blocking: test/tui-front-milestones.test.ts is not in the pull. Name test/front-milestones-private.test.ts instead.

src/core/milestones.ts +2
@@ -649,6 +649,8 @@ function weightJson(weight: Weight): Record<string, unknown> {
649649 open: weight.open,
650650 building: weight.building,
651651 mergedInDays: { days: PILLAR_DAYS, merged: weight.merged },
652 // What landed in the last 7 days, named for who asks "what moved".
653 movedThisWeek: weight.merged,
652654 lastMovement: weight.last,
653655 ...(weight.spentUsd === undefined ? {} : { spentUsd: weight.spentUsd }),
654656 };
src/http/server.ts +6
@@ -240,6 +240,10 @@ import { spaceJson, spacePage } from "../web/pages/space.ts";
240240 import { firstSubmit } from "../web/pages/first.ts";
241241 import { nextPage, nextSubmit } from "../web/pages/next.ts";
242242 import { weekPage, weekSubmit } from "../web/pages/week.ts";
243 import {
244 appMilestonesFragment,
245 liveMilestonesFragment,
246 } from "../web/studio/milestone_lines.ts";
243247 import {
244248 milestonePage,
245249 milestonesPage,
@@ -980,6 +984,7 @@ export function buildRouter(): Router<Handler> {
980984 router.add("GET", "/live/on-you", liveOnYouFragment);
981985 router.add("GET", "/live/client-apps", liveClientAppsFragment);
982986 router.add("GET", "/live/feed", liveFeedFragment);
987 router.add("GET", "/live/milestones", liveMilestonesFragment);
983988 router.add("GET", "/live/moving", liveMovingFragment);
984989 router.add("GET", "/live/chips", liveChipsFragment);
985990 router.add("GET", "/feed", feedPage);
@@ -1333,6 +1338,7 @@ export function buildRouter(): Router<Handler> {
13331338 router.add("GET", "/:owner/:name/app/state", appStateFragment);
13341339 // "Now" on the same page, alone: the region's own address.
13351340 router.add("GET", "/:owner/:name/app/now", appNowFragment);
1341 router.add("GET", "/:owner/:name/app/milestones", appMilestonesFragment);
13361342 router.add("GET", "/:owner/:name/app/answers", appAnswersFragment);
13371343 router.add("GET", "/:owner/:name/app/:env/open", appOpen);
13381344 // Where a private app sends a person who arrived by its link.
src/story/wave_last.ts added +80
@@ -0,0 +1,80 @@
1 /**
2 * The wave that last moved each milestone: read the newest waves, ask each
3 * which milestones its pulls moved (`wave_moved.ts`), and keep, per milestone,
4 * the first (newest) wave to name it. Bounded: only the newest few waves are
5 * read, so a milestone that moved long ago has no wave rather than a slow page.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import type { Plan } from "../core/milestones.ts";
10 import { canRead, type Repo } from "../core/repos.ts";
11 import type { User } from "../core/users.ts";
12 import { repoDir } from "../git/repos.ts";
13 import { movedForViewer } from "./wave_moved.ts";
14 import { ViewCache } from "../view/cache.ts";
15 import { repoHeads } from "../web/map_cache.ts";
16 import { pullRefs, readWaves, wavePath, type WaveEntry } from "./waves.ts";
17
18 /** How many of the newest waves are read for this. */
19 export const WAVES_READ = 8;
20
21 export interface LastWave {
22 version: string;
23 date: string;
24 headline: string;
25 /** Where the wave's page is on this forge. */
26 href: string;
27 }
28
29 /**
30 * The parsed wave files, kept per repository and its latest event: a push to
outdated

Reads the git wave files on every call, uncached. Cache by repo head.

31 * the branch is an event, so a new wave reads fresh; the TTL covers the rest.
32 * Nothing about a viewer is in here, only what the files say.
33 */
34 const waveFiles = new ViewCache({ maxEntries: 64 });
35 const WAVES_TTL_MS = 60_000;
36
37 /** Milestone number to the newest wave that moved it. */
38 export async function lastWaves(input: {
39 db: Database;
40 reposRoot: string;
41 repo: Repo;
42 viewer: User | undefined;
43 plan: () => Plan;
44 }): Promise<Map<number, LastWave>> {
45 const { repo } = input;
46 // Defence in depth: the routes gate the repository, and so does this.
47 if (!canRead(input.db, repo, input.viewer)) return new Map();
48 const key = `${repo.id}:${repoHeads(input.db, [repo.id]).get(repo.id) ?? 0}:${repo.defaultBranch}`;
49 let entries = waveFiles.get<WaveEntry[]>(key);
50 if (entries === undefined) {
51 entries = (
52 await readWaves(
53 repoDir(input.reposRoot, repo.ownerName, repo.name),
54 repo.defaultBranch,
55 )
56 ).slice(0, WAVES_READ);
57 waveFiles.set(key, entries, WAVES_TTL_MS);
58 }
59 const out = new Map<number, LastWave>();
60 let plan: Plan | undefined;
61 for (const e of entries) {
62 if (!e.ok) continue;
63 const moved = movedForViewer({
64 db: input.db,
65 repo,
66 refs: pullRefs(e.wave),
67 viewer: input.viewer,
68 plan: () => (plan ??= input.plan()),
69 });
70 for (const m of moved)
71 if (!out.has(m.number))
72 out.set(m.number, {
73 version: e.wave.version,
74 date: e.wave.date,
75 headline: e.wave.headline,
76 href: wavePath(repo.ownerName, repo.name, e.wave.version),
77 });
78 }
79 return out;
80 }
src/tui/screens/entries.ts +2
@@ -39,6 +39,7 @@ import { entry as features } from "./featureswitch.ts";
3939 import { entry as forgeView } from "./forgeview.ts";
4040 import { entry as flowsAll } from "./flowsall.ts";
4141 import { entry as front } from "./front.ts";
42 import { entry as frontMilestones } from "./frontms.ts";
4243 import { entry as forges } from "./forges.ts";
4344 import { entry as homePins } from "./home_pins.ts";
4445 import { entry as invites } from "./invites.ts";
@@ -156,6 +157,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
156157 profile,
157158 dotfiles,
158159 now,
160 frontMilestones,
159161 forgeView,
160162 repos,
161163 displays,
src/tui/screens/frontms.ts added +62
@@ -0,0 +1,62 @@
1 /**
2 * The milestones at the front door as a screen (#1083): the few biggest open
3 * milestones, each with how far it is, what landed in 7 days and its
4 * estimate. The same read as the home's lines (`biggestMilestones` over
5 * `frontApps`): a guest sees the public apps' only, a signed-in person their
6 * own apps', and no spend, which is the owner's and has its own page. Enter
7 * opens the milestone on the web.
8 */
9
10 import type { Ctx, Screen } from "../app/app.ts";
11 import { binding } from "../app/keys.ts";
12 import { biggestMilestones, frontApps, lineFacts } from "../../web/studio/milestone_lines.ts";
13 import { milestoneHref } from "../../web/pages/milestones.ts";
14 import { repoBase } from "../../web/chrome.ts";
15 import { style } from "../term/style.ts";
16 import { truncate } from "../term/width.ts";
17 import type { Session } from "../session.ts";
18 import { fill, len, split } from "../ui/layout.ts";
19 import { ListState, drawList, openOnWeb, type ListRow } from "./common.ts";
20 import type { PaletteEntry } from "./entries.ts";
21
22 /** The lines the screen draws, read each time so a change shows on the next key. */
23 export function frontLines(f: Session) {
24 return biggestMilestones(f, frontApps(f, f.user));
25 }
26
27 export function frontMilestonesScreen(f: Session, ctx: Ctx): Screen {
28 const state = new ListState();
29 return {
30 title: "Milestones",
31 keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "open on the web")],
32 render(area, s) {
33 const c = ctx.theme.c;
34 const lines = frontLines(f);
35 const [head, list] = split(area, "v", [len(1), fill(1)]);
36 const note = lines.length === 0 ? "No open milestones." : f.user ? "The biggest open milestones of your apps." : "The biggest open milestones of the public apps.";
37 s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
38 const rows: ListRow[] = lines.map((r) => ({ title: r.limb.name, secondary: lineFacts(r, false) }));
39 drawList(s, list!, c, state, rows);
40 },
41 key(e) {
42 const lines = frontLines(f);
43 if (state.key(e, lines.length)) return true;
44 const at = lines[state.index];
45 if (e.key === "enter" && !e.ctrl && !e.alt && at !== undefined) {
46 openOnWeb(f, ctx, milestoneHref(repoBase(at.repo), at.limb.issue.number), "That milestone");
47 return true;
48 }
49 return false;
50 },
51 };
52 }
53
54 /** Offered to everyone: what it lists is gated as the page's lines are. */
55 export const entry: PaletteEntry = {
56 id: "front-milestones",
57 title: "Milestones",
58 hint: "the biggest open milestones: how far, what landed this week",
59 after: ["now"],
60 offered: () => true,
61 run: (session, ctx) => ctx.push(frontMilestonesScreen(session, ctx)),
62 };
src/web/pages/milestones.ts +52 −11
@@ -40,9 +40,10 @@ import { watchMilestone } from "../../actions/watches.ts";
4040 import { replanResult } from "../../actions/limb_replan.ts";
4141 import { given, pendingFor } from "../../core/limb_replan.ts";
4242 import type { Ctx } from "../../http/context.ts";
43 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
43 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
4444 import { cachedPlan } from "../map_cache.ts";
4545 import { milestonePath } from "../../story/wave_plan.ts";
46 import { lastWaves, type LastWave } from "../../story/wave_last.ts";
4647 import { askToLiveFor } from "../ask_to_live.ts";
4748 import {
4849 crumbsFor,
@@ -203,7 +204,7 @@ function limbLine(base: string, limb: Limb, move: Html | null = null): Html {
203204 }
204205
205206 /** The repository's plan, kept while nothing it reads changes. */
206 export const planFor = (ctx: Ctx, repo: Repo, spend: boolean, now: Date): Plan =>
207 export const planFor = (ctx: Pick<Ctx, "app"> & { request?: Request }, repo: Repo, spend: boolean, now: Date, named = true): Plan =>
207208 cachedPlan(
208209 {
209210 repoId: repo.id,
@@ -215,7 +216,9 @@ export const planFor = (ctx: Ctx, repo: Repo, spend: boolean, now: Date): Plan =
215216 readPlan(ctx.app.db, repo, {
216217 spend,
217218 now,
218 stretch: (name, ms) => notePhase(ctx.request, `plan-${name}`, ms),
219 ...(named && ctx.request !== undefined
220 ? { stretch: (name: string, ms: number) => notePhase(ctx.request!, `plan-${name}`, ms) }
221 : {}),
219222 }),
220223 );
221224
@@ -548,7 +551,10 @@ function milestoneBody(
548551 }
549552
550553 /** GET /:owner/:name/milestones/:number — one milestone, opened. */
551 export function milestonePage(ctx: Ctx, now: Date = new Date()): Response {
554 export async function milestonePage(
555 ctx: Ctx,
556 now: Date = new Date(),
557 ): Promise<Response> {
552558 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
553559 const repo = timePhase(ctx.request, "repo", () => loadRepo(ctx));
554560 if (!repo) return notFoundPage(ctx);
@@ -559,8 +565,19 @@ export function milestonePage(ctx: Ctx, now: Date = new Date()): Response {
559565 : undefined;
560566 const milestone = plan?.milestone(number);
561567 if (plan === undefined || milestone === undefined) return notFoundPage(ctx);
562 const rendering = performance.now();
563568 const viewer = ctx.auth.user;
569 const wave = (
570 await timePhaseAsync(ctx.request, "waves", () =>
571 lastWaves({
572 db: ctx.app.db,
573 reposRoot: ctx.app.reposRoot,
574 repo,
575 viewer,
576 plan: () => plan,
577 }),
578 )
579 ).get(number);
580 const rendering = performance.now();
564581 // Watch is each person's own; acting on the whole limb is for whoever
565582 // administers the repository. One row, Watch first.
566583 const watch =
@@ -595,6 +612,7 @@ export function milestonePage(ctx: Ctx, now: Date = new Date()): Response {
595612 palette: repoPalette(ctx, repo),
596613 body: html`${repoHeader(ctx, repo, "map")}<div data-milestone>
597614 ${milestoneBody(repo, milestone, owner, actions, move)}
615 ${wave === undefined ? null : html`<ul class="list setting-list" id="ms-wave">${row({ nav: true, href: wave.href, title: `Last moved in wave ${wave.version}`, meta: html`${wave.headline}`, trail: html`${wave.date}` })}</ul>`}
598616 ${replanChanged(ctx, repo, milestone)}</div>
599617 ${liveMount({ repo: repoBase(repo) })}`,
600618 scriptJs: liveClientJs,
@@ -638,15 +656,32 @@ export async function milestonesJson(
638656 spend: canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user),
639657 });
640658 const ask = askByUmbrella(ctx, repo);
659 const waves = await lastWaves({
660 db: ctx.app.db,
661 reposRoot: ctx.app.reposRoot,
662 repo,
663 viewer: ctx.auth.user,
664 plan: () => plan,
665 });
641666 return {
642667 repo: { owner: repo.ownerName, name: repo.name },
643668 limbs: plan.limbs.map((limb) => ({
644669 ...limbJson(limb),
645670 ...askOf(ask, limb.issue.number),
671 ...lastWaveOf(waves, limb.issue.number),
646672 })),
647673 };
648674 }
649675
676 /** The wave that last moved a milestone, as data: absent when none did. */
677 const lastWaveOf = (
678 waves: ReadonlyMap<number, LastWave>,
679 number: number,
680 ): { lastWave?: LastWave } => {
681 const w = waves.get(number);
682 return w === undefined ? {} : { lastWave: w };
683 };
684
650685 /** Each pillar's ask-to-live, by its umbrella's number: what its limb shows too. */
651686 function askByUmbrella(
652687 ctx: Ctx,
@@ -679,16 +714,22 @@ export async function milestoneData(
679714 now: Date = new Date(),
680715 ): Promise<Record<string, unknown> | undefined> {
681716 const owner = canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user);
682 const milestone = readPlan(ctx.app.db, repo, { spend: owner, now }).milestone(
683 number,
684 );
717 const plan = readPlan(ctx.app.db, repo, { spend: owner, now });
718 const milestone = plan.milestone(number);
685719 const viewer = ctx.auth.user;
686 return milestone === undefined
687 ? undefined
688 : {
720 if (milestone === undefined) return undefined;
721 const waves = await lastWaves({
722 db: ctx.app.db,
723 reposRoot: ctx.app.reposRoot,
724 repo,
725 viewer,
726 plan: () => plan,
727 });
728 return {
689729 repo: { owner: repo.ownerName, name: repo.name },
690730 ...milestoneJson(milestone, owner),
691731 ...askOf(askByUmbrella(ctx, repo, number), number),
732 ...lastWaveOf(waves, number),
692733 // Whether the caller watches it; a caller who cannot has no answer.
693734 ...(viewer?.kind === "user"
694735 ? {
src/web/studio/app.ts +3
@@ -53,6 +53,7 @@ import {
5353 row,
5454 } from "../ui.ts";
5555 import { answersRegion } from "./answers.ts";
56 import { appMilestones } from "./milestone_lines.ts";
5657 import { nowCard, nowRegion } from "./now.ts";
5758 import { repoAsks } from "./asks.ts";
5859 import { notesCard } from "./notes.ts";
@@ -486,6 +487,7 @@ export async function studioApp(
486487 ${tellRegion}
487488 <div id="app-previews"></div>
488489 ${nowRegion(repo, now)}
490 ${appMilestones(ctx, repo)}
489491 ${more}
490492 </div>
491493 ${liveMount({ repo: base, app: true })}`
@@ -514,6 +516,7 @@ export async function studioApp(
514516 ${tellRegion}
515517 <div id="app-previews">${previews}</div>
516518 ${nowRegion(repo, now)}
519 ${appMilestones(ctx, repo)}
517520 ${notesCard(ctx, repo, record)}
518521 </div>
519522 <aside class="stack">
src/web/studio/asks.ts +1 −1
@@ -98,7 +98,7 @@ export function appsOf(ctx: Pick<Ctx, "app">, user: User | undefined, archived =
9898 * repositories, with their records. This is "your apps": the studio home's
9999 * shelf and its cost line, never widened by what a stranger merely made
100100 * public. */
101 export function writableAppsOf(ctx: Ctx, user: User | undefined) {
101 export function writableAppsOf(ctx: Pick<Ctx, "app">, user: User | undefined) {
102102 return appsFiltered(
103103 ctx,
104104 listReposWritableTo(ctx.app.db, user, {
src/web/studio/home.ts +7 −1
@@ -39,6 +39,7 @@ import { html, when, type Html } from "../html.ts";
3939 import { fragment, page, redirect } from "../layout.ts";
4040 import { siteStructuredData } from "../structured_data.ts";
4141 import { running } from "../../kernel/version.ts";
42 import { homeMilestones } from "./milestone_lines.ts";
4243 import { liveClientJs } from "../live.ts";
4344 import {
4445 btn,
@@ -359,6 +360,7 @@ export async function studioHome(
359360 const allowed = untoldAnswer(ctx.app.db, user.id) === "allowed";
360361 if (allowed) markTold(ctx.app.db, user.id, new Date().toISOString());
361362 const needsYou = html`${requests}${waiting}${held}${alarms}`;
363 const mine = writableAppsOf(ctx, user).repos;
362364 const body = html`<h1 class="sr-only">Home</h1>
363365 ${allowed ? html`<div class="toast" data-tone="success" role="status" id="youre-in"><strong>${YOURE_IN_MESSAGE}</strong> Your account is open: tell the crew what you want to build.</div>` : null}
364366 ${tellBox(ctx, user)}
@@ -366,9 +368,10 @@ export async function studioHome(
366368 ${when(String(needsYou).trim() !== "", () => html`<section class="stack stack-tight" id="needs-you" aria-label="Needs you"><h2 class="small muted">Needs you</h2>${needsYou}</section>`)}
367369 ${when(String(since).trim() !== "", () => html`<section aria-label="Since you were here">${since}</section>`)}
368370 <section aria-label="Live">${timePhase(ctx.request, "moving", () => movingRegion(ctx.app.db, user))}</section>
371 ${homeMilestones(ctx, mine)}
369372 <section class="stack" aria-label="The forge">${kpis}</section>
370373 ${pinnedSlot(ctx.app.db, user)}
371 ${appChips(ctx, user)}
374 ${appChips(ctx, user, mine)}
372375 ${when(
373376 steps,
374377 () => html`<p class="small muted" id="lean-home"><a href="/">Just my apps</a></p>`,
@@ -869,6 +872,8 @@ export function studioDoor(ctx: Ctx): Response {
869872 </ul>
870873 </section>
871874
875 <div class="door-section">${homeMilestones(ctx)}</div>
876
872877 <section class="door-section door-split" id="yours">
873878 <div class="stack">
874879 <h2>Get your own forge</h2>
@@ -904,6 +909,7 @@ bun run src/main.ts</code></pre>
904909 title: "forge: software for what you're building, yours to keep",
905910 description: "Your own forge, with a private AI crew that builds your apps, games and business tools. Open source, and yours to keep.",
906911 body,
912 scriptJs: `${liveClientJs}\n${liveRegionsJs}`,
907913 jsonLd: siteStructuredData(origin, running().version),
908914 ...(visitor
909915 ? {
src/web/studio/living.ts +3 −2
@@ -18,6 +18,7 @@ import { btn, disclosure, row, timeAgo } from "../ui.ts";
1818 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1919 import type { NowStage } from "../../flow/now_rows.ts";
2020 import { nowHref } from "../pages/now.ts";
21 import type { Repo } from "../../core/repos.ts";
2122 import { writableAppsOf } from "./asks.ts";
2223 import { FORGE_TARGET } from "./feed.ts";
2324 import { COMPOSER_FIELDS, TELL_MAX, sharedDraft, shareToken } from "../share_target.ts";
@@ -127,8 +128,8 @@ export function tellBox(ctx: Ctx, user: User): Html {
127128 }
128129
129130 /** Your apps as chips, each a link to the app, and a plus for a new one. */
130 export function appChips(ctx: Ctx, user: User): Html {
131 const { repos } = writableAppsOf(ctx, user);
131 export function appChips(ctx: Ctx, user: User, apps?: readonly Repo[]): Html {
132 const repos = apps ?? writableAppsOf(ctx, user).repos;
132133 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">
133134 <h2 class="small muted">Your apps</h2>
134135 <p class="bar small">
src/web/studio/milestone_lines.ts added +132
@@ -0,0 +1,132 @@
1 /**

Both doors: no src/tui/ screen and no docs/features/ how-to for this feature. Add both; the terminal gates reads like the page.

2 * Milestones at the front door (#1083): the few biggest open milestones as
3 * quiet lines — name, done of total, what landed this week, the estimate —
4 * on the home and on an app's page. Read from `core/milestones.ts`, the
5 * same numbers as the milestones pages; never spend, which is the owner's
6 * and has its own page. A region that has nothing moved paints nothing.
7 */
8
9 import type { Limb, Plan } from "../../core/milestones.ts";
10 import type { Repo } from "../../core/repos.ts";
11 import { PILLAR_DAYS } from "../../core/pillars.ts";
12 import type { Ctx } from "../../http/context.ts";
13 import { fragment } from "../layout.ts";
14 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
15 import { loadRepo, repoBase } from "../chrome.ts";
16 import { html, type Html } from "../html.ts";
17 import { row } from "../ui.ts";
18 import type { User } from "../../core/users.ts";
19 import { milestoneHref, planFor } from "../pages/milestones.ts";
20 import { appsOf, writableAppsOf } from "./asks.ts";
21
22 /** How many milestones the front door names. */
23 export const FRONT_MILESTONES = 3;
24 /** How many apps the home reads plans of, newest first: a bound on the work. */
25 const REPOS_READ = 30;
26 /** The events that can move a milestone. */
27 const MILESTONE_LIVE = "issue.*,pull.merged,pull.closed";
28
29 export interface Line {
30 readonly repo: Repo;
31 readonly limb: Limb;
32 readonly estimate: string;
33 }
34
35 /** The biggest open milestones of these repositories, by open work beneath them. */
36 export function biggestMilestones(
37 ctx: Pick<Ctx, "app">,
38 repos: readonly Repo[],
39 count = FRONT_MILESTONES,
40 ): Line[] {
41 const all: { repo: Repo; limb: Limb; plan: Plan }[] = [];
42 for (const repo of repos.slice(0, REPOS_READ)) {
43 // Spend is never read here: the front door shows no owner facts.
44 const plan = planFor(ctx, repo, false, new Date(), false);
45 for (const limb of plan.limbs)
46 if (!limb.deferred && limb.issue.state === "open" && limb.weight.open > 0)
47 all.push({ repo, limb, plan });
48 }
49 all.sort(
50 (a, b) =>
51 b.limb.weight.open - a.limb.weight.open ||
52 a.limb.issue.number - b.limb.issue.number,
53 );
54 return all.slice(0, count).map(({ repo, limb, plan }) => ({
55 repo,
56 limb,
57 estimate: plan.milestone(limb.issue.number)?.estimate ?? "",
58 }));
59 }
60
61 /** What a line says after its name: how far, what landed, the estimate if there is one. */
62 export function lineFacts(r: Line, scoped: boolean): string {
63 const w = r.limb.weight;
64 return [
65 scoped ? "" : r.repo.name,
66 `${w.done} of ${w.total} done`,
67 w.merged > 0 ? `${w.merged} landed in ${PILLAR_DAYS} days` : "quiet this week",
68 r.estimate,
69 ]
70 .filter((part) => part !== "")
71 .join(" · ");
72 }
73
74 /** The lines: nothing at all when nothing among them moved this week. */
75 function lines(rows: readonly Line[], scoped: boolean): Html | null {
76 if (!rows.some((r) => r.limb.weight.merged > 0)) return null;
77 return html`<ul class="list setting-list">
78 ${rows.map((r) =>
79 row({
80 nav: true,
81 href: milestoneHref(repoBase(r.repo), r.limb.issue.number),
82 title: r.limb.name,
83 meta: html`${lineFacts(r, scoped)}`,
84 }),
85 )}
86 </ul>`;
87 }
88
89 /** The apps whose milestones the home names: the viewer's own, or the public ones for a visitor. */
90 export function frontApps(ctx: Pick<Ctx, "app">, user: User | undefined): Repo[] {
91 return (user ? writableAppsOf(ctx, user) : appsOf(ctx, undefined)).repos;
92 }
93
94 /** The home's region: the viewer's apps, or the public ones for a visitor. */
95 export function homeMilestones(ctx: Ctx, apps?: readonly Repo[]): Html {
96 const repos = apps ?? frontApps(ctx, ctx.auth.user);
97 return html`<div
98 id="live-milestones"
99 data-live="${MILESTONE_LIVE}"
100 data-live-src="/live/milestones"
101 >
102 ${lines(biggestMilestones(ctx, repos), false)}
103 </div>`;
104 }
105
106 /** An app's region: its own milestones, for whoever may read it. */
107 export function appMilestones(ctx: Ctx, repo: Repo): Html {
108 return html`<div
109 id="live-milestones"
110 data-live="${MILESTONE_LIVE}"
111 data-live-repo="${repo.ownerName}/${repo.name}"
112 data-live-src="${repoBase(repo)}/app/milestones"
113 >
114 ${lines(biggestMilestones(ctx, [repo]), true)}
115 </div>`;
116 }
117
118 // GET /live/milestones — the home's lines alone.
119 export function liveMilestonesFragment(ctx: Ctx): Response {
120 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
121 notePhase(ctx.request, "render", 0);
122 return fragment(homeMilestones(ctx), { request: ctx.request });
123 }
124
125 // GET /:owner/:name/app/milestones — an app's lines alone.
126 export function appMilestonesFragment(ctx: Ctx): Response {
127 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
128 notePhase(ctx.request, "render", 0);
129 const repo = loadRepo(ctx);
130 if (!repo) return fragment(html``, { status: 404 });
131 return fragment(appMilestones(ctx, repo), { request: ctx.request });
132 }
test/front-milestones-private.test.ts added +158
@@ -0,0 +1,158 @@
1 /**
2 * Milestones at the front door never carry a private repository to a guest or
3 * to another user: not its lines on `/live/milestones`, not its region at
4 * `/app/milestones`, not its wave through `lastWave` (page, list and single
5 * JSON, and `lastWaves` itself). The terminal screen reads the same.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { linkIssue } from "../src/actions/dependencies.ts";
12 import { openIssue } from "../src/actions/issues.ts";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { createApp, type App } from "../src/app.ts";
15 import { createApp as createAppRecord } from "../src/core/apps.ts";
16 import { getIssueById, setIssueState, type Issue } from "../src/core/issues.ts";
17 import { readPlan } from "../src/core/milestones.ts";
18 import { getRepoById, type Repo } from "../src/core/repos.ts";
19 import { createSession } from "../src/core/sessions.ts";
20 import { createUser, type User } from "../src/core/users.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { lastWaves } from "../src/story/wave_last.ts";
23 import { createForgeApp } from "../src/tui/forge.ts";
24 import type { TuiIdentity } from "../src/tui/session.ts";
25 import { headlessTerminal } from "../src/tui/term/terminal.ts";
26 import { quietLogs } from "./helpers/globals.ts";
27 import { settleApp } from "./helpers/tui.ts";
28 import { mergedPull } from "./helpers/wave-pulls.ts";
29 import { writeWave } from "../src/story/wave.ts";
30
31 quietLogs();
32
33 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
34 if (!r.ok) throw new Error(JSON.stringify(r.error));
35 return r.value;
36 };
37
38 let dir: string;
39 let app: App;
40 let servers: Servers;
41 let alice: User;
42 let bob: User;
43 const cookies = new Map<string, string>();
44 const repos = new Map<string, { repo: Repo; goal: Issue }>();
45
46 async function build(name: string, visibility: "public" | "private", goalTitle: string, headline: string) {
47 let repo = must(await createRepo(app, alice, { name, visibility }));
48 const fresh = () => getRepoById(app.db, repo.id)!;
49 must(await commitToBranch(app, alice, repo, { message: "init", files: [{ path: "README.md", content: "hi\n" }] }));
50 must(createAppRecord(app.db, { repoId: repo.id, slug: name, access: "public" }));
51 const open = (title: string): Issue => must(openIssue(app, alice, fresh(), { title }));
52 const goal = open(goalTitle);
53 const parts = [open(`${name} part a`), open(`${name} part b`)];
54 for (const p of parts) must(linkIssue(app, alice, fresh(), getIssueById(app.db, goal.id)!, getIssueById(app.db, p.id)!));
55 setIssueState(app.db, parts[0]!.id, "closed", "done");
56 const n = await mergedPull(app, alice, repo, `${name} work`);
57 app.db.query("UPDATE issue SET body = ? WHERE repo_id = ? AND number = ?").run(`Closes #${parts[0]!.number}`, repo.id, n);
58 app.db.query("UPDATE pull SET merged_at = ? WHERE issue_id = (SELECT id FROM issue WHERE repo_id = ? AND number = ?)").run(new Date().toISOString(), repo.id, n);
59 const w = writeWave({
60 version: "0.1.0",
61 date: "2026-03-05",
62 headline,
63 highlights: [1, 2, 3].map((k) => ({ title: `Thing ${k}`, why: "because" })),
64 pulls: [{ title: `#${n} as written`, url: `https://forge.example/alice/${name}/pulls/${n}` }],
65 });
66 if (!w.ok) throw w.error;
67 must(await commitToBranch(app, alice, fresh(), { message: "wave", files: [{ path: "waves/0.1.0.md", content: w.value }] }));
68 repo = fresh();
69 repos.set(name, { repo, goal });
70 }
71
72 beforeAll(async () => {
73 dir = await mkdtemp(join(tmpdir(), "forge-front-private-"));
74 app = createApp({ dataDir: dir, port: 0 });
75 servers = await serve(app);
76 const mk = async (username: string, adminIfFirst: boolean) =>
77 must(await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.dev`, password: "a-good-passphrase", adminIfFirst }));
78 alice = await mk("alice", true);
79 bob = await mk("bob", false);
80 for (const u of [alice, bob]) cookies.set(u.username, `forge_session=${createSession(app.db, u.id).token}; forge_mode=dev`);
81 await build("open", "public", "Open goal", "Open wave headline");
82 await build("hidden", "private", "Hidden goal", "Hidden wave headline");
83 }, 60_000);
84
85 afterAll(async () => {
86 servers.stop();
87 app.close();
88 await rm(dir, { recursive: true, force: true });
89 });
90
91 const as = (who: "guest" | "alice" | "bob") => (path: string) =>
92 fetch(`${servers.url}${path}`, { headers: who === "guest" ? {} : { cookie: cookies.get(who)! } }).then(async (r) => ({ status: r.status, text: await r.text() }));
93
94 const SECRETS = ["Hidden goal", "Hidden wave headline", "hidden part", "/alice/hidden/"];
95
96 test("a guest and another user get none of a private repository's lines, region, page or data", async () => {
97 const n = repos.get("hidden")!.goal.number;
98 for (const who of ["guest", "bob"] as const) {
99 const get = as(who);
100 const live = await get("/live/milestones");
101 expect(live.status).toBe(200);
102 for (const s of SECRETS) expect(live.text).not.toContain(s);
103 for (const path of ["/alice/hidden/app/milestones", `/alice/hidden/milestones/${n}`, "/alice/hidden/milestones", "/api/v1/repos/alice/hidden/milestones", `/api/v1/repos/alice/hidden/milestones/${n}`]) {
104 const got = await get(path);
105 expect([path, got.status]).toEqual([path, 404]);
106 for (const s of SECRETS) expect(got.text).not.toContain(s);
107 }
108 }
109 // The same doors do answer the one who may read it.
110 const mine = await as("alice")("/alice/hidden/app/milestones");
111 expect(mine.text).toContain("Hidden goal");
112 const page = await as("alice")(`/alice/hidden/milestones/${n}`);
113 expect(page.text).toContain("Hidden wave headline");
114 });
115
116 test("a public repository still shows to a guest, without its private neighbour", async () => {
117 const live = await as("guest")("/live/milestones");
118 expect(live.text).toContain("Open goal");
119 expect(live.text).not.toContain("Hidden goal");
120 });
121
122 test("lastWaves answers nothing for a viewer who may not read the repository", async () => {
123 const { repo, goal } = repos.get("hidden")!;
124 const plan = () => readPlan(app.db, repo, { spend: false, now: new Date() });
125 for (const viewer of [undefined, bob]) {
126 const got = await lastWaves({ db: app.db, reposRoot: app.reposRoot, repo, viewer, plan });
127 expect(got.size).toBe(0);
128 }
129 const owner = await lastWaves({ db: app.db, reposRoot: app.reposRoot, repo, viewer: alice, plan });
130 expect(owner.get(goal.number)?.headline).toBe("Hidden wave headline");
131 });
132
133 test("the terminal's Milestones screen lists the same: a guest the public apps only, the owner both", async () => {
134 const screen = async (user: User | undefined) => {
135 const terminal = headlessTerminal(140, 32);
136 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
137 const { app: tui } = createForgeApp(app, identity, { terminal, exitHooks: false });
138 tui.start();
139 const key = (k: string, ctrl = false) => tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
140 key("k", true);
141 await settleApp(tui);
142 for (const ch of "Milestones") key(ch);
143 await settleApp(tui);
144 key("enter");
145 await settleApp(tui);
146 const text = tui.screen.toText();
147 tui.stop();
148 return text;
149 };
150 const guest = await screen(undefined);
151 expect(guest).toContain("Open goal");
152 expect(guest).not.toContain("Hidden goal");
153 const other = await screen(bob);
154 expect(other).not.toContain("Hidden goal");
155 const owner = await screen(alice);
156 expect(owner).toContain("Hidden goal");
157 expect(owner).toContain("1 of 2 done");
158 });
test/milestone-front-door.test.ts added +148
@@ -0,0 +1,148 @@
1 /** Milestones at the front door (#1083): live lines, the wave that moved one, and the data's `movedThisWeek` and `lastWave`. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { linkIssue } from "../src/actions/dependencies.ts";
7 import { openIssue } from "../src/actions/issues.ts";
8 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
9 import { createApp, type App } from "../src/app.ts";
10 import { createApp as createAppRecord } from "../src/core/apps.ts";
11 import { getIssueById, setIssueState, type Issue } from "../src/core/issues.ts";
12 import { getRepoById, type Repo } from "../src/core/repos.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 { mergedPull, waveNaming } from "./helpers/wave-pulls.ts";
17
18 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
19 if (!r.ok) throw new Error(JSON.stringify(r.error));
20 return r.value;
21 };
22
23 let dir: string;
24 let app: App;
25 let servers: Servers;
26 let alice: User;
27 let cookie: string;
28 let repo: Repo;
29 let goal: Issue;
30 let quiet: Issue;
31 const fresh = () => getRepoById(app.db, repo.id)!;
32 const open = (title: string): Issue => must(openIssue(app, alice, fresh(), { title }));
33 const link = (waiter: Issue, on: Issue): void => {
34 must(linkIssue(app, alice, fresh(), getIssueById(app.db, waiter.id)!, getIssueById(app.db, on.id)!));
35 };
36
37 beforeAll(async () => {
38 dir = await mkdtemp(join(tmpdir(), "forge-front-ms-"));
39 app = createApp({ dataDir: dir, port: 0 });
40 servers = await serve(app);
41 alice = must(
42 await createUser(app.db, {
43 origin: "seed",
44 trusted: true,
45 username: "alice",
46 email: "[email protected]",
47 password: "a-good-passphrase",
48 adminIfFirst: true,
49 }),
50 );
51 cookie = `forge_session=${createSession(app.db, alice.id).token}; forge_mode=dev`;
52 repo = must(await createRepo(app, alice, { name: "plan", visibility: "public" }));
53 must(await commitToBranch(app, alice, repo, { message: "init", files: [{ path: "README.md", content: "hi\n" }] }));
54 must(createAppRecord(app.db, { repoId: repo.id, slug: "plan", access: "public" }));
55 goal = open("Ship the front door");
56 const parts = [open("Part A"), open("Part B"), open("Part C")];
57 for (const p of parts) link(goal, p);
58 setIssueState(app.db, parts[0]!.id, "closed", "done");
59 const n = await mergedPull(app, alice, repo, "Part A work");
60 app.db.query("UPDATE issue SET body = ? WHERE repo_id = ? AND number = ?").run(`Closes #${parts[0]!.number}`, repo.id, n);
61 // It merged just now: what landed this week.
62 app.db.query("UPDATE pull SET merged_at = ? WHERE issue_id = (SELECT id FROM issue WHERE repo_id = ? AND number = ?)").run(new Date().toISOString(), repo.id, n);
63 must(
64 await commitToBranch(app, alice, fresh(), {
65 message: "wave",
66 files: [{ path: "waves/0.1.0.md", content: waveNaming("0.1.0", [{ owner: "alice", name: "plan", number: n }]) }],
67 }),
68 );
69 // A milestone with open work and nothing landed: it has no wave.
70 quiet = open("Quiet goal");
71 link(quiet, open("Quiet part"));
72 }, 60_000);
73
74 afterAll(() => {
75 servers.stop();
76 app.close();
77 });
78
79 const get = (path: string, auth = true) =>
80 fetch(`${servers.url}${path}`, { headers: auth ? { cookie } : {} }).then((r) => r.text());
81
82 test("the home and an app's page carry live lines: name, done of total, moved this week", async () => {
83 for (const [path, src] of [
84 ["/?all=1", "/live/milestones"],
85 ["/alice/plan/app", "/alice/plan/app/milestones"],
86 ] as const) {
87 const text = await get(path);
88 expect(text).toContain(`data-live-src="${src}"`);
89 expect(text).toContain("Ship the front door");
90 expect(text).toContain("1 of 3 done");
91 expect(text).toContain("1 landed in 7 days");
92 }
93 const live = await get("/live/milestones");
94 expect(live).toContain(`/alice/plan/milestones/${goal.number}`);
95 // Biggest by open work first; the one that moved nothing says so.
96 expect(live.indexOf("Ship the front door")).toBeLessThan(live.indexOf("Quiet goal"));
97 expect(live).toContain("quiet this week");
98 // A milestone with no estimate leaves no dangling separator.
99 expect(live).not.toMatch(/·\s*<\/span>/);
100 expect(live).not.toMatch(/·\s*$/m);
101 });
102
103 test("a visitor sees a public repository's milestones, without spend", async () => {
104 const text = await get("/alice/plan/app/milestones", false);
105 expect(text).toContain("Ship the front door");
106 expect(text).not.toMatch(/\$\d/);
107 });
108
109 test("a region paints nothing when nothing moved", async () => {
110 const empty = await get("/alice/plan/app/milestones").then((t) => t.includes("row-title"));
111 expect(empty).toBe(true);
112 app.db.query("UPDATE pull SET merged_at = '2020-01-01T00:00:00.000Z'").run();
113 expect(await get("/live/milestones")).not.toContain("row-title");
114 });
115
116 test("the milestone page links back to the wave that moved it; one no wave moved has none", async () => {
117 const page = await get(`/alice/plan/milestones/${goal.number}`);
118 expect(page).toContain(`id="ms-wave"`);
119 expect(page).toContain(`href="/alice/plan/waves/0.1.0"`);
120 expect(await get(`/alice/plan/milestones/${quiet.number}`)).not.toContain("ms-wave");
121 });
122
123 test("the data carries movedThisWeek and the last wave per milestone", async () => {
124 const list = JSON.parse(await get("/api/v1/repos/alice/plan/milestones"));
125 const limb = list.items.find((l: { number: number }) => l.number === goal.number);
126 expect(limb.movedThisWeek).toBeNumber();
127 expect(limb.lastWave.version).toBe("0.1.0");
128 const one = JSON.parse(await get(`/api/v1/repos/alice/plan/milestones/${goal.number}`));
129 expect(one.lastWave.href).toBe("/alice/plan/waves/0.1.0");
130 const none = JSON.parse(await get(`/api/v1/repos/alice/plan/milestones/${quiet.number}`));
131 expect(none.lastWave).toBeUndefined();
132 });
133
134 test("the waves are read from git once per latest event, and a push shows at once", async () => {
135 const { lastWaves } = await import("../src/story/wave_last.ts");
136 const { readPlan } = await import("../src/core/milestones.ts");
137 const input = { db: app.db, reposRoot: app.reposRoot, repo: fresh(), viewer: alice, plan: () => readPlan(app.db, fresh(), { spend: false, now: new Date() }) };
138 const first = await lastWaves(input);
139 expect(first.get(goal.number)?.version).toBe("0.1.0");
140 // Unchanged repository: the same kept answer, no read of the tree.
141 const again = await lastWaves(input);
142 expect(again.get(goal.number)).toEqual(first.get(goal.number)!);
143 // A newer wave naming the same pull: a push is an event, so the next read sees it.
144 const naming = waveNaming("0.2.0", [{ owner: "alice", name: "plan", number: Number(app.db.query<{ number: number }, [string]>("SELECT MAX(number) AS number FROM issue WHERE repo_id = ? AND id IN (SELECT issue_id FROM pull)").get(repo.id)!.number) }]);
145 must(await commitToBranch(app, alice, fresh(), { message: "wave 2", files: [{ path: "waves/0.2.0.md", content: naming }] }));
146 const after = await lastWaves({ ...input, repo: fresh() });
147 expect(after.get(goal.number)?.version).toBe("0.2.0");
148 });
test/milestone-pace.test.ts +1 −1
@@ -88,7 +88,7 @@ const ctxFor = (number: number): Ctx =>
8888
8989 /** The page's pace line and the MCP's data, both at NOW. */
9090 async function both(top: Issue) {
91 const html = await milestonePage(ctxFor(top.number), NOW).text();
91 const html = await (await milestonePage(ctxFor(top.number), NOW)).text();
9292 const line = /id="ms-pace"[\s\S]*?<span>([^<]*)<\/span>\s*<\/div>/.exec(html)?.[1];
9393 const data = (await milestoneData(ctxFor(top.number), repo, top.number, NOW))!;
9494 return { html, line, data };
test/palette-roster.test.ts +4
@@ -100,6 +100,7 @@ test("a site-admin person is offered the whole roster", () => {
100100 ["bugs", "Bugs", "open bugs and the bug clock"],
101101 ["home-pins", "Pinned panels", "the panels pinned to home: reorder and unpin"],
102102 ["now", "Now", "what check, review, land and live count, with the rows"],
103 ["front-milestones", "Milestones", "the biggest open milestones: how far, what landed this week"],
103104 ["forge-view", "Forge view", "the forge's counts, and Foundry when it is installed"],
104105 ["origin-apps", "Connection apps", "your OAuth apps for connecting accounts; a adds, d removes"],
105106 ["system-memory", "System memory", "RSS beside the JS heap, hourly for 24 h"],
@@ -245,6 +246,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
245246 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
246247 ["lend-credit", "Lend a child credit", "set the dollars a forge you sent may spend on this one's model credential"],
247248 ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"],
249 ["front-milestones", "Milestones", "the biggest open milestones: how far, what landed this week"],
248250 ["forge-view", "Forge view", "the forge's counts, and Foundry when it is installed"],
249251 ["family", "Family", "the forge that sent this one, and the forges it sent"],
250252 ["chain", "Chain", "the forges this forge's code descends from, root first"],
@@ -324,6 +326,7 @@ test("a member person is offered the signed-in roster without the admin entries"
324326 ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"],
325327 ["profile", "Your profile", "display name and email"],
326328 ["dotfiles", "Your dotfiles", "the repository every shell you open copies from"],
329 ["front-milestones", "Milestones", "the biggest open milestones: how far, what landed this week"],
327330 ["forge-view", "Forge view", "the forge's counts, and Foundry when it is installed"],
328331 ["family", "Family", "the forge that sent this one, and the forges it sent"],
329332 ["chain", "Chain", "the forges this forge's code descends from, root first"],
@@ -362,6 +365,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
362365 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
363366 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
364367 ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"],
368 ["front-milestones", "Milestones", "the biggest open milestones: how far, what landed this week"],
365369 ["family", "Family", "the forge that sent this one, and the forges it sent"],
366370 ["chain", "Chain", "the forges this forge's code descends from, root first"],
367371 ["quit", "Quit", "disconnect"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs