From bce582b56898ca23df9668c5b8c2387a8acf2cc2 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:33:19 +0200 Subject: [PATCH] fix(extension): preserve target and chat visibility state --- extension/background.js | 54 +++++------------------- extension/chat-overlay-contract.test.mjs | 10 +++++ extension/chat-overlay.js | 15 +++++-- extension/target-tab-lifecycle.test.mjs | 16 +++++++ 4 files changed, 48 insertions(+), 47 deletions(-) diff --git a/extension/background.js b/extension/background.js index 685525d..888a4dc 100644 --- a/extension/background.js +++ b/extension/background.js @@ -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); } } diff --git a/extension/chat-overlay-contract.test.mjs b/extension/chat-overlay-contract.test.mjs index 5989f3b..c7be28f 100644 --- a/extension/chat-overlay-contract.test.mjs +++ b/extension/chat-overlay-contract.test.mjs @@ -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'); diff --git a/extension/chat-overlay.js b/extension/chat-overlay.js index 22a4273..5748c7f 100644 --- a/extension/chat-overlay.js +++ b/extension/chat-overlay.js @@ -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; diff --git a/extension/target-tab-lifecycle.test.mjs b/extension/target-tab-lifecycle.test.mjs index f38cb96..cceb5eb 100644 --- a/extension/target-tab-lifecycle.test.mjs +++ b/extension/target-tab-lifecycle.test.mjs @@ -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)');