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/2] 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 000000000..2ff36cb9e --- /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 ae866007e..864d10270 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 65db3881e..d949fcad4 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 033838544..b955c847b 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 000000000..ded3ea86b --- /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 f3a2b2a27..90a262da1 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -4345,6 +4345,7 @@

Read My Mind

+ diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 65755b90e..a8c175fa7 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 000000000..1aa105a63 --- /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 000000000..72a988e0c --- /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/2] 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 | 294 ++++++++++++++++++++-------- 4 files changed, 344 insertions(+), 150 deletions(-) diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index b40ab49bd..764afc94f 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 ded3ea86b..33fc56035 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 a8c175fa7..de886aae4 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 1aa105a63..7f12f2980 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(); + await settled(); + }; + const readingsInside = async () => { + const edge = (await box('#headerRight')).left + 1; + const lefts = await p + .locator('#headerRight > :not(.header-divider)') + .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 - 250); + expect(after.width).toBeLessThan(before.width - 100); 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); - + 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(); - 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(''); + 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('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"'); + 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'); + 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('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)); + 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('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); + 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('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('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(); }); });