fix(tabs): route the lineage spine between the state labels and the tabs

With tabArrangement 'state' (the default) and lineage lines on (the desktop
default), the spine ran straight through the WORKING / WAITING / NEEDS YOU
label of every row it passed. #538's
`.session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows)` pads the
strip by the label gutter and pulls each label to the strip's edge with a
negative margin; it outranks #544's `.lineage-tree.tabs-auto-wrap`
`padding-left: 20px`, so the spine channel disappeared, and the spine,
anchored at the strip's left edge (computeLineageTree), sat in the label
column.

The channel now opens BETWEEN the label column and the tabs: with lineage,
the state strip pads by gutter + --lineage-spine-channel, every label keeps
its column at the edge (still under the brand) and hands the channel back
after itself, and the lead label still starts right after the brand.
computeLineageTree takes the channel's left edge as `spineLeft`, which
session-lineage.js reads back from the padding the CSS laid out (content edge
minus the channel), so the two cannot drift; without it, or where the channel
is the padding itself (classic, ledger, case), the spine stays at the strip's
edge exactly as before. The spine's clamp now only considers tabs in rows the
spine can run beside (every row after the first), so a first row that starts
left of the channel (a quiet idle group right after the brand) no longer
drags the spine back over the labels.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-08 21:36:04 +02:00
parent f5acf19a11
commit 382d7dd406
5 changed files with 221 additions and 13 deletions
+23 -6
View File
@@ -316,6 +316,11 @@ function computeTabScrollLeft(input) {
// (`.session-tabs.lineage-tree`: a wider row gap, bottom padding for the last
// row's gap, and the spine channel on the left of a wrapped strip).
//
// The channel is at the strip's left edge, except where a tab arrangement puts
// something there: grouped by state, the edge holds the label column and the
// channel opens between the labels and the tabs. session-lineage.js measures it
// and passes its left edge as `spineLeft`; without one it is the strip's edge.
//
// Rows come from computeLineageRows() over EVERY tab in the strip, not only the
// endpoints: a row's gap sits under its TALLEST tab (the active tab is 2px
// taller), or siblings in one row would hang their bus at different heights.
@@ -440,9 +445,11 @@ function lineagePolylinePath(points, radius) {
/**
* Routes from one parent tab to each of its children.
*
* input: { parent, children: [{ id, rect }], strip?, tabs?, orientation?, lane?,
* laneCount?, radius? }. `tabs` is every tab rect in the strip (rows are
* derived from it); `lane`/`laneCount` separate families drawn together.
* input: { parent, children: [{ id, rect }], strip?, tabs?, spineLeft?,
* orientation?, lane?, laneCount?, radius? }. `tabs` is every tab rect in
* the strip (rows are derived from it); `spineLeft` is the left edge of the
* spine channel (default: the strip's left edge); `lane`/`laneCount`
* separate families drawn together.
* Returns { routes: [{ id, points, d, endX, endY }] } or null.
*/
function computeLineageTree(input) {
@@ -509,9 +516,19 @@ function computeLineageTree(input) {
};
const pRow = rowOf(parent);
const gp = gapUnder(pRow);
const tabLefts = (input?.tabs || []).map(lineageRect).filter(Boolean).map((r) => r.left);
const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left), ...tabLefts);
const spineBase = strip ? strip.left : minLeft - LINEAGE_SPINE_INSET_PX * 2;
// The spine runs from one row's gap to another's, so it only ever passes BESIDE
// rows after the first, and only their tabs bound it. The first row may start
// left of the channel (grouped by state it starts after the brand and a label
// of its own width), and clamping to it would pull the spine back over the
// label column.
const lowerLefts = [parent, ...visible.map((c) => c.rect), ...(input?.tabs || []).map(lineageRect)]
.filter((r) => r && rowOf(r) > 0)
.map((r) => r.left);
const minLeft = lowerLefts.length ? Math.min(...lowerLefts) : Math.min(parent.left, ...visible.map((c) => c.rect.left));
const channelLeft = Number(input?.spineLeft);
const spineBase = strip
? Math.max(strip.left, Number.isFinite(channelLeft) ? channelLeft : strip.left)
: minLeft - LINEAGE_SPINE_INSET_PX * 2;
const spineX = Math.min(spineBase + LINEAGE_SPINE_INSET_PX + lane * LINEAGE_SPINE_STEP_PX, minLeft - 2);
for (const { id, rect } of visible) {
+13
View File
@@ -260,7 +260,19 @@ Object.assign(CodemanApp.prototype, {
// The header strip's rows come from EVERY tab in it (computeTree hangs a row's
// gap under its tallest tab), web tabs included. The rail needs none.
const tabRects = [];
let spineLeft;
if (orientation === 'horizontal') {
// Where the spine channel is: the reserved --lineage-spine-channel just left
// of the strip's content edge. Usually that is the strip's own left edge,
// but grouped by state the label column comes first (styles.css), and a
// spine at the edge ran through every label. Read back from the padding
// the CSS laid out, so geometry and stylesheet cannot disagree.
const style = typeof getComputedStyle === 'function' ? getComputedStyle(strip) : null;
const channel = style ? parseFloat(style.getPropertyValue('--lineage-spine-channel')) : NaN;
if (Number.isFinite(channel)) {
const inset = (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.paddingLeft) || 0);
spineLeft = stripRect.left + inset - channel;
}
for (const tab of strip.querySelectorAll('.session-tab')) {
const id = tab.getAttribute('data-id');
const key = id ? 'tab:' + id : null;
@@ -292,6 +304,7 @@ Object.assign(CodemanApp.prototype, {
children: children.map((c) => ({ id: c.edge.childId, rect: c.rect })),
strip: stripRect,
tabs: tabRects,
spineLeft,
orientation,
lane,
laneCount,
+26 -5
View File
@@ -20734,7 +20734,7 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
line's cross size is clamped at zero. A margin on the items spaces a group
boundary and a group's own continuation line alike, and the break, which
carries none, adds nothing. The last row's margin replaces the bottom
padding (with lineage, part of its room). The gap is --lineage-row-gap
padding (with lineage, part of its room, below). The gap is --lineage-row-gap
while lineage lines are routed through it (`.session-tabs.lineage-tree`,
near the top of this file). */
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) {
@@ -20747,10 +20747,6 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
margin-bottom: var(--lineage-row-gap, 4px);
}
.session-tabs-host > .session-tabs.tabs-triage.lineage-tree:is(.tabs-auto-wrap, .tabs-two-rows) {
padding-bottom: calc(var(--lineage-bottom-room) - var(--lineage-row-gap));
}
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-head {
justify-content: flex-start;
width: var(--tab-triage-gutter, 92px);
@@ -20771,6 +20767,31 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
height: 0;
}
/* Lineage lines (session-lineage.js): the spine channel (the 20px the lineage
rule reserves on the left of a wrapped strip) opens BETWEEN the label column
and the tabs, not at the strip's edge. At the edge it was the label column
itself: the gutter padding replaced the channel and the spine ran through
every label it passed. session-lineage.js reads the channel's position back
from this padding (the content edge minus --lineage-spine-channel), so the
two cannot drift apart. Labels keep their column under the brand, and the
lead row, which the spine never runs alongside, still starts right after
the brand. */
.session-tabs-host > .session-tabs.tabs-triage.lineage-tree:is(.tabs-auto-wrap, .tabs-two-rows) {
padding-left: calc(var(--tab-triage-gutter, 92px) + var(--lineage-spine-channel));
/* The last row's margin is part of the bottom routing room. */
padding-bottom: calc(var(--lineage-bottom-room) - var(--lineage-row-gap));
}
.session-tabs-host > .session-tabs.tabs-triage.lineage-tree:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-head {
margin-left: calc(-1 * (var(--tab-triage-gutter, 92px) + var(--lineage-spine-channel)));
margin-right: var(--lineage-spine-channel);
}
.session-tabs-host > .session-tabs.tabs-triage.lineage-tree:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-head--lead {
margin-left: calc(var(--tab-triage-brand, 100px) - var(--tab-triage-gutter, 92px) - var(--lineage-spine-channel));
margin-right: 0;
}
/* Side rail and sidebar: each heading is a section row above its rows. */
html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-triage > .tab-triage-head,
html[data-session-list='sidebar'] .session-sidebar .session-tabs.tabs-triage > .tab-triage-head {