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
+99
View File
@@ -14,6 +14,9 @@
* heading.
* - `tabArrangement: 'classic'` leaves no trace (no headings, no inline order, no
* 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
* `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);
});
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', () => {
const app = makeApp();
app._mobileOverviewState = undefined;