From fa2d75c95124f2b523e0601eaed7dd1bb1d28c88 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:00:03 +0800 Subject: [PATCH 1/3] feat(header): resizable toolbar divider that collapses the usage readings as it narrows --- .changeset/header-divider.md | 5 + config/test-suites.ts | 1 + scripts/build.mjs | 2 + src/web/public/app.js | 4 +- src/web/public/header-divider.js | 160 ++++++++++++++++++++++++++++ src/web/public/index.html | 1 + src/web/public/styles.css | 91 ++++++++++++++++ test/header-divider.browser.test.ts | 130 ++++++++++++++++++++++ test/header-divider.test.ts | 74 +++++++++++++ 9 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 .changeset/header-divider.md create mode 100644 src/web/public/header-divider.js create mode 100644 test/header-divider.browser.test.ts create mode 100644 test/header-divider.test.ts diff --git a/.changeset/header-divider.md b/.changeset/header-divider.md new file mode 100644 index 00000000..2ff36cb9 --- /dev/null +++ b/.changeset/header-divider.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +Drag the line between the session tabs and the header toolbar to resize the toolbar. Dragging it right narrows the toolbar, which then wraps onto a second line instead of squeezing out the tabs, and the usage readings shed their labels, then their provider names, then their percentages as it narrows; double-click, Home or Escape resets it. Arrow keys resize it from the keyboard, and the width is remembered on the device. diff --git a/config/test-suites.ts b/config/test-suites.ts index ae866007..864d1027 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -26,6 +26,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/tab-rail-search.browser.test.ts', 'test/tab-focus.browser.test.ts', 'test/session-sidebar-ux.browser.test.ts', + 'test/header-divider.browser.test.ts', 'test/session-options-responsive.browser.test.ts', 'test/inline-rename.test.ts', 'test/opencode-resize.test.ts', diff --git a/scripts/build.mjs b/scripts/build.mjs index 65db3881..d949fcad 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -120,6 +120,7 @@ run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --m run('minify tab-layout-browser.js', 'npx esbuild dist/web/public/tab-layout-browser.js --minify --outfile=dist/web/public/tab-layout-browser.js --allow-overwrite'); run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite'); run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite'); +run('minify header-divider.js', 'npx esbuild dist/web/public/header-divider.js --minify --outfile=dist/web/public/header-divider.js --allow-overwrite'); run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite'); run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite'); run('minify ralph-panel.js', 'npx esbuild dist/web/public/ralph-panel.js --minify --outfile=dist/web/public/ralph-panel.js --allow-overwrite'); @@ -149,6 +150,7 @@ console.log('\n[build] content-hash cache busting'); 'tab-layout-browser.js', 'app.js', 'tab-rail-resize.js', + 'header-divider.js', 'terminal-ui.js', 'respawn-ui.js', 'ralph-panel.js', diff --git a/src/web/public/app.js b/src/web/public/app.js index 03383854..b955c847 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3680,7 +3680,9 @@ class CodemanApp { seg('mo', pct(usage?.monthly), false), ].filter(Boolean); if (!windows.length) return ''; - const label = labelled ? `${provider}` : ''; + // data-short is what the narrowest header densities show instead of the name (header-divider.js). + const short = { Claude: 'CL', Codex: 'CX', Copilot: 'CP' }[provider] ?? provider.slice(0, 2).toUpperCase(); + const label = labelled ? `${provider}` : ''; return `${label}${windows.join('ยท')}`; }; const rows = [row('Claude', data, true), row('Codex', data.codex, false), row('Copilot', data.copilot, false)].filter( diff --git a/src/web/public/header-divider.js b/src/web/public/header-divider.js new file mode 100644 index 00000000..ded3ea86 --- /dev/null +++ b/src/web/public/header-divider.js @@ -0,0 +1,160 @@ +/** + * @fileoverview A draggable divider on the left edge of the header's right-hand toolbar. + * + * Dragging it left gives the toolbar (stats, plan usage, buttons) more room; dragging it right narrows the + * toolbar, which then wraps onto a second line instead of squeezing the session tabs out. The width is a + * device-local convenience kept in localStorage; double-click or Home resets it. The handle is absolutely + * positioned inside #headerRight, so it takes no layout room and is simply hidden where the tabs are not in + * the header (sidebar, vertical rail, phones). + * + * @loadorder after app.js; self-contained, no CodemanApp dependency. + */ +(function () { + const STORAGE_KEY = 'codeman-header-right-width'; + const MIN_WIDTH = 160; + const MIN_TABS_ROOM = 240; + const KEY_STEP = 16; + + /** Keep a requested toolbar width between the minimum and what leaves the tabs some room. */ + function clampWidth(width, headerWidth, brandWidth) { + const max = Math.max(MIN_WIDTH, headerWidth - brandWidth - MIN_TABS_ROOM); + return Math.round(Math.min(max, Math.max(MIN_WIDTH, width))); + } + + /** + * How much of the usage readings to show for a toolbar this wide. The narrower the toolbar, the more of + * the labels go: window labels first, then provider names (shortened), then the percentages (the rings + * stay, and the chip's tooltip keeps the numbers). + */ + function densityFor(width) { + if (width >= 560) return ''; + if (width >= 400) return 'tight'; + if (width >= 280) return 'dense'; + return 'tiny'; + } + + function readStored() { + try { + const value = Number(localStorage.getItem(STORAGE_KEY)); + return Number.isFinite(value) && value > 0 ? value : null; + } catch { + return null; + } + } + + function writeStored(width) { + try { + if (width === null) localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, String(width)); + } catch { + /* storage blocked: the width lasts until the page reloads */ + } + } + + function init() { + const header = document.querySelector('.header'); + const right = document.getElementById('headerRight'); + if (!header || !right || right.querySelector('.header-divider')) return; + const brand = header.querySelector('.header-brand'); + const root = document.documentElement; + + const handle = document.createElement('div'); + handle.className = 'header-divider'; + handle.setAttribute('role', 'separator'); + handle.setAttribute('aria-orientation', 'vertical'); + handle.setAttribute('aria-label', 'Resize the toolbar (double-click to reset)'); + handle.tabIndex = 0; + right.insertBefore(handle, right.firstChild); + + const bounds = () => ({ header: header.clientWidth, brand: brand ? brand.offsetWidth : 0 }); + const current = () => right.getBoundingClientRect().width; + + /** The narrowest the toolbar can be without a reading poking out past its left edge (the divider). */ + function contentMinimum() { + const style = getComputedStyle(right); + const frame = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + parseFloat(style.borderLeftWidth); + let widest = 0; + for (const child of right.children) { + if (child === handle || child.offsetParent === null) continue; + widest = Math.max(widest, child.getBoundingClientRect().width, child.scrollWidth); + } + return Math.ceil(widest + frame); + } + + function setDensity(width) { + const density = densityFor(width); + if (density) root.setAttribute('data-header-density', density); + else root.removeAttribute('data-header-density'); + } + + function apply(width, persist) { + if (width === null) { + root.style.removeProperty('--header-right-width'); + root.removeAttribute('data-header-right-width'); + root.removeAttribute('data-header-density'); + handle.removeAttribute('aria-valuenow'); + if (persist) writeStored(null); + return; + } + const { header: h, brand: b } = bounds(); + let clamped = clampWidth(width, h, b); + root.setAttribute('data-header-right-width', 'set'); + // A narrower toolbar sheds labels, which shrinks the readings, which lowers the minimum: settle + // on a width no reading overflows at the density that width gets. + for (let i = 0; i < 3; i++) { + root.style.setProperty('--header-right-width', clamped + 'px'); + setDensity(clamped); + const floor = contentMinimum(); + if (clamped >= floor) break; + clamped = floor; + } + handle.setAttribute('aria-valuenow', String(clamped)); + if (persist) writeStored(clamped); + } + + handle.addEventListener('pointerdown', (event) => { + if (event.button !== 0) return; + event.preventDefault(); + handle.setPointerCapture?.(event.pointerId); + document.body.classList.add('header-divider-dragging'); + const onMove = (e) => apply(header.getBoundingClientRect().right - e.clientX - 12, false); + const onUp = () => { + handle.removeEventListener('pointermove', onMove); + handle.removeEventListener('pointerup', onUp); + handle.removeEventListener('pointercancel', onUp); + document.body.classList.remove('header-divider-dragging'); + const width = Number.parseFloat(root.style.getPropertyValue('--header-right-width')); + if (Number.isFinite(width)) writeStored(width); + }; + handle.addEventListener('pointermove', onMove); + handle.addEventListener('pointerup', onUp); + handle.addEventListener('pointercancel', onUp); + }); + + handle.addEventListener('dblclick', () => apply(null, true)); + + handle.addEventListener('keydown', (event) => { + if (event.key === 'Home' || event.key === 'Escape') { + apply(null, true); + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + const step = event.key === 'ArrowLeft' ? KEY_STEP : -KEY_STEP; + apply(current() + step, true); + } else { + return; + } + event.preventDefault(); + }); + + window.addEventListener('resize', () => { + const width = Number.parseFloat(root.style.getPropertyValue('--header-right-width')); + if (Number.isFinite(width)) apply(width, false); + }); + + const stored = readStored(); + if (stored !== null) apply(stored, false); + } + + window.CodemanHeaderDivider = { clampWidth, densityFor, MIN_WIDTH, MIN_TABS_ROOM }; + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); +})(); diff --git a/src/web/public/index.html b/src/web/public/index.html index f3a2b2a2..90a262da 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -4345,6 +4345,7 @@ + diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 65755b90..a8c175fa 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2249,6 +2249,97 @@ html[data-tab-orientation='vertical'].tab-rail-compact margin-left: auto; padding-left: 0.75rem; border-left: 1px solid var(--glass-border); + position: relative; +} + +/* Draggable edge of the toolbar (header-divider.js). Absolute, so it takes no layout room and the + existing border-left stays the visible line. */ +.header-divider { + position: absolute; + top: 0; + bottom: 0; + left: -6px; + width: 12px; + cursor: col-resize; + touch-action: none; + z-index: 2; +} + +.header-divider:hover, +.header-divider:focus-visible { + background: color-mix(in srgb, var(--accent) 24%, transparent); + outline: none; +} + +.header-divider:focus-visible { + box-shadow: inset 0 0 0 2px var(--accent); +} + +/* A width the user chose: the toolbar keeps it and wraps onto a second line when its content is wider. */ +html[data-header-right-width]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) + body:not(.solo-mode) + .header-right { + flex: 0 0 var(--header-right-width); + width: var(--header-right-width); + flex-wrap: wrap; + justify-content: flex-end; + align-content: center; + row-gap: 0.25rem; + min-height: var(--header-height); +} + +/* The narrower the toolbar, the less each usage reading shows (data-header-density, header-divider.js): + tight drops the window and CPU/MEM labels, dense shortens the provider names, tiny keeps only the rings. + The same rules apply under every header-stats style. */ +html[data-header-density] .header-system-stats .stat-label, +html[data-header-density] .header-plan-usage .pu-label { + display: none; +} + +html[data-header-density] .header-system-stats, +html[data-header-density] .header-plan-usage { + gap: 8px; + padding: 0 8px; +} + +html[data-header-density='dense'] .header-plan-usage .pu-provider, +html[data-header-density='tiny'] .header-plan-usage .pu-provider { + font-size: 0; +} + +html[data-header-density='dense'] .header-plan-usage .pu-provider::after, +html[data-header-density='tiny'] .header-plan-usage .pu-provider::after { + content: attr(data-short); + font-size: 0.6rem; +} + +html[data-header-density='tiny'] .header-system-stats .stat-value, +html[data-header-density='tiny'] .header-plan-usage .pu-val { + display: none; +} + +body.header-divider-dragging, +body.header-divider-dragging * { + cursor: col-resize !important; + user-select: none !important; +} + +/* Only where the tabs share the header with the toolbar. */ +html[data-tab-orientation='vertical'] .header-divider, +html[data-session-list='sidebar'] .header-divider, +body.solo-mode .header-divider { + display: none; +} + +@media (max-width: 768px) { + .header-divider { + display: none; + } + + html[data-header-right-width] .header-right { + flex: 0 1 auto; + width: auto; + } } /* Connection Indicator */ diff --git a/test/header-divider.browser.test.ts b/test/header-divider.browser.test.ts new file mode 100644 index 00000000..1aa105a6 --- /dev/null +++ b/test/header-divider.browser.test.ts @@ -0,0 +1,130 @@ +/** Real Chromium: dragging the header divider resizes the toolbar and wraps it onto a second line. */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; + +const dir = resolve(import.meta.dirname, '../src/web/public'); +const styles = readFileSync(resolve(dir, 'styles.css'), 'utf8'); +const controller = readFileSync(resolve(dir, 'header-divider.js'), 'utf8'); + +const page = (root = 'data-tab-orientation="horizontal" data-session-list="header"') => ` + +
+
+
+
+
usage
+
stats
+ +
+
+`; + +describe('header divider (real Chromium)', () => { + let browser: Browser; + let p: Page; + beforeAll(async () => { + browser = await chromium.launch(); + }); + afterAll(async () => { + await browser.close(); + }); + + async function open(html: string) { + p = await browser.newPage({ viewport: { width: 1400, height: 600 } }); + await p.setContent(html); + await p.addScriptTag({ content: controller }); + } + const box = (sel: string) => p.locator(sel).evaluate((el) => el.getBoundingClientRect().toJSON()); + + it('narrows the toolbar and wraps it onto a second line when dragged right, and resets on double-click', async () => { + await open(page()); + const before = await box('#headerRight'); + const handle = await box('.header-divider'); + const y = handle.top + handle.height / 2; + await p.mouse.move(handle.left + 6, y); + await p.mouse.down(); + await p.mouse.move(handle.left + 6 + 330, y, { steps: 6 }); + await p.mouse.up(); + const after = await box('#headerRight'); + expect(after.width).toBeLessThan(before.width - 250); + expect(after.height).toBeGreaterThan(before.height + 10); + // setContent gives an opaque origin: storage is blocked, and the drag must still work. + expect(await p.evaluate(() => document.documentElement.style.getPropertyValue('--header-right-width'))).not.toBe( + '' + ); + const tabs = await box('.session-tabs-host'); + expect(tabs.width).toBeGreaterThan(before.width); + + await p.locator('.header-divider').dblclick(); + const reset = await box('#headerRight'); + expect(Math.abs(reset.width - before.width)).toBeLessThan(2); + expect(await p.evaluate(() => document.documentElement.style.getPropertyValue('--header-right-width'))).toBe(''); + await p.close(); + }); + + it('drops usage labels, then provider names, then percentages as the toolbar narrows', async () => { + const chip = + '
Claude' + + '5h49%
'; + await open(page('data-header-stats="compact" data-tab-orientation="horizontal" data-session-list="header"')); + await p.evaluate((html) => document.getElementById('headerRight')!.insertAdjacentHTML('beforeend', html), chip); + const visible = (sel: string) => p.locator(sel).isVisible(); + expect(await visible('.pu-label')).toBe(true); + const sizes: Array<[number, string]> = [ + [450, 'tight'], + [330, 'dense'], + [200, 'tiny'], + ]; + for (const [width, density] of sizes) { + const handle = await box('.header-divider'); + const header = await box('.header'); + await p.mouse.move(handle.left + 6, handle.top + handle.height / 2); + await p.mouse.down(); + await p.mouse.move(header.right - width - 12, handle.top + handle.height / 2, { steps: 5 }); + await p.mouse.up(); + expect(await p.evaluate(() => document.documentElement.dataset.headerDensity)).toBe(density); + } + expect(await visible('.pu-label')).toBe(false); + expect(await visible('.pu-val')).toBe(false); + expect(await p.locator('.pu-provider').evaluate((el) => getComputedStyle(el, '::after').content)).toBe('"CL"'); + await p.close(); + }); + + it('stops the divider at the widest reading so nothing pokes out past it', async () => { + await open(page()); + const handle = await box('.header-divider'); + const header = await box('.header'); + await p.mouse.move(handle.left + 6, handle.top + handle.height / 2); + await p.mouse.down(); + await p.mouse.move(header.right - 20, handle.top + handle.height / 2, { steps: 8 }); + await p.mouse.up(); + const right = await box('#headerRight'); + const edge = right.left + 1; + const lefts = await p + .locator('#headerRight > :not(.header-divider)') + .evaluateAll((els) => els.map((el) => el.getBoundingClientRect().left)); + for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge); + await p.close(); + }); + + it('resizes from the keyboard', async () => { + await open(page()); + const before = await box('#headerRight'); + await p.locator('.header-divider').focus(); + await p.keyboard.press('ArrowRight'); + await p.keyboard.press('ArrowRight'); + const after = await box('#headerRight'); + expect(after.width).toBeLessThan(before.width - 20); + await p.keyboard.press('Home'); + expect(Math.abs((await box('#headerRight')).width - before.width)).toBeLessThan(2); + await p.close(); + }); + + it('is hidden when the tabs live in the sidebar', async () => { + await open(page('data-tab-orientation="horizontal" data-session-list="sidebar"')); + expect(await p.locator('.header-divider').isVisible()).toBe(false); + await p.close(); + }); +}); diff --git a/test/header-divider.test.ts b/test/header-divider.test.ts new file mode 100644 index 00000000..72a988e0 --- /dev/null +++ b/test/header-divider.test.ts @@ -0,0 +1,74 @@ +/** + * @fileoverview The draggable divider at the left edge of the header toolbar: the width clamp keeps the tabs + * some room, the controller is wired into the page and build, and the stored width is device-local. + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; + +const publicDir = resolve(import.meta.dirname, '../src/web/public'); +const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8'); +const buildJs = readFileSync(resolve(import.meta.dirname, '../scripts/build.mjs'), 'utf8'); + +function load() { + const window: Record = {}; + const context = vm.createContext({ + window, + document: { readyState: 'loading', addEventListener: () => {}, querySelector: () => null }, + localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, + }); + vm.runInContext(read('header-divider.js'), context, { filename: 'header-divider.js' }); + return window.CodemanHeaderDivider as { + clampWidth: (w: number, header: number, brand: number) => number; + densityFor: (w: number) => string; + MIN_WIDTH: number; + MIN_TABS_ROOM: number; + }; +} + +describe('header divider', () => { + it('clamps the toolbar between its minimum and what leaves the tabs room', () => { + const { clampWidth, MIN_WIDTH, MIN_TABS_ROOM } = load(); + expect(clampWidth(10, 1600, 120)).toBe(MIN_WIDTH); + expect(clampWidth(5000, 1600, 120)).toBe(1600 - 120 - MIN_TABS_ROOM); + expect(clampWidth(420.4, 1600, 120)).toBe(420); + }); + + it('shows less of the usage readings the narrower the toolbar gets', () => { + const { densityFor } = load(); + expect([700, 560, 559, 400, 399, 280, 279, 160].map(densityFor)).toEqual([ + '', + '', + 'tight', + 'tight', + 'dense', + 'dense', + 'tiny', + 'tiny', + ]); + }); + + it('never goes below the minimum even in a header too narrow for the tabs room', () => { + const { clampWidth, MIN_WIDTH } = load(); + expect(clampWidth(400, 300, 100)).toBe(MIN_WIDTH); + }); + + it('is wired into the page and the build, and hides where the tabs are not in the header', () => { + expect(read('index.html')).toContain(''); + expect(buildJs).toContain("run('minify header-divider.js'"); + expect(buildJs).toMatch(/HASHABLE[\s\S]{0,1500}'header-divider\.js'/); + const css = read('styles.css'); + expect(css).toContain('.header-divider'); + expect(css).toMatch(/data-session-list='sidebar'\] \.header-divider/); + expect(css).toMatch(/data-tab-orientation='vertical'\] \.header-divider/); + }); + + it('keeps the width on the device only and offers a keyboard reset', () => { + const js = read('header-divider.js'); + expect(js).toContain('localStorage'); + expect(js).not.toContain('/api/settings'); + expect(js).toMatch(/key === 'Home'/); + expect(js).toContain("addEventListener('dblclick'"); + }); +}); From f3c90abeb0d2a36ac97ff680fdb5fe9113d30681 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:58:40 +0800 Subject: [PATCH 2/3] fix(header): settle the divider when readings change, keep density to the header layout, restore the chosen width --- docs/wiki/The-Dashboard.md | 6 + src/web/public/header-divider.js | 100 +++++++--- src/web/public/styles.css | 94 +++++---- test/header-divider.browser.test.ts | 300 ++++++++++++++++++++-------- 4 files changed, 347 insertions(+), 153 deletions(-) diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index b40ab49b..764afc94 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -178,6 +178,12 @@ The connection readout, CPU, MEM and the plan usage windows can be drawn three w Hiding System Stats or Plan Usage still hides them in every style. +The line between the session tabs and the toolbar is a handle: drag it right to narrow the toolbar +(it wraps onto a second line and the usage readings shed their labels, then their provider names, +then their percentages), left to widen it. Arrow keys resize it, and double-click or Home resets it. +It stops at the widest reading, the width is kept per device, and it is not shown on phones, in the +sidebar layout, in the vertical rail or in a solo window. + New header controls never appear on phones. Phone layout is deliberately minimal and is covered in [Mobile Guide](Mobile-Guide). diff --git a/src/web/public/header-divider.js b/src/web/public/header-divider.js index ded3ea86..33fc5603 100644 --- a/src/web/public/header-divider.js +++ b/src/web/public/header-divider.js @@ -63,9 +63,14 @@ handle.setAttribute('role', 'separator'); handle.setAttribute('aria-orientation', 'vertical'); handle.setAttribute('aria-label', 'Resize the toolbar (double-click to reset)'); + handle.setAttribute('aria-valuemin', String(MIN_WIDTH)); handle.tabIndex = 0; right.insertBefore(handle, right.firstChild); + // The width the user asked for. The applied width can be smaller (a narrow window) or larger (a reading + // that needs more room), and the request is what comes back when the window grows again. + let wanted = readStored(); + const bounds = () => ({ header: header.clientWidth, brand: brand ? brand.offsetWidth : 0 }); const current = () => right.getBoundingClientRect().width; @@ -87,20 +92,20 @@ else root.removeAttribute('data-header-density'); } - function apply(width, persist) { - if (width === null) { + /** Apply `wanted`, settled on a width no reading overflows at the density that width gets. */ + function settle() { + if (wanted === null) { root.style.removeProperty('--header-right-width'); root.removeAttribute('data-header-right-width'); root.removeAttribute('data-header-density'); handle.removeAttribute('aria-valuenow'); - if (persist) writeStored(null); + handle.removeAttribute('aria-valuemax'); return; } const { header: h, brand: b } = bounds(); - let clamped = clampWidth(width, h, b); + let clamped = clampWidth(wanted, h, b); root.setAttribute('data-header-right-width', 'set'); - // A narrower toolbar sheds labels, which shrinks the readings, which lowers the minimum: settle - // on a width no reading overflows at the density that width gets. + // A narrower toolbar sheds labels, which shrinks the readings, which lowers the minimum. for (let i = 0; i < 3; i++) { root.style.setProperty('--header-right-width', clamped + 'px'); setDensity(clamped); @@ -109,49 +114,92 @@ clamped = floor; } handle.setAttribute('aria-valuenow', String(clamped)); - if (persist) writeStored(clamped); + handle.setAttribute('aria-valuemax', String(clampWidth(Infinity, h, b))); } + /** Several changes in one frame (a chip refill, a resize) settle once. */ + let settleFrame = 0; + function scheduleSettle() { + if (wanted === null || settleFrame) return; + settleFrame = requestAnimationFrame(() => { + settleFrame = 0; + settle(); + }); + } + + /** The tab strip and the subagent lines measure the header, so they re-measure after a user resize. */ + function relayout() { + window.app?.updateTabOverflowMode?.(); + window.app?.updateConnectionLines?.(); + } + + function apply(width) { + wanted = width === null ? null : Math.round(width); + writeStored(wanted); + settle(); + relayout(); + } + + // Readings arrive after init (the plan-usage chip is filled in asynchronously, a Codex or Copilot row + // can start reporting later, a header button can be switched on): re-settle when any child changes size. + const resizeObserver = new ResizeObserver(scheduleSettle); + const watch = () => { + for (const child of right.children) if (child !== handle) resizeObserver.observe(child); + }; + watch(); + new MutationObserver(() => { + watch(); + scheduleSettle(); + }).observe(right, { childList: true }); + handle.addEventListener('pointerdown', (event) => { if (event.button !== 0) return; event.preventDefault(); handle.setPointerCapture?.(event.pointerId); document.body.classList.add('header-divider-dragging'); - const onMove = (e) => apply(header.getBoundingClientRect().right - e.clientX - 12, false); - const onUp = () => { + const padRight = parseFloat(getComputedStyle(header).paddingRight) || 0; + let moveFrame = 0; + let lastX = event.clientX; + const onMove = (e) => { + lastX = e.clientX; + if (moveFrame) return; + moveFrame = requestAnimationFrame(() => { + moveFrame = 0; + wanted = Math.round(header.getBoundingClientRect().right - lastX - padRight); + settle(); + }); + }; + const finish = () => { handle.removeEventListener('pointermove', onMove); - handle.removeEventListener('pointerup', onUp); - handle.removeEventListener('pointercancel', onUp); + handle.removeEventListener('pointerup', finish); + handle.removeEventListener('pointercancel', finish); + handle.removeEventListener('lostpointercapture', finish); + if (moveFrame) cancelAnimationFrame(moveFrame); + moveFrame = 0; document.body.classList.remove('header-divider-dragging'); - const width = Number.parseFloat(root.style.getPropertyValue('--header-right-width')); - if (Number.isFinite(width)) writeStored(width); + if (wanted !== null) apply(wanted); }; handle.addEventListener('pointermove', onMove); - handle.addEventListener('pointerup', onUp); - handle.addEventListener('pointercancel', onUp); + handle.addEventListener('pointerup', finish); + handle.addEventListener('pointercancel', finish); + handle.addEventListener('lostpointercapture', finish); }); - handle.addEventListener('dblclick', () => apply(null, true)); + handle.addEventListener('dblclick', () => apply(null)); handle.addEventListener('keydown', (event) => { if (event.key === 'Home' || event.key === 'Escape') { - apply(null, true); + apply(null); } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { - const step = event.key === 'ArrowLeft' ? KEY_STEP : -KEY_STEP; - apply(current() + step, true); + apply((wanted ?? current()) + (event.key === 'ArrowLeft' ? KEY_STEP : -KEY_STEP)); } else { return; } event.preventDefault(); }); - window.addEventListener('resize', () => { - const width = Number.parseFloat(root.style.getPropertyValue('--header-right-width')); - if (Number.isFinite(width)) apply(width, false); - }); - - const stored = readStored(); - if (stored !== null) apply(stored, false); + window.addEventListener('resize', scheduleSettle); + settle(); } window.CodemanHeaderDivider = { clampWidth, densityFor, MIN_WIDTH, MIN_TABS_ROOM }; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index a8c175fa..de886aae 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2275,47 +2275,66 @@ html[data-tab-orientation='vertical'].tab-rail-compact box-shadow: inset 0 0 0 2px var(--accent); } +/* Everything below applies only where the tabs share the header with the toolbar, i.e. where the handle is + visible to undo it: not in the sidebar layout, the vertical rail or a solo window, and not on a phone. */ + /* A width the user chose: the toolbar keeps it and wraps onto a second line when its content is wider. */ -html[data-header-right-width]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) - body:not(.solo-mode) - .header-right { - flex: 0 0 var(--header-right-width); - width: var(--header-right-width); - flex-wrap: wrap; - justify-content: flex-end; - align-content: center; - row-gap: 0.25rem; - min-height: var(--header-height); -} +@media (min-width: 769px) { + html[data-header-right-width]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) + body:not(.solo-mode) + .header-right { + flex: 0 0 var(--header-right-width); + width: var(--header-right-width); + flex-wrap: wrap; + justify-content: flex-end; + align-content: center; + row-gap: 0.25rem; + min-height: var(--header-height); + } -/* The narrower the toolbar, the less each usage reading shows (data-header-density, header-divider.js): - tight drops the window and CPU/MEM labels, dense shortens the provider names, tiny keeps only the rings. - The same rules apply under every header-stats style. */ -html[data-header-density] .header-system-stats .stat-label, -html[data-header-density] .header-plan-usage .pu-label { - display: none; -} + /* The narrower the toolbar, the less each usage reading shows (data-header-density, header-divider.js): + tight drops the window and CPU/MEM labels, dense shortens the provider names, tiny keeps only the rings. + The prefix outweighs the per-style rules further down, so every header-stats style follows it. */ + html[data-header-density]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) + body:not(.solo-mode) + :is(.header-system-stats .stat-label, .header-plan-usage .pu-label) { + display: none; + } -html[data-header-density] .header-system-stats, -html[data-header-density] .header-plan-usage { - gap: 8px; - padding: 0 8px; -} + html[data-header-density]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) + body:not(.solo-mode) + :is(.header-system-stats, .header-plan-usage) { + gap: 8px; + padding: 0 8px; + } -html[data-header-density='dense'] .header-plan-usage .pu-provider, -html[data-header-density='tiny'] .header-plan-usage .pu-provider { - font-size: 0; -} + html:is([data-header-density='dense'], [data-header-density='tiny']):not([data-tab-orientation='vertical']):not( + [data-session-list='sidebar'] + ) + body:not(.solo-mode) + .header-plan-usage + .pu-provider { + font-size: 0; + } -html[data-header-density='dense'] .header-plan-usage .pu-provider::after, -html[data-header-density='tiny'] .header-plan-usage .pu-provider::after { - content: attr(data-short); - font-size: 0.6rem; -} + html:is([data-header-density='dense'], [data-header-density='tiny']):not([data-tab-orientation='vertical']):not( + [data-session-list='sidebar'] + ) + body:not(.solo-mode) + .header-plan-usage + .pu-provider::after { + content: attr(data-short); + font-size: 0.6rem; + } -html[data-header-density='tiny'] .header-system-stats .stat-value, -html[data-header-density='tiny'] .header-plan-usage .pu-val { - display: none; + /* Rings-only is a Compact-style reading: the other styles have no rings, so they keep their values. */ + html[data-header-stats='compact'][data-header-density='tiny']:not([data-tab-orientation='vertical']):not( + [data-session-list='sidebar'] + ) + body:not(.solo-mode) + :is(.header-system-stats .stat-value, .header-plan-usage .pu-val) { + display: none; + } } body.header-divider-dragging, @@ -2335,11 +2354,6 @@ body.solo-mode .header-divider { .header-divider { display: none; } - - html[data-header-right-width] .header-right { - flex: 0 1 auto; - width: auto; - } } /* Connection Indicator */ diff --git a/test/header-divider.browser.test.ts b/test/header-divider.browser.test.ts index 1aa105a6..7f12f298 100644 --- a/test/header-divider.browser.test.ts +++ b/test/header-divider.browser.test.ts @@ -1,4 +1,8 @@ -/** Real Chromium: dragging the header divider resizes the toolbar and wraps it onto a second line. */ +/** + * Real Chromium, against the real header markup from index.html and the real styles.css: dragging the divider + * resizes the toolbar and wraps it onto a second line, the readings shed detail as it narrows, nothing pokes + * out past the divider (even for content that arrives later), and none of it applies where the handle is hidden. + */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -7,19 +11,28 @@ import { chromium, type Browser, type Page } from 'playwright'; const dir = resolve(import.meta.dirname, '../src/web/public'); const styles = readFileSync(resolve(dir, 'styles.css'), 'utf8'); const controller = readFileSync(resolve(dir, 'header-divider.js'), 'utf8'); +const header = /
[\s\S]*?<\/header>/.exec(readFileSync(resolve(dir, 'index.html'), 'utf8'))![0]; -const page = (root = 'data-tab-orientation="horizontal" data-session-list="header"') => ` - -
-
-
-
-
usage
-
stats
- -
-
-`; +const CHIP = + 'Claude' + + '5h49%' + + '7d72%' + + 'Copilot' + + 'mo81%'; + +const ORIGIN = 'http://app.test/'; + +/** The default layout attributes, with any `name="value"` in `extra` replacing the matching default. */ +function rootAttrs(extra = '') { + const attrs = new Map([ + ['data-header-stats', 'compact'], + ['data-tab-orientation', 'horizontal'], + ['data-session-list', 'header'], + ]); + for (const [, name, value] of extra.matchAll(/([\w-]+)="([^"]*)"/g)) attrs.set(name, value); + return [...attrs].map(([name, value]) => `${name}="${value}"`).join(' '); +} +type Attrs = { root?: string; body?: string }; describe('header divider (real Chromium)', () => { let browser: Browser; @@ -31,100 +44,213 @@ describe('header divider (real Chromium)', () => { await browser.close(); }); - async function open(html: string) { - p = await browser.newPage({ viewport: { width: 1400, height: 600 } }); - await p.setContent(html); + /** Open the real header; `stored` is the width kept from an earlier visit, `chip` fills the plan-usage chip. */ + async function open(options: { root?: string; body?: string; stored?: number; chip?: boolean; width?: number } = {}) { + p = await browser.newPage({ viewport: { width: options.width ?? 1400, height: 600 } }); + await p.route(ORIGIN, (route) => + route.fulfill({ + contentType: 'text/html', + body: `${header}`, + }) + ); + await p.goto(ORIGIN); + if (options.stored) + await p.evaluate((w) => localStorage.setItem('codeman-header-right-width', String(w)), options.stored); + if (options.chip) { + await p.evaluate((html) => { + const chip = document.getElementById('planUsageChip')!; + chip.classList.remove('header-plan-usage--hidden'); + chip.innerHTML = html; + }, CHIP); + } await p.addScriptTag({ content: controller }); } const box = (sel: string) => p.locator(sel).evaluate((el) => el.getBoundingClientRect().toJSON()); - - it('narrows the toolbar and wraps it onto a second line when dragged right, and resets on double-click', async () => { - await open(page()); - const before = await box('#headerRight'); + const settled = () => p.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); + const dragTo = async (toolbarWidth: number) => { const handle = await box('.header-divider'); - const y = handle.top + handle.height / 2; - await p.mouse.move(handle.left + 6, y); + const head = await box('.header'); + await p.mouse.move(handle.left + 6, handle.top + 10); await p.mouse.down(); - await p.mouse.move(handle.left + 6 + 330, y, { steps: 6 }); + await p.mouse.move(head.right - 12 - toolbarWidth, handle.top + 10, { steps: 6 }); await p.mouse.up(); - const after = await box('#headerRight'); - expect(after.width).toBeLessThan(before.width - 250); - expect(after.height).toBeGreaterThan(before.height + 10); - // setContent gives an opaque origin: storage is blocked, and the drag must still work. - expect(await p.evaluate(() => document.documentElement.style.getPropertyValue('--header-right-width'))).not.toBe( - '' - ); - const tabs = await box('.session-tabs-host'); - expect(tabs.width).toBeGreaterThan(before.width); - - await p.locator('.header-divider').dblclick(); - const reset = await box('#headerRight'); - expect(Math.abs(reset.width - before.width)).toBeLessThan(2); - expect(await p.evaluate(() => document.documentElement.style.getPropertyValue('--header-right-width'))).toBe(''); - await p.close(); - }); - - it('drops usage labels, then provider names, then percentages as the toolbar narrows', async () => { - const chip = - '
Claude' + - '5h49%
'; - await open(page('data-header-stats="compact" data-tab-orientation="horizontal" data-session-list="header"')); - await p.evaluate((html) => document.getElementById('headerRight')!.insertAdjacentHTML('beforeend', html), chip); - const visible = (sel: string) => p.locator(sel).isVisible(); - expect(await visible('.pu-label')).toBe(true); - const sizes: Array<[number, string]> = [ - [450, 'tight'], - [330, 'dense'], - [200, 'tiny'], - ]; - for (const [width, density] of sizes) { - const handle = await box('.header-divider'); - const header = await box('.header'); - await p.mouse.move(handle.left + 6, handle.top + handle.height / 2); - await p.mouse.down(); - await p.mouse.move(header.right - width - 12, handle.top + handle.height / 2, { steps: 5 }); - await p.mouse.up(); - expect(await p.evaluate(() => document.documentElement.dataset.headerDensity)).toBe(density); - } - expect(await visible('.pu-label')).toBe(false); - expect(await visible('.pu-val')).toBe(false); - expect(await p.locator('.pu-provider').evaluate((el) => getComputedStyle(el, '::after').content)).toBe('"CL"'); - await p.close(); - }); - - it('stops the divider at the widest reading so nothing pokes out past it', async () => { - await open(page()); - const handle = await box('.header-divider'); - const header = await box('.header'); - await p.mouse.move(handle.left + 6, handle.top + handle.height / 2); - await p.mouse.down(); - await p.mouse.move(header.right - 20, handle.top + handle.height / 2, { steps: 8 }); - await p.mouse.up(); - const right = await box('#headerRight'); - const edge = right.left + 1; + await settled(); + }; + const readingsInside = async () => { + const edge = (await box('#headerRight')).left + 1; const lefts = await p .locator('#headerRight > :not(.header-divider)') - .evaluateAll((els) => els.map((el) => el.getBoundingClientRect().left)); - for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge); + .evaluateAll((els) => + els.filter((el) => (el as HTMLElement).offsetParent !== null).map((el) => el.getBoundingClientRect().left) + ); + return { edge, lefts }; + }; + const display = (sel: string) => + p + .locator(sel) + .first() + .evaluate((el) => getComputedStyle(el).display); + + it('narrows the toolbar and wraps it when dragged right, and resets on double-click', async () => { + await open({ chip: true }); + const before = await box('#headerRight'); + await dragTo(330); + const after = await box('#headerRight'); + expect(after.width).toBeLessThan(before.width - 100); + expect(after.height).toBeGreaterThan(before.height + 10); + expect((await box('.session-tabs-host')).width).toBeGreaterThan(before.width / 2); + expect(await p.evaluate(() => localStorage.getItem('codeman-header-right-width'))).not.toBeNull(); + await p.locator('.header-divider').dblclick(); + expect(Math.abs((await box('#headerRight')).width - before.width)).toBeLessThan(2); + expect(await p.evaluate(() => localStorage.getItem('codeman-header-right-width'))).toBeNull(); await p.close(); }); - it('resizes from the keyboard', async () => { - await open(page()); + it('resizes from the keyboard and tells the tab strip to re-measure', async () => { + await open({ chip: true }); + await p.evaluate(() => { + (window as any).relayouts = 0; + (window as any).app = { + updateTabOverflowMode: () => (window as any).relayouts++, + updateConnectionLines: () => {}, + }; + }); const before = await box('#headerRight'); await p.locator('.header-divider').focus(); await p.keyboard.press('ArrowRight'); await p.keyboard.press('ArrowRight'); - const after = await box('#headerRight'); - expect(after.width).toBeLessThan(before.width - 20); + expect((await box('#headerRight')).width).toBeLessThan(before.width - 20); + expect(await p.evaluate(() => (window as any).relayouts)).toBeGreaterThanOrEqual(2); await p.keyboard.press('Home'); expect(Math.abs((await box('#headerRight')).width - before.width)).toBeLessThan(2); await p.close(); }); - it('is hidden when the tabs live in the sidebar', async () => { - await open(page('data-tab-orientation="horizontal" data-session-list="sidebar"')); - expect(await p.locator('.header-divider').isVisible()).toBe(false); + it('stops the divider at the widest reading so nothing pokes out past it', async () => { + await open({ chip: true }); + await dragTo(20); + const { edge, lefts } = await readingsInside(); + for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge); + await p.close(); + }); + + it('keeps a reading inside the divider when it arrives after the width was applied', async () => { + await open({ stored: 160 }); + await settled(); + await p.evaluate((html) => { + const chip = document.getElementById('planUsageChip')!; + chip.classList.remove('header-plan-usage--hidden'); + chip.innerHTML = html; + }, CHIP); + await settled(); + const { edge, lefts } = await readingsInside(); + for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge); + await p.close(); + }); + + it('keeps a wide reading that is switched on later inside the divider', async () => { + await open({ stored: 160 }); + await settled(); + await p.evaluate(() => { + const wide = document.createElement('div'); + wide.id = 'lateReading'; + wide.style.cssText = 'width:320px;flex:none;height:24px'; + document.getElementById('headerRight')!.appendChild(wide); + }); + await settled(); + const { edge, lefts } = await readingsInside(); + for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge); + expect((await box('#headerRight')).width).toBeGreaterThanOrEqual(320); + await p.close(); + }); + + it('sheds labels, then provider names, then percentages as the toolbar narrows', async () => { + await open({ chip: true }); + expect(await display('.pu-label')).not.toBe('none'); + const seen: string[] = []; + for (const width of [450, 330, 200]) { + await dragTo(width); + seen.push((await p.evaluate(() => document.documentElement.dataset.headerDensity)) ?? ''); + } + expect(seen).toEqual(['tight', 'dense', 'tiny']); + expect(await display('.pu-label')).toBe('none'); + expect(await display('.pu-val')).toBe('none'); + expect( + await p + .locator('.pu-provider') + .first() + .evaluate((el) => getComputedStyle(el, '::after').content) + ).toBe('"CL"'); + await p.close(); + }); + + for (const style of ['tiles', 'classic']) { + it(`follows the density steps under the ${style} style, keeping values at the narrowest step`, async () => { + await open({ chip: true, root: `data-header-stats="${style}"` }); + await dragTo(330); + expect( + await p + .locator('.pu-provider') + .first() + .evaluate((el) => getComputedStyle(el).fontSize) + ).toBe('0px'); + await dragTo(200); + expect(await display('.pu-val')).not.toBe('none'); + await p.close(); + }); + } + + for (const [name, root, body] of [ + ['the sidebar layout', 'data-session-list="sidebar"', ''], + ['the vertical rail', 'data-tab-orientation="vertical"', ''], + ['a solo window', '', 'solo-mode'], + ] as const) { + it(`leaves a stored narrow width alone in ${name}, where the handle is hidden`, async () => { + await open({ chip: true, stored: 180, root, body }); + // the stored width is read at init, which already ran inside open() before storage was set + await p.evaluate(() => localStorage.setItem('codeman-header-right-width', '180')); + await p.reload(); + await p.addScriptTag({ content: controller }); + await settled(); + expect(await p.locator('.header-divider').isVisible()).toBe(false); + expect(await display('.stat-label')).not.toBe('none'); + expect(await p.locator('.header-right').evaluate((el) => getComputedStyle(el).flexBasis)).not.toBe('180px'); + await p.close(); + }); + } + + it('does not pin the toolbar to a stored width on a narrow viewport', async () => { + await open({ chip: true, width: 700 }); + await p.evaluate(() => localStorage.setItem('codeman-header-right-width', '300')); + await p.reload(); + await p.addScriptTag({ content: controller }); + await settled(); + expect(await p.locator('.header-right').evaluate((el) => getComputedStyle(el).flexBasis)).not.toBe('300px'); + await p.close(); + }); + + it('gets the chosen width back when the window shrinks and grows again', async () => { + await open({ chip: true }); + await dragTo(700); + const chosen = (await box('#headerRight')).width; + await p.setViewportSize({ width: 1000, height: 600 }); + await settled(); + expect((await box('#headerRight')).width).toBeLessThan(chosen); + await p.setViewportSize({ width: 1400, height: 600 }); + await settled(); + expect(Math.abs((await box('#headerRight')).width - chosen)).toBeLessThan(2); + await p.close(); + }); + + it('clears the dragging state when the pointer capture is lost', async () => { + await open({ chip: true }); + const handle = await box('.header-divider'); + await p.mouse.move(handle.left + 6, handle.top + 10); + await p.mouse.down(); + expect(await p.evaluate(() => document.body.classList.contains('header-divider-dragging'))).toBe(true); + await p.locator('.header-divider').evaluate((el) => el.dispatchEvent(new PointerEvent('lostpointercapture'))); + expect(await p.evaluate(() => document.body.classList.contains('header-divider-dragging'))).toBe(false); + await p.mouse.up(); await p.close(); }); }); From 34a2966c19237a3a5b199bae38ffdda3e143da8d Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 24 Oct 2026 14:08:37 +0200 Subject: [PATCH 3/3] fix: apply the review items for #689 at merge Clamp the stored toolbar width to MIN_WIDTH in apply() and step the arrow keys from the width on screen, so a drag to the window edge or a held ArrowRight no longer stores a negative width or leaves an ArrowLeft dead zone (browser test added) Drop Escape as a reset key on the handle: the global capture-phase Escape handler already closes every panel on it (static pin added) Say in the changeset, the wiki and the densityFor comment that the percentages go at the narrowest step in the Compact style only, and drop Escape from the changeset Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/header-divider.md | 2 +- docs/wiki/The-Dashboard.md | 6 +++--- src/web/public/header-divider.js | 11 ++++++----- test/header-divider.browser.test.ts | 22 ++++++++++++++++++++++ test/header-divider.test.ts | 2 ++ 5 files changed, 34 insertions(+), 9 deletions(-) diff --git a/.changeset/header-divider.md b/.changeset/header-divider.md index 2ff36cb9..6868a3a1 100644 --- a/.changeset/header-divider.md +++ b/.changeset/header-divider.md @@ -2,4 +2,4 @@ 'aicodeman': minor --- -Drag the line between the session tabs and the header toolbar to resize the toolbar. Dragging it right narrows the toolbar, which then wraps onto a second line instead of squeezing out the tabs, and the usage readings shed their labels, then their provider names, then their percentages as it narrows; double-click, Home or Escape resets it. Arrow keys resize it from the keyboard, and the width is remembered on the device. +Drag the line between the session tabs and the header toolbar to resize the toolbar. Dragging it right narrows the toolbar, which then wraps onto a second line instead of squeezing out the tabs, and the usage readings shed their labels, then their provider names, then (in the Compact style) their percentages as it narrows; double-click or Home resets it. Arrow keys resize it from the keyboard, and the width is remembered on the device. diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index 764afc94..db23eb42 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -180,9 +180,9 @@ Hiding System Stats or Plan Usage still hides them in every style. The line between the session tabs and the toolbar is a handle: drag it right to narrow the toolbar (it wraps onto a second line and the usage readings shed their labels, then their provider names, -then their percentages), left to widen it. Arrow keys resize it, and double-click or Home resets it. -It stops at the widest reading, the width is kept per device, and it is not shown on phones, in the -sidebar layout, in the vertical rail or in a solo window. +then, in the Compact style, their percentages), left to widen it. Arrow keys resize it, and +double-click or Home resets it. It stops at the widest reading, the width is kept per device, and it +is not shown on phones, in the sidebar layout, in the vertical rail or in a solo window. New header controls never appear on phones. Phone layout is deliberately minimal and is covered in [Mobile Guide](Mobile-Guide). diff --git a/src/web/public/header-divider.js b/src/web/public/header-divider.js index 33fc5603..0309bf62 100644 --- a/src/web/public/header-divider.js +++ b/src/web/public/header-divider.js @@ -23,8 +23,8 @@ /** * How much of the usage readings to show for a toolbar this wide. The narrower the toolbar, the more of - * the labels go: window labels first, then provider names (shortened), then the percentages (the rings - * stay, and the chip's tooltip keeps the numbers). + * the labels go: window labels first, then provider names (shortened), then, in the Compact style, the + * percentages (the rings stay, and the chip's tooltip keeps the numbers). */ function densityFor(width) { if (width >= 560) return ''; @@ -134,7 +134,7 @@ } function apply(width) { - wanted = width === null ? null : Math.round(width); + wanted = width === null ? null : Math.max(MIN_WIDTH, Math.round(width)); writeStored(wanted); settle(); relayout(); @@ -188,10 +188,11 @@ handle.addEventListener('dblclick', () => apply(null)); handle.addEventListener('keydown', (event) => { - if (event.key === 'Home' || event.key === 'Escape') { + if (event.key === 'Home') { apply(null); } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { - apply((wanted ?? current()) + (event.key === 'ArrowLeft' ? KEY_STEP : -KEY_STEP)); + // Step from the width on screen, not the request, so a key held past the floor never leaves a dead zone. + apply(current() + (event.key === 'ArrowLeft' ? KEY_STEP : -KEY_STEP)); } else { return; } diff --git a/test/header-divider.browser.test.ts b/test/header-divider.browser.test.ts index 7f12f298..ebf756eb 100644 --- a/test/header-divider.browser.test.ts +++ b/test/header-divider.browser.test.ts @@ -126,6 +126,28 @@ describe('header divider (real Chromium)', () => { await p.close(); }); + it('never stores a width below the minimum, and widens on the first ArrowLeft after a held ArrowRight', async () => { + await open({ chip: true }); + const stored = () => p.evaluate(() => Number(localStorage.getItem('codeman-header-right-width'))); + // a drag into the window's last pixels used to store a negative width, which the next load dropped + await dragTo(-6); + expect(await stored()).toBeGreaterThanOrEqual(160); + await p.locator('.header-divider').dblclick(); + await p.locator('.header-divider').focus(); + for (let i = 0; i < 60; i++) await p.keyboard.press('ArrowRight'); + await settled(); + expect(await stored()).toBeGreaterThanOrEqual(160); + const floor = (await box('#headerRight')).width; + // Escape is the global close-panels key, not a reset + await p.keyboard.press('Escape'); + await settled(); + expect(Math.abs((await box('#headerRight')).width - floor)).toBeLessThan(2); + await p.keyboard.press('ArrowLeft'); + await settled(); + expect((await box('#headerRight')).width).toBeGreaterThan(floor); + await p.close(); + }); + it('stops the divider at the widest reading so nothing pokes out past it', async () => { await open({ chip: true }); await dragTo(20); diff --git a/test/header-divider.test.ts b/test/header-divider.test.ts index 72a988e0..46d154e4 100644 --- a/test/header-divider.test.ts +++ b/test/header-divider.test.ts @@ -69,6 +69,8 @@ describe('header divider', () => { expect(js).toContain('localStorage'); expect(js).not.toContain('/api/settings'); expect(js).toMatch(/key === 'Home'/); + // Escape belongs to the global close-panels handler (app.js), so the handle must not claim it too. + expect(js).not.toMatch(/'Escape'/); expect(js).toContain("addEventListener('dblclick'"); }); });