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
+2 -2
View File
@@ -254,7 +254,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. BACKEND ONLY: no frontend calls these routes yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend only READS it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. No frontend WRITES the layout yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
@@ -317,7 +317,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
### Frontend
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
+8 -1
View File
@@ -296,7 +296,14 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
**Owner tab layouts** (COD-359, `tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat tab strip, scoped per owner (`SINGLE_USER_LAYOUT_OWNER` = `@single` when multi-user is off), persisted under the `tabLayouts` key in state.json. The pure model is `tab-layout.ts`, `tab-layout-service.ts` is the sole mutation boundary, plus `tab-layout-persistence.ts` and `tab-layout-legacy-order.ts`. A layout is `{version, groups[], ungrouped[], updatedAt}` whose refs point at either a session or a saved webview (`TabRefKind`), capped at 32 groups / 512 refs.
⚠️ **BACKEND ONLY as of 1.24.1**: nothing in `src/web/public/` calls these routes yet, so a UI built on top is new frontend work, not a rewiring job.
⚠️ **The frontend READS the layout; nothing writes it yet.** `tab-layout-browser.js` (pure, loaded before app.js) projects it onto what is live in the page, and app.js's grouped-rail block draws the VERTICAL rail as collapsible group sections. The rules that keep it safe:
- **Grouped iff vertical AND the owner has at least one group.** No layout, a failed `GET` (retried, newest-wins via `createLoadCoordinator`) or zero groups renders the flat rail unchanged; the horizontal strip, phones and the sidebar never group.
- **A render layer, never an order source.** `sessionOrder` (the server-projected global order), Alt+N, Ctrl+Tab and the palette are untouched; a grouped session row is the flat row's markup, so its badge still names its Alt+N slot. Web tabs keep their slot after every session wherever their group puts them (`renderWebviewTab`).
- **Collapse is per-device** (`codeman:tab-groups-collapsed` in localStorage, ids of deleted groups garbage-collected on adoption, any storage failure means all-expanded). A collapsed group still SHOWS the active row, and `_updateActiveTabImmediate` falls through to a full render whenever the structure key changes, since a class toggle cannot reveal a hidden row.
- **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing.
- **Drag-reorder is off in the grouped rail** until grouped editing lands: a flat-order drop cannot express a group move, and the server re-ranks within the old group.
⚠️ **`TabLayoutService` is the single mutation boundary** and every lifecycle caller (session created/removed, webview created/deleted, a legacy order PUT) describes ONE completed server action and gets AT MOST ONE versioned write; writing layout state from a route or a manager directly is what the service exists to prevent.
+2
View File
@@ -86,6 +86,7 @@ run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --
run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite');
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
run('minify tab-layout-browser.js', 'npx esbuild dist/web/public/tab-layout-browser.js --minify --outfile=dist/web/public/tab-layout-browser.js --allow-overwrite');
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite');
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
@@ -113,6 +114,7 @@ console.log('\n[build] content-hash cache busting');
'input-cjk.js',
'terminal-keycode229-recovery.js',
'sanitize-html.js',
'tab-layout-browser.js',
'app.js',
'tab-rail-resize.js',
'terminal-ui.js',
+199 -10
View File
@@ -319,6 +319,8 @@ const _SSE_HANDLER_MAP = [
// Session order (global tab order sync, COD-131)
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
// Owner tab layout (grouped vertical rail)
[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged'],
// Web tabs (dashboard URLs)
[SSE_EVENTS.WEBVIEW_CHANGED, '_onWebviewChanged'],
@@ -573,6 +575,12 @@ class CodemanApp {
this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results
this.sessionOrder = []; // Track tab order for drag-and-drop reordering
this.draggedTabId = null; // Currently dragged tab session ID
// Owner tab layout (GET /api/tab-layout), read-only here: it only changes how
// the vertical rail GROUPS rows. sessionOrder above stays the tab order.
this.tabLayout = null;
this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
this._hiddenTabGroupByRef = new Map(); // 'session:<id>' -> collapsed group id
this._lastTabGroupStructureKey = null;
this.cases = [];
this.currentRun = null;
this.totalTokens = 0;
@@ -4266,6 +4274,10 @@ class CodemanApp {
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
this.syncSessionOrder();
// (Re)read the owner tab layout on every init, including SSE reconnects: a
// tab:layoutChanged sent while this client was disconnected is never replayed.
this._loadTabLayout();
if (data.respawnStatus) {
this.respawnStatus = data.respawnStatus;
} else {
@@ -5078,6 +5090,12 @@ class CodemanApp {
_updateActiveTabImmediate(sessionId) {
const container = this.$('sessionTabs');
if (!container) return;
// Grouped rail: selecting a session hidden in a collapsed group must show it
// (and re-hide the previous exception), which a class toggle cannot do.
if (this._isTabGroupStructureStale()) {
this._fullRenderSessionTabs();
return;
}
const tabs = container.querySelectorAll('.session-tab[data-id]');
for (const tab of tabs) {
if (tab.dataset.id === sessionId) {
@@ -5237,7 +5255,12 @@ class CodemanApp {
const container = this.$('sessionTabs');
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
const currentIds = new Set(this.sessions.keys());
// Grouped rail: a collapsed group keeps its rows out of the DOM, so compare
// against the rows the projection SHOWS, not every live session.
const groupProjection = this._projectTabGroups();
const currentIds = groupProjection
? new Set(groupProjection.visibleRefs.filter((ref) => ref.kind === 'session').map((ref) => ref.id))
: new Set(this.sessions.keys());
// Web tabs live in the same strip but are not in this.sessions, so they need
// their own change check. Without it, the session-only comparison below is
@@ -5246,14 +5269,20 @@ class CodemanApp {
const existingWebIds = [...container.querySelectorAll('.session-tab[data-webview-id]')].map(
t => t.dataset.webviewId
);
const wantedWebIds = (this.webviewOrder || []).filter(id => this.webviews?.has(id));
const wantedWebIds = groupProjection
? groupProjection.visibleRefs.filter((ref) => ref.kind === 'webview').map((ref) => ref.id)
: (this.webviewOrder || []).filter(id => this.webviews?.has(id));
const webTabsUnchanged =
existingWebIds.length === wantedWebIds.length && existingWebIds.every((id, i) => id === wantedWebIds[i]);
// Check if we can do incremental update (same session IDs and same web tabs)
// The grouped rail's structure (sections, collapse, the shown exception) can
// change while the id sets stay equal; the in-place patch below cannot move
// or hide a row, so any structural change takes the full rebuild.
const canIncremental = existingIds.size === currentIds.size &&
[...existingIds].every(id => currentIds.has(id)) &&
webTabsUnchanged;
webTabsUnchanged &&
!this._isTabGroupStructureStale(groupProjection);
if (canIncremental) {
// Read once for the whole pass, like the full-rebuild path: this touches
@@ -5591,6 +5620,10 @@ class CodemanApp {
// layout, and the tabs then carry no inline order at all — the header
// strip's markup is byte-identical to before.
const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));
// One row per session, in tab order. The flat strip emits them as-is; the
// grouped rail places the SAME markup into its sections, so a row never
// differs between the two (badge = Alt+N slot in sessionOrder either way).
const rowHtml = new Map();
let _tabIdx = 0;
for (const id of tabOrder) {
const session = this.sessions.get(id);
@@ -5654,7 +5687,7 @@ class CodemanApp {
const inlineSessionActions = this.shouldInlineSessionActions();
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span>
@@ -5677,12 +5710,36 @@ class CodemanApp {
_tabIdx++;
}
// Web tabs (dashboard URLs) render after the session tabs, continuing the
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
const groupProjection = this._projectTabGroups();
if (groupProjection) {
// Grouped vertical rail. Web tabs keep their flat-strip Alt+N slot (after
// every session), wherever their group puts them.
const webviewSlots = new Map(
(this.webviewOrder || []).filter((wid) => this.webviews?.has(wid)).map((wid, i) => [wid, _tabIdx + i])
);
parts.push(
window.CodemanTabLayout.renderProjection(
groupProjection,
(ref) =>
ref.kind === 'session'
? rowHtml.get(ref.id) || ''
: this.renderWebviewTab?.(ref.id, webviewSlots.get(ref.id) ?? Infinity) || '',
escapeHtml
)
);
this._hiddenTabGroupByRef = new Map(Object.entries(groupProjection.hiddenTabGroupByRef));
} else {
parts.push(...rowHtml.values());
// Web tabs (dashboard URLs) render after the session tabs, continuing the
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
this._hiddenTabGroupByRef = new Map();
}
this._lastTabGroupStructureKey = this._tabGroupStructureKey(groupProjection);
container.innerHTML = parts.join('');
container.classList.toggle('session-tabs--grouped', !!groupProjection);
// Put the strip back where the user left it, then reveal the active tab
// only when it CHANGED (or on the first paint). Restoring unconditionally
@@ -5750,7 +5807,11 @@ class CodemanApp {
// sort is stable, so equal orders keep DOM order, which is the unsorted case.
if (this.isTabRailSorted()) {
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
tabs.sort((a, b) => orderOf(a) - orderOf(b));
// Grouped rail: `order` only sorts WITHIN a group's own flex column, so
// the walk sorts by group first (-1 for every row of the flat strip).
const groups = [...container.querySelectorAll('.tab-layout-group')];
const groupOf = (el) => groups.indexOf(el.closest('.tab-layout-group'));
tabs.sort((a, b) => groupOf(a) - groupOf(b) || orderOf(a) - orderOf(b));
}
const currentIndex = tabs.indexOf(document.activeElement);
@@ -5873,6 +5934,131 @@ class CodemanApp {
}
}
// ═══════════════════════════════════════════════════════════════
// Owner tab layout: grouped vertical rail (read-only)
// ═══════════════════════════════════════════════════════════════
//
// The server owns named tab groups (GET /api/tab-layout, tab-layout*.ts) and
// already projects them onto the global session order, so sessionOrder, Alt+N,
// Ctrl+Tab and every other order consumer are untouched here. This layer only
// decides how the VERTICAL rail draws rows: in sections, with per-device
// collapse. With no groups (or any read failure) the rail is the flat list it
// has always been.
_ensureTabLayoutCoordinator() {
if (this._tabLayoutCoordinator) return this._tabLayoutCoordinator;
if (!window.CodemanTabLayout) return null;
this._tabLayoutCoordinator = window.CodemanTabLayout.createLoadCoordinator({
fetchLayout: async () => {
const data = await this._apiJson('/api/tab-layout');
if (!data?.layout) throw new Error('Tab layout unavailable');
return data.layout;
},
applyLayout: (layout) => this._applyTabLayout(layout),
applyFallback: () => this._applyTabLayout(null),
scheduleRetry: (retry) => setTimeout(retry, 5000),
cancelRetry: (timer) => clearTimeout(timer),
});
return this._tabLayoutCoordinator;
}
_loadTabLayout() {
return this._ensureTabLayoutCoordinator()?.load() ?? Promise.resolve(false);
}
/** SSE tab:layoutChanged carries `{ owner, version }`; the layout itself is re-read. */
_onTabLayoutChanged(data) {
const me = window.__codemanUser;
// Another user's layout changed: nothing of ours moved. (The GET is
// owner-scoped server-side, so this is a saved request, not a guard.)
if (me?.multiUser && typeof data?.owner === 'string' && data.owner !== me.username) return;
if (Number.isSafeInteger(data?.version) && this.tabLayout && data.version <= this.tabLayout.version) return;
this._loadTabLayout();
}
/** Adopt a layout read (or null after a failed read, which renders flat). */
_applyTabLayout(layout) {
let next = null;
if (layout) {
try {
next = window.CodemanTabLayout.normalizeLayout(layout);
} catch {
next = null;
}
}
// An overtaken response is already dropped by the coordinator; this guards a
// reordering between the coordinator and an SSE-triggered reload.
if (next && this.tabLayout && next.version < this.tabLayout.version) return;
this.tabLayout = next;
const storage = this._getTabCollapseStorage();
const collapsed = storage && next
? window.CodemanTabLayout.loadCollapsedGroupIds(storage, next.groups.map((group) => group.id))
: { ids: [], ok: !next };
if (!collapsed.ok) this._tabCollapseStorageFailed = true;
this.collapsedTabGroupIds = new Set(collapsed.ids);
this._fullRenderSessionTabs();
}
/** localStorage, or null once it has failed (collapse then stays all-expanded). */
_getTabCollapseStorage() {
if (this._tabCollapseStorageFailed) return null;
try {
return window.localStorage;
} catch {
this._tabCollapseStorageFailed = true;
return null;
}
}
/**
* The grouped projection for the CURRENT render, or null when the rail should
* render flat: horizontal strip (incl. phones and the sidebar, which force it),
* no layout yet, a failed read, or a layout without groups.
*/
_projectTabGroups() {
if (!this.tabLayout || this._tabOrientation() !== 'vertical' || !window.CodemanTabLayout) return null;
return window.CodemanTabLayout.project(this.tabLayout, {
// sessionOrder, not the sessions Map: rows are built in that order, so a
// session the layout has not placed yet lands where the flat strip has it.
liveSessionIds: this.sessionOrder.filter((id) => this.sessions.has(id)),
openWebviewIds: (this.webviewOrder || []).filter((id) => this.webviews?.has(id)),
collapsedGroupIds: [...this.collapsedTabGroupIds],
activeSessionId: this.activeSessionId,
activeWebviewId: this.activeWebviewId,
});
}
_tabGroupStructureKey(projection) {
return window.CodemanTabLayout?.structureKey(this.tabLayout, projection, [...this.collapsedTabGroupIds]) ?? null;
}
/** True when the DOM's grouping no longer matches what a render would draw. */
_isTabGroupStructureStale(projection = this._projectTabGroups()) {
return this._tabGroupStructureKey(projection) !== this._lastTabGroupStructureKey;
}
/**
* Collapse/expand one group (header click). Per-device: stored in localStorage,
* never sent to the server, so collapsing on a laptop leaves the desktop alone.
* A storage failure leaves every group expanded rather than half-remembered.
*/
toggleTabGroupCollapsed(groupId, forceCollapsed) {
if (!this.tabLayout?.groups?.some((group) => group.id === groupId)) return false;
const next = new Set(this.collapsedTabGroupIds);
const shouldCollapse = forceCollapsed === undefined ? !next.has(groupId) : forceCollapsed === true;
if (shouldCollapse) next.add(groupId);
else next.delete(groupId);
const storage = this._getTabCollapseStorage();
const saved = storage
? window.CodemanTabLayout.saveCollapsedGroupIds(storage, [...next])
: { ids: [], ok: false };
if (!saved.ok) this._tabCollapseStorageFailed = true;
this.collapsedTabGroupIds = new Set(saved.ids);
// The full render also redraws connectors anchored to rows that just moved.
this._fullRenderSessionTabs();
return this.collapsedTabGroupIds.has(groupId) === shouldCollapse;
}
// Set up drag-and-drop handlers on tab elements
setupTabDragHandlers() {
const container = this.$('sessionTabs');
@@ -5884,7 +6070,10 @@ class CodemanApp {
// affordance instead of lying about it — `tabRailSort: 'manual'` is the way
// back to drag-reordering, and Alt+N / Ctrl+Shift+{ } still walk the strip
// order this list is no longer showing.
if (this.isTabRailSorted()) {
// The grouped rail is read-only for now: a flat-order drag cannot express
// "move into that group", and the server would re-rank it within its old
// group anyway. Grouped editing comes with its own drag model.
if (this.isTabRailSorted() || container.classList.contains('session-tabs--grouped')) {
tabs.forEach((tab) => tab.setAttribute('draggable', 'false'));
return;
}
+1
View File
@@ -67,6 +67,7 @@
'Open away digest': '打开离开期间摘要',
'Session Manager': '会话管理器',
'Session actions': '会话操作',
Ungrouped: '未分组',
'Open session manager': '打开会话管理器',
Attachments: '附件',
'Open attachment history': '打开附件历史',
+2
View File
@@ -3739,6 +3739,8 @@
<script defer src="terminal-keycode229-recovery.js"></script>
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
<script defer src="sanitize-html.js"></script>
<!-- Owner tab layout projection (grouped vertical rail); pure, read by app.js. -->
<script defer src="tab-layout-browser.js"></script>
<script defer src="app.js"></script>
<script defer src="tab-rail-resize.js"></script>
<script defer src="terminal-ui.js"></script>
+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);
+87
View File
@@ -634,6 +634,78 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-info {
min-width: 0;
}
/* --- Grouped rail (owner tab layout) ---------------------------------- */
/* Only rendered when the owner has at least one group (tab-layout-browser.js).
Each group's rows sit in their OWN flex column, so the activity sort's inline
`order` (and web tabs' `order: 9999`) rank rows within a group, never across. */
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group,
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-refs {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 2px;
min-width: 0;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group + .tab-layout-group {
margin-top: 4px;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
padding: 4px 6px 2px;
border: 0;
border-radius: 4px;
color: var(--text-muted);
background: transparent;
font: inherit;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-align: left;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle {
cursor: pointer;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:hover {
color: var(--text);
background: var(--bg-hover);
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.tab-layout-group-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-layout-group-count {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
opacity: 0.75;
}
.tab-layout-group-chevron::before {
content: '\25BE';
display: inline-block;
}
.tab-layout-group-toggle[aria-expanded='false'] .tab-layout-group-chevron::before {
content: '\25B8';
}
html[data-tab-orientation='vertical'] .header-right {
margin-left: auto;
}
@@ -10510,6 +10582,21 @@ kbd {
drop-shadow(0 0 9px var(--lineage-color, var(--session-blue, #2b8fd9)));
}
/* An arc re-anchored to a collapsed group's header (its row is hidden): quieter
and dotted, so it reads as "somewhere in there" rather than a real row. */
.connection-line.lineage-line--proxied {
stroke-dasharray: 2 7;
opacity: 0.46;
}
.connection-line.lineage-line--proxied.lineage-line--working {
opacity: 0.62;
}
.lineage-line-dot.lineage-line-dot--proxied {
opacity: 0.46;
}
/* The child end marches while that worker is actually working, so the line
itself carries the signal. Motion is opt-out-able at the OS level. */
@media (prefers-reduced-motion: no-preference) {
+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);
+20 -10
View File
@@ -301,13 +301,26 @@ Object.assign(CodemanApp.prototype, {
let idx = startIndex;
for (const id of this.webviewOrder) {
const webview = this.webviews.get(id);
if (!webview) continue;
const isActive = id === this.activeWebviewId;
const jsonId = escapeHtml(JSON.stringify(id));
const icon = webview.icon ? escapeHtml(webview.icon) : '';
if (!this.webviews.get(id)) continue;
parts.push(this.renderWebviewTab(id, idx));
idx++;
}
return parts.join('');
},
parts.push(`<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
/**
* One web tab's HTML; `idx` is its zero-based Alt+N slot (no badge from 9 up).
* The grouped vertical rail places single web tabs into their group with this,
* so a web tab's markup is the same in every layout.
*/
renderWebviewTab(id, idx) {
const webview = this.webviews.get(id);
if (!webview) return '';
const isActive = id === this.activeWebviewId;
const jsonId = escapeHtml(JSON.stringify(id));
const icon = webview.icon ? escapeHtml(webview.icon) : '';
return `<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
onclick="app.handleWebviewTabClick(event, ${jsonId})"
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
@@ -319,10 +332,7 @@ Object.assign(CodemanApp.prototype, {
</span>
</span>
<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">&#x2699;</span><span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">&times;</span></span>
</div>`);
idx++;
}
return parts.join('');
</div>`;
},
_webviewGlobeIcon() {
+296
View File
@@ -0,0 +1,296 @@
/**
* @fileoverview Browser projection of the owner tab layout (tab-layout-browser.js).
*
* Pins the pure half of the grouped vertical rail: which live rows land in which
* group, what a collapsed group hides (and the one row it must keep showing),
* the per-device collapse storage, the grouped markup, and newest-wins loading.
*
* Port: none (vm-loaded module + static wiring assertions).
*/
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
const SOURCE = readFileSync(new URL('../src/web/public/tab-layout-browser.js', import.meta.url), 'utf8');
const APP_SOURCE = readFileSync(new URL('../src/web/public/app.js', import.meta.url), 'utf8');
const INDEX_SOURCE = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf8');
const BUILD_SOURCE = readFileSync(new URL('../scripts/build.mjs', import.meta.url), 'utf8');
type Ref = { kind: 'session' | 'webview'; id: string };
function loadHelper() {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(SOURCE, context, { filename: 'tab-layout-browser.js' });
return (context.window as any).CodemanTabLayout;
}
const escape = (value: string) =>
value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const layout = (version = 4) => ({
version,
updatedAt: '2026-08-16T00:00:00.000Z',
groups: [
{
id: 'g1',
name: '<Core & Ops>',
refs: [
{ kind: 'session', id: 's2' },
{ kind: 'webview', id: 'w1' },
{ kind: 'session', id: 's1' },
{ kind: 'webview', id: 'saved-closed' },
{ kind: 'session', id: 'gone' },
],
},
{ id: 'g2', name: 'Later', refs: [{ kind: 'session', id: 's3' }] },
],
ungrouped: [
{ kind: 'webview', id: 'w2' },
{ kind: 'session', id: 's4' },
{ kind: 'session', id: 's2' },
],
});
const ids = (refs: Ref[]) => refs.map((ref) => `${ref.kind}:${ref.id}`);
describe('projection', () => {
it('returns null for a layout without groups, so the caller keeps the flat rail', () => {
const h = loadHelper();
expect(h.project({ version: 1, groups: [], ungrouped: [{ kind: 'session', id: 's1' }] }, {})).toBeNull();
expect(h.project(null, {})).toBeNull();
expect(h.hasGroups(h.normalizeLayout({ version: 1 }))).toBe(false);
});
it('places stored refs in their groups, skips gone/unopened refs, and deduplicates', () => {
const h = loadHelper();
const result = h.project(layout(), {
liveSessionIds: ['s1', 's2', 's3', 's4'],
openWebviewIds: ['w1', 'w2'],
});
expect(result.sections.map((s: any) => [s.id, ids(s.refs), s.count])).toEqual([
['g1', ['session:s2', 'webview:w1', 'session:s1'], 3],
['g2', ['session:s3'], 1],
[null, ['webview:w2', 'session:s4'], 2],
]);
expect(new Set(ids(result.visibleRefs)).size).toBe(result.visibleRefs.length);
});
it('appends live sessions and open web tabs the layout has not placed, in caller order', () => {
const h = loadHelper();
const result = h.project(layout(), {
liveSessionIds: ['new-b', 's1', 'new-a'],
openWebviewIds: ['w-local'],
});
expect(ids(result.sections.at(-1).refs)).toEqual(['session:new-b', 'session:new-a', 'webview:w-local']);
});
it('omits an empty ungrouped section', () => {
const h = loadHelper();
const result = h.project(layout(), { liveSessionIds: ['s1', 's3'], openWebviewIds: [] });
expect(result.sections.map((s: any) => s.id)).toEqual(['g1', 'g2']);
});
it('hides a collapsed group but keeps its highlighted row, and records where hidden rows went', () => {
const h = loadHelper();
const common = {
liveSessionIds: ['s1', 's2', 's3', 's4'],
openWebviewIds: ['w1', 'w2'],
collapsedGroupIds: ['g1'],
};
const collapsed = h.project(layout(), { ...common, activeSessionId: 's1' });
expect(ids(collapsed.sections[0].refs)).toEqual(['session:s1']);
expect(collapsed.sections[0].count).toBe(3);
expect(collapsed.sections[0].collapsed).toBe(true);
expect(collapsed.hiddenTabGroupByRef).toEqual({ 'session:s2': 'g1', 'webview:w1': 'g1' });
// An active web tab owns the highlight even while a session stays selected.
const web = h.project(layout(), { ...common, activeSessionId: 's1', activeWebviewId: 'w1' });
expect(ids(web.sections[0].refs)).toEqual(['webview:w1']);
});
});
describe('structure key', () => {
it('changes with every structural input and ignores everything else', () => {
const h = loadHelper();
const opts = { liveSessionIds: ['s1', 's2', 's3'], openWebviewIds: [] };
const base = h.structureKey(layout(), h.project(layout(), opts), []);
expect(h.structureKey(layout(), h.project(layout(), opts), [])).toBe(base);
expect(h.structureKey(layout(5), h.project(layout(5), opts), [])).not.toBe(base);
expect(h.structureKey(layout(), h.project(layout(), { ...opts, collapsedGroupIds: ['g1'] }), ['g1'])).not.toBe(
base
);
expect(h.structureKey(layout(), h.project(layout(), { ...opts, liveSessionIds: ['s1', 's2'] }), [])).not.toBe(base);
expect(h.structureKey(layout(), null, [])).toBeNull();
});
});
describe('per-device collapse storage', () => {
function storage(initial: string | null = null) {
const values = new Map<string, string>();
if (initial !== null) values.set('codeman:tab-groups-collapsed', initial);
return {
getItem: vi.fn((key: string) => values.get(key) ?? null),
setItem: vi.fn((key: string, next: string) => {
values.set(key, next);
}),
};
}
it('round-trips collapsed group ids', () => {
const h = loadHelper();
const local = storage();
expect(h.saveCollapsedGroupIds(local, ['g2', 'g1', 'g2'])).toEqual({ ids: ['g2', 'g1'], ok: true });
expect(h.loadCollapsedGroupIds(local, ['g1', 'g2'])).toEqual({ ids: ['g2', 'g1'], ok: true });
});
it('garbage-collects ids of groups that no longer exist', () => {
const h = loadHelper();
const local = storage('["gone","g1"]');
expect(h.loadCollapsedGroupIds(local, ['g1', 'g2'])).toEqual({ ids: ['g1'], ok: true });
expect(local.setItem).toHaveBeenLastCalledWith('codeman:tab-groups-collapsed', '["g1"]');
});
it('reports failure (all-expanded) on unreadable, malformed or unwritable storage', () => {
const h = loadHelper();
const throwing = {
getItem: () => {
throw new Error('denied');
},
setItem: () => {
throw new Error('quota');
},
};
expect(h.loadCollapsedGroupIds(throwing, ['g1'])).toEqual({ ids: [], ok: false });
expect(h.loadCollapsedGroupIds(storage('{"g1":true}'), ['g1'])).toEqual({ ids: [], ok: false });
expect(h.saveCollapsedGroupIds(throwing, ['g1'])).toEqual({ ids: [], ok: false });
});
});
describe('grouped markup', () => {
it('renders escaped group sections with real toggle buttons around caller-rendered rows', () => {
const h = loadHelper();
const projection = h.project(layout(), {
liveSessionIds: ['s1', 's2', 's3', 's4'],
openWebviewIds: ['w1', 'w2'],
collapsedGroupIds: ['g2'],
});
const html = h.renderProjection(
projection,
(ref: Ref) => `<div class="row" data-ref="${ref.kind}:${ref.id}"></div>`,
escape
);
const doc = new JSDOM(`<main>${html}</main>`).window.document;
expect([...doc.querySelectorAll('.tab-layout-group-name')].map((el) => el.textContent)).toEqual([
'<Core & Ops>',
'Later',
'Ungrouped',
]);
expect(doc.querySelectorAll('.tab-layout-group-name *')).toHaveLength(0);
expect(doc.querySelector('.tab-layout-group-name')?.hasAttribute('data-i18n-skip')).toBe(true);
const g1 = doc.querySelector<HTMLElement>('[data-tab-group-header="g1"]')!;
const g2 = doc.querySelector<HTMLElement>('[data-tab-group-header="g2"]')!;
expect(g1.tagName).toBe('BUTTON');
expect(g1.getAttribute('aria-expanded')).toBe('true');
expect(g2.getAttribute('aria-expanded')).toBe('false');
expect(doc.getElementById(g1.getAttribute('aria-controls')!)?.querySelectorAll('.row')).toHaveLength(3);
// The ungrouped header is a label, not a control: there is nothing to collapse.
expect(doc.querySelector('.tab-layout-ungrouped-header')?.tagName).toBe('DIV');
expect([...doc.querySelectorAll<HTMLElement>('.row')].map((el) => el.dataset.ref)).toEqual(
ids(projection.visibleRefs)
);
});
});
describe('load coordination', () => {
it('applies only the newest response when loads overlap', async () => {
const h = loadHelper();
const pending: Array<(value: unknown) => void> = [];
const applied: number[] = [];
const coordinator = h.createLoadCoordinator({
fetchLayout: () => new Promise((resolve) => pending.push(resolve)),
applyLayout: (value: any) => applied.push(value.version),
applyFallback: vi.fn(),
scheduleRetry: vi.fn(),
});
const first = coordinator.load();
const second = coordinator.load();
pending[1](layout(2));
await second;
pending[0](layout(1));
await first;
expect(applied).toEqual([2]);
});
it('falls back on failure, schedules one retry, and lets a successful retry replace the fallback', async () => {
const h = loadHelper();
const applyLayout = vi.fn();
const applyFallback = vi.fn();
let retry: (() => Promise<boolean>) | undefined;
let attempt = 0;
const coordinator = h.createLoadCoordinator({
fetchLayout: async () => {
attempt++;
if (attempt === 1) throw new Error('offline');
return layout(7);
},
applyLayout,
applyFallback,
scheduleRetry: (fn: () => Promise<boolean>) => {
retry = fn;
return 42;
},
cancelRetry: vi.fn(),
});
await coordinator.load();
expect(applyFallback).toHaveBeenCalledTimes(1);
expect(applyLayout).not.toHaveBeenCalled();
await retry!();
expect(applyLayout).toHaveBeenCalledWith(layout(7));
});
it('cancels a pending retry and ignores in-flight results after dispose', async () => {
const h = loadHelper();
let resolve!: (value: unknown) => void;
const applyLayout = vi.fn();
const cancelRetry = vi.fn();
let calls = 0;
const coordinator = h.createLoadCoordinator({
fetchLayout: () => {
calls++;
if (calls === 1) return Promise.reject(new Error('offline'));
return new Promise((r) => (resolve = r));
},
applyLayout,
applyFallback: vi.fn(),
scheduleRetry: () => 7,
cancelRetry,
});
await coordinator.load();
const second = coordinator.load();
expect(cancelRetry).toHaveBeenCalledWith(7);
coordinator.dispose();
resolve(layout(3));
await second;
expect(applyLayout).not.toHaveBeenCalled();
});
});
describe('browser wiring', () => {
it('loads the helper before app.js, ships it through the build, and wires GET + SSE', () => {
expect(INDEX_SOURCE.indexOf('<script defer src="tab-layout-browser.js"')).toBeGreaterThan(-1);
expect(INDEX_SOURCE.indexOf('<script defer src="tab-layout-browser.js"')).toBeLessThan(
INDEX_SOURCE.indexOf('<script defer src="app.js"')
);
expect(BUILD_SOURCE).toContain("'tab-layout-browser.js'");
expect(APP_SOURCE).toContain("this._apiJson('/api/tab-layout')");
expect(APP_SOURCE).toContain("[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged']");
});
it('never writes the layout from the browser in this slice', () => {
expect(APP_SOURCE).not.toMatch(/['"`]PUT['"`][^\n]*tab-layout|tab-layout[^\n]*['"`]PUT['"`]/);
expect(SOURCE).not.toContain('fetch(');
});
});
+377
View File
@@ -0,0 +1,377 @@
/**
* @fileoverview The grouped vertical rail, driven through the shipping CodemanApp.
*
* Covers the app.js half of the owner-tab-layout frontend: the rail renders the
* owner's groups only when it is vertical AND there is at least one group (every
* other case must be byte-for-byte the flat rail), collapse is per-device and
* keeps the active row, a structural change escapes the incremental patch path,
* drag-reorder is withheld, and lineage arcs to a collapse-hidden session anchor
* to its group header.
*
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), so
* `document`, `localStorage` and `window` below are that window's, not Node's.
*
* Port: none.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = join(process.cwd(), 'src/web/public');
let CodemanApp: { prototype: Record<string, any> };
let window: any;
let document: Document;
let localStorage: Storage;
let localStorageDescriptor: PropertyDescriptor | undefined;
beforeAll(async () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://localhost/',
runScripts: 'outside-only',
});
// Evaluate after DOMContentLoaded so app.js's own bootstrap (new CodemanApp()
// on that event) never runs: each test builds the instance it needs.
if (dom.window.document.readyState !== 'complete') {
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
}
window = dom.window;
document = window.document;
localStorage = window.localStorage;
localStorageDescriptor = Object.getOwnPropertyDescriptor(window, 'localStorage');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
window.setInterval = () => 0;
window.requestAnimationFrame = () => 0;
window.CSS = { escape: (value: string) => value };
window.eval(
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
read('constants.js') +
'\n' +
read('tab-layout-browser.js') +
'\n' +
read('app.js') +
'\n' +
read('webview-tabs.js') +
'\n' +
read('session-lineage.js') +
'\n;window.__GroupedRailCodemanApp = CodemanApp;'
);
CodemanApp = window.__GroupedRailCodemanApp;
});
const layout = {
version: 8,
updatedAt: '2026-08-16T00:00:00.000Z',
groups: [
{
id: 'group-x',
name: '<Core & Ops>',
refs: [
{ kind: 'session', id: 's2' },
{ kind: 'webview', id: 'w1' },
{ kind: 'session', id: 's1' },
],
},
],
ungrouped: [{ kind: 'session', id: 's3' }],
};
function makeApp(options: { tabLayout?: unknown } = {}) {
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
document.documentElement.dataset.tabRailSort = 'manual';
document.body.innerHTML = '<div id="sessionTabs"></div>';
app.$ = (id: string) => document.getElementById(id);
app.sessions = new Map([
['s1', { id: 's1', name: 'One', status: 'idle' }],
['s2', { id: 's2', name: 'Two', status: 'busy' }],
['s3', { id: 's3', name: 'Three', status: 'idle' }],
]);
app.sessionOrder = ['s1', 's2', 's3'];
app.webviews = new Map([['w1', { id: 'w1', name: 'Dashboard', url: 'https://example.test' }]]);
app.webviewOrder = ['w1'];
app.activeSessionId = 's2';
app.activeWebviewId = null;
app.tabLayout = 'tabLayout' in options ? options.tabLayout : layout;
app.collapsedTabGroupIds = new Set();
app._hiddenTabGroupByRef = new Map();
app._lastTabGroupStructureKey = null;
app._tabCollapseStorageFailed = false;
app._inlineRenameActive = false;
app.tabAlerts = new Map();
app.terminalLoadStates = new Map();
app.minimizedSubagents = new Map();
app.hasTabDetachOverride = () => false;
app.renderSubagentTabBadge = () => '';
app.cancelHideSubagentDropdown = () => {};
app.updateTabOverflowMode = () => {};
app.updateConnectionLines = vi.fn();
app._applyTabEntrances = () => {};
app._scrollActiveTabIntoView = () => {};
app._refreshMobileOverviewIfVisible = () => {};
app._refreshHomeSessionsIfVisible = () => {};
app.applySidebarFilter = () => {};
return app;
}
const rowIds = () =>
[...document.querySelectorAll<HTMLElement>('#sessionTabs .session-tab')].map(
(tab) => tab.dataset.webviewId || tab.dataset.id
);
const badges = () =>
[...document.querySelectorAll<HTMLElement>('#sessionTabs .session-tab')].map(
(tab) => tab.querySelector('.tab-number')?.textContent ?? null
);
beforeEach(() => {
delete window.__codemanUser;
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
document.body.innerHTML = '';
localStorage.removeItem('codeman:tab-groups-collapsed');
});
afterEach(() => {
if (localStorageDescriptor) Object.defineProperty(window, 'localStorage', localStorageDescriptor);
else delete window.localStorage;
});
describe('flat rail is unchanged without groups', () => {
it('renders identical markup for no layout, a failed read and a layout with zero groups', () => {
const none = makeApp({ tabLayout: null });
none._fullRenderSessionTabs();
const flat = document.getElementById('sessionTabs')!.innerHTML;
expect(rowIds()).toEqual(['s1', 's2', 's3', 'w1']);
const empty = makeApp({ tabLayout: { version: 3, groups: [], ungrouped: [{ kind: 'session', id: 's3' }] } });
empty._fullRenderSessionTabs();
expect(document.getElementById('sessionTabs')!.innerHTML).toBe(flat);
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
expect(document.getElementById('sessionTabs')!.classList.contains('session-tabs--grouped')).toBe(false);
});
it('keeps the horizontal strip flat even when the owner has groups', () => {
const app = makeApp();
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
app._fullRenderSessionTabs();
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
expect(rowIds()).toEqual(['s1', 's2', 's3', 'w1']);
});
});
describe('grouped vertical rail', () => {
it('draws escaped sections in layout order while badges keep their Alt+N slot', () => {
const app = makeApp();
app._fullRenderSessionTabs();
expect([...document.querySelectorAll('.tab-layout-group-name')].map((el) => el.textContent)).toEqual([
'<Core & Ops>',
'Ungrouped',
]);
expect(document.querySelectorAll('.tab-layout-group-name *')).toHaveLength(0);
expect(rowIds()).toEqual(['s2', 'w1', 's1', 's3']);
// sessionOrder is s1,s2,s3 and the web tab follows every session: the badge
// names the Alt+N key, never the row position.
expect(badges()).toEqual(['2', '4', '1', '3']);
expect(document.querySelectorAll('.session-tab.active')).toHaveLength(1);
});
it('withholds drag-reorder in the grouped rail only', () => {
const app = makeApp();
app._fullRenderSessionTabs();
const draggable = () =>
[...document.querySelectorAll<HTMLElement>('.session-tab[data-id]')].map((t) => t.getAttribute('draggable'));
expect(draggable()).toEqual(['false', 'false', 'false']);
const flat = makeApp({ tabLayout: null });
flat._fullRenderSessionTabs();
expect(draggable()).toEqual(['true', 'true', 'true']);
});
it('collapses from the header button, persists per device, and keeps the active row', () => {
const app = makeApp();
app._fullRenderSessionTabs();
const button = document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!;
// The inline onclick calls the GLOBAL app with the button's own dataset.
expect(button.getAttribute('onclick')).toBe('app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)');
app.toggleTabGroupCollapsed(button.dataset.tabGroupHeader);
expect([...app.collapsedTabGroupIds]).toEqual(['group-x']);
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('["group-x"]');
const header = document.querySelector('[data-tab-group-header="group-x"]')!;
expect(header.getAttribute('aria-expanded')).toBe('false');
expect(header.querySelector('.tab-layout-group-count')?.textContent).toBe('3');
expect(rowIds()).toEqual(['s2', 's3']);
expect(app._hiddenTabGroupByRef.get('session:s1')).toBe('group-x');
app.toggleTabGroupCollapsed('group-x');
expect(rowIds()).toEqual(['s2', 'w1', 's1', 's3']);
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('[]');
});
it('reveals a hidden session when it is selected, without expanding its group', () => {
const app = makeApp();
app.collapsedTabGroupIds = new Set(['group-x']);
app._fullRenderSessionTabs();
expect(rowIds()).toEqual(['s2', 's3']);
app.activeSessionId = 's1';
app._updateActiveTabImmediate('s1');
expect([...app.collapsedTabGroupIds]).toEqual(['group-x']);
expect(rowIds()).toEqual(['s1', 's3']);
expect(badges()).toEqual(['1', '3']);
expect(document.querySelector('.session-tab.active')?.getAttribute('data-id')).toBe('s1');
});
it('patches rows in place while the structure holds, and rebuilds when it changes', () => {
const app = makeApp();
app.collapsedTabGroupIds = new Set(['group-x']);
app._fullRenderSessionTabs();
const full = vi.spyOn(app, '_fullRenderSessionTabs');
app.sessions.get('s3').status = 'busy';
app._renderSessionTabsImmediate();
expect(full).not.toHaveBeenCalled();
expect(document.querySelector('[data-id="s3"] .tab-status')?.className).toBe('tab-status busy');
// Same rows on screen, different grouping: s3 moves into group-x. The id
// sets still match, so only the structure key can catch this.
app.collapsedTabGroupIds = new Set();
app._fullRenderSessionTabs();
full.mockClear();
app.tabLayout = {
...layout,
version: 9,
groups: [{ ...layout.groups[0], refs: [...layout.groups[0].refs, { kind: 'session', id: 's3' }] }],
ungrouped: [],
};
app._renderSessionTabsImmediate();
expect(full).toHaveBeenCalledTimes(1);
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(1);
expect(rowIds()).toEqual(['s2', 'w1', 's1', 's3']);
});
it('falls back to all-expanded when collapse cannot be stored', () => {
const app = makeApp();
const setItem = vi.spyOn(window.Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota');
});
app.toggleTabGroupCollapsed('group-x', true);
setItem.mockRestore();
expect(app.collapsedTabGroupIds.size).toBe(0);
expect(app._tabCollapseStorageFailed).toBe(true);
expect(document.querySelector('[data-tab-group-header="group-x"]')?.getAttribute('aria-expanded')).toBe('true');
});
it('survives localStorage itself being unavailable', () => {
const app = makeApp({ tabLayout: null });
Object.defineProperty(window, 'localStorage', {
configurable: true,
get: () => {
throw new window.DOMException('denied', 'SecurityError');
},
});
expect(() => app._applyTabLayout(layout)).not.toThrow();
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(2);
expect(app.collapsedTabGroupIds.size).toBe(0);
});
});
describe('layout adoption', () => {
it('garbage-collects stale collapse ids and renders on adoption; a failed read renders flat', () => {
localStorage.setItem('codeman:tab-groups-collapsed', '["gone","group-x"]');
const app = makeApp({ tabLayout: null });
app._applyTabLayout(layout);
expect([...app.collapsedTabGroupIds]).toEqual(['group-x']);
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('["group-x"]');
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(2);
app._applyTabLayout(null);
expect(app.tabLayout).toBeNull();
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
expect(rowIds()).toEqual(['s1', 's2', 's3', 'w1']);
});
it('ignores an older layout than the one it already shows', () => {
const app = makeApp();
app.tabLayout = { ...layout, version: 9 };
app._applyTabLayout({ ...layout, version: 8, groups: [] });
expect(app.tabLayout.version).toBe(9);
});
it('re-reads on tab:layoutChanged only for its own owner and a newer version', () => {
const app = makeApp();
app._loadTabLayout = vi.fn();
app.tabLayout = { version: 8, groups: [], ungrouped: [] };
app._onTabLayoutChanged({ owner: '@single', version: 8 });
expect(app._loadTabLayout).not.toHaveBeenCalled();
app._onTabLayoutChanged({ owner: '@single', version: 9 });
expect(app._loadTabLayout).toHaveBeenCalledTimes(1);
window.__codemanUser = { username: 'alice', multiUser: true };
app._onTabLayoutChanged({ owner: 'bob', version: 20 });
expect(app._loadTabLayout).toHaveBeenCalledTimes(1);
app._onTabLayoutChanged({ owner: 'alice', version: 20 });
expect(app._loadTabLayout).toHaveBeenCalledTimes(2);
});
it('loads through GET /api/tab-layout and adopts the envelope payload', async () => {
const app = makeApp({ tabLayout: null });
app._tabLayoutCoordinator = null;
app._apiJson = vi.fn(async () => ({ layout }));
await app._loadTabLayout();
expect(app._apiJson).toHaveBeenCalledWith('/api/tab-layout');
expect(app.tabLayout.version).toBe(8);
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(2);
app._tabLayoutCoordinator.dispose();
});
});
describe('lineage in the grouped rail', () => {
function rect(top: number) {
return () => ({ left: 32, top, right: 292, bottom: top + 36, width: 260, height: 36, x: 32, y: top }) as DOMRect;
}
it('anchors a collapse-hidden endpoint to its group header', () => {
const app = makeApp();
app.activeSessionId = 's3';
app.sessions.get('s3').status = 'working';
app.sessions.get('s3').parentSessionId = 's1';
app.collapsedTabGroupIds = new Set(['group-x']);
app._lineageLinesEnabled = () => true;
app._fullRenderSessionTabs();
expect(rowIds()).toEqual(['s3']);
document.getElementById('sessionTabs')!.getBoundingClientRect = () =>
({ left: 0, top: 0, right: 320, bottom: 320, width: 320, height: 320 }) as DOMRect;
document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!.getBoundingClientRect = rect(20);
document.querySelector<HTMLElement>('[data-id="s3"]')!.getBoundingClientRect = rect(180);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
app._appendLineageConnectionLines(svg, new Map());
const line = svg.querySelector('.lineage-line');
expect(line?.classList.contains('lineage-line--proxied')).toBe(true);
expect(line?.getAttribute('data-parent-tab')).toBe('s1');
expect(svg.querySelector('.lineage-line-dot')?.classList.contains('lineage-line-dot--proxied')).toBe(true);
expect(app._lineageEdgeCount).toBe(1);
});
it('draws nothing when both endpoints hide behind the same header', () => {
const app = makeApp();
app.activeSessionId = 's3';
app.sessions.get('s1').parentSessionId = 's2';
app.collapsedTabGroupIds = new Set(['group-x']);
app._lineageLinesEnabled = () => true;
app._fullRenderSessionTabs();
document.getElementById('sessionTabs')!.getBoundingClientRect = rect(0);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
app._appendLineageConnectionLines(svg, new Map());
expect(svg.querySelectorAll('.lineage-line')).toHaveLength(0);
expect(app._lineageEdgeCount).toBe(0);
});
});