diff --git a/apps/desktop/src/app/chat/right-rail/preview-guest-offscreen.ts b/apps/desktop/src/app/chat/right-rail/preview-guest-offscreen.ts index abe72b57e3..981ec163c7 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-guest-offscreen.ts +++ b/apps/desktop/src/app/chat/right-rail/preview-guest-offscreen.ts @@ -7,14 +7,25 @@ * moves a guest's focus when its `` is hidden), so main's * focused-guest gestures (mouse back/forward, swipe, ⌘R) would navigate it. * Only this renderer knows what is on screen, so it tells main. + * + * A hidden SESSION's page (parked, see parked-panes.ts) keeps running — that + * is the point of keeping it — but must not be heard from a chat the user has + * left. It is muted while parked and handed back as it was. An inactive tab in + * the session on screen keeps playing, like a background tab in any browser. */ +import { useStore } from '@nanostores/react' import { type RefObject, useEffect } from 'react' import { usePaneVisible } from '@/components/pane-shell/pane-visibility' +import { $parkedTreePanes } from '@/components/pane-shell/tree/parked-panes' +import { PREVIEW_TILE_PREFIX } from '@/store/preview-explicit' export interface OffscreenGuest { getWebContentsId?: () => number + isConnected?: boolean + isAudioMuted?: () => boolean + setAudioMuted?: (muted: boolean) => void } /** The guest's webContents id, or null before attach / after removal (Electron @@ -27,8 +38,27 @@ function guestId(webview: null | OffscreenGuest): null | number { } } -export function usePreviewGuestOffscreen(webviewRef: RefObject): void { +/** Mute `webview` unless it already is; returns whether this call muted it, so + * the caller never unmutes a page that was muted before. */ +function muteGuest(webview: OffscreenGuest): boolean { + try { + if (webview.isAudioMuted?.()) { + return false + } + + webview.setAudioMuted?.(true) + + return true + } catch { + // Not attached yet / already gone: nothing is playing. + return false + } +} + +export function usePreviewGuestOffscreen(webviewRef: RefObject, tabId?: string): void { const hidden = !usePaneVisible() + const parkedPanes = useStore($parkedTreePanes) + const parked = Boolean(tabId) && parkedPanes.has(`${PREVIEW_TILE_PREFIX}:${tabId}`) useEffect(() => { const id = guestId(webviewRef.current) @@ -37,4 +67,26 @@ export function usePreviewGuestOffscreen(webviewRef: RefObject { + const webview = webviewRef.current + + if (!parked || !webview || !muteGuest(webview)) { + return + } + + return () => { + // Unmounted while parked (closed, or let go past the retention cap): + // the guest is being destroyed, and unmuting it would only blip. + if (!webview.isConnected) { + return + } + + try { + webview.setAudioMuted?.(false) + } catch { + // The guest went away while parked; nothing to restore. + } + } + }, [parked, webviewRef]) } diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx index a34b74f3fe..d1b789a503 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { onComposerAttachImagesRequest } from '@/app/chat/composer/focus' import { PaneVisibleContext } from '@/components/pane-shell/pane-visibility' +import { setTreePaneParked } from '@/components/pane-shell/tree/parked-panes' import { $previewTabs, closeRightRail, openPreview, previewTabId } from '@/store/preview' import { $connection, $selectedStoredSessionId } from '@/store/session' @@ -943,6 +944,45 @@ describe('PreviewPane off-screen guest', () => { }) expect(setPreviewGuestHidden).toHaveBeenLastCalledWith(41, false) }) + + // A hidden session's kept page keeps running, but is not heard from the chat + // the user switched to; it comes back with the sound it had. + async function renderAudibleGuest(tabId: string, mutedBefore: boolean) { + desktopWindow.hermesDesktop = {} as unknown as Window['hermesDesktop'] + let rendered!: ReturnType + + await act(async () => { + rendered = render() + }) + + let muted = mutedBefore + const setAudioMuted = vi.fn((next: boolean) => (muted = next)) + + Object.assign(rendered.container.querySelector('webview')!, { isAudioMuted: () => muted, setAudioMuted }) + + return { isMuted: () => muted, setAudioMuted } + } + + it("mutes a hidden session's page and restores its sound on return", async () => { + const { isMuted, setAudioMuted } = await renderAudibleGuest('url:audible', false) + + act(() => setTreePaneParked('preview-tile:url:audible', true)) + expect(isMuted()).toBe(true) + + act(() => setTreePaneParked('preview-tile:url:audible', false)) + expect(isMuted()).toBe(false) + expect(setAudioMuted.mock.calls).toEqual([[true], [false]]) + }) + + it('never unmutes a page that was already muted before its session left', async () => { + const { isMuted, setAudioMuted } = await renderAudibleGuest('url:muted', true) + + act(() => setTreePaneParked('preview-tile:url:muted', true)) + act(() => setTreePaneParked('preview-tile:url:muted', false)) + + expect(isMuted()).toBe(true) + expect(setAudioMuted).not.toHaveBeenCalled() + }) }) describe('PreviewPane local HTML Render|Source toggle', () => { diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx index 3953e140fe..94b9223d68 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -276,7 +276,7 @@ export function PreviewPane({ const lastRestartEventRef = useRef('') const previewContentRef = useRef(null) const webviewRef = useRef(null) - usePreviewGuestOffscreen(webviewRef) + usePreviewGuestOffscreen(webviewRef, tabId) const previewServerRestart = useStore($previewServerRestart) const consoleHeight = useStore(consoleState.$height) const consoleOpen = useStore(consoleState.$open) diff --git a/apps/desktop/src/components/pane-shell/tree/parked-panes.ts b/apps/desktop/src/components/pane-shell/tree/parked-panes.ts index 4d624e8c50..987bd124bd 100644 --- a/apps/desktop/src/components/pane-shell/tree/parked-panes.ts +++ b/apps/desktop/src/components/pane-shell/tree/parked-panes.ts @@ -5,19 +5,32 @@ * — while adoption must not dock it back. The contribution's owner parks and * unparks it: a preview tab whose session is not on screen. * + * An atom so a parked body can react to being parked (a Browser mutes its + * page while its session is off screen). + * * Memory-only: after a relaunch there is no live body left to keep. */ -const parked = new Set() +import { atom } from 'nanostores' + +export const $parkedTreePanes = atom>(new Set()) export function isTreePaneParked(paneId: string): boolean { - return parked.has(paneId) + return $parkedTreePanes.get().has(paneId) } export function setTreePaneParked(paneId: string, isParked: boolean): void { - if (isParked) { - parked.add(paneId) - } else { - parked.delete(paneId) + if (isTreePaneParked(paneId) === isParked) { + return } + + const next = new Set($parkedTreePanes.get()) + + if (isParked) { + next.add(paneId) + } else { + next.delete(paneId) + } + + $parkedTreePanes.set(next) }