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/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/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..33fc56035 --- /dev/null +++ b/src/web/public/header-divider.js @@ -0,0 +1,208 @@ +/** + * @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.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; + + /** 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'); + } + + /** 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'); + handle.removeAttribute('aria-valuemax'); + return; + } + const { header: h, brand: b } = bounds(); + 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. + 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)); + 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 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', finish); + handle.removeEventListener('pointercancel', finish); + handle.removeEventListener('lostpointercapture', finish); + if (moveFrame) cancelAnimationFrame(moveFrame); + moveFrame = 0; + document.body.classList.remove('header-divider-dragging'); + if (wanted !== null) apply(wanted); + }; + handle.addEventListener('pointermove', onMove); + handle.addEventListener('pointerup', finish); + handle.addEventListener('pointercancel', finish); + handle.addEventListener('lostpointercapture', finish); + }); + + handle.addEventListener('dblclick', () => apply(null)); + + handle.addEventListener('keydown', (event) => { + if (event.key === 'Home' || event.key === 'Escape') { + apply(null); + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + apply((wanted ?? current()) + (event.key === 'ArrowLeft' ? KEY_STEP : -KEY_STEP)); + } else { + return; + } + event.preventDefault(); + }); + + window.addEventListener('resize', scheduleSettle); + settle(); + } + + 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 @@