From b15cc0eb1aacd0ce85abd364ac25564fa7dae4af Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 1 Sep 2026 11:32:18 +0200 Subject: [PATCH] fix(ui): keep the plan-usage chip's 5h slot when no session window is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header chip silently shrank from "5h 4% · 7d 52%" to a lone "7d 52%", which reads as half the feature breaking rather than as an idle window. Nothing was broken. Claude Code documents `rate_limits.five_hour` as "present only while the API reports it and its resets_at has not passed", so between 5-hour session windows the key simply leaves the statusline payload. Codeman's snapshot replaces the Claude half wholesale on every sample, so the segment disappeared until usage opened a new window. Confirmed against a live 2.1.252 session by capturing real statusline payloads on an isolated tmux socket: the boot render carries no `rate_limits` at all, and the first post-response render carries both windows. The slot now stays, with a dimmed em dash. Claude only: a missing CODEX bucket means that plan has no such limit rather than an idle window, so those stay omitted (pinned by the existing test). The placeholder can never stand alone either — hasWindows() still gates the row, so a provider reporting nothing renders nothing rather than a row of dashes. The tooltip says "5-hour limit: no active session window" instead of dropping the line. Verified in a real browser against a dev instance: the idle chip renders "5h — · 7d 52%" with the dash at opacity 0.55 in --text-dim while the live value keeps its green, and the chip holds its shape (100px idle vs 107px with both windows). --- src/web/public/app.js | 32 +++++++++++++++++++++++++------- src/web/public/styles.css | 7 +++++++ test/plan-usage-chip.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index d256f436..c80d526c 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2634,8 +2634,19 @@ class CodemanApp { // string field (e.g. modelDisplayName, which the route also broadcasts) is // ever shown in this chip, render it via textContent — never interpolate an // untrusted string into this template. - const seg = (label, p) => { - if (p === null) return ''; + // `idle: true` keeps a missing window's SLOT with a dimmed em dash instead of + // dropping it. Claude only: Claude Code documents `five_hour` as "present + // only while the API reports it and its resets_at has not passed", so that + // key leaves the statusline payload whenever no 5-hour session window is + // open, and a chip that silently shrank from two windows to one read as a + // broken feature rather than as an idle window (reported 2026-09-01). A + // missing CODEX bucket means the opposite — that plan has no such limit — + // so those stay omitted rather than showing a dash forever. + const seg = (label, p, idle) => { + if (p === null) { + if (!idle) return ''; + return `${label}—`; + } const n = Math.round(Number(p)); if (!Number.isFinite(n)) return ''; return `${label}${n}%`; @@ -2644,24 +2655,31 @@ class CodemanApp { // provider to tell apart: a machine with Claude alone shows bare windows. const hasWindows = (usage) => pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null; const labelled = hasWindows(data) && hasWindows(data.codex); - const row = (provider, usage) => { - const windows = [seg('5h', pct(usage?.fiveHour)), seg('7d', pct(usage?.sevenDay))].filter(Boolean); + const row = (provider, usage, idle) => { + // hasWindows() gates the row, so a placeholder can only ever appear + // ALONGSIDE a real reading — a provider reporting nothing still renders + // nothing, never a row of em dashes. + if (!hasWindows(usage)) return ''; + const windows = [seg('5h', pct(usage?.fiveHour), idle), seg('7d', pct(usage?.sevenDay), idle)].filter(Boolean); if (!windows.length) return ''; const label = labelled ? `${provider}` : ''; return `${label}${windows.join('·')}`; }; - const rows = [row('Claude', data), row('Codex', data.codex)].filter(Boolean); + const rows = [row('Claude', data, true), row('Codex', data.codex, false)].filter(Boolean); chip.innerHTML = rows.length ? rows.join('') : '—'; const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—'); - const details = (provider, usage) => { + const details = (provider, usage, idle) => { const lines = []; const five = pct(usage?.fiveHour); const seven = pct(usage?.sevenDay); if (five !== null) lines.push(`5-hour limit: ${five}% used (resets ${resetStr(usage.fiveHour)})`); + else if (idle && seven !== null) lines.push('5-hour limit: no active session window'); if (seven !== null) lines.push(`Weekly limit: ${seven}% used (resets ${resetStr(usage.sevenDay)})`); return lines.length ? `${provider} plan usage\n${lines.join('\n')}` : ''; }; - chip.title = [details('Claude', data), details('Codex', data.codex)].filter(Boolean).join('\n\n') || 'Plan usage limits'; + chip.title = + [details('Claude', data, true), details('Codex', data.codex, false)].filter(Boolean).join('\n\n') || + 'Plan usage limits'; } // Scheduled runs diff --git a/src/web/public/styles.css b/src/web/public/styles.css index a517b3c0..f382677d 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -12470,6 +12470,13 @@ kbd { color: var(--text-dim); opacity: 0.45; } +/* A window Claude is not currently reporting: the slot stays, dimmed, so the + chip keeps its shape instead of looking like half of it broke. */ +.header-plan-usage .pu-win-idle .pu-label, +.header-plan-usage .pu-win-idle .pu-val { + color: var(--text-dim); + opacity: 0.55; +} /* Green/yellow/red by how much of the window is used up. */ .header-plan-usage .pu-green { color: #3fb950; diff --git a/test/plan-usage-chip.test.ts b/test/plan-usage-chip.test.ts index 4a801eed..6cd6a46e 100644 --- a/test/plan-usage-chip.test.ts +++ b/test/plan-usage-chip.test.ts @@ -80,6 +80,34 @@ describe('header plan usage chip', () => { expect(codexRow).toContain('7d'); }); + it("keeps Claude's 5h slot as a dash when no session window is open", () => { + // Claude Code ships `five_hour` "only while the API reports it and its + // resets_at has not passed", so between session windows the key is simply + // absent. The chip used to shrink to a lone 7d segment, which reads as a + // broken feature rather than an idle window (reported 2026-09-01). + const { CodemanApp, chip } = loadCodemanAppClass(); + const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp; + + app.updatePlanUsageChip({ sevenDay: { usedPercentage: 52, resetAt: 2000 } }); + + expect(chip.innerHTML).toContain('pu-win-idle'); + expect(chip.innerHTML).toContain('5h'); + expect(chip.innerHTML).toContain('52%'); + expect(chip.title).toContain('no active session window'); + }); + + it('renders no row at all for a provider reporting nothing', () => { + // The placeholder must never stand alone: a row of em dashes would claim a + // provider is idle when it is really absent. + const { CodemanApp, chip } = loadCodemanAppClass(); + const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp; + + app.updatePlanUsageChip({ codex: { sevenDay: { usedPercentage: 40, resetAt: 3000 } } }); + + expect(chip.innerHTML).not.toContain('pu-win-idle'); + expect(chip.innerHTML).toContain('40%'); + }); + it('drops the provider label when Claude is the only provider with limits', () => { const { CodemanApp, chip } = loadCodemanAppClass(); const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;