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

The board opens on its panels, with notes in one quiet line: land pull #3774's branch with its last review asks #3857

Merged opened this

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

Merged

scout/forge:scout/3846-the-board-opens-on-its-panels-with-notes → main 6 commits fork

  • Merged. scout/3846-the-board-opens-on-its-panels-with-notes was merged into main as 85c3b52.

Review

Sign in to review this change.
12 files changed +411 −182
README.md +14
@@ -263,6 +263,20 @@ An app made from the template ships the same beacon as `static/beacon.js`, posti
263263
264264 The `app_vital` board source (`src/boards/sources.ts`) charts those reports: one row per app per day with `app` (the app's id), `day`, `load_p75`, `inp_p75` (ms) and `reports` (rows kept that day), the percentiles nearest-rank per app per day as `reach` does. `max` or `avg` over several days summarises daily percentiles, not the percentile of the whole range. Its rows belong to the app's repository, so a query sees only the repos in its scope. Today boards query with `repoIds: "all"` (`src/web/pages/boards.ts`) and only a site admin reaches them; no non-admin scope exists, so nothing yet applies the settings page's `canAdmin` gate per repository, and whoever builds one must. Days older than the 90-day trim are gone, not zero. The slug and the visitor hash are not columns. No dependency was added. Left out: a starting board for it; pick `app_vital` in the `/boards` source picker.
265265
266 ## The board's notes
267
268 `/board` opens on its panels. Everything that is not a panel — the forge's
269 health (admins), a planner that declined and waits for you, agent pulls that
270 wait for your review — is folded into one quiet line under the title, "3 notes
271 · 2 need you". It is closed by default; click it (or Tab to it and press
272 Enter) to open the list in place, and it stays open across live updates. A
273 declined planner has its answer box right in its note. The crew's own
274 housekeeping (an issue parked twice) is not a note; it is on `/map`. The
275 terminal's first screen says the declined and review-wait notes too, one line
276 each, under the forge strip; a list cut to three says "+N more".
277 The line is the shared `.quiet-details` disclosure, and the "need you" count
278 carries the attention dot.
279
266280 ## Live in the terminal
267281
268282 `ssh` into the forge and you get the whole thing — repositories, code, issues,
docs/DESIGN.md +1 −1
@@ -207,7 +207,7 @@ specification.
207207 | **assignee card** | `.card` with an `.edit-row` form | — | who is on it, then one select and one button for a writer. People first, the crew last and marked `(agent)`, because assigning an agent is what starts it working and the choice is not the same kind of choice | a picker with people, then agents |
208208 | **inbox** (`asks_page.ts`) | one list above the feed | — | _On you_: what asks you and what is assigned to you, one row each, each with what it waits for (`needsYouSection`), and one count in the top bar. Absent when empty: a card saying nothing is a zero drawn | the same two panes on the dashboard |
209209 | **look** (`pages/looks.ts`) | `.look` in a `.looks` strip | `.look[data-slot=phone\|desktop\|thumb\|now]` sizes the frame; `.looks[data-compare]` sets two sides (before · main, then after · the pull) beside each other | a picture always carries its own `width`/`height`, so nothing shifts when it loads; a phone keeps 200px, a desktop takes the rest up to its own size, and under 640px a strip stacks; an attached picture is a `figure` in the strip with its line under it | the picture in the terminal (§6·0c) |
210 | **board** (`pages/board.ts`) | `.board` (`.board-grid`, `.board-region`) | `data-wall` (across the room: no keys, no pointer, larger), `data-focus=working\|landed\|decided\|today` | the one whole-screen layout: four live regions fill a second monitor ≥1100px and each list scrolls on its own, one column below; inside, only §5 parts (list card rows, badges, meters, `kbd`); a swapped region's heading wears `data-fresh` for a moment | four panes, one focused by its number |
210 | **board** (`pages/board.ts`) | `.board` (`.board-grid`, `.board-region`) | `data-wall` (across the room: no keys, no pointer, larger), `data-focus=working\|landed\|decided\|today` | the one whole-screen layout: four live regions fill a second monitor ≥1100px and each list scrolls on its own, one column below; above the panels only a collapsed one-line `#notes` (`<details>`: closed by default, an open list stays open across live swaps), the strips below them; inside, only §5 parts (list card rows, badges, meters, `kbd`); a swapped region's heading wears `data-fresh` for a moment | four panes, one focused by its number |
211211 | **stacked bar chart** `windowSvg()` (`fleet-graph.ts`) | `.plots`, `.plot` (`.plot-name`), `.plot-legend` | bars wear `fg-sN`, the `--series-N` token | server-drawn SVG with `width`/`height` set, so it holds its box before the stylesheet; gridlines `--border`, figures `--muted-fg` in mono, panel names `--faint`; `role="img"` with an `aria-label` totalling it, a `<title>` per bar, and a table of the same numbers beside it | a table of the same numbers |
212212
213213 Two things are deliberately **not** in the vocabulary: skeleton loaders (the
src/actions/owner_waits.ts added +79
@@ -0,0 +1,79 @@
1 /**
2 * What an app's crew waits on its owner for, as plain data: the planner that
3 * declined the direction, and the agent pulls that wait for a person's
4 * review. The board's notes and the terminal's first screen both read it,
5 * so the two doors say the same things. Owner facts only: the caller passes
6 * the repositories whose owner facts the viewer may see.
7 */
8
9 import { getIssuesByIds, type Issue } from "../core/issues.ts";
10 import { joinMany } from "../core/pulls.ts";
11 import type { Repo } from "../core/repos.ts";
12 import { plannerDeclines } from "../crew/autonomy.ts";
13 import { reviewersOf, waitsForPerson } from "../crew/review.ts";
14 import type { Database } from "bun:sqlite";
15
16 export type OwnerWait =
17 | { kind: "declined"; repo: Repo; issue: Issue; runId: string }
18 | { kind: "review"; repo: Repo; none: boolean; waiting: number; since: string | null };
19
20 /** The planner declines among these repositories, one per app. */
21 export function declinedWaits(db: Database, repos: readonly Repo[]): OwnerWait[] {
22 return plannerDeclines(db, repos).map((d) => ({
23 kind: "declined",
24 repo: d.repo,
25 issue: d.issue,
26 runId: d.runId,
27 }));
28 }
29
30 /**
31 * One wait per autonomous app whose agent pulls wait for a person's review
32 * (#597), or whose crew has no reviewer; none when nothing waits and every
33 * such app has its reviewer.
34 */
35 export function reviewWaits(db: Database, repos: readonly Repo[]): OwnerWait[] {
36 const auto = repos.filter((r) => r.autonomy === "autonomous");
37 if (auto.length === 0) return [];
38 const ids = db
39 .query<{ id: string }, string[]>(
40 `SELECT issue.id AS id FROM issue JOIN user ON user.id = issue.author_id
41 WHERE issue.kind = 'pull' AND issue.state = 'open' AND user.is_agent = 1
42 AND issue.repo_id IN (${auto.map((_, i) => `?${i + 1}`).join(",")})`,
43 )
44 .all(...auto.map((r) => r.id))
45 .map((r) => r.id);
46 const pulls = joinMany(db, [...getIssuesByIds(db, ids).values()]);
47 return auto.flatMap((repo): OwnerWait[] => {
48 const waits = pulls
49 .filter((p) => p.repoId === repo.id)
50 .flatMap((p) => {
51 const w = waitsForPerson(db, repo.id, p);
52 return w === undefined ? [] : [w];
53 });
54 const none = reviewersOf(db, repo.id).length === 0;
55 if (!none && waits.length === 0) return [];
56 return [
57 { kind: "review", repo, none, waiting: waits.length, since: waits.map((w) => w.since).sort()[0] ?? null },
58 ];
59 });
60 }
61
62 /** Both kinds for these repositories. */
63 export function ownerWaits(db: Database, repos: readonly Repo[]): OwnerWait[] {
64 return [...declinedWaits(db, repos), ...reviewWaits(db, repos)];
65 }
66
67 /** One plain line, for the terminal. */
68 export function waitLine(w: OwnerWait): string {
69 if (w.kind === "declined")
70 return `${w.repo.name}: the planner declined the direction and waits for you (issue #${w.issue.number})`;
71 return `${w.repo.name}: ${w.none ? "no reviewer: agent pulls wait for you" : "agent pulls wait for your review"}${
72 w.waiting === 0 || w.since === null ? "" : ` · ${w.waiting} waiting since ${w.since.slice(0, 10)}`
73 }`;
74 }
75
76 /** The first `n` lines, then "+N more" when some were left out. */
77 export function withMore(lines: readonly string[], n: number): string[] {
78 return lines.length <= n ? [...lines] : [...lines.slice(0, n), `+${lines.length - n} more`];
79 }
src/tui/screens/dashboard.ts +15 −1
@@ -17,6 +17,7 @@
1717 import { protectionNotice } from "../../kernel/protection.ts";
1818 import { holdWords, plannerHold } from "../../crew/autonomy.ts";
1919 import { forgeLane, forgeNeeds } from "../../actions/health.ts";
20 import { ownerWaits, waitLine, withMore } from "../../actions/owner_waits.ts";
2021 import { forgeHealth } from "../../kernel/guard.ts";
2122 import { coveredByVerdict, forgeVerdict, verdictLine } from "../../kernel/guard_verdict.ts";
2223 import { clientAppRows } from "../../core/client-apps.ts";
@@ -53,6 +54,7 @@ import {
5354 withoutArchived,
5455 type Repo,
5556 getRepoById,
57 ownerFactRepoIds,
5658 } from "../../core/repos.ts";
5759 import type { Ctx, Screen } from "../app/app.ts";
5860 import { binding } from "../app/keys.ts";
@@ -157,6 +159,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
157159 /** Why planning is paused, for the autonomous apps where it is; as on the board. */
158160 let holds = new Map<string, string>();
159161 let forge: string[] | undefined;
162 /** What the crews wait on this person for, one line each, as the board's notes say it. */
163 let waits: string[] = [];
160164 /** A client's apps, one sentence each on whether its agents work or wait (#1812). */
161165 let clientLines: string[] = [];
162166 /** The check lane, as the board's strip says it; null when nothing is queued. */
@@ -228,6 +232,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
228232 ...forge.filter((line) => !coveredByVerdict(line, verdict, fh)),
229233 ];
230234 }
235 const facts = f.user === undefined ? new Set<string>() : ownerFactRepoIds(f.db, all, f.user);
236 waits = ownerWaits(f.db, all.filter((r) => facts.has(r.id))).map(waitLine);
231237 lane = forgeLane(f.app, f.user);
232238 decider = deciderLine(f.app, f.user);
233239 const commit = running().commit;
@@ -794,7 +800,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
794800 body = rest!;
795801 }
796802 if (forge !== undefined) {
797 const lines = forge.length === 0 ? ["All fine"] : forge.slice(0, 3);
803 const lines = forge.length === 0 ? ["All fine"] : withMore(forge, 3);
798804 if (lane !== null) lines.push(`Lane: ${lane}`);
799805 const [strip, rest] = split(body, "v", [len(lines.length), fill(1)]);
800806 lines.forEach((line, i) =>
@@ -808,6 +814,14 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
808814 );
809815 body = rest!;
810816 }
817 if (waits.length > 0) {
818 const lines = withMore(waits, 3);
819 const [strip, rest] = split(body, "v", [len(lines.length), fill(1)]);
820 lines.forEach((line, i) =>
821 s.text(strip!.x, strip!.y + i, truncate(line, strip!.w), style(c.fg, c.bg), strip!.x + strip!.w),
822 );
823 body = rest!;
824 }
811825 const { main, aside, stacked } = beside(body, ctx.twoPane, {
812826 main: fill(3),
813827 aside: fill(2),
src/web/client.ts +3
@@ -2006,6 +2006,9 @@ export const liveRegionsJs = /* js */ `
20062006 if (mine.querySelector('input[type=checkbox]:checked')) return;
20072007 var active = document.activeElement;
20082008 if (active && mine.contains(active) && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return;
2009 // Nor a box with words already typed in it, focused or not.
2010 var typed = Array.prototype.some.call(mine.querySelectorAll('input.input, textarea'), function (i) { return i.value !== ''; });
2011 if (typed) return;
20092012 if (same(mine.innerHTML, theirs.innerHTML)) return;
20102013 // A disclosure the reader opened stays open across the swap.
20112014 var opened = Array.prototype.map.call(mine.querySelectorAll('details[id][open]'), function (d) { return d.id; });
src/web/pages/board-notes.ts added +69
@@ -0,0 +1,69 @@
1 /**
2 * The board's notes: everything that is not a panel, folded into one quiet
3 * line under the title — "4 notes · 1 needs you" — that opens in place.
4 * Closed is the default; a list the reader has opened stays open across live
5 * swaps (`liveRegionsJs`). Nothing is stored.
6 * A note is a short line with a link, never a paragraph; two notes with the
7 * same cause are one. Only what a person can act on is a note: the crew's
8 * own housekeeping (an issue parked twice) is the planner's, on the map.
9 */
10
11 import { html, type Html } from "../html.ts";
12 import { dot } from "../ui.ts";
13
14 /** One line on the board. */
15 export interface Note {
16 /** The cause; a second note with the same key is dropped. */
17 key: string;
18 /** True when it waits for the reader, false when it is only worth knowing. */
19 need: boolean;
20 /** The line itself: one link or one span, `data-need` on a need. */
21 line: Html;
22 }
23
24 /** Notes with each cause once, in the order given. */
25 export function distinct(notes: readonly Note[]): Note[] {
26 const seen = new Set<string>();
27 return notes.filter((n) => !seen.has(n.key) && !!seen.add(n.key));
28 }
29
30 /** "4 notes · 1 needs you": the closed line's words. */
31 export function summaryWords(notes: readonly Note[]): string {
32 const needs = notes.filter((n) => n.need).length;
33 const all = `${notes.length} note${notes.length === 1 ? "" : "s"}`;
34 return needs === 0 ? all : `${all} · ${needs} ${needs === 1 ? "needs" : "need"} you`;
35 }
36
37 /** The closed line: the count of notes, and the need count wearing the attention dot. */
38 function summaryLine(notes: readonly Note[]): Html {
39 const needs = notes.filter((n) => n.need).length;
40 if (needs === 0) return html`${summaryWords(notes)}`;
41 const all = `${notes.length} note${notes.length === 1 ? "" : "s"}`;
42 return html`${all} · <span class="notes-need" data-need>${dot("attn")} ${needs} ${needs === 1 ? "needs" : "need"} you</span>`;
43 }
44
45 /**
46 * The live region for the notes. `fine` is the admin's quiet "All fine"
47 * when nothing is noted; anyone else gets an empty region, ready for the
48 * first note to swap in.
49 */
50 export function notesRegion(
51 notes: readonly Note[],
52 o: { fine: boolean; live: string; ok: boolean | null },
53 ): Html {
54 const attrs = html`id="notes" data-live="${o.live}"${
55 o.ok === null ? "" : html` data-ok="${o.ok ? "yes" : "no"}"`
56 }`;
57 const wrap = (inner: Html | null) =>
58 html`<div ${attrs} role="status">${inner}</div>`;
59 if (notes.length === 0)
60 return wrap(
61 o.fine ? html`<span class="small muted">${dot("success")} All fine</span>` : null,
62 );
63 return wrap(html`<details id="notes-list" class="quiet-details">
64 <summary class="small">${summaryLine(notes)}</summary>
65 <ul class="small muted">
66 ${notes.map((n) => html`<li>${n.line}</li>`)}
67 </ul>
68 </details>`);
69 }
src/web/pages/board.ts +98 −148
@@ -17,7 +17,7 @@
1717 import { sentinelNotice } from "../../crew/sentinel.ts";
1818 import { appsForRepos, type AppRecord } from "../../core/apps.ts";
1919 import { askMeasure } from "../../core/ask_ledger.ts";
20 import { reviewersOf, waitsForPerson } from "../../crew/review.ts";
20 import { declinedWaits, reviewWaits } from "../../actions/owner_waits.ts";
2121 import { tokenReturns } from "../../core/returns.ts";
2222 import { forgeVitals, needLine } from "../../actions/health.ts";
2323 import { returnsRepos } from "../../actions/returns.ts";
@@ -67,11 +67,9 @@ import {
6767 autonomyStatus,
6868 holdWords,
6969 plannerHold,
70 plannerDeclines,
7170 startOfTodayUtc,
72 truncateAtWord,
7371 } from "../../crew/autonomy.ts";
74 import { parkedTwice } from "../../crew/parks.ts";
72 import { distinct, notesRegion, type Note } from "./board-notes.ts";
7573 import type { Ctx } from "../../http/context.ts";
7674 import { repoBase, signInFirst } from "../chrome.ts";
7775 import { boardJs, liveRegionsJs } from "../client.ts";
@@ -261,16 +259,16 @@ export function boardPage(ctx: Ctx): Response {
261259 >
262260 <time class="num small muted" id="board-clock"></time>
263261 </header>
264 <div class="stack">
265 ${forgeStrip(ctx)} ${declinedStrip(ctx, scope)} ${reviewStrip(ctx, scope)}
266 ${asksStrip(ctx)}
267 ${returnsStrip(ctx, scope)}
268 ${windowsStrip(ctx)}
269 </div>
262 ${boardNotes(ctx, scope)}
270263 <div class="board-grid">
271264 ${working(ctx, scope)} ${landed(ctx, scope)} ${decided(ctx, scope)}
272265 ${today(ctx, scope)}
273266 </div>
267 <div class="stack">
268 ${asksStrip(ctx)}
269 ${returnsStrip(ctx, scope)}
270 ${windowsStrip(ctx)}
271 </div>
274272 </div>`;
275273 return page({
276274 ctx,
@@ -360,19 +358,11 @@ export function bigRedirect(ctx: Ctx): Response {
360358
361359 // ---- the forge ----
362360
363 /** The sentinel's open findings, a link to them; nothing when there are none. */
364 function sentinelSpan(app: Ctx["app"]): Html {
365 const n = sentinelNotice(app.db);
366 if (n === null) return html``;
367 return html`<a data-need id="sentinel" href="${n.href}">${dot("attn")} ${n.text}</a>`;
368 }
369
370361 /**
371 * The forge's health as one line, for a site admin only: *All fine*, or
372 * each thing that needs them. A live region of its own; nobody else gets
373 * the element or pays for the reads.
362 * The forge's health as notes, for a site admin only: each thing that needs
363 * them, and the lane when checks are queued. Nobody else pays for the reads.
374364 */
375 function forgeStrip(ctx: Ctx): Html | null {
365 function forgeNotes(ctx: Ctx): { notes: Note[]; ok: boolean } | null {
376366 if (!ctx.auth.user?.isAdmin) return null;
377367 const v = forgeVitals(ctx.app);
378368 const fh = forgeHealth();
@@ -382,75 +372,49 @@ function forgeStrip(ctx: Ctx): Html | null {
382372 const others = fh.incidents.filter((i) => !said(`${i.key}: ${i.message}`));
383373 const ok = v.ok && fh.ok && verdict === undefined;
384374 const lane = laneLine(v.lane);
385 return html`<div
386 class="bar small muted"
387 id="forge"
388 data-live="run.queued,run.started,run.finished,app.*"
389 data-ok="${ok ? "yes" : "no"}"
390 data-rollout="${fh.rollout.state}"
391 role="status"
392 >
393 ${sentinelSpan(ctx.app)}
394 ${rollout === null || said(rollout) ? "" : html`<span data-need>${dot("attn")} ${rollout}</span>`}
395 ${verdict === undefined ? "" : html`<span data-need>${dot("attn")} ${verdictLine(verdict)}</span>`}
396 ${others.map((i) => html`<span data-need>${dot("attn")} ${i.key}: ${i.message}</span>`)}
397 ${lane === null ? "" : html`<span>Lane: ${lane}</span>`}
398 ${
399 ok
400 ? html`<span>${dot("success")} All fine</span>`
401 : v.degraded.map(
402 (d) =>
403 html`<a data-need href="${d.href}">${dot("attn")} ${needLine(d)}</a>`,
404 )
405 }
406 </div>`;
375 const sentinel = sentinelNotice(ctx.app.db);
376 const need = (key: string, line: Html): Note => ({ key, need: true, line });
377 const notes: Note[] = [
378 ...(sentinel === null
379 ? []
380 : [need("sentinel", html`<a data-need id="sentinel" href="${sentinel.href}">${dot("attn")} ${sentinel.text}</a>`)]),
381 ...(rollout === null || said(rollout)
382 ? []
383 : [need("rollout", html`<span data-need>${dot("attn")} ${rollout}</span>`)]),
384 ...(verdict === undefined
385 ? []
386 : [need("verdict", html`<span data-need>${dot("attn")} ${verdictLine(verdict)}</span>`)]),
387 ...others.map((i) => need(`incident:${i.key}`, html`<span data-need>${dot("attn")} ${i.key}: ${i.message}</span>`)),
388 ...(ok
389 ? []
390 : v.degraded.map((d) =>
391 need(`degraded:${d.cause}`, html`<a data-need href="${d.href}">${dot("attn")} ${needLine(d)}</a>`),
392 )),
393 ...(lane === null ? [] : [{ key: "lane", need: false, line: html`<span>Lane: ${lane}</span>` }]),
394 ];
395 return { notes, ok };
407396 }
408397
409398 // ---- agent pulls that wait for a person's review ----
410399
411 /**
412 * One line per autonomous app whose agent pulls wait for a person's review
413 * (#597): "No reviewer: agent pulls wait for you" when its crew has none,
414 * and how many wait and since when — a lapsed review or no reviewer, never
415 * a silent hold. Owner facts only; absent when nothing waits and every
416 * autonomous app has its reviewer.
417 */
418 function reviewStrip(ctx: Ctx, scope: BoardScope): Html | null {
419 const db = ctx.app.db;
420 const repos = scope.repos.filter(
421 (r) => scope.ownerFacts.has(r.id) && r.autonomy === "autonomous",
400 /** The review waits (#597) as notes: owner facts only, one per app. */
401 function reviewNotes(ctx: Ctx, scope: BoardScope): Note[] {
402 const owned = scope.repos.filter((r) => scope.ownerFacts.has(r.id));
403 return reviewWaits(ctx.app.db, owned).flatMap((w): Note[] =>
404 w.kind !== "review"
405 ? []
406 : [
407 {
408 key: `review:${w.repo.id}`,
409 need: true,
410 line: html`<a data-need href="/asks"
411 >${dot("attn")} <b>${w.repo.name}</b>: ${w.none ? "No reviewer: agent pulls wait for you" : "agent pulls wait for your review"}${w.waiting === 0 || w.since === null
412 ? null
413 : html` · <b>${w.waiting}</b> waiting since ${w.since.slice(0, 10)}`}</a
414 >`,
415 },
416 ],
422417 );
423 if (repos.length === 0) return null;
424 const ids = db
425 .query<{ id: string }, string[]>(
426 `SELECT issue.id AS id FROM issue JOIN user ON user.id = issue.author_id
427 WHERE issue.kind = 'pull' AND issue.state = 'open' AND user.is_agent = 1
428 AND issue.repo_id IN (${repos.map((_, i) => `?${i + 1}`).join(",")})`,
429 )
430 .all(...repos.map((r) => r.id))
431 .map((r) => r.id);
432 const pulls = joinMany(db, [...getIssuesByIds(db, ids).values()]);
433 const lines = repos.flatMap((repo) => {
434 const waits = pulls
435 .filter((p) => p.repoId === repo.id)
436 .flatMap((p) => {
437 const w = waitsForPerson(db, repo.id, p);
438 return w === undefined ? [] : [w];
439 });
440 const none = reviewersOf(db, repo.id).length === 0;
441 if (!none && waits.length === 0) return [];
442 const oldest = waits.map((w) => w.since).sort()[0];
443 return [
444 html`<a class="muted small" href="/asks"
445 ><b>${repo.name}</b>: ${none ? "No reviewer: agent pulls wait for you" : "agent pulls wait for your review"}${waits.length === 0
446 ? null
447 : html` · <b>${waits.length}</b> waiting since ${oldest!.slice(0, 10)}`}</a
448 >`,
449 ];
450 });
451 return lines.length === 0
452 ? null
453 : html`<div class="stack stack-tight" id="review-waits">${lines}</div>`;
454418 }
455419
456420 // ---- what asked you, and what came of it ----
@@ -519,72 +483,54 @@ function returnsStrip(ctx: Ctx, scope: BoardScope): Html | null {
519483
520484 // ---- the planner, waiting for a person ----
521485
522 /** An issue's title and a park's reason on the board: one clause each, cut at a word. */
523 const TITLE_MAX = 64;
524 const REASON_MAX = 96;
525
526486 /**
527 * One line per app whose planner declined the direction: its last words
528 * and a box to answer them, which is a comment on the direction issue —
529 * the word `planIfThin` waits for. And one line per issue a crew parked
530 * twice and handed to its planner (`crew/parks.ts`): why, and a link to
531 * the issue, where the planner answers; it stays until the issue is split,
532 * replaced or deferred. Only for apps whose owner facts the viewer may
533 * see; nothing at all when no planner is waiting and none holds an issue.
487 * One note per app whose planner declined the direction: that it waits for
488 * the owner, a link to the run that declined (where the words are) and a
489 * box to answer it: a comment on the direction issue. Only
490 * for apps whose owner facts the viewer may see. An issue the crew parked
491 * twice is the planner's business and is not here (it is on the map).
534492 */
535 function declinedStrip(ctx: Ctx, scope: BoardScope): Html | null {
493 function declinedNotes(ctx: Ctx, scope: BoardScope): Note[] {
536494 const owned = scope.repos.filter((r) => scope.ownerFacts.has(r.id));
537 const declines = plannerDeclines(ctx.app.db, owned);
538 const parked = parkedTwice(ctx.app.db, owned);
539 if (declines.length === 0 && parked.length === 0) return null;
540 return html`<div
541 class="stack"
542 id="declined"
543 data-live="run.queued,run.started,run.finished,issue.*,pull.*"
544 role="status"
545 >
546 ${declines.map((d) => {
547 const base = `/${d.repo.ownerName}/${d.repo.name}`;
548 return html`<form
549 class="card"
550 data-tone="attn"
551 method="post"
552 action="${base}/issues/${d.issue.number}/comments"
495 return declinedWaits(ctx.app.db, owned).flatMap((d) => d.kind !== "declined" ? [] : [{
496 key: `declined:${d.repo.id}`,
497 need: true,
498 line: html`<form
499 class="edit-row"
500 data-need
501 method="post"
502 action="${repoBase(d.repo)}/issues/${d.issue.number}/comments"
503 >
504 <span class="grow"
505 >${dot("attn")} <b>${d.repo.name}</b>: the planner declined the direction
506 and waits for you — <a href="${repoBase(d.repo)}/runs/${d.runId}">why</a></span
553507 >
554 <div class="edit-row">
555 <span class="grow"
556 ><b>${d.repo.name}</b>: the planner declined the direction and waits
557 for you. <q>${d.words}</q></span
558 >
559 <input type="hidden" name="back" value="board" />
560 <input
561 class="input"
562 name="body"
563 required
564 aria-label="Answer the planner on #${d.issue.number}"
565 placeholder="Answer the planner"
566 />
567 <button class="btn" data-variant="primary" type="submit">Answer</button>
568 </div>
569 </form>`;
570 })}
571 ${parked.map((issue) => {
572 const repo = scope.byId.get(issue.repoId)!;
573 return html`<div class="card" data-tone="attn" data-parked="">
574 <div class="edit-row">
575 <span class="grow"
576 ><b>${repo.name}</b>: the crew parked #${issue.number} twice and
577 handed it to the planner —
578 ${truncateAtWord(issue.title, TITLE_MAX)}.
579 ${issue.reasons.map(
580 (reason) => html`<q>${truncateAtWord(reason, REASON_MAX)}</q> `,
581 )}</span
582 >
583 <a class="btn" href="${repoBase(repo)}/issues/${issue.number}">Open</a>
584 </div>
585 </div>`;
586 })}
587 </div>`;
508 <input type="hidden" name="back" value="board" />
509 <input
510 class="input"
511 name="body"
512 required
513 aria-label="Answer the planner on #${d.issue.number}"
514 placeholder="Answer the planner"
515 />
516 <button class="btn" type="submit">Answer</button>
517 </form>`,
518 }]);
519 }
520
521 /** Every note for this viewer, as the one live region under the title. */
522 function boardNotes(ctx: Ctx, scope: BoardScope): Html {
523 const forge = forgeNotes(ctx);
524 const notes = distinct([
525 ...(forge?.notes ?? []),
526 ...declinedNotes(ctx, scope),
527 ...reviewNotes(ctx, scope),
528 ]);
529 return notesRegion(notes, {
530 fine: forge !== null && forge.ok,
531 live: "run.queued,run.started,run.finished,issue.*,pull.*,app.*",
532 ok: forge === null ? null : forge.ok,
533 });
588534 }
589535
590536 // ---- the subscriptions' windows ----
@@ -1059,6 +1005,10 @@ export const boardCss = /* css */ `
10591005 @layer components {
10601006 .board { --pad: clamp(16px, 2.2vw, 32px); display: flex; flex-direction: column; min-height: 100dvh; }
10611007 .board > header, .board > .stack { padding: 12px var(--pad) 0; }
1008 .board > #notes { padding: 8px var(--pad) 0; }
1009 #notes ul { list-style: none; margin: 4px 0 0; padding: 0; max-height: 30dvh; overflow: auto; }
1010 #notes li > * { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; min-height: 44px; overflow-wrap: anywhere; }
1011 #notes li > a { display: block; padding-block: 12px; min-height: 0; }
10621012 .board #board-live { display: inline-flex; align-items: center; }
10631013 .board #board-clock { min-width: 5ch; text-align: right; }
10641014 .board [data-started] { min-width: 7ch; text-align: right; }
@@ -1069,7 +1019,6 @@ export const boardCss = /* css */ `
10691019 .board-region .row-lead img { display: block; border: 1px solid var(--border); border-radius: 4px; object-fit: cover; object-position: top; }
10701020 .board-region .row-main > meter { margin-top: 4px; }
10711021 .board [data-window] > meter { width: 56px; }
1072 .board[data-wall] #declined :is(.input, .btn) { display: none; }
10731022 .spend-table { margin-top: 8px; }
10741023 .spend-table caption { text-align: left; color: var(--muted-fg); }
10751024 .spend-totals { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 8px 0 0; }
@@ -1090,5 +1039,6 @@ export const boardCss = /* css */ `
10901039 .board[data-focus] .board-region > * { zoom: 1.3; }
10911040 .board[data-wall] .board-region > *, .board[data-wall] > .stack { zoom: 1.4; }
10921041 .board[data-wall] { cursor: none; }
1042 .board[data-wall] #notes :is(.input, .btn) { display: none; }
10931043 }
10941044 `;
test/board-notes.test.ts added +47
@@ -0,0 +1,47 @@
1 import { describe, expect, test } from "bun:test";
2 import { withMore } from "../src/actions/owner_waits.ts";
3 import { html } from "../src/web/html.ts";
4 import { distinct, notesRegion, summaryWords, type Note } from "../src/web/pages/board-notes.ts";
5
6 const note = (key: string, need: boolean): Note => ({ key, need, line: html`<span>${key}</span>` });
7 const live = { live: "run.*", ok: null, fine: false };
8
9 describe("the board's notes", () => {
10 test("the closed line counts notes and the ones that need the reader", () => {
11 expect(summaryWords([note("a", true), note("b", false), note("c", false), note("d", false)])).toBe(
12 "4 notes · 1 needs you",
13 );
14 expect(summaryWords([note("a", true), note("b", true)])).toBe("2 notes · 2 need you");
15 expect(summaryWords([note("a", false)])).toBe("1 note");
16 });
17
18 test("two notes with one cause are one", () => {
19 expect(distinct([note("x", true), note("y", true), note("x", false)]).map((n) => n.key)).toEqual(["x", "y"]);
20 });
21
22 test("closed by default, one line each, escaped", () => {
23 const out = notesRegion([{ key: "k", need: true, line: html`<a>${"<b>"}</a>` }], live).text;
24 expect(out).toContain('<details id="notes-list" class="quiet-details">');
25 expect(out).not.toMatch(/<details[^>]*open/);
26 expect(out).toContain("&lt;b&gt;");
27 });
28
29 test("no notes: the admin's quiet All fine, anyone else's empty region", () => {
30 expect(notesRegion([], { ...live, fine: true }).text).toContain("All fine");
31 const empty = notesRegion([], live).text;
32 expect(empty).toContain('id="notes"');
33 expect(empty).not.toContain("<details");
34 });
35
36 test("the need count wears the attention dot; no need, no dot", () => {
37 const some = notesRegion([note("a", true), note("b", false)], live).text;
38 expect(some).toMatch(/<span class="notes-need" data-need><span\s+class="dot"\s+data-tone="attn"\s*><\/span> 1 needs you<\/span>/);
39 expect(notesRegion([note("b", false)], live).text).not.toContain('data-tone="attn"');
40 });
41
42 test("a list cut to 3 says how many were left out", () => {
43 const five = ["a", "b", "c", "d", "e"];
44 expect(withMore(five, 3)).toEqual(["a", "b", "c", "+2 more"]);
45 expect(withMore(five.slice(0, 3), 3)).toEqual(["a", "b", "c"]);
46 });
47 });
test/board.test.ts +35 −2
@@ -468,7 +468,7 @@ describe("the board's forge strip", () => {
468468
469469 test("an admin reads All fine; a stuck run becomes a linked sentence; a non-admin sees nothing", async () => {
470470 const fine = await board("root");
471 expect(fine).toMatch(/id="forge"\s+data-live="run\.queued,run\.started,run\.finished,app\.\*"/);
471 expect(fine).toMatch(/id="notes"\s+data-live="[^"]*app\.\*"\s+data-ok="yes"/);
472472 expect(fine).toContain("All fine");
473473
474474 const made = await createRepo(app, admin, { name: "stuck" });
@@ -485,7 +485,7 @@ describe("the board's forge strip", () => {
485485 expect(stuck).toContain(`href="/root/stuck/runs/${run.id}"`);
486486
487487 const other = await board("bob");
488 expect(other).not.toContain('id="forge"');
488 expect(other).not.toContain("data-ok");
489489 expect(other).not.toContain("All fine");
490490 expect(other).not.toContain("stuck.");
491491 });
@@ -566,6 +566,39 @@ describe("the board's forge strip", () => {
566566 }
567567 });
568568
569 test("eight notes fold into one closed line; the panels come first and nothing else sits above them", async () => {
570 const guard = mkdtempSync(join(tmpdir(), "forge-board-guard-"));
571 process.env["FORGE_GUARD_DIR"] = guard;
572 try {
573 const incidents = Object.fromEntries(
574 Array.from({ length: 8 }, (_, i) => [`cause${i}`, `trouble ${i} <raw words>`]),
575 );
576 writeFileSync(join(guard, "status.json"), JSON.stringify({ incidents }));
577 writeFileSync(join(guard, "checkedAt"), new Date().toISOString());
578 const text = await board("root");
579 const top = text.slice(text.indexOf('<div class="board"'), text.indexOf('class="board-grid"'));
580 // Closed by default; one summary, counted.
581 expect(top).toMatch(/<details id="notes-list" class="quiet-details">/);
582 expect(top).not.toMatch(/<details[^>]*\sopen/);
583 expect(top).toMatch(/<summary[^>]*>\s*\d+ notes · <span[^>]*>\s*<span\s+class="dot"\s+data-tone="attn"\s*><\/span> \d+ need you<\/span>\s*<\/summary>/);
584 expect(top.match(/<li>/g)?.length).toBeGreaterThanOrEqual(8);
585 expect(top).toContain("trouble 7 &lt;raw words&gt;");
586 // The title and the notes are all that precede the panels.
587 expect(top).not.toContain('id="returns-line"');
588 expect(top).not.toContain('id="asks-measure"');
589 expect(top).not.toContain('id="windows"');
590 expect(text.indexOf('id="notes"')).toBeLessThan(text.indexOf('id="working"'));
591 // Notes wrap rather than clip, and the summary is a real touch target.
592 expect(boardCss).not.toMatch(/#notes li[^\n]*nowrap/);
593 expect(boardCss).not.toMatch(/#notes summary \{/);
594 // No stored state: the first paint is the only paint.
595 expect(text).not.toContain("localStorage");
596 } finally {
597 delete process.env["FORGE_GUARD_DIR"];
598 rmSync(guard, { recursive: true, force: true });
599 }
600 });
601
569602 test("the sentinel's line is the admin's alone, and absent before the first hunt", async () => {
570603 expect(await board("root")).not.toContain("sentinel");
571604
test/crew-parks.test.ts +15 −27
@@ -511,24 +511,16 @@ describe("an issue parked twice is the planner's", () => {
511511 expect(told({ parkedTwice: [] })).not.toContain("Parked twice");
512512 });
513513
514 test("the board lists it with its reasons for whoever owns the app, linked to the issue", async () => {
515 const mine = await board("alice");
516 expect(mine).toContain('id="declined"');
517 expect(mine).toMatch(
518 new RegExp(
519 `the crew parked #${work.number} twice and\\s+handed it to the planner —\\s+Scene graph\\.`,
520 ),
521 );
522 expect(mine).toContain(`<q>${declined}</q> <q>${nothing}</q>`);
523 expect(mine).toContain(
524 `<a class="btn" href="/alice/engine/issues/${work.number}">Open</a>`,
525 );
526 expect(mine).toContain(`href="/alice/engine/issues/${again.number}"`);
527 // Nobody else is told: bob cannot see the engine.
528 const theirs = await board("bob");
529 expect(theirs).not.toContain('id="declined"');
530 expect(theirs).not.toContain("parked #");
531 // An owner who is no admin gets the line, escaped, and no word on the forge.
514 test("the board never carries it: parked twice is the crew's business, and the map still lists it", async () => {
515 for (const who of ["alice", "bob"]) {
516 const text = await board(who);
517 expect(text).not.toContain("parked #");
518 expect(text).not.toContain("handed it to the planner");
519 expect(text).not.toContain(declined);
520 expect(text).not.toContain("data-parked");
521 expect(text).not.toContain("/alice/engine/issues/");
522 }
523 // Nor bob's own app, with its title, its reasons and no escaping to get wrong.
532524 const made = await createRepo(app, bob, {
533525 name: "tool",
534526 visibility: "private",
@@ -551,15 +543,13 @@ describe("an issue parked twice is the planner's", () => {
551543 addComment(app, bob, tool, hinge, `Parked by the crew: ${why}`);
552544 await tick();
553545 }
546 expect(parkedTwice(app.db, [tool]).map((i) => i.number)).toEqual([hinge.number]);
554547 const own = await board("bob");
555 expect(own).toContain(`href="/bob/tool/issues/${hinge.number}"`);
556 expect(own).toContain("A &lt;b&gt;hinge&lt;/b&gt;.");
557 expect(own).toContain(
558 "<q>the check stayed red</q> <q>main moved under it &lt;twice&gt;</q>",
559 );
560 expect(own).not.toContain('id="forge"');
548 expect(own).not.toContain("hinge");
549 expect(own).not.toContain("main moved under it");
561550 expect(own).not.toContain("All fine");
562 expect(own).not.toContain("/alice/engine/");
551 // And bob still cannot see alice's engine through the board.
552 expect(own).not.toContain("/alice/engine/issues/");
563553 });
564554
565555 test("the map's data and the MCP's `map` list it with its reasons", async () => {
@@ -981,8 +971,6 @@ describe("an issue parked twice is the planner's", () => {
981971 expect(await board("alice")).not.toContain("/alice/engine/issues/");
982972 // A person's word is the other way off: bob says what to try on his.
983973 addComment(app, bob, tool, hinge, "Try a smaller hinge.");
984 expect(await board("bob")).not.toContain('id="declined"');
985 expect(await board("alice")).not.toContain('id="declined"');
986974 // The owner opens the gate: what waited is as it was, so it is the
987975 // planner's again.
988976 expect(
test/planner-declined.test.ts +33 −1
@@ -11,6 +11,12 @@ import { mkdtemp, rm } from "node:fs/promises";
1111 import { tmpdir } from "node:os";
1212 import { join } from "node:path";
1313 import { createApp, type App } from "../src/app.ts";
14 import { createForgeApp } from "../src/tui/forge.ts";
15 import { dashboardScreen } from "../src/tui/screens/dashboard.ts";
16 import { headlessTerminal } from "../src/tui/term/terminal.ts";
17 import { settleApp } from "./helpers/tui.ts";
18 import { boardCss } from "../src/web/pages/board.ts";
19 import { liveRegionsJs } from "../src/web/client.ts";
1420 import { openIssue } from "../src/actions/issues.ts";
1521 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1622 import { createApp as insertApp } from "../src/core/apps.ts";
@@ -179,10 +185,36 @@ describe("the planner declined, waiting for a person", () => {
179185 expect(board).toContain(
180186 "the planner declined the direction and waits for you",
181187 );
182 expect(board).toContain("the direction is already done");
188 // One line linking the run, where the words are, with the answer box.
189 expect(board).not.toContain("the direction is already done");
190 expect(board).toContain(`href="/alice/garden/runs/${runId}"`);
183191 expect(board).toContain(
184192 `action="/alice/garden/issues/${direction.number}/comments"`,
185193 );
194 expect(board).toContain('name="back" value="board"');
195 // The wall board has no pointer or keys: the box and button are hidden there.
196 expect(boardCss).toMatch(/\[data-wall\] #notes :is\(\.input, \.btn\) \{ display: none; \}/);
197 // A live swap never wipes words a person has typed in a box.
198 expect(liveRegionsJs).toContain("input.input, textarea");
199 // The terminal's first screen says the same, one line.
200 const shown = async (user: User): Promise<string> => {
201 const terminal = headlessTerminal(160, 30);
202 const { app: tui, session } = createForgeApp(
203 app,
204 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
205 { terminal, exitHooks: false },
206 );
207 tui.start();
208 tui.ctx.push(dashboardScreen(session, tui.ctx));
209 await settleApp(tui);
210 const text = tui.screen.toText();
211 tui.stop?.();
212 return text;
213 };
214 expect(await shown(alice)).toContain(
215 `garden: the planner declined the direction and waits for you (issue #${direction.number})`,
216 );
217 expect(await shown(bob)).not.toContain("the planner declined");
186218 const asks = await page(aliceCookie, "/asks");
187219 expect(asks).toContain("The planner declined the direction");
188220 expect(asks).toContain(
test/review-waits.test.ts +2 −2
@@ -174,7 +174,7 @@ describe("an agent pull that waits for a person's review says so", () => {
174174 expect(askLinesFor(app.db, bob).some((l) => l.kind === "review")).toBe(
175175 false,
176176 );
177 expect(await page(bobCookie, "/board")).not.toContain('id="review-waits"');
177 expect(await page(bobCookie, "/board")).not.toContain("No reviewer");
178178 expect((await now(bobRo)).some((r) => r.why === "review")).toBe(false);
179179 });
180180
@@ -197,7 +197,7 @@ describe("an agent pull that waits for a person's review says so", () => {
197197
198198 test("the board and the autonomy settings say so", async () => {
199199 const board = await page(aliceCookie, "/board");
200 expect(board).toContain('id="review-waits"');
200 expect(board).toContain('<details id="notes-list" class="quiet-details">');
201201 expect(board).toContain("No reviewer: agent pulls wait for you");
202202 expect(board).toContain("<b>1</b> waiting since");
203203 const settings = await page(aliceCookie, "/alice/engine/settings");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs