fix(desktop): mute a hidden session's kept Browser and restore its sound on return

A hidden session's live Browser stays mounted so returning shows the same
page, but it kept playing audio into the chat the user had switched to. The
parked-pane set is now an atom; a preview pane whose tab is parked mutes its
guest (webview.setAudioMuted) and unmutes on return only if it was the one that
muted it, so a page muted beforehand stays muted. Inactive tabs in the session
on screen keep playing, like background tabs in a browser.

Timers/rendering: a CSS-hidden webview still reports visible to Chromium and
Electron has no public API to mark a guest hidden without detaching it (any
detach destroys the guest, losing its state), so throttling is left as is.
This commit is contained in:
teknium1
2026-10-01 23:48:08 -07:00
committed by Teknium
parent 3cb409bed8
commit 263fe5ccbd
4 changed files with 113 additions and 8 deletions

View File

@@ -7,14 +7,25 @@
* moves a guest's focus when its `<webview>` 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<null | OffscreenGuest>): 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<null | OffscreenGuest>, 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<null | OffscreenG
window.hermesDesktop?.setPreviewGuestHidden?.(id, hidden)
}
}, [hidden, webviewRef])
useEffect(() => {
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])
}

View File

@@ -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<typeof render>
await act(async () => {
rendered = render(<PreviewPane tabId={tabId} target={target} />)
})
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', () => {

View File

@@ -276,7 +276,7 @@ export function PreviewPane({
const lastRestartEventRef = useRef('')
const previewContentRef = useRef<HTMLDivElement | null>(null)
const webviewRef = useRef<PreviewWebview | null>(null)
usePreviewGuestOffscreen(webviewRef)
usePreviewGuestOffscreen(webviewRef, tabId)
const previewServerRestart = useStore($previewServerRestart)
const consoleHeight = useStore(consoleState.$height)
const consoleOpen = useStore(consoleState.$open)

View File

@@ -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<string>()
import { atom } from 'nanostores'
export const $parkedTreePanes = atom<ReadonlySet<string>>(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)
}