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";
5454export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom";
5555export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload";
5656export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme";
57export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted";
5758export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";
5859export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";
5960export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
apps/desktop/src/ipc/methods/preview.ts+11
......@@ -13,6 +13,7 @@ import {
1313 DesktopPreviewRecordingSaveInputSchema,
1414 DesktopPreviewRegisterWebviewInputSchema,
1515 DesktopPreviewScreenshotArtifactSchema,
16 DesktopPreviewSetAudioMutedInputSchema,
1617 DesktopPreviewSetColorSchemeInputSchema,
1718 DesktopPreviewCreateTabInputSchema,
1819 DesktopPreviewTabInputSchema,
......@@ -153,6 +154,15 @@ export const setColorScheme = DesktopIpc.makeIpcMethod({
153154 yield* manager.setColorScheme(tabId, colorScheme);
154155 }),
155156});
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});
156166export const openDevTools = tabMethod(
157167 IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL,
158168 "desktop.ipc.preview.openDevTools",
......@@ -372,6 +382,7 @@ export const methods = [
372382 resetZoom,
373383 hardReload,
374384 setColorScheme,
385 setAudioMuted,
375386 openDevTools,
376387 clearCookies,
377388 clearCache,
apps/desktop/src/preload.ts+2
......@@ -183,6 +183,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
183183 hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }),
184184 setColorScheme: (tabId, colorScheme) =>
185185 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 }),
186188 openDevTools: (tabId) =>
187189 ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
188190 clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL),
apps/desktop/src/preview/Manager.test.ts+319
......@@ -170,6 +170,8 @@ const makeTestPreviewWebContents = (
170170 isLoading: () => false,
171171 getZoomFactor: () => 1,
172172 setZoomFactor: vi.fn(),
173 setAudioMuted: vi.fn(),
174 isCurrentlyAudible: () => false,
173175 on: vi.fn(),
174176 off: vi.fn(),
175177 ipc: { on: vi.fn(), off: vi.fn() },
......@@ -234,6 +236,8 @@ const makeFaviconWebContents = (options?: {
234236 isDevToolsOpened: () => false,
235237 getZoomFactor: () => 1,
236238 setZoomFactor: vi.fn(),
239 setAudioMuted: vi.fn(),
240 isCurrentlyAudible: () => false,
237241 reload,
238242 reloadIgnoringCache: vi.fn(),
239243 loadURL,
......@@ -459,6 +463,8 @@ describe("PreviewManager", () => {
459463 isLoading: () => false,
460464 getZoomFactor: () => 1,
461465 setZoomFactor: vi.fn(),
466 setAudioMuted: vi.fn(),
467 isCurrentlyAudible: () => false,
462468 loadURL,
463469 on: vi.fn((event: string, listener: (...args: never[]) => void) => {
464470 listeners.set(event, listener);
......@@ -1002,6 +1008,8 @@ describe("PreviewManager", () => {
10021008 return effectiveZoom;
10031009 },
10041010 setZoomFactor,
1011 setAudioMuted: vi.fn(),
1012 isCurrentlyAudible: () => false,
10051013 on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
10061014 listeners.set(event, listener);
10071015 }),
......@@ -1066,6 +1074,8 @@ describe("PreviewManager", () => {
10661074 isLoading: () => false,
10671075 getZoomFactor: () => 1,
10681076 setZoomFactor: replacementSetZoomFactor,
1077 setAudioMuted: vi.fn(),
1078 isCurrentlyAudible: () => false,
10691079 on: vi.fn(),
10701080 off: vi.fn(),
10711081 ipc: { on: vi.fn(), off: vi.fn() },
......@@ -1104,6 +1114,8 @@ describe("PreviewManager", () => {
11041114 isLoading: () => false,
11051115 getZoomFactor: () => 1,
11061116 setZoomFactor,
1117 setAudioMuted: vi.fn(),
1118 isCurrentlyAudible: () => false,
11071119 on: vi.fn(),
11081120 off: vi.fn(),
11091121 ipc: { on: vi.fn(), off: vi.fn() },
......@@ -1148,6 +1160,8 @@ describe("PreviewManager", () => {
11481160 isLoading: () => false,
11491161 getZoomFactor: () => 1,
11501162 setZoomFactor,
1163 setAudioMuted: vi.fn(),
1164 isCurrentlyAudible: () => false,
11511165 on: vi.fn(),
11521166 off: vi.fn(),
11531167 ipc: { on: vi.fn(), off: vi.fn() },
......@@ -1201,6 +1215,8 @@ describe("PreviewManager", () => {
12011215 isLoading: () => false,
12021216 getZoomFactor: () => 1,
12031217 setZoomFactor: vi.fn(),
1218 setAudioMuted: vi.fn(),
1219 isCurrentlyAudible: () => false,
12041220 on: vi.fn(),
12051221 off: vi.fn(),
12061222 ipc: { on: vi.fn(), off: vi.fn() },
......@@ -1257,6 +1273,287 @@ describe("PreviewManager", () => {
12571273 ),
12581274 );
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
12601557 effectIt.effect("blocks late webview and capture starts during tab close", () =>
12611558 withManager((manager) =>
12621559 Effect.gen(function* () {
......@@ -1346,6 +1643,8 @@ describe("PreviewManager", () => {
13461643 isLoading: () => loading,
13471644 getZoomFactor: () => 1,
13481645 setZoomFactor: vi.fn(),
1646 setAudioMuted: vi.fn(),
1647 isCurrentlyAudible: () => false,
13491648 on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
13501649 listeners.set(event, listener);
13511650 }),
......@@ -1436,6 +1735,8 @@ describe("PreviewManager", () => {
14361735 isLoading: () => false,
14371736 getZoomFactor: () => 1,
14381737 setZoomFactor: vi.fn(),
1738 setAudioMuted: vi.fn(),
1739 isCurrentlyAudible: () => false,
14391740 on: vi.fn((event: string, listener: (...args: never[]) => void) => {
14401741 listeners.set(event, listener);
14411742 }),
......@@ -1525,6 +1826,8 @@ describe("PreviewManager", () => {
15251826 isLoading: () => false,
15261827 getZoomFactor: () => 1,
15271828 setZoomFactor: vi.fn(),
1829 setAudioMuted: vi.fn(),
1830 isCurrentlyAudible: () => false,
15281831 on: vi.fn(),
15291832 off: vi.fn(),
15301833 ipc: { on: vi.fn(), off: vi.fn() },
......@@ -1735,6 +2038,8 @@ describe("PreviewManager", () => {
17352038 isDevToolsOpened: () => false,
17362039 getZoomFactor: () => 1,
17372040 setZoomFactor: vi.fn(),
2041 setAudioMuted: vi.fn(),
2042 isCurrentlyAudible: () => false,
17382043 on: vi.fn(),
17392044 off: vi.fn(),
17402045 ipc: { on: vi.fn(), off: vi.fn() },
......@@ -1815,6 +2120,8 @@ describe("PreviewManager", () => {
18152120 isLoading: () => false,
18162121 getZoomFactor: () => 1,
18172122 setZoomFactor: vi.fn(),
2123 setAudioMuted: vi.fn(),
2124 isCurrentlyAudible: () => false,
18182125 on: vi.fn(),
18192126 off: vi.fn(),
18202127 ipc: { on: vi.fn(), off: vi.fn() },
......@@ -2204,6 +2511,8 @@ describe("PreviewManager", () => {
22042511 isFocused: () => true,
22052512 getZoomFactor: () => 1,
22062513 setZoomFactor: vi.fn(),
2514 setAudioMuted: vi.fn(),
2515 isCurrentlyAudible: () => false,
22072516 on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
22082517 listeners.set(event, listener);
22092518 }),
......@@ -2255,6 +2564,8 @@ describe("PreviewManager", () => {
22552564 isLoading: () => false,
22562565 getZoomFactor: () => 1,
22572566 setZoomFactor: vi.fn(),
2567 setAudioMuted: vi.fn(),
2568 isCurrentlyAudible: () => false,
22582569 on: vi.fn(),
22592570 off: vi.fn(),
22602571 ipc: {
......@@ -2387,6 +2698,8 @@ describe("PreviewManager", () => {
23872698 isDevToolsOpened: () => false,
23882699 getZoomFactor: () => 1,
23892700 setZoomFactor: vi.fn(),
2701 setAudioMuted: vi.fn(),
2702 isCurrentlyAudible: () => false,
23902703 on: vi.fn(),
23912704 off: vi.fn(),
23922705 ipc: {
......@@ -2485,6 +2798,8 @@ describe("PreviewManager", () => {
24852798 focus,
24862799 getZoomFactor: () => 1,
24872800 setZoomFactor: vi.fn(),
2801 setAudioMuted: vi.fn(),
2802 isCurrentlyAudible: () => false,
24882803 on: vi.fn(),
24892804 off: vi.fn(),
24902805 ipc: {
......@@ -2638,6 +2953,8 @@ describe("PreviewManager", () => {
26382953 isDevToolsOpened: () => false,
26392954 getZoomFactor: () => 1,
26402955 setZoomFactor: vi.fn(),
2956 setAudioMuted: vi.fn(),
2957 isCurrentlyAudible: () => false,
26412958 on: vi.fn(),
26422959 off: vi.fn(),
26432960 ipc: {
......@@ -2705,6 +3022,8 @@ describe("PreviewManager", () => {
27053022 isDevToolsOpened: () => false,
27063023 getZoomFactor: () => 1,
27073024 setZoomFactor: vi.fn(),
3025 setAudioMuted: vi.fn(),
3026 isCurrentlyAudible: () => false,
27083027 on: vi.fn(),
27093028 off: vi.fn(),
27103029 ipc: { on: vi.fn(), off: vi.fn() },
apps/desktop/src/preview/Manager.ts+149-5
......@@ -88,6 +88,10 @@ export interface PreviewTabState {
8888 zoomFactor: number;
8989 pictureInPicture: boolean;
9090 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;
9195 controller: "human" | "agent" | "none";
9296 favicon?: DesktopPreviewFavicon;
9397 updatedAt: string;
......@@ -661,7 +665,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
661665 }),
662666 ] as const;
663667 });
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);
665673 });
666674
667675 /**
......@@ -680,6 +688,62 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
680688 ).pipe(Effect.ignore);
681689 });
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
683747 const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* (
684748 tabId: string,
685749 ) {
......@@ -1389,6 +1453,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13891453 ) => {
13901454 if (event.isMainFrame && !event.isSameDocument) cancelFaviconCapture();
13911455 };
1456 const audioStateChanged = (
1457 event: Electron.Event<Electron.WebContentsAudioStateChangedEventParams>,
1458 ) => runFork(syncTabAudible(tabId, wc, event.audible));
13921459 const publishFavicon = Effect.fn("PreviewManager.publishFavicon")(function* (input: {
13931460 readonly captureDocumentId: number;
13941461 readonly dataUrl: string;
......@@ -1583,6 +1650,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
15831650 wc.off("did-start-loading", sync);
15841651 wc.off("did-stop-loading", sync);
15851652 wc.off("did-fail-load", failed as never);
1653 wc.off("audio-state-changed", audioStateChanged);
15861654 wc.off("before-input-event", beforeInput);
15871655 wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput);
15881656 wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
......@@ -1598,6 +1666,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
15981666 wc.on("did-start-loading", sync);
15991667 wc.on("did-stop-loading", sync);
16001668 wc.on("did-fail-load", failed as never);
1669 wc.on("audio-state-changed", audioStateChanged);
16011670 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
16021671 wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
16031672 wc.setWindowOpenHandler(({ url }) => {
......@@ -1652,6 +1721,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
16521721 zoomFactor: normalizeZoomFactor(defaults?.zoomFactor),
16531722 pictureInPicture: false,
16541723 colorScheme: defaults?.colorScheme ?? "system",
1724 audioMuted: false,
1725 audible: false,
16551726 controller: "none",
16561727 updatedAt,
16571728 };
......@@ -1718,6 +1789,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
17181789 zoomFactor: DEFAULT_ZOOM_FACTOR,
17191790 pictureInPicture: false,
17201791 colorScheme: "system",
1792 audioMuted: false,
1793 audible: false,
17211794 controller: "none",
17221795 updatedAt,
17231796 };
......@@ -1808,7 +1881,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
18081881 yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () =>
18091882 wc.setZoomFactor(currentTab.zoomFactor),
18101883 );
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 );
18111890 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;
18121896 const registeredAt = yield* currentIso;
18131897 const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) =>
18141898 Effect.gen(function* () {
......@@ -1831,6 +1915,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
18311915 navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus,
18321916 canGoBack: wc.navigationHistory.canGoBack(),
18331917 canGoForward: wc.navigationHistory.canGoForward(),
1918 audible: attachedAudible,
18341919 updatedAt: registeredAt,
18351920 };
18361921 return [
......@@ -1852,11 +1937,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
18521937 return yield* new PreviewTabNotFoundError({ tabId });
18531938 }
18541939 const { state: registered, pendingUrl } = registration.value;
1855 // A zoom action that landed while this attach was in flight addressed the
1856 // guest this one replaced, so settle the new guest on the committed factor.
1940 // A zoom or mute action that landed while this attach was in flight
1941 // addressed the guest this one replaced, so settle the new guest on the
1942 // committed values.
18571943 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);
18581947 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);
18601956 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>
18611957 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
18621958 );
......@@ -1906,6 +2002,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
19062002 zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR,
19072003 pictureInPicture: current?.pictureInPicture ?? false,
19082004 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,
19092011 controller: current?.controller ?? "none",
19102012 ...(current?.favicon ? { favicon: current.favicon } : {}),
19112013 updatedAt,
......@@ -1917,7 +2019,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
19172019 }),
19182020 ] as const;
19192021 });
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);
19212026 if (pending.webContentsId == null) return;
19222027 const webContentsId = pending.webContentsId;
19232028 const wc = webContents.fromId(webContentsId);
......@@ -2251,6 +2356,39 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
22512356 yield* applyColorScheme(tabId, wc, colorScheme);
22522357 });
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
22542392 const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* (
22552393 tabId: string,
22562394 ) {
......@@ -3543,6 +3681,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
35433681 revealArtifact,
35443682 saveRecording,
35453683 setAnnotationTheme,
3684 setAudioMuted,
35463685 setColorScheme,
35473686 setMainWindow,
35483687 startRecording,
......@@ -3846,6 +3985,10 @@ export class PreviewManager extends Context.Service<
38463985 tabId: string,
38473986 colorScheme: DesktopPreviewColorScheme,
38483987 ) => Effect.Effect<void, PreviewManagerError>;
3988 readonly setAudioMuted: (
3989 tabId: string,
3990 audioMuted: boolean,
3991 ) => Effect.Effect<void, PreviewManagerError>;
38493992 readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
38503993 readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>;
38513994 readonly clearCache: () => Effect.Effect<void, PreviewManagerError>;
......@@ -3944,6 +4087,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
39444087 reapplyZoom: operations.reapplyZoom,
39454088 hardReload: operations.hardReload,
39464089 setColorScheme: operations.setColorScheme,
4090 setAudioMuted: operations.setAudioMuted,
39474091 openDevTools: operations.openDevTools,
39484092 clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () {
39494093 yield* browserSession
apps/web/src/components/ChatView.tsx+11
......@@ -136,6 +136,7 @@ import {
136136 setActivePreviewTab,
137137 useThreadPreviewState,
138138} from "../previewStateStore";
139import { previewRuntimeTabId } from "../browser/previewRuntimeTabId";
139140import { addBrowserSurface } from "./preview/addBrowserSurface";
140141import { closePreviewSession } from "./preview/closePreviewSession";
141142import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
......@@ -1636,6 +1637,14 @@ function ChatViewContent(props: ChatViewProps) {
16361637 const activeFileSurface =
16371638 activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null;
16381639 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 );
16391648 const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) =>
16401649 selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef),
16411650 );
......@@ -6627,6 +6636,7 @@ function ChatViewContent(props: ChatViewProps) {
66276636 pendingSurfaceIds={pendingFileSurfaceIds}
66286637 previewSessions={activePreviewState.sessions}
66296638 desktopByTabId={activePreviewState.desktopByTabId}
6639 previewRuntimeTabId={resolvePreviewRuntimeTabId}
66306640 terminalLabelsById={activeTerminalLabelsById}
66316641 onActivate={activateRightPanelSurface}
66326642 onCloseSurface={closeRightPanelSurface}
......@@ -6666,6 +6676,7 @@ function ChatViewContent(props: ChatViewProps) {
66666676 pendingSurfaceIds={pendingFileSurfaceIds}
66676677 previewSessions={activePreviewState.sessions}
66686678 desktopByTabId={activePreviewState.desktopByTabId}
6679 previewRuntimeTabId={resolvePreviewRuntimeTabId}
66696680 terminalLabelsById={activeTerminalLabelsById}
66706681 onActivate={activateRightPanelSurface}
66716682 onCloseSurface={closeRightPanelSurface}
apps/web/src/components/RightPanelTabs.test.tsx+85-4
......@@ -2,7 +2,7 @@ import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/con
22import { renderToStaticMarkup } from "react-dom/server";
33import { describe, expect, it } from "vite-plus/test";
44
5import { RightPanelTabs } from "./RightPanelTabs";
5import { RightPanelTabs, tabMuteMenuItem } from "./RightPanelTabs";
66
77const previewSurface = {
88 id: "browser:tab-1" as const,
......@@ -39,7 +39,10 @@ const favicon = (dataUrl: string, pageUrl: string): DesktopPreviewFavicon => ({
3939 capturedAt: 1,
4040});
4141
42function overlay(icon: DesktopPreviewFavicon | null) {
42function overlay(
43 icon: DesktopPreviewFavicon | null,
44 audio?: { audible?: boolean; audioMuted?: boolean },
45) {
4346 return {
4447 hasWebContents: true,
4548 canGoBack: false,
......@@ -48,12 +51,19 @@ function overlay(icon: DesktopPreviewFavicon | null) {
4851 zoomFactor: 1,
4952 pictureInPicture: false,
5053 colorScheme: "system" as const,
54 audioMuted: audio?.audioMuted ?? false,
55 audible: audio?.audible ?? false,
5156 controller: "none" as const,
5257 favicon: icon,
5358 };
5459}
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) {
5767 return renderToStaticMarkup(
5868 <RightPanelTabs
5969 mode="inline"
......@@ -62,9 +72,10 @@ function renderTabs(first: DesktopPreviewFavicon | null, second?: DesktopPreview
6272 pendingSurfaceIds={new Set()}
6373 previewSessions={sessions}
6474 desktopByTabId={{
65 "tab-1": overlay(first),
75 "tab-1": overlay(first, audio),
6676 ...(second ? { "tab-2": overlay(second) } : {}),
6777 }}
78 {...(previewRuntimeTabId ? { previewRuntimeTabId } : {})}
6879 terminalLabelsById={new Map()}
6980 onActivate={() => undefined}
7081 onCloseSurface={() => undefined}
......@@ -113,3 +124,73 @@ describe("RightPanelTabs preview favicon", () => {
113124 expect(html).not.toContain("data:image/png;base64,AAAA");
114125 });
115126});
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 {
88 Globe2,
99 Plus,
1010 TerminalSquare,
11 Volume2,
12 VolumeOff,
1113 X,
1214} from "lucide-react";
1315import {
......@@ -37,6 +39,7 @@ import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
3739
3840import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell";
3941import { FaviconImage } from "./preview/PreviewFaviconIcon";
42import { previewBridge } from "./preview/previewBridge";
4043import { PierreEntryIcon } from "./chat/PierreEntryIcon";
4144
4245interface RightPanelTabsProps {
......@@ -52,6 +55,12 @@ interface RightPanelTabsProps {
5255 pendingSurfaceIds: ReadonlySet<string>;
5356 previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>;
5457 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;
5564 terminalLabelsById: ReadonlyMap<string, string>;
5665 onActivate: (surface: RightPanelSurface) => void;
5766 onCloseSurface: (surface: RightPanelSurface) => void;
......@@ -116,7 +125,53 @@ const SURFACE_UNAVAILABLE_HINTS = {
116125 agents: "Available from a thread.",
117126} 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
121176function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) {
122177 return (
......@@ -534,6 +589,25 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
534589 if (surface.kind === "file") {
535590 items.push({ id: "copy-path", label: "Copy path" });
536591 }
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 }
537611 items.push(
538612 { id: "close", label: "Close" },
539613 {
......@@ -558,6 +632,18 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
558632 case "copy-path":
559633 if (surface.kind === "file") props.onCopyFilePath(surface.relativePath);
560634 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 }
561647 case "close":
562648 props.onCloseSurface(surface);
563649 break;
......@@ -626,6 +712,15 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
626712 const active = surface.id === props.activeSurfaceId;
627713 const pending = props.pendingSurfaceIds.has(surface.id);
628714 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;
629724 return (
630725 <div
631726 key={surface.id}
......@@ -663,6 +758,34 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
663758 </span>
664759 <X className="hidden size-3 group-hover/tab:block group-focus-visible/close:block" />
665760 </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 )}
666789 <Tooltip>
667790 <TooltipTrigger
668791 render={
apps/web/src/components/preview/PreviewView.test.tsx+2
......@@ -107,6 +107,8 @@ vi.mock("~/previewStateStore", () => ({
107107 zoomFactor: 1,
108108 pictureInPicture: mocks.pictureInPicture,
109109 colorScheme: "system",
110 audioMuted: false,
111 audible: false,
110112 controller: "none",
111113 },
112114 },
apps/web/src/components/preview/usePreviewBridge.test.ts+2
......@@ -19,6 +19,8 @@ function state(navStatus: DesktopPreviewTabState["navStatus"]): DesktopPreviewTa
1919 zoomFactor: 1,
2020 pictureInPicture: false,
2121 colorScheme: "system",
22 audioMuted: false,
23 audible: false,
2224 controller: "none",
2325 favicon,
2426 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
121121 zoomFactor: state.zoomFactor,
122122 pictureInPicture: state.pictureInPicture,
123123 colorScheme: state.colorScheme,
124 audioMuted: state.audioMuted,
125 audible: state.audible,
124126 controller: state.controller,
125127 favicon: state.favicon && originOf(state.favicon.pageUrl) === navOrigin ? state.favicon : null,
126128 };
apps/web/src/previewStateStore.test.ts+8
......@@ -321,6 +321,8 @@ describe("previewStateStore (single-tab)", () => {
321321 zoomFactor: 1,
322322 pictureInPicture: false,
323323 colorScheme: "system",
324 audioMuted: false,
325 audible: false,
324326 controller: "none",
325327 favicon: null,
326328 });
......@@ -342,6 +344,8 @@ describe("previewStateStore (single-tab)", () => {
342344 zoomFactor: 1,
343345 pictureInPicture: false,
344346 colorScheme: "system",
347 audioMuted: false,
348 audible: false,
345349 controller: "none",
346350 favicon: null,
347351 });
......@@ -391,6 +395,8 @@ describe("previewStateStore (single-tab)", () => {
391395 zoomFactor: 1,
392396 pictureInPicture: false,
393397 colorScheme: "system",
398 audioMuted: false,
399 audible: false,
394400 controller: "none",
395401 favicon: null,
396402 });
......@@ -506,6 +512,8 @@ describe("previewStateStore (single-tab)", () => {
506512 zoomFactor: 1,
507513 pictureInPicture: false,
508514 colorScheme: "system",
515 audioMuted: false,
516 audible: false,
509517 controller: "none",
510518 favicon: null,
511519 });
apps/web/src/previewStateStore.ts+2
......@@ -28,6 +28,8 @@ export interface DesktopPreviewOverlay {
2828 zoomFactor: number;
2929 pictureInPicture: boolean;
3030 colorScheme: DesktopPreviewColorScheme;
31 audioMuted: boolean;
32 audible: boolean;
3133 controller: "human" | "agent" | "none";
3234 favicon: DesktopPreviewFavicon | null;
3335}
packages/contracts/src/ipc.ts+26
......@@ -558,6 +558,19 @@ export interface DesktopPreviewTabState {
558558 /** Whether this tab is currently mirrored into a desktop picture-in-picture window. */
559559 pictureInPicture: boolean;
560560 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;
561574 controller: "human" | "agent" | "none";
562575 favicon?: DesktopPreviewFavicon;
563576 updatedAt: string;
......@@ -597,6 +610,8 @@ export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState>
597610 zoomFactor: Schema.Number,
598611 pictureInPicture: Schema.Boolean,
599612 colorScheme: DesktopPreviewColorSchemeSchema,
613 audioMuted: Schema.Boolean,
614 audible: Schema.Boolean,
600615 controller: Schema.Literals(["human", "agent", "none"]),
601616 favicon: Schema.optionalKey(DesktopPreviewFaviconSchema),
602617 updatedAt: Schema.String,
......@@ -993,6 +1008,11 @@ export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({
9931008 colorScheme: DesktopPreviewColorSchemeSchema,
9941009});
9951010
1011export const DesktopPreviewSetAudioMutedInputSchema = Schema.Struct({
1012 tabId: DesktopPreviewTabIdSchema,
1013 audioMuted: Schema.Boolean,
1014});
1015
9961016export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({
9971017 theme: DesktopPreviewAnnotationThemeSchema,
9981018});
......@@ -1144,6 +1164,12 @@ export interface DesktopPreviewBridge {
11441164 * override). Persists per tab and is re-applied across webview swaps.
11451165 */
11461166 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>;
11471173 /** Open the guest webview's DevTools (detached). */
11481174 openDevTools: (tabId: string) => Promise<void>;
11491175 /** Drop cookies + storage data for the preview partition (all tabs). */