authorgravatar for chris@xenforo.comChris Deeming <chris@xenforo.com> 2026-08-18 23:45:25+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-18 18:45:25-04:00
log3a02c9cf1d6767cad60e5056cb5b722931f6cc5d
treeb80bb9d2f985255d7c1bae10fdd2bd74244b2ae3
parent6a687ee43bf222672ab8d3f4c0bab3d8d174f79f
signature Signed by PGP key B5690EEEBB952194

feat(desktop): mute a browser tab (#7252)


14 files changed, 744 insertions(+), 10 deletions(-)

apps/desktop/src/ipc/channels.ts+1
...@@ -54,6 +54,7 @@ export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out";...@@ -54,6 +54,7 @@ export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out";
54export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom";54export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom";
55export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload";55export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload";
56export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme";56export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme";
57export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted";
57export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";58export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";
58export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";59export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";
59export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";60export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
apps/desktop/src/ipc/methods/preview.ts+11
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 DesktopPreviewRecordingSaveInputSchema,13 DesktopPreviewRecordingSaveInputSchema,
14 DesktopPreviewRegisterWebviewInputSchema,14 DesktopPreviewRegisterWebviewInputSchema,
15 DesktopPreviewScreenshotArtifactSchema,15 DesktopPreviewScreenshotArtifactSchema,
16 DesktopPreviewSetAudioMutedInputSchema,
16 DesktopPreviewSetColorSchemeInputSchema,17 DesktopPreviewSetColorSchemeInputSchema,
17 DesktopPreviewCreateTabInputSchema,18 DesktopPreviewCreateTabInputSchema,
18 DesktopPreviewTabInputSchema,19 DesktopPreviewTabInputSchema,
...@@ -153,6 +154,15 @@ export const setColorScheme = DesktopIpc.makeIpcMethod({...@@ -153,6 +154,15 @@ export const setColorScheme = DesktopIpc.makeIpcMethod({
153 yield* manager.setColorScheme(tabId, colorScheme);154 yield* manager.setColorScheme(tabId, colorScheme);
154 }),155 }),
155});156});
157export const setAudioMuted = DesktopIpc.makeIpcMethod({
158 channel: IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL,
159 payload: DesktopPreviewSetAudioMutedInputSchema,
160 result: Schema.Void,
161 handler: Effect.fn("desktop.ipc.preview.setAudioMuted")(function* ({ tabId, audioMuted }) {
162 const manager = yield* PreviewManager.PreviewManager;
163 yield* manager.setAudioMuted(tabId, audioMuted);
164 }),
165});
156export const openDevTools = tabMethod(166export const openDevTools = tabMethod(
157 IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL,167 IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL,
158 "desktop.ipc.preview.openDevTools",168 "desktop.ipc.preview.openDevTools",
...@@ -372,6 +382,7 @@ export const methods = [...@@ -372,6 +382,7 @@ export const methods = [
372 resetZoom,382 resetZoom,
373 hardReload,383 hardReload,
374 setColorScheme,384 setColorScheme,
385 setAudioMuted,
375 openDevTools,386 openDevTools,
376 clearCookies,387 clearCookies,
377 clearCache,388 clearCache,
apps/desktop/src/preload.ts+2
...@@ -183,6 +183,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -183,6 +183,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
183 hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }),183 hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }),
184 setColorScheme: (tabId, colorScheme) =>184 setColorScheme: (tabId, colorScheme) =>
185 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }),185 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }),
186 setAudioMuted: (tabId, audioMuted) =>
187 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }),
186 openDevTools: (tabId) =>188 openDevTools: (tabId) =>
187 ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),189 ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
188 clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL),190 clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL),
apps/desktop/src/preview/Manager.test.ts+319
...@@ -170,6 +170,8 @@ const makeTestPreviewWebContents = (...@@ -170,6 +170,8 @@ const makeTestPreviewWebContents = (
170 isLoading: () => false,170 isLoading: () => false,
171 getZoomFactor: () => 1,171 getZoomFactor: () => 1,
172 setZoomFactor: vi.fn(),172 setZoomFactor: vi.fn(),
173 setAudioMuted: vi.fn(),
174 isCurrentlyAudible: () => false,
173 on: vi.fn(),175 on: vi.fn(),
174 off: vi.fn(),176 off: vi.fn(),
175 ipc: { on: vi.fn(), off: vi.fn() },177 ipc: { on: vi.fn(), off: vi.fn() },
...@@ -234,6 +236,8 @@ const makeFaviconWebContents = (options?: {...@@ -234,6 +236,8 @@ const makeFaviconWebContents = (options?: {
234 isDevToolsOpened: () => false,236 isDevToolsOpened: () => false,
235 getZoomFactor: () => 1,237 getZoomFactor: () => 1,
236 setZoomFactor: vi.fn(),238 setZoomFactor: vi.fn(),
239 setAudioMuted: vi.fn(),
240 isCurrentlyAudible: () => false,
237 reload,241 reload,
238 reloadIgnoringCache: vi.fn(),242 reloadIgnoringCache: vi.fn(),
239 loadURL,243 loadURL,
...@@ -459,6 +463,8 @@ describe("PreviewManager", () => {...@@ -459,6 +463,8 @@ describe("PreviewManager", () => {
459 isLoading: () => false,463 isLoading: () => false,
460 getZoomFactor: () => 1,464 getZoomFactor: () => 1,
461 setZoomFactor: vi.fn(),465 setZoomFactor: vi.fn(),
466 setAudioMuted: vi.fn(),
467 isCurrentlyAudible: () => false,
462 loadURL,468 loadURL,
463 on: vi.fn((event: string, listener: (...args: never[]) => void) => {469 on: vi.fn((event: string, listener: (...args: never[]) => void) => {
464 listeners.set(event, listener);470 listeners.set(event, listener);
...@@ -1002,6 +1008,8 @@ describe("PreviewManager", () => {...@@ -1002,6 +1008,8 @@ describe("PreviewManager", () => {
1002 return effectiveZoom;1008 return effectiveZoom;
1003 },1009 },
1004 setZoomFactor,1010 setZoomFactor,
1011 setAudioMuted: vi.fn(),
1012 isCurrentlyAudible: () => false,
1005 on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {1013 on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
1006 listeners.set(event, listener);1014 listeners.set(event, listener);
1007 }),1015 }),
...@@ -1066,6 +1074,8 @@ describe("PreviewManager", () => {...@@ -1066,6 +1074,8 @@ describe("PreviewManager", () => {
1066 isLoading: () => false,1074 isLoading: () => false,
1067 getZoomFactor: () => 1,1075 getZoomFactor: () => 1,
1068 setZoomFactor: replacementSetZoomFactor,1076 setZoomFactor: replacementSetZoomFactor,
1077 setAudioMuted: vi.fn(),
1078 isCurrentlyAudible: () => false,
1069 on: vi.fn(),1079 on: vi.fn(),
1070 off: vi.fn(),1080 off: vi.fn(),
1071 ipc: { on: vi.fn(), off: vi.fn() },1081 ipc: { on: vi.fn(), off: vi.fn() },
...@@ -1104,6 +1114,8 @@ describe("PreviewManager", () => {...@@ -1104,6 +1114,8 @@ describe("PreviewManager", () => {
1104 isLoading: () => false,1114 isLoading: () => false,
1105 getZoomFactor: () => 1,1115 getZoomFactor: () => 1,
1106 setZoomFactor,1116 setZoomFactor,
1117 setAudioMuted: vi.fn(),
1118 isCurrentlyAudible: () => false,
1107 on: vi.fn(),1119 on: vi.fn(),
1108 off: vi.fn(),1120 off: vi.fn(),
1109 ipc: { on: vi.fn(), off: vi.fn() },1121 ipc: { on: vi.fn(), off: vi.fn() },
...@@ -1148,6 +1160,8 @@ describe("PreviewManager", () => {...@@ -1148,6 +1160,8 @@ describe("PreviewManager", () => {
1148 isLoading: () => false,1160 isLoading: () => false,
1149 getZoomFactor: () => 1,1161 getZoomFactor: () => 1,
1150 setZoomFactor,1162 setZoomFactor,
1163 setAudioMuted: vi.fn(),
1164 isCurrentlyAudible: () => false,
1151 on: vi.fn(),1165 on: vi.fn(),
1152 off: vi.fn(),1166 off: vi.fn(),
1153 ipc: { on: vi.fn(), off: vi.fn() },1167 ipc: { on: vi.fn(), off: vi.fn() },
...@@ -1201,6 +1215,8 @@ describe("PreviewManager", () => {...@@ -1201,6 +1215,8 @@ describe("PreviewManager", () => {
1201 isLoading: () => false,1215 isLoading: () => false,
1202 getZoomFactor: () => 1,1216 getZoomFactor: () => 1,
1203 setZoomFactor: vi.fn(),1217 setZoomFactor: vi.fn(),
1218 setAudioMuted: vi.fn(),
1219 isCurrentlyAudible: () => false,
1204 on: vi.fn(),1220 on: vi.fn(),
1205 off: vi.fn(),1221 off: vi.fn(),
1206 ipc: { on: vi.fn(), off: vi.fn() },1222 ipc: { on: vi.fn(), off: vi.fn() },
...@@ -1257,6 +1273,287 @@ describe("PreviewManager", () => {...@@ -1257,6 +1273,287 @@ describe("PreviewManager", () => {
1257 ),1273 ),
1258 );1274 );
12591275
1276 const makeAudioWebContents = (id: number) => {
1277 const listeners = new Map<string, (...args: never[]) => void>();
1278 const setAudioMuted = vi.fn();
1279 let audible = false;
1280 let audibleAfterFirstRead = false;
1281 let audibleReads = 0;
1282 return {
1283 setAudioMuted,
1284 emitAudioState: (next: boolean) => {
1285 audible = next;
1286 listeners.get("audio-state-changed")?.({ audible: next } as never);
1287 },
1288 /**
1289 * Starts playing between the attach-time read and the post-attach
1290 * reconcile, without a delivered event — the window in which
1291 * audio-state-changed fires against a guest the tab does not own yet.
1292 */
1293 startPlayingAfterFirstRead: () => {
1294 audibleAfterFirstRead = true;
1295 },
1296 wc: {
1297 id,
1298 isDestroyed: () => false,
1299 isDevToolsOpened: () => false,
1300 getType: () => "webview",
1301 getURL: () => "https://example.com",
1302 getTitle: () => "Example",
1303 isLoading: () => false,
1304 getZoomFactor: () => 1,
1305 setZoomFactor: vi.fn(),
1306 setAudioMuted,
1307 isCurrentlyAudible: () => {
1308 audibleReads += 1;
1309 if (audibleAfterFirstRead && audibleReads > 1) return true;
1310 return audible;
1311 },
1312 loadURL: vi.fn(async () => undefined),
1313 on: vi.fn((event: string, listener: (...args: never[]) => void) => {
1314 listeners.set(event, listener);
1315 }),
1316 off: vi.fn((event: string) => {
1317 listeners.delete(event);
1318 }),
1319 ipc: { on: vi.fn(), off: vi.fn() },
1320 send: webviewSend,
1321 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
1322 setWindowOpenHandler: vi.fn(),
1323 debugger: {
1324 isAttached: () => false,
1325 attach: vi.fn(),
1326 sendCommand: vi.fn(async () => undefined),
1327 on: vi.fn(),
1328 off: vi.fn(),
1329 },
1330 } as never,
1331 };
1332 };
1333
1334 effectIt.effect("mutes the guest and re-applies the mute across webview swaps", () =>
1335 withManager((manager) =>
1336 Effect.gen(function* () {
1337 const first = makeAudioWebContents(42);
1338 fromId.mockReturnValue(first.wc);
1339 const states: PreviewManager.PreviewTabState[] = [];
1340
1341 yield* manager.subscribeStateChanges((_tabId, state) =>
1342 Effect.sync(() => {
1343 states.push(state);
1344 }),
1345 );
1346 yield* manager.createTab("tab_audio");
1347 yield* manager.registerWebview("tab_audio", 42);
1348 yield* Effect.yieldNow;
1349
1350 expect(states.at(-1)?.audioMuted).toBe(false);
1351
1352 yield* manager.setAudioMuted("tab_audio", true);
1353
1354 expect(first.setAudioMuted).toHaveBeenCalledWith(true);
1355 expect(states.at(-1)?.audioMuted).toBe(true);
1356
1357 const replacement = makeAudioWebContents(43);
1358 fromId.mockReturnValue(replacement.wc);
1359 yield* manager.registerWebview("tab_audio", 43);
1360 yield* Effect.yieldNow;
1361
1362 expect(replacement.setAudioMuted).toHaveBeenCalledWith(true);
1363 expect(states.at(-1)?.audioMuted).toBe(true);
1364
1365 yield* manager.setAudioMuted("tab_audio", false);
1366
1367 expect(replacement.setAudioMuted).toHaveBeenLastCalledWith(false);
1368 expect(states.at(-1)?.audioMuted).toBe(false);
1369 }),
1370 ),
1371 );
1372
1373 effectIt.effect("fails and rolls back when the guest refuses a mute", () =>
1374 withManager((manager) =>
1375 Effect.gen(function* () {
1376 const guest = makeAudioWebContents(42);
1377 fromId.mockReturnValue(guest.wc);
1378 const states: PreviewManager.PreviewTabState[] = [];
1379
1380 yield* manager.subscribeStateChanges((_tabId, state) =>
1381 Effect.sync(() => {
1382 states.push(state);
1383 }),
1384 );
1385 yield* manager.createTab("tab_audio_fail");
1386 yield* manager.registerWebview("tab_audio_fail", 42);
1387 yield* Effect.yieldNow;
1388
1389 guest.setAudioMuted.mockImplementationOnce(() => {
1390 throw new Error("guest refused");
1391 });
1392 const exit = yield* manager.setAudioMuted("tab_audio_fail", true).pipe(Effect.exit);
1393
1394 // Reporting success would draw the tab as muted while it keeps playing.
1395 expect(Exit.isFailure(exit)).toBe(true);
1396 expect(states.at(-1)?.audioMuted).toBe(false);
1397 }),
1398 ),
1399 );
1400
1401 effectIt.effect("still registers a guest that refuses the mute reassert", () =>
1402 withManager((manager) =>
1403 Effect.gen(function* () {
1404 const first = makeAudioWebContents(42);
1405 fromId.mockReturnValue(first.wc);
1406 yield* manager.createTab("tab_audio_attach_fail");
1407 yield* manager.registerWebview("tab_audio_attach_fail", 42);
1408 yield* Effect.yieldNow;
1409 yield* manager.setAudioMuted("tab_audio_attach_fail", true);
1410
1411 const replacement = makeAudioWebContents(43);
1412 // Fails the post-attach settle, not the pre-publish apply.
1413 replacement.setAudioMuted.mockImplementationOnce(() => undefined);
1414 replacement.setAudioMuted.mockImplementationOnce(() => {
1415 throw new Error("guest went away");
1416 });
1417 fromId.mockReturnValue(replacement.wc);
1418
1419 // Reconciliation is best-effort: a guest dying mid-attach must not fail
1420 // the registration it was attaching for.
1421 const exit = yield* manager.registerWebview("tab_audio_attach_fail", 43).pipe(Effect.exit);
1422 expect(Exit.isSuccess(exit)).toBe(true);
1423 }),
1424 ),
1425 );
1426
1427 effectIt.effect("reconciles audibility that changed while the guest attached", () =>
1428 withManager((manager) =>
1429 Effect.gen(function* () {
1430 const guest = makeAudioWebContents(42);
1431 guest.startPlayingAfterFirstRead();
1432 fromId.mockReturnValue(guest.wc);
1433 const states: PreviewManager.PreviewTabState[] = [];
1434
1435 yield* manager.subscribeStateChanges((_tabId, state) =>
1436 Effect.sync(() => {
1437 states.push(state);
1438 }),
1439 );
1440 yield* manager.createTab("tab_audio_window");
1441 yield* manager.registerWebview("tab_audio_window", 42);
1442 yield* Effect.yieldNow;
1443
1444 // audio-state-changed for this transition was dropped: it fired before
1445 // the tab owned the guest. Without a post-attach reconcile the icon
1446 // stays wrong until the next real transition, which may never come.
1447 expect(states.at(-1)?.audible).toBe(true);
1448 }),
1449 ),
1450 );
1451
1452 effectIt.effect("publishes audibility transitions and drops repeats", () =>
1453 withManager((manager) =>
1454 Effect.gen(function* () {
1455 const guest = makeAudioWebContents(42);
1456 fromId.mockReturnValue(guest.wc);
1457 const states: PreviewManager.PreviewTabState[] = [];
1458
1459 yield* manager.subscribeStateChanges((_tabId, state) =>
1460 Effect.sync(() => {
1461 states.push(state);
1462 }),
1463 );
1464 yield* manager.createTab("tab_audible");
1465 yield* manager.registerWebview("tab_audible", 42);
1466 yield* Effect.yieldNow;
1467
1468 expect(states.at(-1)?.audible).toBe(false);
1469
1470 guest.emitAudioState(true);
1471 yield* Effect.yieldNow;
1472 expect(states.at(-1)?.audible).toBe(true);
1473
1474 // Chromium re-emits per media element; only real transitions publish.
1475 const publishedAfterFirst = states.length;
1476 guest.emitAudioState(true);
1477 yield* Effect.yieldNow;
1478 expect(states.length).toBe(publishedAfterFirst);
1479
1480 guest.emitAudioState(false);
1481 yield* Effect.yieldNow;
1482 expect(states.at(-1)?.audible).toBe(false);
1483 expect(states.length).toBeGreaterThan(publishedAfterFirst);
1484 }),
1485 ),
1486 );
1487
1488 effectIt.effect("ignores audio state from a replaced guest", () =>
1489 withManager((manager) =>
1490 Effect.gen(function* () {
1491 const first = makeAudioWebContents(42);
1492 fromId.mockReturnValue(first.wc);
1493 const states: PreviewManager.PreviewTabState[] = [];
1494
1495 yield* manager.subscribeStateChanges((_tabId, state) =>
1496 Effect.sync(() => {
1497 states.push(state);
1498 }),
1499 );
1500 yield* manager.createTab("tab_audio_stale");
1501 yield* manager.registerWebview("tab_audio_stale", 42);
1502 yield* Effect.yieldNow;
1503
1504 const replacement = makeAudioWebContents(43);
1505 fromId.mockReturnValue(replacement.wc);
1506 yield* manager.registerWebview("tab_audio_stale", 43);
1507 yield* Effect.yieldNow;
1508
1509 const publishedBefore = states.length;
1510 first.emitAudioState(true);
1511 yield* Effect.yieldNow;
1512
1513 expect(states.length).toBe(publishedBefore);
1514 expect(states.at(-1)?.audible).toBe(false);
1515 }),
1516 ),
1517 );
1518
1519 effectIt.effect("carries mute and audibility across navigation", () =>
1520 withManager((manager) =>
1521 Effect.gen(function* () {
1522 const guest = makeAudioWebContents(42);
1523 fromId.mockReturnValue(guest.wc);
1524 const states: PreviewManager.PreviewTabState[] = [];
1525
1526 yield* manager.subscribeStateChanges((_tabId, state) =>
1527 Effect.sync(() => {
1528 states.push(state);
1529 }),
1530 );
1531 yield* manager.createTab("tab_audio_nav");
1532 yield* manager.registerWebview("tab_audio_nav", 42);
1533 yield* Effect.yieldNow;
1534
1535 yield* manager.setAudioMuted("tab_audio_nav", true);
1536 guest.emitAudioState(true);
1537 yield* Effect.yieldNow;
1538 expect(states.at(-1)?.audible).toBe(true);
1539
1540 yield* manager.navigate("tab_audio_nav", "https://example.com/next");
1541 yield* Effect.yieldNow;
1542
1543 // navigate runs before loadURL swaps the document, so the old page can
1544 // still be playing. Dropping audibility here would lose the speaker
1545 // with no transition left to bring it back.
1546 expect(states.at(-1)?.audioMuted).toBe(true);
1547 expect(states.at(-1)?.audible).toBe(true);
1548
1549 // Chromium reports the real stop once the new document takes over.
1550 guest.emitAudioState(false);
1551 yield* Effect.yieldNow;
1552 expect(states.at(-1)?.audible).toBe(false);
1553 }),
1554 ),
1555 );
1556
1260 effectIt.effect("blocks late webview and capture starts during tab close", () =>1557 effectIt.effect("blocks late webview and capture starts during tab close", () =>
1261 withManager((manager) =>1558 withManager((manager) =>
1262 Effect.gen(function* () {1559 Effect.gen(function* () {
...@@ -1346,6 +1643,8 @@ describe("PreviewManager", () => {...@@ -1346,6 +1643,8 @@ describe("PreviewManager", () => {
1346 isLoading: () => loading,1643 isLoading: () => loading,
1347 getZoomFactor: () => 1,1644 getZoomFactor: () => 1,
1348 setZoomFactor: vi.fn(),1645 setZoomFactor: vi.fn(),
1646 setAudioMuted: vi.fn(),
1647 isCurrentlyAudible: () => false,
1349 on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {1648 on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
1350 listeners.set(event, listener);1649 listeners.set(event, listener);
1351 }),1650 }),
...@@ -1436,6 +1735,8 @@ describe("PreviewManager", () => {...@@ -1436,6 +1735,8 @@ describe("PreviewManager", () => {
1436 isLoading: () => false,1735 isLoading: () => false,
1437 getZoomFactor: () => 1,1736 getZoomFactor: () => 1,
1438 setZoomFactor: vi.fn(),1737 setZoomFactor: vi.fn(),
1738 setAudioMuted: vi.fn(),
1739 isCurrentlyAudible: () => false,
1439 on: vi.fn((event: string, listener: (...args: never[]) => void) => {1740 on: vi.fn((event: string, listener: (...args: never[]) => void) => {
1440 listeners.set(event, listener);1741 listeners.set(event, listener);
1441 }),1742 }),
...@@ -1525,6 +1826,8 @@ describe("PreviewManager", () => {...@@ -1525,6 +1826,8 @@ describe("PreviewManager", () => {
1525 isLoading: () => false,1826 isLoading: () => false,
1526 getZoomFactor: () => 1,1827 getZoomFactor: () => 1,
1527 setZoomFactor: vi.fn(),1828 setZoomFactor: vi.fn(),
1829 setAudioMuted: vi.fn(),
1830 isCurrentlyAudible: () => false,
1528 on: vi.fn(),1831 on: vi.fn(),
1529 off: vi.fn(),1832 off: vi.fn(),
1530 ipc: { on: vi.fn(), off: vi.fn() },1833 ipc: { on: vi.fn(), off: vi.fn() },
...@@ -1735,6 +2038,8 @@ describe("PreviewManager", () => {...@@ -1735,6 +2038,8 @@ describe("PreviewManager", () => {
1735 isDevToolsOpened: () => false,2038 isDevToolsOpened: () => false,
1736 getZoomFactor: () => 1,2039 getZoomFactor: () => 1,
1737 setZoomFactor: vi.fn(),2040 setZoomFactor: vi.fn(),
2041 setAudioMuted: vi.fn(),
2042 isCurrentlyAudible: () => false,
1738 on: vi.fn(),2043 on: vi.fn(),
1739 off: vi.fn(),2044 off: vi.fn(),
1740 ipc: { on: vi.fn(), off: vi.fn() },2045 ipc: { on: vi.fn(), off: vi.fn() },
...@@ -1815,6 +2120,8 @@ describe("PreviewManager", () => {...@@ -1815,6 +2120,8 @@ describe("PreviewManager", () => {
1815 isLoading: () => false,2120 isLoading: () => false,
1816 getZoomFactor: () => 1,2121 getZoomFactor: () => 1,
1817 setZoomFactor: vi.fn(),2122 setZoomFactor: vi.fn(),
2123 setAudioMuted: vi.fn(),
2124 isCurrentlyAudible: () => false,
1818 on: vi.fn(),2125 on: vi.fn(),
1819 off: vi.fn(),2126 off: vi.fn(),
1820 ipc: { on: vi.fn(), off: vi.fn() },2127 ipc: { on: vi.fn(), off: vi.fn() },
...@@ -2204,6 +2511,8 @@ describe("PreviewManager", () => {...@@ -2204,6 +2511,8 @@ describe("PreviewManager", () => {
2204 isFocused: () => true,2511 isFocused: () => true,
2205 getZoomFactor: () => 1,2512 getZoomFactor: () => 1,
2206 setZoomFactor: vi.fn(),2513 setZoomFactor: vi.fn(),
2514 setAudioMuted: vi.fn(),
2515 isCurrentlyAudible: () => false,
2207 on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {2516 on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
2208 listeners.set(event, listener);2517 listeners.set(event, listener);
2209 }),2518 }),
...@@ -2255,6 +2564,8 @@ describe("PreviewManager", () => {...@@ -2255,6 +2564,8 @@ describe("PreviewManager", () => {
2255 isLoading: () => false,2564 isLoading: () => false,
2256 getZoomFactor: () => 1,2565 getZoomFactor: () => 1,
2257 setZoomFactor: vi.fn(),2566 setZoomFactor: vi.fn(),
2567 setAudioMuted: vi.fn(),
2568 isCurrentlyAudible: () => false,
2258 on: vi.fn(),2569 on: vi.fn(),
2259 off: vi.fn(),2570 off: vi.fn(),
2260 ipc: {2571 ipc: {
...@@ -2387,6 +2698,8 @@ describe("PreviewManager", () => {...@@ -2387,6 +2698,8 @@ describe("PreviewManager", () => {
2387 isDevToolsOpened: () => false,2698 isDevToolsOpened: () => false,
2388 getZoomFactor: () => 1,2699 getZoomFactor: () => 1,
2389 setZoomFactor: vi.fn(),2700 setZoomFactor: vi.fn(),
2701 setAudioMuted: vi.fn(),
2702 isCurrentlyAudible: () => false,
2390 on: vi.fn(),2703 on: vi.fn(),
2391 off: vi.fn(),2704 off: vi.fn(),
2392 ipc: {2705 ipc: {
...@@ -2485,6 +2798,8 @@ describe("PreviewManager", () => {...@@ -2485,6 +2798,8 @@ describe("PreviewManager", () => {
2485 focus,2798 focus,
2486 getZoomFactor: () => 1,2799 getZoomFactor: () => 1,
2487 setZoomFactor: vi.fn(),2800 setZoomFactor: vi.fn(),
2801 setAudioMuted: vi.fn(),
2802 isCurrentlyAudible: () => false,
2488 on: vi.fn(),2803 on: vi.fn(),
2489 off: vi.fn(),2804 off: vi.fn(),
2490 ipc: {2805 ipc: {
...@@ -2638,6 +2953,8 @@ describe("PreviewManager", () => {...@@ -2638,6 +2953,8 @@ describe("PreviewManager", () => {
2638 isDevToolsOpened: () => false,2953 isDevToolsOpened: () => false,
2639 getZoomFactor: () => 1,2954 getZoomFactor: () => 1,
2640 setZoomFactor: vi.fn(),2955 setZoomFactor: vi.fn(),
2956 setAudioMuted: vi.fn(),
2957 isCurrentlyAudible: () => false,
2641 on: vi.fn(),2958 on: vi.fn(),
2642 off: vi.fn(),2959 off: vi.fn(),
2643 ipc: {2960 ipc: {
...@@ -2705,6 +3022,8 @@ describe("PreviewManager", () => {...@@ -2705,6 +3022,8 @@ describe("PreviewManager", () => {
2705 isDevToolsOpened: () => false,3022 isDevToolsOpened: () => false,
2706 getZoomFactor: () => 1,3023 getZoomFactor: () => 1,
2707 setZoomFactor: vi.fn(),3024 setZoomFactor: vi.fn(),
3025 setAudioMuted: vi.fn(),
3026 isCurrentlyAudible: () => false,
2708 on: vi.fn(),3027 on: vi.fn(),
2709 off: vi.fn(),3028 off: vi.fn(),
2710 ipc: { on: vi.fn(), off: vi.fn() },3029 ipc: { on: vi.fn(), off: vi.fn() },
apps/desktop/src/preview/Manager.ts+149-5
...@@ -88,6 +88,10 @@ export interface PreviewTabState {...@@ -88,6 +88,10 @@ export interface PreviewTabState {
88 zoomFactor: number;88 zoomFactor: number;
89 pictureInPicture: boolean;89 pictureInPicture: boolean;
90 colorScheme: DesktopPreviewColorScheme;90 colorScheme: DesktopPreviewColorScheme;
91 /** User intent to silence this tab. Re-applied to each guest that attaches. */
92 audioMuted: boolean;
93 /** Observed from Chromium. Stays true while a muted tab keeps playing. */
94 audible: boolean;
91 controller: "human" | "agent" | "none";95 controller: "human" | "agent" | "none";
92 favicon?: DesktopPreviewFavicon;96 favicon?: DesktopPreviewFavicon;
93 updatedAt: string;97 updatedAt: string;
...@@ -661,7 +665,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -661,7 +665,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
661 }),665 }),
662 ] as const;666 ] as const;
663 });667 });
664 if (Option.isSome(next)) yield* emit(tabId, next.value);668 // emitIfCurrent, not emit: an event-driven writer such as syncTabAudible
669 // can commit between the modify above and here, and republishing this
670 // snapshot would roll the UI back to a value that writer will not send
671 // again because it suppresses unchanged audibility.
672 if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value);
665 });673 });
666674
667 /**675 /**
...@@ -680,6 +688,62 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -680,6 +688,62 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
680 ).pipe(Effect.ignore);688 ).pipe(Effect.ignore);
681 });689 });
682690
691 /**
692 * Mute counterpart to {@link assertTabZoom}: pushes the tab's committed mute
693 * onto whichever guest it currently owns, reading both at call time so an
694 * older snapshot can never roll back a mute action that landed after it.
695 *
696 * Failures propagate so the user-facing setter can roll its commit back.
697 * Reconciliation callers, where a guest going away mid-attach is expected,
698 * discard the error at their own call site.
699 */
700 const assertTabAudioMuted = Effect.fn("PreviewManager.assertTabAudioMuted")(function* (
701 tabId: string,
702 ) {
703 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
704 if (!tab || tab.webContentsId == null) return;
705 const wc = webContents.fromId(tab.webContentsId);
706 if (!wc || wc.isDestroyed()) return;
707 yield* attempt({ operation: "assertTabAudioMuted", tabId, webContentsId: wc.id }, () =>
708 wc.setAudioMuted(tab.audioMuted),
709 );
710 });
711
712 /**
713 * Publishes an observed audibility value for the guest that reported it.
714 * Shared by the `audio-state-changed` handler and the post-attach reconcile
715 * so both drop values from a guest the tab no longer owns, and both skip
716 * unchanged values: Chromium re-emits per media element, and republishing
717 * would cost an IPC push per element rather than per real transition.
718 */
719 const syncTabAudible = Effect.fn("PreviewManager.syncTabAudible")(function* (
720 tabId: string,
721 wc: Electron.WebContents,
722 audible: boolean,
723 ) {
724 if (wc.isDestroyed()) return;
725 const updatedAt = yield* currentIso;
726 const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
727 const current = tabs.get(tabId);
728 if (
729 !current ||
730 current.webContentsId !== wc.id ||
731 webContents.fromId(wc.id) !== wc ||
732 current.audible === audible
733 ) {
734 return [Option.none<PreviewTabState>(), tabs] as const;
735 }
736 const state: PreviewTabState = { ...current, audible, updatedAt };
737 return [
738 Option.some(state),
739 replaceMap(tabs, (copy) => {
740 copy.set(tabId, state);
741 }),
742 ] as const;
743 });
744 if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value);
745 });
746
683 const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* (747 const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* (
684 tabId: string,748 tabId: string,
685 ) {749 ) {
...@@ -1389,6 +1453,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1389,6 +1453,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1389 ) => {1453 ) => {
1390 if (event.isMainFrame && !event.isSameDocument) cancelFaviconCapture();1454 if (event.isMainFrame && !event.isSameDocument) cancelFaviconCapture();
1391 };1455 };
1456 const audioStateChanged = (
1457 event: Electron.Event<Electron.WebContentsAudioStateChangedEventParams>,
1458 ) => runFork(syncTabAudible(tabId, wc, event.audible));
1392 const publishFavicon = Effect.fn("PreviewManager.publishFavicon")(function* (input: {1459 const publishFavicon = Effect.fn("PreviewManager.publishFavicon")(function* (input: {
1393 readonly captureDocumentId: number;1460 readonly captureDocumentId: number;
1394 readonly dataUrl: string;1461 readonly dataUrl: string;
...@@ -1583,6 +1650,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1583,6 +1650,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1583 wc.off("did-start-loading", sync);1650 wc.off("did-start-loading", sync);
1584 wc.off("did-stop-loading", sync);1651 wc.off("did-stop-loading", sync);
1585 wc.off("did-fail-load", failed as never);1652 wc.off("did-fail-load", failed as never);
1653 wc.off("audio-state-changed", audioStateChanged);
1586 wc.off("before-input-event", beforeInput);1654 wc.off("before-input-event", beforeInput);
1587 wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput);1655 wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput);
1588 wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);1656 wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
...@@ -1598,6 +1666,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1598,6 +1666,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1598 wc.on("did-start-loading", sync);1666 wc.on("did-start-loading", sync);
1599 wc.on("did-stop-loading", sync);1667 wc.on("did-stop-loading", sync);
1600 wc.on("did-fail-load", failed as never);1668 wc.on("did-fail-load", failed as never);
1669 wc.on("audio-state-changed", audioStateChanged);
1601 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);1670 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
1602 wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);1671 wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
1603 wc.setWindowOpenHandler(({ url }) => {1672 wc.setWindowOpenHandler(({ url }) => {
...@@ -1652,6 +1721,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1652,6 +1721,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1652 zoomFactor: normalizeZoomFactor(defaults?.zoomFactor),1721 zoomFactor: normalizeZoomFactor(defaults?.zoomFactor),
1653 pictureInPicture: false,1722 pictureInPicture: false,
1654 colorScheme: defaults?.colorScheme ?? "system",1723 colorScheme: defaults?.colorScheme ?? "system",
1724 audioMuted: false,
1725 audible: false,
1655 controller: "none",1726 controller: "none",
1656 updatedAt,1727 updatedAt,
1657 };1728 };
...@@ -1718,6 +1789,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1718,6 +1789,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1718 zoomFactor: DEFAULT_ZOOM_FACTOR,1789 zoomFactor: DEFAULT_ZOOM_FACTOR,
1719 pictureInPicture: false,1790 pictureInPicture: false,
1720 colorScheme: "system",1791 colorScheme: "system",
1792 audioMuted: false,
1793 audible: false,
1721 controller: "none",1794 controller: "none",
1722 updatedAt,1795 updatedAt,
1723 };1796 };
...@@ -1808,7 +1881,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1808,7 +1881,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1808 yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () =>1881 yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () =>
1809 wc.setZoomFactor(currentTab.zoomFactor),1882 wc.setZoomFactor(currentTab.zoomFactor),
1810 );1883 );
1884 // A replacement guest attaches unmuted, so reassert the tab's mute before it
1885 // is published rather than letting it emit audio the user already silenced.
1886 // Settled again after attach, below, the same way zoom is.
1887 yield* attempt({ operation: "registerWebview.restoreAudioMuted", tabId, webContentsId }, () =>
1888 wc.setAudioMuted(currentTab.audioMuted),
1889 );
1811 yield* attachListeners(tabId, wc);1890 yield* attachListeners(tabId, wc);
1891 const readAudible = attempt(
1892 { operation: "registerWebview.readAudible", tabId, webContentsId },
1893 () => wc.isCurrentlyAudible(),
1894 ).pipe(Effect.orElseSucceed(() => false));
1895 const attachedAudible = yield* readAudible;
1812 const registeredAt = yield* currentIso;1896 const registeredAt = yield* currentIso;
1813 const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) =>1897 const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) =>
1814 Effect.gen(function* () {1898 Effect.gen(function* () {
...@@ -1831,6 +1915,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1831,6 +1915,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1831 navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus,1915 navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus,
1832 canGoBack: wc.navigationHistory.canGoBack(),1916 canGoBack: wc.navigationHistory.canGoBack(),
1833 canGoForward: wc.navigationHistory.canGoForward(),1917 canGoForward: wc.navigationHistory.canGoForward(),
1918 audible: attachedAudible,
1834 updatedAt: registeredAt,1919 updatedAt: registeredAt,
1835 };1920 };
1836 return [1921 return [
...@@ -1852,11 +1937,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1852,11 +1937,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1852 return yield* new PreviewTabNotFoundError({ tabId });1937 return yield* new PreviewTabNotFoundError({ tabId });
1853 }1938 }
1854 const { state: registered, pendingUrl } = registration.value;1939 const { state: registered, pendingUrl } = registration.value;
1855 // A zoom action that landed while this attach was in flight addressed the1940 // A zoom or mute action that landed while this attach was in flight
1856 // guest this one replaced, so settle the new guest on the committed factor.1941 // addressed the guest this one replaced, so settle the new guest on the
1942 // committed values.
1857 yield* assertTabZoom(tabId);1943 yield* assertTabZoom(tabId);
1944 // Best-effort here, unlike in setAudioMuted: a guest that dies mid-attach
1945 // must not fail the registration it was attaching for.
1946 yield* assertTabAudioMuted(tabId).pipe(Effect.ignore);
1858 runFork(restoreControlSession(tabId, wc));1947 runFork(restoreControlSession(tabId, wc));
1859 yield* emit(tabId, registered);1948 // emitIfCurrent, not emit: audio-state-changed can land between the commit
1949 // above and here, and republishing this snapshot would roll the UI back to
1950 // a superseded audibility that syncTabAudible will not re-send.
1951 yield* emitIfCurrent(tabId, registered);
1952 // Transitions that fired before the tab owned this guest were dropped by
1953 // syncTabAudible's ownership check, so re-read and reconcile through the
1954 // same path the event uses.
1955 yield* syncTabAudible(tabId, wc, yield* readAudible);
1860 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>1956 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>
1861 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),1957 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
1862 );1958 );
...@@ -1906,6 +2002,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1906,6 +2002,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1906 zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR,2002 zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR,
1907 pictureInPicture: current?.pictureInPicture ?? false,2003 pictureInPicture: current?.pictureInPicture ?? false,
1908 colorScheme: current?.colorScheme ?? "system",2004 colorScheme: current?.colorScheme ?? "system",
2005 // Both carry across navigation. Mute is user intent, and the old
2006 // document keeps playing until loadURL actually replaces it, so
2007 // clearing audibility here would drop the speaker with no transition
2008 // left to restore it. Chromium reports the change when it happens.
2009 audioMuted: current?.audioMuted ?? false,
2010 audible: current?.audible ?? false,
1909 controller: current?.controller ?? "none",2011 controller: current?.controller ?? "none",
1910 ...(current?.favicon ? { favicon: current.favicon } : {}),2012 ...(current?.favicon ? { favicon: current.favicon } : {}),
1911 updatedAt,2013 updatedAt,
...@@ -1917,7 +2019,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1917,7 +2019,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1917 }),2019 }),
1918 ] as const;2020 ] as const;
1919 });2021 });
1920 yield* emit(tabId, pending);2022 // emitIfCurrent for the same reason as update: this snapshot carries
2023 // audibility forward, and an audio-state-changed landing in between would
2024 // otherwise be rolled back with no follow-up transition to correct it.
2025 yield* emitIfCurrent(tabId, pending);
1921 if (pending.webContentsId == null) return;2026 if (pending.webContentsId == null) return;
1922 const webContentsId = pending.webContentsId;2027 const webContentsId = pending.webContentsId;
1923 const wc = webContents.fromId(webContentsId);2028 const wc = webContents.fromId(webContentsId);
...@@ -2251,6 +2356,39 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2251,6 +2356,39 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2251 yield* applyColorScheme(tabId, wc, colorScheme);2356 yield* applyColorScheme(tabId, wc, colorScheme);
2252 });2357 });
22532358
2359 const setAudioMuted = Effect.fn("PreviewManager.setAudioMuted")(function* (
2360 tabId: string,
2361 audioMuted: boolean,
2362 ) {
2363 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
2364 if (!tab) {
2365 return yield* new PreviewTabNotFoundError({ tabId });
2366 }
2367 // Commit and apply under the tab's lifecycle lock, then assert the
2368 // committed value rather than this call's argument. Two overlapping toggles
2369 // would otherwise be free to commit in one order and reach Chromium in the
2370 // other, leaving the icon disagreeing with the guest.
2371 yield* withTabLifecycleLock(
2372 tabId,
2373 Effect.gen(function* () {
2374 // Record the intent even when no guest is attached yet — it is
2375 // re-applied by registerWebview when one arrives.
2376 const previous = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.audioMuted;
2377 const committed = previous !== undefined && previous !== audioMuted;
2378 if (committed) {
2379 yield* update(tabId, { audioMuted });
2380 }
2381 // Roll the commit back if Chromium refused: reporting success here
2382 // would leave the tab drawn as muted while it keeps playing.
2383 yield* assertTabAudioMuted(tabId).pipe(
2384 Effect.tapError(() =>
2385 committed ? update(tabId, { audioMuted: previous }) : Effect.void,
2386 ),
2387 );
2388 }),
2389 );
2390 });
2391
2254 const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* (2392 const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* (
2255 tabId: string,2393 tabId: string,
2256 ) {2394 ) {
...@@ -3543,6 +3681,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -3543,6 +3681,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
3543 revealArtifact,3681 revealArtifact,
3544 saveRecording,3682 saveRecording,
3545 setAnnotationTheme,3683 setAnnotationTheme,
3684 setAudioMuted,
3546 setColorScheme,3685 setColorScheme,
3547 setMainWindow,3686 setMainWindow,
3548 startRecording,3687 startRecording,
...@@ -3846,6 +3985,10 @@ export class PreviewManager extends Context.Service<...@@ -3846,6 +3985,10 @@ export class PreviewManager extends Context.Service<
3846 tabId: string,3985 tabId: string,
3847 colorScheme: DesktopPreviewColorScheme,3986 colorScheme: DesktopPreviewColorScheme,
3848 ) => Effect.Effect<void, PreviewManagerError>;3987 ) => Effect.Effect<void, PreviewManagerError>;
3988 readonly setAudioMuted: (
3989 tabId: string,
3990 audioMuted: boolean,
3991 ) => Effect.Effect<void, PreviewManagerError>;
3849 readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>;3992 readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
3850 readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>;3993 readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>;
3851 readonly clearCache: () => Effect.Effect<void, PreviewManagerError>;3994 readonly clearCache: () => Effect.Effect<void, PreviewManagerError>;
...@@ -3944,6 +4087,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -3944,6 +4087,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
3944 reapplyZoom: operations.reapplyZoom,4087 reapplyZoom: operations.reapplyZoom,
3945 hardReload: operations.hardReload,4088 hardReload: operations.hardReload,
3946 setColorScheme: operations.setColorScheme,4089 setColorScheme: operations.setColorScheme,
4090 setAudioMuted: operations.setAudioMuted,
3947 openDevTools: operations.openDevTools,4091 openDevTools: operations.openDevTools,
3948 clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () {4092 clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () {
3949 yield* browserSession4093 yield* browserSession
apps/web/src/components/ChatView.tsx+11
...@@ -136,6 +136,7 @@ import {...@@ -136,6 +136,7 @@ import {
136 setActivePreviewTab,136 setActivePreviewTab,
137 useThreadPreviewState,137 useThreadPreviewState,
138} from "../previewStateStore";138} from "../previewStateStore";
139import { previewRuntimeTabId } from "../browser/previewRuntimeTabId";
139import { addBrowserSurface } from "./preview/addBrowserSurface";140import { addBrowserSurface } from "./preview/addBrowserSurface";
140import { closePreviewSession } from "./preview/closePreviewSession";141import { closePreviewSession } from "./preview/closePreviewSession";
141import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";142import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
...@@ -1636,6 +1637,14 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1636,6 +1637,14 @@ function ChatViewContent(props: ChatViewProps) {
1636 const activeFileSurface =1637 const activeFileSurface =
1637 activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null;1638 activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null;
1638 const activePreviewState = useThreadPreviewState(activeThreadRef);1639 const activePreviewState = useThreadPreviewState(activeThreadRef);
1640 const activePreviewServerEpoch = activePreviewState.serverEpoch;
1641 const resolvePreviewRuntimeTabId = useMemo(
1642 () =>
1643 activeThreadRef
1644 ? (tabId: string) => previewRuntimeTabId(activeThreadRef, activePreviewServerEpoch, tabId)
1645 : undefined,
1646 [activeThreadRef, activePreviewServerEpoch],
1647 );
1639 const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) =>1648 const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) =>
1640 selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef),1649 selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef),
1641 );1650 );
...@@ -6627,6 +6636,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6627,6 +6636,7 @@ function ChatViewContent(props: ChatViewProps) {
6627 pendingSurfaceIds={pendingFileSurfaceIds}6636 pendingSurfaceIds={pendingFileSurfaceIds}
6628 previewSessions={activePreviewState.sessions}6637 previewSessions={activePreviewState.sessions}
6629 desktopByTabId={activePreviewState.desktopByTabId}6638 desktopByTabId={activePreviewState.desktopByTabId}
6639 previewRuntimeTabId={resolvePreviewRuntimeTabId}
6630 terminalLabelsById={activeTerminalLabelsById}6640 terminalLabelsById={activeTerminalLabelsById}
6631 onActivate={activateRightPanelSurface}6641 onActivate={activateRightPanelSurface}
6632 onCloseSurface={closeRightPanelSurface}6642 onCloseSurface={closeRightPanelSurface}
...@@ -6666,6 +6676,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6666,6 +6676,7 @@ function ChatViewContent(props: ChatViewProps) {
6666 pendingSurfaceIds={pendingFileSurfaceIds}6676 pendingSurfaceIds={pendingFileSurfaceIds}
6667 previewSessions={activePreviewState.sessions}6677 previewSessions={activePreviewState.sessions}
6668 desktopByTabId={activePreviewState.desktopByTabId}6678 desktopByTabId={activePreviewState.desktopByTabId}
6679 previewRuntimeTabId={resolvePreviewRuntimeTabId}
6669 terminalLabelsById={activeTerminalLabelsById}6680 terminalLabelsById={activeTerminalLabelsById}
6670 onActivate={activateRightPanelSurface}6681 onActivate={activateRightPanelSurface}
6671 onCloseSurface={closeRightPanelSurface}6682 onCloseSurface={closeRightPanelSurface}
apps/web/src/components/RightPanelTabs.test.tsx+85-4
...@@ -2,7 +2,7 @@ import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/con...@@ -2,7 +2,7 @@ import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/con
2import { renderToStaticMarkup } from "react-dom/server";2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
44
5import { RightPanelTabs } from "./RightPanelTabs";5import { RightPanelTabs, tabMuteMenuItem } from "./RightPanelTabs";
66
7const previewSurface = {7const previewSurface = {
8 id: "browser:tab-1" as const,8 id: "browser:tab-1" as const,
...@@ -39,7 +39,10 @@ const favicon = (dataUrl: string, pageUrl: string): DesktopPreviewFavicon => ({...@@ -39,7 +39,10 @@ const favicon = (dataUrl: string, pageUrl: string): DesktopPreviewFavicon => ({
39 capturedAt: 1,39 capturedAt: 1,
40});40});
4141
42function overlay(icon: DesktopPreviewFavicon | null) {42function overlay(
43 icon: DesktopPreviewFavicon | null,
44 audio?: { audible?: boolean; audioMuted?: boolean },
45) {
43 return {46 return {
44 hasWebContents: true,47 hasWebContents: true,
45 canGoBack: false,48 canGoBack: false,
...@@ -48,12 +51,19 @@ function overlay(icon: DesktopPreviewFavicon | null) {...@@ -48,12 +51,19 @@ function overlay(icon: DesktopPreviewFavicon | null) {
48 zoomFactor: 1,51 zoomFactor: 1,
49 pictureInPicture: false,52 pictureInPicture: false,
50 colorScheme: "system" as const,53 colorScheme: "system" as const,
54 audioMuted: audio?.audioMuted ?? false,
55 audible: audio?.audible ?? false,
51 controller: "none" as const,56 controller: "none" as const,
52 favicon: icon,57 favicon: icon,
53 };58 };
54}59}
5560
56function renderTabs(first: DesktopPreviewFavicon | null, second?: DesktopPreviewFavicon) {61function renderTabs(
62 first: DesktopPreviewFavicon | null,
63 second?: DesktopPreviewFavicon,
64 audio?: { audible?: boolean; audioMuted?: boolean },
65 previewRuntimeTabId: ((tabId: string) => string) | null = (tabId) => `runtime:${tabId}`,
66) {
57 return renderToStaticMarkup(67 return renderToStaticMarkup(
58 <RightPanelTabs68 <RightPanelTabs
59 mode="inline"69 mode="inline"
...@@ -62,9 +72,10 @@ function renderTabs(first: DesktopPreviewFavicon | null, second?: DesktopPreview...@@ -62,9 +72,10 @@ function renderTabs(first: DesktopPreviewFavicon | null, second?: DesktopPreview
62 pendingSurfaceIds={new Set()}72 pendingSurfaceIds={new Set()}
63 previewSessions={sessions}73 previewSessions={sessions}
64 desktopByTabId={{74 desktopByTabId={{
65 "tab-1": overlay(first),75 "tab-1": overlay(first, audio),
66 ...(second ? { "tab-2": overlay(second) } : {}),76 ...(second ? { "tab-2": overlay(second) } : {}),
67 }}77 }}
78 {...(previewRuntimeTabId ? { previewRuntimeTabId } : {})}
68 terminalLabelsById={new Map()}79 terminalLabelsById={new Map()}
69 onActivate={() => undefined}80 onActivate={() => undefined}
70 onCloseSurface={() => undefined}81 onCloseSurface={() => undefined}
...@@ -113,3 +124,73 @@ describe("RightPanelTabs preview favicon", () => {...@@ -113,3 +124,73 @@ describe("RightPanelTabs preview favicon", () => {
113 expect(html).not.toContain("data:image/png;base64,AAAA");124 expect(html).not.toContain("data:image/png;base64,AAAA");
114 });125 });
115});126});
127
128describe("RightPanelTabs audio indicator", () => {
129 // A muted tab only shows the indicator while it is actually making sound:
130 // arming mute on a quiet tab is deliberate and stays invisible until there
131 // is something to suppress.
132 const cases = [
133 { audible: false, audioMuted: false, label: null },
134 { audible: false, audioMuted: true, label: null },
135 { audible: true, audioMuted: false, label: "Mute Local site" },
136 { audible: true, audioMuted: true, label: "Unmute Local site" },
137 ] as const;
138
139 it.each(cases)("audible=$audible muted=$audioMuted", ({ audible, audioMuted, label }) => {
140 const html = renderTabs(null, undefined, { audible, audioMuted });
141 if (label === null) {
142 expect(html).not.toContain("Mute Local site");
143 expect(html).not.toContain("Unmute Local site");
144 } else {
145 expect(html).toContain(`aria-label="${label}"`);
146 }
147 });
148
149 it("addresses the desktop by runtime tab id, never the server session id", () => {
150 // Session ids are only unique per server process; sending one to the
151 // Electron manager raises PreviewTabNotFoundError and silently no-ops.
152 const seen: string[] = [];
153 renderTabs(null, undefined, { audible: true }, (tabId) => {
154 seen.push(tabId);
155 return `runtime:${tabId}`;
156 });
157 expect(seen).toContain("tab-1");
158 });
159
160 it("hides the toggle when no runtime tab id can be resolved", () => {
161 const html = renderTabs(null, undefined, { audible: true }, null);
162 expect(html).not.toContain("Mute Local site");
163 });
164});
165
166describe("tabMuteMenuItem", () => {
167 const overlay = (audioMuted: boolean) =>
168 ({ audioMuted, audible: false }) as Parameters<typeof tabMuteMenuItem>[0]["overlay"];
169
170 it("stays disabled until the desktop tab exists", () => {
171 // The server session id resolves before the preview manager finishes
172 // createTab. Muting in that window fails with an error nobody surfaces.
173 expect(tabMuteMenuItem({ overlay: null, canResolveRuntimeTabId: true })).toEqual({
174 label: "Mute tab",
175 disabled: true,
176 });
177 });
178
179 it("stays disabled when no runtime tab id can be resolved", () => {
180 expect(tabMuteMenuItem({ overlay: overlay(false), canResolveRuntimeTabId: false })).toEqual({
181 label: "Mute tab",
182 disabled: true,
183 });
184 });
185
186 it("offers mute and unmute once the tab is addressable", () => {
187 expect(tabMuteMenuItem({ overlay: overlay(false), canResolveRuntimeTabId: true })).toEqual({
188 label: "Mute tab",
189 disabled: false,
190 });
191 expect(tabMuteMenuItem({ overlay: overlay(true), canResolveRuntimeTabId: true })).toEqual({
192 label: "Unmute tab",
193 disabled: false,
194 });
195 });
196});
apps/web/src/components/RightPanelTabs.tsx+124-1
...@@ -8,6 +8,8 @@ import {...@@ -8,6 +8,8 @@ import {
8 Globe2,8 Globe2,
9 Plus,9 Plus,
10 TerminalSquare,10 TerminalSquare,
11 Volume2,
12 VolumeOff,
11 X,13 X,
12} from "lucide-react";14} from "lucide-react";
13import {15import {
...@@ -37,6 +39,7 @@ import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";...@@ -37,6 +39,7 @@ import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
3739
38import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell";40import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell";
39import { FaviconImage } from "./preview/PreviewFaviconIcon";41import { FaviconImage } from "./preview/PreviewFaviconIcon";
42import { previewBridge } from "./preview/previewBridge";
40import { PierreEntryIcon } from "./chat/PierreEntryIcon";43import { PierreEntryIcon } from "./chat/PierreEntryIcon";
4144
42interface RightPanelTabsProps {45interface RightPanelTabsProps {
...@@ -52,6 +55,12 @@ interface RightPanelTabsProps {...@@ -52,6 +55,12 @@ interface RightPanelTabsProps {
52 pendingSurfaceIds: ReadonlySet<string>;55 pendingSurfaceIds: ReadonlySet<string>;
53 previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>;56 previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>;
54 desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>;57 desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>;
58 /**
59 * Maps a server session tab id to the desktop runtime tab id the Electron
60 * preview manager is keyed by. Session ids are only unique within one server
61 * process, so desktop operations must not be addressed with them.
62 */
63 previewRuntimeTabId?: ((tabId: string) => string) | undefined;
55 terminalLabelsById: ReadonlyMap<string, string>;64 terminalLabelsById: ReadonlyMap<string, string>;
56 onActivate: (surface: RightPanelSurface) => void;65 onActivate: (surface: RightPanelSurface) => void;
57 onCloseSurface: (surface: RightPanelSurface) => void;66 onCloseSurface: (surface: RightPanelSurface) => void;
...@@ -116,7 +125,53 @@ const SURFACE_UNAVAILABLE_HINTS = {...@@ -116,7 +125,53 @@ const SURFACE_UNAVAILABLE_HINTS = {
116 agents: "Available from a thread.",125 agents: "Available from a thread.",
117} as const;126} as const;
118127
119type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all";128type TabContextMenuAction =
129 | "copy-path"
130 | "toggle-mute"
131 | "close"
132 | "close-others"
133 | "close-to-right"
134 | "close-all";
135
136/**
137 * Desktop preview tab backing a surface, or null for non-preview surfaces, the
138 * "new browser tab" placeholder, and the web build where no desktop tab exists.
139 */
140function previewTabIdOf(
141 surface: RightPanelSurface,
142 sessions: Readonly<Record<string, PreviewSessionSnapshot>>,
143): string | null {
144 if (surface.kind !== "preview" || !surface.resourceId) return null;
145 return sessions[surface.resourceId]?.tabId ?? null;
146}
147
148/**
149 * Label and enabled state for a preview tab's mute menu entry.
150 * Stays disabled until desktop overlay state arrives: a server session id can
151 * resolve while the preview manager's createTab is still in flight, and muting
152 * then fails with a PreviewTabNotFoundError nothing surfaces to the user.
153 */
154export function tabMuteMenuItem(input: {
155 overlay: DesktopPreviewOverlay | null;
156 canResolveRuntimeTabId: boolean;
157}): { label: string; disabled: boolean } {
158 const muted = input.overlay?.audioMuted ?? false;
159 return {
160 label: muted ? "Unmute tab" : "Mute tab",
161 disabled: input.overlay === null || !input.canResolveRuntimeTabId,
162 };
163}
164
165type TabAudioState = "none" | "audible" | "muted";
166
167/**
168 * A muted tab that is not making sound shows nothing: mute is armed silently,
169 * and the indicator only appears once there is audio to speak of.
170 */
171function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState {
172 if (!overlay?.audible) return "none";
173 return overlay.audioMuted ? "muted" : "audible";
174}
120175
121function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) {176function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) {
122 return (177 return (
...@@ -534,6 +589,25 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -534,6 +589,25 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
534 if (surface.kind === "file") {589 if (surface.kind === "file") {
535 items.push({ id: "copy-path", label: "Copy path" });590 items.push({ id: "copy-path", label: "Copy path" });
536 }591 }
592 const menuPreviewTabId = previewTabIdOf(surface, props.previewSessions);
593 // Desktop overlay state only arrives once the preview manager has created
594 // the tab. A server session id alone can still be ahead of that, and
595 // muting then fails with PreviewTabNotFoundError that nobody surfaces.
596 const menuOverlay = menuPreviewTabId
597 ? (props.desktopByTabId[menuPreviewTabId] ?? null)
598 : null;
599 const menuMuted = menuOverlay?.audioMuted ?? false;
600 if (surface.kind === "preview") {
601 // Not gated on audibility: silencing a quiet tab ahead of time is the
602 // point, so the item is offered whenever the tab is mutable at all.
603 items.push({
604 id: "toggle-mute",
605 ...tabMuteMenuItem({
606 overlay: menuOverlay,
607 canResolveRuntimeTabId: props.previewRuntimeTabId !== undefined,
608 }),
609 });
610 }
537 items.push(611 items.push(
538 { id: "close", label: "Close" },612 { id: "close", label: "Close" },
539 {613 {
...@@ -558,6 +632,18 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -558,6 +632,18 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
558 case "copy-path":632 case "copy-path":
559 if (surface.kind === "file") props.onCopyFilePath(surface.relativePath);633 if (surface.kind === "file") props.onCopyFilePath(surface.relativePath);
560 break;634 break;
635 case "toggle-mute": {
636 // menuOverlay repeats the disabled gate above: the desktop tab must
637 // exist before it can be addressed, however the menu was dismissed.
638 const runtimeTabId =
639 menuPreviewTabId && menuOverlay
640 ? (props.previewRuntimeTabId?.(menuPreviewTabId) ?? null)
641 : null;
642 if (runtimeTabId) {
643 void previewBridge?.setAudioMuted(runtimeTabId, !menuMuted).catch(() => undefined);
644 }
645 break;
646 }
561 case "close":647 case "close":
562 props.onCloseSurface(surface);648 props.onCloseSurface(surface);
563 break;649 break;
...@@ -626,6 +712,15 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -626,6 +712,15 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
626 const active = surface.id === props.activeSurfaceId;712 const active = surface.id === props.activeSurfaceId;
627 const pending = props.pendingSurfaceIds.has(surface.id);713 const pending = props.pendingSurfaceIds.has(surface.id);
628 const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById);714 const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById);
715 const previewTabId = previewTabIdOf(surface, props.previewSessions);
716 // Desktop state is keyed by the session id, but desktop actions
717 // must be addressed with the runtime id.
718 const audio = tabAudioState(
719 previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null,
720 );
721 const audioRuntimeTabId = previewTabId
722 ? (props.previewRuntimeTabId?.(previewTabId) ?? null)
723 : null;
629 return (724 return (
630 <div725 <div
631 key={surface.id}726 key={surface.id}
...@@ -663,6 +758,34 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -663,6 +758,34 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
663 </span>758 </span>
664 <X className="hidden size-3 group-hover/tab:block group-focus-visible/close:block" />759 <X className="hidden size-3 group-hover/tab:block group-focus-visible/close:block" />
665 </button>760 </button>
761 {audio === "none" || !audioRuntimeTabId ? null : (
762 <Tooltip>
763 <TooltipTrigger
764 render={
765 <button
766 type="button"
767 className="cursor-pointer flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted"
768 aria-label={audio === "muted" ? `Unmute ${title}` : `Mute ${title}`}
769 onClick={(event) => {
770 // Sibling of the close button, inside a tab that
771 // activates on click: keep this to the toggle.
772 event.stopPropagation();
773 void previewBridge
774 ?.setAudioMuted(audioRuntimeTabId, audio !== "muted")
775 .catch(() => undefined);
776 }}
777 >
778 {audio === "muted" ? (
779 <VolumeOff className="size-3" />
780 ) : (
781 <Volume2 className="size-3" />
782 )}
783 </button>
784 }
785 />
786 <TooltipPopup>{audio === "muted" ? "Unmute tab" : "Mute tab"}</TooltipPopup>
787 </Tooltip>
788 )}
666 <Tooltip>789 <Tooltip>
667 <TooltipTrigger790 <TooltipTrigger
668 render={791 render={
apps/web/src/components/preview/PreviewView.test.tsx+2
...@@ -107,6 +107,8 @@ vi.mock("~/previewStateStore", () => ({...@@ -107,6 +107,8 @@ vi.mock("~/previewStateStore", () => ({
107 zoomFactor: 1,107 zoomFactor: 1,
108 pictureInPicture: mocks.pictureInPicture,108 pictureInPicture: mocks.pictureInPicture,
109 colorScheme: "system",109 colorScheme: "system",
110 audioMuted: false,
111 audible: false,
110 controller: "none",112 controller: "none",
111 },113 },
112 },114 },
apps/web/src/components/preview/usePreviewBridge.test.ts+2
...@@ -19,6 +19,8 @@ function state(navStatus: DesktopPreviewTabState["navStatus"]): DesktopPreviewTa...@@ -19,6 +19,8 @@ function state(navStatus: DesktopPreviewTabState["navStatus"]): DesktopPreviewTa
19 zoomFactor: 1,19 zoomFactor: 1,
20 pictureInPicture: false,20 pictureInPicture: false,
21 colorScheme: "system",21 colorScheme: "system",
22 audioMuted: false,
23 audible: false,
22 controller: "none",24 controller: "none",
23 favicon,25 favicon,
24 updatedAt: "2026-08-09T00:00:00.000Z",26 updatedAt: "2026-08-09T00:00:00.000Z",
apps/web/src/components/preview/usePreviewBridge.ts+2
...@@ -121,6 +121,8 @@ export function projectDesktopState(state: DesktopPreviewTabState): DesktopPrevi...@@ -121,6 +121,8 @@ export function projectDesktopState(state: DesktopPreviewTabState): DesktopPrevi
121 zoomFactor: state.zoomFactor,121 zoomFactor: state.zoomFactor,
122 pictureInPicture: state.pictureInPicture,122 pictureInPicture: state.pictureInPicture,
123 colorScheme: state.colorScheme,123 colorScheme: state.colorScheme,
124 audioMuted: state.audioMuted,
125 audible: state.audible,
124 controller: state.controller,126 controller: state.controller,
125 favicon: state.favicon && originOf(state.favicon.pageUrl) === navOrigin ? state.favicon : null,127 favicon: state.favicon && originOf(state.favicon.pageUrl) === navOrigin ? state.favicon : null,
126 };128 };
apps/web/src/previewStateStore.test.ts+8
...@@ -321,6 +321,8 @@ describe("previewStateStore (single-tab)", () => {...@@ -321,6 +321,8 @@ describe("previewStateStore (single-tab)", () => {
321 zoomFactor: 1,321 zoomFactor: 1,
322 pictureInPicture: false,322 pictureInPicture: false,
323 colorScheme: "system",323 colorScheme: "system",
324 audioMuted: false,
325 audible: false,
324 controller: "none",326 controller: "none",
325 favicon: null,327 favicon: null,
326 });328 });
...@@ -342,6 +344,8 @@ describe("previewStateStore (single-tab)", () => {...@@ -342,6 +344,8 @@ describe("previewStateStore (single-tab)", () => {
342 zoomFactor: 1,344 zoomFactor: 1,
343 pictureInPicture: false,345 pictureInPicture: false,
344 colorScheme: "system",346 colorScheme: "system",
347 audioMuted: false,
348 audible: false,
345 controller: "none",349 controller: "none",
346 favicon: null,350 favicon: null,
347 });351 });
...@@ -391,6 +395,8 @@ describe("previewStateStore (single-tab)", () => {...@@ -391,6 +395,8 @@ describe("previewStateStore (single-tab)", () => {
391 zoomFactor: 1,395 zoomFactor: 1,
392 pictureInPicture: false,396 pictureInPicture: false,
393 colorScheme: "system",397 colorScheme: "system",
398 audioMuted: false,
399 audible: false,
394 controller: "none",400 controller: "none",
395 favicon: null,401 favicon: null,
396 });402 });
...@@ -506,6 +512,8 @@ describe("previewStateStore (single-tab)", () => {...@@ -506,6 +512,8 @@ describe("previewStateStore (single-tab)", () => {
506 zoomFactor: 1,512 zoomFactor: 1,
507 pictureInPicture: false,513 pictureInPicture: false,
508 colorScheme: "system",514 colorScheme: "system",
515 audioMuted: false,
516 audible: false,
509 controller: "none",517 controller: "none",
510 favicon: null,518 favicon: null,
511 });519 });
apps/web/src/previewStateStore.ts+2
...@@ -28,6 +28,8 @@ export interface DesktopPreviewOverlay {...@@ -28,6 +28,8 @@ export interface DesktopPreviewOverlay {
28 zoomFactor: number;28 zoomFactor: number;
29 pictureInPicture: boolean;29 pictureInPicture: boolean;
30 colorScheme: DesktopPreviewColorScheme;30 colorScheme: DesktopPreviewColorScheme;
31 audioMuted: boolean;
32 audible: boolean;
31 controller: "human" | "agent" | "none";33 controller: "human" | "agent" | "none";
32 favicon: DesktopPreviewFavicon | null;34 favicon: DesktopPreviewFavicon | null;
33}35}
packages/contracts/src/ipc.ts+26
...@@ -558,6 +558,19 @@ export interface DesktopPreviewTabState {...@@ -558,6 +558,19 @@ export interface DesktopPreviewTabState {
558 /** Whether this tab is currently mirrored into a desktop picture-in-picture window. */558 /** Whether this tab is currently mirrored into a desktop picture-in-picture window. */
559 pictureInPicture: boolean;559 pictureInPicture: boolean;
560 colorScheme: DesktopPreviewColorScheme;560 colorScheme: DesktopPreviewColorScheme;
561 /**
562 * Whether the user has silenced this tab. Per tab rather than per origin, so
563 * two tabs on the same site mute independently. Survives navigation and
564 * webview swaps, but is dropped when the tab closes.
565 */
566 audioMuted: boolean;
567 /**
568 * Whether the guest is currently emitting audio. Observed from Chromium, and
569 * independent of {@link audioMuted}: a muted tab that is playing still reports
570 * `true`, which is what lets the tab strip distinguish "muted and making
571 * sound" from "muted and silent".
572 */
573 audible: boolean;
561 controller: "human" | "agent" | "none";574 controller: "human" | "agent" | "none";
562 favicon?: DesktopPreviewFavicon;575 favicon?: DesktopPreviewFavicon;
563 updatedAt: string;576 updatedAt: string;
...@@ -597,6 +610,8 @@ export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState>...@@ -597,6 +610,8 @@ export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState>
597 zoomFactor: Schema.Number,610 zoomFactor: Schema.Number,
598 pictureInPicture: Schema.Boolean,611 pictureInPicture: Schema.Boolean,
599 colorScheme: DesktopPreviewColorSchemeSchema,612 colorScheme: DesktopPreviewColorSchemeSchema,
613 audioMuted: Schema.Boolean,
614 audible: Schema.Boolean,
600 controller: Schema.Literals(["human", "agent", "none"]),615 controller: Schema.Literals(["human", "agent", "none"]),
601 favicon: Schema.optionalKey(DesktopPreviewFaviconSchema),616 favicon: Schema.optionalKey(DesktopPreviewFaviconSchema),
602 updatedAt: Schema.String,617 updatedAt: Schema.String,
...@@ -993,6 +1008,11 @@ export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({...@@ -993,6 +1008,11 @@ export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({
993 colorScheme: DesktopPreviewColorSchemeSchema,1008 colorScheme: DesktopPreviewColorSchemeSchema,
994});1009});
9951010
1011export const DesktopPreviewSetAudioMutedInputSchema = Schema.Struct({
1012 tabId: DesktopPreviewTabIdSchema,
1013 audioMuted: Schema.Boolean,
1014});
1015
996export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({1016export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({
997 theme: DesktopPreviewAnnotationThemeSchema,1017 theme: DesktopPreviewAnnotationThemeSchema,
998});1018});
...@@ -1144,6 +1164,12 @@ export interface DesktopPreviewBridge {...@@ -1144,6 +1164,12 @@ export interface DesktopPreviewBridge {
1144 * override). Persists per tab and is re-applied across webview swaps.1164 * override). Persists per tab and is re-applied across webview swaps.
1145 */1165 */
1146 setColorScheme: (tabId: string, colorScheme: DesktopPreviewColorScheme) => Promise<void>;1166 setColorScheme: (tabId: string, colorScheme: DesktopPreviewColorScheme) => Promise<void>;
1167 /**
1168 * Silence the tab's audio output. Persists per tab and is re-applied across
1169 * webview swaps, but is dropped when the tab closes. Muting a silent tab is
1170 * allowed; it simply takes effect once the page plays something.
1171 */
1172 setAudioMuted: (tabId: string, audioMuted: boolean) => Promise<void>;
1147 /** Open the guest webview's DevTools (detached). */1173 /** Open the guest webview's DevTools (detached). */
1148 openDevTools: (tabId: string) => Promise<void>;1174 openDevTools: (tabId: string) => Promise<void>;
1149 /** Drop cookies + storage data for the preview partition (all tabs). */1175 /** Drop cookies + storage data for the preview partition (all tabs). */