fix(extension): preserve target and chat visibility state

This commit is contained in:
Timo
2026-08-17 21:33:19 +02:00
parent 1e6778ba93
commit bce582b568
4 changed files with 48 additions and 47 deletions
+11 -43
View File
@@ -701,38 +701,6 @@ function clearCurrentContentTarget() {
currentTargetHasVideo = false;
}
function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) {
if (expectedTabId !== null && normalizeTabId(currentTabId) !== normalizeTabId(expectedTabId)) {
return false;
}
if (expectedGeneration !== null && targetActivationGeneration !== expectedGeneration) {
return false;
}
completeForceSyncBeforeTargetChange(null);
invalidateTargetActivations();
clearPendingTarget().catch(() => {});
if (currentTabId) deactivateTargetTab(currentTabId).catch(() => {});
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
lastContentHeartbeatAt = null;
if (currentRoom) {
roomIdleSince = Date.now();
}
chrome.storage.session.set({
currentTabId,
currentTabTitle,
currentTargetFrameId,
currentTargetDocumentId,
currentTargetHasVideo,
roomIdleSince,
lastContentHeartbeatAt
}).catch(() => {});
updateBadgeStatus();
return true;
}
async function leaveRoomAfterIdleGrace(reason) {
if (!currentRoom) return;
connectIntent = false;
@@ -2717,10 +2685,15 @@ async function activateTargetTab(tabId, tabTitle, {
}
return { status: 'superseded' };
}
if (previousTabId === selectedTabId
&& expectedCurrentTabId === selectedTabId
&& isMediaTargetNavigationError(error)) {
addLog('Media document changed during refresh; keeping the previous target until navigation completes', 'warn');
const isCurrentTargetRefresh = previousTabId === selectedTabId
&& expectedCurrentTabId === selectedTabId;
if (isCurrentTargetRefresh) {
addLog(
isMediaTargetNavigationError(error)
? 'Media document changed during refresh; keeping the previous target until navigation completes'
: `Media target refresh failed (${error.message}); keeping the selected target for recovery`,
'warn'
);
throw error;
}
currentTabId = null;
@@ -3087,16 +3060,12 @@ async function _routeToContentInternal(tabId, action, payload, actionTimestamp,
return;
}
if (retries >= 3) {
addLog(`Content Script not responding in tab ${tabId} after ${retries} retries`, 'warn');
clearTargetTabForIdle(tabId, targetGeneration);
addLog(`Content Script not responding in tab ${tabId} after ${retries} retries; keeping the selected target for recovery`, 'warn');
return;
}
const message = String(error?.message || '');
if (message.includes('Receiving end does not exist')
|| message.includes('Extension context invalidated')
|| message.includes('No document with id')
|| message.includes('No document with ID')) {
if (isMissingContentReceiverError(error) || message.includes('Extension context invalidated')) {
try {
const response = await refreshCurrentMediaTarget(tabId);
if (response?.status !== 'ok' && response?.status !== 'activation_in_progress') return;
@@ -3116,7 +3085,6 @@ async function _routeToContentInternal(tabId, action, payload, actionTimestamp,
}
addLog(`Content Script not responding in tab ${tabId}`, 'warn');
clearTargetTabForIdle(tabId, targetGeneration);
}
}
+10
View File
@@ -173,6 +173,16 @@ describe('chat overlay contract', () => {
expect(overlaySource).toContain("if (destroyed || area !== 'local') return");
});
it('persists the last manual open/closed state across context refreshes', () => {
expect(overlaySource).toContain('const openStateKey = `chatOverlayOpen:${location.origin}`');
expect(overlaySource).toContain('let lastUserOpenState = null');
expect(overlaySource).toContain('function setOpened(next, persistPreference = true)');
expect(overlaySource).toContain('setLocalStorage({ [openStateKey]: opened })');
expect(overlaySource).toContain('setOpened(false, false)');
expect(overlaySource).toContain('setOpened(lastUserOpenState ?? (chatStartMode === \'open\'), false)');
expect(overlaySource).toContain('typeof data[openStateKey] === \'boolean\'');
});
it('keeps chat hidden by default without discarding the room chat key', () => {
expect(popupSource).toContain('localData.chatEnabled === true');
expect(backgroundSource).toContain('chatEnabled: data.chatEnabled === true');
+11 -4
View File
@@ -28,6 +28,7 @@
large: Object.freeze({ width: 440, height: 640 })
});
const storageKey = `chatOverlayLayout:${location.origin}`;
const openStateKey = `chatOverlayOpen:${location.origin}`;
const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
let context = null;
let opened = false;
@@ -57,6 +58,7 @@
let chatSize = 'standard';
let chatStartMode = 'bubble';
let chatReactionDisplay = 'chat';
let lastUserOpenState = null;
let themeMode = 'system';
let themePalette = 'eucalyptus';
let pageDockTarget = null;
@@ -576,8 +578,12 @@
if (persistPreference) setLocalStorage({ chatSize });
}
function setOpened(next) {
function setOpened(next, persistPreference = true) {
opened = !!next && !!context?.enabled;
if (persistPreference) {
lastUserOpenState = opened;
setLocalStorage({ [openStateKey]: opened });
}
panel.classList.toggle('open', opened);
launcher.style.display = opened ? 'none' : '';
if (opened) {
@@ -604,10 +610,10 @@
launcher.setAttribute('aria-disabled', String(!context?.enabled));
if (!optedIn) startStateApplied = false;
if (!context?.enabled) {
setOpened(false);
setOpened(false, false);
} else if (preferencesLoaded && !startStateApplied) {
startStateApplied = true;
setOpened(chatStartMode === 'open');
setOpened(lastUserOpenState ?? (chatStartMode === 'open'), false);
}
applyStrings();
applyLayout();
@@ -967,7 +973,7 @@
systemTheme.addEventListener('change', handleSystemTheme);
chrome.storage.onChanged.addListener(handleStorage);
chrome.runtime.onMessage.addListener(handleRuntime);
chrome.storage.local.get([storageKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay'], data => {
chrome.storage.local.get([storageKey, openStateKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay'], data => {
if (destroyed) return;
const storedLayout = data[storageKey];
if (storedLayout && typeof storedLayout === 'object') {
@@ -978,6 +984,7 @@
chatPosition = normalizePosition(data.chatPosition);
chatSize = normalizeSize(data.chatSize);
chatStartMode = data.chatStartMode === 'open' ? 'open' : 'bubble';
lastUserOpenState = typeof data[openStateKey] === 'boolean' ? data[openStateKey] : null;
chatReactionDisplay = data.chatReactionDisplay === 'video' ? 'video' : 'chat';
layout.mode = chatPosition;
if (layout.mode === 'detached') layout.detachedInitialized = true;
+16
View File
@@ -77,6 +77,22 @@ describe('target tab lifecycle', () => {
expect(backgroundSource).toContain("activation?.status === 'activation_in_progress'");
});
it('does not discard a selected tab when its media frame refresh is transiently unavailable', () => {
const refreshFailureGuard = backgroundSource.slice(
backgroundSource.indexOf('const isCurrentTargetRefresh'),
backgroundSource.indexOf('currentTabId = null', backgroundSource.indexOf('const isCurrentTargetRefresh'))
);
expect(refreshFailureGuard).toContain('keeping the selected target for recovery');
expect(refreshFailureGuard).not.toContain('currentTabId = null');
const routeSource = backgroundSource.slice(
backgroundSource.indexOf('async function _routeToContentInternal'),
backgroundSource.indexOf('// --- Keep-Alive Mechanism ---')
);
expect(routeSource).toContain('keeping the selected target for recovery');
expect(routeSource).not.toContain('clearTargetTabForIdle(tabId, targetGeneration)');
});
it('serializes content commands and coalesces target refreshes', () => {
expect(backgroundSource).toContain('contentCommandQueue.catch(() => {}).then(deliver)');
expect(backgroundSource).toContain('if (mediaTargetRefreshTask && mediaTargetRefreshTabId === selectedTabId)');