From f67eec0e9de28894dafad50b073eb6030fa46e48 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 13 Nov 2026 15:25:15 -0500 Subject: [PATCH 1/2] dashboard: say on the page when what it shows is stale A read the server answers with its last good data (a slow run, or a failed one) now puts a note at the top: the time of what is shown, and that the page updates when the latest is ready. Fixed, so nothing under the cursor moves; over the content on a desktop and below the nav on a phone; clicks pass through it. A failure's banner replaces it. Co-Authored-By: Claude Opus 5.5 --- docs/devteam/dashboard/index.html | 14 ++++++++++++++ test/dashboard.test.mjs | 28 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 23472a1..5e21523 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -47,6 +47,9 @@ .banner.updated { border-color: color-mix(in srgb, var(--blue-fg, var(--fg-2)) 45%, var(--line)); background: color-mix(in srgb, var(--blue-fg, var(--fg-2)) 7%, var(--surface)); } .banner.updated button { margin: 0 0 0 8px; } .held { position: fixed; right: 16px; bottom: 64px; z-index: 30; margin: 0; box-shadow: var(--shadow); } + /* fixed, so it never moves what is under the cursor; at the top, as both bottom corners are taken, + over the content, not the nav (below it on a phone), and never in the way of a click */ + .stale-note { position: fixed; top: 12px; left: calc(232px + 16px); right: 16px; width: fit-content; margin: 0 auto; z-index: 30; pointer-events: none; padding: 4px 12px; text-align: center; border: 1px solid color-mix(in srgb, var(--amber) 45%, var(--line)); border-radius: 12px; background: color-mix(in srgb, var(--amber) 12%, var(--surface)); color: var(--amber-fg); font-size: 12px; box-shadow: var(--shadow); } .banner b { color: var(--red-fg); } .banner pre { margin: 6px 0 0; max-height: 9em; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.5 var(--mono); color: var(--fg-2); } .banner.stale { border-color: color-mix(in srgb, var(--amber) 45%, var(--line)); background: color-mix(in srgb, var(--amber) 7%, var(--surface)); } @@ -78,6 +81,7 @@ .side .brand { padding: 0 8px 0 0; } .side .nav { display: flex; gap: 2px; } .side .foot { display: none; } + .stale-note { top: 60px; left: 16px; } .nav a { white-space: nowrap; } /* a long URL or id wraps; it doesn't widen the page */ main { padding: 16px 16px 64px; overflow-wrap: anywhere; } @@ -581,6 +585,7 @@ +

Loading…

@@ -4003,6 +4008,7 @@

${linkify(k.statement)}

// load there is nothing else to see, and after it the page would otherwise show old data as current. function showFailure(message) { const b = $('banner'); + $('stale').hidden = true; // the banner says what is shown, and why $('loading').hidden = true; const first = !DATA; const fix = /needs Node 18|is not a function|SyntaxError|Unexpected token/.test(message) @@ -5065,8 +5071,16 @@

${linkify(k.statement)}

$('banner').hidden = true; $('loading').hidden = true; document.querySelector('main').classList.remove('waiting'); + staleNote(d); drawOrHold(d); } +// The server answers a slow read with the last data it had, marked stale: say so where it shows on +// any screen, with the time of what is shown, until a fresh read lands. +function staleNote(d) { + const n = $('stale'); + n.hidden = !STALE; + if (STALE) n.textContent = `Showing data from ${new Date(d.generated_at).toLocaleTimeString()}. The latest is taking a while; this page updates when it's ready.`; +} // The tab title carries the Inbox count. Notifications are the hub's, for every instance. function inboxTitle(d) { diff --git a/test/dashboard.test.mjs b/test/dashboard.test.mjs index 236ed17..8f75b24 100644 --- a/test/dashboard.test.mjs +++ b/test/dashboard.test.mjs @@ -709,6 +709,34 @@ test('a slow data run: a read with data to fall back on has it at once, marked s await until(async () => (await read()).asked.includes('Second?'), 'the run landing'); }); +test('a stale read is said on the page: a note at the top with the time of what is shown, on a phone too', { skip: canDrive ? false : 'needs Chrome and a Node with WebSocket', timeout: 120000 }, async (t) => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'devteam-stale-note-')); + t.after(() => fs.rmSync(base, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })); + // waiting no time at all on a run: every read after the first is the last good data, marked stale + const env = { ...process.env, DEVTEAM_DATA_WAIT_MS: '0', DEVTEAM_HUB_PORT: String(PORTS + 42), DEVTEAM_PORT_BASE: String(PORTS + 43), CLAUDE_CONFIG_DIR: path.join(base, 'claude'), CODEX_HOME: path.join(base, 'codex') }; + for (const k of ['DEVTEAM_ROLE', 'DEVTEAM_TICKET', 'DEVTEAM_FEATURE', 'DEVTEAM_RUN', 'DEVTEAM_SUPERVISOR', 'TMUX', 'TMUX_PANE']) delete env[k]; + const root = bareProject(base, 'stale'); + const port = await freePort(); + const dash = startDashboard(root, env, ['--port', String(port)]); + t.after(() => dash.stop()); + await until(() => /dashboard on http/.test(dash.out()), 'the dashboard'); + const chrome = await openChrome(); + t.after(() => chrome.close()); + const p = await chrome.newPage(); + t.after(() => p.close()); + await p.send('Page.enable'); + await p.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 800, deviceScaleFactor: 1, mobile: true }); + await p.send('Page.addScriptToEvaluateOnNewDocument', { source: POLL_FAST }); + await p.send('Page.navigate', { url: `http://127.0.0.1:${port}/` }); + const soon = soonIn(p, 30000, () => dash.out()); + await soon(`DATA && window.__reads?.length >= 1 && window.__reads[0].ms !== null`, 'the first read drawn'); + assert.equal(await p.ev(`$('stale').hidden`), true, 'the first read waits for its run, so nothing is stale'); + await soon(`!$('stale').hidden`, 'the note, once a read is stale'); + const note = await p.ev(`(() => { const n = $('stale'), r = n.getBoundingClientRect(); return { text: n.textContent, shown: getComputedStyle(n).display !== 'none', inside: r.left >= 0 && r.right <= innerWidth && r.top >= 0, at: new Date(DATA.generated_at).toLocaleTimeString() }; })()`); + assert.ok(note.shown && note.inside, "shown, inside a phone's width"); + assert.equal(note.text, `Showing data from ${note.at}. The latest is taking a while; this page updates when it's ready.`); +}); + test('the dashboard and hub pages parse: one stray quote and nothing shows at all', () => { for (const page of ['index.html', 'hub.html']) { const html = fs.readFileSync(path.join(kit, 'dashboard', page), 'utf8'); From 787683cea45f6669b98da168dd1b3406f768b0c9 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 13 Nov 2026 15:32:34 -0500 Subject: [PATCH 2/2] dashboard: stale note review fixes - It names the time of what is drawn, not of a read held while the owner types. - It gives way to a banner at the top, and sits under a drawer or review (z-index 19). - On a phone it sits below the nav as measured, not a fixed 60 px. - Its text is set only when it changes, so a screen reader isn't told again each poll. - Plainer copy, with the date when the data isn't from today. - The test checks it in one evaluation, with no assertion racing the next poll. Co-Authored-By: Claude Opus 5.5 --- docs/devteam/dashboard/index.html | 20 +++++++++++++------- test/dashboard.test.mjs | 12 +++++++----- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 5e21523..faf9f37 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -48,8 +48,9 @@ .banner.updated button { margin: 0 0 0 8px; } .held { position: fixed; right: 16px; bottom: 64px; z-index: 30; margin: 0; box-shadow: var(--shadow); } /* fixed, so it never moves what is under the cursor; at the top, as both bottom corners are taken, - over the content, not the nav (below it on a phone), and never in the way of a click */ - .stale-note { position: fixed; top: 12px; left: calc(232px + 16px); right: 16px; width: fit-content; margin: 0 auto; z-index: 30; pointer-events: none; padding: 4px 12px; text-align: center; border: 1px solid color-mix(in srgb, var(--amber) 45%, var(--line)); border-radius: 12px; background: color-mix(in srgb, var(--amber) 12%, var(--surface)); color: var(--amber-fg); font-size: 12px; box-shadow: var(--shadow); } + over the content, not the nav (below it on a phone, where staleNote measures the nav), under a + drawer or review, and never in the way of a click */ + .stale-note { position: fixed; top: 12px; left: calc(232px + 16px); right: 16px; width: fit-content; margin: 0 auto; z-index: 19; pointer-events: none; padding: 4px 12px; text-align: center; border: 1px solid color-mix(in srgb, var(--amber) 45%, var(--line)); border-radius: 12px; background: color-mix(in srgb, var(--amber) 12%, var(--surface)); color: var(--amber-fg); font-size: 12px; box-shadow: var(--shadow); } .banner b { color: var(--red-fg); } .banner pre { margin: 6px 0 0; max-height: 9em; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.5 var(--mono); color: var(--fg-2); } .banner.stale { border-color: color-mix(in srgb, var(--amber) 45%, var(--line)); background: color-mix(in srgb, var(--amber) 7%, var(--surface)); } @@ -5071,15 +5072,20 @@

${linkify(k.statement)}

$('banner').hidden = true; $('loading').hidden = true; document.querySelector('main').classList.remove('waiting'); - staleNote(d); drawOrHold(d); + staleNote(); } // The server answers a slow read with the last data it had, marked stale: say so where it shows on -// any screen, with the time of what is shown, until a fresh read lands. -function staleNote(d) { +// any screen, with the time of what is drawn (not of a read held while the owner types), until a +// fresh read lands. A banner at the top says more, and the note gives way to it. +function staleNote() { const n = $('stale'); - n.hidden = !STALE; - if (STALE) n.textContent = `Showing data from ${new Date(d.generated_at).toLocaleTimeString()}. The latest is taking a while; this page updates when it's ready.`; + n.hidden = !STALE || !DATA || !$('banner').hidden || !$('updated').hidden; + if (n.hidden) return; + const at = new Date(DATA.generated_at); + const t = `Showing data from ${at.toDateString() === new Date().toDateString() ? at.toLocaleTimeString() : at.toLocaleString()}. Newer data is taking a while to load; this page will update by itself.`; + if (n.textContent !== t) n.textContent = t; // unchanged, a screen reader isn't told again + n.style.top = matchMedia('(max-width: 900px)').matches ? `${Math.max(0, document.querySelector('.side').getBoundingClientRect().bottom) + 8}px` : ''; } // The tab title carries the Inbox count. Notifications are the hub's, for every instance. diff --git a/test/dashboard.test.mjs b/test/dashboard.test.mjs index 8f75b24..5e4fc69 100644 --- a/test/dashboard.test.mjs +++ b/test/dashboard.test.mjs @@ -730,11 +730,13 @@ test('a stale read is said on the page: a note at the top with the time of what await p.send('Page.navigate', { url: `http://127.0.0.1:${port}/` }); const soon = soonIn(p, 30000, () => dash.out()); await soon(`DATA && window.__reads?.length >= 1 && window.__reads[0].ms !== null`, 'the first read drawn'); - assert.equal(await p.ev(`$('stale').hidden`), true, 'the first read waits for its run, so nothing is stale'); - await soon(`!$('stale').hidden`, 'the note, once a read is stale'); - const note = await p.ev(`(() => { const n = $('stale'), r = n.getBoundingClientRect(); return { text: n.textContent, shown: getComputedStyle(n).display !== 'none', inside: r.left >= 0 && r.right <= innerWidth && r.top >= 0, at: new Date(DATA.generated_at).toLocaleTimeString() }; })()`); - assert.ok(note.shown && note.inside, "shown, inside a phone's width"); - assert.equal(note.text, `Showing data from ${note.at}. The latest is taking a while; this page updates when it's ready.`); + // one check, in the page, so a read landing between two can't split them: shown, inside a phone's + // width, below the nav, naming the time of what is drawn + await soon(`(() => { + const n = $('stale'), r = n.getBoundingClientRect(), nav = document.querySelector('.side').getBoundingClientRect(); + const want = 'Showing data from ' + new Date(DATA.generated_at).toLocaleTimeString() + '. Newer data is taking a while to load; this page will update by itself.'; + return !n.hidden && getComputedStyle(n).display !== 'none' && r.left >= 0 && r.right <= innerWidth && r.top >= nav.bottom && n.textContent === want; + })()`, 'the note, once a read is stale, saying what it shows'); }); test('the dashboard and hub pages parse: one stray quote and nothing shows at all', () => {