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