diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 02f9ad0df36e..180e02810801 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -54,6 +54,7 @@ export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out"; export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom"; export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; +export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted"; export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 2453cfc0bdcd..9850230a03a9 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -13,6 +13,7 @@ import { DesktopPreviewRecordingSaveInputSchema, DesktopPreviewRegisterWebviewInputSchema, DesktopPreviewScreenshotArtifactSchema, + DesktopPreviewSetAudioMutedInputSchema, DesktopPreviewSetColorSchemeInputSchema, DesktopPreviewCreateTabInputSchema, DesktopPreviewTabInputSchema, @@ -153,6 +154,15 @@ export const setColorScheme = DesktopIpc.makeIpcMethod({ yield* manager.setColorScheme(tabId, colorScheme); }), }); +export const setAudioMuted = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, + payload: DesktopPreviewSetAudioMutedInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setAudioMuted")(function* ({ tabId, audioMuted }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setAudioMuted(tabId, audioMuted); + }), +}); export const openDevTools = tabMethod( IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, "desktop.ipc.preview.openDevTools", @@ -372,6 +382,7 @@ export const methods = [ resetZoom, hardReload, setColorScheme, + setAudioMuted, openDevTools, clearCookies, clearCache, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index b56be717e201..ee03141f2d82 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -183,6 +183,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }), setColorScheme: (tabId, colorScheme) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), + setAudioMuted: (tabId, audioMuted) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), openDevTools: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index c4297a69c260..880e704f8099 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -170,6 +170,8 @@ const makeTestPreviewWebContents = ( isLoading: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), ipc: { on: vi.fn(), off: vi.fn() }, @@ -234,6 +236,8 @@ const makeFaviconWebContents = (options?: { isDevToolsOpened: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, reload, reloadIgnoringCache: vi.fn(), loadURL, @@ -459,6 +463,8 @@ describe("PreviewManager", () => { isLoading: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, loadURL, on: vi.fn((event: string, listener: (...args: never[]) => void) => { listeners.set(event, listener); @@ -1002,6 +1008,8 @@ describe("PreviewManager", () => { return effectiveZoom; }, setZoomFactor, + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { listeners.set(event, listener); }), @@ -1066,6 +1074,8 @@ describe("PreviewManager", () => { isLoading: () => false, getZoomFactor: () => 1, setZoomFactor: replacementSetZoomFactor, + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), ipc: { on: vi.fn(), off: vi.fn() }, @@ -1104,6 +1114,8 @@ describe("PreviewManager", () => { isLoading: () => false, getZoomFactor: () => 1, setZoomFactor, + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), ipc: { on: vi.fn(), off: vi.fn() }, @@ -1148,6 +1160,8 @@ describe("PreviewManager", () => { isLoading: () => false, getZoomFactor: () => 1, setZoomFactor, + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), ipc: { on: vi.fn(), off: vi.fn() }, @@ -1201,6 +1215,8 @@ describe("PreviewManager", () => { isLoading: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), ipc: { on: vi.fn(), off: vi.fn() }, @@ -1257,6 +1273,287 @@ describe("PreviewManager", () => { ), ); + const makeAudioWebContents = (id: number) => { + const listeners = new Map void>(); + const setAudioMuted = vi.fn(); + let audible = false; + let audibleAfterFirstRead = false; + let audibleReads = 0; + return { + setAudioMuted, + emitAudioState: (next: boolean) => { + audible = next; + listeners.get("audio-state-changed")?.({ audible: next } as never); + }, + /** + * Starts playing between the attach-time read and the post-attach + * reconcile, without a delivered event — the window in which + * audio-state-changed fires against a guest the tab does not own yet. + */ + startPlayingAfterFirstRead: () => { + audibleAfterFirstRead = true; + }, + wc: { + id, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted, + isCurrentlyAudible: () => { + audibleReads += 1; + if (audibleAfterFirstRead && audibleReads > 1) return true; + return audible; + }, + loadURL: vi.fn(async () => undefined), + on: vi.fn((event: string, listener: (...args: never[]) => void) => { + listeners.set(event, listener); + }), + off: vi.fn((event: string) => { + listeners.delete(event); + }), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never, + }; + }; + + effectIt.effect("mutes the guest and re-applies the mute across webview swaps", () => + withManager((manager) => + Effect.gen(function* () { + const first = makeAudioWebContents(42); + fromId.mockReturnValue(first.wc); + const states: PreviewManager.PreviewTabState[] = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_audio"); + yield* manager.registerWebview("tab_audio", 42); + yield* Effect.yieldNow; + + expect(states.at(-1)?.audioMuted).toBe(false); + + yield* manager.setAudioMuted("tab_audio", true); + + expect(first.setAudioMuted).toHaveBeenCalledWith(true); + expect(states.at(-1)?.audioMuted).toBe(true); + + const replacement = makeAudioWebContents(43); + fromId.mockReturnValue(replacement.wc); + yield* manager.registerWebview("tab_audio", 43); + yield* Effect.yieldNow; + + expect(replacement.setAudioMuted).toHaveBeenCalledWith(true); + expect(states.at(-1)?.audioMuted).toBe(true); + + yield* manager.setAudioMuted("tab_audio", false); + + expect(replacement.setAudioMuted).toHaveBeenLastCalledWith(false); + expect(states.at(-1)?.audioMuted).toBe(false); + }), + ), + ); + + effectIt.effect("fails and rolls back when the guest refuses a mute", () => + withManager((manager) => + Effect.gen(function* () { + const guest = makeAudioWebContents(42); + fromId.mockReturnValue(guest.wc); + const states: PreviewManager.PreviewTabState[] = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_audio_fail"); + yield* manager.registerWebview("tab_audio_fail", 42); + yield* Effect.yieldNow; + + guest.setAudioMuted.mockImplementationOnce(() => { + throw new Error("guest refused"); + }); + const exit = yield* manager.setAudioMuted("tab_audio_fail", true).pipe(Effect.exit); + + // Reporting success would draw the tab as muted while it keeps playing. + expect(Exit.isFailure(exit)).toBe(true); + expect(states.at(-1)?.audioMuted).toBe(false); + }), + ), + ); + + effectIt.effect("still registers a guest that refuses the mute reassert", () => + withManager((manager) => + Effect.gen(function* () { + const first = makeAudioWebContents(42); + fromId.mockReturnValue(first.wc); + yield* manager.createTab("tab_audio_attach_fail"); + yield* manager.registerWebview("tab_audio_attach_fail", 42); + yield* Effect.yieldNow; + yield* manager.setAudioMuted("tab_audio_attach_fail", true); + + const replacement = makeAudioWebContents(43); + // Fails the post-attach settle, not the pre-publish apply. + replacement.setAudioMuted.mockImplementationOnce(() => undefined); + replacement.setAudioMuted.mockImplementationOnce(() => { + throw new Error("guest went away"); + }); + fromId.mockReturnValue(replacement.wc); + + // Reconciliation is best-effort: a guest dying mid-attach must not fail + // the registration it was attaching for. + const exit = yield* manager.registerWebview("tab_audio_attach_fail", 43).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + }), + ), + ); + + effectIt.effect("reconciles audibility that changed while the guest attached", () => + withManager((manager) => + Effect.gen(function* () { + const guest = makeAudioWebContents(42); + guest.startPlayingAfterFirstRead(); + fromId.mockReturnValue(guest.wc); + const states: PreviewManager.PreviewTabState[] = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_audio_window"); + yield* manager.registerWebview("tab_audio_window", 42); + yield* Effect.yieldNow; + + // audio-state-changed for this transition was dropped: it fired before + // the tab owned the guest. Without a post-attach reconcile the icon + // stays wrong until the next real transition, which may never come. + expect(states.at(-1)?.audible).toBe(true); + }), + ), + ); + + effectIt.effect("publishes audibility transitions and drops repeats", () => + withManager((manager) => + Effect.gen(function* () { + const guest = makeAudioWebContents(42); + fromId.mockReturnValue(guest.wc); + const states: PreviewManager.PreviewTabState[] = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_audible"); + yield* manager.registerWebview("tab_audible", 42); + yield* Effect.yieldNow; + + expect(states.at(-1)?.audible).toBe(false); + + guest.emitAudioState(true); + yield* Effect.yieldNow; + expect(states.at(-1)?.audible).toBe(true); + + // Chromium re-emits per media element; only real transitions publish. + const publishedAfterFirst = states.length; + guest.emitAudioState(true); + yield* Effect.yieldNow; + expect(states.length).toBe(publishedAfterFirst); + + guest.emitAudioState(false); + yield* Effect.yieldNow; + expect(states.at(-1)?.audible).toBe(false); + expect(states.length).toBeGreaterThan(publishedAfterFirst); + }), + ), + ); + + effectIt.effect("ignores audio state from a replaced guest", () => + withManager((manager) => + Effect.gen(function* () { + const first = makeAudioWebContents(42); + fromId.mockReturnValue(first.wc); + const states: PreviewManager.PreviewTabState[] = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_audio_stale"); + yield* manager.registerWebview("tab_audio_stale", 42); + yield* Effect.yieldNow; + + const replacement = makeAudioWebContents(43); + fromId.mockReturnValue(replacement.wc); + yield* manager.registerWebview("tab_audio_stale", 43); + yield* Effect.yieldNow; + + const publishedBefore = states.length; + first.emitAudioState(true); + yield* Effect.yieldNow; + + expect(states.length).toBe(publishedBefore); + expect(states.at(-1)?.audible).toBe(false); + }), + ), + ); + + effectIt.effect("carries mute and audibility across navigation", () => + withManager((manager) => + Effect.gen(function* () { + const guest = makeAudioWebContents(42); + fromId.mockReturnValue(guest.wc); + const states: PreviewManager.PreviewTabState[] = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_audio_nav"); + yield* manager.registerWebview("tab_audio_nav", 42); + yield* Effect.yieldNow; + + yield* manager.setAudioMuted("tab_audio_nav", true); + guest.emitAudioState(true); + yield* Effect.yieldNow; + expect(states.at(-1)?.audible).toBe(true); + + yield* manager.navigate("tab_audio_nav", "https://example.com/next"); + yield* Effect.yieldNow; + + // navigate runs before loadURL swaps the document, so the old page can + // still be playing. Dropping audibility here would lose the speaker + // with no transition left to bring it back. + expect(states.at(-1)?.audioMuted).toBe(true); + expect(states.at(-1)?.audible).toBe(true); + + // Chromium reports the real stop once the new document takes over. + guest.emitAudioState(false); + yield* Effect.yieldNow; + expect(states.at(-1)?.audible).toBe(false); + }), + ), + ); + effectIt.effect("blocks late webview and capture starts during tab close", () => withManager((manager) => Effect.gen(function* () { @@ -1346,6 +1643,8 @@ describe("PreviewManager", () => { isLoading: () => loading, getZoomFactor: () => 1, setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { listeners.set(event, listener); }), @@ -1436,6 +1735,8 @@ describe("PreviewManager", () => { isLoading: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn((event: string, listener: (...args: never[]) => void) => { listeners.set(event, listener); }), @@ -1525,6 +1826,8 @@ describe("PreviewManager", () => { isLoading: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), ipc: { on: vi.fn(), off: vi.fn() }, @@ -1735,6 +2038,8 @@ describe("PreviewManager", () => { isDevToolsOpened: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), ipc: { on: vi.fn(), off: vi.fn() }, @@ -1815,6 +2120,8 @@ describe("PreviewManager", () => { isLoading: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), ipc: { on: vi.fn(), off: vi.fn() }, @@ -2204,6 +2511,8 @@ describe("PreviewManager", () => { isFocused: () => true, getZoomFactor: () => 1, setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { listeners.set(event, listener); }), @@ -2255,6 +2564,8 @@ describe("PreviewManager", () => { isLoading: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), ipc: { @@ -2387,6 +2698,8 @@ describe("PreviewManager", () => { isDevToolsOpened: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), ipc: { @@ -2485,6 +2798,8 @@ describe("PreviewManager", () => { focus, getZoomFactor: () => 1, setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), ipc: { @@ -2638,6 +2953,8 @@ describe("PreviewManager", () => { isDevToolsOpened: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), ipc: { @@ -2705,6 +3022,8 @@ describe("PreviewManager", () => { isDevToolsOpened: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), ipc: { on: vi.fn(), off: vi.fn() }, diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 63abde47d6d8..abcd71a103e1 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -88,6 +88,10 @@ export interface PreviewTabState { zoomFactor: number; pictureInPicture: boolean; colorScheme: DesktopPreviewColorScheme; + /** User intent to silence this tab. Re-applied to each guest that attaches. */ + audioMuted: boolean; + /** Observed from Chromium. Stays true while a muted tab keeps playing. */ + audible: boolean; controller: "human" | "agent" | "none"; favicon?: DesktopPreviewFavicon; updatedAt: string; @@ -661,7 +665,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ] as const; }); - if (Option.isSome(next)) yield* emit(tabId, next.value); + // emitIfCurrent, not emit: an event-driven writer such as syncTabAudible + // can commit between the modify above and here, and republishing this + // snapshot would roll the UI back to a value that writer will not send + // again because it suppresses unchanged audibility. + if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value); }); /** @@ -680,6 +688,62 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ).pipe(Effect.ignore); }); + /** + * Mute counterpart to {@link assertTabZoom}: pushes the tab's committed mute + * onto whichever guest it currently owns, reading both at call time so an + * older snapshot can never roll back a mute action that landed after it. + * + * Failures propagate so the user-facing setter can roll its commit back. + * Reconciliation callers, where a guest going away mid-attach is expected, + * discard the error at their own call site. + */ + const assertTabAudioMuted = Effect.fn("PreviewManager.assertTabAudioMuted")(function* ( + tabId: string, + ) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab || tab.webContentsId == null) return; + const wc = webContents.fromId(tab.webContentsId); + if (!wc || wc.isDestroyed()) return; + yield* attempt({ operation: "assertTabAudioMuted", tabId, webContentsId: wc.id }, () => + wc.setAudioMuted(tab.audioMuted), + ); + }); + + /** + * Publishes an observed audibility value for the guest that reported it. + * Shared by the `audio-state-changed` handler and the post-attach reconcile + * so both drop values from a guest the tab no longer owns, and both skip + * unchanged values: Chromium re-emits per media element, and republishing + * would cost an IPC push per element rather than per real transition. + */ + const syncTabAudible = Effect.fn("PreviewManager.syncTabAudible")(function* ( + tabId: string, + wc: Electron.WebContents, + audible: boolean, + ) { + if (wc.isDestroyed()) return; + const updatedAt = yield* currentIso; + const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if ( + !current || + current.webContentsId !== wc.id || + webContents.fromId(wc.id) !== wc || + current.audible === audible + ) { + return [Option.none(), tabs] as const; + } + const state: PreviewTabState = { ...current, audible, updatedAt }; + return [ + Option.some(state), + replaceMap(tabs, (copy) => { + copy.set(tabId, state); + }), + ] as const; + }); + if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value); + }); + const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* ( tabId: string, ) { @@ -1389,6 +1453,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) => { if (event.isMainFrame && !event.isSameDocument) cancelFaviconCapture(); }; + const audioStateChanged = ( + event: Electron.Event, + ) => runFork(syncTabAudible(tabId, wc, event.audible)); const publishFavicon = Effect.fn("PreviewManager.publishFavicon")(function* (input: { readonly captureDocumentId: number; readonly dataUrl: string; @@ -1583,6 +1650,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("did-start-loading", sync); wc.off("did-stop-loading", sync); wc.off("did-fail-load", failed as never); + wc.off("audio-state-changed", audioStateChanged); wc.off("before-input-event", beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); @@ -1598,6 +1666,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("did-start-loading", sync); wc.on("did-stop-loading", sync); wc.on("did-fail-load", failed as never); + wc.on("audio-state-changed", audioStateChanged); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.setWindowOpenHandler(({ url }) => { @@ -1652,6 +1721,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function zoomFactor: normalizeZoomFactor(defaults?.zoomFactor), pictureInPicture: false, colorScheme: defaults?.colorScheme ?? "system", + audioMuted: false, + audible: false, controller: "none", updatedAt, }; @@ -1718,6 +1789,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function zoomFactor: DEFAULT_ZOOM_FACTOR, pictureInPicture: false, colorScheme: "system", + audioMuted: false, + audible: false, controller: "none", updatedAt, }; @@ -1808,7 +1881,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => wc.setZoomFactor(currentTab.zoomFactor), ); + // A replacement guest attaches unmuted, so reassert the tab's mute before it + // is published rather than letting it emit audio the user already silenced. + // Settled again after attach, below, the same way zoom is. + yield* attempt({ operation: "registerWebview.restoreAudioMuted", tabId, webContentsId }, () => + wc.setAudioMuted(currentTab.audioMuted), + ); yield* attachListeners(tabId, wc); + const readAudible = attempt( + { operation: "registerWebview.readAudible", tabId, webContentsId }, + () => wc.isCurrentlyAudible(), + ).pipe(Effect.orElseSucceed(() => false)); + const attachedAudible = yield* readAudible; const registeredAt = yield* currentIso; const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) => Effect.gen(function* () { @@ -1831,6 +1915,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, canGoBack: wc.navigationHistory.canGoBack(), canGoForward: wc.navigationHistory.canGoForward(), + audible: attachedAudible, updatedAt: registeredAt, }; return [ @@ -1852,11 +1937,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* new PreviewTabNotFoundError({ tabId }); } const { state: registered, pendingUrl } = registration.value; - // A zoom action that landed while this attach was in flight addressed the - // guest this one replaced, so settle the new guest on the committed factor. + // A zoom or mute action that landed while this attach was in flight + // addressed the guest this one replaced, so settle the new guest on the + // committed values. yield* assertTabZoom(tabId); + // Best-effort here, unlike in setAudioMuted: a guest that dies mid-attach + // must not fail the registration it was attaching for. + yield* assertTabAudioMuted(tabId).pipe(Effect.ignore); runFork(restoreControlSession(tabId, wc)); - yield* emit(tabId, registered); + // emitIfCurrent, not emit: audio-state-changed can land between the commit + // above and here, and republishing this snapshot would roll the UI back to + // a superseded audibility that syncTabAudible will not re-send. + yield* emitIfCurrent(tabId, registered); + // Transitions that fired before the tab owned this guest were dropped by + // syncTabAudible's ownership check, so re-read and reconcile through the + // same path the event uses. + yield* syncTabAudible(tabId, wc, yield* readAudible); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), ); @@ -1906,6 +2002,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, pictureInPicture: current?.pictureInPicture ?? false, colorScheme: current?.colorScheme ?? "system", + // Both carry across navigation. Mute is user intent, and the old + // document keeps playing until loadURL actually replaces it, so + // clearing audibility here would drop the speaker with no transition + // left to restore it. Chromium reports the change when it happens. + audioMuted: current?.audioMuted ?? false, + audible: current?.audible ?? false, controller: current?.controller ?? "none", ...(current?.favicon ? { favicon: current.favicon } : {}), updatedAt, @@ -1917,7 +2019,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ] as const; }); - yield* emit(tabId, pending); + // emitIfCurrent for the same reason as update: this snapshot carries + // audibility forward, and an audio-state-changed landing in between would + // otherwise be rolled back with no follow-up transition to correct it. + yield* emitIfCurrent(tabId, pending); if (pending.webContentsId == null) return; const webContentsId = pending.webContentsId; const wc = webContents.fromId(webContentsId); @@ -2251,6 +2356,39 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* applyColorScheme(tabId, wc, colorScheme); }); + const setAudioMuted = Effect.fn("PreviewManager.setAudioMuted")(function* ( + tabId: string, + audioMuted: boolean, + ) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + // Commit and apply under the tab's lifecycle lock, then assert the + // committed value rather than this call's argument. Two overlapping toggles + // would otherwise be free to commit in one order and reach Chromium in the + // other, leaving the icon disagreeing with the guest. + yield* withTabLifecycleLock( + tabId, + Effect.gen(function* () { + // Record the intent even when no guest is attached yet — it is + // re-applied by registerWebview when one arrives. + const previous = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.audioMuted; + const committed = previous !== undefined && previous !== audioMuted; + if (committed) { + yield* update(tabId, { audioMuted }); + } + // Roll the commit back if Chromium refused: reporting success here + // would leave the tab drawn as muted while it keeps playing. + yield* assertTabAudioMuted(tabId).pipe( + Effect.tapError(() => + committed ? update(tabId, { audioMuted: previous }) : Effect.void, + ), + ); + }), + ); + }); + const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* ( tabId: string, ) { @@ -3543,6 +3681,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function revealArtifact, saveRecording, setAnnotationTheme, + setAudioMuted, setColorScheme, setMainWindow, startRecording, @@ -3846,6 +3985,10 @@ export class PreviewManager extends Context.Service< tabId: string, colorScheme: DesktopPreviewColorScheme, ) => Effect.Effect; + readonly setAudioMuted: ( + tabId: string, + audioMuted: boolean, + ) => Effect.Effect; readonly openDevTools: (tabId: string) => Effect.Effect; readonly clearCookies: () => Effect.Effect; readonly clearCache: () => Effect.Effect; @@ -3944,6 +4087,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { reapplyZoom: operations.reapplyZoom, hardReload: operations.hardReload, setColorScheme: operations.setColorScheme, + setAudioMuted: operations.setAudioMuted, openDevTools: operations.openDevTools, clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { yield* browserSession diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0a5b7bb8c601..64dd5ebfd392 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -136,6 +136,7 @@ import { setActivePreviewTab, useThreadPreviewState, } from "../previewStateStore"; +import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; @@ -1636,6 +1637,14 @@ function ChatViewContent(props: ChatViewProps) { const activeFileSurface = activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; const activePreviewState = useThreadPreviewState(activeThreadRef); + const activePreviewServerEpoch = activePreviewState.serverEpoch; + const resolvePreviewRuntimeTabId = useMemo( + () => + activeThreadRef + ? (tabId: string) => previewRuntimeTabId(activeThreadRef, activePreviewServerEpoch, tabId) + : undefined, + [activeThreadRef, activePreviewServerEpoch], + ); const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef), ); @@ -6627,6 +6636,7 @@ function ChatViewContent(props: ChatViewProps) { pendingSurfaceIds={pendingFileSurfaceIds} previewSessions={activePreviewState.sessions} desktopByTabId={activePreviewState.desktopByTabId} + previewRuntimeTabId={resolvePreviewRuntimeTabId} terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} @@ -6666,6 +6676,7 @@ function ChatViewContent(props: ChatViewProps) { pendingSurfaceIds={pendingFileSurfaceIds} previewSessions={activePreviewState.sessions} desktopByTabId={activePreviewState.desktopByTabId} + previewRuntimeTabId={resolvePreviewRuntimeTabId} terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 7312f0b8c651..dc65cd2bf79c 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -2,7 +2,7 @@ import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/con import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { RightPanelTabs } from "./RightPanelTabs"; +import { RightPanelTabs, tabMuteMenuItem } from "./RightPanelTabs"; const previewSurface = { id: "browser:tab-1" as const, @@ -39,7 +39,10 @@ const favicon = (dataUrl: string, pageUrl: string): DesktopPreviewFavicon => ({ capturedAt: 1, }); -function overlay(icon: DesktopPreviewFavicon | null) { +function overlay( + icon: DesktopPreviewFavicon | null, + audio?: { audible?: boolean; audioMuted?: boolean }, +) { return { hasWebContents: true, canGoBack: false, @@ -48,12 +51,19 @@ function overlay(icon: DesktopPreviewFavicon | null) { zoomFactor: 1, pictureInPicture: false, colorScheme: "system" as const, + audioMuted: audio?.audioMuted ?? false, + audible: audio?.audible ?? false, controller: "none" as const, favicon: icon, }; } -function renderTabs(first: DesktopPreviewFavicon | null, second?: DesktopPreviewFavicon) { +function renderTabs( + first: DesktopPreviewFavicon | null, + second?: DesktopPreviewFavicon, + audio?: { audible?: boolean; audioMuted?: boolean }, + previewRuntimeTabId: ((tabId: string) => string) | null = (tabId) => `runtime:${tabId}`, +) { return renderToStaticMarkup( undefined} onCloseSurface={() => undefined} @@ -113,3 +124,73 @@ describe("RightPanelTabs preview favicon", () => { expect(html).not.toContain("data:image/png;base64,AAAA"); }); }); + +describe("RightPanelTabs audio indicator", () => { + // A muted tab only shows the indicator while it is actually making sound: + // arming mute on a quiet tab is deliberate and stays invisible until there + // is something to suppress. + const cases = [ + { audible: false, audioMuted: false, label: null }, + { audible: false, audioMuted: true, label: null }, + { audible: true, audioMuted: false, label: "Mute Local site" }, + { audible: true, audioMuted: true, label: "Unmute Local site" }, + ] as const; + + it.each(cases)("audible=$audible muted=$audioMuted", ({ audible, audioMuted, label }) => { + const html = renderTabs(null, undefined, { audible, audioMuted }); + if (label === null) { + expect(html).not.toContain("Mute Local site"); + expect(html).not.toContain("Unmute Local site"); + } else { + expect(html).toContain(`aria-label="${label}"`); + } + }); + + it("addresses the desktop by runtime tab id, never the server session id", () => { + // Session ids are only unique per server process; sending one to the + // Electron manager raises PreviewTabNotFoundError and silently no-ops. + const seen: string[] = []; + renderTabs(null, undefined, { audible: true }, (tabId) => { + seen.push(tabId); + return `runtime:${tabId}`; + }); + expect(seen).toContain("tab-1"); + }); + + it("hides the toggle when no runtime tab id can be resolved", () => { + const html = renderTabs(null, undefined, { audible: true }, null); + expect(html).not.toContain("Mute Local site"); + }); +}); + +describe("tabMuteMenuItem", () => { + const overlay = (audioMuted: boolean) => + ({ audioMuted, audible: false }) as Parameters[0]["overlay"]; + + it("stays disabled until the desktop tab exists", () => { + // The server session id resolves before the preview manager finishes + // createTab. Muting in that window fails with an error nobody surfaces. + expect(tabMuteMenuItem({ overlay: null, canResolveRuntimeTabId: true })).toEqual({ + label: "Mute tab", + disabled: true, + }); + }); + + it("stays disabled when no runtime tab id can be resolved", () => { + expect(tabMuteMenuItem({ overlay: overlay(false), canResolveRuntimeTabId: false })).toEqual({ + label: "Mute tab", + disabled: true, + }); + }); + + it("offers mute and unmute once the tab is addressable", () => { + expect(tabMuteMenuItem({ overlay: overlay(false), canResolveRuntimeTabId: true })).toEqual({ + label: "Mute tab", + disabled: false, + }); + expect(tabMuteMenuItem({ overlay: overlay(true), canResolveRuntimeTabId: true })).toEqual({ + label: "Unmute tab", + disabled: false, + }); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index b91e81bc7a0b..354d1443ee98 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -8,6 +8,8 @@ import { Globe2, Plus, TerminalSquare, + Volume2, + VolumeOff, X, } from "lucide-react"; import { @@ -37,6 +39,7 @@ import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; import { FaviconImage } from "./preview/PreviewFaviconIcon"; +import { previewBridge } from "./preview/previewBridge"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; interface RightPanelTabsProps { @@ -52,6 +55,12 @@ interface RightPanelTabsProps { pendingSurfaceIds: ReadonlySet; previewSessions: Readonly>; desktopByTabId: Readonly>; + /** + * Maps a server session tab id to the desktop runtime tab id the Electron + * preview manager is keyed by. Session ids are only unique within one server + * process, so desktop operations must not be addressed with them. + */ + previewRuntimeTabId?: ((tabId: string) => string) | undefined; terminalLabelsById: ReadonlyMap; onActivate: (surface: RightPanelSurface) => void; onCloseSurface: (surface: RightPanelSurface) => void; @@ -116,7 +125,53 @@ const SURFACE_UNAVAILABLE_HINTS = { agents: "Available from a thread.", } as const; -type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all"; +type TabContextMenuAction = + | "copy-path" + | "toggle-mute" + | "close" + | "close-others" + | "close-to-right" + | "close-all"; + +/** + * Desktop preview tab backing a surface, or null for non-preview surfaces, the + * "new browser tab" placeholder, and the web build where no desktop tab exists. + */ +function previewTabIdOf( + surface: RightPanelSurface, + sessions: Readonly>, +): string | null { + if (surface.kind !== "preview" || !surface.resourceId) return null; + return sessions[surface.resourceId]?.tabId ?? null; +} + +/** + * Label and enabled state for a preview tab's mute menu entry. + * Stays disabled until desktop overlay state arrives: a server session id can + * resolve while the preview manager's createTab is still in flight, and muting + * then fails with a PreviewTabNotFoundError nothing surfaces to the user. + */ +export function tabMuteMenuItem(input: { + overlay: DesktopPreviewOverlay | null; + canResolveRuntimeTabId: boolean; +}): { label: string; disabled: boolean } { + const muted = input.overlay?.audioMuted ?? false; + return { + label: muted ? "Unmute tab" : "Mute tab", + disabled: input.overlay === null || !input.canResolveRuntimeTabId, + }; +} + +type TabAudioState = "none" | "audible" | "muted"; + +/** + * A muted tab that is not making sound shows nothing: mute is armed silently, + * and the indicator only appears once there is audio to speak of. + */ +function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState { + if (!overlay?.audible) return "none"; + return overlay.audioMuted ? "muted" : "audible"; +} function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { return ( @@ -534,6 +589,25 @@ export function RightPanelTabs(props: RightPanelTabsProps) { if (surface.kind === "file") { items.push({ id: "copy-path", label: "Copy path" }); } + const menuPreviewTabId = previewTabIdOf(surface, props.previewSessions); + // Desktop overlay state only arrives once the preview manager has created + // the tab. A server session id alone can still be ahead of that, and + // muting then fails with PreviewTabNotFoundError that nobody surfaces. + const menuOverlay = menuPreviewTabId + ? (props.desktopByTabId[menuPreviewTabId] ?? null) + : null; + const menuMuted = menuOverlay?.audioMuted ?? false; + if (surface.kind === "preview") { + // Not gated on audibility: silencing a quiet tab ahead of time is the + // point, so the item is offered whenever the tab is mutable at all. + items.push({ + id: "toggle-mute", + ...tabMuteMenuItem({ + overlay: menuOverlay, + canResolveRuntimeTabId: props.previewRuntimeTabId !== undefined, + }), + }); + } items.push( { id: "close", label: "Close" }, { @@ -558,6 +632,18 @@ export function RightPanelTabs(props: RightPanelTabsProps) { case "copy-path": if (surface.kind === "file") props.onCopyFilePath(surface.relativePath); break; + case "toggle-mute": { + // menuOverlay repeats the disabled gate above: the desktop tab must + // exist before it can be addressed, however the menu was dismissed. + const runtimeTabId = + menuPreviewTabId && menuOverlay + ? (props.previewRuntimeTabId?.(menuPreviewTabId) ?? null) + : null; + if (runtimeTabId) { + void previewBridge?.setAudioMuted(runtimeTabId, !menuMuted).catch(() => undefined); + } + break; + } case "close": props.onCloseSurface(surface); break; @@ -626,6 +712,15 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const active = surface.id === props.activeSurfaceId; const pending = props.pendingSurfaceIds.has(surface.id); const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); + const previewTabId = previewTabIdOf(surface, props.previewSessions); + // Desktop state is keyed by the session id, but desktop actions + // must be addressed with the runtime id. + const audio = tabAudioState( + previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, + ); + const audioRuntimeTabId = previewTabId + ? (props.previewRuntimeTabId?.(previewTabId) ?? null) + : null; return (
+ {audio === "none" || !audioRuntimeTabId ? null : ( + + { + // Sibling of the close button, inside a tab that + // activates on click: keep this to the toggle. + event.stopPropagation(); + void previewBridge + ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") + .catch(() => undefined); + }} + > + {audio === "muted" ? ( + + ) : ( + + )} + + } + /> + {audio === "muted" ? "Unmute tab" : "Mute tab"} + + )} ({ zoomFactor: 1, pictureInPicture: mocks.pictureInPicture, colorScheme: "system", + audioMuted: false, + audible: false, controller: "none", }, }, diff --git a/apps/web/src/components/preview/usePreviewBridge.test.ts b/apps/web/src/components/preview/usePreviewBridge.test.ts index 75387f0c8fb4..14acf0d69609 100644 --- a/apps/web/src/components/preview/usePreviewBridge.test.ts +++ b/apps/web/src/components/preview/usePreviewBridge.test.ts @@ -19,6 +19,8 @@ function state(navStatus: DesktopPreviewTabState["navStatus"]): DesktopPreviewTa zoomFactor: 1, pictureInPicture: false, colorScheme: "system", + audioMuted: false, + audible: false, controller: "none", favicon, updatedAt: "2026-08-09T00:00:00.000Z", diff --git a/apps/web/src/components/preview/usePreviewBridge.ts b/apps/web/src/components/preview/usePreviewBridge.ts index dc62ef981aa5..58b918ad819e 100644 --- a/apps/web/src/components/preview/usePreviewBridge.ts +++ b/apps/web/src/components/preview/usePreviewBridge.ts @@ -121,6 +121,8 @@ export function projectDesktopState(state: DesktopPreviewTabState): DesktopPrevi zoomFactor: state.zoomFactor, pictureInPicture: state.pictureInPicture, colorScheme: state.colorScheme, + audioMuted: state.audioMuted, + audible: state.audible, controller: state.controller, favicon: state.favicon && originOf(state.favicon.pageUrl) === navOrigin ? state.favicon : null, }; diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 975ef59f4bed..bfe5d46b1877 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -321,6 +321,8 @@ describe("previewStateStore (single-tab)", () => { zoomFactor: 1, pictureInPicture: false, colorScheme: "system", + audioMuted: false, + audible: false, controller: "none", favicon: null, }); @@ -342,6 +344,8 @@ describe("previewStateStore (single-tab)", () => { zoomFactor: 1, pictureInPicture: false, colorScheme: "system", + audioMuted: false, + audible: false, controller: "none", favicon: null, }); @@ -391,6 +395,8 @@ describe("previewStateStore (single-tab)", () => { zoomFactor: 1, pictureInPicture: false, colorScheme: "system", + audioMuted: false, + audible: false, controller: "none", favicon: null, }); @@ -506,6 +512,8 @@ describe("previewStateStore (single-tab)", () => { zoomFactor: 1, pictureInPicture: false, colorScheme: "system", + audioMuted: false, + audible: false, controller: "none", favicon: null, }); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 5a65d1709497..a40e65fbc6a8 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -28,6 +28,8 @@ export interface DesktopPreviewOverlay { zoomFactor: number; pictureInPicture: boolean; colorScheme: DesktopPreviewColorScheme; + audioMuted: boolean; + audible: boolean; controller: "human" | "agent" | "none"; favicon: DesktopPreviewFavicon | null; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 9be21da65b04..6a1f1c3209d5 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -558,6 +558,19 @@ export interface DesktopPreviewTabState { /** Whether this tab is currently mirrored into a desktop picture-in-picture window. */ pictureInPicture: boolean; colorScheme: DesktopPreviewColorScheme; + /** + * Whether the user has silenced this tab. Per tab rather than per origin, so + * two tabs on the same site mute independently. Survives navigation and + * webview swaps, but is dropped when the tab closes. + */ + audioMuted: boolean; + /** + * Whether the guest is currently emitting audio. Observed from Chromium, and + * independent of {@link audioMuted}: a muted tab that is playing still reports + * `true`, which is what lets the tab strip distinguish "muted and making + * sound" from "muted and silent". + */ + audible: boolean; controller: "human" | "agent" | "none"; favicon?: DesktopPreviewFavicon; updatedAt: string; @@ -597,6 +610,8 @@ export const DesktopPreviewTabStateSchema: Schema.Codec zoomFactor: Schema.Number, pictureInPicture: Schema.Boolean, colorScheme: DesktopPreviewColorSchemeSchema, + audioMuted: Schema.Boolean, + audible: Schema.Boolean, controller: Schema.Literals(["human", "agent", "none"]), favicon: Schema.optionalKey(DesktopPreviewFaviconSchema), updatedAt: Schema.String, @@ -993,6 +1008,11 @@ export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({ colorScheme: DesktopPreviewColorSchemeSchema, }); +export const DesktopPreviewSetAudioMutedInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + audioMuted: Schema.Boolean, +}); + export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({ theme: DesktopPreviewAnnotationThemeSchema, }); @@ -1144,6 +1164,12 @@ export interface DesktopPreviewBridge { * override). Persists per tab and is re-applied across webview swaps. */ setColorScheme: (tabId: string, colorScheme: DesktopPreviewColorScheme) => Promise; + /** + * Silence the tab's audio output. Persists per tab and is re-applied across + * webview swaps, but is dropped when the tab closes. Muting a silent tab is + * allowed; it simply takes effect once the page plays something. + */ + setAudioMuted: (tabId: string, audioMuted: boolean) => Promise; /** Open the guest webview's DevTools (detached). */ openDevTools: (tabId: string) => Promise; /** Drop cookies + storage data for the preview partition (all tabs). */