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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3846-the-board-opens-on-its-panels-with-notes → main 6 commits fork
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 | ||
| 263 | 263 | |
| 264 | 264 | 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. |
| 265 | 265 | |
| 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 | ||
| 266 | 280 | ## Live in the terminal |
| 267 | 281 | |
| 268 | 282 | `ssh` into the forge and you get the whole thing — repositories, code, issues, |
docs/DESIGN.md +1 −1
| @@ -207,7 +207,7 @@ specification. | ||
| 207 | 207 | | **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 | |
| 208 | 208 | | **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 | |
| 209 | 209 | | **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 | | |
| 211 | 211 | | **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 | |
| 212 | 212 | |
| 213 | 213 | 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 @@ | ||
| 17 | 17 | import { protectionNotice } from "../../kernel/protection.ts"; |
| 18 | 18 | import { holdWords, plannerHold } from "../../crew/autonomy.ts"; |
| 19 | 19 | import { forgeLane, forgeNeeds } from "../../actions/health.ts"; |
| 20 | import { ownerWaits, waitLine, withMore } from "../../actions/owner_waits.ts"; | |
| 20 | 21 | import { forgeHealth } from "../../kernel/guard.ts"; |
| 21 | 22 | import { coveredByVerdict, forgeVerdict, verdictLine } from "../../kernel/guard_verdict.ts"; |
| 22 | 23 | import { clientAppRows } from "../../core/client-apps.ts"; |
| @@ -53,6 +54,7 @@ import { | ||
| 53 | 54 | withoutArchived, |
| 54 | 55 | type Repo, |
| 55 | 56 | getRepoById, |
| 57 | ownerFactRepoIds, | |
| 56 | 58 | } from "../../core/repos.ts"; |
| 57 | 59 | import type { Ctx, Screen } from "../app/app.ts"; |
| 58 | 60 | import { binding } from "../app/keys.ts"; |
| @@ -157,6 +159,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 157 | 159 | /** Why planning is paused, for the autonomous apps where it is; as on the board. */ |
| 158 | 160 | let holds = new Map<string, string>(); |
| 159 | 161 | 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[] = []; | |
| 160 | 164 | /** A client's apps, one sentence each on whether its agents work or wait (#1812). */ |
| 161 | 165 | let clientLines: string[] = []; |
| 162 | 166 | /** 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 { | ||
| 228 | 232 | ...forge.filter((line) => !coveredByVerdict(line, verdict, fh)), |
| 229 | 233 | ]; |
| 230 | 234 | } |
| 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); | |
| 231 | 237 | lane = forgeLane(f.app, f.user); |
| 232 | 238 | decider = deciderLine(f.app, f.user); |
| 233 | 239 | const commit = running().commit; |
| @@ -794,7 +800,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 794 | 800 | body = rest!; |
| 795 | 801 | } |
| 796 | 802 | 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); | |
| 798 | 804 | if (lane !== null) lines.push(`Lane: ${lane}`); |
| 799 | 805 | const [strip, rest] = split(body, "v", [len(lines.length), fill(1)]); |
| 800 | 806 | lines.forEach((line, i) => |
| @@ -808,6 +814,14 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 808 | 814 | ); |
| 809 | 815 | body = rest!; |
| 810 | 816 | } |
| 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 | } | |
| 811 | 825 | const { main, aside, stacked } = beside(body, ctx.twoPane, { |
| 812 | 826 | main: fill(3), |
| 813 | 827 | aside: fill(2), |
src/web/client.ts +3
| @@ -2006,6 +2006,9 @@ export const liveRegionsJs = /* js */ ` | ||
| 2006 | 2006 | if (mine.querySelector('input[type=checkbox]:checked')) return; |
| 2007 | 2007 | var active = document.activeElement; |
| 2008 | 2008 | 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; | |
| 2009 | 2012 | if (same(mine.innerHTML, theirs.innerHTML)) return; |
| 2010 | 2013 | // A disclosure the reader opened stays open across the swap. |
| 2011 | 2014 | 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 @@ | ||
| 17 | 17 | import { sentinelNotice } from "../../crew/sentinel.ts"; |
| 18 | 18 | import { appsForRepos, type AppRecord } from "../../core/apps.ts"; |
| 19 | 19 | import { askMeasure } from "../../core/ask_ledger.ts"; |
| 20 | import { reviewersOf, waitsForPerson } from "../../crew/review.ts"; | |
| 20 | import { declinedWaits, reviewWaits } from "../../actions/owner_waits.ts"; | |
| 21 | 21 | import { tokenReturns } from "../../core/returns.ts"; |
| 22 | 22 | import { forgeVitals, needLine } from "../../actions/health.ts"; |
| 23 | 23 | import { returnsRepos } from "../../actions/returns.ts"; |
| @@ -67,11 +67,9 @@ import { | ||
| 67 | 67 | autonomyStatus, |
| 68 | 68 | holdWords, |
| 69 | 69 | plannerHold, |
| 70 | plannerDeclines, | |
| 71 | 70 | startOfTodayUtc, |
| 72 | truncateAtWord, | |
| 73 | 71 | } from "../../crew/autonomy.ts"; |
| 74 | import { parkedTwice } from "../../crew/parks.ts"; | |
| 72 | import { distinct, notesRegion, type Note } from "./board-notes.ts"; | |
| 75 | 73 | import type { Ctx } from "../../http/context.ts"; |
| 76 | 74 | import { repoBase, signInFirst } from "../chrome.ts"; |
| 77 | 75 | import { boardJs, liveRegionsJs } from "../client.ts"; |
| @@ -261,16 +259,16 @@ export function boardPage(ctx: Ctx): Response { | ||
| 261 | 259 | > |
| 262 | 260 | <time class="num small muted" id="board-clock"></time> |
| 263 | 261 | </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)} | |
| 270 | 263 | <div class="board-grid"> |
| 271 | 264 | ${working(ctx, scope)} ${landed(ctx, scope)} ${decided(ctx, scope)} |
| 272 | 265 | ${today(ctx, scope)} |
| 273 | 266 | </div> |
| 267 | <div class="stack"> | |
| 268 | ${asksStrip(ctx)} | |
| 269 | ${returnsStrip(ctx, scope)} | |
| 270 | ${windowsStrip(ctx)} | |
| 271 | </div> | |
| 274 | 272 | </div>`; |
| 275 | 273 | return page({ |
| 276 | 274 | ctx, |
| @@ -360,19 +358,11 @@ export function bigRedirect(ctx: Ctx): Response { | ||
| 360 | 358 | |
| 361 | 359 | // ---- the forge ---- |
| 362 | 360 | |
| 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 | ||
| 370 | 361 | /** |
| 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. | |
| 374 | 364 | */ |
| 375 | function forgeStrip(ctx: Ctx): Html | null { | |
| 365 | function forgeNotes(ctx: Ctx): { notes: Note[]; ok: boolean } | null { | |
| 376 | 366 | if (!ctx.auth.user?.isAdmin) return null; |
| 377 | 367 | const v = forgeVitals(ctx.app); |
| 378 | 368 | const fh = forgeHealth(); |
| @@ -382,75 +372,49 @@ function forgeStrip(ctx: Ctx): Html | null { | ||
| 382 | 372 | const others = fh.incidents.filter((i) => !said(`${i.key}: ${i.message}`)); |
| 383 | 373 | const ok = v.ok && fh.ok && verdict === undefined; |
| 384 | 374 | 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 }; | |
| 407 | 396 | } |
| 408 | 397 | |
| 409 | 398 | // ---- agent pulls that wait for a person's review ---- |
| 410 | 399 | |
| 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 | ], | |
| 422 | 417 | ); |
| 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>`; | |
| 454 | 418 | } |
| 455 | 419 | |
| 456 | 420 | // ---- what asked you, and what came of it ---- |
| @@ -519,72 +483,54 @@ function returnsStrip(ctx: Ctx, scope: BoardScope): Html | null { | ||
| 519 | 483 | |
| 520 | 484 | // ---- the planner, waiting for a person ---- |
| 521 | 485 | |
| 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 | ||
| 526 | 486 | /** |
| 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). | |
| 534 | 492 | */ |
| 535 | function declinedStrip(ctx: Ctx, scope: BoardScope): Html | null { | |
| 493 | function declinedNotes(ctx: Ctx, scope: BoardScope): Note[] { | |
| 536 | 494 | 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 | |
| 553 | 507 | > |
| 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 | }); | |
| 588 | 534 | } |
| 589 | 535 | |
| 590 | 536 | // ---- the subscriptions' windows ---- |
| @@ -1059,6 +1005,10 @@ export const boardCss = /* css */ ` | ||
| 1059 | 1005 | @layer components { |
| 1060 | 1006 | .board { --pad: clamp(16px, 2.2vw, 32px); display: flex; flex-direction: column; min-height: 100dvh; } |
| 1061 | 1007 | .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; } | |
| 1062 | 1012 | .board #board-live { display: inline-flex; align-items: center; } |
| 1063 | 1013 | .board #board-clock { min-width: 5ch; text-align: right; } |
| 1064 | 1014 | .board [data-started] { min-width: 7ch; text-align: right; } |
| @@ -1069,7 +1019,6 @@ export const boardCss = /* css */ ` | ||
| 1069 | 1019 | .board-region .row-lead img { display: block; border: 1px solid var(--border); border-radius: 4px; object-fit: cover; object-position: top; } |
| 1070 | 1020 | .board-region .row-main > meter { margin-top: 4px; } |
| 1071 | 1021 | .board [data-window] > meter { width: 56px; } |
| 1072 | .board[data-wall] #declined :is(.input, .btn) { display: none; } | |
| 1073 | 1022 | .spend-table { margin-top: 8px; } |
| 1074 | 1023 | .spend-table caption { text-align: left; color: var(--muted-fg); } |
| 1075 | 1024 | .spend-totals { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 8px 0 0; } |
| @@ -1090,5 +1039,6 @@ export const boardCss = /* css */ ` | ||
| 1090 | 1039 | .board[data-focus] .board-region > * { zoom: 1.3; } |
| 1091 | 1040 | .board[data-wall] .board-region > *, .board[data-wall] > .stack { zoom: 1.4; } |
| 1092 | 1041 | .board[data-wall] { cursor: none; } |
| 1042 | .board[data-wall] #notes :is(.input, .btn) { display: none; } | |
| 1093 | 1043 | } |
| 1094 | 1044 | `; |
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("<b>"); | |
| 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", () => { | ||
| 468 | 468 | |
| 469 | 469 | test("an admin reads All fine; a stuck run becomes a linked sentence; a non-admin sees nothing", async () => { |
| 470 | 470 | 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"/); | |
| 472 | 472 | expect(fine).toContain("All fine"); |
| 473 | 473 | |
| 474 | 474 | const made = await createRepo(app, admin, { name: "stuck" }); |
| @@ -485,7 +485,7 @@ describe("the board's forge strip", () => { | ||
| 485 | 485 | expect(stuck).toContain(`href="/root/stuck/runs/${run.id}"`); |
| 486 | 486 | |
| 487 | 487 | const other = await board("bob"); |
| 488 | expect(other).not.toContain('id="forge"'); | |
| 488 | expect(other).not.toContain("data-ok"); | |
| 489 | 489 | expect(other).not.toContain("All fine"); |
| 490 | 490 | expect(other).not.toContain("stuck."); |
| 491 | 491 | }); |
| @@ -566,6 +566,39 @@ describe("the board's forge strip", () => { | ||
| 566 | 566 | } |
| 567 | 567 | }); |
| 568 | 568 | |
| 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 <raw words>"); | |
| 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 | ||
| 569 | 602 | test("the sentinel's line is the admin's alone, and absent before the first hunt", async () => { |
| 570 | 603 | expect(await board("root")).not.toContain("sentinel"); |
| 571 | 604 |
test/crew-parks.test.ts +15 −27
| @@ -511,24 +511,16 @@ describe("an issue parked twice is the planner's", () => { | ||
| 511 | 511 | expect(told({ parkedTwice: [] })).not.toContain("Parked twice"); |
| 512 | 512 | }); |
| 513 | 513 | |
| 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. | |
| 532 | 524 | const made = await createRepo(app, bob, { |
| 533 | 525 | name: "tool", |
| 534 | 526 | visibility: "private", |
| @@ -551,15 +543,13 @@ describe("an issue parked twice is the planner's", () => { | ||
| 551 | 543 | addComment(app, bob, tool, hinge, `Parked by the crew: ${why}`); |
| 552 | 544 | await tick(); |
| 553 | 545 | } |
| 546 | expect(parkedTwice(app.db, [tool]).map((i) => i.number)).toEqual([hinge.number]); | |
| 554 | 547 | const own = await board("bob"); |
| 555 | expect(own).toContain(`href="/bob/tool/issues/${hinge.number}"`); | |
| 556 | expect(own).toContain("A <b>hinge</b>."); | |
| 557 | expect(own).toContain( | |
| 558 | "<q>the check stayed red</q> <q>main moved under it <twice></q>", | |
| 559 | ); | |
| 560 | expect(own).not.toContain('id="forge"'); | |
| 548 | expect(own).not.toContain("hinge"); | |
| 549 | expect(own).not.toContain("main moved under it"); | |
| 561 | 550 | 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/"); | |
| 563 | 553 | }); |
| 564 | 554 | |
| 565 | 555 | 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", () => { | ||
| 981 | 971 | expect(await board("alice")).not.toContain("/alice/engine/issues/"); |
| 982 | 972 | // A person's word is the other way off: bob says what to try on his. |
| 983 | 973 | 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"'); | |
| 986 | 974 | // The owner opens the gate: what waited is as it was, so it is the |
| 987 | 975 | // planner's again. |
| 988 | 976 | expect( |
test/planner-declined.test.ts +33 −1
| @@ -11,6 +11,12 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 11 | 11 | import { tmpdir } from "node:os"; |
| 12 | 12 | import { join } from "node:path"; |
| 13 | 13 | 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"; | |
| 14 | 20 | import { openIssue } from "../src/actions/issues.ts"; |
| 15 | 21 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 16 | 22 | import { createApp as insertApp } from "../src/core/apps.ts"; |
| @@ -179,10 +185,36 @@ describe("the planner declined, waiting for a person", () => { | ||
| 179 | 185 | expect(board).toContain( |
| 180 | 186 | "the planner declined the direction and waits for you", |
| 181 | 187 | ); |
| 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}"`); | |
| 183 | 191 | expect(board).toContain( |
| 184 | 192 | `action="/alice/garden/issues/${direction.number}/comments"`, |
| 185 | 193 | ); |
| 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"); | |
| 186 | 218 | const asks = await page(aliceCookie, "/asks"); |
| 187 | 219 | expect(asks).toContain("The planner declined the direction"); |
| 188 | 220 | 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", () => { | ||
| 174 | 174 | expect(askLinesFor(app.db, bob).some((l) => l.kind === "review")).toBe( |
| 175 | 175 | false, |
| 176 | 176 | ); |
| 177 | expect(await page(bobCookie, "/board")).not.toContain('id="review-waits"'); | |
| 177 | expect(await page(bobCookie, "/board")).not.toContain("No reviewer"); | |
| 178 | 178 | expect((await now(bobRo)).some((r) => r.why === "review")).toBe(false); |
| 179 | 179 | }); |
| 180 | 180 | |
| @@ -197,7 +197,7 @@ describe("an agent pull that waits for a person's review says so", () => { | ||
| 197 | 197 | |
| 198 | 198 | test("the board and the autonomy settings say so", async () => { |
| 199 | 199 | const board = await page(aliceCookie, "/board"); |
| 200 | expect(board).toContain('id="review-waits"'); | |
| 200 | expect(board).toContain('<details id="notes-list" class="quiet-details">'); | |
| 201 | 201 | expect(board).toContain("No reviewer: agent pulls wait for you"); |
| 202 | 202 | expect(board).toContain("<b>1</b> waiting since"); |
| 203 | 203 | const settings = await page(aliceCookie, "/alice/engine/settings"); |
new above
offline