fix(tabs): wrap case clusters box by box and never route a lineage line through a tab

With tabArrangement 'case' at 1440px (nine tabs in four cases), the lineage
routes ran horizontally through the middle of tabs, cluster labels and box
borders. #538's clusters are `max-width: 100%` boxes that may shrink, so in
the one-line strip every box squeezed and wrapped inside itself (a one-tab
case's swatch alone on a line above its tab). The strip then never
overflowed, so updateTabOverflowMode() never wrapped it, and #544's routing
room (`.lineage-tree.tabs-auto-wrap`: row gap and spine channel) never
applied. computeLineageRows grouped the squeezed tabs by top alone into rows
that overlap (10-40, 20-50, 42-74), and gapUnder put the first row's gap at
y 30, inside the tabs below it.

- On the desktop header strip a cluster keeps its width, so clusters that do
  not fit overflow and the strip wraps box by box; the boxes' own gaps read
  --lineage-row-gap, so the routes run between box rows and through the
  spine channel. A case wider than the whole strip still wraps inside its
  box, and now wraps the strip too (`_tabClustersWrapInside`, the new
  `innerWrap` input of shouldAutoWrapTabs), so its inner rows get the gap.
- computeLineageRows never returns overlapping rows: tabs whose spans
  overlap are one row. gapUnder returns null when there is no real gap, and
  a route with no gap is not drawn. A final pass drops any route with a
  segment inside a tab, so no arrangement can draw a line through a tab
  (a layout without the room loses lines instead). Pinned with the squeezed
  rects measured live.

At 1920px, where the clusters fit on one line, nothing changes.

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 382d7dd406
commit e308e99f05
6 changed files with 230 additions and 11 deletions
+81
View File
@@ -380,6 +380,87 @@ describe('lineage tree geometry: header strip', () => {
});
});
describe('lineage tree geometry: never through a tab, in any arrangement', () => {
// Measured live (1440x900, tabArrangement 'case', nine tabs in four cases) before
// the case boxes kept their width: every box squeezed and wrapped inside itself,
// so tabs sat at tops 10, 20, 42 and 43 with spans that overlap. The old rows
// (grouped by top alone) put the "gap" under the first row at y 30, inside the
// tabs, and the routes ran through them.
const strip: Rect = { left: 94, top: 6, width: 980, height: 85 };
const squeezed: Record<string, Rect> = {
'w1-webshop': { left: 201, top: 10, width: 99, height: 30 },
'w2-webshop': { left: 109, top: 42, width: 101, height: 30 },
'w3-webshop': { left: 212, top: 42, width: 101, height: 30 },
'w1-api-gateway': { left: 518, top: 10, width: 99, height: 30 },
'w2-api-gateway': { left: 407, top: 42, width: 143, height: 32 },
'w3-api-gateway': { left: 552, top: 43, width: 101, height: 30 },
'w1-notes': { left: 744, top: 20, width: 136, height: 30 },
'w1-docs-site': { left: 968, top: 10, width: 99, height: 30 },
'w2-docs-site': { left: 870, top: 42, width: 101, height: 30 },
};
const all = Object.values(squeezed);
it('makes overlapping spans one row, so no gap is ever inside a tab', () => {
const helper = loadLineageHelper();
// 10-40 and 20-50 overlap, and 42-74 overlaps 20-50: one row, no gap inside.
expect(helper.computeRows(all)).toEqual([{ top: 10, bottom: 74 }]);
// Rows that only share a top (the active tab is taller) stay one row, and rows
// with a real gap between them stay apart.
expect(helper.computeRows([tab(0, 4), tab(130, 4, 120, 32), tab(0, 46)])).toEqual([
{ top: 4, bottom: 36 },
{ top: 46, bottom: 76 },
]);
});
it('draws no segment inside a tab, whichever tab is the parent or the child', () => {
const helper = loadLineageHelper();
const names = Object.keys(squeezed);
let drawn = 0;
for (const parentName of names) {
for (const lane of [0, 1, 2]) {
const children = names.filter((n) => n !== parentName).map((n) => ({ id: n, rect: squeezed[n] }));
const geom = helper.computeTree({
parent: squeezed[parentName],
children,
strip,
tabs: all,
lane,
laneCount: 3,
});
for (const route of geom?.routes ?? []) {
drawn++;
for (let i = 1; i < route.points.length; i++) {
for (const r of all) {
expect(crossesRect(route.points[i - 1], route.points[i], r), `${parentName} -> ${route.id}`).toBe(false);
}
}
}
}
}
// Some routes still have a clean way (a tab with nothing under it), so the
// check above is not vacuous.
expect(drawn).toBeGreaterThan(0);
});
it('draws nothing into rows with no gap between them', () => {
const helper = loadLineageHelper();
// Two rows touching (no row gap at all): the only place a route could run is
// through the tabs of the other row.
const upper = [tab(0, 4), tab(130, 4)];
const lower = [tab(0, 34), tab(130, 34)];
const geom = helper.computeTree({
parent: upper[0],
children: [
{ id: 'below', rect: lower[1] },
{ id: 'beside', rect: upper[1] },
],
strip: { left: 0, top: 0, width: 600, height: 80 },
tabs: [...upper, ...lower],
})!;
expect(geom.routes).toEqual([]);
});
});
describe('lineage tree geometry: vertical rail', () => {
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
const parent: Rect = { left: 132, top: 40, width: 260, height: 40 };
+51 -3
View File
@@ -171,7 +171,9 @@ describe('tab layouts by case and ledger (app.js)', () => {
name: box.querySelector('.tab-cluster-name')?.textContent ?? null,
count: box.querySelector('.tab-cluster-count')?.textContent ?? null,
single: box.classList.contains('tab-cluster--single'),
rows: [...box.querySelectorAll<HTMLElement>('.session-tab')].map((t) => t.dataset.id || `web:${t.dataset.webviewId}`),
rows: [...box.querySelectorAll<HTMLElement>('.session-tab')].map(
(t) => t.dataset.id || `web:${t.dataset.webviewId}`
),
}));
beforeEach(() => {
@@ -190,7 +192,9 @@ describe('tab layouts by case and ledger (app.js)', () => {
{ name: null, count: null, single: true, rows: ['web:w1'] },
]);
for (const box of container().querySelectorAll<HTMLElement>(':scope > .tab-cluster:not(.tab-cluster--web)')) {
expect(box.getAttribute('style')).toMatch(/^--cluster-color: var\(--session-(blue|green|purple|orange|pink|yellow|red)\)$/);
expect(box.getAttribute('style')).toMatch(
/^--cluster-color: var\(--session-(blue|green|purple|orange|pink|yellow|red)\)$/
);
expect(box.getAttribute('role')).toBe('presentation');
}
});
@@ -261,6 +265,35 @@ describe('tab layouts by case and ledger (app.js)', () => {
expect(container().classList.contains('tabs-clusters')).toBe(false);
});
it('wraps the header strip when a case box wraps inside itself', () => {
// A case wider than the strip wraps in its own box and never overflows, so
// the measured auto-wrap alone would leave the strip unwrapped, and with it the
// lineage routing room unreserved.
const app = makeApp();
app._fullRenderSessionTabs();
Object.assign(app, {
_syncLineageGutter: () => {},
isSessionSidebarActive: () => false,
loadAppSettingsFromStorage: () => ({ tabArrangement: 'case' }),
getDefaultSettings: () => ({ tabTwoRows: false, tabOrientation: 'horizontal' }),
});
const webshop = [...container().querySelectorAll<HTMLElement>('[data-cluster-key="/c/webshop"] > .session-tab')];
expect(webshop).toHaveLength(3);
const place = (tops: number[]) =>
webshop.forEach((el, i) => Object.defineProperty(el, 'offsetTop', { configurable: true, value: tops[i] }));
const overflow = CodemanApp.prototype.updateTabOverflowMode;
place([0, 0, 0]);
overflow.call(app);
expect(container().classList.contains('tabs-auto-wrap')).toBe(false);
expect(app._tabClustersWrapInside(container())).toBe(false);
place([0, 0, 38]);
overflow.call(app);
expect(app._tabClustersWrapInside(container())).toBe(true);
expect(container().classList.contains('tabs-auto-wrap')).toBe(true);
});
it('draws the ledger with the classic markup and one class', () => {
makeApp('classic')._fullRenderSessionTabs();
const classic = container().innerHTML;
@@ -279,6 +312,19 @@ describe('tab layouts by case and ledger (static)', () => {
expect(css).toMatch(/\.session-tabs\.tabs-clusters \.tab-name-case \{\s*display: none;/);
});
it('keeps each case box its width on the desktop header strip, so the strip wraps box by box', () => {
// Allowed to shrink, every box squeezed and wrapped inside itself at 1440px
// (overlapping tab rows, the lineage routes through tabs and labels) and the
// strip never overflowed, so it never wrapped.
const media = css.indexOf(
'@media (min-width: 768px) {\n .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster {'
);
expect(media).toBeGreaterThan(-1);
expect(css.slice(media, css.indexOf('}', media))).toContain('flex-shrink: 0;');
// Only a case wider than the strip wraps inside its box.
expect(css).toMatch(/\.session-tabs-host > \.session-tabs\.tabs-clusters > \.tab-cluster \{[^}]*max-width: 100%;/);
});
it('keeps the ledger to the desktop header strip', () => {
const media = css.indexOf('@media (min-width: 768px) {\n .session-tabs-host > .session-tabs.tabs-ledger {');
expect(media).toBeGreaterThan(-1);
@@ -288,7 +334,9 @@ describe('tab layouts by case and ledger (static)', () => {
});
it('keeps every ledger row one height and makes the active cell stand out', () => {
const ledger = css.slice(css.indexOf('@media (min-width: 768px) {\n .session-tabs-host > .session-tabs.tabs-ledger {'));
const ledger = css.slice(
css.indexOf('@media (min-width: 768px) {\n .session-tabs-host > .session-tabs.tabs-ledger {')
);
expect(ledger).toContain('align-items: stretch;');
expect(ledger).toContain('min-height: 30px;');
expect(ledger).toMatch(
+16
View File
@@ -78,6 +78,22 @@ describe('tab overflow layout policy', () => {
// A single overflowing tab must not wrap (need at least 2 to form a second row).
expect(helper.shouldAutoWrapTabs({ ...base, tabCount: 1, scrollWidth: 1400, clientWidth: 760 })).toBe(false);
});
it('wraps a strip whose case cluster already wraps inside itself, though nothing overflows', () => {
// A case wider than the whole strip is capped at its width and wraps in its
// box, so the strip has rows that scrollWidth never shows. Without the wrap,
// the lineage routing room (row gap, spine channel) is never reserved.
const helper = loadTabOverflowHelper();
const fits = { manualTwoRows: false, tabCount: 12, scrollWidth: 800, clientWidth: 800 };
expect(helper.shouldAutoWrapTabs({ ...fits, deviceType: 'desktop', innerWrap: true })).toBe(true);
expect(helper.shouldAutoWrapTabs({ ...fits, deviceType: 'desktop', innerWrap: false })).toBe(false);
// The other guards still win.
expect(helper.shouldAutoWrapTabs({ ...fits, deviceType: 'tablet', innerWrap: true })).toBe(false);
expect(helper.shouldAutoWrapTabs({ ...fits, deviceType: 'desktop', manualTwoRows: true, innerWrap: true })).toBe(
false
);
});
});
// Issue #257: the phone tab strip scrolls horizontally, so the active tab can