Skip to content

Commit 9428470

Browse files
committed
Retry tab chat ownership reconciliation
1 parent 051792f commit 9428470

3 files changed

Lines changed: 71 additions & 16 deletions

File tree

src/chrome/src/ui/sidepanel.js

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2125,13 +2125,19 @@ let visibleStateRefreshPromise = Promise.resolve();
21252125
let visibleStateRefreshInProgress = false;
21262126
let visibleStateRefreshGeneration = 0;
21272127
let lastVisibleTabChatSnapshot = null;
2128+
const TAB_CHAT_HANDOFF_RETRY_MS = 250;
2129+
2130+
function waitForTabChatHandoffRetry() {
2131+
return new Promise(resolve => setTimeout(resolve, TAB_CHAT_HANDOFF_RETRY_MS));
2132+
}
21282133

21292134
async function waitForVisibleSidePanelStateRefresh() {
21302135
let pendingRefresh;
21312136
do {
21322137
pendingRefresh = visibleStateRefreshPromise;
21332138
await pendingRefresh.catch(() => {});
2134-
} while (pendingRefresh !== visibleStateRefreshPromise);
2139+
if (tabSwitchTransitionId != null) await waitForTabChatHandoffRetry();
2140+
} while (pendingRefresh !== visibleStateRefreshPromise || tabSwitchTransitionId != null);
21352141
}
21362142

21372143
function schedulePersist() {
@@ -3711,6 +3717,7 @@ async function switchToTab(newTabId) {
37113717
const switchGeneration = ++tabSwitchGeneration;
37123718
tabSwitchTransitionId = newTabId;
37133719
queuedTabSwitchMessages = [];
3720+
syncSendButtonState();
37143721
// The activity strip is a single panel-wide DOM node, unlike the tab-scoped
37153722
// chat and run journals. Clear the outgoing tab's transient status before
37163723
// any async restore work can yield; restoreActiveRunState (or a queued target
@@ -3735,7 +3742,9 @@ async function switchToTab(newTabId) {
37353742
syncCurrentTabRunFlags();
37363743
syncApiMutationsAllowedForCurrentTab();
37373744

3738-
// Restore new tab's chat from memory or storage.
3745+
// Chrome gives each tab-specific side panel its own document. The
3746+
// visibility refresh below coordinates ownership when the destination
3747+
// document becomes active, so this in-document switch only restores cache.
37393748
const html = await loadTabChat(newTabId);
37403749
if (switchGeneration !== tabSwitchGeneration || currentTabId !== newTabId) return;
37413750
if (html) {
@@ -3761,6 +3770,7 @@ async function switchToTab(newTabId) {
37613770
refreshRecommendedActions();
37623771
} finally {
37633772
if (switchGeneration === tabSwitchGeneration && tabSwitchTransitionId === newTabId) tabSwitchTransitionId = null;
3773+
syncSendButtonState();
37643774
}
37653775
drainQueuedAgentUpdatesForTab(newTabId);
37663776
consumePendingContextMenuPrompt().then(() => drainQueuedContextMenuPrompts()).catch(() => {});
@@ -3804,7 +3814,18 @@ function requestVisibleSidePanelStateRefresh() {
38043814
visibleStateRefreshPromise = visibleStateRefreshPromise.catch(() => {}).then(async () => {
38053815
if (document.visibilityState === 'hidden' || tabSwitchTransitionId != null) return false;
38063816
visibleStateRefreshPending = false;
3807-
return refreshVisibleSidePanelState();
3817+
let refreshed = await refreshVisibleSidePanelState();
3818+
while (refreshed === false
3819+
&& document.visibilityState !== 'hidden'
3820+
&& tabSwitchTransitionId == null) {
3821+
visibleStateRefreshPending = true;
3822+
syncSendButtonState();
3823+
await waitForTabChatHandoffRetry();
3824+
if (document.visibilityState === 'hidden' || tabSwitchTransitionId != null) return false;
3825+
visibleStateRefreshPending = false;
3826+
refreshed = await refreshVisibleSidePanelState();
3827+
}
3828+
return refreshed;
38083829
});
38093830
const refreshPromise = visibleStateRefreshPromise;
38103831
refreshPromise.finally(() => {
@@ -6296,7 +6317,7 @@ function isOutOfBandSlashDraft(value) {
62966317
function syncSendButtonState() {
62976318
if (!sendBtn) return;
62986319
const draft = normalizeScreenshotCommandText(inputEl?.value || '').trim();
6299-
if (visibleStateRefreshPending || visibleStateRefreshInProgress) {
6320+
if (tabSwitchTransitionId != null || visibleStateRefreshPending || visibleStateRefreshInProgress) {
63006321
sendBtn.disabled = true;
63016322
return;
63026323
}

src/firefox/src/ui/sidepanel.js

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1545,13 +1545,19 @@ let visibleStateRefreshPromise = Promise.resolve();
15451545
let visibleStateRefreshInProgress = false;
15461546
let visibleStateRefreshGeneration = 0;
15471547
let lastVisibleTabChatSnapshot = null;
1548+
const TAB_CHAT_HANDOFF_RETRY_MS = 250;
1549+
1550+
function waitForTabChatHandoffRetry() {
1551+
return new Promise(resolve => setTimeout(resolve, TAB_CHAT_HANDOFF_RETRY_MS));
1552+
}
15481553

15491554
async function waitForVisibleSidePanelStateRefresh() {
15501555
let pendingRefresh;
15511556
do {
15521557
pendingRefresh = visibleStateRefreshPromise;
15531558
await pendingRefresh.catch(() => {});
1554-
} while (pendingRefresh !== visibleStateRefreshPromise);
1559+
if (tabSwitchTransitionId != null) await waitForTabChatHandoffRetry();
1560+
} while (pendingRefresh !== visibleStateRefreshPromise || tabSwitchTransitionId != null);
15551561
}
15561562

15571563
function schedulePersist() {
@@ -3560,6 +3566,7 @@ async function switchToTab(newTabId) {
35603566
const switchGeneration = ++tabSwitchGeneration;
35613567
tabSwitchTransitionId = newTabId;
35623568
queuedTabSwitchMessages = [];
3569+
syncSendButtonState();
35633570
// The activity strip is a single panel-wide DOM node, unlike the tab-scoped
35643571
// chat and run journals. Clear the outgoing tab's transient status before
35653572
// any async restore work can yield; restoreActiveRunState (or a queued target
@@ -3584,8 +3591,16 @@ async function switchToTab(newTabId) {
35843591
syncCurrentTabRunFlags();
35853592
syncApiMutationsAllowedForCurrentTab();
35863593

3587-
// Restore new tab's chat from memory or storage.
3588-
const html = await loadTabChat(newTabId);
3594+
// Acquire shared handoff ownership for the destination before its DOM can
3595+
// render or persist. Retry transient background failures while this remains
3596+
// the current tab-switch generation.
3597+
let html = TAB_CHAT_LOAD_FAILED;
3598+
while (html === TAB_CHAT_LOAD_FAILED) {
3599+
html = await loadTabChat(newTabId, { waitForHandoff: true });
3600+
if (html !== TAB_CHAT_LOAD_FAILED) break;
3601+
if (switchGeneration !== tabSwitchGeneration || currentTabId !== newTabId) return;
3602+
await waitForTabChatHandoffRetry();
3603+
}
35893604
if (switchGeneration !== tabSwitchGeneration || currentTabId !== newTabId) return;
35903605
if (html) {
35913606
await hydrateRestoredChatHistory(newTabId, html);
@@ -3610,6 +3625,7 @@ async function switchToTab(newTabId) {
36103625
refreshRecommendedActions();
36113626
} finally {
36123627
if (switchGeneration === tabSwitchGeneration && tabSwitchTransitionId === newTabId) tabSwitchTransitionId = null;
3628+
syncSendButtonState();
36133629
}
36143630
drainQueuedAgentUpdatesForTab(newTabId);
36153631
consumePendingContextMenuPrompt().then(() => drainQueuedContextMenuPrompts()).catch(() => {});
@@ -3652,7 +3668,18 @@ function requestVisibleSidePanelStateRefresh() {
36523668
visibleStateRefreshPromise = visibleStateRefreshPromise.catch(() => {}).then(async () => {
36533669
if (document.visibilityState === 'hidden' || tabSwitchTransitionId != null) return false;
36543670
visibleStateRefreshPending = false;
3655-
return refreshVisibleSidePanelState();
3671+
let refreshed = await refreshVisibleSidePanelState();
3672+
while (refreshed === false
3673+
&& document.visibilityState !== 'hidden'
3674+
&& tabSwitchTransitionId == null) {
3675+
visibleStateRefreshPending = true;
3676+
syncSendButtonState();
3677+
await waitForTabChatHandoffRetry();
3678+
if (document.visibilityState === 'hidden' || tabSwitchTransitionId != null) return false;
3679+
visibleStateRefreshPending = false;
3680+
refreshed = await refreshVisibleSidePanelState();
3681+
}
3682+
return refreshed;
36563683
});
36573684
const refreshPromise = visibleStateRefreshPromise;
36583685
refreshPromise.finally(() => {
@@ -6130,7 +6157,7 @@ function isOutOfBandSlashDraft(value) {
61306157
function syncSendButtonState() {
61316158
if (!sendBtn) return;
61326159
const draft = normalizeScreenshotCommandText(inputEl?.value || '').trim();
6133-
if (visibleStateRefreshPending || visibleStateRefreshInProgress) {
6160+
if (tabSwitchTransitionId != null || visibleStateRefreshPending || visibleStateRefreshInProgress) {
61346161
sendBtn.disabled = true;
61356162
return;
61366163
}

test/run.js

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17704,7 +17704,7 @@ test('sidepanel queues target-tab updates and suppresses non-target updates duri
1770417704
const markIdx = body.indexOf('tabSwitchTransitionId = newTabId;');
1770517705
const flushIdx = body.indexOf('await flushRenderedTabChat();');
1770617706
const historyFlushIdx = body.indexOf('await flushChatHistorySnapshot(outgoingTabId);');
17707-
const loadIdx = body.indexOf('const html = await loadTabChat(newTabId);');
17707+
const loadIdx = body.indexOf('loadTabChat(newTabId');
1770817708
const clearTransitionIdx = body.indexOf('if (switchGeneration === tabSwitchGeneration && tabSwitchTransitionId === newTabId) tabSwitchTransitionId = null;');
1770917709
const replayIdx = body.indexOf('drainQueuedAgentUpdatesForTab(newTabId);');
1771017710
const consumeIdx = body.indexOf('consumePendingContextMenuPrompt()');
@@ -17799,7 +17799,7 @@ test('sidepanel hydrates restored history ids before fallback records', () => {
1779917799
const switchMatch = panel.match(/async function switchToTab\(newTabId\) \{([\s\S]*?)\n\}/);
1780017800
assert.ok(switchMatch, `${label}: switchToTab body missing`);
1780117801
const switchBody = switchMatch[1];
17802-
const loadIdx = switchBody.indexOf('const html = await loadTabChat(newTabId);');
17802+
const loadIdx = switchBody.indexOf('loadTabChat(newTabId');
1780317803
const restoredHasUserIdx = switchBody.indexOf('if (html) {');
1780417804
const hydrateRestoredIdx = switchBody.indexOf('await hydrateRestoredChatHistory(newTabId, html);');
1780517805
const postHydrateGuardIdx = switchBody.indexOf('if (switchGeneration !== tabSwitchGeneration || currentTabId !== newTabId) return;', hydrateRestoredIdx);
@@ -21886,19 +21886,26 @@ test('context-menu ownership and stale-panel persistence guards are wired in bot
2188621886
);
2188721887
assert.match(
2188821888
panel,
21889-
/let visibleStateRefreshPromise = Promise\.resolve\(\);[\s\S]*?async function waitForVisibleSidePanelStateRefresh\(\) \{[\s\S]*?pendingRefresh = visibleStateRefreshPromise;[\s\S]*?await pendingRefresh\.catch\(\(\) => \{\}\);[\s\S]*?pendingRefresh !== visibleStateRefreshPromise/,
21890-
`${label}: sends should wait until the complete serialized visibility refresh queue settles`,
21889+
/let visibleStateRefreshPromise = Promise\.resolve\(\);[\s\S]*?async function waitForVisibleSidePanelStateRefresh\(\) \{[\s\S]*?pendingRefresh = visibleStateRefreshPromise;[\s\S]*?await pendingRefresh\.catch\(\(\) => \{\}\);[\s\S]*?tabSwitchTransitionId != null[\s\S]*?pendingRefresh !== visibleStateRefreshPromise \|\| tabSwitchTransitionId != null/,
21890+
`${label}: sends should wait until visibility refreshes and coordinated tab switches settle`,
2189121891
);
2189221892
assert.match(
2189321893
panel,
21894-
/async function refreshVisibleSidePanelState\(\) \{[\s\S]*?tabChats\.delete\(tabId\);[\s\S]*?await loadTabChat\(tabId, \{ waitForHandoff: true \}\);[\s\S]*?await restoreActiveRunState\(tabId\);[\s\S]*?function requestVisibleSidePanelStateRefresh\(\) \{[\s\S]*?visibleStateRefreshPromise = visibleStateRefreshPromise\.catch\(\(\) => \{\}\)\.then\(async \(\) => \{[\s\S]*?return refreshVisibleSidePanelState\(\);[\s\S]*?refreshPromise\.then\(\(refreshed\) => \{[\s\S]*?refreshPromise !== visibleStateRefreshPromise[\s\S]*?consumePendingContextMenuPrompt\(\)/,
21894+
/async function refreshVisibleSidePanelState\(\) \{[\s\S]*?tabChats\.delete\(tabId\);[\s\S]*?await loadTabChat\(tabId, \{ waitForHandoff: true \}\);[\s\S]*?await restoreActiveRunState\(tabId\);[\s\S]*?function requestVisibleSidePanelStateRefresh\(\) \{[\s\S]*?visibleStateRefreshPromise = visibleStateRefreshPromise\.catch\(\(\) => \{\}\)\.then\(async \(\) => \{[\s\S]*?let refreshed = await refreshVisibleSidePanelState\(\);[\s\S]*?while \(refreshed === false[\s\S]*?visibleStateRefreshPending = true;[\s\S]*?await waitForTabChatHandoffRetry\(\);[\s\S]*?refreshed = await refreshVisibleSidePanelState\(\);[\s\S]*?refreshPromise\.then\(\(refreshed\) => \{[\s\S]*?refreshPromise !== visibleStateRefreshPromise[\s\S]*?consumePendingContextMenuPrompt\(\)/,
2189521895
`${label}: a returning panel should serialize the shared handoff before consuming prompts`,
2189621896
);
2189721897
assert.match(
2189821898
panel,
21899-
/function syncSendButtonState\(\) \{[\s\S]*?if \(visibleStateRefreshPending \|\| visibleStateRefreshInProgress\) \{\s*sendBtn\.disabled = true;\s*return;/,
21900-
`${label}: the composer should stay disabled throughout visibility reconciliation`,
21899+
/function syncSendButtonState\(\) \{[\s\S]*?if \(tabSwitchTransitionId != null \|\| visibleStateRefreshPending \|\| visibleStateRefreshInProgress\) \{\s*sendBtn\.disabled = true;\s*return;/,
21900+
`${label}: the composer should stay disabled throughout tab-switch and visibility reconciliation`,
2190121901
);
21902+
if (label === 'firefox') {
21903+
assert.match(
21904+
panel,
21905+
/async function switchToTab\(newTabId\) \{[\s\S]*?tabSwitchTransitionId = newTabId;[\s\S]*?let html = TAB_CHAT_LOAD_FAILED;[\s\S]*?while \(html === TAB_CHAT_LOAD_FAILED\) \{[\s\S]*?loadTabChat\(newTabId, \{ waitForHandoff: true \}\);[\s\S]*?await waitForTabChatHandoffRetry\(\);/,
21906+
'firefox: switching tabs should acquire destination handoff ownership and retry transient failures before rendering',
21907+
);
21908+
}
2190221909
assert.match(
2190321910
panel,
2190421911
/async function sendMessage\(extraChatParams = \{\}\) \{[\s\S]*?await waitForVisibleSidePanelStateRefresh\(\);\s*stopListening\(\);\s*let text = inputEl\.value\.trim\(\);/,

0 commit comments

Comments
 (0)