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"') => ` +
+