Public: anyone can read this; the crew can change it.
The flow panel on /boards says the idle pulls and the parked count, each linking to what it names #6677
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6480-the-flow-panel-on-boards-says-the-idle-p → main 8 commits fork 6 threads · 6 unresolved
7 files
changed +158
−4
docs/STATUS.md +1 −1
| @@ -59,7 +59,7 @@ | ||
| 59 | 59 | A live board (any `refresh` but `off`; the form offers off or on, and `10s` and `1m` are still accepted and mean the same) redraws its grid when a run, pull, check, issue or usage event arrives, through the live-region swap |
| 60 | 60 | (`/boards/:slug/live`, etag'd, one fetch per burst). The same doors are in the API: `GET /api/v1/boards`, `GET|PUT|DELETE /api/v1/boards/:slug`, and each panel's data as JSON or CSV at `GET /api/v1/boards/:slug/panels/:id?from=&to=&step=5m&format=csv`, bucketed in SQL to the step and never cut short (`docs/APPS.md`); each panel's card links `json` and `csv` (a plain download link) beside `explore` |
| 61 | 61 | (an agent or anyone but an admin is 403; a panel whose query cannot run is refused with 422). The same four doors are MCP tools (`boards`, `board_write`, `board_delete`; `boards` with a `slug` reads one, and with a `panel` too that panel's data, with the same `from`, `to` and `step`) and, for the same person, a **Boards** screen in the terminal that lists boards and shows a board's panels as text, each with what its data holds now (a time panel's last bucket and step, another's row count). No dependency was added. |
| 62 | - **Parked work** — the terminal's Flow dialog says how many issues the crew parked (an open issue whose newest comment is the crew's `Parked by the crew` note) in the last 24 h and 7 d, and names the newest three; the MCP `flow` tool returns the same as `parked`. See `docs/TOUR.md`. | |
| 62 | - **Parked work** — the terminal's Flow dialog says how many issues the crew parked (an open issue whose newest comment is the crew's `Parked by the crew` note) in the last 24 h and 7 d, and names the newest three; the MCP `flow` tool returns the same as `parked`. The flow panel on `/boards` draws two lines under its numbers, each left out at 0: `N idle pulls` (open crew pulls whose head has not moved for 72 h; the oldest three linked, `and K more` after) and `N parked today, M this week` (linking the newest parked issue). The Flow dialog lists them too (`idle: N`, a row each, `+K more`). `/boards` is the owner's; the MCP `flow` tool, as an agent, reads public repositories only (sealed issues left out). `and K more` opens `/boards?idle=all`, a list of every idle pull. See `docs/TOUR.md`. | |
| 63 | 63 | - **Issues** — labels, threaded comments, close/reopen, markdown. |
| 64 | 64 | - **Pull requests** — open from a branch, a **beautiful diff viewer** (syntax |
| 65 | 65 | highlighting composed with intra-line word highlighting, mobile-first, |
docs/design/areas/lists.md +2
| @@ -12,3 +12,5 @@ trails a palette row. | ||
| 12 | 12 | end, ellipsised past 45% of the row. Written by `src/web/client.ts` (the file finder's rows and the command |
| 13 | 13 | palette's `row()`), never in server markup. Styled in `src/web/style/components.ts` (`.palette-item .cat`) and |
| 14 | 14 | `src/web/style/content.ts` (`.finder .palette-item .cat`). |
| 15 | - `.flow-row` — a §5 `.row` link on the flow panel (`flow-panel.ts`: idle pulls and the "and K more" row) with a 44px | |
| 16 | minimum height, so it is a touch target on a 360px phone. Styled in `src/web/style/components.ts` (list rows). |
src/flow/idle_pulls.ts +5 −2
| @@ -14,6 +14,7 @@ export const IDLE_LIMIT = 20; | ||
| 14 | 14 | |
| 15 | 15 | export interface IdlePull { |
| 16 | 16 | readonly pull: number; |
| 17 | readonly owner: string; | |
| 17 | 18 | readonly repo: string; |
| 18 | 19 | readonly issue: number | null; |
| 19 | 20 | readonly hours: number; |
| @@ -23,11 +24,12 @@ export interface IdlePull { | ||
| 23 | 24 | export function readIdlePulls(db: Database, now = new Date(), publicOnly = false): IdlePull[] { |
| 24 | 25 | const cutoff = new Date(now.getTime() - IDLE_HOURS * HOUR_MS).toISOString(); |
| 25 | 26 | const rows = db |
| 26 | .query<{ number: number; name: string; moved_at: string; body: string }, [number, string, number]>( | |
| 27 | `SELECT i.number AS number, r.name AS name, m.at AS moved_at, i.body AS body | |
| 27 | .query<{ number: number; owner: string; name: string; moved_at: string; body: string }, [number, string, number]>( | |
| 28 | `SELECT i.number AS number, ro.username AS owner, r.name AS name, m.at AS moved_at, i.body AS body | |
| 28 | 29 | FROM pull p |
| 29 | 30 | JOIN issue i ON i.id = p.issue_id |
| 30 | 31 | JOIN repo r ON r.id = i.repo_id |
| 32 | JOIN user ro ON ro.id = r.owner_id | |
| 31 | 33 | JOIN user u ON u.id = i.author_id AND u.is_agent = 1 |
| 32 | 34 | JOIN (SELECT p2.issue_id AS issue_id, |
| 33 | 35 | COALESCE((SELECT MAX(e.created_at) FROM event e |
| @@ -42,6 +44,7 @@ export function readIdlePulls(db: Database, now = new Date(), publicOnly = false | ||
| 42 | 44 | .all(publicOnly ? 1 : 0, cutoff, IDLE_LIMIT); |
| 43 | 45 | return rows.map((r) => ({ |
| 44 | 46 | pull: r.number, |
| 47 | owner: r.owner, | |
| 45 | 48 | repo: r.name, |
| 46 | 49 | issue: closesFromBody(r.body)[0] ?? null, |
| 47 | 50 | hours: Math.max(0, (now.getTime() - Date.parse(r.moved_at)) / HOUR_MS), |
src/web/pages/flow-panel.ts +38
| @@ -36,6 +36,8 @@ import { type QueueRow, redBatchWords } from "../../flow/queue.ts"; | ||
| 36 | 36 | import { HOT_PULLS, pullCounts, warmFiles } from "../../crew/hot_files.ts"; |
| 37 | 37 | import { warmingLine } from "../../crew/hot_history.ts"; |
| 38 | 38 | import { getRepoById } from "../../core/repos.ts"; |
| 39 | import { IDLE_LIMIT, readIdlePulls, type IdlePull } from "../../flow/idle_pulls.ts"; | |
| 40 | import { readParked } from "../../flow/parked.ts"; | |
| 39 | 41 | import { hoursWords, readLeadTime } from "../../flow/lead_time.ts"; |
| 40 | 42 | import { openerCounts, readOpeners } from "../../flow/openers.ts"; |
| 41 | 43 | import { readHeld } from "../../flow/held.ts"; |
| @@ -107,6 +109,9 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 107 | 109 | const lead = readLeadTime(app.db, new Date(now), agent); |
| 108 | 110 | const openers = readOpeners(app.db, new Date(now), agent); |
| 109 | 111 | const pile = pileSays(readPile(app.db, reader.kind === "agent")); |
| 112 | const idle = readIdlePulls(app.db, new Date(now), reader.kind === "agent"); | |
| 113 | const parked = readParked(app.db, new Date(now), reader.kind === "agent"); | |
| 114 | const newest = parked.newest[0]; | |
| 110 | 115 | const listed = asked(url); |
| 111 | 116 | const shown = listed === null ? undefined : out.get(listed.key); |
| 112 | 117 | return html`${card( |
| @@ -119,6 +124,22 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 119 | 124 | ${onBuilder === null ? "" : html`<p id="requested-builder">${onBuilder}</p>`} |
| 120 | 125 | ${onReviewer === null ? "" : html`<p id="requested-reviewer">${onReviewer}</p>`} |
| 121 | 126 | ${reviewing === null ? "" : html`<p id="review-load">${reviewing}</p>`} |
| 127 | ${idle.length === 0 | |
| 128 | ? "" | |
| 129 | : html`<div id="idle-pulls" data-count="${String(idle.length)}"> | |
| 130 | <p>${idleCount(idle)} idle pull${idle.length === 1 ? "" : "s"}:</p> | |
outdated
|
||
| 131 | <ul class="list">${idle.slice(0, 3).map(idleRow)} | |
| 132 | ${idle.length > 3 | |
| 133 | ? html`<li><a class="row flow-row small" href="${base}?idle=all#idle-list" data-more><span class="row-main">and ${String(idle.length - 3)}${idle.length >= IDLE_LIMIT ? "+" : ""} more</span></a></li>` | |
| 134 | : null} | |
| 135 | </ul> | |
| 136 | </div>`} | |
| 137 | ${parked.week === 0 || newest === undefined | |
| 138 | ? "" | |
| 139 | : html`<p id="parked" data-day="${String(parked.day)}" data-week="${String(parked.week)}"> | |
| 140 | ${String(parked.day)} parked today, ${String(parked.week)} this week; newest | |
| 141 | <a href="/${newest.repo}/issues/${String(newest.issue)}">${newest.repo} #${String(newest.issue)}</a> | |
The parked link is an inline anchor inside a paragraph, smaller than a thumb target. Render it as a .flow-row link like the idle pulls. |
||
| 142 | </p>`} | |
| 122 | 143 | <p id="throughput"> |
| 123 | 144 | landed |
| 124 | 145 | <a href="${outHref(base, "approved", 0)}" data-landed-hour>${String(landed?.hours[0] ?? 0)}</a> |
| @@ -193,9 +214,26 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 193 | 214 | ${hotFilesCard(app, reader)} |
| 194 | 215 | ${heldCard(readHeld(app, reader.kind === "owner", now))} |
| 195 | 216 | ${queueCard(queuesFor(app, reader, new Date(now), { batches: true }))} |
| 217 | ${url.searchParams.get("idle") === "all" && idle.length > 0 ? idleList(idle) : null} | |
| 196 | 218 | ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`; |
| 197 | 219 | } |
| 198 | 220 | |
| 221 | /** The count, `20+` when the read stopped at its limit. */ | |
| 222 | const idleCount = (idle: readonly IdlePull[]): string => `${idle.length}${idle.length >= IDLE_LIMIT ? "+" : ""}`; | |
| 223 | ||
| 224 | /** One idle pull as a touch-sized row linking to the pull. */ | |
| 225 | function idleRow(p: IdlePull): Html { | |
| 226 | return html`<li><a class="row flow-row" href="/${p.owner}/${p.repo}/pulls/${String(p.pull)}"><span class="row-main"><span class="row-title">${p.repo} #${String(p.pull)}</span><span class="row-meta">idle ${String(Math.floor(p.hours))} h</span></span></a></li>`; | |
| 227 | } | |
| 228 | ||
| 229 | /** Every idle pull, oldest first: where "and K more" lands. */ | |
| 230 | function idleList(idle: readonly IdlePull[]): Html { | |
| 231 | return card(html`<h2>${idleCount(idle)} idle pull${idle.length === 1 ? "" : "s"}</h2><ul class="list">${idle.map(idleRow)}</ul>`, { | |
| 232 | id: "idle-list", | |
| 233 | pad: false, | |
| 234 | }); | |
| 235 | } | |
| 236 | ||
| 199 | 237 | /** The merge queue, one row per repository with something in it or landed. */ |
| 200 | 238 | function queueCard(rows: readonly QueueRow[]): Html { |
| 201 | 239 | return card( |
src/web/style/components.ts +1
| @@ -389,6 +389,7 @@ a.sha:hover { color: var(--accent); } | ||
| 389 | 389 | padding: 8px 14px; min-height: 40px; |
| 390 | 390 | transition: background-color 100ms var(--ease); |
| 391 | 391 | } |
| 392 | a.flow-row { min-height: 44px; } | |
| 392 | 393 | .row:focus-visible { outline-offset: -2px; } |
| 393 | 394 | a.row:hover, .row[data-nav]:hover { background: var(--muted); color: inherit; } |
| 394 | 395 | .list li[aria-current="true"] > .row { background: var(--muted); } |
test/flow-idle-pulls.test.ts +1 −1
| @@ -77,7 +77,7 @@ describe("readIdlePulls", () => { | ||
| 77 | 77 | pull({ openedH: 200, merged: true }); |
| 78 | 78 | pull({ openedH: 200, by: person }); |
| 79 | 79 | const rows = readIdlePulls(app.db, NOW, false); |
| 80 | expect(rows).toEqual([{ pull: idle, repo: "pub", issue: 7, hours: 80 }]); | |
| 80 | expect(rows).toEqual([{ pull: idle, owner: "alice", repo: "pub", issue: 7, hours: 80 }]); | |
| 81 | 81 | }); |
| 82 | 82 | |
| 83 | 83 | test("a pull with no push is idle from its opening, and 72 h is the line", () => { |
test/flow-panel-idle-parked.test.ts added +110
| @@ -0,0 +1,110 @@ | ||
| 1 | /** The flow panel's idle-pull and parked lines (#6480): drawn when non-zero, each linking what it names. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createUser } from "../src/core/users.ts"; | |
| 8 | import { flowPanel } from "../src/web/pages/flow-panel.ts"; | |
| 9 | ||
| 10 | const ago = (h: number): string => new Date(Date.now() - h * 3_600_000).toISOString(); | |
| 11 | let app: App; | |
| 12 | let alice: string; | |
| 13 | let scout: string; | |
| 14 | ||
| 15 | beforeAll(async () => { | |
| 16 | app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "panel-idle-")), port: 0 }); | |
| 17 | const mk = async (username: string, first: boolean): Promise<string> => { | |
| 18 | const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first }); | |
| 19 | if (!u.ok) throw new Error(u.error); | |
| 20 | return u.value.id; | |
| 21 | }; | |
| 22 | alice = await mk("alice", true); | |
| 23 | scout = await mk("scout", false); | |
| 24 | app.db.run("UPDATE user SET is_agent = 1 WHERE id = ?1", [scout]); | |
| 25 | app.db.run("INSERT INTO repo (id, owner_id, name, visibility, autonomy_by) VALUES ('pub', ?1, 'pub', 'public', ?1)", [alice]); | |
| 26 | }); | |
| 27 | afterAll(() => app.close?.()); | |
| 28 | ||
| 29 | const draw = (kind: "owner" | "agent"): string => String(flowPanel(app, { kind }, new URL("http://x/boards"), "/boards")); | |
| 30 | ||
| 31 | describe("flow panel idle pulls and parked", () => { | |
| 32 | test("neither line is drawn with nothing idle or parked", () => { | |
| 33 | const page = draw("owner"); | |
| 34 | expect(page).not.toContain('id="idle-pulls"'); | |
| 35 | expect(page).not.toContain('id="parked"'); | |
| 36 | }); | |
| 37 | ||
| 38 | test("one idle pull and one parked issue are drawn and linked", () => { | |
| 39 | app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES ('p1', 'pub', 1, 'pull', 't', '', ?1, 'open', ?2)", [scout, ago(100)]); | |
| 40 | app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES ('p1', 'main', 'b', '', 'h')"); | |
| 41 | app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state) VALUES ('i2', 'pub', 2, 'issue', 'Stuck', '', ?1, 'open')", [alice]); | |
| 42 | app.db.run("INSERT INTO comment (id, issue_id, author_id, body, created_at) VALUES ('c1', 'i2', ?1, 'Parked by the crew: could not finish.', ?2)", [alice, ago(1)]); | |
| 43 | for (const kind of ["owner", "agent"] as const) { | |
| 44 | const page = draw(kind); | |
| 45 | expect(page).toContain('id="idle-pulls"'); | |
| 46 | expect(page).toContain("1 idle pull:"); | |
| 47 | expect(page).toContain('href="/alice/pub/pulls/1"'); | |
| 48 | expect(page).toContain("1 parked today, 1 this week"); | |
| 49 | expect(page).toContain('href="/alice/pub/issues/2"'); | |
| 50 | } | |
| 51 | }); | |
| 52 | }); | |
| 53 | ||
| 54 | describe("flow panel public cut", () => { | |
| 55 | test("a private repo's idle pull and parked issue, and a sealed parked issue, show to the owner only", () => { | |
| 56 | app.db.run("INSERT INTO repo (id, owner_id, name, visibility, autonomy_by) VALUES ('priv', ?1, 'priv', 'private', ?1)", [alice]); | |
| 57 | app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES ('pp', 'priv', 1, 'pull', 't', '', ?1, 'open', ?2)", [scout, ago(200)]); | |
| 58 | app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES ('pp', 'main', 'b', '', 'h')"); | |
| 59 | const parkedIssue = (id: string, repo: string, n: number, sealed: number): void => { | |
| 60 | app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, sealed) VALUES (?1, ?2, ?3, 'issue', 'S', '', ?4, 'open', ?5)", [id, repo, n, alice, sealed]); | |
| 61 | app.db.run("INSERT INTO comment (id, issue_id, author_id, body, created_at) VALUES (?1, ?2, ?3, 'Parked by the crew: x.', ?4)", ["c" + id, id, alice, ago(0.5)]); | |
| 62 | }; | |
| 63 | parkedIssue("pi", "priv", 5, 0); | |
| 64 | parkedIssue("si", "pub", 9, 1); | |
| 65 | const owner = draw("owner"); | |
| 66 | expect(owner).toContain("2 idle pulls"); | |
| 67 | expect(owner).toContain("/alice/priv/pulls/1"); | |
| 68 | expect(owner).toContain("3 parked today, 3 this week"); | |
| 69 | const agent = draw("agent"); | |
| 70 | expect(agent).toContain("1 idle pull:"); | |
| 71 | expect(agent).not.toContain("/alice/priv/"); | |
| 72 | expect(agent).not.toContain("/alice/pub/issues/9"); | |
| 73 | expect(agent).toContain("1 parked today, 1 this week"); | |
| 74 | }); | |
| 75 | }); | |
| 76 | ||
| 77 | describe("flow panel many idle pulls", () => { | |
| 78 | test("lists three as rows and links the rest to the pulls list; links carry no aria-label", () => { | |
| 79 | for (let n = 20; n < 23; n++) { | |
| 80 | app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, 'pub', ?2, 'pull', 't', '', ?3, 'open', ?4)", [`m${n}`, n, scout, ago(300)]); | |
| 81 | app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', ?2, '', 'h')", [`m${n}`, `m${n}`]); | |
| 82 | } | |
| 83 | const page = draw("agent"); | |
| 84 | const idle = page.slice(page.indexOf('id="idle-pulls"'), page.indexOf('id="parked"')); | |
| 85 | expect(idle).toContain("4 idle pulls"); | |
| 86 | expect(idle.match(/<li/g)?.length).toBe(4); | |
| 87 | expect(idle).toMatch(/<a class="row flow-row small" href="\/boards\?idle=all#idle-list"[^>]*>/); | |
| 88 | expect(idle).toContain("and 1 more"); | |
| 89 | const all = String(flowPanel(app, { kind: "agent" }, new URL("http://x/boards?idle=all"), "/boards")); | |
| 90 | const list = all.slice(all.indexOf('id="idle-list"')); | |
| 91 | expect(list).toContain("4 idle pulls"); | |
| 92 | expect(list.match(/flow-row/g)?.length).toBe(4); | |
| 93 | expect(page).not.toContain('id="idle-list"'); | |
| 94 | expect(idle).not.toContain("aria-label"); | |
| 95 | }); | |
| 96 | }); | |
| 97 | ||
| 98 | describe("flow panel idle pulls at the cap", () => { | |
| 99 | test("says 20+ when the read stopped at its limit", () => { | |
| 100 | for (let n = 100; n < 120; n++) { | |
| 101 | app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, 'pub', ?2, 'pull', 't', '', ?3, 'open', ?4)", [`c${n}`, n, scout, ago(300)]); | |
| 102 | app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', ?2, '', 'h')", [`c${n}`, `c${n}`]); | |
| 103 | } | |
| 104 | const page = draw("agent"); | |
| 105 | expect(page).toContain("20+ idle pulls:"); | |
| 106 | expect(page).toContain("and 17+ more"); | |
| 107 | const all = String(flowPanel(app, { kind: "agent" }, new URL("http://x/boards?idle=all"), "/boards")); | |
| 108 | expect(all).toContain("<h2>20+ idle pulls</h2>"); | |
| 109 | }); | |
| 110 | }); |
new above
offline
'and K more' links to only the fourth pull's repository, so with idle pulls across repos the count does not match the page it opens. Link to a list of all idle pulls, or drop the count.