Skip to content

feat(header): resizable toolbar divider that collapses the usage readings as it narrows - #689

Merged
Ark0N merged 2 commits into
Ark0N:masterfrom
opticon454:feature/header-resizable-divider
Oct 24, 2026
Merged

Ark0N merged 2 commits into
Ark0N:masterfrom
opticon454:feature/header-resizable-divider

Conversation

@opticon454

Copy link
Copy Markdown
Contributor

What

The line between the session tabs and the header toolbar is now a handle you can drag. Dragging it right narrows the toolbar, which wraps onto a second line, and the usage readings shed detail as it narrows, so the tabs get the room back.

Why

With the default compact header style the toolbar (WS, CPU, MEM, the Claude, Codex and Copilot plan-usage pills, then the buttons) takes a large share of the top bar, and the tabs get whatever is left. There was no way to trade one for the other short of hiding things in Settings.

How

  • src/web/public/header-divider.js (new, self-contained): an absolutely positioned handle on the left edge of #headerRight, so it takes no layout room and the existing border stays the visible line. Pointer drag, arrow keys (Left widens, Right narrows), double-click or Home or Escape to reset. The width is a device-local convenience in localStorage (try/catch, so a blocked store only costs the memory), not a setting.
  • It cannot be dragged past the widest reading: the minimum is measured from the toolbar's children at the density that width gets, so a pill can never poke out past the divider. The upper bound leaves the tabs 240px.
  • The narrower the toolbar, the less each usage reading shows (data-header-density on <html>): below 560px the window and CPU/MEM labels go, below 400px the provider names shorten to CL, CX and CP (data-short on .pu-provider), below 280px only the rings are left. The chip's tooltip keeps the numbers. The rules do not depend on the header-stats style.
  • Hidden where the tabs are not in the header: the sidebar layout, the vertical rail, solo windows and phones.
  • index.html, scripts/build.mjs (minify and hashed file list) and a changeset.

Screenshots

Full width (nothing changed):

Full width

Narrowed: labels gone, percentages kept:

Narrowed

Narrowest: rings only:

Narrowest

Tests

  • test/header-divider.test.ts: the width clamp, the density steps, wiring into the page and build, device-local storage.
  • test/header-divider.browser.test.ts (real Chromium, listed in BROWSER_TEST_GLOBS, run with npm run test:browser): a drag right narrows the toolbar and wraps it to two rows, the tabs keep their room, double-click and keyboard reset it, the labels, names and percentages drop in turn, it stops at the widest reading (this one fails without the measurement), and it is hidden in the sidebar layout.
  • npm run lint, tsc --noEmit, check:frontend-syntax, prettier and the related plan-usage and tab-rail tests pass.

@Ark0N

Ark0N commented Oct 23, 2026

Copy link
Copy Markdown
Owner

Thanks @opticon454, this is a nice idea and a tidy implementation: the line between the tabs and the toolbar becomes a handle, and the usage readings shed detail as the toolbar narrows so the tabs get the room back. Typecheck, lint, format, the frontend checks and both of your test files pass here.

I ran the controller against the real styles.css and the real <header> block from index.html in Chromium, and a few cases fall outside what the browser fixture covers. These need changes before merge:

  1. Content that grows after the width is applied pokes out past the divider (src/web/public/header-divider.js:154). The floor from contentMinimum() is only re-checked on a drag, a key press or a window resize. With a stored width of 160 and the plan-usage chip filled in after init (it is populated asynchronously), the chip is 195px wide in a 160px toolbar and sits 35px over the tabs. The same thing happens when a Codex or Copilot row starts reporting later, or a header button is switched on. Please re-run the settle (apply(current, false)) when the toolbar's content changes, for example with a ResizeObserver on #headerRight's children, coalesced to one pass per frame.

  2. Density still applies where the handle is hidden (src/web/public/styles.css:2294). The width rule is guarded to the header layout, but the html[data-header-density] ... selectors are not, and apply() sets the attribute from the stored width in every layout. With a stored narrow width, the sidebar layout, the vertical rail and a solo window all hide the CPU/MEM labels and values, and there is no handle there to undo it. Please give the density selectors the same :not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) body:not(.solo-mode) guard.

  3. The narrow-viewport reset never wins (src/web/public/styles.css:2339). html[data-header-right-width] .header-right is specificity (0,2,1) and the rule it undoes is (0,5,2), so at 700px wide the toolbar keeps flex: 0 0 300px with the handle hidden. Wrapping the fixed-width rule and the density rules in @media (min-width: 769px) (and dropping the reset) fixes this and item 2's phone case together.

  4. Tests: please build the browser fixture from the real header markup and add cases for the three items above (chip injected after init, sidebar, rail and solo with a stored width, a 700px viewport). Every one of them passes the current fixture.

Smaller things, fine in the same round:

  • Under the tiles header style the density rules lose to the later tiles rules (same specificity), so nothing is hidden, and at dense the provider reads CLAUDECL / CODEXCX because the ::after short name is appended to the full one (styles.css:2305). Under classic, tiny density leaves the chip with no reading at all, since classic has no rings (styles.css:2316). Please make the density block win under every style and cap classic at dense (or keep .pu-val there).
  • The resize listener re-applies the already-clamped width, so shrinking the window and growing it back does not restore the chosen width until a reload (header-divider.js:148). Re-apply readStored() instead, debounced like tab-rail-resize.js.
  • After a drag the tab strip's wrap mode is not re-measured (updateTabOverflowMode() runs on tab renders and window resize only), so widening the toolbar can leave tabs in the horizontal scroller until the next render. Calling window.app?.updateTabOverflowMode?.() and window.app?.updateConnectionLines?.() at the end of a drag and after a key resize covers it (header-divider.js:121).
  • tab-rail-resize.js is a good template for the drag itself: it handles lostpointercapture (here a lost capture leaves body.header-divider-dragging on) and coalesces pointer moves with requestAnimationFrame. The - 12 in onMove could read the header's padding from getComputedStyle, and the separator could carry aria-valuemin/aria-valuemax like the rail's handle.
  • One sentence about the handle in docs/wiki/The-Dashboard.md under Header Stats Style would make it discoverable.

Once the numbered items are in I will take another look and merge. Thanks again for the contribution.

… the header layout, restore the chosen width
@opticon454

Copy link
Copy Markdown
Contributor Author

Thanks @Ark0N, all four numbered items and the smaller ones are in f3c90abe.

  1. Late content. The controller keeps the width the user asked for and re-settles it whenever the toolbar's content changes: a ResizeObserver on each child of #headerRight (re-attached by a MutationObserver when children are added), one pass per frame. A reading that arrives after init, such as a chip filled in later or a row that starts reporting, can no longer sit over the tabs.
  2. Density outside the header layout. The density rules carry the same guard as the width rule (:not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) body:not(.solo-mode)), so a stored narrow width changes nothing in the sidebar layout, the vertical rail or a solo window.
  3. Narrow viewport. The width rule and the density rules now sit in @media (min-width: 769px) and the reset is gone.
  4. Tests. test/header-divider.browser.test.ts now builds its page from the real <header> block of index.html and the real styles.css, served from an origin so localStorage works. New cases: a reading appended after init, a stored width in the sidebar layout, the vertical rail and a solo window, a 700px viewport, the tiles and classic styles, and the shrink-and-grow restore. Run against the previous controller and styles, nine of the cases fail.

Smaller things:

  • Tiles and classic. The density block wins under every style (its prefix outweighs the per-style rules), so the provider reads CL, not CLAUDECL. Rings-only is Compact only: classic and tiles keep their values at the narrowest step.
  • Resize. The requested width is kept apart from the applied one, so shrinking the window and growing it back restores it. Stored widths are the request, not the clamp.
  • Tab strip. After a drag, a key resize and a reset the controller calls app.updateTabOverflowMode() and app.updateConnectionLines() when they exist.
  • Drag. lostpointercapture ends the drag, pointer moves are coalesced with requestAnimationFrame, the offset is the header's computed right padding, and the separator carries aria-valuemin and aria-valuemax.
  • Docs. A paragraph about the handle under Header Stats Style in docs/wiki/The-Dashboard.md.

Typecheck, frontend syntax, prettier, the unit tests and the Chromium suite (divider, tab rail and sidebar files) pass here.

@Ark0N

Ark0N commented Oct 23, 2026

Copy link
Copy Markdown
Owner

Thanks @opticon454 for the second round. The divider between the tabs and the toolbar now holds up in every case I raised: late readings stay inside it, nothing changes in the sidebar, rail or a solo window, the narrow viewport is left alone, and the tiles and classic styles follow the density steps. All checks, both of your test files and the full suite pass here and in CI.

Probing the edges turned up one bug, which I will fix on top when I merge:

  1. The stored width can go negative (src/web/public/header-divider.js:137). apply() stores the request unclamped, so dragging the handle into the window's last ~12px (:168) or holding ArrowRight (:194) stores a negative number, and readStored() (:39) drops it on the next load, so the toolbar comes back at full width. Holding ArrowRight also leaves a dead zone: ArrowLeft does nothing until the request climbs back past the floor. The fix I will apply:
// apply()
wanted = width === null ? null : Math.max(MIN_WIDTH, Math.round(width));
// keydown: step from the width on screen
apply(current() + (event.key === 'ArrowLeft' ? KEY_STEP : -KEY_STEP));

Small things I will also touch at merge:

  • Escape on the handle resets the width and, through the global Escape handler (app.js:1437), closes every open panel too. The wiki and the file's overview only list double-click and Home, so I will drop Escape (header-divider.js:191).
  • The changeset and wiki say the percentages go at the narrowest step; that holds for the Compact style only, so I will say so (.changeset/header-divider.md:5, docs/wiki/The-Dashboard.md:182).

Not blocking, noted as follow-ups:

  • At a few chosen widths a reading tick (CPU 9% to 10%) moves an item onto a new row, so the header and the terminal's row count change with the reading (styles.css:2283). A fixed min-width in ch on .stat-value and .pu-val under data-header-right-width would pin the wrap points.
  • settle() (header-divider.js:109) does not apply the last floor it measures, so a reading whose width climbs across three density steps ends up past the divider. Today's readings do not climb like that.
  • Under Classic, shortening the provider names frees no room because Classic pins .pu-provider to 46px (styles.css:14576).
  • The separator drops aria-valuenow at the default width (header-divider.js:101), and its label has no zh-CN entry.

Thanks again, this is a nice addition to the header.

@Ark0N
Ark0N merged commit f3c90ab into Ark0N:master Oct 24, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Oct 24, 2026
Clamp the stored toolbar width to MIN_WIDTH in apply() and step the arrow keys from the width on screen, so a drag to the window edge or a held ArrowRight no longer stores a negative width or leaves an ArrowLeft dead zone (browser test added)
Drop Escape as a reset key on the handle: the global capture-phase Escape handler already closes every panel on it (static pin added)
Say in the changeset, the wiki and the densityFor comment that the percentages go at the narrowest step in the Compact style only, and drop Escape from the changeset

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 24, 2026
… collapses the usage readings as it narrows
Ark0N pushed a commit that referenced this pull request Oct 24, 2026
…ngs after CPU and MEM

Conflict in config/test-suites.ts resolved by keeping both sides: #689's
header-divider and #692's header-host-sensors browser tests are both
listed in BROWSER_TEST_GLOBS.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 24, 2026
…ss ones at Compact tiny (release review)

#689's density steps hide every `.header-system-stats .stat-label` once the
toolbar is narrowed, and #692's readings (GPU, VRAM, GPU MHz, CPU MHz) have
nothing else naming them: at tight, dense and tiny the header showed "40%"
beside CPU's "12%", "3.2G" beside MEM's "7.9G", and two bare "1800 MHz" /
"4200 MHz" with no way to tell the GPU clock from the CPU one. At Compact +
tiny, where values are hidden too, the two clocks (no ring) were left as
0-px items that still took an 8 px flex gap each. Neither PR shows it alone.

- The four host items carry `stat-item--host`, excluded from the density
  label hide, so they keep their labels at every step.
- `stat-item--ringless` (static on the two clocks, toggled by
  `_updateHostSensors` on VRAM while its total, and so its ring, is unknown)
  is hidden at Compact + tiny instead of staying an empty item.

Tests: the browser test drives every density in all three styles against
the real markup and styles.css (it fails on the previous files), and the
unit test pins the VRAM ringless toggle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Ark0N

Ark0N commented Oct 24, 2026

Copy link
Copy Markdown
Owner

Merged, thanks! This ships in 1.45.0.

At merge the stored width is clamped to MIN_WIDTH and the arrow keys step from the width on screen, so a drag to the window edge or a held ArrowRight can no longer store a negative width or leave an ArrowLeft dead zone. Escape is no longer a reset key, since the global Escape handler already closes panels on it.

The release review then found an interaction with #692. Under your density steps the GPU, VRAM and clock readings lost their only label, so "40%" sat next to CPU's "12%" and the two clocks read as bare MHz values. Those items now carry stat-item--host and keep their labels, and ringless items hide at Compact's tiny step instead of leaving an empty gap (fb83a5c).

@opticon454
opticon454 deleted the feature/header-resizable-divider branch October 24, 2026 15:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants