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

Contraction: fold /big into the board as its one wall mode #411

Merged opened this

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

Merged

mason/forge:mason/394-contraction-fold-big-into-the-board-as-i → main 1 commit fork

  • Merged. mason/394-contraction-fold-big-into-the-board-as-i was merged into main as 46b0f05.

Review

Sign in to review this change.
10 files changed +140 −2777
docs/STUDIO.md +4
@@ -387,6 +387,10 @@ three statements, `standings`). Every region
387387 is live — it swaps itself when a matching event lands, and the dot in the
388388 corner says whether the stream is connected. `?apps=a,b` narrows it to some
389389 apps. Keys: `f` fullscreen, `1`–`4` one region full-bleed, `0` all.
390 `?wall` is its one wall mode, for a screen across the room that nobody
391 touches: the same regions in larger type, no key hints, no pointer. The
392 old big view folded into it — `/big` and every address it had land on
393 `/board?wall`, keeping `?app=owner/name` as the board's `?apps=`.
390394 Everything links somewhere; nothing needs a click. The board is a wall for
391395 apps you can write to: a public app someone else owns can still appear in
392396 it (`listReposVisibleTo` includes every public repo), but its cost, and
src/http/server.ts +3 −4
@@ -157,8 +157,7 @@ import {
157157 runsList,
158158 } from "../web/pages/runs.ts";
159159 import { lookImage } from "../web/pages/looks.ts";
160 import { boardPage } from "../web/pages/board.ts";
161 import { bigPage } from "../web/pages/big.ts";
160 import { bigRedirect, boardPage } from "../web/pages/board.ts";
162161 import {
163162 agentCreate,
164163 agentDelete,
@@ -436,8 +435,8 @@ export function buildRouter(): Router<Handler> {
436435 router.add("GET", "/board", boardPage);
437436 // One screen for "is everything fine?": what asks the viewer (docs/STUDIO.md).
438437 router.add("GET", "/asks", asksPage);
439 // The big view: the whole forge as one keyboard-first frame.
440 router.add("GET", "/big", bigPage);
438 // The big view, folded into the board: every old address lands on its wall mode.
439 router.add("GET", "/big", bigRedirect);
441440 router.add("GET", "/search", searchPage);
442441 // The JS-free half of every destructive action. Two literal segments, so it
443442 // is registered before /:owner/:name can claim the shape.
src/web/big-model.ts deleted −222
@@ -1,222 +0,0 @@
1 /**
2 * The big view's pure half: where a movement lands, what a typed command
3 * means, and the key sheet every view is drawn from. No DOM in here, so
4 * the page renders the footer and the sheet from the same table the client
5 * acts on, and a test can hold them against each other. The client script
6 * (`big.ts`) carries these functions by their source (`String(fn)`), so
7 * the browser runs exactly what the tests ran.
8 */
9
10 export type View = "home" | "app" | "run" | "board" | "map" | "list";
11
12 export type ListKind = "runs" | "issues" | "pulls" | "map";
13
14 // ---- where things are ----
15
16 /** The one shape of a big-view address: what is open, and how deep. */
17 export interface Where {
18 readonly app?: string | undefined;
19 readonly list?: ListKind | undefined;
20 readonly run?: string | undefined;
21 readonly view?: "board" | "map" | undefined;
22 }
23
24 export function bigUrl(where: Where): string {
25 const q = new URLSearchParams();
26 if (where.run !== undefined) q.set("run", where.run);
27 else if (where.app !== undefined) {
28 q.set("app", where.app);
29 if (where.list !== undefined) q.set("list", where.list);
30 } else if (where.view !== undefined) q.set("view", where.view);
31 const text = q.toString();
32 return text === "" ? "/big" : `/big?${text}`;
33 }
34
35 /** One level up: a list or a run to its app, an app or a wall to home. */
36 export function backUrl(where: Where, runApp?: string): string {
37 if (where.run !== undefined)
38 return runApp === undefined ? "/big" : bigUrl({ app: runApp });
39 if (where.app !== undefined && where.list !== undefined)
40 return bigUrl({ app: where.app });
41 return "/big";
42 }
43
44 // ---- the focus ----
45
46 /**
47 * Where a step of `step` rows lands, over `count` rows, from `at` (-1 for
48 * no focus yet). A first step down lands on the first row, a first step up
49 * on the last; nothing ever wraps, as in vim.
50 */
51 export function moveIndex(count: number, at: number, step: number): number {
52 if (count === 0) return -1;
53 const next = at < 0 ? (step > 0 ? 0 : count - 1) : at + step;
54 return Math.max(0, Math.min(count - 1, next));
55 }
56
57 /** The next pane over, wrapping: tab from the last pane lands on the first. */
58 export function nextPane(count: number, at: number, step: number): number {
59 if (count === 0) return -1;
60 return ((((at < 0 ? 0 : at) + step) % count) + count) % count;
61 }
62
63 /** A half page: half the rows a pane shows, and one at least. */
64 export function halfPage(visible: number): number {
65 return Math.max(1, Math.floor(visible / 2));
66 }
67
68 // ---- the command line ----
69
70 export interface Command {
71 readonly name: string;
72 readonly arg?: string | undefined;
73 readonly does: string;
74 }
75
76 /** What `:` takes. `:app` is the one that needs a word after it. */
77 export const COMMANDS: readonly Command[] = [
78 { name: "app", arg: "name", does: "open an app" },
79 { name: "home", does: "every app" },
80 { name: "board", does: "the wall: working, landed, decided, today" },
81 { name: "map", does: "every app's map, one lane each" },
82 { name: "runs", does: "this app's runs" },
83 { name: "issues", does: "this app's open issues" },
84 { name: "pulls", does: "this app's open pulls" },
85 { name: "help", does: "the key sheet" },
86 { name: "q", does: "leave for the web UI" },
87 ];
88
89 export type Parsed =
90 | { readonly kind: "go"; readonly url: string }
91 | { readonly kind: "help" }
92 | { readonly kind: "none" }
93 | { readonly kind: "error"; readonly message: string };
94
95 /** The app a word names, from the ones the viewer can see (`owner/name`). */
96 export function findApp(
97 word: string,
98 apps: readonly string[],
99 ): string | undefined {
100 const w = word.toLowerCase();
101 const exact = apps.find(
102 (a) => a.toLowerCase() === w || a.toLowerCase().split("/")[1] === w,
103 );
104 if (exact !== undefined) return exact;
105 return apps.find(
106 (a) =>
107 a.toLowerCase().startsWith(w) ||
108 (a.toLowerCase().split("/")[1] ?? "").startsWith(w),
109 );
110 }
111
112 /**
113 * What a typed command means. `:app notes` and a bare `:notes` both open
114 * the app; the list commands need an app open; anything else is an error
115 * that says so, never a guess.
116 */
117 export function parseCommand(
118 text: string,
119 here: { readonly app?: string | undefined; readonly apps: readonly string[] },
120 ): Parsed {
121 const words = text.trim().replace(/^:/, "").split(/\s+/).filter(Boolean);
122 const head = (words[0] ?? "").toLowerCase();
123 if (head === "") return { kind: "none" };
124 const q = (w: Record<string, string>): string => {
125 const p = new URLSearchParams(w);
126 return `/big?${p.toString()}`;
127 };
128 if (head === "app") {
129 const word = words[1];
130 if (word === undefined) return { kind: "error", message: "app which?" };
131 const app = findApp(word, here.apps);
132 return app === undefined
133 ? { kind: "error", message: `no app called ${word}` }
134 : { kind: "go", url: q({ app }) };
135 }
136 if (head === "home") return { kind: "go", url: "/big" };
137 if (head === "board") return { kind: "go", url: q({ view: "board" }) };
138 if (head === "map")
139 return {
140 kind: "go",
141 url:
142 here.app === undefined
143 ? q({ view: "map" })
144 : q({ app: here.app, list: "map" }),
145 };
146 if (head === "runs" || head === "issues" || head === "pulls")
147 return here.app === undefined
148 ? { kind: "error", message: `open an app first, then :${head}` }
149 : { kind: "go", url: q({ app: here.app, list: head }) };
150 if (head === "help" || head === "?") return { kind: "help" };
151 if (head === "q" || head === "quit")
152 return {
153 kind: "go",
154 url: here.app === undefined ? "/" : `/${here.app}`,
155 };
156 const app = findApp(head, here.apps);
157 return app === undefined
158 ? { kind: "error", message: `unknown: ${head}` }
159 : { kind: "go", url: q({ app }) };
160 }
161
162 // ---- the keys ----
163
164 export interface KeyHint {
165 /** The chips, as typed: `["j", "k"]`, `["ctrl-d"]`, `["1"–"9"]`. */
166 readonly keys: readonly string[];
167 readonly does: string;
168 /** Shown in the footer, not only on the sheet. */
169 readonly foot?: boolean;
170 }
171
172 const MOVE: readonly KeyHint[] = [
173 { keys: ["h", "j", "k", "l"], does: "move", foot: true },
174 { keys: ["↑", "↓", "←", "→"], does: "move" },
175 { keys: ["g", "G"], does: "top, bottom" },
176 { keys: ["ctrl-d", "ctrl-u"], does: "half a page" },
177 { keys: ["tab", "1"], does: "next pane, pane 1–9" },
178 { keys: ["enter"], does: "open", foot: true },
179 { keys: ["esc"], does: "back", foot: true },
180 { keys: ["o"], does: "open in the web UI, in a new tab" },
181 { keys: [":"], does: "command", foot: true },
182 { keys: ["/"], does: "filter", foot: true },
183 { keys: ["?"], does: "help", foot: true },
184 { keys: ["f"], does: "fullscreen" },
185 ];
186
187 const BY_VIEW: Readonly<Record<View, readonly KeyHint[]>> = {
188 home: [{ keys: ["t"], does: "tell the focused app's crew", foot: true }],
189 app: [
190 { keys: ["s"], does: "ship the focused green pull", foot: true },
191 {
192 keys: ["x"],
193 does: "stop the focused run, close the focused ask",
194 foot: true,
195 },
196 { keys: ["t"], does: "tell the crew, or the focused ask", foot: true },
197 { keys: ["r"], does: "runs", foot: true },
198 { keys: ["i"], does: "issues", foot: true },
199 { keys: ["p"], does: "pulls", foot: true },
200 { keys: ["m"], does: "the map", foot: true },
201 ],
202 run: [
203 { keys: ["t"], does: "steer it", foot: true },
204 { keys: ["x"], does: "stop it", foot: true },
205 ],
206 board: [{ keys: ["0"], does: "every region" }],
207 map: [{ keys: ["enter"], does: "open the app" }],
208 list: [
209 { keys: ["s"], does: "ship a green pull", foot: true },
210 { keys: ["x"], does: "close it", foot: true },
211 ],
212 };
213
214 /** Every key for a view: the shared movement first, then the view's own. */
215 export function keysFor(view: View): readonly KeyHint[] {
216 return [...MOVE, ...BY_VIEW[view]];
217 }
218
219 /** The footer's line: what fits on one row, in order. */
220 export function footKeysFor(view: View): readonly KeyHint[] {
221 return keysFor(view).filter((k) => k.foot === true);
222 }
src/web/big.ts deleted −467
@@ -1,467 +0,0 @@
1 /**
2 * The big view's client: one focus model for the keyboard and a gamepad,
3 * one line for a command, a filter or a word to the crew, a small stack of
4 * where you came from, and the clocks. Everything a key does is a thing on
5 * the page already — a row's link, a row's button, the footer's own chips
6 * — so the sheet cannot promise what the page does not have.
7 *
8 * The pure parts (`big-model.ts`) are carried in by their source, so the
9 * browser runs exactly what the tests ran. Nothing here animates: the
10 * clock ticks once a second, and the rest waits for a key.
11 */
12
13 import {
14 findApp,
15 halfPage,
16 moveIndex,
17 nextPane,
18 parseCommand,
19 } from "./big-model.ts";
20
21 const shared = [moveIndex, nextPane, halfPage, findApp, parseCommand]
22 .map(String)
23 .join("\n");
24
25 export const bigJs = /* js */ `
26 ${shared}
27 (function () {
28 var doc = document;
29 var big = doc.getElementById('big');
30 if (!big) return;
31 var here = big.getAttribute('data-here') || '';
32 var apps = (big.getAttribute('data-apps') || '').split(' ').filter(Boolean);
33 var where = location.pathname + location.search;
34
35 // ---- memory: this browser's tab remembers where you were ----
36 function remember(k, v) { try { sessionStorage.setItem(k, JSON.stringify(v)); } catch (e) {} }
37 function recall(k) { try { var v = sessionStorage.getItem(k); return v ? JSON.parse(v) : null; } catch (e) { return null; } }
38
39 // ---- panes and rows ----
40 function panes() { return Array.prototype.slice.call(big.querySelectorAll('.big-pane')); }
41 function shown(el) { return !el.hasAttribute('data-filtered') && !el.closest('[hidden]'); }
42 function rowsOf(pane) { return pane ? Array.prototype.filter.call(pane.querySelectorAll('[data-key]'), shown) : []; }
43 function nameOf(pane) { return pane ? pane.getAttribute('data-pane') || '' : ''; }
44 var paneAt = 0;
45 var keys = {};
46 function current() { return panes()[paneAt] || null; }
47 function rowByKey(pane, key) {
48 var rows = rowsOf(pane);
49 for (var i = 0; i < rows.length; i++) if (rows[i].getAttribute('data-key') === key) return rows[i];
50 return null;
51 }
52 function focusedRow() { var p = current(); return p ? rowByKey(p, keys[nameOf(p)] || '') : null; }
53 function paint() {
54 panes().forEach(function (p, i) {
55 p.classList.toggle('is-pane', i === paneAt);
56 var k = keys[nameOf(p)] || '';
57 Array.prototype.forEach.call(p.querySelectorAll('.is-focus'), function (el) {
58 if (el.getAttribute('data-key') !== k) el.classList.remove('is-focus');
59 });
60 var el = rowByKey(p, k);
61 if (el) el.classList.add('is-focus');
62 });
63 glance();
64 remember('big:' + where, { pane: paneAt, keys: keys });
65 }
66 function settle(pane, row, scroll) {
67 if (!pane) return;
68 keys[nameOf(pane)] = row ? row.getAttribute('data-key') : '';
69 paint();
70 if (row && !mode) {
71 row.focus({ preventScroll: true });
72 if (scroll !== false) row.scrollIntoView({ block: 'nearest' });
73 }
74 }
75 function moveRow(step) {
76 var p = current(); if (!p) return;
77 var rows = rowsOf(p), r = focusedRow();
78 if (rows.length === 0) {
79 // A pane with no rows — a transcript — scrolls by the line instead.
80 var box = p.querySelector('.run-log') || p.querySelector('.big-scroll');
81 if (box) box.scrollTop += step * 40;
82 return;
83 }
84 var next = moveIndex(rows.length, r ? rows.indexOf(r) : -1, step);
85 if (next >= 0) settle(p, rows[next]);
86 }
87 function movePane(step) {
88 var all = panes();
89 paneAt = nextPane(all.length, paneAt, step);
90 var p = current();
91 settle(p, focusedRow() || rowsOf(p)[0] || null);
92 }
93 function toPane(n) {
94 if (n >= panes().length) return;
95 paneAt = n;
96 var p = current();
97 settle(p, focusedRow() || rowsOf(p)[0] || null);
98 }
99 function jump(end) {
100 var p = current(); if (!p) return;
101 var rows = rowsOf(p);
102 settle(p, end === 'top' ? rows[0] || null : rows[rows.length - 1] || null);
103 }
104 function pageRows(p) {
105 var rows = rowsOf(p);
106 if (rows.length === 0) return 1;
107 var h = rows[0].getBoundingClientRect().height || 24;
108 var box = p.querySelector('.big-scroll') || p;
109 return Math.max(1, Math.floor(box.clientHeight / h));
110 }
111
112 // ---- the glance: beside the apps, the focused app at a glance ----
113 function glance() {
114 var pane = big.querySelector('[data-pane="glance"]');
115 if (!pane) return;
116 var k = keys['apps'] || '', on = null;
117 Array.prototype.forEach.call(pane.querySelectorAll('[data-for]'), function (g) {
118 var hit = g.getAttribute('data-for') === k;
119 g.hidden = !hit;
120 if (hit) on = g;
121 });
122 var empty = pane.querySelector('.big-glance-empty');
123 if (empty) empty.hidden = !!on;
124 var note = pane.querySelector('.big-h-note');
125 if (note) note.textContent = on ? on.getAttribute('data-name') || '' : '';
126 }
127
128 // ---- navigation: a stack of where you came from ----
129 function go(url) {
130 var stack = recall('big:stack') || [];
131 stack.push(where);
132 if (stack.length > 30) stack.shift();
133 remember('big:stack', stack);
134 location.href = url;
135 }
136 function back() {
137 var stack = recall('big:stack') || [];
138 var prev = stack.pop();
139 remember('big:stack', stack);
140 location.href = prev && prev !== where ? prev : big.getAttribute('data-back') || '/big';
141 }
142 // What a row opens: where it says, else — for a run — its page here,
143 // else the link it is or holds.
144 function hrefOf(row) {
145 if (row.getAttribute('data-href')) return row.getAttribute('data-href');
146 var key = row.getAttribute('data-key') || '';
147 if (key.indexOf('run:') === 0) return '/big?run=' + encodeURIComponent(key.slice(4));
148 if (row.tagName.toLowerCase() === 'a') return row.getAttribute('href');
149 var a = row.querySelector('a[href]');
150 return a ? a.getAttribute('href') : null;
151 }
152 function openRow() {
153 var r = focusedRow(); if (!r) return;
154 var href = hrefOf(r); if (!href) return;
155 if (href.indexOf('/big') === 0) go(href); else location.href = href;
156 }
157 function openWeb() {
158 var r = focusedRow();
159 var href = r ? r.getAttribute('data-open') || hrefOf(r) : big.getAttribute('data-open');
160 if (href && href.indexOf('/big') !== 0) window.open(href, '_blank', 'noopener');
161 }
162
163 // ---- actions: a key presses a real control ----
164 function hint(text, tone) {
165 var el = doc.getElementById('big-hint'); if (!el) return;
166 el.textContent = text || '';
167 if (tone) el.setAttribute('data-tone', tone); else el.removeAttribute('data-tone');
168 if (text) setTimeout(function () { if (el.textContent === text) { el.textContent = ''; el.removeAttribute('data-tone'); } }, 6000);
169 }
170 function act(key) {
171 var r = focusedRow(), p = current();
172 var btn = (r && r.querySelector('[data-act="' + key + '"]')) || (p && p.querySelector('.big-h [data-act="' + key + '"], .big-run [data-act="' + key + '"]'));
173 if (!btn) return false;
174 btn.click();
175 return true;
176 }
177 function post(url, body) {
178 return fetch(url, { method: 'POST', body: body, headers: { accept: 'application/json' }, credentials: 'same-origin' })
179 .then(function (r) {
180 var type = r.headers.get('content-type') || '';
181 if (type.indexOf('application/json') !== -1)
182 return r.json().then(function (out) { return { ok: !!out.ok, message: out.flash || out.error || (out.ok ? 'done' : 'that could not be done') }; });
183 return { ok: r.ok, message: r.ok ? 'done' : 'that could not be done (' + r.status + ')' };
184 }, function () { return { ok: false, message: 'the forge could not be reached' }; });
185 }
186 // A pane's form posts in place; the stream brings what changed. The
187 // run's own composer keeps its own script.
188 big.addEventListener('submit', function (ev) {
189 var form = ev.target;
190 if (!form || form.id === 'big-line' || form.id === 'run-steer' || !window.fetch) return;
191 ev.preventDefault();
192 var body = new FormData(form);
193 var pressed = ev.submitter;
194 if (pressed && pressed.name) body.append(pressed.name, pressed.value);
195 var row = pressed && pressed.closest ? pressed.closest('[data-key]') : null;
196 if (row) row.setAttribute('data-busy', '');
197 hint('…');
198 post(form.getAttribute('action') || where, body).then(function (out) {
199 if (row) row.removeAttribute('data-busy');
200 hint(out.message, out.ok ? 'success' : 'danger');
201 });
202 });
203
204 // ---- the line: a command, a filter, or a word ----
205 var line = doc.getElementById('big-line');
206 var input = doc.getElementById('big-line-in');
207 var mark = doc.getElementById('big-line-mark');
208 var note = doc.getElementById('big-line-note');
209 var mode = null;
210 var filters = {};
211 function openLine(m, action, placeholder) {
212 if (!line || !input) return;
213 mode = m;
214 line.hidden = false;
215 line.setAttribute('data-mode', m);
216 line.setAttribute('action', action || '');
217 if (mark) mark.textContent = m === 'cmd' ? ':' : m === 'filter' ? '/' : '\\u203a';
218 input.value = m === 'filter' ? filters[nameOf(current())] || '' : '';
219 input.placeholder = placeholder || '';
220 if (note) { note.textContent = ''; note.removeAttribute('data-tone'); }
221 input.focus();
222 }
223 function closeLine() {
224 mode = null;
225 if (line) line.hidden = true;
226 if (input) input.blur();
227 var r = focusedRow();
228 if (r) r.focus({ preventScroll: true });
229 }
230 function applyFilter(p) {
231 if (!p) return;
232 var q = (filters[nameOf(p)] || '').toLowerCase();
233 Array.prototype.forEach.call(p.querySelectorAll('[data-key]'), function (row) {
234 var hit = q === '' || (row.textContent || '').toLowerCase().indexOf(q) !== -1;
235 if (hit) row.removeAttribute('data-filtered'); else row.setAttribute('data-filtered', '');
236 });
237 var h = p.querySelector('.big-h');
238 if (h) h.setAttribute('data-q', q);
239 if (!focusedRow()) settle(p, rowsOf(p)[0] || null, false);
240 }
241 function fail(message) { if (note) { note.textContent = message; note.setAttribute('data-tone', 'danger'); } }
242 function submitLine() {
243 if (!input) return;
244 var text = input.value;
245 if (mode === 'cmd') {
246 var parsed = parseCommand(text, { app: here || undefined, apps: apps });
247 if (parsed.kind === 'go') { closeLine(); go(parsed.url); }
248 else if (parsed.kind === 'help') { closeLine(); openHelp(); }
249 else if (parsed.kind === 'error') fail(parsed.message);
250 else closeLine();
251 } else if (mode === 'say') {
252 var said = text.trim();
253 if (!said) return;
254 input.disabled = true;
255 post(line.getAttribute('action'), new URLSearchParams({ text: said })).then(function (out) {
256 input.disabled = false;
257 if (out.ok) { closeLine(); hint(out.message, 'success'); } else { fail(out.message); input.focus(); }
258 });
259 } else closeLine();
260 }
261 if (line) line.addEventListener('submit', function (e) { e.preventDefault(); submitLine(); });
262 if (input) input.addEventListener('input', function () {
263 if (mode !== 'filter') return;
264 var p = current();
265 filters[nameOf(p)] = input.value;
266 applyFilter(p);
267 });
268 function say() {
269 var steer = doc.getElementById('steer-text');
270 if (steer) { steer.focus(); return; }
271 var r = focusedRow(), p = current();
272 var action = (r && r.getAttribute('data-say')) || (p && p.getAttribute('data-say')) || big.getAttribute('data-say');
273 if (!action) { hint('nothing here takes a word', 'danger'); return; }
274 var who = (r && r.getAttribute('data-say-who')) || (p && p.getAttribute('data-say-who')) || big.getAttribute('data-say-who') || 'the crew';
275 openLine('say', action, 'tell ' + who + ' \\u2014 enter sends, esc keeps quiet');
276 }
277
278 // ---- the sheet ----
279 function openHelp() {
280 var dlg = doc.getElementById('big-help');
281 if (!dlg || !dlg.showModal) return;
282 if (dlg.open) dlg.close(); else dlg.showModal();
283 }
284 function fullscreen() {
285 if (doc.fullscreenElement) { if (doc.exitFullscreen) doc.exitFullscreen(); }
286 else if (doc.documentElement.requestFullscreen) doc.documentElement.requestFullscreen().catch(function () {});
287 }
288
289 // ---- keys ----
290 function chipGo(key) {
291 var chip = doc.querySelector('#big-foot [data-k="' + key + '"][data-go]');
292 if (!chip) return false;
293 go(chip.getAttribute('data-go'));
294 return true;
295 }
296 function handle(key, shift) {
297 switch (key) {
298 case 'j': case 'ArrowDown': moveRow(1); return true;
299 case 'k': case 'ArrowUp': moveRow(-1); return true;
300 case 'h': case 'ArrowLeft': movePane(-1); return true;
301 case 'l': case 'ArrowRight': movePane(1); return true;
302 case 'Tab': movePane(shift ? -1 : 1); return true;
303 case 'g': jump('top'); return true;
304 case 'G': jump('bottom'); return true;
305 case 'ctrl-d': moveRow(halfPage(pageRows(current()))); return true;
306 case 'ctrl-u': moveRow(-halfPage(pageRows(current()))); return true;
307 case 'Enter': openRow(); return true;
308 case 'Escape': case 'Backspace': back(); return true;
309 case ':': openLine('cmd', '', 'app <name> \\u00b7 home \\u00b7 board \\u00b7 map \\u00b7 runs \\u00b7 issues \\u00b7 pulls \\u00b7 q'); return true;
310 case '/': openLine('filter', '', 'filter this pane as you type'); return true;
311 case '?': openHelp(); return true;
312 case 'f': fullscreen(); return true;
313 case 'o': openWeb(); return true;
314 case 't': say(); return true;
315 case 's': case 'x': if (!act(key)) hint('nothing focused takes ' + key, 'danger'); return true;
316 default:
317 if (/^[1-9]$/.test(key)) { toPane(Number(key) - 1); return true; }
318 if (/^[a-z]$/.test(key)) return chipGo(key);
319 return false;
320 }
321 }
322 function typing(t) { return !!(t && t.matches && t.matches('input, textarea, select, [contenteditable]')); }
323 doc.addEventListener('keydown', function (e) {
324 if (e.metaKey || e.altKey) return;
325 var t = e.target;
326 var dlg = doc.querySelector('dialog[open]');
327 if (dlg) {
328 if (dlg.id === 'big-help' && (e.key === 'Escape' || e.key === '?')) { e.preventDefault(); e.stopPropagation(); dlg.close(); }
329 return;
330 }
331 if (typing(t)) {
332 if (e.key === 'Escape') {
333 e.preventDefault(); e.stopPropagation();
334 if (mode) closeLine(); else { t.blur(); var r = focusedRow(); if (r) r.focus({ preventScroll: true }); }
335 }
336 return;
337 }
338 if (e.ctrlKey) {
339 if (e.key === 'd' || e.key === 'u') { e.preventDefault(); e.stopPropagation(); handle('ctrl-' + e.key, false); }
340 return;
341 }
342 if (handle(e.key, e.shiftKey)) { e.preventDefault(); e.stopPropagation(); }
343 }, true);
344 // A click lands the focus where the pointer is; the link or button under
345 // it still does its own thing. A tell chip opens the line for its row.
346 big.addEventListener('mousedown', function (e) {
347 var row = e.target && e.target.closest ? e.target.closest('[data-key]') : null;
348 if (!row) return;
349 var pane = row.closest('.big-pane');
350 var at = panes().indexOf(pane);
351 if (at !== -1) { paneAt = at; settle(pane, row, false); }
352 });
353 big.addEventListener('click', function (e) {
354 var chip = e.target && e.target.closest ? e.target.closest('[data-say-open]') : null;
355 if (chip) { e.preventDefault(); say(); }
356 });
357
358 // ---- the gamepad: the same moves, from the couch ----
359 var BTN = { 0: 'Enter', 1: 'Escape', 3: '?', 4: 'h', 5: 'l', 8: '/', 9: ':', 12: 'k', 13: 'j', 14: 'h', 15: 'l' };
360 var held = {}, loop = null;
361 function fire(k) {
362 if (mode) { if (k === 'Escape') closeLine(); else if (k === 'Enter') submitLine(); return; }
363 var dlg = doc.querySelector('dialog[open]');
364 if (dlg) { if (k === 'Escape' || k === '?') dlg.close(); return; }
365 handle(k, false);
366 }
367 function poll() {
368 var list = navigator.getGamepads ? navigator.getGamepads() : [];
369 var any = false, now = Date.now();
370 for (var i = 0; i < list.length; i++) {
371 var gp = list[i]; if (!gp) continue;
372 any = true;
373 var down = {};
374 for (var b = 0; b < gp.buttons.length; b++) if (gp.buttons[b].pressed && BTN[b]) down[BTN[b]] = true;
375 var ax = gp.axes[0] || 0, ay = gp.axes[1] || 0;
376 if (ay < -0.5) down['k'] = true; else if (ay > 0.5) down['j'] = true;
377 if (ax < -0.5) down['h'] = true; else if (ax > 0.5) down['l'] = true;
378 Object.keys(down).forEach(function (k) {
379 var h = held[k];
380 if (!h) { held[k] = { next: now + 380 }; fire(k); }
381 else if (now >= h.next && 'jkhl'.indexOf(k) !== -1) { h.next = now + 110; fire(k); }
382 });
383 Object.keys(held).forEach(function (k) { if (!down[k]) delete held[k]; });
384 }
385 if (any) loop = requestAnimationFrame(poll); else { loop = null; big.removeAttribute('data-pad'); }
386 }
387 window.addEventListener('gamepadconnected', function () {
388 big.setAttribute('data-pad', '');
389 if (loop === null) loop = requestAnimationFrame(poll);
390 });
391
392 // ---- clocks: the corner, every elapsed, every relative time ----
393 var clock = doc.getElementById('big-clock');
394 function two(n) { return (n < 10 ? '0' : '') + n; }
395 function span(s) {
396 return s < 60 ? s + 's'
397 : s < 3600 ? Math.floor(s / 60) + 'm ' + two(s % 60) + 's'
398 : Math.floor(s / 3600) + 'h ' + two(Math.floor((s % 3600) / 60)) + 'm';
399 }
400 function ago(s) {
401 if (s < 60) return 'just now';
402 if (s < 3600) return Math.floor(s / 60) + 'm ago';
403 if (s < 86400) return Math.floor(s / 3600) + 'h ago';
404 if (s < 86400 * 30) return Math.floor(s / 86400) + 'd ago';
405 if (s < 86400 * 365) return Math.floor(s / (86400 * 30)) + 'mo ago';
406 return Math.floor(s / (86400 * 365)) + 'y ago';
407 }
408 function tick() {
409 var now = Date.now();
410 if (clock) {
411 var d = new Date(now);
412 clock.textContent = two(d.getHours()) + ':' + two(d.getMinutes());
413 clock.setAttribute('datetime', d.toISOString());
414 }
415 Array.prototype.forEach.call(big.querySelectorAll('[data-started]:not(#run-elapsed)'), function (el) {
416 var t = Date.parse(el.getAttribute('data-started') || '');
417 if (t) el.textContent = span(Math.max(0, Math.round((now - t) / 1000)));
418 });
419 Array.prototype.forEach.call(big.querySelectorAll('time[datetime]'), function (el) {
420 if (el === clock) return;
421 var t = Date.parse(el.getAttribute('datetime') || '');
422 if (t) el.textContent = ago(Math.max(0, (now - t) / 1000));
423 });
424 }
425 window.setInterval(tick, 1000);
426 tick();
427
428 // ---- live: the dot, and a swap that keeps the focus where it was ----
429 doc.addEventListener('forge:live', function (e) {
430 var live = doc.getElementById('big-live');
431 if (live && e.detail) live.setAttribute('data-state', e.detail.state);
432 });
433 doc.addEventListener('forge:swapped', function (e) {
434 var pane = e.target && e.target.closest ? e.target.closest('.big-pane') : null;
435 if (pane) {
436 applyFilter(pane);
437 pane.setAttribute('data-fresh', '');
438 window.setTimeout(function () { pane.removeAttribute('data-fresh'); }, 1500);
439 }
440 paint();
441 tick();
442 var r = focusedRow(), p = current();
443 if (!r && p) settle(p, rowsOf(p)[0] || null, false);
444 else if (r && !mode && doc.activeElement !== r && !typing(doc.activeElement)) r.focus({ preventScroll: true });
445 });
446
447 // ---- the map pans to where the crew is ----
448 (function () {
449 var working = big.querySelector('.map-node[data-state="working"]');
450 if (!working) return;
451 var was = doc.activeElement;
452 working.focus({ preventScroll: true });
453 if (was && was !== doc.body && was.focus) was.focus({ preventScroll: true }); else working.blur();
454 })();
455
456 // ---- restore: the pane and row this tab was on here ----
457 (function () {
458 var saved = recall('big:' + where);
459 if (saved) {
460 paneAt = Math.min(saved.pane || 0, Math.max(0, panes().length - 1));
461 keys = saved.keys || {};
462 }
463 var p = current();
464 settle(p, focusedRow() || rowsOf(p)[0] || null, false);
465 })();
466 })();
467 `;
src/web/client.ts +2 −2
@@ -2045,8 +2045,8 @@ export const liveRegionsJs = /* js */ `
20452045 var mine = document.getElementById(id);
20462046 var theirs = fresh.getElementById(id);
20472047 if (!mine || !theirs) return;
2048 // A card mid-action (the big view marks it data-busy while its post
2049 // is in flight) is never pulled out from under the click; the next
2048 // A region mid-action (something in it marked data-busy while its
2049 // post is in flight) is never pulled out from under the click; the next
20502050 // matching event tries the swap again.
20512051 if (mine.querySelector('[data-busy]')) return;
20522052 var active = document.activeElement;
src/web/pages/big.ts deleted −1303
@@ -1,1303 +0,0 @@
1 /**
2 * The big view: the whole forge as one full-screen, keyboard-first frame
3 * — a header line, a body of panes, a footer line of keys — with the feel
4 * of k9s, lazygit and btop, in the browser, and from a couch with a
5 * gamepad. A second front door, not a re-skin: every pane reads the same
6 * tables the studio and the board read, and every action is one key that
7 * presses a real control.
8 *
9 * Views are server-rendered pages under one shell (`/big`, `?app=`,
10 * `?run=`, `?view=board|map`, `&list=runs|issues|pulls|map`); the client
11 * script is the same on every one, the focus is remembered per address,
12 * and the live regions keep the page fresh in place.
13 */
14
15 import { getAppByRepo, appsForRepos } from "../../core/apps.ts";
16 import { listDecisionsForRepos } from "../../core/decisions.ts";
17 import { criticalPath, readyNow, repoGraph } from "../../core/dependencies.ts";
18 import {
19 getIssue,
20 getIssueById,
21 getIssuesByIds,
22 listIssues,
23 type Issue,
24 } from "../../core/issues.ts";
25 import { listPulls, mergedPullsForRepos, type Pull } from "../../core/pulls.ts";
26 import {
27 canRead,
28 canWrite,
29 getRepo,
30 getRepoById,
31 listReposVisibleTo,
32 type Repo,
33 } from "../../core/repos.ts";
34 import {
35 getRun,
36 isTerminal,
37 latestRunsForIssues,
38 listActiveRuns,
39 listRuns,
40 type Run,
41 } from "../../core/runs.ts";
42 import { getUsersByIds, type User } from "../../core/users.ts";
43 import { autonomyStatus } from "../../crew/autonomy.ts";
44 import type { Ctx } from "../../http/context.ts";
45 import {
46 backUrl,
47 bigUrl,
48 COMMANDS,
49 footKeysFor,
50 keysFor,
51 type ListKind,
52 type View,
53 type Where,
54 } from "../big-model.ts";
55 import { bigJs } from "../big.ts";
56 import { notFoundPage, repoBase, signInFirst } from "../chrome.ts";
57 import { liveRegionsJs, runLogJs } from "../client.ts";
58 import { html, when, type Html } from "../html.ts";
59 import { page } from "../layout.ts";
60 import { bigCss } from "../style/big.ts";
61 import { avatar, runStateBadge, time } from "../ui.ts";
62 import { boardCss, boardRegions, boardScope } from "./board.ts";
63 import {
64 countWords,
65 drawMap,
66 drawingWidth,
67 emphasisFor,
68 globalLanes,
69 laneHtml,
70 layout,
71 mapCss,
72 mapViewJs,
73 marksFor,
74 wordFor as mapWordFor,
75 } from "./map.ts";
76 import {
77 costText,
78 durationText,
79 logLine,
80 reasonWords,
81 runTitle,
82 } from "./runs.ts";
83 import { runLog } from "../../actions/runs.ts";
84
85 // ---- words ----
86
87 const money = (n: number): string => `$${n.toFixed(2)}`;
88 const pct = (n: number): number =>
89 Math.max(0, Math.min(100, Math.round(n * 100)));
90
91 type RunWord = "working" | "queued" | "paused";
92 function runWord(run: Run): RunWord {
93 if (run.state === "running") return "working";
94 return run.notBefore === null ? "queued" : "paused";
95 }
96 const startedAt = (r: Run): string => r.startedAt ?? r.createdAt;
97
98 /** Why a run exists, as a person reads it beside its clock. */
99 function why(run: Run): string {
100 switch (run.reason) {
101 case "assigned":
102 return "building it";
103 case "feedback":
104 return "on what you said";
105 case "check_failed":
106 return "fixing the check";
107 case "conflict":
108 return "catching up with main";
109 case "review":
110 return "reviewing it";
111 case "":
112 return "working";
113 default:
114 return run.reason.replace(/_/g, " ");
115 }
116 }
117
118 const KIND_WORDS = {
119 next: "next issue",
120 land: "landing",
121 retry: "try again",
122 lean: "a lean",
123 };
124
125 // ---- the shell ----
126
127 interface Shell {
128 readonly view: View;
129 readonly layout: "home" | "app" | "run" | "wall" | "lanes" | "list";
130 readonly title: string;
131 /** After `home`: the path down to here. */
132 readonly crumbs: readonly { label: string; href?: string }[];
133 readonly panes: Html;
134 readonly where: Where;
135 /** The app open here, `owner/name`, for the command line and the footer. */
136 readonly here?: string | undefined;
137 readonly back: string;
138 /** Where a word goes when nothing more specific is focused. */
139 readonly say?: string | undefined;
140 readonly sayWho?: string | undefined;
141 /** The web UI's page for this view, for `o`. */
142 readonly open?: string | undefined;
143 readonly apps: readonly string[];
144 readonly css?: string | undefined;
145 readonly js?: string | undefined;
146 }
147
148 const chips = (keys: readonly string[]): Html =>
149 html`${keys.map((k) => html`<kbd>${k}</kbd>`)}`;
150
151 /** The footer: what fits on one line. A letter that opens a view carries where. */
152 function foot(s: Shell): Html {
153 const go: Record<string, string> = {};
154 if (s.here !== undefined) {
155 go["r"] = bigUrl({ app: s.here, list: "runs" });
156 go["i"] = bigUrl({ app: s.here, list: "issues" });
157 go["p"] = bigUrl({ app: s.here, list: "pulls" });
158 go["m"] = bigUrl({ app: s.here, list: "map" });
159 }
160 return html`<footer class="big-foot" id="big-foot">
161 ${footKeysFor(s.view).map((k) => {
162 const first = k.keys[0] ?? "";
163 const target = k.keys.length === 1 ? go[first] : undefined;
164 return html`<span
165 class="big-key"
166 data-k="${first}"
167 ${target === undefined ? null : html`data-go="${target}"`}
168 >${chips(k.keys)}${k.does}</span
169 >`;
170 })}
171 <span class="spacer"></span>
172 <span class="big-foot-view">${s.view}</span>
173 </footer>`;
174 }
175
176 /** The sheet `?` opens: every key for this view, then the command line's words. */
177 function sheet(s: Shell): Html {
178 return html`<dialog class="modal big-help" id="big-help" aria-label="Keys">
179 <div class="big-help-cols">
180 <div>
181 <h2>Keys · ${s.view}</h2>
182 <table>
183 <tbody>
184 ${keysFor(s.view).map(
185 (k) =>
186 html`<tr>
187 <td>${chips(k.keys)}</td>
188 <td>${k.does}</td>
189 </tr>`,
190 )}
191 </tbody>
192 </table>
193 </div>
194 <div>
195 <h2>Commands · <kbd>:</kbd></h2>
196 <table>
197 <tbody>
198 ${COMMANDS.map(
199 (c) =>
200 html`<tr>
201 <td>
202 <span class="big-cmd"
203 >:${c.name}${c.arg === undefined
204 ? null
205 : html` <i>${c.arg}</i>`}</span
206 >
207 </td>
208 <td>${c.does}</td>
209 </tr>`,
210 )}
211 </tbody>
212 </table>
213 <p>
214 A gamepad works too: the d-pad and left stick move, A opens, B goes
215 back, Y is this sheet, the bumpers switch panes.
216 </p>
217 </div>
218 </div>
219 </dialog>`;
220 }
221
222 function shell(ctx: Ctx, s: Shell): Response {
223 const body = html`<div
224 class="big"
225 id="big"
226 data-view="${s.view}"
227 data-back="${s.back}"
228 data-here="${s.here ?? ""}"
229 data-apps="${s.apps.join(" ")}"
230 ${when(s.say, (v) => html`data-say="${v}"`)}
231 ${when(s.sayWho, (v) => html`data-say-who="${v}"`)}
232 ${when(s.open, (v) => html`data-open="${v}"`)}
233 >
234 <header class="big-top">
235 <a class="big-brand" href="/"><span class="mark"></span>forge</a>
236 <nav class="big-crumbs" aria-label="Where you are">
237 <a href="/big">home</a>
238 ${s.crumbs.map(
239 (c, i) =>
240 html`<span aria-hidden="true">›</span>${i === s.crumbs.length - 1 ||
241 c.href === undefined
242 ? html`<b>${c.label}</b>`
243 : html`<a href="${c.href}">${c.label}</a>`}`,
244 )}
245 </nav>
246 <span class="spacer"></span>
247 <span
248 class="big-hint"
249 id="big-hint"
250 role="status"
251 aria-live="polite"
252 ></span>
253 <span class="big-pad" aria-hidden="true">gamepad</span>
254 <span
255 class="big-live"
256 id="big-live"
257 data-state="connecting"
258 title="The live stream"
259 ><span class="big-dot"></span
260 ></span>
261 <time class="big-clock" id="big-clock"></time>
262 </header>
263 <div class="big-body" data-layout="${s.layout}">${s.panes}</div>
264 ${foot(s)}
265 <form class="big-line" id="big-line" method="post" action="" hidden>
266 <span class="big-line-mark" id="big-line-mark" aria-hidden="true">:</span>
267 <label class="sr-only" for="big-line-in">The line</label>
268 <input
269 class="big-line-in"
270 id="big-line-in"
271 name="text"
272 autocomplete="off"
273 spellcheck="false"
274 />
275 <span class="big-line-note" id="big-line-note"></span>
276 </form>
277 ${sheet(s)}
278 </div>`;
279 return page({
280 ctx,
281 title: s.title,
282 body,
283 bare: true,
284 theme: "dark",
285 styleCss: `${bigCss}\n${s.css ?? ""}`,
286 scriptJs: `${liveRegionsJs}\n${s.js ?? ""}\n${bigJs}`,
287 });
288 }
289
290 /** One pane: a numbered heading, then rows that scroll. */
291 function pane(o: {
292 name: string;
293 n: number;
294 title: string;
295 count?: number | undefined;
296 note?: Html | undefined;
297 body: Html;
298 live?: string | undefined;
299 repo?: Repo | undefined;
300 say?: string | undefined;
301 sayWho?: string | undefined;
302 cls?: string | undefined;
303 }): Html {
304 return html`<section
305 class="big-pane${o.cls === undefined ? "" : ` ${o.cls}`}"
306 data-pane="${o.name}"
307 data-n="${o.n}"
308 id="big-${o.name}"
309 ${when(o.live, (v) => html`data-live="${v}"`)}
310 ${when(o.repo, (r) => html`data-live-repo="${r.ownerName}/${r.name}"`)}
311 ${when(o.say, (v) => html`data-say="${v}"`)}
312 ${when(o.sayWho, (v) => html`data-say-who="${v}"`)}
313 aria-labelledby="big-${o.name}-h"
314 >
315 <h2 class="big-h" id="big-${o.name}-h">
316 <kbd aria-hidden="true">${o.n}</kbd>${o.title}
317 ${when(
318 o.count !== undefined && o.count > 0,
319 () => html`<span class="big-count">${o.count}</span>`,
320 )}
321 <span class="spacer"></span>
322 ${when(o.note, (v) => html`<span class="big-h-note">${v}</span>`)}
323 </h2>
324 <div class="big-scroll">${o.body}</div>
325 </section>`;
326 }
327
328 const rows = (items: Html[], quiet: string): Html =>
329 items.length === 0
330 ? html`<p class="big-quiet">${quiet}</p>`
331 : html`<ul class="big-rows">
332 ${items}
333 </ul>`;
334
335 /** A `<kbd>` chip on a control, so the key and the button are one thing. */
336 const act = (key: string, label: string, form: string, lead = false): Html =>
337 html`<button
338 class="big-act"
339 type="submit"
340 form="${form}"
341 data-act="${key}"
342 ${lead ? html`data-lead=""` : null}
343 >
344 <kbd>${key}</kbd>${label}
345 </button>`;
346
347 const sayChip = (lead = false): Html =>
348 html`<button
349 class="big-act"
350 type="button"
351 data-say-open=""
352 ${lead ? html`data-lead=""` : null}
353 >
354 <kbd>t</kbd>tell
355 </button>`;
356
357 // ---- the route ----
358
359 /** The apps the viewer can see, as repositories: the big view's whole world. */
360 function visibleApps(ctx: Ctx, user: User): Repo[] {
361 const repos = listReposVisibleTo(ctx.app.db, user, { limit: 200 });
362 const records = appsForRepos(
363 ctx.app.db,
364 repos.map((r) => r.id),
365 );
366 return repos.filter((r) => records.has(r.id));
367 }
368
369 const slug = (repo: Repo): string => `${repo.ownerName}/${repo.name}`;
370
371 const isList = (v: string | null): v is ListKind =>
372 v === "runs" || v === "issues" || v === "pulls" || v === "map";
373
374 /** GET /big — signed in. `?app=owner/name`, `?run=id`, `?view=board|map`. */
375 export function bigPage(ctx: Ctx): Response {
376 const user = ctx.auth.user;
377 if (!user) return signInFirst(ctx);
378 const q = ctx.url.searchParams;
379 const apps = visibleApps(ctx, user);
380 const names = apps.map(slug);
381
382 const runId = q.get("run");
383 if (runId !== null) {
384 const run = getRun(ctx.app.db, runId);
385 const repo =
386 run === undefined ? undefined : getRepoById(ctx.app.db, run.repoId);
387 if (!run || !repo || !canRead(ctx.app.db, repo, user))
388 return notFoundPage(ctx);
389 return runView(ctx, user, repo, run, names);
390 }
391 const appName = q.get("app");
392 if (appName !== null) {
393 const [owner, name] = appName.split("/");
394 const repo = owner && name ? getRepo(ctx.app.db, owner, name) : undefined;
395 if (!repo || !canRead(ctx.app.db, repo, user)) return notFoundPage(ctx);
396 if (getAppByRepo(ctx.app.db, repo.id) === undefined)
397 return notFoundPage(ctx);
398 const list = q.get("list");
399 return isList(list)
400 ? listView(ctx, user, repo, list, names)
401 : appView(ctx, user, repo, names);
402 }
403 const view = q.get("view");
404 if (view === "board") return boardView(ctx, user, apps, names);
405 if (view === "map") return mapView(ctx, user, names);
406 return homeView(ctx, user, apps, names);
407 }
408
409 // ---- home: every app, and the focused one at a glance ----
410
411 function homeView(
412 ctx: Ctx,
413 user: User,
414 apps: readonly Repo[],
415 names: readonly string[],
416 ): Response {
417 const db = ctx.app.db;
418 const ids = apps.map((r) => r.id);
419 const running = listActiveRuns(db, ids);
420 const actors = getUsersByIds(
421 db,
422 running.map((r) => r.actorId).filter((id): id is string => id !== null),
423 );
424 const about = getIssuesByIds(
425 db,
426 running.map((r) => r.issueId).filter((id): id is string => id !== null),
427 );
428 const runsBy = new Map<string, Run[]>();
429 for (const r of running)
430 runsBy.set(r.repoId, [...(runsBy.get(r.repoId) ?? []), r]);
431 const working = running.filter((r) => r.state === "running").length;
432 const waiting = running.length - working;
433
434 const appRows = apps.map((repo) => {
435 const key = `app:${slug(repo)}`;
436 const flying = runsBy.get(repo.id) ?? [];
437 const working = flying.filter((r) => r.state === "running").length;
438 const status =
439 repo.autonomy === "off" ? undefined : autonomyStatus(ctx.app, repo);
440 const share =
441 status === undefined || status.ceilingUsd <= 0
442 ? 0
443 : status.spentTodayUsd / status.ceilingUsd;
444 return html`<li
445 class="big-row"
446 data-kind="app"
447 data-key="${key}"
448 data-href="${bigUrl({ app: slug(repo) })}"
449 data-open="${repoBase(repo)}"
450 data-say="${repoBase(repo)}/tell"
451 data-say-who="${repo.name}'s crew"
452 tabindex="-1"
453 >
454 <span class="big-t"
455 >${repo.name}${repo.description === ""
456 ? null
457 : html` <small>· ${repo.description}</small>`}</span
458 >
459 <span
460 class="big-word"
461 data-word="${status?.paused === "ceiling" ? "paused" : repo.autonomy}"
462 >${status?.paused === "ceiling" ? "at ceiling" : repo.autonomy}</span
463 >
464 <span
465 class="big-n"
466 ${working === 0 ? html`data-zero=""` : html`data-hot=""`}
467 ><b>${working}</b> working</span
468 >
469 ${status === undefined
470 ? html`<span class="big-spend"><span></span><span>—</span></span>`
471 : html`<span class="big-spend"
472 ><span class="big-bar"
473 ><span
474 class="big-fill"
475 data-w="${pct(share)}"
476 ${share >= 0.9 ? html`data-near=""` : null}
477 ></span></span
478 ><span
479 >${money(status.spentTodayUsd)} /
480 $${status.ceilingUsd.toFixed(0)}</span
481 ></span
482 >`}
483 </li>`;
484 });
485
486 const glances = apps.map((repo) => {
487 const base = repoBase(repo);
488 const flying = runsBy.get(repo.id) ?? [];
489 const landed = mergedPullsForRepos(db, [repo.id], 3);
490 const decided = listDecisionsForRepos(db, [repo.id], 3);
491 const decidedAbout = getIssuesByIds(
492 db,
493 decided.map((d) => d.issueId).filter((id): id is string => id !== null),
494 );
495 const status =
496 repo.autonomy === "off" ? undefined : autonomyStatus(ctx.app, repo);
497 const direction =
498 repo.autonomyDirection.split("\n")[0]?.trim() ||
499 repo.description ||
500 "No direction yet.";
501 return html`<div
502 class="big-glance"
503 data-for="app:${slug(repo)}"
504 data-name="${repo.name}"
505 hidden
506 >
507 <p class="big-glance-dir">
508 ${direction}
509 <small
510 >${repo.autonomy === "off"
511 ? "not running on its own"
512 : `${repo.autonomy} · ${money(status?.spentTodayUsd ?? 0)} of $${(status?.ceilingUsd ?? 0).toFixed(0)} today · ${status?.active ?? 0} of ${status?.slots ?? 0} slots`}</small
513 >
514 </p>
515 <h3 class="big-sub">Working</h3>
516 ${rows(
517 flying.map((r) => runRow(base, r, actors, about, undefined)),
518 "Nobody is working here right now.",
519 )}
520 <h3 class="big-sub">Landed</h3>
521 ${rows(
522 landed.map((p) => landedRow(base, p)),
523 "Nothing has shipped yet.",
524 )}
525 <h3 class="big-sub">Decided</h3>
526 ${rows(
527 decided.map((d) => decisionRow(db, repo, d, decidedAbout)),
528 "Nothing decided yet.",
529 )}
530 </div>`;
531 });
532
533 const live = "run.*,pull.*,issue.*,check.*";
534 const panes = html`${pane({
535 name: "apps",
536 n: 1,
537 title: "Apps",
538 count: apps.length,
539 note: html`${working}
540 working${waiting > 0 ? html` · ${waiting} waiting` : null}`,
541 body: rows(appRows, "No apps yet. Make one at /apps/new."),
542 live,
543 })}
544 ${pane({
545 name: "glance",
546 n: 2,
547 title: "At a glance",
548 body: html`${glances}
549 <div class="big-glance-empty">Pick an app</div>`,
550 live,
551 })}`;
552 return shell(ctx, {
553 view: "home",
554 layout: "home",
555 title: "Big",
556 crumbs: [],
557 panes,
558 where: {},
559 back: "/",
560 open: "/",
561 apps: names,
562 });
563 }
564
565 // ---- rows the views share ----
566
567 function runRow(
568 base: string,
569 run: Run,
570 actors: ReadonlyMap<string, User>,
571 about: ReadonlyMap<string, Issue>,
572 stopForm: string | undefined,
573 ): Html {
574 const who =
575 run.actorId === null
576 ? "crew"
577 : (actors.get(run.actorId)?.username ?? "crew");
578 const issue = run.issueId === null ? undefined : about.get(run.issueId);
579 const word = runWord(run);
580 const live = run.state === "running" || run.state === "queued";
581 return html`<li
582 class="big-row"
583 data-kind="run"
584 data-key="run:${run.id}"
585 data-href="${bigUrl({ run: run.id })}"
586 data-open="${base}/runs/${run.id}"
587 data-state="${live ? word : run.state}"
588 tabindex="-1"
589 >
590 ${avatar(who)}
591 <span class="big-m"><b>${who}</b></span>
592 <span class="big-t"
593 >${issue?.title ?? (run.summary || "on the repository")}
594 <small class="big-m"
595 >·
596 ${live
597 ? word === "paused"
598 ? "resumes soon"
599 : why(run)
600 : run.summary || run.state}</small
601 ></span
602 >
603 ${live
604 ? html`<span class="big-el" data-started="${startedAt(run)}"></span>`
605 : html`<span class="big-el">${durationText(run) ?? "—"}</span>`}
606 <span class="big-cost">${run.costUsd > 0 ? money(run.costUsd) : ""}</span>
607 <span class="big-acts"
608 >${live
609 ? html`<span class="big-word" data-word="${word}">${word}</span>`
610 : html`<span class="big-word" data-word="${run.state}"
611 >${run.state === "ok" ? "done" : run.state}</span
612 >`}${stopForm === undefined
613 ? null
614 : act("x", run.state === "running" ? "stop" : "cancel", stopForm)}</span
615 >
616 </li>`;
617 }
618
619 function landedRow(base: string, pull: Pull): Html {
620 return html`<li
621 class="big-row"
622 data-kind="landed"
623 data-key="pull:${pull.id}"
624 data-href="${base}/pulls/${pull.number}"
625 tabindex="-1"
626 >
627 <span class="big-t">${pull.title}</span>
628 <span class="big-m">${pull.authorName}</span>
629 <span class="big-when">${time(pull.mergedAt ?? pull.createdAt)}</span>
630 </li>`;
631 }
632
633 function decisionRow(
634 db: Ctx["app"]["db"],
635 repo: Repo,
636 d: ReturnType<typeof listDecisionsForRepos>[number],
637 about: ReadonlyMap<string, Issue>,
638 ): Html {
639 const base = repoBase(repo);
640 let issue: Issue | undefined =
641 d.issueId === null ? undefined : about.get(d.issueId);
642 if (d.kind === "next" && /^\d+$/.test(d.answer))
643 issue = getIssue(db, repo.id, Number(d.answer)) ?? issue;
644 const href =
645 issue === undefined
646 ? base
647 : `${base}/${issue.kind === "pull" ? "pulls" : "issues"}/${issue.number}`;
648 const answer =
649 d.kind === "next" && issue !== undefined ? `#${issue.number}` : d.answer;
650 return html`<li
651 class="big-row"
652 data-kind="decision"
653 data-key="decision:${d.id}"
654 data-href="${href}"
655 tabindex="-1"
656 >
657 <span class="big-word">${KIND_WORDS[d.kind]}</span>
658 <span class="big-t">${answer}</span>
659 <span class="big-m"
660 >${issue?.title ?? repo.name} ·
661 ${d.by === "rule" ? "by the rule" : `by ${d.by}`}</span
662 >
663 <span class="big-p" title="how sure">${Math.round(d.p * 100)}%</span>
664 <span class="big-word" data-word="${d.outcome ?? ""}"
665 >${d.outcome ?? "—"}</span
666 >
667 <span class="big-when">${time(d.createdAt)}</span>
668 </li>`;
669 }
670
671 /** The hidden form a run's stop chip submits, when the viewer may. */
672 function stopFormFor(ctx: Ctx, repo: Repo, run: Run): string | undefined {
673 if (run.kind !== "agent") return undefined;
674 if (run.state !== "running" && run.state !== "queued") return undefined;
675 if (!canWrite(ctx.app.db, repo, ctx.auth.user)) return undefined;
676 return `stop-${run.id}`;
677 }
678
679 const stopForms = (
680 base: string,
681 runs: readonly Run[],
682 ids: ReadonlyMap<string, string>,
683 ): Html =>
684 html`${runs.map((r) =>
685 when(
686 ids.get(r.id),
687 (id) =>
688 html`<form
689 id="${id}"
690 method="post"
691 action="${base}/runs/${r.id}/cancel"
692 hidden
693 ></form>`,
694 ),
695 )}`;
696
697 // ---- app: working, asks, the map, landed ----
698
699 function appView(
700 ctx: Ctx,
701 user: User,
702 repo: Repo,
703 names: readonly string[],
704 ): Response {
705 const db = ctx.app.db;
706 const base = repoBase(repo);
707 const running = listActiveRuns(db, [repo.id]);
708 running.sort((a, b) => {
709 if ((a.state === "running") !== (b.state === "running"))
710 return a.state === "running" ? -1 : 1;
711 return startedAt(a).localeCompare(startedAt(b));
712 });
713 const actors = getUsersByIds(
714 db,
715 running.map((r) => r.actorId).filter((id): id is string => id !== null),
716 );
717 const about = getIssuesByIds(
718 db,
719 running.map((r) => r.issueId).filter((id): id is string => id !== null),
720 );
721 const stops = new Map<string, string>();
722 for (const r of running) {
723 const id = stopFormFor(ctx, repo, r);
724 if (id !== undefined) stops.set(r.id, id);
725 }
726 const status =
727 repo.autonomy === "off" ? undefined : autonomyStatus(ctx.app, repo);
728 const landed = mergedPullsForRepos(db, [repo.id], 12);
729
730 const graph = repoGraph(db, repo.id);
731 const ready = readyNow(graph);
732 const marks = marksFor(db, repo.id, graph, running);
733 const readySet = new Set(ready.map((r) => r.issue.id));
734 const counts = countWords(
735 graph.issues.map((i) => mapWordFor(i, readySet, marks)),
736 );
737
738 const panes = html`${pane({
739 name: "working",
740 n: 1,
741 title: "Working now",
742 count: running.length,
743 note:
744 status === undefined
745 ? html`${repo.autonomy}`
746 : html`<b>${status.active}</b> of ${status.slots} slots ·
747 <b>${money(status.spentTodayUsd)}</b> of
748 $${status.ceilingUsd.toFixed(0)}
749 today${status.paused === null
750 ? null
751 : html` · paused: ${status.paused}`}`,
752 body: html`${rows(
753 running.map((r) => runRow(base, r, actors, about, stops.get(r.id))),
754 "Nobody is working right now. Tell the crew something.",
755 )}${stopForms(base, running, stops)}`,
756 live: "run.*",
757 repo,
758 })}
759 ${pane({
760 name: "map",
761 n: 3,
762 title: "Map",
763 note: html`<b>${counts.ready}</b> ready · <b>${counts.working}</b> working ·
764 <b>${counts.blocked}</b> blocked${counts.waiting > 0
765 ? html` · <b>${counts.waiting}</b> waiting`
766 : null}`,
767 body: drawMap(
768 repo,
769 graph,
770 layout(graph),
771 emphasisFor(ready, criticalPath(graph), marks),
772 { id: "big-map-canvas", cap: 0.42 },
773 ),
774 live: "run.*,issue.*,pull.*",
775 repo,
776 })}
777 ${pane({
778 name: "landed",
779 n: 4,
780 title: "Landed",
781 count: landed.length,
782 body: rows(
783 landed.map((p) => landedRow(base, p)),
784 "Nothing has shipped yet.",
785 ),
786 live: "pull.merged,run.finished",
787 repo,
788 })}`;
789 return shell(ctx, {
790 view: "app",
791 layout: "app",
792 title: `${repo.name} · big`,
793 crumbs: [{ label: repo.name }],
794 panes,
795 where: { app: slug(repo) },
796 here: slug(repo),
797 back: "/big",
798 say: `${base}/tell`,
799 sayWho: `${repo.name}'s crew`,
800 open: base,
801 apps: names,
802 css: mapCss,
803 js: mapViewJs,
804 });
805 }
806
807 // ---- lists: runs, issues, pulls, the map alone ----
808
809 function listView(
810 ctx: Ctx,
811 user: User,
812 repo: Repo,
813 kind: ListKind,
814 names: readonly string[],
815 ): Response {
816 const db = ctx.app.db;
817 const base = repoBase(repo);
818 const where: Where = { app: slug(repo), list: kind };
819 const common = {
820 where,
821 here: slug(repo),
822 back: backUrl(where),
823 say: `${base}/tell`,
824 sayWho: `${repo.name}'s crew`,
825 apps: names,
826 crumbs: [
827 { label: repo.name, href: bigUrl({ app: slug(repo) }) },
828 { label: kind },
829 ],
830 };
831 if (kind === "map") {
832 const graph = repoGraph(db, repo.id);
833 const ready = readyNow(graph);
834 const marks = marksFor(db, repo.id, graph, listActiveRuns(db, [repo.id]));
835 return shell(ctx, {
836 ...common,
837 view: "list",
838 layout: "list",
839 title: `Map · ${repo.name} · big`,
840 open: `${base}/map`,
841 panes: pane({
842 name: "map",
843 n: 1,
844 title: "Map",
845 note: html`what waits for what · the top edge is ready now`,
846 body: drawMap(
847 repo,
848 graph,
849 layout(graph),
850 emphasisFor(ready, criticalPath(graph), marks),
851 { id: "big-map-canvas", cap: 0.8 },
852 ),
853 live: "run.*,issue.*,pull.*",
854 repo,
855 }),
856 css: mapCss,
857 js: mapViewJs,
858 });
859 }
860 if (kind === "runs") {
861 const runs = listRuns(db, repo.id, { limit: 60 });
862 const actors = getUsersByIds(
863 db,
864 runs.map((r) => r.actorId).filter((id): id is string => id !== null),
865 );
866 const about = getIssuesByIds(
867 db,
868 runs.map((r) => r.issueId).filter((id): id is string => id !== null),
869 );
870 const stops = new Map<string, string>();
871 for (const r of runs) {
872 const id = stopFormFor(ctx, repo, r);
873 if (id !== undefined) stops.set(r.id, id);
874 }
875 return shell(ctx, {
876 ...common,
877 view: "list",
878 layout: "list",
879 title: `Runs · ${repo.name} · big`,
880 open: `${base}/runs`,
881 panes: pane({
882 name: "runs",
883 n: 1,
884 title: "Runs",
885 count: runs.length,
886 body: html`${rows(
887 runs.map((r) => runRow(base, r, actors, about, stops.get(r.id))),
888 "No runs yet.",
889 )}${stopForms(base, runs, stops)}`,
890 live: "run.*",
891 repo,
892 }),
893 });
894 }
895 if (kind === "issues") {
896 const issues = listIssues(db, repo.id, { state: "open", limit: 60 });
897 const active = new Set(
898 listActiveRuns(db, [repo.id])
899 .map((r) => r.issueId)
900 .filter((id): id is string => id !== null),
901 );
902 return shell(ctx, {
903 ...common,
904 view: "list",
905 layout: "list",
906 title: `Issues · ${repo.name} · big`,
907 open: `${base}/issues`,
908 panes: pane({
909 name: "issues",
910 n: 1,
911 title: "Open issues",
912 count: issues.length,
913 body: rows(
914 issues.map(
915 (i) =>
916 html`<li
917 class="big-row"
918 data-kind="issue"
919 data-key="issue:${i.id}"
920 data-href="${base}/issues/${i.number}"
921 tabindex="-1"
922 >
923 <span class="big-num">#${i.number}</span>
924 <span class="big-t">${i.title}</span>
925 <span class="big-m">${i.assigneeName ?? "nobody on it"}</span>
926 <span
927 class="big-word"
928 data-word="${active.has(i.id) ? "working" : "waiting"}"
929 >${active.has(i.id) ? "working" : "open"}</span
930 >
931 <span class="big-when">${time(i.createdAt)}</span>
932 </li>`,
933 ),
934 "Nothing open. Tell the crew something.",
935 ),
936 live: "run.*,issue.*",
937 repo,
938 }),
939 });
940 }
941 const pulls = listPulls(db, repo.id, { state: "open", limit: 60 });
942 const checks = latestRunsForIssues(
943 db,
944 pulls.map((p) => p.id),
945 );
946 const writer = canWrite(db, repo, user);
947 const pullWord = (p: Pull): string => {
948 const c = checks.get(p.id);
949 return c?.state === "ok"
950 ? "green"
951 : c?.state === "failed"
952 ? "red"
953 : "checking";
954 };
955 return shell(ctx, {
956 ...common,
957 view: "list",
958 layout: "list",
959 title: `Pulls · ${repo.name} · big`,
960 open: `${base}/pulls`,
961 panes: pane({
962 name: "pulls",
963 n: 1,
964 title: "Open pulls",
965 count: pulls.length,
966 body: html`${rows(
967 pulls.map((p) => {
968 const word = pullWord(p);
969 return html`<li
970 class="big-row"
971 data-kind="pull"
972 data-key="pull:${p.id}"
973 data-href="${base}/pulls/${p.number}"
974 data-say="${base}/pulls/${p.number}/say"
975 data-say-who="${p.authorName}"
976 tabindex="-1"
977 >
978 <span class="big-num">#${p.number}</span>
979 <span class="big-t">${p.title}</span>
980 <span class="big-m">${p.authorName}</span>
981 <span class="big-acts"
982 ><span class="big-word" data-word="${word}">${word}</span
983 >${writer && word === "green"
984 ? act("s", "ship", `ship-${p.id}`, true)
985 : null}${writer
986 ? act("x", "close", `close-${p.id}`)
987 : null}</span
988 >
989 <span class="big-when">${time(p.createdAt)}</span>
990 </li>`;
991 }),
992 "No open pulls.",
993 )}${when(
994 writer,
995 () =>
996 html`${pulls.map(
997 (p) =>
998 html`<form
999 id="ship-${p.id}"
1000 method="post"
1001 action="${base}/pulls/${p.number}/ship"
1002 hidden
1003 >
1004 <input type="hidden" name="pull" value="${p.number}" />
1005 </form>
1006 <form
1007 id="close-${p.id}"
1008 method="post"
1009 action="${base}/pulls/${p.number}/state"
1010 hidden
1011 >
1012 <input type="hidden" name="state" value="closed" />
1013 </form>`,
1014 )}`,
1015 )}`,
1016 live: "run.*,pull.*,check.*",
1017 repo,
1018 }),
1019 });
1020 }
1021
1022 // ---- run: the transcript, followed, and the steer line ----
1023
1024 function runView(
1025 ctx: Ctx,
1026 user: User,
1027 repo: Repo,
1028 run: Run,
1029 names: readonly string[],
1030 ): Response {
1031 const found = runLog(ctx.app, user, repo, run.id);
1032 if (!found.ok) return notFoundPage(ctx);
1033 const { lines, truncated } = found.value;
1034 const db = ctx.app.db;
1035 const base = repoBase(repo);
1036 const actor =
1037 run.actorId === null
1038 ? undefined
1039 : getUsersByIds(db, [run.actorId]).get(run.actorId);
1040 const issue =
1041 run.issueId === null ? undefined : getIssueById(db, run.issueId);
1042 const title = runTitle(run, actor?.username, issue);
1043 const unfinished = !isTerminal(run.state);
1044 const writer = canWrite(db, repo, user);
1045 const duration = durationText(run);
1046 const stream = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/log?follow=1`;
1047 const who = actor?.username ?? "agent";
1048 const where: Where = { run: run.id };
1049
1050 const body = html`<div class="big-run">
1051 <div class="big-run-meta">
1052 <h1>${title}</h1>
1053 <span id="run-state" data-kind="${run.kind}"
1054 >${runStateBadge(run.state, run.kind)}</span
1055 >
1056 <span>${reasonWords(run.reason)}</span>
1057 ${when(
1058 issue,
1059 (i) =>
1060 html`<a
1061 href="${base}/${i.kind === "pull" ? "pulls" : "issues"}/${i.number}"
1062 >#${i.number}</a
1063 >`,
1064 )}
1065 <span class="num" id="run-cost" ${run.costUsd > 0 ? null : html`hidden`}
1066 >${costText(run.costUsd)} spent</span
1067 >
1068 <span class="num" id="run-turns" ${run.turns > 0 ? null : html`hidden`}
1069 >${run.turns} turn${run.turns === 1 ? "" : "s"}</span
1070 >
1071 ${when(
1072 duration,
1073 (d) =>
1074 html`<span
1075 class="num"
1076 id="run-elapsed"
1077 data-started="${run.startedAt ?? ""}"
1078 data-finished="${run.finishedAt ?? ""}"
1079 >${run.finishedAt === null ? "running for" : "took"} ${d}</span
1080 >`,
1081 )}
1082 ${when(
1083 writer && unfinished && issue === undefined,
1084 () =>
1085 html`<div class="run-actions" id="run-cancel">
1086 <form method="post" action="${base}/runs/${run.id}/cancel">
1087 <button class="big-act" type="submit" data-act="x">
1088 <kbd>x</kbd>${run.state === "running" ? "stop" : "cancel"}
1089 </button>
1090 </form>
1091 </div>`,
1092 )}
1093 ${when(
1094 run.summary !== "",
1095 () => html`<p class="run-summary" id="run-summary">${run.summary}</p>`,
1096 )}
1097 ${when(
1098 truncated,
1099 () =>
1100 html`<p class="run-summary">Showing the last 512 KB of the log.</p>`,
1101 )}
1102 </div>
1103 ${when(
1104 lines.length === 0,
1105 () =>
1106 html`<p class="big-quiet" id="run-empty">
1107 Nothing has been logged yet.
1108 </p>`,
1109 )}
1110 <div class="run-live" id="run-live" data-state="${run.state}">
1111 <div
1112 class="run-log"
1113 id="run-log"
1114 data-who="${who}"
1115 ${unfinished ? html`data-stream="${stream}"` : null}
1116 >
1117 ${lines.map((l) => logLine(l, who))}
1118 <button type="button" class="run-jump" id="run-jump" hidden>
1119 newer below
1120 </button>
1121 </div>
1122 ${when(
1123 writer && run.kind === "agent" && issue !== undefined,
1124 () =>
1125 html`<form
1126 class="run-steer"
1127 id="run-steer"
1128 method="post"
1129 action="${base}/runs/${run.id}/steer"
1130 data-api="/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/steer"
1131 data-running="${run.state === "running" ? "1" : "0"}"
1132 >
1133 <label class="sr-only" for="steer-text"
1134 >Say something to ${who}</label
1135 >
1136 <textarea
1137 class="input"
1138 id="steer-text"
1139 name="text"
1140 rows="1"
1141 required
1142 placeholder="${run.state === "running"
1143 ? `steer ${who} — enter sends, esc returns to the keys`
1144 : `tell ${who} for its next run — enter sends`}"
1145 ></textarea>
1146 <div class="bar">
1147 <span class="muted small" id="run-steer-note"
1148 >${run.state === "running"
1149 ? "Heard after the step it is on. Every word is kept in the log."
1150 : "The run is over; this goes on the conversation and starts a run."}</span
1151 >
1152 <span class="spacer"></span>
1153 ${when(
1154 unfinished,
1155 () =>
1156 html`<button
1157 type="submit"
1158 class="big-act"
1159 id="run-stop"
1160 data-act="x"
1161 formaction="${base}/runs/${run.id}/cancel"
1162 formnovalidate
1163 >
1164 <kbd>x</kbd>${run.state === "running" ? "stop" : "cancel"}
1165 </button>`,
1166 )}
1167 <button type="submit" class="big-act" data-lead="">
1168 <kbd>enter</kbd>send
1169 </button>
1170 </div>
1171 </form>`,
1172 )}
1173 </div>
1174 </div>`;
1175 return shell(ctx, {
1176 view: "run",
1177 layout: "run",
1178 title: `${title} · big`,
1179 crumbs: [
1180 { label: repo.name, href: bigUrl({ app: slug(repo) }) },
1181 { label: who === "agent" ? "run" : `${who}'s run` },
1182 ],
1183 panes: pane({
1184 name: "run",
1185 n: 1,
1186 title:
1187 run.kind === "agent"
1188 ? "Run"
1189 : run.kind === "check"
1190 ? "Check"
1191 : "Deploy",
1192 note: html`${run.state === "running"
1193 ? "following the tail"
1194 : run.state}${run.costUsd > 0 ? html` · ${money(run.costUsd)}` : null}`,
1195 body,
1196 cls: "big-run-pane",
1197 }),
1198 where,
1199 here: slug(repo),
1200 back: backUrl(where, slug(repo)),
1201 open: `${base}/runs/${run.id}`,
1202 apps: names,
1203 js: runLogJs,
1204 });
1205 }
1206
1207 // ---- board: the wall's four regions, focusable ----
1208
1209 function boardView(
1210 ctx: Ctx,
1211 user: User,
1212 apps: readonly Repo[],
1213 names: readonly string[],
1214 ): Response {
1215 const scope = boardScope(ctx, apps);
1216 const regions = boardRegions(ctx, scope);
1217 const wrap = (name: string, n: number, region: Html): Html =>
1218 html`<section
1219 class="big-pane big-wall"
1220 data-pane="${name}"
1221 data-n="${n}"
1222 aria-label="${name}"
1223 >
1224 <div class="big-scroll">${region}</div>
1225 </section>`;
1226 return shell(ctx, {
1227 view: "board",
1228 layout: "wall",
1229 title: "Board · big",
1230 crumbs: [{ label: "board" }],
1231 panes: html`${wrap("working", 1, regions.working)}${wrap(
1232 "landed",
1233 2,
1234 regions.landed,
1235 )}${wrap("decided", 3, regions.decided)}${wrap("today", 4, regions.today)}`,
1236 where: { view: "board" },
1237 back: "/big",
1238 open: "/board",
1239 apps: names,
1240 css: boardCss,
1241 });
1242 }
1243
1244 // ---- map: every app, one lane each ----
1245
1246 function mapView(ctx: Ctx, user: User, names: readonly string[]): Response {
1247 const lanes = globalLanes(ctx.app, user);
1248 const width = Math.max(0, ...lanes.map((l) => drawingWidth(l.placed)));
1249 const body =
1250 lanes.length === 0
1251 ? html`<p class="big-quiet">
1252 Nothing to map: no open issues anywhere you can see, and nobody
1253 working.
1254 </p>`
1255 : html`<div class="big-rows">
1256 ${lanes.map(
1257 (lane) =>
1258 html`<div
1259 class="big-row big-lane"
1260 data-key="lane:${slug(lane.repo)}"
1261 data-href="${bigUrl({ app: slug(lane.repo) })}"
1262 data-open="${repoBase(lane.repo)}/map"
1263 tabindex="-1"
1264 >
1265 ${laneHtml(lane, width)}
1266 </div>`,
1267 )}
1268 </div>`;
1269 return shell(ctx, {
1270 view: "map",
1271 layout: "lanes",
1272 title: "Map · big",
1273 crumbs: [{ label: "map" }],
1274 panes: html`<section
1275 class="big-pane"
1276 data-pane="lanes"
1277 data-n="1"
1278 id="map-lanes"
1279 data-live="run.*,issue.*,pull.*"
1280 aria-labelledby="big-lanes-h"
1281 >
1282 <h2 class="big-h" id="big-lanes-h">
1283 <kbd aria-hidden="true">1</kbd>Every app
1284 ${when(
1285 lanes.length > 0,
1286 () => html`<span class="big-count">${lanes.length}</span>`,
1287 )}
1288 <span class="spacer"></span>
1289 <span class="big-h-note"
1290 >the top edge of each lane is ready now · a ringed card is where the
1291 crew is</span
1292 >
1293 </h2>
1294 <div class="big-scroll">${body}</div>
1295 </section>`,
1296 where: { view: "map" },
1297 back: "/big",
1298 open: "/map",
1299 apps: names,
1300 css: mapCss,
1301 js: mapViewJs,
1302 });
1303 }
src/web/pages/board.ts +84 −60
@@ -7,6 +7,10 @@
77 * forge's health, and each subscription's windows. Nothing on it
88 * needs a click, and everything on it links somewhere. It reads the same
99 * tables as the studio and adds no state of its own.
10 *
11 * `?wall` is its one wall mode, for a screen across the room that nobody
12 * touches: the same regions in larger type, without the key hints or a
13 * pointer. The old big view (`/big`, every address it had) lands here.
1014 */
1115
1216 import { appsForRepos, type AppRecord } from "../../core/apps.ts";
@@ -36,7 +40,7 @@ import type { Ctx } from "../../http/context.ts";
3640 import { repoBase, signInFirst } from "../chrome.ts";
3741 import { boardJs, liveRegionsJs } from "../client.ts";
3842 import { html, when, type Html } from "../html.ts";
39 import { page } from "../layout.ts";
43 import { page, redirect } from "../layout.ts";
4044 import { avatar, icon, time } from "../ui.ts";
4145 import { voiceFor } from "../studio/voice.ts";
4246 import {
@@ -89,7 +93,7 @@ const pct = (n: number): number =>
8993 Math.max(0, Math.min(100, Math.round(n * 100)));
9094
9195 /** The apps on the board: every one the viewer can see, or the named few. */
92 export interface BoardScope {
96 interface BoardScope {
9397 readonly repos: readonly Repo[];
9498 readonly ids: readonly string[];
9599 readonly byId: ReadonlyMap<string, Repo>;
@@ -100,12 +104,12 @@ export interface BoardScope {
100104 readonly ownerFacts: ReadonlySet<string>;
101105 }
102106
103 /** The scope for some app repositories: the board's, or the big view's. */
104 export function boardScope(ctx: Ctx, repos: readonly Repo[]): BoardScope {
105 const records = appsForRepos(
106 ctx.app.db,
107 repos.map((r) => r.id),
108 );
107 /** The scope for some repositories: the ones among them that are apps. */
108 function boardScope(
109 ctx: Ctx,
110 repos: readonly Repo[],
111 records: ReadonlyMap<string, AppRecord>,
112 ): BoardScope {
109113 const kept = repos.filter((r) => records.has(r.id));
110114 return {
111115 repos: kept,
@@ -120,22 +124,9 @@ export function boardScope(ctx: Ctx, repos: readonly Repo[]): BoardScope {
120124 };
121125 }
122126
123 /** The four regions, for a page that frames them its own way. */
124 export function boardRegions(
125 ctx: Ctx,
126 scope: BoardScope,
127 ): { working: Html; landed: Html; decided: Html; today: Html } {
128 return {
129 working: working(ctx, scope),
130 landed: landed(ctx, scope),
131 decided: decided(ctx, scope),
132 today: today(ctx, scope),
133 };
134 }
135
136127 // ---- the page ----
137128
138 /** GET /board — signed in; every app the viewer can see, or `?apps=` some. */
129 /** GET /board — signed in; every app the viewer can see, or `?apps=` some; `?wall` for the wall mode. */
139130 export function boardPage(ctx: Ctx): Response {
140131 const user = ctx.auth.user;
141132 if (!user) return signInFirst(ctx);
@@ -155,21 +146,24 @@ export function boardPage(ctx: Ctx): Response {
155146 ctx,
156147 visible.filter(
157148 (r) =>
158 records.has(r.id) &&
159 (wanted.size === 0 ||
160 wanted.has(r.name.toLowerCase()) ||
161 wanted.has(`${r.ownerName}/${r.name}`.toLowerCase())),
149 wanted.size === 0 ||
150 wanted.has(r.name.toLowerCase()) ||
151 wanted.has(`${r.ownerName}/${r.name}`.toLowerCase()),
162152 ),
153 records,
163154 );
164155 const repos = scope.repos;
156 const wall = ctx.url.searchParams.has("wall");
165157
166 const body = html`<div class="board">
158 const body = html`<div class="board" ${wall ? html`data-wall=""` : null}>
167159 <header class="board-top">
168160 <a class="board-brand" href="/"><span class="mark"></span>forge</a>
169161 <span class="board-scope"
170 >${repos.length === 1
171 ? repos[0]!.name
172 : `${repos.length} app${repos.length === 1 ? "" : "s"}`}</span
162 >${
163 repos.length === 1
164 ? repos[0]!.name
165 : `${repos.length} app${repos.length === 1 ? "" : "s"}`
166 }</span
173167 >
174168 <span class="spacer"></span>
175169 <span class="board-keys" aria-hidden="true"
@@ -223,14 +217,34 @@ function region(
223217 () => html`<span class="board-count">${rows.length}</span>`,
224218 )}
225219 </h2>
226 ${rows.length === 0
227 ? html`<p class="board-quiet">${quiet}</p>`
228 : html`<ul class="board-list">
229 ${rows}
230 </ul>`}
220 ${
221 rows.length === 0
222 ? html`<p class="board-quiet">${quiet}</p>`
223 : html`<ul class="board-list">
224 ${rows}
225 </ul>`
226 }
231227 </section>`;
232228 }
233229
230 /** An app's address as the big view wrote it: `owner/name`, nothing else. */
231 const APP_SLUG = /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/;
232
233 /**
234 * GET /big — the big view, folded into the board. Every address it had
235 * (`?app=`, `&list=`, `?run=`, `?view=board|map`) lands on the wall mode;
236 * an app it named stays the scope, as `?apps=`. The board decides what the
237 * viewer may see, so this reads nothing and says nothing about what exists.
238 */
239 export function bigRedirect(ctx: Ctx): Response {
240 const app = ctx.url.searchParams.get("app") ?? "";
241 return redirect(
242 APP_SLUG.test(app)
243 ? `/board?apps=${encodeURIComponent(app)}&wall`
244 : "/board?wall",
245 );
246 }
247
234248 // ---- the forge ----
235249
236250 /** Where a need can be acted on, when it can: a stuck run's page. */
@@ -263,14 +277,16 @@ function forgeStrip(ctx: Ctx): Html | null {
263277 data-ok="${v.ok ? "yes" : "no"}"
264278 role="status"
265279 >
266 ${v.ok
267 ? html`<span class="board-forge-ok">All fine</span>`
268 : v.needs.map((need) => {
269 const href = needHref(need, v);
270 return href === undefined
271 ? html`<span class="board-forge-need">${need}</span>`
272 : html`<a class="board-forge-need" href="${href}">${need}</a>`;
273 })}
280 ${
281 v.ok
282 ? html`<span class="board-forge-ok">All fine</span>`
283 : v.needs.map((need) => {
284 const href = needHref(need, v);
285 return href === undefined
286 ? html`<span class="board-forge-need">${need}</span>`
287 : html`<a class="board-forge-need" href="${href}">${need}</a>`;
288 })
289 }
274290 </div>`;
275291 }
276292
@@ -323,11 +339,13 @@ function windowsStrip(ctx: Ctx): Html | null {
323339 const words = windowWords(w);
324340 return html`<span class="board-window" data-window="${w.window}"
325341 ><span class="board-window-name">${words.name}</span>
326 ${words.usedPct === null
327 ? null
328 : html`<span class="board-bar"
329 ><span class="board-fill" data-w="${words.usedPct}"></span
330 ></span>`}
342 ${
343 words.usedPct === null
344 ? null
345 : html`<span class="board-bar"
346 ><span class="board-fill" data-w="${words.usedPct}"></span
347 ></span>`
348 }
331349 <span class="board-window-nums"
332350 >${figures([words.used, words.remaining, words.resets])}</span
333351 ></span
@@ -389,9 +407,9 @@ function working(ctx: Ctx, scope: BoardScope): Html {
389407 <span class="board-run-meta">
390408 <span class="board-state">${word}</span>
391409 <span
392 >${word === "paused"
393 ? "resumes soon"
394 : reasonWords(run.reason)}</span
410 >${
411 word === "paused" ? "resumes soon" : reasonWords(run.reason)
412 }</span
395413 >
396414 ${when(
397415 run.costUsd > 0 && scope.ownerFacts.has(repo.id),
@@ -456,15 +474,17 @@ function landed(ctx: Ctx, scope: BoardScope): Html {
456474 href="${repoBase(repo)}/pulls/${pull.number}"
457475 >
458476 <span class="board-thumb"
459 >${picture === undefined
460 ? icon("merge")
461 : html`<img
462 src="${picture}"
463 width="34"
464 height="46"
465 alt=""
466 loading="lazy"
467 />`}</span
477 >${
478 picture === undefined
479 ? icon("merge")
480 : html`<img
481 src="${picture}"
482 width="34"
483 height="46"
484 alt=""
485 loading="lazy"
486 />`
487 }</span
468488 >
469489 <span class="board-landed-title">${pull.title}</span>
470490 <span class="board-when">${time(pull.mergedAt ?? pull.createdAt)}</span>
@@ -770,6 +790,10 @@ ${widths}
770790 }
771791 /* One region, full bleed. */
772792 .board[data-focus] .board-region { display: none; }
793 /* The wall mode: read from across the room, and nobody at the keys. */
794 .board[data-wall] { font-size: 20px; cursor: none; }
795 .board[data-wall] .board-keys { display: none; }
796 @media (min-width: 1600px) { .board[data-wall] { font-size: 23px; } }
773797 .board[data-focus="working"] #working, .board[data-focus="landed"] #landed,
774798 .board[data-focus="decided"] #decided, .board[data-focus="today"] #today { display: flex; }
775799 }
src/web/style/big.ts deleted −245
@@ -1,245 +0,0 @@
1 /**
2 * The big view's stylesheet: a k9s frame in the browser. One header line,
3 * a body of panes that fill the window and scroll on their own, one footer
4 * line of key chips. Dense, calm, dark by default; the forge's tokens
5 * throughout, so every theme gets it. Nothing here animates on its own —
6 * a focus ring moves, a hover tints, and that is all the motion there is.
7 */
8
9 /** Width rules for the bars: a whole percentage as `data-w`, since an inline style is not allowed. */
10 const widths = Array.from(
11 { length: 101 },
12 (_, n) => `.big-fill[data-w="${n}"] { width: ${n}%; }`,
13 ).join("\n");
14
15 export const bigCss = /* css */ `
16 .big {
17 --gap: 10px; --line: 30px;
18 height: 100dvh; display: grid; grid-template-rows: var(--line) minmax(0, 1fr) var(--line);
19 font-size: 13px; line-height: 1.4; overflow: hidden;
20 font-variant-numeric: tabular-nums;
21 }
22 .big kbd { height: 18px; min-width: 18px; padding: 0 4px; font-size: 10.5px; border-bottom-width: 1px; }
23 .big .num, .big .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
24 .big .spacer { flex: 1; }
25
26 /* ---- the header line ---- */
27 .big-top { display: flex; align-items: center; gap: 12px; padding: 0 12px; border-bottom: 1px solid var(--border); color: var(--muted-fg); font-size: 12.5px; white-space: nowrap; min-width: 0; }
28 .big-brand { display: inline-flex; align-items: center; gap: 7px; font-weight: 650; letter-spacing: -0.03em; font-size: 13.5px; color: var(--fg); }
29 .big-brand .mark { width: 9px; height: 9px; border-radius: 3px; background: linear-gradient(135deg, var(--accent), var(--merged)); }
30 .big-crumbs { display: flex; align-items: center; gap: 6px; min-width: 0; overflow: hidden; }
31 .big-crumbs a { color: var(--muted-fg); }
32 .big-crumbs a:hover { color: var(--fg); }
33 .big-crumbs > span { color: var(--faint); }
34 .big-crumbs b { color: var(--fg); font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
35 .big-hint { font-size: 12px; color: var(--muted-fg); overflow: hidden; text-overflow: ellipsis; }
36 .big-hint[data-tone="danger"] { color: var(--danger); }
37 .big-hint[data-tone="success"] { color: var(--success); }
38 .big-live { display: inline-flex; align-items: center; }
39 .big-dot { width: 7px; height: 7px; border-radius: 999px; background: var(--faint); }
40 .big-live[data-state="open"] .big-dot { background: var(--success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 22%, transparent); }
41 .big-live[data-state="reconnecting"] .big-dot { background: var(--attn); }
42 .big-clock { font-family: var(--mono); font-size: 12.5px; color: var(--muted-fg); min-width: 5ch; text-align: right; }
43 .big-pad { display: none; font-size: 11px; color: var(--faint); }
44 .big[data-pad] .big-pad { display: inline; }
45
46 /* ---- the body of panes ---- */
47 .big-body { display: grid; gap: var(--gap); padding: var(--gap); min-height: 0; }
48 .big-body[data-layout="home"] { grid-template-columns: minmax(0, 11fr) minmax(0, 9fr); }
49 .big-body[data-layout="app"], .big-body[data-layout="wall"] { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); }
50 .big-body[data-layout="run"], .big-body[data-layout="lanes"], .big-body[data-layout="list"] { grid-template-columns: minmax(0, 1fr); }
51 .big-pane {
52 min-width: 0; min-height: 0; display: flex; flex-direction: column;
53 border: 1px solid var(--border); border-radius: var(--radius); background: var(--card);
54 }
55 .big-pane.is-pane { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); }
56 .big-h {
57 flex: none; display: flex; align-items: center; gap: 8px; margin: 0; padding: 0 10px; height: 28px;
58 border-bottom: 1px solid var(--border);
59 font-size: 11px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--muted-fg);
60 white-space: nowrap; overflow: hidden;
61 }
62 .big-pane.is-pane .big-h { color: var(--fg); }
63 .big-h kbd { letter-spacing: 0; }
64 .big-count { font-weight: 500; letter-spacing: 0; color: var(--faint); }
65 .big-h-note { font-weight: 500; letter-spacing: 0; text-transform: none; color: var(--faint); overflow: hidden; text-overflow: ellipsis; }
66 .big-h-note b { color: var(--fg); font-weight: 600; }
67 .big-scroll { flex: 1; min-height: 0; overflow: auto; scrollbar-width: thin; scrollbar-color: var(--border) transparent; overscroll-behavior: contain; }
68 .big-rows { margin: 0; padding: 4px 0; list-style: none; }
69 .big-quiet { margin: 0; padding: 10px 12px; color: var(--faint); }
70 .big-pane[data-fresh] .big-h { color: var(--accent); }
71
72 /* ---- rows ---- */
73 .big-row {
74 position: relative; display: grid; align-items: center; gap: 2px 10px;
75 padding: 5px 10px 5px 12px; border-left: 2px solid transparent; color: inherit; outline: none;
76 cursor: default;
77 }
78 .big-row:hover { background: color-mix(in srgb, var(--fg) 4%, transparent); }
79 .big-row.is-focus { background: color-mix(in srgb, var(--accent) 13%, transparent); border-left-color: var(--accent); }
80 .big-row a { color: inherit; }
81 .big-row[data-busy] { opacity: .5; }
82 .big-t { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
83 .big-m { color: var(--muted-fg); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
84 .big-m b { color: var(--fg); font-weight: 600; }
85 .big-when { color: var(--faint); font-size: 12px; white-space: nowrap; }
86 .big-acts { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
87 .big-act {
88 display: inline-flex; align-items: center; gap: 5px; height: 20px; padding: 0 6px 0 3px;
89 border: 1px solid var(--border); border-radius: var(--radius-sm); background: transparent; color: var(--muted-fg);
90 font: inherit; font-size: 11.5px; cursor: pointer;
91 }
92 .big-act kbd { height: 14px; min-width: 14px; font-size: 10px; padding: 0 3px; background: var(--muted); }
93 .big-act:hover { color: var(--fg); border-color: var(--border-strong); }
94 .big-act[data-lead] { color: var(--fg); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
95 .big-row.is-focus .big-act { color: var(--fg); }
96 .big-bar { display: block; height: 4px; border-radius: 999px; background: var(--muted); overflow: hidden; }
97 .big-fill { display: block; height: 100%; border-radius: inherit; background: var(--muted-fg); }
98 .big-fill[data-near] { background: var(--attn); }
99 ${widths}
100 .big-word { font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--muted-fg); white-space: nowrap; }
101 .big-word[data-word="working"], .big-word[data-word="autonomous"] { color: var(--accent); }
102 .big-word[data-word="queued"], .big-word[data-word="assisted"], .big-word[data-word="waiting"] { color: var(--muted-fg); }
103 .big-word[data-word="paused"], .big-word[data-word="stopped"], .big-word[data-word="failed"], .big-word[data-word="red"] { color: var(--attn); }
104 .big-word[data-word="look"], .big-word[data-word="green"], .big-word[data-word="shipped"], .big-word[data-word="ok"] { color: var(--success); }
105 .big-word[data-word="off"], .big-word[data-word="cancelled"] { color: var(--faint); }
106 .big-row .avatar { width: 20px; height: 20px; font-size: 9px; }
107
108 /* app rows: name · autonomy · working · asks · spend */
109 .big-row[data-kind="app"] { grid-template-columns: minmax(0, 1fr) 7em 5.5em 5.5em 9em; }
110 .big-row[data-kind="app"] .big-t { font-size: 13.5px; }
111 .big-row[data-kind="app"] .big-t small { color: var(--muted-fg); font-weight: 500; font-size: 12px; }
112 .big-n { font-family: var(--mono); font-size: 12px; color: var(--muted-fg); white-space: nowrap; }
113 .big-n b { color: var(--fg); font-weight: 600; }
114 .big-n[data-zero] { color: var(--faint); }
115 .big-n[data-hot] b { color: var(--accent); }
116 .big-spend { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; font-family: var(--mono); font-size: 11.5px; color: var(--muted-fg); }
117
118 /* run rows: who · title · elapsed · cost */
119 .big-row[data-kind="run"] { grid-template-columns: auto 6.5em minmax(0, 1fr) 6ch 6ch auto; }
120 .big-row[data-kind="run"] .big-t { grid-column: 3; }
121 .big-el { font-family: var(--mono); font-size: 12.5px; text-align: right; white-space: nowrap; }
122 .big-row[data-state="working"] .big-el { color: var(--accent); }
123 .big-row[data-state="queued"] .big-el, .big-row[data-state="paused"] .big-el { color: var(--faint); }
124 .big-cost { font-family: var(--mono); font-size: 12px; color: var(--muted-fg); text-align: right; white-space: nowrap; }
125
126 /* ask rows: mark · title + short · actions */
127 .big-row[data-kind="ask"] { grid-template-columns: 5.5em minmax(0, 1fr) auto; }
128 .big-row[data-kind="ask"] .big-m { grid-column: 2; }
129 .big-row[data-kind="ask"] .big-acts { grid-row: 1 / span 2; grid-column: 3; }
130 .big-row[data-kind="ask"] .big-word { grid-row: 1 / span 2; }
131
132 /* landed rows: title · who · when */
133 .big-row[data-kind="landed"] { grid-template-columns: minmax(0, 1fr) auto auto; }
134 /* decision rows: kind · answer · about · sure · outcome · when */
135 .big-row[data-kind="decision"] { grid-template-columns: 6em 5em minmax(0, 1fr) 4ch 5em auto; }
136 .big-row[data-kind="decision"] .big-p { font-family: var(--mono); font-size: 11.5px; color: var(--muted-fg); text-align: right; }
137 /* issue and pull rows: number · title · who · word · when */
138 .big-row[data-kind="issue"], .big-row[data-kind="pull"] { grid-template-columns: 4.5ch minmax(0, 1fr) auto auto auto; }
139 .big-num { font-family: var(--mono); font-size: 12px; color: var(--faint); }
140
141 /* ---- the glance: the focused app at a glance ---- */
142 .big-glance { display: flex; flex-direction: column; min-height: 0; }
143 .big-glance-dir { margin: 0; padding: 10px 12px 8px; font-size: 14px; font-weight: 550; letter-spacing: -0.01em; text-wrap: pretty; border-bottom: 1px solid var(--border); }
144 .big-glance-dir small { display: block; margin-top: 2px; font-size: 12px; font-weight: 500; color: var(--muted-fg); }
145 .big-sub { margin: 0; padding: 8px 12px 2px; font-size: 10.5px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--faint); }
146 .big-glance-empty { display: grid; place-items: center; height: 100%; color: var(--faint); }
147
148 /* ---- the map inside a pane ---- */
149 .big-pane .map-canvas { border: 0; border-radius: 0; background: var(--card); flex: 1; min-height: 0; }
150 .big-pane .map-empty { border: 0; padding: 24px 12px; }
151 .big-pane .map-node.is-focus .map-card { stroke: var(--accent); stroke-width: 2; }
152 .big-pane .map-tools { display: none; }
153
154 /* ---- the lanes: the global map, one row per app ---- */
155 .big-lane { padding: 0; border-left-width: 2px; }
156 .big-lane .map-lane { border: 0; border-radius: 0; background: transparent; box-shadow: none; }
157 .big-lane + .big-lane .map-lane { border-top: 1px solid var(--border); }
158 .big-lane .map-lane-head { padding: 8px 12px; }
159 .big-lane .map-lane-name { font-size: 13.5px; }
160 .big-lane .map-lane-head .map-counts { font-size: 11.5px; }
161
162 /* ---- the board's regions inside panes ---- */
163 .big-wall .board-region { padding: 8px 10px; }
164 .big-wall .board-h { position: static; padding: 0 0 6px; background: transparent; font-size: 11px; }
165 .big-wall .board-list { gap: 4px; }
166 .big-wall .board-card { padding: 7px 10px; border-color: transparent; border-radius: var(--radius-sm); background: transparent; outline: none; }
167 .big-wall .board-card:hover { border-color: var(--border); }
168 .big-wall .board-card.is-focus { background: color-mix(in srgb, var(--accent) 13%, transparent); border-color: color-mix(in srgb, var(--accent) 45%, transparent); }
169 .big-wall .board-run { padding: 7px 10px; }
170 .big-wall .board-run .avatar { width: 26px; height: 26px; font-size: 11px; margin-top: 1px; }
171 .big-wall .board-run-title { font-size: 1em; -webkit-line-clamp: 1; }
172 .big-wall .board-elapsed { font-size: 1.15em; }
173 .big-wall .board-run-meta, .big-wall .board-run-who { font-size: .92em; }
174 .big-wall .board-landed-title { font-size: 1em; }
175 .big-wall .board-thumb { width: 24px; height: 32px; }
176 .big-wall .board-landed .board-thumb img { width: 100%; height: 100%; }
177 .big-wall .board-app-name { font-size: 1em; }
178 .big-wall .board-quiet { padding: 4px 0; color: var(--faint); }
179
180 /* ---- the run: the transcript fills the pane ---- */
181 .big-run { display: flex; flex-direction: column; min-height: 0; flex: 1; }
182 .big-run .run-live { margin: 0; flex: 1; min-height: 0; display: flex; flex-direction: column; }
183 .big-run .run-log { flex: 1; min-height: 0; max-height: none !important; border: 0; border-radius: 0; background: transparent; font-size: 13px; padding: 8px 12px; }
184 .big-run .run-steer { border: 0; border-top: 1px solid var(--border); border-radius: 0; background: var(--card); padding: 8px 12px; }
185 .big-run .run-steer textarea { min-height: 32px; font-size: 13px; }
186 .big-run .run-actions { margin: 0; }
187 .big-run-meta { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 12.5px; color: var(--muted-fg); }
188 .big-run-meta h1 { font-size: 14px; font-weight: 600; letter-spacing: -0.01em; margin: 0; }
189 .big-run-meta .num { font-family: var(--mono); }
190 .big-run-meta .run-summary { flex-basis: 100%; margin: 0; font-size: 12.5px; color: var(--muted-fg); }
191
192 /* ---- the footer line ---- */
193 .big-foot { display: flex; align-items: center; gap: 4px 14px; padding: 0 12px; border-top: 1px solid var(--border); color: var(--muted-fg); font-size: 12px; white-space: nowrap; overflow: hidden; }
194 .big-key { display: inline-flex; align-items: center; gap: 3px; }
195 .big-key kbd + kbd { margin-left: -1px; }
196 .big-key kbd:last-of-type { margin-right: 3px; }
197 .big-foot .spacer { flex: 1; }
198 .big-foot .big-foot-view { color: var(--faint); font-family: var(--mono); font-size: 11.5px; }
199
200 /* ---- the line: command, filter, or a word to the crew ---- */
201 .big-line {
202 position: fixed; left: var(--gap); right: var(--gap); bottom: var(--line); z-index: 5;
203 display: flex; align-items: center; gap: 8px; padding: 6px 10px;
204 background: var(--card); border: 1px solid color-mix(in srgb, var(--accent) 55%, var(--border)); border-radius: var(--radius-sm);
205 box-shadow: var(--shadow-2);
206 }
207 .big-line-mark { font-family: var(--mono); font-weight: 600; color: var(--accent); min-width: 1ch; }
208 .big-line[data-mode="say"] .big-line-mark { color: var(--success); }
209 .big-line-in { flex: 1; min-width: 0; border: 0; background: transparent; color: var(--fg); font: inherit; font-family: var(--mono); font-size: 13px; outline: none; padding: 0; }
210 .big-line-note { font-size: 12px; color: var(--muted-fg); white-space: nowrap; }
211 .big-line-note[data-tone="danger"] { color: var(--danger); }
212
213 /* ---- the key sheet ---- */
214 .big-help { width: min(720px, 92vw); max-height: 86dvh; overflow: auto; }
215 .big-help h2 { font-size: 13px; margin: 0 0 8px; }
216 .big-help h2 + h2 { margin-top: 16px; }
217 .big-help table { width: 100%; border-collapse: collapse; }
218 .big-help td { padding: 4px 10px 4px 0; vertical-align: top; font-size: 13px; }
219 .big-help td:first-child { width: 12em; white-space: nowrap; }
220 .big-help kbd + kbd { margin-left: 3px; }
221 .big-help .big-cmd { font-family: var(--mono); color: var(--fg); }
222 .big-help .big-cmd i { color: var(--muted-fg); font-style: normal; }
223 .big-help .big-help-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 24px; }
224 .big-help p { margin: 10px 0 0; color: var(--faint); font-size: 12px; }
225
226 /* ---- filtered away ---- */
227 .big [data-filtered] { display: none; }
228
229 /* ---- narrow: stacked, the body scrolls ---- */
230 @media (max-width: 899px) {
231 .big { height: auto; min-height: 100dvh; grid-template-rows: var(--line) minmax(0, 1fr) var(--line); overflow: visible; }
232 .big-body[data-layout] { grid-template-columns: minmax(0, 1fr); grid-template-rows: none; }
233 .big-pane { min-height: 220px; max-height: 60dvh; }
234 .big-row[data-kind="app"] { grid-template-columns: minmax(0, 1fr) auto auto; }
235 .big-row[data-kind="app"] .big-spend, .big-row[data-kind="app"] .big-word { display: none; }
236 .big-row[data-kind="run"] { grid-template-columns: auto minmax(0, 1fr) auto; }
237 .big-row[data-kind="run"] .big-t { grid-column: 2; }
238 .big-row[data-kind="run"] .big-cost, .big-row[data-kind="run"] .big-word { display: none; }
239 .big-row[data-kind="decision"] { grid-template-columns: 6em minmax(0, 1fr) auto; }
240 .big-row[data-kind="decision"] .big-p, .big-row[data-kind="decision"] .big-when { display: none; }
241 .big-foot { overflow: auto; }
242 .big-foot .big-foot-view { display: none; }
243 }
244 @media (min-width: 1600px) { .big { font-size: 14px; } }
245 `;
test/big.test.ts deleted −474
@@ -1,474 +0,0 @@
1 /**
2 * The big view: one keyboard-first frame over the whole forge. Anonymous
3 * is sent to sign in; every view renders the shell — header, panes,
4 * footer — with live regions; the app view lists a run in flight and an
5 * ask with its keys; the command line's targets are real pages; and the
6 * sheet lists every key the footer shows. The pure model is tested on
7 * its own: where a step lands, what a command means.
8 */
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App } from "../src/app.ts";
14 import { enableApp } from "../src/actions/apps.ts";
15 import { addToCrew, createAgent } from "../src/actions/crew.ts";
16 import { openIssue } from "../src/actions/issues.ts";
17 import { openPull } from "../src/actions/pulls.ts";
18 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
19 import { updateRepoSettings, type Repo } from "../src/core/repos.ts";
20 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
21 import { createUser, getUserByName, type User } from "../src/core/users.ts";
22 import { serve, type Servers } from "../src/http/server.ts";
23 import {
24 backUrl,
25 bigUrl,
26 COMMANDS,
27 findApp,
28 footKeysFor,
29 halfPage,
30 keysFor,
31 moveIndex,
32 nextPane,
33 parseCommand,
34 } from "../src/web/big-model.ts";
35
36 // ---- the model ----
37
38 describe("the big view's model", () => {
39 test("a step lands where vim would: first row on the way down, last on the way up, never past an end", () => {
40 expect(moveIndex(0, -1, 1)).toBe(-1);
41 expect(moveIndex(3, -1, 1)).toBe(0);
42 expect(moveIndex(3, -1, -1)).toBe(2);
43 expect(moveIndex(3, 0, 1)).toBe(1);
44 expect(moveIndex(3, 2, 1)).toBe(2);
45 expect(moveIndex(3, 0, -1)).toBe(0);
46 expect(moveIndex(10, 2, 5)).toBe(7);
47 expect(moveIndex(10, 8, 5)).toBe(9);
48 expect(halfPage(9)).toBe(4);
49 expect(halfPage(1)).toBe(1);
50 });
51
52 test("panes wrap both ways", () => {
53 expect(nextPane(4, 3, 1)).toBe(0);
54 expect(nextPane(4, 0, -1)).toBe(3);
55 expect(nextPane(4, -1, 1)).toBe(1);
56 expect(nextPane(0, 0, 1)).toBe(-1);
57 });
58
59 test("addresses and their parents", () => {
60 expect(bigUrl({})).toBe("/big");
61 expect(bigUrl({ app: "alice/notes" })).toBe("/big?app=alice%2Fnotes");
62 expect(bigUrl({ app: "alice/notes", list: "runs" })).toBe(
63 "/big?app=alice%2Fnotes&list=runs",
64 );
65 expect(bigUrl({ run: "r1" })).toBe("/big?run=r1");
66 expect(bigUrl({ view: "board" })).toBe("/big?view=board");
67 expect(backUrl({ app: "alice/notes", list: "runs" })).toBe(
68 "/big?app=alice%2Fnotes",
69 );
70 expect(backUrl({ app: "alice/notes" })).toBe("/big");
71 expect(backUrl({ run: "r1" }, "alice/notes")).toBe(
72 "/big?app=alice%2Fnotes",
73 );
74 expect(backUrl({ view: "map" })).toBe("/big");
75 });
76
77 test("a command means one thing, or says why not", () => {
78 const apps = ["alice/notes", "alice/shop", "bob/tempo"];
79 expect(findApp("tempo", apps)).toBe("bob/tempo");
80 expect(findApp("Alice/Shop", apps)).toBe("alice/shop");
81 expect(findApp("no", apps)).toBe("alice/notes");
82 expect(findApp("zz", apps)).toBeUndefined();
83 const here = { apps };
84 expect(parseCommand("app tempo", here)).toEqual({
85 kind: "go",
86 url: "/big?app=bob%2Ftempo",
87 });
88 expect(parseCommand(":tempo", here)).toEqual({
89 kind: "go",
90 url: "/big?app=bob%2Ftempo",
91 });
92 expect(parseCommand("map", here)).toEqual({
93 kind: "go",
94 url: "/big?view=map",
95 });
96 expect(parseCommand("map", { ...here, app: "alice/shop" })).toEqual({
97 kind: "go",
98 url: "/big?app=alice%2Fshop&list=map",
99 });
100 expect(parseCommand("runs", here)).toEqual({
101 kind: "error",
102 message: "open an app first, then :runs",
103 });
104 expect(parseCommand("pulls", { ...here, app: "alice/shop" })).toEqual({
105 kind: "go",
106 url: "/big?app=alice%2Fshop&list=pulls",
107 });
108 expect(parseCommand("app", here)).toEqual({
109 kind: "error",
110 message: "app which?",
111 });
112 expect(parseCommand("app nothing", here).kind).toBe("error");
113 expect(parseCommand("help", here)).toEqual({ kind: "help" });
114 expect(parseCommand("q", here)).toEqual({ kind: "go", url: "/" });
115 expect(parseCommand("q", { ...here, app: "alice/shop" })).toEqual({
116 kind: "go",
117 url: "/alice/shop",
118 });
119 expect(parseCommand(" ", here)).toEqual({ kind: "none" });
120 expect(parseCommand("frobnicate", here).kind).toBe("error");
121 });
122
123 test("the footer is a subset of the sheet, for every view", () => {
124 for (const view of [
125 "home",
126 "app",
127 "run",
128 "board",
129 "map",
130 "list",
131 ] as const) {
132 const sheet = new Set(keysFor(view).map((k) => k.keys.join(" ")));
133 for (const k of footKeysFor(view))
134 expect(sheet.has(k.keys.join(" "))).toBe(true);
135 }
136 });
137 });
138
139 // ---- the pages ----
140
141 describe("the big view", () => {
142 let dataDir: string;
143 let app: App;
144 let servers: Servers;
145 let base: string;
146 let alice: User;
147 let notes: Repo;
148 let shop: Repo;
149 let cookie: string;
150
151 const get = (path: string, extra: Record<string, string> = {}) =>
152 fetch(`${base}${path}`, {
153 headers: { cookie, ...extra },
154 redirect: "manual",
155 });
156
157 async function makeApp(name: string): Promise<Repo> {
158 const made = await createRepo(app, alice, { name, visibility: "private" });
159 if (!made.ok) throw new Error(String(made.error));
160 const seeded = await commitToBranch(app, alice, made.value, {
161 message: "Start",
162 files: [
163 {
164 path: "package.json",
165 content: `{"name":"${name}","scripts":{"start":"bun run server.ts"}}\n`,
166 },
167 {
168 path: "server.ts",
169 content:
170 "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n",
171 },
172 ],
173 });
174 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
175 const enabled = await enableApp(app, alice, made.value, { slug: name });
176 if (!enabled.ok) throw new Error(String(enabled.error));
177 return made.value;
178 }
179
180 /** The text of every `<kbd>` inside the element with this id. */
181 function kbdsIn(html: string, id: string): Set<string> {
182 const start = html.indexOf(`id="${id}"`);
183 const open = html.lastIndexOf("<", start);
184 const tag = html.slice(open + 1, html.indexOf(" ", open));
185 const close = html.indexOf(`</${tag}>`, start);
186 const slice = html.slice(start, close);
187 return new Set(
188 [...slice.matchAll(/<kbd[^>]*>([^<]*)<\/kbd>/g)].map((m) => m[1]!),
189 );
190 }
191
192 beforeAll(async () => {
193 dataDir = await mkdtemp(join(tmpdir(), "forge-big-"));
194 app = createApp({ dataDir, port: 0 });
195 servers = await serve(app);
196 base = servers.url;
197 const a = await createUser(app.db, {
198 username: "alice",
199 email: "[email protected]",
200 password: "a-good-passphrase",
201 adminIfFirst: true,
202 });
203 if (!a.ok) throw new Error(a.error);
204 alice = a.value;
205 cookie = (
206 (
207 await fetch(`${base}/signin`, {
208 method: "POST",
209 body: new URLSearchParams({
210 username: "alice",
211 password: "a-good-passphrase",
212 }),
213 redirect: "manual",
214 })
215 ).headers.get("set-cookie") ?? ""
216 ).split(";")[0]!;
217 notes = await makeApp("notes");
218 shop = await makeApp("shop");
219 const scout = await createAgent(app, alice, { username: "scout" });
220 if (!scout.ok) throw new Error(String(scout.error));
221 expect(addToCrew(app, alice, notes, "scout").ok).toBe(true);
222 expect(addToCrew(app, alice, shop, "scout").ok).toBe(true);
223 notes = updateRepoSettings(app.db, notes, {
224 autonomy: "autonomous",
225 autonomyDirection: "Notes that load fast.\nAnd stay small.",
226 autonomyCeilingUsd: 20,
227 autonomySlots: 2,
228 autonomyBy: alice.id,
229 });
230 shop = updateRepoSettings(app.db, shop, {
231 autonomy: "assisted",
232 autonomyDirection: "Sell one real thing.",
233 autonomyCeilingUsd: 10,
234 autonomySlots: 1,
235 autonomyBy: alice.id,
236 });
237 }, 30_000);
238
239 afterAll(async () => {
240 servers.stop();
241 app.close();
242 await rm(dataDir, { recursive: true, force: true });
243 });
244
245 test("anonymous is sent to sign in, and comes back here", async () => {
246 const res = await fetch(`${base}/big?app=alice%2Fnotes`, {
247 redirect: "manual",
248 });
249 expect(res.status).toBe(303);
250 expect(res.headers.get("location")).toBe(
251 "/signin?next=%2Fbig%3Fapp%3Dalice%252Fnotes",
252 );
253 });
254
255 test("home: the shell, two live panes, every app as a row, the focused one at a glance", async () => {
256 const res = await get("/big");
257 expect(res.status).toBe(200);
258 const html = await res.text();
259 expect(html).toContain('<html lang="en" data-theme="dark">');
260 expect(html).not.toContain('class="topbar"');
261 expect(html).toMatch(/<main\s+class="bare"\s+id="main"\s*>/);
262 expect(html).toContain('id="big"');
263 expect(html).toContain('data-view="home"');
264 expect(html).toContain('data-apps="alice/shop alice/notes"');
265 expect(html).toContain('<header class="big-top">');
266 expect(html).toContain('id="big-clock"');
267 expect(html).toContain('id="big-live"');
268 expect(html).toContain('<footer class="big-foot" id="big-foot">');
269 expect(html).toContain('id="big-line"');
270 expect(html).toContain('id="big-help"');
271 expect(html).toMatch(
272 /id="big-apps"\s+data-live="run\.\*,pull\.\*,issue\.\*,check\.\*"/,
273 );
274 expect(html).toMatch(
275 /id="big-glance"\s+data-live="run\.\*,pull\.\*,issue\.\*,check\.\*"/,
276 );
277 // The rows: name, autonomy word, working, asks, spend against the ceiling.
278 expect(html).toContain('data-key="app:alice/notes"');
279 expect(html).toContain('data-href="/big?app=alice%2Fnotes"');
280 expect(html).toContain('data-say="/alice/notes/tell"');
281 expect(html).toContain('data-word="autonomous"');
282 expect(html).toContain('data-word="assisted"');
283 expect(html).toMatch(/\$0\.00\s*\/\s*\$20/);
284 // The glance: the direction's first line only, and its own lists.
285 expect(html).toContain('data-for="app:alice/notes"');
286 expect(html).toContain("Notes that load fast.");
287 expect(html).not.toContain("And stay small.");
288 expect(html).toContain("Nobody is working here right now.");
289 expect(html).toContain("new EventSource('/events')");
290 // A viewer who chose a theme keeps it.
291 const paper = await (
292 await get("/big", { cookie: `${cookie}; forge_theme=paper` })
293 ).text();
294 expect(paper).toContain('data-theme="paper"');
295 });
296
297 test("app: a run in flight with its clock and stop key, the map, landed", async () => {
298 const scout = getUserByName(app.db, "scout")!;
299 const told = openIssue(app, alice, shop, {
300 title: "Checkout keeps the total steady",
301 });
302 if (!told.ok) throw new Error(String(told.error));
303 const running = createRun(app.db, {
304 repoId: shop.id,
305 kind: "agent",
306 issueId: told.value.id,
307 actorId: scout.id,
308 reason: "assigned",
309 });
310 claimRun(app.db, running.id);
311 // A green pull the crew opened: the shop is assisted, so it asks.
312 const branch = await commitToBranch(app, alice, shop, {
313 branch: "crew/wrap",
314 base: "main",
315 message: "Gift wrap",
316 files: [{ path: "WRAP.md", content: "wrap\n" }],
317 });
318 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
319 const opened = await openPull(app, scout, shop, {
320 title: "Gift wrap as a line item",
321 body: "",
322 baseBranch: "main",
323 headBranch: "crew/wrap",
324 });
325 if (!opened.ok) throw new Error(String(opened.error));
326 const pull = opened.value;
327 const check = createRun(app.db, {
328 repoId: shop.id,
329 kind: "check",
330 issueId: pull.id,
331 env: `pull-${pull.number}`,
332 commitOid: pull.headOid,
333 reason: "pull",
334 });
335 claimRun(app.db, check.id);
336 finishRun(app.db, check.id, { state: "ok", summary: "check passed" });
337
338 const html = await (await get("/big?app=alice%2Fshop")).text();
339 expect(html).toContain('data-view="app"');
340 expect(html).toContain('data-here="alice/shop"');
341 expect(html).toContain('data-say="/alice/shop/tell"');
342 expect(html).toMatch(
343 /id="big-working"\s+data-live="run\.\*"\s+data-live-repo="alice\/shop"/,
344 );
345 expect(html).toMatch(
346 /id="big-map"\s+data-live="run\.\*,issue\.\*,pull\.\*"/,
347 );
348 expect(html).toMatch(
349 /id="big-landed"\s+data-live="pull\.merged,run\.finished"/,
350 );
351 // The run: who, what, the clock from when it started, and x to stop.
352 const working = html.slice(
353 html.indexOf('id="big-working"'),
354 html.indexOf('id="big-map"'),
355 );
356 expect(working).toContain(`data-key="run:${running.id}"`);
357 expect(working).toContain(`data-href="/big?run=${running.id}"`);
358 expect(working).toContain('data-state="working"');
359 expect(working).toContain("Checkout keeps the total steady");
360 expect(working).toContain("building it");
361 expect(working).toMatch(/data-started="[^"]+"/);
362 expect(working).toContain(`data-act="x"`);
363 expect(working).toContain(`form="stop-${running.id}"`);
364 expect(working).toContain(`action="/alice/shop/runs/${running.id}/cancel"`);
365 expect(working).toContain("<b>1</b> of 1 slots");
366 // The map drew the issue the crew is on, ringed, with a key.
367 const map = html.slice(
368 html.indexOf('id="big-map"'),
369 html.indexOf('id="big-landed"'),
370 );
371 expect(map).toContain('class="map-node"');
372 expect(map).toContain('data-state="working"');
373 expect(map).toContain(`data-key="issue:${told.value.id}"`);
374 expect(map).toContain("<b>1</b> working");
375 // The footer offers the lists, each with where it goes.
376 expect(html).toMatch(
377 /data-k="r"\s+data-go="\/big\?app=alice%2Fshop&amp;list=runs"/,
378 );
379 expect(html).toMatch(
380 /data-k="p"\s+data-go="\/big\?app=alice%2Fshop&amp;list=pulls"/,
381 );
382 expect(html).toMatch(
383 /data-k="m"\s+data-go="\/big\?app=alice%2Fshop&amp;list=map"/,
384 );
385
386 // The run's own view: the transcript, following, and the steer line.
387 const run = await (await get(`/big?run=${running.id}`)).text();
388 expect(run).toContain('data-view="run"');
389 expect(run).toContain('data-back="/big?app=alice%2Fshop"');
390 expect(run).toContain('id="run-log"');
391 expect(run).toContain(
392 `data-stream="/api/v1/repos/alice/shop/runs/${running.id}/log?follow=1"`,
393 );
394 expect(run).toContain('id="run-steer"');
395 expect(run).toContain(`action="/alice/shop/runs/${running.id}/steer"`);
396 expect(run).toContain('data-running="1"');
397 expect(run).toMatch(/id="run-stop"\s+data-act="x"/);
398
399 // The lists.
400 const runs = await (await get("/big?app=alice%2Fshop&list=runs")).text();
401 expect(runs).toContain('data-view="list"');
402 expect(runs).toContain(`data-key="run:${running.id}"`);
403 expect(runs).toContain(`data-key="run:${check.id}"`);
404 const pulls = await (await get("/big?app=alice%2Fshop&list=pulls")).text();
405 expect(pulls).toContain(`data-key="pull:${pull.id}"`);
406 expect(pulls).toContain('data-word="green"');
407 expect(pulls).toContain(`form="ship-${pull.id}"`);
408 const issues = await (
409 await get("/big?app=alice%2Fshop&list=issues")
410 ).text();
411 expect(issues).toContain(`data-key="issue:${told.value.id}"`);
412 expect(issues).toContain('data-word="working"');
413 // Home now counts them.
414 const home = await (await get("/big")).text();
415 expect(home).toMatch(/1\s+working/);
416 // Somebody else's app, or none: not found, never a hint of it.
417 expect((await get("/big?app=alice%2Fnowhere")).status).toBe(404);
418 expect((await get("/big?run=nothing")).status).toBe(404);
419 });
420
421 test("board and map inside the shell, focusable", async () => {
422 const board = await (await get("/big?view=board")).text();
423 expect(board).toContain('data-view="board"');
424 expect(board).toContain('data-layout="wall"');
425 expect(board).toMatch(/id="working"\s+data-live="run\.\*"/);
426 expect(board).toMatch(/id="today"\s+data-live="run\.\*,pull\.merged"/);
427 expect(board).toContain('class="board-card board-run"');
428 expect(board).toMatch(/data-key="run:[^"]+"/);
429 expect(board).toMatch(/data-key="app:alice\/notes"/);
430 const map = await (await get("/big?view=map")).text();
431 expect(map).toContain('data-view="map"');
432 expect(map).toMatch(
433 /id="map-lanes"\s+data-live="run\.\*,issue\.\*,pull\.\*"/,
434 );
435 expect(map).toContain('data-key="lane:alice/shop"');
436 expect(map).toContain('data-href="/big?app=alice%2Fshop"');
437 expect(map).toContain('class="map-lane"');
438 });
439
440 test("the command line's targets are pages, and the sheet lists every key the footer shows", async () => {
441 const home = await (await get("/big")).text();
442 const apps = /data-apps="([^"]*)"/.exec(home)![1]!.split(" ");
443 for (const c of COMMANDS) {
444 if (c.name === "help") continue;
445 const parsed = parseCommand(
446 c.arg === undefined ? c.name : `${c.name} shop`,
447 { app: "alice/shop", apps },
448 );
449 expect(parsed.kind).toBe("go");
450 if (parsed.kind !== "go") continue;
451 const res = await get(parsed.url);
452 expect([200, 303]).toContain(res.status);
453 if (res.status === 303) {
454 // :q leaves for the web UI, which sends a studio reader to its home.
455 expect(res.headers.get("location")).not.toContain("/signin");
456 }
457 }
458 for (const path of [
459 "/big",
460 "/big?app=alice%2Fshop",
461 "/big?app=alice%2Fshop&list=runs",
462 "/big?view=board",
463 "/big?view=map",
464 ]) {
465 const html = await (await get(path)).text();
466 const foot = kbdsIn(html, "big-foot");
467 const sheet = kbdsIn(html, "big-help");
468 expect(foot.size).toBeGreaterThan(5);
469 for (const k of foot) expect(sheet.has(k)).toBe(true);
470 // Every command has a line on the sheet.
471 for (const c of COMMANDS) expect(html).toContain(`:${c.name}`);
472 }
473 });
474 });
test/board.test.ts +47
@@ -1,6 +1,7 @@
11 /**
22 * The board: a live wall of the crews at work. Signed in, four live
33 * regions; a run in flight and a merged pull on it; `?apps=` narrows it;
4 * `?wall` is its wall mode, where every old `/big` address lands;
45 * anonymous is sent to sign in.
56 */
67 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
@@ -279,6 +280,52 @@ describe("the board", () => {
279280 expect(home).toContain('href="/board"');
280281 expect(home).toContain("Board");
281282 });
283
284 test("?wall is the wall mode: the same live regions, marked for the far screen", async () => {
285 const plain = await (await get("/board")).text();
286 expect(plain).not.toContain("data-wall");
287 const res = await get("/board?apps=alice/notes&wall");
288 expect(res.status).toBe(200);
289 const html = await res.text();
290 expect(html).toMatch(/<div class="board"\s+data-wall="">/);
291 expect(html).toMatch(/id="working"\s+data-live="run\.\*"/);
292 expect(html).toMatch(/class="board-scope"\s*>notes</);
293 });
294
295 // The big view's every address, each its own test: all land on the wall mode.
296 const oldRoutes: [string, string][] = [
297 ["/big", "/board?wall"],
298 ["/big?view=board", "/board?wall"],
299 ["/big?view=map", "/board?wall"],
300 ["/big?app=alice/notes", "/board?apps=alice%2Fnotes&wall"],
301 ["/big?app=alice%2Fnotes&list=runs", "/board?apps=alice%2Fnotes&wall"],
302 ["/big?app=alice/notes&list=issues", "/board?apps=alice%2Fnotes&wall"],
303 ["/big?app=alice/notes&list=pulls", "/board?apps=alice%2Fnotes&wall"],
304 ["/big?app=alice/notes&list=map", "/board?apps=alice%2Fnotes&wall"],
305 ["/big?run=0123456789abcdef", "/board?wall"],
306 // An address that was never an app keeps no scope.
307 ["/big?app=../../etc", "/board?wall"],
308 ["/big?app=%22%3E%3Cscript%3E", "/board?wall"],
309 ];
310 for (const [from, to] of oldRoutes)
311 test(`${from} lands on ${to}`, async () => {
312 const res = await get(from);
313 expect(res.status).toBe(303);
314 expect(res.headers.get("location")).toBe(to);
315 const landed = await get(to);
316 expect(landed.status).toBe(200);
317 expect(await landed.text()).toContain("data-wall");
318 });
319
320 test("anonymous at /big reaches the sign-in by way of the wall", async () => {
321 const res = await fetch(`${base}/big?app=alice/notes`, {
322 redirect: "manual",
323 });
324 expect(res.status).toBe(303);
325 expect(res.headers.get("location")).toBe("/board?apps=alice%2Fnotes&wall");
326 const next = await fetch(`${base}/board?wall`, { redirect: "manual" });
327 expect(next.headers.get("location")).toMatch(/^\/signin\?next=/);
328 });
282329 });
283330
284331 describe("the board's forge strip", () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs