diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html
index 23472a1..faf9f37 100644
--- a/docs/devteam/dashboard/index.html
+++ b/docs/devteam/dashboard/index.html
@@ -47,6 +47,10 @@
.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, 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)); }
@@ -78,6 +82,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 +586,7 @@
The dashboard was updated since this tab opened, so some of what you see may be out of date.
+
Loading…
@@ -4003,6 +4009,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)
@@ -5066,6 +5073,19 @@
${linkify(k.statement)}
$('loading').hidden = true;
document.querySelector('main').classList.remove('waiting');
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 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 || !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 236ed17..5e4fc69 100644
--- a/test/dashboard.test.mjs
+++ b/test/dashboard.test.mjs
@@ -709,6 +709,36 @@ 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');
+ // 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', () => {
for (const page of ['index.html', 'hub.html']) {
const html = fs.readFileSync(path.join(kit, 'dashboard', page), 'utf8');