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

The board says when it has stopped being live, instead of going quietly stale #82

Merged opened this

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

Merged

scout/tempo:scout/67-the-board-says-when-it-has-stopped-being → main 1 commit fork

  • Merged. scout/67-the-board-says-when-it-has-stopped-being was merged into main as fd15894.

Review

Sign in to review this change.
4 files changed +156 −13
README.md +10 −1
@@ -65,7 +65,16 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
6565 trying to replay whatever was missed while it was down. Claiming a
6666 board doesn't publish an event — "Kept by" is the one piece of board
6767 state that only updates on next load, a deliberate narrowing (see
68 "What's simulated").
68 "What's simulated"). A small dot and label next to the board's name
69 say whether that connection is actually up: "Live" once it's open,
70 "Not live — reconnecting" if a drop lingers past about a second (a
71 quick blip never gets a word), and "Not live — Reconnect" with a
72 button if the browser has given up retrying on its own — which per
73 the EventSource spec is permanent for any non-200 response, including
74 the 503 this same server answers with once a board has 100 tabs open
75 on it (`MAX_SUBSCRIBERS_PER_BOARD` in `src/limits.ts`). The button
76 opens a fresh connection and, once it's up, reconciles the same way
77 an automatic reconnect does.
6978 - **Agents**: `POST /mcp` speaks MCP over plain JSON-RPC 2.0
7079 (`initialize`, `tools/list`, `tools/call`) with five tools —
7180 `list_boards`, `list_columns`, `list_cards`, `add_card`, `move_card` —
src/live-refresh.test.ts +60
@@ -53,14 +53,31 @@ afterEach(() => {
5353 });
5454
5555 class FakeEventSource {
56 static readonly CONNECTING = 0;
57 static readonly OPEN = 1;
58 static readonly CLOSED = 2;
5659 onmessage: ((message: { data: string }) => void) | null = null;
5760 onopen: (() => void) | null = null;
61 onerror: (() => void) | null = null;
62 readyState = FakeEventSource.CONNECTING;
5863 constructor(_url: string) {
5964 latestSource = this;
6065 }
6166 }
6267 let latestSource: FakeEventSource | undefined;
6368
69 // Fires onopen/onerror on whichever EventSource the script most recently
70 // constructed — the same one a real browser would be retrying, including
71 // the fresh one board.js makes when the Reconnect button is clicked.
72 function open(): void {
73 latestSource!.readyState = FakeEventSource.OPEN;
74 latestSource!.onopen?.();
75 }
76 function fail(readyState: number): void {
77 latestSource!.readyState = readyState;
78 latestSource!.onerror?.();
79 }
80
6481 async function run(boardId: string, onFetch?: (path: string, init: RequestInit) => void): Promise<{ window: Window; document: Document }> {
6582 const html = await (await fetch(`${base}/b/${boardId}`)).text();
6683 const [, scriptSrc] = html.match(/<script src="(\/static\/board[^"]+)"/)!;
@@ -215,3 +232,46 @@ test("a column added or deleted elsewhere updates every card's Move to column dr
215232
216233 await window.close();
217234 });
235
236 test("the board says when its live connection drops, waits out a routine reconnect before saying so, and offers a way back from a permanent failure", async () => {
237 const boardId = await newBoard();
238 let htmlFetches = 0;
239 const { window, document } = await run(boardId, (_path, init) => {
240 const headers = init.headers as Record<string, string> | undefined;
241 if (headers?.accept === "text/html") htmlFetches++;
242 });
243 const liveText = () => document.getElementById("live-text")!.textContent;
244 const reconnectBtn = document.getElementById("live-reconnect") as unknown as HTMLButtonElement;
245
246 expect(liveText()).toBe("Not live"); // server-rendered, before any script has run
247 expect(reconnectBtn.hidden).toBe(true);
248
249 open();
250 expect(liveText()).toBe("Live");
251
252 // A drop the browser will retry on its own: no flicker right away, and
253 // "reconnecting" only once it's clear this isn't an instant recovery.
254 fail(FakeEventSource.CONNECTING);
255 expect(liveText()).toBe("Live");
256 await new Promise((resolve) => setTimeout(resolve, 1100));
257 expect(liveText()).toBe("Not live — reconnecting");
258 expect(reconnectBtn.hidden).toBe(true);
259
260 // The permanent failure EventSource itself never retries from (a 503 from
261 // a full board, among others): says so immediately and offers a way back.
262 fail(FakeEventSource.CLOSED);
263 expect(liveText()).toBe("Not live — Reconnect");
264 expect(reconnectBtn.hidden).toBe(false);
265
266 // The button constructs a fresh connection; once it opens, reconcile()
267 // re-reads the board exactly once, same as an automatic reconnect would.
268 const before = htmlFetches;
269 reconnectBtn.dispatchEvent(new window.Event("click", { bubbles: true }));
270 expect(reconnectBtn.hidden).toBe(true);
271 open();
272 await new Promise((resolve) => setTimeout(resolve, 20));
273 expect(liveText()).toBe("Live");
274 expect(htmlFetches).toBe(before + 1);
275
276 await window.close();
277 });
src/pages/board.ts +12 −1
@@ -44,6 +44,12 @@ const styles = `
4444 button.ghost, a.ghost { background: transparent; color: inherit; border: 1px solid #8884; padding: .35rem .7rem; border-radius: .4rem; }
4545 .tools { position: fixed; top: .75rem; right: .75rem; display: flex; gap: .4rem; z-index: 10; }
4646 .tools button { padding: .3rem .55rem; font-size: 1rem; line-height: 1; }
47 .live { display: inline-flex; align-items: center; gap: .35rem; font-size: .8rem; opacity: .85; white-space: nowrap; }
48 .live .dot { width: .55rem; height: .55rem; border-radius: 50%; background: #8886; flex: none; }
49 .live[data-state="live"] .dot { background: #16a34a; }
50 .live[data-state="reconnecting"] .dot { background: #d97706; }
51 .live[data-state="closed"] .dot { background: #b91c1c; }
52 .live button { padding: .1rem .5rem; font-size: .75rem; }
4753 .board { display: flex; gap: 1rem; align-items: flex-start; overflow-x: auto; max-width: 100%; padding-bottom: 1rem; }
4854 .column { background: #8881; border-radius: .6rem; padding: .6rem; width: 16rem; flex: none; }
4955 .column.drop-target { outline: 2px dashed var(--accent); }
@@ -240,6 +246,11 @@ export function boardPage({ board, columns, cards, query, who, error }: BoardDat
240246 <input name="name" value="${escape(board.name)}" maxlength="100" aria-label="Board name">
241247 <button class="ghost">Rename</button>
242248 </form>
249 <span class="live" id="live-status" data-state="off">
250 <span class="dot" aria-hidden="true"></span>
251 <span id="live-text" aria-live="polite">Not live</span>
252 <button type="button" id="live-reconnect" class="ghost" hidden>Reconnect</button>
253 </span>
243254 ${doorLink(who, `/b/${board.id}`)}
244255 </header>
245256 <p class="hint">Share this board with its URL. Drag a card, focus it (Tab) and press the arrow keys, or press and hold it on a touchscreen, to move it — no mouse needed. Type a title and press Enter to save it. ${keepHtml(board, who)}</p>
@@ -278,6 +289,6 @@ const boardShortcuts: [string, string][] = [
278289
279290 // Named by content — a change to static/board.js gets a new hash here,
280291 // so the year-long cache on /static/ never serves a stale script.
281 export const BOARD_JS = "board.109353762.js";
292 export const BOARD_JS = "board.822340210.js";
282293 export const THEME_JS = "theme.1025564933.js";
283294 export const BOARDS_JS = "boards.608180649.js";
static/board.109353762.js → static/board.822340210.js renamed +74 −11
@@ -578,20 +578,83 @@
578578 }
579579 }
580580
581 const source = new EventSource(`/b/${boardId}/events`);
582 source.onmessage = (message) => {
583 const event = JSON.parse(message.data);
584 if (event.client && event.client === clientId) return; // this tab already applied it optimistically
585 apply(event);
586 };
581 // ---- live status: the board's own connection, not its content ----
582 // Server-rendered neutral ("Not live", grey dot, hidden button) so a
583 // page with JavaScript off never claims to be live and nothing here
584 // shifts layout by creating or removing elements — only text, a class
585 // and one boolean toggle on markup that already exists.
586 const liveStatus = document.getElementById("live-status");
587 const liveText = document.getElementById("live-text");
588 const reconnectBtn = document.getElementById("live-reconnect");
589 // A connection that drops and comes straight back within about a
590 // second is the common case (a deploy, a blip) — flashing "not live"
591 // for that would be noise, so the reconnecting state waits this long
592 // before it shows itself, and never appears at all if onopen wins the race.
593 const NOT_LIVE_DELAY_MS = 1000;
594 let notLiveTimer = null;
595 function setLive() {
596 clearTimeout(notLiveTimer);
597 notLiveTimer = null;
598 if (liveStatus) liveStatus.dataset.state = "live";
599 if (liveText) liveText.textContent = "Live";
600 if (reconnectBtn) reconnectBtn.hidden = true;
601 }
602 function setReconnecting() {
603 if (notLiveTimer || (liveStatus && liveStatus.dataset.state === "reconnecting")) return;
604 notLiveTimer = setTimeout(() => {
605 notLiveTimer = null;
606 if (liveStatus) liveStatus.dataset.state = "reconnecting";
607 if (liveText) liveText.textContent = "Not live — reconnecting";
608 if (reconnectBtn) reconnectBtn.hidden = true;
609 }, NOT_LIVE_DELAY_MS);
610 }
611 // The permanent failure the EventSource spec leaves silent: a non-200
612 // or wrong-content-type response (the board's own 503 when it is full,
613 // among others) closes the connection for good and the browser will
614 // never retry. This is the one state that offers a way back rather
615 // than implying one is already coming.
616 function setClosed() {
617 clearTimeout(notLiveTimer);
618 notLiveTimer = null;
619 if (liveStatus) liveStatus.dataset.state = "closed";
620 if (liveText) liveText.textContent = "Not live — Reconnect";
621 if (reconnectBtn) reconnectBtn.hidden = false;
622 }
623
624 function wireMessages(es) {
625 es.onmessage = (message) => {
626 const event = JSON.parse(message.data);
627 if (event.client && event.client === clientId) return; // this tab already applied it optimistically
628 apply(event);
629 };
630 }
631
587632 // A dropped connection reconnects on its own; the reconnect itself
588633 // (not the first, successful connection) means something may have been
589634 // missed while it was down, so that one reconciles by re-reading the
590 // board rather than trusting nothing was lost.
635 // board rather than trusting nothing was lost. The same is true of a
636 // reconnect started by hand from the button below.
591637 let connectedBefore = false;
592 source.onopen = () => {
593 if (connectedBefore) reconcile();
594 connectedBefore = true;
595 };
638 function connect() {
639 const es = new EventSource(`/b/${boardId}/events`);
640 wireMessages(es);
641 es.onopen = () => {
642 setLive();
643 if (connectedBefore) reconcile();
644 connectedBefore = true;
645 };
646 es.onerror = () => {
647 if (es.readyState === EventSource.CLOSED) setClosed();
648 else setReconnecting();
649 };
650 return es;
651 }
652 let source = connect();
653 if (reconnectBtn) {
654 reconnectBtn.addEventListener("click", () => {
655 reconnectBtn.hidden = true;
656 source = connect();
657 });
658 }
596659 }
597660 })();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs