| author | |
| committer | |
| log | 3a02c9cf1d6767cad60e5056cb5b722931f6cc5d |
| tree | b80bb9d2f985255d7c1bae10fdd2bd74244b2ae3 |
| parent | 6a687ee43bf222672ab8d3f4c0bab3d8d174f79f |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 54 | export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom"; |
| 55 | 55 | export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; |
| 56 | 56 | export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; |
| 57 | export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted"; | |
| 57 | 58 | export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; |
| 58 | 59 | export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; |
| 59 | 60 | export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; |
apps/desktop/src/ipc/methods/preview.ts+11| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | DesktopPreviewRecordingSaveInputSchema, |
| 14 | 14 | DesktopPreviewRegisterWebviewInputSchema, |
| 15 | 15 | DesktopPreviewScreenshotArtifactSchema, |
| 16 | DesktopPreviewSetAudioMutedInputSchema, | |
| 16 | 17 | DesktopPreviewSetColorSchemeInputSchema, |
| 17 | 18 | DesktopPreviewCreateTabInputSchema, |
| 18 | 19 | DesktopPreviewTabInputSchema, |
| ... | ... | @@ -153,6 +154,15 @@ export const setColorScheme = DesktopIpc.makeIpcMethod({ |
| 153 | 154 | yield* manager.setColorScheme(tabId, colorScheme); |
| 154 | 155 | }), |
| 155 | 156 | }); |
| 157 | export 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 | }); | |
| 156 | 166 | export const openDevTools = tabMethod( |
| 157 | 167 | IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, |
| 158 | 168 | "desktop.ipc.preview.openDevTools", |
| ... | ... | @@ -372,6 +382,7 @@ export const methods = [ |
| 372 | 382 | resetZoom, |
| 373 | 383 | hardReload, |
| 374 | 384 | setColorScheme, |
| 385 | setAudioMuted, | |
| 375 | 386 | openDevTools, |
| 376 | 387 | clearCookies, |
| 377 | 388 | clearCache, |
apps/desktop/src/preload.ts+2| ... | ... | @@ -183,6 +183,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 183 | 183 | hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }), |
| 184 | 184 | setColorScheme: (tabId, colorScheme) => |
| 185 | 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 | 188 | openDevTools: (tabId) => |
| 187 | 189 | ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), |
| 188 | 190 | clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), |
apps/desktop/src/preview/Manager.test.ts+319| ... | ... | @@ -170,6 +170,8 @@ const makeTestPreviewWebContents = ( |
| 170 | 170 | isLoading: () => false, |
| 171 | 171 | getZoomFactor: () => 1, |
| 172 | 172 | setZoomFactor: vi.fn(), |
| 173 | setAudioMuted: vi.fn(), | |
| 174 | isCurrentlyAudible: () => false, | |
| 173 | 175 | on: vi.fn(), |
| 174 | 176 | off: vi.fn(), |
| 175 | 177 | ipc: { on: vi.fn(), off: vi.fn() }, |
| ... | ... | @@ -234,6 +236,8 @@ const makeFaviconWebContents = (options?: { |
| 234 | 236 | isDevToolsOpened: () => false, |
| 235 | 237 | getZoomFactor: () => 1, |
| 236 | 238 | setZoomFactor: vi.fn(), |
| 239 | setAudioMuted: vi.fn(), | |
| 240 | isCurrentlyAudible: () => false, | |
| 237 | 241 | reload, |
| 238 | 242 | reloadIgnoringCache: vi.fn(), |
| 239 | 243 | loadURL, |
| ... | ... | @@ -459,6 +463,8 @@ describe("PreviewManager", () => { |
| 459 | 463 | isLoading: () => false, |
| 460 | 464 | getZoomFactor: () => 1, |
| 461 | 465 | setZoomFactor: vi.fn(), |
| 466 | setAudioMuted: vi.fn(), | |
| 467 | isCurrentlyAudible: () => false, | |
| 462 | 468 | loadURL, |
| 463 | 469 | on: vi.fn((event: string, listener: (...args: never[]) => void) => { |
| 464 | 470 | listeners.set(event, listener); |
| ... | ... | @@ -1002,6 +1008,8 @@ describe("PreviewManager", () => { |
| 1002 | 1008 | return effectiveZoom; |
| 1003 | 1009 | }, |
| 1004 | 1010 | setZoomFactor, |
| 1011 | setAudioMuted: vi.fn(), | |
| 1012 | isCurrentlyAudible: () => false, | |
| 1005 | 1013 | on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { |
| 1006 | 1014 | listeners.set(event, listener); |
| 1007 | 1015 | }), |
| ... | ... | @@ -1066,6 +1074,8 @@ describe("PreviewManager", () => { |
| 1066 | 1074 | isLoading: () => false, |
| 1067 | 1075 | getZoomFactor: () => 1, |
| 1068 | 1076 | setZoomFactor: replacementSetZoomFactor, |
| 1077 | setAudioMuted: vi.fn(), | |
| 1078 | isCurrentlyAudible: () => false, | |
| 1069 | 1079 | on: vi.fn(), |
| 1070 | 1080 | off: vi.fn(), |
| 1071 | 1081 | ipc: { on: vi.fn(), off: vi.fn() }, |
| ... | ... | @@ -1104,6 +1114,8 @@ describe("PreviewManager", () => { |
| 1104 | 1114 | isLoading: () => false, |
| 1105 | 1115 | getZoomFactor: () => 1, |
| 1106 | 1116 | setZoomFactor, |
| 1117 | setAudioMuted: vi.fn(), | |
| 1118 | isCurrentlyAudible: () => false, | |
| 1107 | 1119 | on: vi.fn(), |
| 1108 | 1120 | off: vi.fn(), |
| 1109 | 1121 | ipc: { on: vi.fn(), off: vi.fn() }, |
| ... | ... | @@ -1148,6 +1160,8 @@ describe("PreviewManager", () => { |
| 1148 | 1160 | isLoading: () => false, |
| 1149 | 1161 | getZoomFactor: () => 1, |
| 1150 | 1162 | setZoomFactor, |
| 1163 | setAudioMuted: vi.fn(), | |
| 1164 | isCurrentlyAudible: () => false, | |
| 1151 | 1165 | on: vi.fn(), |
| 1152 | 1166 | off: vi.fn(), |
| 1153 | 1167 | ipc: { on: vi.fn(), off: vi.fn() }, |
| ... | ... | @@ -1201,6 +1215,8 @@ describe("PreviewManager", () => { |
| 1201 | 1215 | isLoading: () => false, |
| 1202 | 1216 | getZoomFactor: () => 1, |
| 1203 | 1217 | setZoomFactor: vi.fn(), |
| 1218 | setAudioMuted: vi.fn(), | |
| 1219 | isCurrentlyAudible: () => false, | |
| 1204 | 1220 | on: vi.fn(), |
| 1205 | 1221 | off: vi.fn(), |
| 1206 | 1222 | ipc: { on: vi.fn(), off: vi.fn() }, |
| ... | ... | @@ -1257,6 +1273,287 @@ describe("PreviewManager", () => { |
| 1257 | 1273 | ), |
| 1258 | 1274 | ); |
| 1259 | 1275 | |
| 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 | 1557 | effectIt.effect("blocks late webview and capture starts during tab close", () => |
| 1261 | 1558 | withManager((manager) => |
| 1262 | 1559 | Effect.gen(function* () { |
| ... | ... | @@ -1346,6 +1643,8 @@ describe("PreviewManager", () => { |
| 1346 | 1643 | isLoading: () => loading, |
| 1347 | 1644 | getZoomFactor: () => 1, |
| 1348 | 1645 | setZoomFactor: vi.fn(), |
| 1646 | setAudioMuted: vi.fn(), | |
| 1647 | isCurrentlyAudible: () => false, | |
| 1349 | 1648 | on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { |
| 1350 | 1649 | listeners.set(event, listener); |
| 1351 | 1650 | }), |
| ... | ... | @@ -1436,6 +1735,8 @@ describe("PreviewManager", () => { |
| 1436 | 1735 | isLoading: () => false, |
| 1437 | 1736 | getZoomFactor: () => 1, |
| 1438 | 1737 | setZoomFactor: vi.fn(), |
| 1738 | setAudioMuted: vi.fn(), | |
| 1739 | isCurrentlyAudible: () => false, | |
| 1439 | 1740 | on: vi.fn((event: string, listener: (...args: never[]) => void) => { |
| 1440 | 1741 | listeners.set(event, listener); |
| 1441 | 1742 | }), |
| ... | ... | @@ -1525,6 +1826,8 @@ describe("PreviewManager", () => { |
| 1525 | 1826 | isLoading: () => false, |
| 1526 | 1827 | getZoomFactor: () => 1, |
| 1527 | 1828 | setZoomFactor: vi.fn(), |
| 1829 | setAudioMuted: vi.fn(), | |
| 1830 | isCurrentlyAudible: () => false, | |
| 1528 | 1831 | on: vi.fn(), |
| 1529 | 1832 | off: vi.fn(), |
| 1530 | 1833 | ipc: { on: vi.fn(), off: vi.fn() }, |
| ... | ... | @@ -1735,6 +2038,8 @@ describe("PreviewManager", () => { |
| 1735 | 2038 | isDevToolsOpened: () => false, |
| 1736 | 2039 | getZoomFactor: () => 1, |
| 1737 | 2040 | setZoomFactor: vi.fn(), |
| 2041 | setAudioMuted: vi.fn(), | |
| 2042 | isCurrentlyAudible: () => false, | |
| 1738 | 2043 | on: vi.fn(), |
| 1739 | 2044 | off: vi.fn(), |
| 1740 | 2045 | ipc: { on: vi.fn(), off: vi.fn() }, |
| ... | ... | @@ -1815,6 +2120,8 @@ describe("PreviewManager", () => { |
| 1815 | 2120 | isLoading: () => false, |
| 1816 | 2121 | getZoomFactor: () => 1, |
| 1817 | 2122 | setZoomFactor: vi.fn(), |
| 2123 | setAudioMuted: vi.fn(), | |
| 2124 | isCurrentlyAudible: () => false, | |
| 1818 | 2125 | on: vi.fn(), |
| 1819 | 2126 | off: vi.fn(), |
| 1820 | 2127 | ipc: { on: vi.fn(), off: vi.fn() }, |
| ... | ... | @@ -2204,6 +2511,8 @@ describe("PreviewManager", () => { |
| 2204 | 2511 | isFocused: () => true, |
| 2205 | 2512 | getZoomFactor: () => 1, |
| 2206 | 2513 | setZoomFactor: vi.fn(), |
| 2514 | setAudioMuted: vi.fn(), | |
| 2515 | isCurrentlyAudible: () => false, | |
| 2207 | 2516 | on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { |
| 2208 | 2517 | listeners.set(event, listener); |
| 2209 | 2518 | }), |
| ... | ... | @@ -2255,6 +2564,8 @@ describe("PreviewManager", () => { |
| 2255 | 2564 | isLoading: () => false, |
| 2256 | 2565 | getZoomFactor: () => 1, |
| 2257 | 2566 | setZoomFactor: vi.fn(), |
| 2567 | setAudioMuted: vi.fn(), | |
| 2568 | isCurrentlyAudible: () => false, | |
| 2258 | 2569 | on: vi.fn(), |
| 2259 | 2570 | off: vi.fn(), |
| 2260 | 2571 | ipc: { |
| ... | ... | @@ -2387,6 +2698,8 @@ describe("PreviewManager", () => { |
| 2387 | 2698 | isDevToolsOpened: () => false, |
| 2388 | 2699 | getZoomFactor: () => 1, |
| 2389 | 2700 | setZoomFactor: vi.fn(), |
| 2701 | setAudioMuted: vi.fn(), | |
| 2702 | isCurrentlyAudible: () => false, | |
| 2390 | 2703 | on: vi.fn(), |
| 2391 | 2704 | off: vi.fn(), |
| 2392 | 2705 | ipc: { |
| ... | ... | @@ -2485,6 +2798,8 @@ describe("PreviewManager", () => { |
| 2485 | 2798 | focus, |
| 2486 | 2799 | getZoomFactor: () => 1, |
| 2487 | 2800 | setZoomFactor: vi.fn(), |
| 2801 | setAudioMuted: vi.fn(), | |
| 2802 | isCurrentlyAudible: () => false, | |
| 2488 | 2803 | on: vi.fn(), |
| 2489 | 2804 | off: vi.fn(), |
| 2490 | 2805 | ipc: { |
| ... | ... | @@ -2638,6 +2953,8 @@ describe("PreviewManager", () => { |
| 2638 | 2953 | isDevToolsOpened: () => false, |
| 2639 | 2954 | getZoomFactor: () => 1, |
| 2640 | 2955 | setZoomFactor: vi.fn(), |
| 2956 | setAudioMuted: vi.fn(), | |
| 2957 | isCurrentlyAudible: () => false, | |
| 2641 | 2958 | on: vi.fn(), |
| 2642 | 2959 | off: vi.fn(), |
| 2643 | 2960 | ipc: { |
| ... | ... | @@ -2705,6 +3022,8 @@ describe("PreviewManager", () => { |
| 2705 | 3022 | isDevToolsOpened: () => false, |
| 2706 | 3023 | getZoomFactor: () => 1, |
| 2707 | 3024 | setZoomFactor: vi.fn(), |
| 3025 | setAudioMuted: vi.fn(), | |
| 3026 | isCurrentlyAudible: () => false, | |
| 2708 | 3027 | on: vi.fn(), |
| 2709 | 3028 | off: vi.fn(), |
| 2710 | 3029 | ipc: { on: vi.fn(), off: vi.fn() }, |
apps/desktop/src/preview/Manager.ts+149-5| ... | ... | @@ -88,6 +88,10 @@ export interface PreviewTabState { |
| 88 | 88 | zoomFactor: number; |
| 89 | 89 | pictureInPicture: boolean; |
| 90 | 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 | 95 | controller: "human" | "agent" | "none"; |
| 92 | 96 | favicon?: DesktopPreviewFavicon; |
| 93 | 97 | updatedAt: string; |
| ... | ... | @@ -661,7 +665,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 661 | 665 | }), |
| 662 | 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 | }); |
| 666 | 674 | |
| 667 | 675 | /** |
| ... | ... | @@ -680,6 +688,62 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 680 | 688 | ).pipe(Effect.ignore); |
| 681 | 689 | }); |
| 682 | 690 | |
| 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 | 747 | const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* ( |
| 684 | 748 | tabId: string, |
| 685 | 749 | ) { |
| ... | ... | @@ -1389,6 +1453,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1389 | 1453 | ) => { |
| 1390 | 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 | 1459 | const publishFavicon = Effect.fn("PreviewManager.publishFavicon")(function* (input: { |
| 1393 | 1460 | readonly captureDocumentId: number; |
| 1394 | 1461 | readonly dataUrl: string; |
| ... | ... | @@ -1583,6 +1650,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1583 | 1650 | wc.off("did-start-loading", sync); |
| 1584 | 1651 | wc.off("did-stop-loading", sync); |
| 1585 | 1652 | wc.off("did-fail-load", failed as never); |
| 1653 | wc.off("audio-state-changed", audioStateChanged); | |
| 1586 | 1654 | wc.off("before-input-event", beforeInput); |
| 1587 | 1655 | wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); |
| 1588 | 1656 | wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); |
| ... | ... | @@ -1598,6 +1666,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1598 | 1666 | wc.on("did-start-loading", sync); |
| 1599 | 1667 | wc.on("did-stop-loading", sync); |
| 1600 | 1668 | wc.on("did-fail-load", failed as never); |
| 1669 | wc.on("audio-state-changed", audioStateChanged); | |
| 1601 | 1670 | wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); |
| 1602 | 1671 | wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); |
| 1603 | 1672 | wc.setWindowOpenHandler(({ url }) => { |
| ... | ... | @@ -1652,6 +1721,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1652 | 1721 | zoomFactor: normalizeZoomFactor(defaults?.zoomFactor), |
| 1653 | 1722 | pictureInPicture: false, |
| 1654 | 1723 | colorScheme: defaults?.colorScheme ?? "system", |
| 1724 | audioMuted: false, | |
| 1725 | audible: false, | |
| 1655 | 1726 | controller: "none", |
| 1656 | 1727 | updatedAt, |
| 1657 | 1728 | }; |
| ... | ... | @@ -1718,6 +1789,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1718 | 1789 | zoomFactor: DEFAULT_ZOOM_FACTOR, |
| 1719 | 1790 | pictureInPicture: false, |
| 1720 | 1791 | colorScheme: "system", |
| 1792 | audioMuted: false, | |
| 1793 | audible: false, | |
| 1721 | 1794 | controller: "none", |
| 1722 | 1795 | updatedAt, |
| 1723 | 1796 | }; |
| ... | ... | @@ -1808,7 +1881,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1808 | 1881 | yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => |
| 1809 | 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 | 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 | 1896 | const registeredAt = yield* currentIso; |
| 1813 | 1897 | const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) => |
| 1814 | 1898 | Effect.gen(function* () { |
| ... | ... | @@ -1831,6 +1915,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1831 | 1915 | navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, |
| 1832 | 1916 | canGoBack: wc.navigationHistory.canGoBack(), |
| 1833 | 1917 | canGoForward: wc.navigationHistory.canGoForward(), |
| 1918 | audible: attachedAudible, | |
| 1834 | 1919 | updatedAt: registeredAt, |
| 1835 | 1920 | }; |
| 1836 | 1921 | return [ |
| ... | ... | @@ -1852,11 +1937,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1852 | 1937 | return yield* new PreviewTabNotFoundError({ tabId }); |
| 1853 | 1938 | } |
| 1854 | 1939 | 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. | |
| 1857 | 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 | 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 | 1956 | yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => |
| 1861 | 1957 | wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), |
| 1862 | 1958 | ); |
| ... | ... | @@ -1906,6 +2002,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1906 | 2002 | zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, |
| 1907 | 2003 | pictureInPicture: current?.pictureInPicture ?? false, |
| 1908 | 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 | 2011 | controller: current?.controller ?? "none", |
| 1910 | 2012 | ...(current?.favicon ? { favicon: current.favicon } : {}), |
| 1911 | 2013 | updatedAt, |
| ... | ... | @@ -1917,7 +2019,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1917 | 2019 | }), |
| 1918 | 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 | 2026 | if (pending.webContentsId == null) return; |
| 1922 | 2027 | const webContentsId = pending.webContentsId; |
| 1923 | 2028 | const wc = webContents.fromId(webContentsId); |
| ... | ... | @@ -2251,6 +2356,39 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2251 | 2356 | yield* applyColorScheme(tabId, wc, colorScheme); |
| 2252 | 2357 | }); |
| 2253 | 2358 | |
| 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 | 2392 | const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* ( |
| 2255 | 2393 | tabId: string, |
| 2256 | 2394 | ) { |
| ... | ... | @@ -3543,6 +3681,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 3543 | 3681 | revealArtifact, |
| 3544 | 3682 | saveRecording, |
| 3545 | 3683 | setAnnotationTheme, |
| 3684 | setAudioMuted, | |
| 3546 | 3685 | setColorScheme, |
| 3547 | 3686 | setMainWindow, |
| 3548 | 3687 | startRecording, |
| ... | ... | @@ -3846,6 +3985,10 @@ export class PreviewManager extends Context.Service< |
| 3846 | 3985 | tabId: string, |
| 3847 | 3986 | colorScheme: DesktopPreviewColorScheme, |
| 3848 | 3987 | ) => Effect.Effect<void, PreviewManagerError>; |
| 3988 | readonly setAudioMuted: ( | |
| 3989 | tabId: string, | |
| 3990 | audioMuted: boolean, | |
| 3991 | ) => Effect.Effect<void, PreviewManagerError>; | |
| 3849 | 3992 | readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 3850 | 3993 | readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>; |
| 3851 | 3994 | readonly clearCache: () => Effect.Effect<void, PreviewManagerError>; |
| ... | ... | @@ -3944,6 +4087,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 3944 | 4087 | reapplyZoom: operations.reapplyZoom, |
| 3945 | 4088 | hardReload: operations.hardReload, |
| 3946 | 4089 | setColorScheme: operations.setColorScheme, |
| 4090 | setAudioMuted: operations.setAudioMuted, | |
| 3947 | 4091 | openDevTools: operations.openDevTools, |
| 3948 | 4092 | clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { |
| 3949 | 4093 | yield* browserSession |
apps/web/src/components/ChatView.tsx+11| ... | ... | @@ -136,6 +136,7 @@ import { |
| 136 | 136 | setActivePreviewTab, |
| 137 | 137 | useThreadPreviewState, |
| 138 | 138 | } from "../previewStateStore"; |
| 139 | import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; | |
| 139 | 140 | import { addBrowserSurface } from "./preview/addBrowserSurface"; |
| 140 | 141 | import { closePreviewSession } from "./preview/closePreviewSession"; |
| 141 | 142 | import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; |
| ... | ... | @@ -1636,6 +1637,14 @@ function ChatViewContent(props: ChatViewProps) { |
| 1636 | 1637 | const activeFileSurface = |
| 1637 | 1638 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; |
| 1638 | 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 | 1648 | const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) => |
| 1640 | 1649 | selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef), |
| 1641 | 1650 | ); |
| ... | ... | @@ -6627,6 +6636,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 6627 | 6636 | pendingSurfaceIds={pendingFileSurfaceIds} |
| 6628 | 6637 | previewSessions={activePreviewState.sessions} |
| 6629 | 6638 | desktopByTabId={activePreviewState.desktopByTabId} |
| 6639 | previewRuntimeTabId={resolvePreviewRuntimeTabId} | |
| 6630 | 6640 | terminalLabelsById={activeTerminalLabelsById} |
| 6631 | 6641 | onActivate={activateRightPanelSurface} |
| 6632 | 6642 | onCloseSurface={closeRightPanelSurface} |
| ... | ... | @@ -6666,6 +6676,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 6666 | 6676 | pendingSurfaceIds={pendingFileSurfaceIds} |
| 6667 | 6677 | previewSessions={activePreviewState.sessions} |
| 6668 | 6678 | desktopByTabId={activePreviewState.desktopByTabId} |
| 6679 | previewRuntimeTabId={resolvePreviewRuntimeTabId} | |
| 6669 | 6680 | terminalLabelsById={activeTerminalLabelsById} |
| 6670 | 6681 | onActivate={activateRightPanelSurface} |
| 6671 | 6682 | onCloseSurface={closeRightPanelSurface} |
apps/web/src/components/RightPanelTabs.test.tsx+85-4| ... | ... | @@ -2,7 +2,7 @@ import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/con |
| 2 | 2 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | |
| 5 | import { RightPanelTabs } from "./RightPanelTabs"; | |
| 5 | import { RightPanelTabs, tabMuteMenuItem } from "./RightPanelTabs"; | |
| 6 | 6 | |
| 7 | 7 | const previewSurface = { |
| 8 | 8 | id: "browser:tab-1" as const, |
| ... | ... | @@ -39,7 +39,10 @@ const favicon = (dataUrl: string, pageUrl: string): DesktopPreviewFavicon => ({ |
| 39 | 39 | capturedAt: 1, |
| 40 | 40 | }); |
| 41 | 41 | |
| 42 | function overlay(icon: DesktopPreviewFavicon | null) { | |
| 42 | function overlay( | |
| 43 | icon: DesktopPreviewFavicon | null, | |
| 44 | audio?: { audible?: boolean; audioMuted?: boolean }, | |
| 45 | ) { | |
| 43 | 46 | return { |
| 44 | 47 | hasWebContents: true, |
| 45 | 48 | canGoBack: false, |
| ... | ... | @@ -48,12 +51,19 @@ function overlay(icon: DesktopPreviewFavicon | null) { |
| 48 | 51 | zoomFactor: 1, |
| 49 | 52 | pictureInPicture: false, |
| 50 | 53 | colorScheme: "system" as const, |
| 54 | audioMuted: audio?.audioMuted ?? false, | |
| 55 | audible: audio?.audible ?? false, | |
| 51 | 56 | controller: "none" as const, |
| 52 | 57 | favicon: icon, |
| 53 | 58 | }; |
| 54 | 59 | } |
| 55 | 60 | |
| 56 | function renderTabs(first: DesktopPreviewFavicon | null, second?: DesktopPreviewFavicon) { | |
| 61 | function 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 | 67 | return renderToStaticMarkup( |
| 58 | 68 | <RightPanelTabs |
| 59 | 69 | mode="inline" |
| ... | ... | @@ -62,9 +72,10 @@ function renderTabs(first: DesktopPreviewFavicon | null, second?: DesktopPreview |
| 62 | 72 | pendingSurfaceIds={new Set()} |
| 63 | 73 | previewSessions={sessions} |
| 64 | 74 | desktopByTabId={{ |
| 65 | "tab-1": overlay(first), | |
| 75 | "tab-1": overlay(first, audio), | |
| 66 | 76 | ...(second ? { "tab-2": overlay(second) } : {}), |
| 67 | 77 | }} |
| 78 | {...(previewRuntimeTabId ? { previewRuntimeTabId } : {})} | |
| 68 | 79 | terminalLabelsById={new Map()} |
| 69 | 80 | onActivate={() => undefined} |
| 70 | 81 | onCloseSurface={() => undefined} |
| ... | ... | @@ -113,3 +124,73 @@ describe("RightPanelTabs preview favicon", () => { |
| 113 | 124 | expect(html).not.toContain("data:image/png;base64,AAAA"); |
| 114 | 125 | }); |
| 115 | 126 | }); |
| 127 | ||
| 128 | describe("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 | ||
| 166 | describe("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 | 8 | Globe2, |
| 9 | 9 | Plus, |
| 10 | 10 | TerminalSquare, |
| 11 | Volume2, | |
| 12 | VolumeOff, | |
| 11 | 13 | X, |
| 12 | 14 | } from "lucide-react"; |
| 13 | 15 | import { |
| ... | ... | @@ -37,6 +39,7 @@ import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; |
| 37 | 39 | |
| 38 | 40 | import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; |
| 39 | 41 | import { FaviconImage } from "./preview/PreviewFaviconIcon"; |
| 42 | import { previewBridge } from "./preview/previewBridge"; | |
| 40 | 43 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; |
| 41 | 44 | |
| 42 | 45 | interface RightPanelTabsProps { |
| ... | ... | @@ -52,6 +55,12 @@ interface RightPanelTabsProps { |
| 52 | 55 | pendingSurfaceIds: ReadonlySet<string>; |
| 53 | 56 | previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>; |
| 54 | 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 | 64 | terminalLabelsById: ReadonlyMap<string, string>; |
| 56 | 65 | onActivate: (surface: RightPanelSurface) => void; |
| 57 | 66 | onCloseSurface: (surface: RightPanelSurface) => void; |
| ... | ... | @@ -116,7 +125,53 @@ const SURFACE_UNAVAILABLE_HINTS = { |
| 116 | 125 | agents: "Available from a thread.", |
| 117 | 126 | } as const; |
| 118 | 127 | |
| 119 | type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all"; | |
| 128 | type 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 | */ | |
| 140 | function 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 | */ | |
| 154 | export 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 | ||
| 165 | type 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 | */ | |
| 171 | function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState { | |
| 172 | if (!overlay?.audible) return "none"; | |
| 173 | return overlay.audioMuted ? "muted" : "audible"; | |
| 174 | } | |
| 120 | 175 | |
| 121 | 176 | function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { |
| 122 | 177 | return ( |
| ... | ... | @@ -534,6 +589,25 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 534 | 589 | if (surface.kind === "file") { |
| 535 | 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 | 611 | items.push( |
| 538 | 612 | { id: "close", label: "Close" }, |
| 539 | 613 | { |
| ... | ... | @@ -558,6 +632,18 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 558 | 632 | case "copy-path": |
| 559 | 633 | if (surface.kind === "file") props.onCopyFilePath(surface.relativePath); |
| 560 | 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 | 647 | case "close": |
| 562 | 648 | props.onCloseSurface(surface); |
| 563 | 649 | break; |
| ... | ... | @@ -626,6 +712,15 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 626 | 712 | const active = surface.id === props.activeSurfaceId; |
| 627 | 713 | const pending = props.pendingSurfaceIds.has(surface.id); |
| 628 | 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 | 724 | return ( |
| 630 | 725 | <div |
| 631 | 726 | key={surface.id} |
| ... | ... | @@ -663,6 +758,34 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 663 | 758 | </span> |
| 664 | 759 | <X className="hidden size-3 group-hover/tab:block group-focus-visible/close:block" /> |
| 665 | 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 | 789 | <Tooltip> |
| 667 | 790 | <TooltipTrigger |
| 668 | 791 | render={ |
apps/web/src/components/preview/PreviewView.test.tsx+2| ... | ... | @@ -107,6 +107,8 @@ vi.mock("~/previewStateStore", () => ({ |
| 107 | 107 | zoomFactor: 1, |
| 108 | 108 | pictureInPicture: mocks.pictureInPicture, |
| 109 | 109 | colorScheme: "system", |
| 110 | audioMuted: false, | |
| 111 | audible: false, | |
| 110 | 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 | 19 | zoomFactor: 1, |
| 20 | 20 | pictureInPicture: false, |
| 21 | 21 | colorScheme: "system", |
| 22 | audioMuted: false, | |
| 23 | audible: false, | |
| 22 | 24 | controller: "none", |
| 23 | 25 | favicon, |
| 24 | 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 | 121 | zoomFactor: state.zoomFactor, |
| 122 | 122 | pictureInPicture: state.pictureInPicture, |
| 123 | 123 | colorScheme: state.colorScheme, |
| 124 | audioMuted: state.audioMuted, | |
| 125 | audible: state.audible, | |
| 124 | 126 | controller: state.controller, |
| 125 | 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 | 321 | zoomFactor: 1, |
| 322 | 322 | pictureInPicture: false, |
| 323 | 323 | colorScheme: "system", |
| 324 | audioMuted: false, | |
| 325 | audible: false, | |
| 324 | 326 | controller: "none", |
| 325 | 327 | favicon: null, |
| 326 | 328 | }); |
| ... | ... | @@ -342,6 +344,8 @@ describe("previewStateStore (single-tab)", () => { |
| 342 | 344 | zoomFactor: 1, |
| 343 | 345 | pictureInPicture: false, |
| 344 | 346 | colorScheme: "system", |
| 347 | audioMuted: false, | |
| 348 | audible: false, | |
| 345 | 349 | controller: "none", |
| 346 | 350 | favicon: null, |
| 347 | 351 | }); |
| ... | ... | @@ -391,6 +395,8 @@ describe("previewStateStore (single-tab)", () => { |
| 391 | 395 | zoomFactor: 1, |
| 392 | 396 | pictureInPicture: false, |
| 393 | 397 | colorScheme: "system", |
| 398 | audioMuted: false, | |
| 399 | audible: false, | |
| 394 | 400 | controller: "none", |
| 395 | 401 | favicon: null, |
| 396 | 402 | }); |
| ... | ... | @@ -506,6 +512,8 @@ describe("previewStateStore (single-tab)", () => { |
| 506 | 512 | zoomFactor: 1, |
| 507 | 513 | pictureInPicture: false, |
| 508 | 514 | colorScheme: "system", |
| 515 | audioMuted: false, | |
| 516 | audible: false, | |
| 509 | 517 | controller: "none", |
| 510 | 518 | favicon: null, |
| 511 | 519 | }); |
apps/web/src/previewStateStore.ts+2| ... | ... | @@ -28,6 +28,8 @@ export interface DesktopPreviewOverlay { |
| 28 | 28 | zoomFactor: number; |
| 29 | 29 | pictureInPicture: boolean; |
| 30 | 30 | colorScheme: DesktopPreviewColorScheme; |
| 31 | audioMuted: boolean; | |
| 32 | audible: boolean; | |
| 31 | 33 | controller: "human" | "agent" | "none"; |
| 32 | 34 | favicon: DesktopPreviewFavicon | null; |
| 33 | 35 | } |
packages/contracts/src/ipc.ts+26| ... | ... | @@ -558,6 +558,19 @@ export interface DesktopPreviewTabState { |
| 558 | 558 | /** Whether this tab is currently mirrored into a desktop picture-in-picture window. */ |
| 559 | 559 | pictureInPicture: boolean; |
| 560 | 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 | 574 | controller: "human" | "agent" | "none"; |
| 562 | 575 | favicon?: DesktopPreviewFavicon; |
| 563 | 576 | updatedAt: string; |
| ... | ... | @@ -597,6 +610,8 @@ export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState> |
| 597 | 610 | zoomFactor: Schema.Number, |
| 598 | 611 | pictureInPicture: Schema.Boolean, |
| 599 | 612 | colorScheme: DesktopPreviewColorSchemeSchema, |
| 613 | audioMuted: Schema.Boolean, | |
| 614 | audible: Schema.Boolean, | |
| 600 | 615 | controller: Schema.Literals(["human", "agent", "none"]), |
| 601 | 616 | favicon: Schema.optionalKey(DesktopPreviewFaviconSchema), |
| 602 | 617 | updatedAt: Schema.String, |
| ... | ... | @@ -993,6 +1008,11 @@ export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({ |
| 993 | 1008 | colorScheme: DesktopPreviewColorSchemeSchema, |
| 994 | 1009 | }); |
| 995 | 1010 | |
| 1011 | export const DesktopPreviewSetAudioMutedInputSchema = Schema.Struct({ | |
| 1012 | tabId: DesktopPreviewTabIdSchema, | |
| 1013 | audioMuted: Schema.Boolean, | |
| 1014 | }); | |
| 1015 | ||
| 996 | 1016 | export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({ |
| 997 | 1017 | theme: DesktopPreviewAnnotationThemeSchema, |
| 998 | 1018 | }); |
| ... | ... | @@ -1144,6 +1164,12 @@ export interface DesktopPreviewBridge { |
| 1144 | 1164 | * override). Persists per tab and is re-applied across webview swaps. |
| 1145 | 1165 | */ |
| 1146 | 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 | 1173 | /** Open the guest webview's DevTools (detached). */ |
| 1148 | 1174 | openDevTools: (tabId: string) => Promise<void>; |
| 1149 | 1175 | /** Drop cookies + storage data for the preview partition (all tabs). */ |