Public: anyone can read this; the crew can change it.
My pinned graphs are back on home #8728
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8726-my-pinned-graphs-are-back-on-home → main 5 commits fork 3 threads · 3 unresolved
14 files
changed +347
−9
docs/design/areas/home.md +1
| @@ -3,3 +3,4 @@ | ||
| 3 | 3 | - `.moving` — the living home's two lists (Being built, Just shipped), drawn by `movingRegion` in `src/web/studio/living.ts`: a one-column grid on a phone, two equal columns from 700px. Each list always holds three rows, so a quiet list is as tall as a busy one and nothing below it moves. |
| 4 | 4 | - `.tell-box` — home's one-line box that tells the crew: a `bar` whose text input takes the free width (`flex: 1 1 14rem`) and wraps the app picker and Tell under it on a phone. |
| 5 | 5 | - `.kpi-row` — a modifier on `.stats` for the home's figure tiles, not a second layout: it only changes the phone case. `.stats` alone (`minmax(160px, 1fr)`) falls to one column at 360px (two 160px tiles and the gap need 332px of 328px), stacking nine 104px tiles to ~1,100px; under 480px `.kpi-row` is two equal `minmax(0, 1fr)` columns instead. Tile height stays fixed (`.stat`), so nothing shifts. |
| 6 | - `.home-panels-slot` — home's place for the pinned panels, right after the KPIs (`pinnedSlot` in `src/web/studio/pinned.ts`). Only while it still waits for `/live/home/panels` (`data-refresh`) it holds 240px, about one panel row, with the 16px below it that `.bgrid` keeps (the system has no spacing tokens, only the 4px scale in §4, and both sit on it), so the charts arriving after first paint shift nothing; the refresh script drops the attribute when the fragment lands, is empty, or fails, and the slot collapses to its content. |
docs/features/living-home.md +3 −1
| @@ -10,7 +10,9 @@ Signed in, `/` is five things and nothing else: | ||
| 10 | 10 | |
| 11 | 11 | The terminal's home carries the same lines (`movingLines` in `src/tui/screens/moving.ts`): Now for a site admin, then being built and just shipped, read for the viewer; its tell is the box. |
| 12 | 12 | |
| 13 | The feed — the composer, the timeline, what is on you, since you were here, pinned panels — is `/feed`, linked from home. | |
| 13 | 6. **Pinned panels** — an admin's own pins, in their order, right after the KPIs. With no pin nothing is drawn. With one, home holds an empty `#home-panels` slot (one panel row tall, so nothing shifts) that the refresh script fills from `/live/home/panels` after first paint, keeping Up, Down and Unpin; with no script a link opens the same fragment. The panels sit in a live region (`#home-pins`) that redraws on `board.pins`, the event every pin, unpin and move makes for its owner alone, so a change from a board, the terminal, the API or the MCP shows at once. In the terminal the same list is the palette's Pinned panels (`src/tui/screens/home_pins.ts`): `<`/`>` move, `u` unpins, `enter` opens the board. `/feed` still draws them too. | |
| 14 | ||
| 15 | The feed — the composer, the timeline, what is on you, since you were here — is `/feed`, linked from home. | |
| 14 | 16 | |
| 15 | 17 | Left out on purpose: the waiting-on-you line, the clog, vitals and keeper lines (the boards and the Flow dialog still show them), and "slowest right now" (it needs a timing read the home does not have). The box posts to `/tell` today; it becomes the `/talk` entry (#7652) when that lands. |
| 16 | 18 |
src/actions/board-pins.ts +7
| @@ -35,6 +35,10 @@ async function resolved(app: App, who: User): Promise<{ live: Pinned[]; gone: Pi | ||
| 35 | 35 | return { live, gone }; |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | /** Every door showing these pins (home's slot, the terminal's screen) redraws: an event for its owner alone. */ | |
| 39 | export const PINS_EVENT = "board.pins"; | |
| 40 | const moved = (app: App, who: User): void => void app.bus.append({ kind: "board.pins", actorId: who.id }); | |
| 41 | ||
| 38 | 42 | export async function listPinned(app: App, actor: User | undefined): Promise<Result<Pinned[], "not_found">> { |
| 39 | 43 | const who = person(actor); |
| 40 | 44 | return who ? ok((await resolved(app, who)).live) : err("not_found"); |
| @@ -52,6 +56,7 @@ export async function pinPanel(app: App, actor: User | undefined, slug: unknown, | ||
| 52 | 56 | if (live.length >= MAX_PINS && !live.some((p) => p.slug === which.slug && p.title === which.title)) return err("too_many"); |
| 53 | 57 | pin(app.db, who.id, which); |
| 54 | 58 | forgetRegion(app.db, who.id, "panels"); |
| 59 | moved(app, who); | |
| 55 | 60 | return ok(which); |
| 56 | 61 | } |
| 57 | 62 | |
| @@ -62,6 +67,7 @@ export function unpinPanel(app: App, actor: User | undefined, slug: unknown, tit | ||
| 62 | 67 | if (!which) return err("invalid"); |
| 63 | 68 | unpin(app.db, who.id, which); |
| 64 | 69 | forgetRegion(app.db, who.id, "panels"); |
| 70 | moved(app, who); | |
| 65 | 71 | return ok(which); |
| 66 | 72 | } |
| 67 | 73 | |
| @@ -73,5 +79,6 @@ export function movePinned(app: App, actor: User | undefined, slug: unknown, tit | ||
| 73 | 79 | if (!listPins(app.db, who.id).some((x) => x.slug === which.slug && x.title === which.title)) return err("not_found"); |
| 74 | 80 | movePin(app.db, who.id, which, by); |
| 75 | 81 | forgetRegion(app.db, who.id, "panels"); |
| 82 | moved(app, who); | |
| 76 | 83 | return ok(which); |
| 77 | 84 | } |
src/core/event_audience.ts +2
| @@ -200,6 +200,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined { | ||
| 200 | 200 | if ( |
| 201 | 201 | kind === "repo.pinned" || |
| 202 | 202 | kind === "repo.unpinned" || |
| 203 | // A person's pinned panels changed (`actions/board-pins.ts`): theirs alone. | |
| 204 | kind === "board.pins" || | |
| 203 | 205 | kind.startsWith("fleet.") || |
| 204 | 206 | kind.startsWith("thought.") |
| 205 | 207 | ) |
src/design/activity.ts +1
| @@ -150,6 +150,7 @@ const WORDS: Record<string, string> = { | ||
| 150 | 150 | "repo.imported": "imported issues and pulls", |
| 151 | 151 | "repo.pinned": "pinned a repository", |
| 152 | 152 | "repo.unpinned": "unpinned a repository", |
| 153 | "board.pins": "changed their pinned panels", | |
| 153 | 154 | "branch.created": "created a branch", |
| 154 | 155 | "branch.deleted": "deleted a branch", |
| 155 | 156 | "label.created": "added a label", |
src/tui/screens/entries.ts +2
| @@ -22,6 +22,7 @@ import { entry as domains } from "./domains.ts"; | ||
| 22 | 22 | import { entry as feed } from "./feed.ts"; |
| 23 | 23 | import { entry as front } from "./front.ts"; |
| 24 | 24 | import { entry as forges } from "./forges.ts"; |
| 25 | import { entry as homePins } from "./home_pins.ts"; | |
| 25 | 26 | import { entry as invites } from "./invites.ts"; |
| 26 | 27 | import { entry as limits } from "./limits.ts"; |
| 27 | 28 | import { modelCeilingEntry, modelGrantEntry } from "./model.ts"; |
| @@ -60,6 +61,7 @@ export interface PaletteEntry { | ||
| 60 | 61 | export const ENTRIES: readonly PaletteEntry[] = [ |
| 61 | 62 | sentinel, |
| 62 | 63 | boards, |
| 64 | homePins, | |
| 63 | 65 | system, |
| 64 | 66 | telemetry, |
| 65 | 67 | resilience, |
src/tui/screens/home_pins.ts added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | /** | |
| 2 | * The panels pinned to home, in the terminal (#8726): the same list home | |
| 3 | * draws after its KPIs, in its owner's order, read and changed through | |
| 4 | * `actions/board-pins.ts` as the page's Up, Down and Unpin are, so the gate | |
| 5 | * is the action's (a forge admin who is a person, their own pins only) and | |
| 6 | * the palette offers the door only to that person. `<`/`>` move the marked | |
| 7 | * pin, `u` unpins it, `enter` opens its board. A change from any door (home, | |
| 8 | * a board, the API, the MCP) redraws this list, and one made here redraws home. | |
| 9 | */ | |
| 10 | ||
| 11 | import { listPinned, movePinned, PINS_EVENT, unpinPanel, type Pinned } from "../../actions/board-pins.ts"; | |
| 12 | import { showBoard } from "../../actions/boards.ts"; | |
| 13 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 14 | import { binding } from "../app/keys.ts"; | |
| 15 | import type { Session } from "../session.ts"; | |
| 16 | import { fill, len, split } from "../ui/layout.ts"; | |
| 17 | import { emptyState } from "../ui/widgets.ts"; | |
| 18 | import { style } from "../term/style.ts"; | |
| 19 | import { truncate } from "../term/width.ts"; | |
| 20 | import { boardScreen, plain } from "./boards.ts"; | |
| 21 | import { ListState, drawList, hitList, live, plural, runAsync, type ListRow } from "./common.ts"; | |
| 22 | import type { PaletteEntry } from "./entries.ts"; | |
| 23 | ||
| 24 | const mayPin = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind !== "agent"; | |
| 25 | ||
| 26 | /** One pin as a list row: the panel's title, then its board and chart. */ | |
| 27 | export const pinRow = (p: Pinned): ListRow => ({ title: plain(p.panel.title), secondary: `${plain(p.board.title)} · ${p.panel.chart}` }); | |
| 28 | ||
| 29 | export function homePinsScreen(f: Session, ctx: Ctx): Screen { | |
| 30 | const state = new ListState(); | |
| 31 | let pins: Pinned[] = []; | |
| 32 | const read = async (): Promise<void> => { | |
| 33 | const listed = await listPinned(f.app, f.user); | |
| 34 | pins = listed.ok ? listed.value : []; | |
| 35 | state.clamp(pins.length); | |
| 36 | ctx.invalidate(); | |
| 37 | }; | |
| 38 | const act = (by?: -1 | 1): void => { | |
| 39 | const p = pins[state.index]; | |
| 40 | if (p === undefined) return; | |
| 41 | runAsync(ctx, async () => { | |
| 42 | const done = by === undefined ? unpinPanel(f.app, f.user, p.slug, p.title) : movePinned(f.app, f.user, p.slug, p.title, by); | |
| 43 | if (done.ok && by !== undefined) state.index = Math.max(0, Math.min(pins.length - 1, state.index + by)); | |
| 44 | ctx.toast(done.ok ? (by === undefined ? "unpinned" : "moved on home") : "could not change that pin", done.ok ? "success" : "danger"); | |
| 45 | await read(); | |
| 46 | }); | |
| 47 | }; | |
| 48 | const open = (index: number): void => { | |
| 49 | const p = pins[index]; | |
| 50 | if (p === undefined) return; | |
| 51 | runAsync(ctx, async () => { | |
| 52 | const shown = await showBoard(f.app, f.user, p.slug); | |
| 53 | if (!shown.ok) ctx.toast("That board is gone", "default"); | |
| 54 | else ctx.push(boardScreen(ctx, f, p.slug, shown.value)); | |
| 55 | }); | |
| 56 | }; | |
| 57 | return { | |
| 58 | title: "Pinned panels", | |
| 59 | keys: [binding(["j", "k"], "move"), binding(["<", ">"], "move pin"), binding(["u"], "unpin"), binding(["enter"], "open board")], | |
| 60 | async load(signal) { | |
| 61 | await read(); | |
| 62 | live(f, signal, { kinds: [PINS_EVENT], where: (e) => e.actorId === f.user?.id }, read); | |
| 63 | }, | |
| 64 | render(area, s) { | |
| 65 | const c = ctx.theme.c; | |
| 66 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 67 | s.text(head!.x, head!.y, truncate(`${plural(pins.length, "panel")} pinned to home, in its order`, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 68 | if (pins.length === 0) { | |
| 69 | emptyState(s, body!, c, { title: "Nothing pinned", body: "Open Boards, mark a panel with [ or ], and press p." }); | |
| 70 | return; | |
| 71 | } | |
| 72 | drawList(s, body!, c, state, pins.map(pinRow)); | |
| 73 | hitList(ctx, body!, state, pins.length, { open }); | |
| 74 | }, | |
| 75 | key(e) { | |
| 76 | if (!mayPin(f)) return false; | |
| 77 | if (state.key(e, pins.length)) return true; | |
| 78 | if (e.key === "enter") return open(state.index), true; | |
| 79 | if (e.key === "u") return act(), true; | |
| 80 | if (e.key === "<" || e.key === ">") return act(e.key === ">" ? 1 : -1), true; | |
| 81 | return false; | |
| 82 | }, | |
| 83 | }; | |
| 84 | } | |
| 85 | ||
| 86 | /** Home's pinned panels, as the page gates them: a forge admin who is a person. */ | |
| 87 | export const entry: PaletteEntry = { | |
| 88 | id: "home-pins", | |
| 89 | title: "Pinned panels", | |
| 90 | hint: "the panels pinned to home: reorder and unpin", | |
| 91 | after: ["boards"], | |
| 92 | offered: mayPin, | |
| 93 | run: (session, ctx) => ctx.push(homePinsScreen(session, ctx)), | |
| 94 | }; |
src/web/studio/home.ts +2 −1
| @@ -64,7 +64,7 @@ import { accessFingerprint } from "../../core/access_fingerprint.ts"; | ||
| 64 | 64 | import { homeFresh, homeRegion, homeVariant, refreshJs, type HomeRegion } from "./home_last.ts"; |
| 65 | 65 | import type { KpiTimer } from "../../flow/kpis.ts"; |
| 66 | 66 | import { archivedAppCount } from "../pages/apps.ts"; |
| 67 | import { pinnedPanels } from "./pinned.ts"; | |
| 67 | import { pinnedPanels, pinnedSlot } from "./pinned.ts"; | |
| 68 | 68 | import { isNavigation, lastVisit, noteVisit } from "../../core/home_visit.ts"; |
| 69 | 69 | import { sinceYouWere } from "./since.ts"; |
| 70 | 70 | import { homeTabs, unshippedAsks } from "./home_tabs.ts"; |
| @@ -328,6 +328,7 @@ export async function studioHome( | ||
| 328 | 328 | ${tellBox(ctx, user)} |
| 329 | 329 | ${ask} ${address} |
| 330 | 330 | <section class="stack" aria-label="The forge">${kpis}</section> |
| 331 | ${pinnedSlot(ctx.app.db, user)} | |
| 331 | 332 | ${timePhase(ctx.request, "moving", () => movingRegion(ctx.app.db, user))} |
| 332 | 333 | ${appChips(ctx, user)} |
| 333 | 334 | ${when( |
src/web/studio/home_last.ts +2 −2
| @@ -121,7 +121,7 @@ export const refreshJs = /* js */ ` | ||
| 121 | 121 | var holder = document.createElement('template'); |
| 122 | 122 | holder.innerHTML = text; |
| 123 | 123 | var root = holder.content.firstElementChild; |
| 124 | if (!root) return; | |
| 124 | if (!root) { el.removeAttribute('data-refresh'); return; } | |
| 125 | 125 | // A region that owns its element (the feed, bound once by its script) is swapped inside it. |
| 126 | 126 | var inner = root.children.length === 1 ? root.firstElementChild : null; |
| 127 | 127 | var mine = inner && inner.id ? el.querySelector('#' + inner.id) : null; |
| @@ -129,7 +129,7 @@ export const refreshJs = /* js */ ` | ||
| 129 | 129 | el.removeAttribute('data-refresh'); |
| 130 | 130 | el.dispatchEvent(new CustomEvent('forge:swapped', { bubbles: true })); |
| 131 | 131 | }) |
| 132 | .catch(function () {}); | |
| 132 | .catch(function () { el.removeAttribute('data-refresh'); }); | |
| 133 | 133 | }); |
| 134 | 134 | })(); |
| 135 | 135 | `; |
src/web/studio/pinned.ts +25 −3
| @@ -4,10 +4,12 @@ | ||
| 4 | 4 | * viewer's own pins are read. A pin whose board or panel is gone is skipped. |
| 5 | 5 | */ |
| 6 | 6 | import type { App } from "../../app.ts"; |
| 7 | import { listPinned } from "../../actions/board-pins.ts"; | |
| 7 | import { listPinned, PINS_EVENT } from "../../actions/board-pins.ts"; | |
| 8 | 8 | import type { Board, Panel } from "../../boards/store.ts"; |
| 9 | 9 | import type { Pin } from "../../boards/pins.ts"; |
| 10 | 10 | import type { User } from "../../core/users.ts"; |
| 11 | import type { Database } from "bun:sqlite"; | |
| 12 | import { listPins } from "../../boards/pins.ts"; | |
| 11 | 13 | import { html, type Html } from "../html.ts"; |
| 12 | 14 | import { btn } from "../ui.ts"; |
| 13 | 15 | import { cachedPanel } from "./home_cache.ts"; |
| @@ -31,6 +33,26 @@ const cached = (p: Pin & { board: Board; panel: Panel }): typeof runFor => (db, | ||
| 31 | 33 | |
| 32 | 34 | export async function pinnedPanels(app: App, user: User): Promise<Html | null> { |
| 33 | 35 | const listed = await listPinned(app, user); |
| 34 | if (!listed.ok || listed.value.length === 0) return null; | |
| 35 | return html`<section class="bgrid" aria-label="Pinned panels">${listed.value.map((p) => drawPanel(app, p.board, p.panel, { slug: p.slug, index: p.board.panels.indexOf(p.panel) }, controls(p), cached(p)))}</section>`; | |
| 36 | if (!listed.ok) return null; | |
| 37 | const shown = listed.value.length === 0 ? null : html`<section class="bgrid" aria-label="Pinned panels">${listed.value.map((p) => drawPanel(app, p.board, p.panel, { slug: p.slug, index: p.board.panels.indexOf(p.panel) }, controls(p), cached(p)))}</section>`; | |
| 38 | return pinsRegion(shown); | |
| 39 | } | |
| 40 | ||
| 41 | /** The live region the pins sit in: a pin, unpin or move from any door (the terminal too) redraws it from `/live/home/panels`. */ | |
| 42 | const pinsRegion = (inner: Html | null): Html => | |
| 43 | html`<div id="home-pins" data-live="${PINS_EVENT}" data-live-src="/live/home/panels">${inner}</div>`; | |
| 44 | ||
| 45 | /** | |
| 46 | * Home's place for the pins, right after the KPIs. Without a pin it is an | |
| 47 | * empty live region and takes no space (a pin made elsewhere fills it); with | |
| 48 | * one, an empty slot that the page's script fills from | |
outdated
The pins have no terminal screen. Add one in src/tui/ with its entry where AGENTS.md says, gated the same as this (admin, own pins), and cover it with a test. |
||
| 49 | * `/live/home/panels` after first paint, so the charts never cost the paint. | |
| 50 | * The slot holds one panel row's height until the fragment lands (or | |
| 51 | * fails, or comes back empty), so nothing below it shifts. Without script | |
| 52 | * the noscript link opens the same fragment. | |
| 53 | */ | |
| 54 | export function pinnedSlot(db: Database, user: User): Html | null { | |
| 55 | if (!user.isAdmin || user.kind === "agent") return null; | |
| 56 | if (listPins(db, user.id).length === 0) return html`<div id="home-panels" class="home-panels-slot">${pinsRegion(null)}</div>`; | |
| 57 | return html`<div id="home-panels" class="home-panels-slot" data-refresh="/live/home/panels">${pinsRegion(null)}<noscript><p class="small muted"><a href="/live/home/panels">Open your pinned panels</a>.</p></noscript></div>`; | |
| 36 | 58 | } |
src/web/style/components.ts +3
| @@ -864,6 +864,9 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 864 | 864 | .bgrid h2 { font-size: 14px; } |
| 865 | 865 | @media (max-width: 720px) { .bgrid { grid-template-columns: minmax(0, 1fr); } .bp-w2, .bp-w3 { grid-column: auto; } } |
| 866 | 866 | |
| 867 | /* Home's slot for pinned panels holds one panel row until the fragment lands, then the panels size it. */ | |
| 868 | .home-panels-slot[data-refresh] { min-height: 240px; margin-bottom: 16px; } | |
outdated
|
||
| 869 | ||
| 867 | 870 | /* ---- settings shell ---- */ |
| 868 | 871 | .settings { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px; margin-top: 16px; } |
| 869 | 872 | .settings-nav { |
test/board-pins.test.ts +76 −2
| @@ -1,4 +1,6 @@ | ||
| 1 | 1 | /** Pinned panels (#2741): pin shows on home under the KPIs in the owner's order, unpin removes, nobody else's pins show. */ |
| 2 | import { stylesheetAsset } from "../src/web/asset.ts"; | |
| 3 | import { refreshJs } from "../src/web/studio/home_last.ts"; | |
| 2 | 4 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 3 | 5 | import { mkdtemp, rm } from "node:fs/promises"; |
| 4 | 6 | import { tmpdir } from "node:os"; |
| @@ -25,10 +27,50 @@ const post = (path: string, fields: Record<string, string>, who = "alice", heade | ||
| 25 | 27 | method: "POST", redirect: "manual", body: new URLSearchParams(fields), |
| 26 | 28 | headers: { authorization: `Bearer ${tok[who]}`, ...headers }, |
| 27 | 29 | }); |
| 28 | const home = async (who: string) => | |
| 29 | (await fetch(servers.url + "/feed", { headers: { authorization: `Bearer ${tok[who]}` } })).text(); | |
| 30 | const get = async (path: string, who: string) => | |
| 31 | (await fetch(servers.url + path, { headers: { authorization: `Bearer ${tok[who]}` } })).text(); | |
| 32 | /** Home as the page's script leaves it: the slot after first paint, then the fragment it fetches. */ | |
| 33 | const home = async (who: string) => { | |
| 34 | const first = await get("/?all=1", who); | |
| 35 | expect(titles(first)).toEqual([]); // nothing of the charts in the first paint | |
| 36 | return first.includes('data-refresh="/live/home/panels"') ? get("/live/home/panels", who) : ""; | |
| 37 | }; | |
| 30 | 38 | const titles = (h: string) => [...h.matchAll(/<h2>(Pin [ABC])<\/h2>/g)].map((m) => m[1]); |
| 31 | 39 | |
| 40 | type Answer = () => Promise<{ ok: boolean; text: () => Promise<string> }>; | |
| 41 | /** | |
| 42 | * `refreshJs` run against one slot in a stand-in document: whether the slot still | |
| 43 | * waits (holds its reserve) afterwards, and what its live region holds. The | |
| 44 | * stand-in parses only what the fragment is: a <div> around #home-pins. | |
| 45 | */ | |
| 46 | async function refreshSlot(answer: Answer): Promise<{ waiting: boolean; pins: string }> { | |
| 47 | const attrs = new Map([["data-refresh", "/live/home/panels"]]); | |
| 48 | const pins = { id: "home-pins", innerHTML: "" }; | |
| 49 | const slot = { | |
| 50 | getAttribute: (n: string) => attrs.get(n) ?? null, | |
| 51 | removeAttribute: (n: string) => void attrs.delete(n), | |
| 52 | querySelector: (q: string) => (q === "#home-pins" ? pins : null), | |
| 53 | dispatchEvent: () => true, | |
| 54 | innerHTML: "", | |
| 55 | }; | |
| 56 | const template = () => { | |
| 57 | const content: { firstElementChild: unknown } = { firstElementChild: null }; | |
| 58 | return { | |
| 59 | content, | |
| 60 | set innerHTML(text: string) { | |
| 61 | const inner = { id: "home-pins", innerHTML: "PANELS" }; | |
| 62 | content.firstElementChild = text === "" ? null : { children: [inner], firstElementChild: inner, innerHTML: text }; | |
| 63 | }, | |
| 64 | }; | |
| 65 | }; | |
| 66 | const document = { querySelectorAll: () => [slot], createElement: template }; | |
| 67 | const window = { fetch: answer, HTMLTemplateElement: function () {} }; | |
| 68 | class CustomEvent { constructor(readonly type: string) {} } | |
| 69 | new Function("window", "document", "fetch", "CustomEvent", refreshJs)(window, document, answer, CustomEvent); | |
| 70 | for (let i = 0; i < 10; i++) await Promise.resolve(); | |
| 71 | return { waiting: attrs.has("data-refresh"), pins: pins.innerHTML }; | |
| 72 | } | |
| 73 | ||
| 32 | 74 | beforeAll(async () => { |
| 33 | 75 | dir = await mkdtemp(join(tmpdir(), "forge-board-pins-")); |
| 34 | 76 | app = createApp({ dataDir: dir, port: 0 }); |
| @@ -73,6 +115,38 @@ describe("pinned panels", () => { | ||
| 73 | 115 | expect(titles(await home("alice"))).toEqual(["Pin A", "Pin B"]); |
| 74 | 116 | }); |
| 75 | 117 | |
| 118 | test("home's first paint holds a slot after the KPIs only when something is pinned", async () => { | |
| 119 | const first = await get("/?all=1", "alice"); | |
| 120 | expect(first.indexOf('aria-label="The forge"')).toBeLessThan(first.indexOf('id="home-panels"')); | |
| 121 | expect(first.indexOf('id="home-panels"')).toBeLessThan(first.indexOf("live-moving")); | |
| 122 | expect(first).not.toContain("Pinned panels"); | |
| 123 | expect(first).toContain('class="home-panels-slot"'); | |
| 124 | expect(first).toContain("Open your pinned panels"); // no-script door | |
| 125 | expect(first).toMatch(/id="home-pins"[^>]*data-live="board\.pins"/); // a pin from any door redraws it live | |
| 126 | // The reserve is held only while the slot waits: once the script drops data-refresh, nothing holds it. | |
| 127 | const css = await (await fetch(servers.url + stylesheetAsset().path)).text(); | |
| 128 | const rules = [...css.matchAll(/([^{}]*\.home-panels-slot[^{}]*)\{([^}]*)\}/g)].map((m) => [m[1]!.trim(), m[2]!] as const); | |
| 129 | expect(rules.some(([sel, body]) => sel.includes("[data-refresh]") && /min-height/.test(body))).toBe(true); | |
| 130 | expect(rules.some(([sel, body]) => !sel.includes("[data-refresh]") && /min-height/.test(body))).toBe(false); | |
| 131 | const slot = first.match(/<div id="home-panels"[\s\S]*?<\/noscript><\/div>/)?.[0] ?? ""; | |
| 132 | expect(slot.length).toBeLessThan(400); // the pins cost home's first paint almost no bytes | |
| 133 | expect(await get("/?all=1", "bob")).not.toContain("home-panels"); | |
| 134 | // An admin with no pins: an empty live region to fill, no reserve, no fetch, no link. | |
| 135 | const none = await get("/?all=1", "carol"); | |
| 136 | expect(none).toContain('id="home-pins"'); | |
| 137 | expect(none).not.toContain('data-refresh="/live/home/panels"'); | |
| 138 | expect(none).not.toContain("Open your pinned panels"); | |
| 139 | }); | |
| 140 | ||
| 141 | test("the slot is filled when the fragment lands, and collapses when it is empty or fails", async () => { | |
| 142 | const filled = await refreshSlot(async () => ({ ok: true, text: async () => "<div>pins</div>" })); | |
| 143 | expect(filled.pins).toBe("PANELS"); | |
| 144 | expect(filled.waiting).toBe(false); | |
| 145 | expect((await refreshSlot(async () => ({ ok: true, text: async () => "" }))).waiting).toBe(false); | |
| 146 | expect((await refreshSlot(async () => ({ ok: false, text: async () => "" }))).waiting).toBe(false); | |
| 147 | expect((await refreshSlot(async () => { throw new Error("offline"); })).waiting).toBe(false); | |
| 148 | }); | |
| 149 | ||
| 76 | 150 | test("the pin button is on the board page and flips to Unpin", async () => { |
| 77 | 151 | const page = await (await fetch(servers.url + "/boards/mine", { headers: { authorization: `Bearer ${tok.alice}` } })).text(); |
| 78 | 152 | expect(page).toContain("Unpin: Pin A"); |
test/palette-roster.test.ts +1
| @@ -72,6 +72,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 72 | 72 | ["signins", "Signed in", "your browsers and tokens; d signs a browser out"], |
| 73 | 73 | ["sentinel", "Sentinel", "the forge's open faults"], |
| 74 | 74 | ["boards", "Boards", "the forge's boards and their panels"], |
| 75 | ["home-pins", "Pinned panels", "the panels pinned to home: reorder and unpin"], | |
| 75 | 76 | ["origin-apps", "Origin apps", "your OAuth apps for connecting origins; a adds, d removes"], |
| 76 | 77 | ["system-memory", "System memory", "RSS beside the JS heap, hourly for 24 h"], |
| 77 | 78 | ["flow", "Flow", "the pipeline's bottleneck and throughput"], |
test/tui-home-pins.test.ts added +128
| @@ -0,0 +1,128 @@ | ||
| 1 | /** | |
| 2 | * Home's pinned panels in the terminal (#8726): a forge admin who is a person | |
| 3 | * lists their own pins in home's order, moves and unpins them, and each change | |
| 4 | * is an event for them alone, which home's live region redraws on. Nobody | |
| 5 | * else has the door, and nobody else's pins show. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { PINS_EVENT, pinPanel } from "../src/actions/board-pins.ts"; | |
| 12 | import { writeBoard } from "../src/actions/boards.ts"; | |
| 13 | import { createAgent } from "../src/actions/crew.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { listPins } from "../src/boards/pins.ts"; | |
| 16 | import { eventAudience } from "../src/core/event_audience.ts"; | |
| 17 | import type { ForgeEvent } from "../src/core/events.ts"; | |
| 18 | import { createUser, type User } from "../src/core/users.ts"; | |
| 19 | import { createForgeApp, forgeCommands } from "../src/tui/forge.ts"; | |
| 20 | import { homePinsScreen } from "../src/tui/screens/home_pins.ts"; | |
| 21 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | import { settleApp } from "./helpers/tui.ts"; | |
| 24 | ||
| 25 | quietLogs(); | |
| 26 | ||
| 27 | let dir: string; | |
| 28 | let app: App; | |
| 29 | let carol: User; | |
| 30 | let dana: User; | |
| 31 | let bob: User; | |
| 32 | let bot: User; | |
| 33 | ||
| 34 | const identity = (user?: User) => ({ sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }); | |
| 35 | ||
| 36 | async function screenText(user: User | undefined, keys = ""): Promise<string> { | |
| 37 | const terminal = headlessTerminal(120, 30); | |
| 38 | const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false }); | |
| 39 | tui.start(); | |
| 40 | tui.ctx.push(homePinsScreen(session, tui.ctx)); | |
| 41 | await settleApp(tui); | |
| 42 | for (const key of keys) { | |
| 43 | terminal.feed(key); | |
| 44 | await settleApp(tui); | |
| 45 | } | |
| 46 | const text = tui.screen.toText(); | |
| 47 | tui.stop?.(); | |
| 48 | return text; | |
| 49 | } | |
| 50 | ||
| 51 | const doors = (user?: User): string[] => { | |
| 52 | const { app: tui, session } = createForgeApp(app, identity(user), { terminal: headlessTerminal(80, 24), exitHooks: false }); | |
| 53 | const ids = forgeCommands(session)(tui.ctx).map((c) => c.id); | |
| 54 | tui.stop?.(); | |
| 55 | return ids; | |
| 56 | }; | |
| 57 | ||
| 58 | const order = (u: User) => listPins(app.db, u.id).map((p) => p.title); | |
| 59 | const panel = (title: string) => ({ title, chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 }); | |
| 60 | ||
| 61 | beforeAll(async () => { | |
| 62 | dir = await mkdtemp(join(tmpdir(), "forge-tui-home-pins-")); | |
| 63 | app = createApp({ dataDir: dir, port: 0 }); | |
| 64 | const mk = async (username: string, first = false) => { | |
| 65 | const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first }); | |
| 66 | if (!r.ok) throw new Error(r.error); | |
| 67 | return r.value; | |
| 68 | }; | |
| 69 | carol = await mk("carol", true); | |
| 70 | dana = await mk("dana"); | |
| 71 | app.db.run("UPDATE user SET is_admin = 1 WHERE id = ?", [dana.id]); | |
| 72 | dana = { ...dana, isAdmin: true }; | |
| 73 | bob = await mk("bob"); | |
| 74 | const made = await createAgent(app, carol, { username: "pinbot", brief: "x" }); | |
| 75 | if (!made.ok) throw new Error(String(made.error)); | |
| 76 | app.db.run("UPDATE user SET is_admin = 1 WHERE id = ?", [made.value.user.id]); | |
| 77 | bot = { ...made.value.user, isAdmin: true }; | |
| 78 | const w = await writeBoard(app, carol, "mine", { title: "Mine", range: "all", refresh: "off", panels: [panel("Pin A"), panel("Pin B"), panel("Pin C")] }); | |
| 79 | if (!w.ok) throw new Error(JSON.stringify(w.error)); | |
| 80 | for (const t of ["Pin A", "Pin B", "Pin C"]) expect((await pinPanel(app, carol, "mine", t)).ok).toBe(true); | |
| 81 | expect((await pinPanel(app, dana, "mine", "Pin C")).ok).toBe(true); | |
| 82 | }, 30_000); | |
| 83 | ||
| 84 | afterAll(async () => { | |
| 85 | app.close(); | |
| 86 | await rm(dir, { recursive: true, force: true }); | |
| 87 | }); | |
| 88 | ||
| 89 | describe("tui home pins", () => { | |
| 90 | test("the palette offers the door to an admin person only", () => { | |
| 91 | expect(doors(carol)).toContain("home-pins"); | |
| 92 | expect(doors(bob)).not.toContain("home-pins"); | |
| 93 | expect(doors(bot)).not.toContain("home-pins"); | |
| 94 | expect(doors(undefined)).not.toContain("home-pins"); | |
| 95 | }); | |
| 96 | ||
| 97 | test("an admin person reads their own pins in home's order, nobody else's", async () => { | |
| 98 | const text = await screenText(carol); | |
| 99 | expect(text).toContain("3 panels pinned to home"); | |
| 100 | expect(text.indexOf("Pin A")).toBeLessThan(text.indexOf("Pin B")); | |
| 101 | expect(text.indexOf("Pin B")).toBeLessThan(text.indexOf("Pin C")); | |
| 102 | const hers = await screenText(dana); | |
| 103 | expect(hers).toContain("1 panel pinned to home"); | |
| 104 | expect(hers).not.toContain("Pin A"); | |
| 105 | }); | |
| 106 | ||
| 107 | test("a member and an agent see no pins and change none", async () => { | |
| 108 | for (const who of [bob, bot]) { | |
| 109 | const text = await screenText(who, "u>"); | |
| 110 | expect(text).toContain("Nothing pinned"); | |
| 111 | } | |
| 112 | expect(order(carol)).toEqual(["Pin A", "Pin B", "Pin C"]); | |
| 113 | }); | |
| 114 | ||
| 115 | test("> moves the marked pin, u unpins it, and each change is an event for its owner alone", async () => { | |
| 116 | const seen: ForgeEvent[] = []; | |
| 117 | const off = app.bus.subscribe((e) => void (e.kind === PINS_EVENT && seen.push(e))); | |
| 118 | await screenText(carol, ">"); | |
| 119 | expect(order(carol)).toEqual(["Pin B", "Pin A", "Pin C"]); | |
| 120 | await screenText(carol, "ju"); // the second row, now Pin A | |
| 121 | expect(order(carol)).toEqual(["Pin B", "Pin C"]); | |
| 122 | expect(order(dana)).toEqual(["Pin C"]); | |
| 123 | off(); | |
| 124 | expect(seen.length).toBe(2); | |
| 125 | expect(seen.every((e) => e.actorId === carol.id)).toBe(true); | |
| 126 | expect(eventAudience(PINS_EVENT)).toBe("actor"); | |
| 127 | }); | |
| 128 | }); |
new above
offline
The empty div has no min-height, so the late-arriving charts shift everything below them. Reserve the space (token or class) and collapse it when the fragment is empty.