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
1 change: 1 addition & 0 deletions config/test-suites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const BROWSER_TEST_GLOBS = [
'test/tab-focus.browser.test.ts',
'test/session-sidebar-ux.browser.test.ts',
'test/header-divider.browser.test.ts',
'test/tab-strip-corner.browser.test.ts',
'test/header-host-sensors.browser.test.ts',
'test/session-options-responsive.browser.test.ts',
'test/inline-rename.test.ts',
Expand Down
2 changes: 1 addition & 1 deletion src/web/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L

**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk and the phone swipe follow computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. ⚠️ The rail's **Search sessions** box (`setTabRailSearch`, in memory only) and the sidebar filter share ONE row filter, `_applyTabListFilter()` over the pure `CodemanTabSearch` (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (`tabAlerts`, red or yellow) is never hidden by either box: it goes in as `keep: true`, counts toward its group (which stays on screen) but not toward `matchCount`. ⚠️ Escape in the rail box is claimed by the global capture-phase Escape branch (the box's own `onkeydown` runs after it, too late to stop `closeAllPanels()`), and a filter change that shows or hides anything redraws the connector lines, since a keystroke re-renders nothing; a floating window finds its parent tab only through `_paintedSessionTab()` (a hidden row's rect is all zero but truthy, which drew connectors from the viewport's corner). While it runs `_projectTabGroups()` projects every group open (a collapsed group's rows are not in the DOM) and `toggleTabGroupCollapsed()` refuses, leaving the stored collapse untouched; drag stays on during a search (owner decision), a drop landing between the visible neighbours; `applyTabOrientation()` clears it off the rail. Tests: `test/tab-rail-search.test.ts`, `test/tab-rail-search.browser.test.ts`. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](../../docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)

**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. ⚠️ Grouping is a render layer: `sessionOrder`, Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep the canonical order, and only the phone swipe walks the drawn order (`nextSession({ visual: true })` through `_stripSessionOrder()`, the same `_visibleTabRows()` walk the strip's arrow keys use); an active session the list does not draw steps in at the first or last row, and an empty drawn list goes nowhere rather than falling back to a hidden session (`test/session-swipe-gesture.test.ts`). Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](../../docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip; once it wraps on desktop it is L-shaped via `_syncTabStripCorner()` (the toolbar sits over its top-right corner and later rows run the full width under it, a strip already past its max-height keeps the plain wrap): ⚠️ `.tabs-corner` comes off before the wrap is measured, and the strip is `display: block` in that state, so flex-only rules do not apply to it there (`test/tab-strip-corner.browser.test.ts`). Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. ⚠️ Grouping is a render layer: `sessionOrder`, Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep the canonical order, and only the phone swipe walks the drawn order (`nextSession({ visual: true })` through `_stripSessionOrder()`, the same `_visibleTabRows()` walk the strip's arrow keys use); an active session the list does not draw steps in at the first or last row, and an empty drawn list goes nowhere rather than falling back to a hidden session (`test/session-swipe-gesture.test.ts`). Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](../../docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)

**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): `tiles` / `compact` (two pills, a ring beside every value) / `classic` (as before), keyed on `data-header-stats` (pre-paint + `applyHeaderStatsStyle()`), forced to `classic` below 768px and in a solo window. ⚠️ The clustered styles MOVE `#connectionIndicator` into `#headerSystemStats` and `#planUsageChip` right after it; `classic` moves them back to comment anchors. ⚠️ WS joins the pill only while System Stats is shown. ⚠️ The extra parts (stat rings, tile words, plan rings, meters) are always rendered and hidden by default in CSS, which is what keeps `classic` unchanged; tile words are derived beside the connection descriptor, never added to it (`test/connection-indicator.test.ts` pins its shape). ⚠️ A tile is a three-row grid (label, value, bar) with pixel line-heights and the bundled JetBrains Mono; never lay the bar over a fixed-height tile (a taller system mono put the value on top of it). The WS tile's grid lives on the inner `.connection-tile`, because JS writes the indicator's `display` inline. Tests: `test/header-stats-style.test.ts`. → [architecture-invariants#header-stats-styles](../../docs/architecture-invariants.md#header-stats-styles)

Expand Down
90 changes: 89 additions & 1 deletion src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5910,7 +5910,7 @@ class CodemanApp {
// tabs-auto-wrap is measured, not derived from settings — updateTabOverflowMode()
// drops it in sidebar mode, but drop it here too so nothing paints wrapped
// for a frame before the next measure.
if (sidebar) tabsEl.classList.remove('tabs-auto-wrap');
if (sidebar) tabsEl.classList.remove('tabs-auto-wrap', 'tabs-corner');
// Collapse/expand changes whether the filter is reachable, so re-evaluate it
// here too — not only at the render tails.
this.applySidebarFilter(this._sidebarFilter);
Expand Down Expand Up @@ -6901,7 +6901,14 @@ class CodemanApp {
updateTabOverflowMode() {
const container = this.$('sessionTabs');
if (!container) return;
// The corner takes the toolbar out of the header's flow, which widens the
// strip, so it goes before the wrap is measured and comes back once decided.
container.classList.remove('tabs-corner');
this._decideTabOverflow(container);
this._syncTabStripCorner(container);
}

_decideTabOverflow(container) {
// Lineage routing room (session-lineage.js) changes the strip's padding and
// row gap, so it is decided before the wrap is measured below.
this._syncLineageGutter?.();
Expand Down Expand Up @@ -6963,6 +6970,87 @@ class CodemanApp {
container.classList.toggle('tabs-auto-wrap', shouldWrap);
}

/**
* L-shaped header strip: once the classic strip wraps, the toolbar
* (#headerRight) sits over the strip's top-right corner (styles.css,
* `.tabs-corner`) and the rows under the first one run the header's full
* width beneath it. The strip keeps the toolbar's corner clear with a float
* sized here, from the toolbar's own box, so a toolbar that wraps onto more
* lines (header-divider.js) or gains a reading keeps every tab out from
* under it. The grouped arrangements (state, case, ledger) lay their rows
* out themselves and keep the plain strip.
*/
_syncTabStripCorner(container) {
const right = this.$('headerRight');
const on =
!!right &&
container.offsetParent !== null &&
!!container.parentElement?.classList.contains('session-tabs-host') &&
(container.classList.contains('tabs-auto-wrap') || container.classList.contains('tabs-two-rows')) &&
!container.classList.contains('tabs-triage') &&
!container.classList.contains('tabs-clusters') &&
!container.classList.contains('tabs-ledger') &&
MobileDetection.getDeviceType() === 'desktop';
const off = () => {
container.classList.remove('tabs-corner');
container.style.removeProperty('--tab-corner-width');
container.style.removeProperty('--tab-corner-height');
};
// A strip past its max-height scrolls, and the float would scroll its tabs
// under the toolbar, which stays put: keep the plain wrap. The plain strip
// is measured here (updateTabOverflowMode took .tabs-corner off), before
// the class goes on; toggling it on a scrolled strip lets scroll anchoring
// move the strip.
if (!on || container.scrollHeight > container.clientHeight + 1) {
off();
return;
}
container.classList.add('tabs-corner');
if (!this._tabCornerObserver && typeof ResizeObserver !== 'undefined') {
let frame = 0;
// A toolbar that changes size (a reading arrives, the divider settles)
// moves the tabs without a render: re-decide the wrap and the corner, and
// redraw the lineage routes that follow the tabs.
this._tabCornerObserver = new ResizeObserver(() => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
const strip = this.$('sessionTabs');
if (!strip || !/\btabs-(corner|auto-wrap|two-rows)\b/.test(strip.className)) return;
this.updateTabOverflowMode();
this.updateConnectionLines?.();
});
});
this._tabCornerObserver.observe(right);
}
const style = getComputedStyle(container);
const strip = container.getBoundingClientRect();
const box = right.getBoundingClientRect();
// The float sits at the strip's content padding edge (the plain strip did
// not scroll, so scrollTop is 0).
const contentTop = strip.top + parseFloat(style.paddingTop);
const contentLeft = strip.left + container.clientLeft + parseFloat(style.paddingLeft);
const contentRight = strip.left + container.clientLeft + container.clientWidth - parseFloat(style.paddingRight);
// A first tab that cannot fit beside the toolbar would drop the whole first
// line below it and leave an empty band: keep the plain strip instead.
const firstTab = container.querySelector(':scope > .session-tab');
if (firstTab && box.left - 8 - contentLeft < firstTab.offsetWidth + 2) {
off();
return;
}
const width = `${Math.max(0, Math.ceil(contentRight - box.left + 8))}px`;
const height = `${Math.max(0, Math.ceil(box.bottom - contentTop))}px`;
if (container.style.getPropertyValue('--tab-corner-width') !== width) {
container.style.setProperty('--tab-corner-width', width);
}
if (container.style.getPropertyValue('--tab-corner-height') !== height) {
container.style.setProperty('--tab-corner-height', height);
}
// A plain strip just under its max-height can pass it once the float is in:
// fall back to the plain strip, which fit.
if (container.scrollHeight > container.clientHeight + 1) off();
}

/**
* True when a case cluster in the header strip wraps inside its own box: a case
* wider than the whole strip (styles.css caps a box at the strip's width). The
Expand Down
39 changes: 39 additions & 0 deletions src/web/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,45 @@ body {
padding-left: var(--lineage-spine-channel);
}

/* L-shaped strip (`_syncTabStripCorner()`, app.js): a wrapped classic strip
runs the header's full width, the toolbar sits over its top-right corner,
and only the rows beside the toolbar stop short of it. A flex line cannot
be shortened on its own, so the strip lays its tabs out inline, around a
float the size of the toolbar's box (measured into --tab-corner-width and
--tab-corner-height). The strip is a scroll container, so it contains the
float and is never shorter than the toolbar it now holds up; a strip that
would actually scroll keeps the plain wrap instead. Inline tabs space their
rows with a bottom margin (the old row-gap, lineage's included); the strip
carries no font size of its own, so the whitespace between tabs is 0 wide. */
.header:has(> .session-tabs-host > .session-tabs.tabs-corner) > .header-right {
position: absolute;
top: 0.35rem;
right: 0.75rem;
}

.session-tabs-host > .session-tabs.tabs-corner {
display: block;
font-size: 0;
padding-bottom: 0;
}

.session-tabs-host > .session-tabs.tabs-corner::before {
content: '';
float: right;
width: var(--tab-corner-width, 0px);
height: var(--tab-corner-height, 0px);
}

.session-tabs-host > .session-tabs.tabs-corner > .session-tab {
display: inline-flex;
vertical-align: top;
margin: 0 2px var(--lineage-row-gap, 4px) 0;
}

.session-tabs-host > .session-tabs.lineage-tree.tabs-corner {
padding-bottom: calc(var(--lineage-bottom-room) - var(--lineage-row-gap));
}

.session-tabs::-webkit-scrollbar {
width: 4px;
height: 0;
Expand Down
Loading
Loading