fix(tabs): keep the active tab in view when it changes state band on a phone

On phones and 600-767px tablets the header strip stays one horizontally
scrolling row. #538 (Tab Layout, default "by state") orders that row in one
flex `order` band per state, so when the ACTIVE session changes state (a
prompt sent: idle to working; a permission prompt: needs you) its chip
moves to another band while scrollLeft stays put, and the tab in use left
the screen (measured at 390px: x 165 to -870). #257's reveal rules only
covered a CHANGED active tab: _updateActiveTabImmediate reveals on a
switch, and _fullRenderSessionTabs restores scrollLeft and re-reveals only
when _lastRenderedActiveTabId changed, while a state change is an
incremental pass that never reveals at all.

_noteActiveTabBand() records the active tab's band (read off the element,
so it is what is on screen) and reports when it moved while the tab stayed
active. Both render paths reveal on that, in the single scrolling row only
(_isScrollingTabRow: not wrapping, not a vertical list). It is keyed on the
band, not the raw order value, because another tab entering or leaving the
active tab's band shifts that value by one and another tab's move must not
yank a strip the user is browsing. _updateActiveTabImmediate records too,
so the pass right after a switch still counts (viewing a waiting tab spends
its alert and drops it into the idle row). The incremental path reveals
after updateTabOverflowMode(), and only from the branch that reached
_syncTabTriageChrome(), so a pass that falls through to a full rebuild
mid-loop leaves the record for the rebuild to compare.

Tests in test/tab-triage.test.ts pin the reveal on both paths and after a
switch, and that another tab's band change, a wrapping strip, a vertical
list and an active web tab never scroll.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-08 21:36:06 +02:00
parent 1296223403
commit 4ee382832a
4 changed files with 163 additions and 3 deletions
+1 -1
View File
@@ -331,7 +331,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**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) **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)
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Test: `test/mobile-tab-tap-zones.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](docs/architecture-invariants.md#mobile-tab-strip-scrolling) **Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](docs/architecture-invariants.md#mobile-tab-strip-scrolling)
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar) **Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
+2
View File
@@ -891,6 +891,8 @@ Tests: `test/terminal-touch-tap.test.ts`.
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: `_updateActiveTabImmediate()` scrolls the selected tab into view via `computeTabScrollLeft()` (pure, in constants.js) using **rect math on the strip's own `scrollLeft`**, never `scrollIntoView()`, which would also scroll the document under a fixed header; `_fullRenderSessionTabs()` **restores `scrollLeft`** across the `innerHTML` rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab **only when it changed** (`_lastRenderedActiveTabId`), so browsing the far end of the strip is not undone by background renders. **Mobile tab strip scrolling** (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: `_updateActiveTabImmediate()` scrolls the selected tab into view via `computeTabScrollLeft()` (pure, in constants.js) using **rect math on the strip's own `scrollLeft`**, never `scrollIntoView()`, which would also scroll the document under a fixed header; `_fullRenderSessionTabs()` **restores `scrollLeft`** across the `innerHTML` rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab **only when it changed** (`_lastRenderedActiveTabId`), so browsing the far end of the strip is not undone by background renders.
⚠️ **Grouped by state, the active tab can move without being switched to.** The strip orders its tabs in one `order` band per state (`CodemanTabTriage`), so the active session changing state (a prompt sent: idle to working; a permission prompt: needs you) slides its chip into another band while `scrollLeft` stays, and the tab in use left the screen (measured at 390px: x 165 to -870). `_noteActiveTabBand()` records the active tab's band (read off the element, also from `_updateActiveTabImmediate()`, so the pass right after a switch counts: viewing a waiting tab spends its alert and drops it to idle) and both render paths reveal it when that band moved, in the single scrolling row only (`_isScrollingTabRow()`). Keyed on the BAND, never the raw order value: another tab entering or leaving the active tab's band shifts that value by one, and another tab's move must never yank a strip the user is browsing. The incremental path reveals after `updateTabOverflowMode()`, and only from the branch that reached `_syncTabTriageChrome()`, so a pass that falls through to a full rebuild mid-loop leaves the record for the rebuild to compare. Tests: `test/tab-triage.test.ts`.
⚠️ **The ACTIVE tab is the only one with action icons, and on a phone they can eat it**: `.session-tab.active .tab-name` reserves `min-width: 44px` in the phone block (under 600px), because a short session name rendered a 13px label against a 50px gear+close cluster, putting the tab's geometric CENTRE on the gear, so a thumb aiming at the tab opened Session Options instead of switching (measured at 360/393/430px; only long names cleared it). ⚠️ **The ACTIVE tab is the only one with action icons, and on a phone they can eat it**: `.session-tab.active .tab-name` reserves `min-width: 44px` in the phone block (under 600px), because a short session name rendered a 13px label against a 50px gear+close cluster, putting the tab's geometric CENTRE on the gear, so a thumb aiming at the tab opened Session Options instead of switching (measured at 360/393/430px; only long names cleared it).
⚠️ **The floor is set by the 10th tab onward, not by the tabs you can see**: `.tab-number` renders only for `_tabIdx < 9`, so tab 10 loses 16px + a gap off its left and its centre sits 10px further right. The centre clears the icons when `reserved > icons + rightEdge - leftRunUp - gap` (= 50 + 9 - 17 - 4 = **38px**), hit-testing snaps to whole pixels so 39px still lands on the gear, and the practical floor is 40px — a NUMBERED tab clears it at 20px, which is exactly why reasoning from the tabs on screen would put the centre back on the gear. `test/mobile-tab-tap-zones.test.ts` recomputes that inequality from the stylesheet, so widening the gear or the padding fails there rather than on a phone. The guarantee is centre-off-the-ICONS, not centre-inside-the-label (on a numberless tab it lands in the gap between them, which still switches). Non-active tabs keep their icons hidden and stay tappable end to end. ⚠️ **The floor is set by the 10th tab onward, not by the tabs you can see**: `.tab-number` renders only for `_tabIdx < 9`, so tab 10 loses 16px + a gap off its left and its centre sits 10px further right. The centre clears the icons when `reserved > icons + rightEdge - leftRunUp - gap` (= 50 + 9 - 17 - 4 = **38px**), hit-testing snaps to whole pixels so 39px still lands on the gear, and the practical floor is 40px — a NUMBERED tab clears it at 20px, which is exactly why reasoning from the tabs on screen would put the centre back on the gear. `test/mobile-tab-tap-zones.test.ts` recomputes that inequality from the stylesheet, so widening the gear or the padding fails there rather than on a phone. The guarantee is centre-off-the-ICONS, not centre-inside-the-label (on a numberless tab it lands in the gap between them, which still switches). Non-active tabs keep their icons hidden and stay tappable end to end.
+61 -2
View File
@@ -5870,6 +5870,10 @@ class CodemanApp {
// strip scrolls horizontally, so a tab selected from the palette, a swipe, // strip scrolls horizontally, so a tab selected from the palette, a swipe,
// Alt+N or a push notification could stay parked off-screen. // Alt+N or a push notification could stay parked off-screen.
this._scrollActiveTabIntoView(sessionId); this._scrollActiveTabIntoView(sessionId);
// Where the new active tab sits now, so the render pass that follows can
// tell when it changes state band (viewing a waiting tab spends its alert
// and drops it into the idle row).
this._noteActiveTabBand(container);
// Lineage lines draw only the SELECTED tab's family (session-lineage.js), so a // Lineage lines draw only the SELECTED tab's family (session-lineage.js), so a
// selection change is a redraw whenever any lineage exists at all. // selection change is a redraw whenever any lineage exists at all.
if (this._lineageTotalEdges > 0) this.updateConnectionLines(); if (this._lineageTotalEdges > 0) this.updateConnectionLines();
@@ -5930,6 +5934,54 @@ class CodemanApp {
} }
} }
/**
* Record the active tab's state band and report whether it MOVED band since
* the last record while staying the active tab.
*
* Grouped by state (tabArrangement 'state'), the phone/tablet strip is still
* one scrolling row, but its tabs come in bands of `order` values, one band
* per state (CodemanTabTriage, constants.js). The active session changing
* state (a prompt sent: idle to working; a permission prompt: needs you)
* moves its tab into another band while scrollLeft stays put, so the tab in
* use slid out of view (measured at 390px: x 165 to -870). Both render paths
* reveal it when this says so.
*
* The band, not the raw order: another tab entering or leaving the active
* tab's band shifts its order value by one, and that must not yank a strip
* the user may be browsing. A changed active tab is not a move either: the
* switch already revealed it (#257). Read off the element, so the record is
* what is on screen, and called from _updateActiveTabImmediate() too, so a
* band change in the pass right after a switch still counts.
*
* @returns {boolean}
*/
_noteActiveTabBand(container) {
const id = this.activeWebviewId ? null : this.activeSessionId;
const tab = id && container ? container.querySelector(`.session-tab[data-id="${id}"]`) : null;
const prev = this._activeTabBand;
if (!tab) {
this._activeTabBand = null;
return false;
}
const order = tab.style.order;
const stride = window.CodemanTabTriage?.STRIDE || 10000;
const band = order === '' ? null : Math.floor(Number(order) / stride);
this._activeTabBand = { id, band };
return !!prev && prev.id === id && prev.band !== band;
}
/**
* True when the tab list is the header's one horizontally scrolling row
* (phones and tablets): neither wrapping into rows nor a vertical list.
*/
_isScrollingTabRow(container) {
return (
!this._isVerticalTabList() &&
!container.classList.contains('tabs-auto-wrap') &&
!container.classList.contains('tabs-two-rows')
);
}
/** /**
* Where a floating window (subagent / ultracode) attaches to its parent tab. * Where a floating window (subagent / ultracode) attaches to its parent tab.
* Header strip: below the tab, connector runs vertically. Sidebar AND the * Header strip: below the tab, connector runs vertically. Sidebar AND the
@@ -6055,6 +6107,7 @@ class CodemanApp {
!this._isTabGroupStructureStale(groupProjection) && !this._isTabGroupStructureStale(groupProjection) &&
(clusterLayout ? clusterLayout.key : null) === (this._lastTabClusterKey ?? null); (clusterLayout ? clusterLayout.key : null) === (this._lastTabClusterKey ?? null);
let activeBandMoved = false;
if (canIncremental) { if (canIncremental) {
// Read once for the whole pass, like the full-rebuild path: this touches // Read once for the whole pass, like the full-rebuild path: this touches
// the DOM and the loop below runs for every session on every SSE tick. // the DOM and the loop below runs for every session on every SSE tick.
@@ -6266,6 +6319,9 @@ class CodemanApp {
} }
this._syncTabTriageChrome(container, triage); this._syncTabTriageChrome(container, triage);
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection }); this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
// A state change is this path's job, and so is the active tab changing
// state band: revealed below, once the wrap mode is current.
activeBandMoved = this._noteActiveTabBand(container);
} else { } else {
// Full rebuild needed (sessions added/removed) // Full rebuild needed (sessions added/removed)
this._fullRenderSessionTabs(); this._fullRenderSessionTabs();
@@ -6277,6 +6333,7 @@ class CodemanApp {
this._lastRenderedActiveTabId = this.activeSessionId; this._lastRenderedActiveTabId = this.activeSessionId;
this.updateTabOverflowMode(); this.updateTabOverflowMode();
if (activeBandMoved && this._isScrollingTabRow(container)) this._scrollActiveTabIntoView(this.activeSessionId);
// After the wrap measurement: the `unroll` style starts tabs at max-width 0, // After the wrap measurement: the `unroll` style starts tabs at max-width 0,
// so measuring mid-animation would decide the wrap on collapsed widths. // so measuring mid-animation would decide the wrap on collapsed widths.
this._applyTabEntrances?.(); this._applyTabEntrances?.();
@@ -6600,16 +6657,18 @@ class CodemanApp {
} }
this._syncTabTriageChrome(container, triage); this._syncTabTriageChrome(container, triage);
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection }); this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
const activeBandMoved = this._noteActiveTabBand(container) && this._isScrollingTabRow(container);
// Put the strip back where the user left it, then reveal the active tab // 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 // only when it CHANGED, or moved to another state band in the scrolling row
// (_noteActiveTabBand), or on the first paint. Restoring unconditionally
// and revealing conditionally is what lets someone browse the far end of // and revealing conditionally is what lets someone browse the far end of
// the strip while a background rebuild fires, without the active tab ever // the strip while a background rebuild fires, without the active tab ever
// being stranded off-screen after a switch. // being stranded off-screen after a switch.
container.scrollLeft = prevScrollLeft; container.scrollLeft = prevScrollLeft;
container.scrollTop = prevScrollTop; container.scrollTop = prevScrollTop;
this._lastRenderedActiveTabId = this.activeSessionId; this._lastRenderedActiveTabId = this.activeSessionId;
if (isFirstRender || prevActiveTabId !== this.activeSessionId) { if (isFirstRender || prevActiveTabId !== this.activeSessionId || activeBandMoved) {
this._scrollActiveTabIntoView(this.activeSessionId, isFirstRender ? 'auto' : 'smooth'); this._scrollActiveTabIntoView(this.activeSessionId, isFirstRender ? 'auto' : 'smooth');
} }
+99
View File
@@ -14,6 +14,9 @@
* heading. * heading.
* - `tabArrangement: 'classic'` leaves no trace (no headings, no inline order, no * - `tabArrangement: 'classic'` leaves no trace (no headings, no inline order, no
* class), and named groups in the vertical rail win over it. * class), and named groups in the vertical rail win over it.
* - In the phone/tablet strip (one scrolling row) the ACTIVE tab changing band
* is revealed, since its chip moves while scrollLeft stays; another tab
* changing band never moves the strip (#257's browse-the-far-end rule).
* *
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), so * The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), so
* `document` below is that window's. * `document` below is that window's.
@@ -411,6 +414,102 @@ describe('tab grouping in the render paths (app.js)', () => {
expect(app._isTabDropAcrossGroups(tab('s1'))).toBe(false); expect(app._isTabDropAcrossGroups(tab('s1'))).toBe(false);
}); });
describe('keeping the active tab in view in the scrolling row (phones, tablets)', () => {
// The phone and tablet strip is ONE horizontally scrolling row, and a state
// change moves a tab to another band while scrollLeft stays put. The active
// tab moving band is revealed; anything else moving must not yank the strip.
function setup() {
const app = makeApp();
const reveal = vi.fn();
app._scrollActiveTabIntoView = reveal;
app._fullRenderSessionTabs();
reveal.mockClear();
return { app, reveal };
}
it('reveals the active tab when it changes state band on an incremental pass', () => {
const { app, reveal } = setup();
const fullRender = vi.spyOn(app, '_fullRenderSessionTabs');
// s2 is active and working; its turn ends.
app.sessions.get('s2').status = 'idle';
app._renderSessionTabsImmediate();
expect(fullRender).not.toHaveBeenCalled();
expect(reveal).toHaveBeenCalledTimes(1);
expect(reveal.mock.calls[0][0]).toBe('s2');
// And again when a permission prompt sends it to the front.
app.pendingHooks.set('s2', new Set(['permission_prompt']));
app._renderSessionTabsImmediate();
expect(reveal).toHaveBeenCalledTimes(2);
// A pass that changes nothing reveals nothing.
app._renderSessionTabsImmediate();
expect(reveal).toHaveBeenCalledTimes(2);
});
it('leaves the strip alone when another tab changes band, even one that shifts the active tab', () => {
const { app, reveal } = setup();
// s4 leaves the working row.
app.sessions.get('s4').status = 'idle';
app._renderSessionTabsImmediate();
// s1 joins the working row AHEAD of the active s2, so s2's own order value
// changes while its band does not.
const before = orderOf(tab('s2'));
app.pendingHooks.delete('s1');
app.sessions.get('s1').status = 'busy';
app._renderSessionTabsImmediate();
expect(orderOf(tab('s2'))).not.toBe(before);
expect(reveal).not.toHaveBeenCalled();
});
it('counts a band change in the pass right after a switch (viewing a waiting tab)', () => {
const { app, reveal } = setup();
// Switch to s1, which is waiting; looking at it spends the alert.
app.activeSessionId = 's1';
app._updateActiveTabImmediate('s1');
expect(reveal).toHaveBeenCalledTimes(1);
app.pendingHooks.delete('s1');
app._renderSessionTabsImmediate();
expect(reveal).toHaveBeenCalledTimes(2);
expect(reveal.mock.calls[1][0]).toBe('s1');
});
it('reveals it when the same pass falls through to a full rebuild', () => {
const { app, reveal } = setup();
const fullRender = vi.spyOn(app, '_fullRenderSessionTabs');
// A task badge appearing forces the rebuild, mid-loop.
app.sessions.get('s2').status = 'idle';
app.sessions.get('s2').taskStats = { running: 1, total: 1 };
app._renderSessionTabsImmediate();
expect(fullRender).toHaveBeenCalled();
expect(reveal).toHaveBeenCalledTimes(1);
expect(reveal.mock.calls[0][0]).toBe('s2');
});
it('does nothing in a wrapping strip or a vertical list', () => {
const { app, reveal } = setup();
container().classList.add('tabs-auto-wrap');
app.sessions.get('s2').status = 'idle';
app._renderSessionTabsImmediate();
container().classList.remove('tabs-auto-wrap');
container().classList.add('tabs-two-rows');
app.sessions.get('s2').status = 'busy';
app._renderSessionTabsImmediate();
container().classList.remove('tabs-two-rows');
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
app.sessions.get('s2').status = 'idle';
app._renderSessionTabsImmediate();
expect(reveal).not.toHaveBeenCalled();
});
it('does nothing while a web tab holds the highlight', () => {
const { app, reveal } = setup();
app.activeWebviewId = 'w1';
app._renderSessionTabsImmediate();
app.sessions.get('s2').status = 'idle';
app._renderSessionTabsImmediate();
expect(reveal).not.toHaveBeenCalled();
});
});
it('degrades to the flat strip when mobile-overview.js is stale or missing', () => { it('degrades to the flat strip when mobile-overview.js is stale or missing', () => {
const app = makeApp(); const app = makeApp();
app._mobileOverviewState = undefined; app._mobileOverviewState = undefined;