Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/header-divider.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions config/test-suites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions docs/wiki/The-Dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 2 additions & 0 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 3 additions & 1 deletion src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -3680,7 +3680,9 @@ class CodemanApp {
seg('mo', pct(usage?.monthly), false),
].filter(Boolean);
if (!windows.length) return '';
const label = labelled ? `<span class="pu-provider">${provider}</span>` : '';
// 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 ? `<span data-short="${short}" class="pu-provider">${provider}</span>` : '';
return `<span class="pu-row">${label}<span class="pu-windows">${windows.join('<span class="pu-sep">·</span>')}</span></span>`;
};
const rows = [row('Claude', data, true), row('Codex', data.codex, false), row('Copilot', data.copilot, false)].filter(
Expand Down
208 changes: 208 additions & 0 deletions src/web/public/header-divider.js
Original file line number Diff line number Diff line change
@@ -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();
})();
1 change: 1 addition & 0 deletions src/web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4345,6 +4345,7 @@ <h3><span aria-hidden="true">🧠</span> Read My Mind</h3>
<script defer src="tab-layout-browser.js"></script>
<script defer src="app.js"></script>
<script defer src="tab-rail-resize.js"></script>
<script defer src="header-divider.js"></script>
<script defer src="terminal-ui.js"></script>
<script defer src="terminal-tile.js"></script>
<script defer src="terminal-split.js"></script>
Expand Down
105 changes: 105 additions & 0 deletions src/web/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2249,6 +2249,111 @@ 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);
}

/* 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. */
@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 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]: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: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: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;
}

/* 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,
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;
}
}

/* Connection Indicator */
Expand Down
Loading
Loading