fix(tabs): collapsed-header alerts, quieter layout reads, tree key and touch fixes (#517, #519 review)

Maintainer merge-time fixes for the grouped vertical rail (#517) and its
tree semantics (#519), from the two PR reviews.

#517 minors
- A collapsed group hid rows that need the user with no signal on its
  header. The header now takes the most urgent alert among the session
  rows its collapse hides, in the tab alert language (tab-alert-action
  red ring, tab-alert-idle yellow ring, the existing ::before rules
  extended to the header). New pure hiddenGroupAlerts() over a per-section
  `hidden` list; _syncTabGroupHeaderAlerts() patches it on BOTH render
  paths, since alerts change without a rebuild. The kept selection draws
  its own ring and is not counted.
- Every layout read rebuilt the whole tab strip, and failed reads retried
  every 5 s forever. _applyTabLayout() now rebuilds only when the
  structure key changed. The key drops the layout version (bumped on
  every session create/close and order PUT) and instead carries group
  names and the rows each collapse hides, so a version bump that moves
  nothing costs nothing and a rename still rebuilds. The load coordinator
  backs off (5, 10, 20, 40 s, capped at 60 s) and stops after 4 retries;
  the next SSE init or tab:layoutChanged tries again, a success resets.
- A malformed stored collapse value disabled collapse on that device for
  good. A parse or shape error now reads as nothing collapsed and is
  rewritten to []; ok:false stays reserved for a store that throws.
- Ctrl+Shift+{ / } still reordered across groups, where the server
  re-ranks per group, sends no session:orderChanged and leaves this
  client's sessionOrder and Alt+N targets diverged. The move is now a
  no-op unless the neighbour is in the active session's own section
  (_canSwapActiveTabWith, reading the projection's new sectionByRef, which
  also covers rows a collapse hides). Within a group the swap still works
  and the server agrees with it; the flat rail and the strip are
  unchanged.

#517 nits
- Keyboard group toggle dropping focus: already fixed by #519's
  focus-by-identity; the Enter toggle test now pins focus on the header.
- Header <button> inside role=tablist: moot, #519 made the header a
  treeitem inside role=tree.
- Byte-identity test not comparing against master: skipped in the suite
  (a test cannot read another revision's files portably). Checked by
  hand instead: the flat strip and flat rail markup of this branch before
  and after this commit are identical in all 16 cases (both orientations,
  manual and activity sort, no layout and zero groups, full and
  incremental paths).
- Doubled blank line in docs/architecture-invariants.md: removed.

#519 minors
- A tap on a tree header or unselected row dismissed the touch keyboard:
  the roving tabindex parks those at -1, so the [tabindex] arm of
  MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR missed them. The selector now
  lists [role="treeitem"].
- The tree key handler acted on keys pressed on a focused control inside
  a row (Enter on the overflow button re-selected and reloaded the active
  session instead of reopening its menu). It now returns unless the key
  landed on the treeitem itself.

#519 nits
- aria-posinset/setsize went stale when the activity-sorted grouped rail
  re-sorted rows on the incremental path. The position pass is extracted
  (_applyTabTreePositions) and re-run, with aria-selected and the header
  alerts, at the end of the incremental branch while the rail is a tree.
- An expanded group with no open rows was announced as an expanded parent
  owning an empty group. A group with no open rows is now a tree leaf: no
  aria-expanded, no aria-owns, its rows container presentation; Left and
  Right do nothing on it, and its chevron keys off the section's
  collapsed class instead of aria-expanded.

Tests: tab-layout-browser (malformed storage, backoff with a bounded
drain, structure key, hidden alerts, leaf groups, sectionByRef),
tab-layout-rail (header alerts on both paths, render-on-change, backoff
without rebuilds, malformed storage, Ctrl+Shift section gate, in-row
control keys, leaf header keys, posinset after an incremental re-sort,
the dismiss selector matching tree items), and three new Chromium tests
in tab-activation.browser (Enter on a focused overflow button, the touch
keyboard staying up on tree taps, the collapsed header's red ring). Every
new test fails on the pre-fix sources. Docs: architecture-invariants
owner-tab-layouts and keyboard-dismissal sections, one clause in
CLAUDE.md's dismissal rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-04 23:52:41 +02:00
parent b451b3851e
commit 7917273188
9 changed files with 670 additions and 61 deletions
+93 -20
View File
@@ -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();
+9 -3
View File
@@ -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;
+107 -22
View File
@@ -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
* (`<kind>:<id>`) 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 (
`<section class="tab-layout-group${section.collapsed ? ' tab-layout-group--collapsed' : ''}" role="presentation" data-tab-group-id="${id}">` +
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}" aria-expanded="${expanded ? 'true' : 'false'}"${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)">` +
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}"${expandedAttr}${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)">` +
'<span class="tab-layout-group-chevron" aria-hidden="true"></span>' +
`<span class="tab-layout-group-name" id="${nameId}" data-i18n-skip>${escapeHtml(section.name)}</span>` +
`<span class="tab-layout-group-count">${section.count}</span></div>` +
@@ -221,11 +290,21 @@
* 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.
*
* Retries back off and stop: the delay doubles from `retryDelayMs` up to
* `maxRetryDelayMs`, and after `maxRetries` consecutive failures nothing more
* is scheduled (`scheduleRetry(fn, delayMs)`). The next outside load (an SSE
* reconnect re-runs init, a `tab:layoutChanged` re-reads) tries again, and
* any success resets the count.
*/
function createLoadCoordinator(options) {
const baseDelay = Number.isFinite(options.retryDelayMs) ? options.retryDelayMs : 5000;
const maxDelay = Number.isFinite(options.maxRetryDelayMs) ? options.maxRetryDelayMs : 60000;
const maxRetries = Number.isSafeInteger(options.maxRetries) ? options.maxRetries : 4;
let generation = 0;
let disposed = false;
let retryHandle = null;
let failures = 0;
const clearRetry = () => {
if (retryHandle !== null && options.cancelRetry) options.cancelRetry(retryHandle);
retryHandle = null;
@@ -237,12 +316,17 @@
try {
const layout = await options.fetchLayout();
if (disposed || requestGeneration !== generation) return false;
failures = 0;
options.applyLayout(layout);
return true;
} catch (_error) {
if (disposed || requestGeneration !== generation) return false;
failures++;
options.applyFallback();
retryHandle = options.scheduleRetry(() => load());
if (failures <= maxRetries) {
const delay = Math.min(maxDelay, baseDelay * 2 ** (failures - 1));
retryHandle = options.scheduleRetry(() => load(), delay);
}
return false;
}
};
@@ -260,6 +344,7 @@
normalizeLayout,
hasGroups,
project,
hiddenGroupAlerts,
structureKey,
renderProjection,
createLoadCoordinator,
+4
View File
@@ -55,6 +55,9 @@
// (_installMobileKeyboardDismiss). Two groups: anything that is about to take
// focus itself, and the accessory bar, which is built to be used while the
// keyboard is open.
// ⚠️ A roving-tabindex widget parks every item but one at tabindex=-1, so the
// `[tabindex]` arm cannot see its items: the grouped tab rail's rows and
// headers are listed by role instead, or tapping one would drop the keyboard.
const MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR = [
'input',
'textarea',
@@ -64,6 +67,7 @@
'[contenteditable=""]',
'[contenteditable="true"]',
'[tabindex]:not([tabindex="-1"])',
'[role="treeitem"]',
'.keyboard-accessory-bar',
'.path-picker-overlay',
].join(',');