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
+260
View File
@@ -0,0 +1,260 @@
/**
* @fileoverview Read-only browser projection of the owner tab layout.
*
* `GET /api/tab-layout` returns the owner's named tab GROUPS (`src/tab-layout.ts`
* is the server model). Browser assets cannot import that TypeScript, so this
* module is a small, dependency-free mirror that owns three things:
*
* 1. Projection: which live sessions and open web tabs land in which group,
* and which rows a collapsed group hides.
* 2. Rendering: the grouped markup for the vertical tab rail. Rows themselves
* are rendered by the caller (app.js, webview-tabs.js), so a grouped row is
* byte-identical to the flat rail's row.
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
* read degrades to the flat rail with a bounded retry.
*
* The server stays the only authority for layout content. Collapse is a
* per-device view preference and lives in localStorage only.
*
* Grouped rendering is opt-in by construction: a layout with no groups (every
* owner until they create one) projects to `null`, and the caller keeps the flat
* rail exactly as it was.
*
* @dependency none
* @loadorder 5.9 (before app.js, which reads window.CodemanTabLayout)
*/
(function initCodemanTabLayout(global) {
'use strict';
const COLLAPSED_STORAGE_KEY = 'codeman:tab-groups-collapsed';
const refKey = (ref) => `${ref.kind}:${ref.id}`;
const validRef = (ref) =>
!!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0;
const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
const stableIds = (value) => [...new Set(asIds(value))];
const copyRefs = (value) =>
Array.isArray(value) ? value.filter(validRef).map((r) => ({ kind: r.kind, id: r.id })) : [];
/**
* Defensive copy of a server layout. Unknown fields are dropped, so a newer
* server adding model fields cannot leak half-understood state into the view.
*/
function normalizeLayout(value) {
if (!value || typeof value !== 'object') throw new Error('Invalid tab layout');
const groups = Array.isArray(value.groups) ? value.groups : [];
return {
version: Number.isSafeInteger(value.version) && value.version >= 0 ? value.version : 0,
groups: groups
.filter((group) => group && typeof group.id === 'string' && group.id.length > 0)
.map((group) => ({
id: group.id,
name: typeof group.name === 'string' ? group.name : '',
refs: copyRefs(group.refs),
})),
ungrouped: copyRefs(value.ungrouped),
};
}
function hasGroups(layout) {
return !!layout && Array.isArray(layout.groups) && layout.groups.length > 0;
}
function loadCollapsedGroupIds(storage, validGroupIds) {
try {
const raw = storage.getItem(COLLAPSED_STORAGE_KEY);
const parsed = raw === null ? [] : JSON.parse(raw);
if (!Array.isArray(parsed)) throw new Error('Invalid collapsed tab groups');
const loaded = stableIds(parsed);
if (validGroupIds === undefined) return { ids: loaded, ok: true };
// Garbage-collect ids of groups that no longer exist, so a deleted group's
// id cannot silently collapse a future group that reuses it.
const valid = new Set(stableIds(validGroupIds));
const kept = loaded.filter((id) => valid.has(id));
if (kept.length !== loaded.length) storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(kept));
return { ids: kept, ok: true };
} catch (_error) {
return { ids: [], ok: false };
}
}
function saveCollapsedGroupIds(storage, groupIds) {
const ids = stableIds(groupIds);
try {
storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(ids));
return { ids, ok: true };
} catch (_error) {
return { ids: [], ok: false };
}
}
/**
* Project a layout onto what is live in this browser.
*
* Every live session and open web tab appears exactly once: stored refs keep
* their group and stored order; anything the layout has not caught up with yet
* (a session created a moment ago, a web tab opened on this device only) is
* appended to the ungrouped section in the caller's order. Saved web tabs that
* are not open here are skipped, as are refs to sessions that are gone.
*
* A collapsed group hides its rows, EXCEPT the highlighted one (the active web
* tab, else the active session), so selecting a hidden session by keyboard,
* palette or Alt+N never leaves the user with no visible selection.
*
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef }} null when the
* layout has no groups: the caller renders the flat rail unchanged.
*/
function project(layoutInput, options = {}) {
if (!layoutInput) return null;
const layout = normalizeLayout(layoutInput);
if (!hasGroups(layout)) return null;
const liveSessionIds = stableIds(options.liveSessionIds);
const openWebviewIds = stableIds(options.openWebviewIds);
const live = new Set(liveSessionIds);
const open = new Set(openWebviewIds);
const collapsed = new Set(asIds(options.collapsedGroupIds));
const highlighted = options.activeWebviewId
? `webview:${options.activeWebviewId}`
: options.activeSessionId
? `session:${options.activeSessionId}`
: '';
const renderable = (ref) => (ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id));
const placed = new Set();
const visibleRefs = [];
const hiddenTabGroupByRef = {};
const sections = [];
const place = (refs, sectionId, isCollapsed) => {
const shown = [];
let count = 0;
for (const ref of refs) {
const key = refKey(ref);
if (placed.has(key) || !renderable(ref)) continue;
placed.add(key);
count++;
if (isCollapsed && key !== highlighted) {
hiddenTabGroupByRef[key] = sectionId;
continue;
}
const copy = { kind: ref.kind, id: ref.id };
shown.push(copy);
visibleRefs.push(copy);
}
return { shown, count };
};
for (const group of layout.groups) {
const isCollapsed = collapsed.has(group.id);
const { shown, count } = place(group.refs, group.id, isCollapsed);
sections.push({ id: group.id, name: group.name, refs: shown, count, collapsed: isCollapsed });
}
const omissions = [
...liveSessionIds.map((id) => ({ kind: 'session', id })),
...openWebviewIds.map((id) => ({ kind: 'webview', id })),
];
const ungrouped = place([...layout.ungrouped, ...omissions], null, false);
if (ungrouped.count > 0) {
sections.push({ id: null, name: '', refs: ungrouped.shown, count: ungrouped.count, collapsed: false });
}
return { sections, visibleRefs, hiddenTabGroupByRef };
}
/**
* Everything that changes the grouped rail's STRUCTURE (which rows exist and
* where), as opposed to a row's own status/name/badges. The incremental render
* path only patches rows in place, so a change here forces a full rebuild.
*/
function structureKey(layout, projection, collapsedGroupIds) {
if (!projection) return null;
return JSON.stringify({
version: layout && Number.isSafeInteger(layout.version) ? layout.version : null,
collapsed: stableIds(collapsedGroupIds).sort(),
sections: projection.sections.map((section) => [section.id, section.count, section.refs.map(refKey)]),
});
}
/**
* Grouped rail markup. `renderRef(ref)` returns one row's HTML ('' to skip it);
* `escapeHtml` is the caller's escaper. Group names are user content, so they
* are escaped and marked `data-i18n-skip`.
*
* The group header is a real <button> carrying `aria-expanded`; its accessible
* name is the group name plus count, so no per-state label string is needed.
*/
function renderProjection(projection, renderRef, escapeHtml) {
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
return sections
.map((section, index) => {
const rows = section.refs.map((ref) => renderRef(ref)).join('');
if (section.id === null) {
return (
'<section class="tab-layout-group tab-layout-ungrouped" role="presentation" data-tab-group-id="">' +
`<div class="tab-layout-group-header tab-layout-ungrouped-header"><span class="tab-layout-group-name">Ungrouped</span><span class="tab-layout-group-count">${section.count}</span></div>` +
`<div class="tab-layout-group-refs" role="presentation">${rows}</div></section>`
);
}
const id = escapeHtml(section.id);
const refsId = `tab-layout-group-refs-${index}`;
return (
`<section class="tab-layout-group${section.collapsed ? ' tab-layout-group--collapsed' : ''}" role="presentation" data-tab-group-id="${id}">` +
`<button type="button" class="tab-layout-group-header tab-layout-group-toggle" data-tab-group-header="${id}" aria-expanded="${section.collapsed ? 'false' : 'true'}" aria-controls="${refsId}" onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)">` +
'<span class="tab-layout-group-chevron" aria-hidden="true"></span>' +
`<span class="tab-layout-group-name" data-i18n-skip>${escapeHtml(section.name)}</span>` +
`<span class="tab-layout-group-count">${section.count}</span></button>` +
`<div class="tab-layout-group-refs" id="${refsId}" role="presentation">${rows}</div></section>`
);
})
.join('');
}
/**
* Newest-wins layout loading. A response that was overtaken by a later load is
* dropped; a failure applies the fallback (the flat rail) and schedules ONE
* retry, replacing any retry already pending.
*/
function createLoadCoordinator(options) {
let generation = 0;
let disposed = false;
let retryHandle = null;
const clearRetry = () => {
if (retryHandle !== null && options.cancelRetry) options.cancelRetry(retryHandle);
retryHandle = null;
};
const load = async () => {
if (disposed) return false;
const requestGeneration = ++generation;
clearRetry();
try {
const layout = await options.fetchLayout();
if (disposed || requestGeneration !== generation) return false;
options.applyLayout(layout);
return true;
} catch (_error) {
if (disposed || requestGeneration !== generation) return false;
options.applyFallback();
retryHandle = options.scheduleRetry(() => load());
return false;
}
};
return {
load,
dispose() {
disposed = true;
generation++;
clearRetry();
},
};
}
global.CodemanTabLayout = {
normalizeLayout,
hasGroups,
project,
structureKey,
renderProjection,
createLoadCoordinator,
loadCollapsedGroupIds,
saveCollapsedGroupIds,
};
})(typeof window !== 'undefined' ? window : globalThis);