mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
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:
+61
-2
@@ -5870,6 +5870,10 @@ class CodemanApp {
|
||||
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
|
||||
// Alt+N or a push notification could stay parked off-screen.
|
||||
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
|
||||
// selection change is a redraw whenever any lineage exists at all.
|
||||
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.
|
||||
* Header strip: below the tab, connector runs vertically. Sidebar AND the
|
||||
@@ -6055,6 +6107,7 @@ class CodemanApp {
|
||||
!this._isTabGroupStructureStale(groupProjection) &&
|
||||
(clusterLayout ? clusterLayout.key : null) === (this._lastTabClusterKey ?? null);
|
||||
|
||||
let activeBandMoved = false;
|
||||
if (canIncremental) {
|
||||
// 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.
|
||||
@@ -6266,6 +6319,9 @@ class CodemanApp {
|
||||
}
|
||||
this._syncTabTriageChrome(container, triage);
|
||||
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 {
|
||||
// Full rebuild needed (sessions added/removed)
|
||||
this._fullRenderSessionTabs();
|
||||
@@ -6277,6 +6333,7 @@ class CodemanApp {
|
||||
this._lastRenderedActiveTabId = this.activeSessionId;
|
||||
|
||||
this.updateTabOverflowMode();
|
||||
if (activeBandMoved && this._isScrollingTabRow(container)) this._scrollActiveTabIntoView(this.activeSessionId);
|
||||
// After the wrap measurement: the `unroll` style starts tabs at max-width 0,
|
||||
// so measuring mid-animation would decide the wrap on collapsed widths.
|
||||
this._applyTabEntrances?.();
|
||||
@@ -6600,16 +6657,18 @@ class CodemanApp {
|
||||
}
|
||||
this._syncTabTriageChrome(container, triage);
|
||||
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
|
||||
// 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
|
||||
// the strip while a background rebuild fires, without the active tab ever
|
||||
// being stranded off-screen after a switch.
|
||||
container.scrollLeft = prevScrollLeft;
|
||||
container.scrollTop = prevScrollTop;
|
||||
this._lastRenderedActiveTabId = this.activeSessionId;
|
||||
if (isFirstRender || prevActiveTabId !== this.activeSessionId) {
|
||||
if (isFirstRender || prevActiveTabId !== this.activeSessionId || activeBandMoved) {
|
||||
this._scrollActiveTabIntoView(this.activeSessionId, isFirstRender ? 'auto' : 'smooth');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user