feat(tabs): grouped vertical rail from owner tab layouts

The vertical tab rail now reads the owner's tab layout (GET /api/tab-layout)
and draws its groups as collapsible sections. This is the first frontend
consumer of the tab-layout backend and it is read-only: nothing in the
browser writes the layout yet.

- tab-layout-browser.js (new, pure, loaded before app.js): projects the
  layout onto the live sessions and open web tabs, renders the grouped
  markup, stores collapse per device, and sequences loads newest-wins with
  a bounded retry on failure.
- app.js: loads the layout on init and on tab:layoutChanged, renders the
  grouped rail from the same per-row markup the flat rail uses, falls
  through to a full render whenever the grouping structure changes, and
  withholds drag-reorder in the grouped rail.
- Grouping is opt-in by construction. With no layout, a failed read, a
  layout without groups, or a horizontal strip, the rail renders exactly
  as before (byte-identical markup).
- Grouping is a render layer only: sessionOrder, Alt+N, Ctrl+Tab and the
  palette keep reading the server-projected order, and row badges keep
  their Alt+N slot.
- A collapsed group still shows the active row; lineage arcs to a hidden
  session anchor to its group header.
- webview-tabs.js: renderWebviewTab() extracted so a single web tab can be
  placed into its group with unchanged markup.
This commit is contained in:
Aamer Akhter
2026-10-01 14:14:30 -04:00
parent 848ab48b0a
commit 7cbce5bf6c
12 changed files with 1281 additions and 34 deletions
+27 -11
View File
@@ -153,7 +153,6 @@ Object.assign(CodemanApp.prototype, {
const edges = this._collectLineageEdges();
if (edges.length === 0) return;
this._lineageEdgeCount = edges.length;
if (!rects) rects = new Map();
// PHASE 1 — reads.
@@ -162,19 +161,35 @@ Object.assign(CodemanApp.prototype, {
const stripRect = strip.getBoundingClientRect();
const orientation =
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
// A session hidden inside a collapsed group of the grouped rail has no row
// to anchor to, so its end of the arc moves to that group's header (a
// "proxied" endpoint, drawn quieter). Two endpoints proxied to the SAME
// header would be an arc from a row to itself: skipped.
const resolveEndpoint = (id) => {
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
const groupId = this._hiddenTabGroupByRef?.get('session:' + id);
if (!groupId) return { key: 'tab:' + id, element: null, proxied: false };
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
return { key: 'group:' + groupId, element: header, proxied: !!header };
};
const resolvedEdges = [];
for (const edge of edges) {
for (const id of [edge.parentId, edge.childId]) {
const key = 'tab:' + id;
if (rects.has(key)) continue;
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
rects.set(key, tab ? tab.getBoundingClientRect() : null);
const parentEndpoint = resolveEndpoint(edge.parentId);
const childEndpoint = resolveEndpoint(edge.childId);
if (parentEndpoint.key === childEndpoint.key) continue;
resolvedEdges.push({ edge, parentEndpoint, childEndpoint });
for (const endpoint of [parentEndpoint, childEndpoint]) {
if (rects.has(endpoint.key)) continue;
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
}
}
this._lineageEdgeCount = resolvedEdges.length;
// PHASE 2 — writes, from the cache only.
for (const edge of edges) {
const parentRect = rects.get('tab:' + edge.parentId);
const childRect = rects.get('tab:' + edge.childId);
for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
const parentRect = rects.get(parentEndpoint.key);
const childRect = rects.get(childEndpoint.key);
if (!parentRect || !childRect) continue;
const geom = compute({
@@ -191,7 +206,8 @@ Object.assign(CodemanApp.prototype, {
// The working class marches the dashes, so an active worker is visible along
// the line itself. `status` is the CHILD's, which is the interesting end.
const working = edge.status === 'working' ? ' lineage-line--working' : '';
line.setAttribute('class', 'connection-line lineage-line' + working);
const proxied = parentEndpoint.proxied || childEndpoint.proxied;
line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
// The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
// Every arc out of one tab shares it — see _lineageColorFor().
@@ -211,7 +227,7 @@ Object.assign(CodemanApp.prototype, {
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
// works, so the two have to be changed together.
dot.setAttribute('r', '3.5');
dot.setAttribute('class', 'lineage-line-dot' + working);
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
dot.setAttribute('data-child-tab', edge.childId);
if (color) dot.style.setProperty('--lineage-color', color);
svg.appendChild(dot);