diff --git a/CLAUDE.md b/CLAUDE.md index bce504cf..d42a87be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -370,7 +370,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Service worker precache** (`sw.js` + `scripts/build.mjs`): `BUILD_ID` and `HASHED_ASSETS` are build-generated and the build THROWS unless each declaration appears exactly once; `caches.match` must pass `ignoreSearch: true` because `cacheBustAssets` appends `?v=` to hashed names. → [architecture-invariants#service-worker-precache-and-cache-key](docs/architecture-invariants.md#service-worker-precache-and-cache-key) -**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](docs/architecture-invariants.md#dismissing-the-on-screen-keyboard) +**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`; roving-tabindex items sit at `tabindex=-1`, so the grouped rail's are listed as `[role="treeitem"]`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](docs/architecture-invariants.md#dismissing-the-on-screen-keyboard) **Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 599px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 34f7c6fc..16a75d3d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -298,14 +298,14 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t ⚠️ **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. +- **Grouped iff vertical AND the owner has at least one group.** No layout, a failed `GET` (newest-wins via `createLoadCoordinator`, retried at 5/10/20/40 s and then left to the next SSE init or `tab:layoutChanged`) or zero groups renders the flat rail unchanged; the horizontal strip, phones and the sidebar never group. ⚠️ Adopting a layout rebuilds the strip ONLY when the structure key changed (`_applyTabLayout`): the server announces a change on every session create/close and order PUT, and the key deliberately leaves the layout version out, so those announcements cost a flat rail nothing. - **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. +- **Collapse is per-device** (`codeman:tab-groups-collapsed` in localStorage, ids of deleted groups garbage-collected on adoption). A store that throws means all-expanded; a malformed stored VALUE reads as empty and is rewritten, so it can never disable collapse on that device for good. 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. +- **A collapsed header carries the most urgent alert it hides** (`hiddenGroupAlerts()`, applied by `_syncTabGroupHeaderAlerts` on BOTH render paths, since alerts change without a rebuild), in the tab alert language: `tab-alert-action` red, `tab-alert-idle` yellow. A permission prompt behind a collapse must never be invisible. - **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. -- **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's. -- **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. - +- **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. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device. +- **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's. +- **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard). ⚠️ **`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. @@ -939,7 +939,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u **Dismissing the on-screen keyboard** (PRs #279/#280, `terminal-ui.js`): the terminal parks focus on a hidden textarea that nothing used to release, so TWO gestures now blur it, and they own different regions. -**(1)** `_installMobileKeyboardDismiss()` — a document-level `touchend` that fires only while the terminal input actually holds focus, **never inside `#terminalContainer`** (tap classification owns that) and **never on a control** (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, matched with `closest()` so an icon inside a button counts). Session tabs are covered by the selector's `[tabindex]:not([tabindex="-1"])` arm, which is what stops a tab tap from blurring and then being re-focused by `selectSession()`. +**(1)** `_installMobileKeyboardDismiss()`, a document-level `touchend` that fires only while the terminal input actually holds focus, **never inside `#terminalContainer`** (tap classification owns that) and **never on a control** (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, matched with `closest()` so an icon inside a button counts). Session tabs are covered by the selector's `[tabindex]:not([tabindex="-1"])` arm, which is what stops a tab tap from blurring and then being re-focused by `selectSession()`. ⚠️ A roving-tabindex widget defeats that arm: the grouped rail's tree parks every item but one at `tabindex=-1`, and its group headers are not buttons, so the selector lists `[role="treeitem"]` by role (pinned by `test/tab-layout-rail.test.ts` and `test/tab-activation.browser.test.ts`). **(2)** In `_handleMobileTerminalTap`, a second tap on **inert `content`** (`startedWithTerminalFocus`) blurs instead of re-focusing. ⚠️ Scoped to `content` on purpose: the prompt row (`input`) keeps focus-then-position so a second tap still places the caret, and actionable rows blur earlier via `_isActionableMobileTerminalTap`. diff --git a/src/web/public/app.js b/src/web/public/app.js index 3e285b30..fbb03342 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -5581,6 +5581,15 @@ class CodemanApp { subagentBadgeEl.remove(); } } + // Grouped tree: the loop above can re-sort rows (`style.order`), move the + // highlight, and change the alerts a collapsed header stands in for, none + // of which rebuilds the rail. Keep what assistive tech and the headers + // report in step. The flat list (role=tablist) never takes this branch. + if (groupProjection && container.getAttribute('role') === 'tree') { + this._syncTabTreeSelection(container); + this._applyTabTreePositions(container); + this._syncTabGroupHeaderAlerts(container, groupProjection); + } } else { // Full rebuild needed (sessions added/removed) this._fullRenderSessionTabs(); @@ -5859,7 +5868,10 @@ class CodemanApp { container.innerHTML = parts.join(''); container.classList.toggle('session-tabs--grouped', !!groupProjection); this._applyTabListRole(container, !!groupProjection); - if (groupProjection) this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside }); + if (groupProjection) { + this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside }); + this._syncTabGroupHeaderAlerts(container, 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 @@ -6065,21 +6077,7 @@ class CodemanApp { } } for (const header of container.querySelectorAll('[data-tab-group-header]')) header.setAttribute('aria-level', '1'); - // Position within each level: the level-1 run (headers, ungrouped rows, a - // collapsed group's kept row) and each group's own rows. - const items = this._tabTreeItems(container); - const sets = new Map(); - for (const item of items) { - const owner = item.getAttribute('aria-level') === '2' ? item.closest('[role="group"]') : container; - if (!sets.has(owner)) sets.set(owner, []); - sets.get(owner).push(item); - } - for (const members of sets.values()) { - members.forEach((item, index) => { - item.setAttribute('aria-setsize', String(members.length)); - item.setAttribute('aria-posinset', String(index + 1)); - }); - } + const items = this._applyTabTreePositions(container); const byIdentity = (id) => (id ? items.find((item) => this._tabTreeIdentity(item) === id) : null); // A focused row that a collapse just hid hands focus to its group header. @@ -6094,6 +6092,47 @@ class CodemanApp { if (refocus && document.activeElement !== target) target.focus(); } + /** + * aria-posinset / aria-setsize within each level, in PAINTED order: the + * level-1 run (headers, ungrouped rows, a collapsed group's kept row) and each + * group's own rows. Runs after every full render AND after an incremental pass, + * because the activity-sorted rail re-sorts rows in place. Returns the items. + */ + _applyTabTreePositions(container) { + const items = this._tabTreeItems(container); + const sets = new Map(); + for (const item of items) { + const owner = item.getAttribute('aria-level') === '2' ? item.closest('[role="group"]') : container; + if (!sets.has(owner)) sets.set(owner, []); + sets.get(owner).push(item); + } + for (const members of sets.values()) { + members.forEach((item, index) => { + const setsize = String(members.length); + const posinset = String(index + 1); + if (item.getAttribute('aria-setsize') !== setsize) item.setAttribute('aria-setsize', setsize); + if (item.getAttribute('aria-posinset') !== posinset) item.setAttribute('aria-posinset', posinset); + }); + } + return items; + } + + /** + * A collapsed group hides its rows, including ones that need the user. Its + * header takes the most urgent hidden alert in the tab alert language + * (`tab-alert-action` red, `tab-alert-idle` yellow), so a permission prompt + * behind a collapse is never invisible. Patched in place on both render paths: + * alerts change without a rebuild. + */ + _syncTabGroupHeaderAlerts(container, projection) { + const alerts = window.CodemanTabLayout?.hiddenGroupAlerts(projection, (id) => this.tabAlerts?.get(id)) || {}; + for (const header of container.querySelectorAll('[data-tab-group-header]')) { + const alert = alerts[header.dataset.tabGroupHeader]; + header.classList.toggle('tab-alert-action', alert === 'action'); + header.classList.toggle('tab-alert-idle', alert === 'idle'); + } + } + /** Keep aria-selected on the grouped tree in step with the .active class. */ _syncTabTreeSelection(container) { if (container?.getAttribute('role') !== 'tree') return; @@ -6112,10 +6151,17 @@ class CodemanApp { if (e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return; const items = this._tabTreeItems(container); const current = e.target?.closest?.('[role="treeitem"]'); + // A key pressed on a control INSIDE a row (its close or overflow button, + // focused by a click or handed focus back by the action menu) belongs to + // that control: Enter there must reopen the menu, not re-select the row. + // Same contract as the flat list, which acts only on a focused row. + if (!current || current !== e.target) return; const index = items.indexOf(current); if (index < 0) return; const groupId = current.dataset.tabGroupHeader || null; const expanded = current.getAttribute('aria-expanded') === 'true'; + // A group with no open rows is a leaf (no aria-expanded): nothing to open. + const expandable = current.hasAttribute('aria-expanded'); const focusAt = (next) => { if (!next) return; this._setTabTreeStop(container, next); @@ -6141,7 +6187,7 @@ class CodemanApp { focusAt(items[items.length - 1]); break; case 'ArrowRight': - if (!groupId) return; + if (!groupId || !expandable) return; if (!expanded) toggle(false); else { const child = items.find((item) => item.closest('[role="group"]')?.id === current.getAttribute('aria-owns')); @@ -6151,7 +6197,7 @@ class CodemanApp { break; case 'ArrowLeft': if (groupId) { - if (!expanded) return; + if (!expandable || !expanded) return; toggle(true); } else { const group = current.closest('[role="group"]'); @@ -6287,7 +6333,13 @@ class CodemanApp { }, applyLayout: (layout) => this._applyTabLayout(layout), applyFallback: () => this._applyTabLayout(null), - scheduleRetry: (retry) => setTimeout(retry, 5000), + // 5 s, 10 s, 20 s, 40 s, then stop until the next SSE init or + // tab:layoutChanged asks again: an unreachable server must not cost a + // fetch every 5 s for as long as the page stays open. + retryDelayMs: 5000, + maxRetryDelayMs: 60000, + maxRetries: 4, + scheduleRetry: (retry, delayMs) => setTimeout(retry, delayMs), cancelRetry: (timer) => clearTimeout(timer), }); return this._tabLayoutCoordinator; @@ -6327,7 +6379,11 @@ class CodemanApp { : { ids: [], ok: !next }; if (!collapsed.ok) this._tabCollapseStorageFailed = true; this.collapsedTabGroupIds = new Set(collapsed.ids); - this._fullRenderSessionTabs(); + // The server announces a layout change on every session create/close, web + // tab create/delete and order PUT, and most of those move nothing on this + // rail (always so on the flat rail, which is every owner without groups). + // Rebuild only when what the rail would draw actually changed. + if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs(); } /** localStorage, or null once it has failed (collapse then stays all-expanded). */ @@ -6495,10 +6551,26 @@ class CodemanApp { }); } + /** + * Grouped rail: Ctrl+Shift+{ / } may only swap the active session with a + * neighbour in its OWN section. Across a group boundary the server re-ranks + * each group on its own (`putLegacyOrder`), so nothing moves there, no + * session:orderChanged comes back, and this client would keep a swapped + * sessionOrder (and Alt+N targets) that no other device shares. Same reason + * drag is off in the grouped rail. Any other layout: always allowed. + */ + _canSwapActiveTabWith(neighbourId) { + const projection = this._projectTabGroups(); + if (!projection) return true; + const sectionOf = (id) => projection.sectionByRef[`session:${id}`]; + return sectionOf(this.activeSessionId) === sectionOf(neighbourId); + } + moveActiveTabLeft() { if (!this.activeSessionId) return; const idx = this.sessionOrder.indexOf(this.activeSessionId); if (idx <= 0) return; + if (!this._canSwapActiveTabWith(this.sessionOrder[idx - 1])) return; [this.sessionOrder[idx - 1], this.sessionOrder[idx]] = [this.sessionOrder[idx], this.sessionOrder[idx - 1]]; this.saveSessionOrder(); this._fullRenderSessionTabs(); @@ -6508,6 +6580,7 @@ class CodemanApp { if (!this.activeSessionId) return; const idx = this.sessionOrder.indexOf(this.activeSessionId); if (idx === -1 || idx >= this.sessionOrder.length - 1) return; + if (!this._canSwapActiveTabWith(this.sessionOrder[idx + 1])) return; [this.sessionOrder[idx], this.sessionOrder[idx + 1]] = [this.sessionOrder[idx + 1], this.sessionOrder[idx]]; this.saveSessionOrder(); this._fullRenderSessionTabs(); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index c3c0d34d..81b17118 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -677,6 +677,8 @@ html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group + .tab-layout- } html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header { + /* Positioned so a collapsed header can carry the tab alert ring (::before). */ + position: relative; display: flex; align-items: center; gap: 6px; @@ -728,7 +730,9 @@ html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:focus-v display: inline-block; } -.tab-layout-group-toggle[aria-expanded='false'] .tab-layout-group-chevron::before { +/* Keyed on the section, not aria-expanded: a group with no open rows is a tree + leaf with no aria-expanded at all, and its chevron must still show its state. */ +.tab-layout-group--collapsed > .tab-layout-group-toggle .tab-layout-group-chevron::before { content: '\25B8'; } @@ -1860,7 +1864,8 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix { The overlay paints above the tab's inline content (positioned vs flow), which is fine at these alphas and is exactly what keeps it visible over the active tab's opaque-ish background. */ -.session-tabs .session-tab.tab-alert-action::before { +.session-tabs .session-tab.tab-alert-action::before, +.session-tabs .tab-layout-group-header.tab-alert-action::before { content: ''; position: absolute; inset: -2px; @@ -1885,7 +1890,8 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix { box-shadow: 0 0 6px rgba(239, 68, 68, 0.7); } -.session-tabs .session-tab.tab-alert-idle::before { +.session-tabs .session-tab.tab-alert-idle::before, +.session-tabs .tab-layout-group-header.tab-alert-idle::before { content: ''; position: absolute; inset: -2px; diff --git a/src/web/public/tab-layout-browser.js b/src/web/public/tab-layout-browser.js index fb32e0b0..de5b529d 100644 --- a/src/web/public/tab-layout-browser.js +++ b/src/web/public/tab-layout-browser.js @@ -11,7 +11,7 @@ * 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. + * read degrades to the flat rail with a capped, backed-off retry. * * The server stays the only authority for layout content. Collapse is a * per-device view preference and lives in localStorage only. @@ -61,18 +61,34 @@ return !!layout && Array.isArray(layout.groups) && layout.groups.length > 0; } + /** Stored collapse ids, or null when the stored value is not a JSON array. */ + function parseCollapsedIds(raw) { + if (raw === null) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? stableIds(parsed) : null; + } catch (_error) { + return null; + } + } + + /** + * Read the per-device collapse ids. `ok: false` means the STORE failed (a read + * or write threw), and the caller then keeps every group expanded. A malformed + * VALUE is not a store failure: it reads as "nothing collapsed" and is + * rewritten, or a shape left behind by another build (a rollback) would leave + * collapse disabled on this device for good. + */ 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); + const parsed = parseCollapsedIds(storage.getItem(COLLAPSED_STORAGE_KEY)); + const loaded = 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)); + if (!parsed || kept.length !== loaded.length) storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(kept)); return { ids: kept, ok: true }; } catch (_error) { return { ids: [], ok: false }; @@ -102,8 +118,11 @@ * 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. + * @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }} + * null when the layout has no groups: the caller renders the flat rail + * unchanged. Each section lists the rows it shows (`refs`) and the rows its + * collapse hides (`hidden`); `sectionByRef` maps every placed row + * (`:`) to its section id (null = Ungrouped), shown or hidden. */ function project(layoutInput, options = {}) { if (!layoutInput) return null; @@ -123,31 +142,35 @@ const placed = new Set(); const visibleRefs = []; const hiddenTabGroupByRef = {}; + const sectionByRef = {}; const sections = []; const place = (refs, sectionId, isCollapsed) => { const shown = []; + const hidden = []; let count = 0; for (const ref of refs) { const key = refKey(ref); if (placed.has(key) || !renderable(ref)) continue; placed.add(key); + sectionByRef[key] = sectionId; count++; + const copy = { kind: ref.kind, id: ref.id }; if (isCollapsed && key !== highlighted) { hiddenTabGroupByRef[key] = sectionId; + hidden.push(copy); continue; } - const copy = { kind: ref.kind, id: ref.id }; shown.push(copy); visibleRefs.push(copy); } - return { shown, count }; + return { shown, hidden, 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 { shown, hidden, count } = place(group.refs, group.id, isCollapsed); + sections.push({ id: group.id, name: group.name, refs: shown, hidden, count, collapsed: isCollapsed }); } const omissions = [ ...liveSessionIds.map((id) => ({ kind: 'session', id })), @@ -155,22 +178,64 @@ ]; 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 }); + sections.push({ + id: null, + name: '', + refs: ungrouped.shown, + hidden: [], + count: ungrouped.count, + collapsed: false, + }); } - return { sections, visibleRefs, hiddenTabGroupByRef }; + return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }; + } + + const ALERT_RANK = { action: 2, idle: 1 }; + + /** + * The most urgent alert behind each COLLAPSED header: `{ [groupId]: 'action' | + * 'idle' }` over the session rows the collapse hides. A shown row (the kept + * selection, any expanded group) draws its own alert, so it is not counted + * here. `alertOf(sessionId)` is the caller's tab alert lookup. + */ + function hiddenGroupAlerts(projection, alertOf) { + const result = {}; + const sections = projection && Array.isArray(projection.sections) ? projection.sections : []; + for (const section of sections) { + if (section.id === null || !Array.isArray(section.hidden)) continue; + let best = null; + for (const ref of section.hidden) { + if (ref.kind !== 'session') continue; + const alert = alertOf(ref.id); + if (ALERT_RANK[alert] && (!best || ALERT_RANK[alert] > ALERT_RANK[best])) best = alert; + } + if (best) result[section.id] = best; + } + return result; } /** * 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. + * where, the headers' names, what a collapse hides), 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. + * + * Deliberately NOT the layout version: the server bumps it on every session + * create/close and order PUT, and a bump that moves nothing visible must not + * cost every client a full tab-strip rebuild. `layout` is accepted for + * signature stability only. */ - function structureKey(layout, projection, collapsedGroupIds) { + 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)]), + sections: projection.sections.map((section) => [ + section.id, + section.name, + section.count, + section.refs.map(refKey), + (section.hidden || []).map(refKey), + ]), }); } @@ -186,6 +251,8 @@ * (the rows sit below the header visually, not inside it). * - a COLLAPSED group owns nothing: the one row it still shows (the * selection) is a level-1 sibling, never the child of a closed node. + * - a group with NO open rows is a leaf: no `aria-expanded`, no owned group, + * so it is not announced as an expanded parent of an empty group. * - Ungrouped rows are level-1 items. Their "Ungrouped" heading is a visual * divider only, hidden from assistive tech, and its rows are not a group. */ @@ -204,10 +271,12 @@ const id = escapeHtml(section.id); const refsId = `tab-layout-group-refs-${index}`; const nameId = `tab-layout-group-name-${index}`; - const expanded = !section.collapsed; + const leaf = section.count === 0; + const expanded = !section.collapsed && !leaf; + const expandedAttr = leaf ? '' : ` aria-expanded="${expanded ? 'true' : 'false'}"`; return ( `