| 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,6 +54,7 @@ export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out"; |
| 54 | export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom"; | 54 | export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom"; |
| 55 | export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; | 55 | export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; |
| 56 | export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; | 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 | export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; | 58 | export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; |
| 58 | export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; | 59 | export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; |
| 59 | export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; | 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,6 +13,7 @@ import { |
| 13 | DesktopPreviewRecordingSaveInputSchema, | 13 | DesktopPreviewRecordingSaveInputSchema, |
| 14 | DesktopPreviewRegisterWebviewInputSchema, | 14 | DesktopPreviewRegisterWebviewInputSchema, |
| 15 | DesktopPreviewScreenshotArtifactSchema, | 15 | DesktopPreviewScreenshotArtifactSchema, |
| 16 | DesktopPreviewSetAudioMutedInputSchema, | ||
| 16 | DesktopPreviewSetColorSchemeInputSchema, | 17 | DesktopPreviewSetColorSchemeInputSchema, |
| 17 | DesktopPreviewCreateTabInputSchema, | 18 | DesktopPreviewCreateTabInputSchema, |
| 18 | DesktopPreviewTabInputSchema, | 19 | DesktopPreviewTabInputSchema, |
| ... | @@ -153,6 +154,15 @@ export const setColorScheme = DesktopIpc.makeIpcMethod({ | ... | @@ -153,6 +154,15 @@ export const setColorScheme = DesktopIpc.makeIpcMethod({ |
| 153 | yield* manager.setColorScheme(tabId, colorScheme); | 154 | yield* manager.setColorScheme(tabId, colorScheme); |
| 154 | }), | 155 | }), |
| 155 | }); | 156 | }); |
| 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 | export const openDevTools = tabMethod( | 166 | export const openDevTools = tabMethod( |
| 157 | IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, | 167 | IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, |
| 158 | "desktop.ipc.preview.openDevTools", | 168 | "desktop.ipc.preview.openDevTools", |
| ... | @@ -372,6 +382,7 @@ export const methods = [ | ... | @@ -372,6 +382,7 @@ export const methods = [ |
| 372 | resetZoom, | 382 | resetZoom, |
| 373 | hardReload, | 383 | hardReload, |
| 374 | setColorScheme, | 384 | setColorScheme, |
| 385 | setAudioMuted, | ||
| 375 | openDevTools, | 386 | openDevTools, |
| 376 | clearCookies, | 387 | clearCookies, |
| 377 | clearCache, | 388 | clearCache, |
apps/desktop/src/preload.ts+2| ... | @@ -183,6 +183,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -183,6 +183,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 183 | hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }), | 183 | hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }), |
| 184 | setColorScheme: (tabId, colorScheme) => | 184 | setColorScheme: (tabId, colorScheme) => |
| 185 | ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), | 185 | ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), |
| 186 | setAudioMuted: (tabId, audioMuted) => | ||
| 187 | ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), | ||
| 186 | openDevTools: (tabId) => | 188 | openDevTools: (tabId) => |
| 187 | ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), | 189 | ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), |
| 188 | clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), | 190 | clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), |
apps/desktop/src/preview/Manager.test.ts+319| ... | @@ -170,6 +170,8 @@ const makeTestPreviewWebContents = ( | ... | @@ -170,6 +170,8 @@ const makeTestPreviewWebContents = ( |
| 170 | isLoading: () => false, | 170 | isLoading: () => false, |
| 171 | getZoomFactor: () => 1, | 171 | getZoomFactor: () => 1, |
| 172 | setZoomFactor: vi.fn(), | 172 | setZoomFactor: vi.fn(), |
| 173 | setAudioMuted: vi.fn(), | ||
| 174 | isCurrentlyAudible: () => false, | ||
| 173 | on: vi.fn(), | 175 | on: vi.fn(), |
| 174 | off: vi.fn(), | 176 | off: vi.fn(), |
| 175 | ipc: { on: vi.fn(), off: vi.fn() }, | 177 | ipc: { on: vi.fn(), off: vi.fn() }, |
| ... | @@ -234,6 +236,8 @@ const makeFaviconWebContents = (options?: { | ... | @@ -234,6 +236,8 @@ const makeFaviconWebContents = (options?: { |
| 234 | isDevToolsOpened: () => false, | 236 | isDevToolsOpened: () => false, |
| 235 | getZoomFactor: () => 1, | 237 | getZoomFactor: () => 1, |
| 236 | setZoomFactor: vi.fn(), | 238 | setZoomFactor: vi.fn(), |
| 239 | setAudioMuted: vi.fn(), | ||
| 240 | isCurrentlyAudible: () => false, | ||
| 237 | reload, | 241 | reload, |
| 238 | reloadIgnoringCache: vi.fn(), | 242 | reloadIgnoringCache: vi.fn(), |
| 239 | loadURL, | 243 | loadURL, |
| ... | @@ -459,6 +463,8 @@ describe("PreviewManager", () => { | ... | @@ -459,6 +463,8 @@ describe("PreviewManager", () => { |
| 459 | isLoading: () => false, | 463 | isLoading: () => false, |
| 460 | getZoomFactor: () => 1, | 464 | getZoomFactor: () => 1, |
| 461 | setZoomFactor: vi.fn(), | 465 | setZoomFactor: vi.fn(), |
| 466 | setAudioMuted: vi.fn(), | ||
| 467 | isCurrentlyAudible: () => false, | ||
| 462 | loadURL, | 468 | loadURL, |
| 463 | on: vi.fn((event: string, listener: (...args: never[]) => void) => { | 469 | on: vi.fn((event: string, listener: (...args: never[]) => void) => { |
| 464 | listeners.set(event, listener); | 470 | listeners.set(event, listener); |
| ... | @@ -1002,6 +1008,8 @@ describe("PreviewManager", () => { | ... | @@ -1002,6 +1008,8 @@ describe("PreviewManager", () => { |
| 1002 | return effectiveZoom; | 1008 | return effectiveZoom; |
| 1003 | }, | 1009 | }, |
| 1004 | setZoomFactor, | 1010 | setZoomFactor, |
| 1011 | setAudioMuted: vi.fn(), | ||
| 1012 | isCurrentlyAudible: () => false, | ||
| 1005 | on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { | 1013 | on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { |
| 1006 | listeners.set(event, listener); | 1014 | listeners.set(event, listener); |
| 1007 | }), | 1015 | }), |
| ... | @@ -1066,6 +1074,8 @@ describe("PreviewManager", () => { | ... | @@ -1066,6 +1074,8 @@ describe("PreviewManager", () => { |
| 1066 | isLoading: () => false, | 1074 | isLoading: () => false, |
| 1067 | getZoomFactor: () => 1, | 1075 | getZoomFactor: () => 1, |
| 1068 | setZoomFactor: replacementSetZoomFactor, | 1076 | setZoomFactor: replacementSetZoomFactor, |
| 1077 | setAudioMuted: vi.fn(), | ||
| 1078 | isCurrentlyAudible: () => false, | ||
| 1069 | on: vi.fn(), | 1079 | on: vi.fn(), |
| 1070 | off: vi.fn(), | 1080 | off: vi.fn(), |
| 1071 | ipc: { on: vi.fn(), off: vi.fn() }, | 1081 | ipc: { on: vi.fn(), off: vi.fn() }, |
| ... | @@ -1104,6 +1114,8 @@ describe("PreviewManager", () => { | ... | @@ -1104,6 +1114,8 @@ describe("PreviewManager", () => { |
| 1104 | isLoading: () => false, | 1114 | isLoading: () => false, |
| 1105 | getZoomFactor: () => 1, | 1115 | getZoomFactor: () => 1, |
| 1106 | setZoomFactor, | 1116 | setZoomFactor, |
| 1117 | setAudioMuted: vi.fn(), | ||
| 1118 | isCurrentlyAudible: () => false, | ||
| 1107 | on: vi.fn(), | 1119 | on: vi.fn(), |
| 1108 | off: vi.fn(), | 1120 | off: vi.fn(), |
| 1109 | ipc: { on: vi.fn(), off: vi.fn() }, | 1121 | ipc: { on: vi.fn(), off: vi.fn() }, |
| ... | @@ -1148,6 +1160,8 @@ describe("PreviewManager", () => { | ... | @@ -1148,6 +1160,8 @@ describe("PreviewManager", () => { |
| 1148 | isLoading: () => false, | 1160 | isLoading: () => false, |
| 1149 | getZoomFactor: () => 1, | 1161 | getZoomFactor: () => 1, |
| 1150 | setZoomFactor, | 1162 | setZoomFactor, |
| 1163 | setAudioMuted: vi.fn(), | ||
| 1164 | isCurrentlyAudible: () => false, | ||
| 1151 | on: vi.fn(), | 1165 | on: vi.fn(), |
| 1152 | off: vi.fn(), | 1166 | off: vi.fn(), |
| 1153 | ipc: { on: vi.fn(), off: vi.fn() }, | 1167 | ipc: { on: vi.fn(), off: vi.fn() }, |
| ... | @@ -1201,6 +1215,8 @@ describe("PreviewManager", () => { | ... | @@ -1201,6 +1215,8 @@ describe("PreviewManager", () => { |
| 1201 | isLoading: () => false, | 1215 | isLoading: () => false, |
| 1202 | getZoomFactor: () => 1, | 1216 | getZoomFactor: () => 1, |
| 1203 | setZoomFactor: vi.fn(), | 1217 | setZoomFactor: vi.fn(), |
| 1218 | setAudioMuted: vi.fn(), | ||
| 1219 | isCurrentlyAudible: () => false, | ||
| 1204 | on: vi.fn(), | 1220 | on: vi.fn(), |
| 1205 | off: vi.fn(), | 1221 | off: vi.fn(), |
| 1206 | ipc: { on: vi.fn(), off: vi.fn() }, | 1222 | ipc: { on: vi.fn(), off: vi.fn() }, |
| ... | @@ -1257,6 +1273,287 @@ describe("PreviewManager", () => { | ... | @@ -1257,6 +1273,287 @@ describe("PreviewManager", () => { |
| 1257 | ), | 1273 | ), |
| 1258 | ); | 1274 | ); |
| 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 | effectIt.effect("blocks late webview and capture starts during tab close", () => | 1557 | effectIt.effect("blocks late webview and capture starts during tab close", () => |
| 1261 | withManager((manager) => | 1558 | withManager((manager) => |
| 1262 | Effect.gen(function* () { | 1559 | Effect.gen(function* () { |
| ... | @@ -1346,6 +1643,8 @@ describe("PreviewManager", () => { | ... | @@ -1346,6 +1643,8 @@ describe("PreviewManager", () => { |
| 1346 | isLoading: () => loading, | 1643 | isLoading: () => loading, |
| 1347 | getZoomFactor: () => 1, | 1644 | getZoomFactor: () => 1, |
| 1348 | setZoomFactor: vi.fn(), | 1645 | setZoomFactor: vi.fn(), |
| 1646 | setAudioMuted: vi.fn(), | ||
| 1647 | isCurrentlyAudible: () => false, | ||
| 1349 | on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { | 1648 | on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { |
| 1350 | listeners.set(event, listener); | 1649 | listeners.set(event, listener); |
| 1351 | }), | 1650 | }), |
| ... | @@ -1436,6 +1735,8 @@ describe("PreviewManager", () => { | ... | @@ -1436,6 +1735,8 @@ describe("PreviewManager", () => { |
| 1436 | isLoading: () => false, | 1735 | isLoading: () => false, |
| 1437 | getZoomFactor: () => 1, | 1736 | getZoomFactor: () => 1, |
| 1438 | setZoomFactor: vi.fn(), | 1737 | setZoomFactor: vi.fn(), |
| 1738 | setAudioMuted: vi.fn(), | ||
| 1739 | isCurrentlyAudible: () => false, | ||
| 1439 | on: vi.fn((event: string, listener: (...args: never[]) => void) => { | 1740 | on: vi.fn((event: string, listener: (...args: never[]) => void) => { |
| 1440 | listeners.set(event, listener); | 1741 | listeners.set(event, listener); |
| 1441 | }), | 1742 | }), |
| ... | @@ -1525,6 +1826,8 @@ describe("PreviewManager", () => { | ... | @@ -1525,6 +1826,8 @@ describe("PreviewManager", () => { |
| 1525 | isLoading: () => false, | 1826 | isLoading: () => false, |
| 1526 | getZoomFactor: () => 1, | 1827 | getZoomFactor: () => 1, |
| 1527 | setZoomFactor: vi.fn(), | 1828 | setZoomFactor: vi.fn(), |
| 1829 | setAudioMuted: vi.fn(), | ||
| 1830 | isCurrentlyAudible: () => false, | ||
| 1528 | on: vi.fn(), | 1831 | on: vi.fn(), |
| 1529 | off: vi.fn(), | 1832 | off: vi.fn(), |
| 1530 | ipc: { on: vi.fn(), off: vi.fn() }, | 1833 | ipc: { on: vi.fn(), off: vi.fn() }, |
| ... | @@ -1735,6 +2038,8 @@ describe("PreviewManager", () => { | ... | @@ -1735,6 +2038,8 @@ describe("PreviewManager", () => { |
| 1735 | isDevToolsOpened: () => false, | 2038 | isDevToolsOpened: () => false, |
| 1736 | getZoomFactor: () => 1, | 2039 | getZoomFactor: () => 1, |
| 1737 | setZoomFactor: vi.fn(), | 2040 | setZoomFactor: vi.fn(), |
| 2041 | setAudioMuted: vi.fn(), | ||
| 2042 | isCurrentlyAudible: () => false, | ||
| 1738 | on: vi.fn(), | 2043 | on: vi.fn(), |
| 1739 | off: vi.fn(), | 2044 | off: vi.fn(), |
| 1740 | ipc: { on: vi.fn(), off: vi.fn() }, | 2045 | ipc: { on: vi.fn(), off: vi.fn() }, |
| ... | @@ -1815,6 +2120,8 @@ describe("PreviewManager", () => { | ... | @@ -1815,6 +2120,8 @@ describe("PreviewManager", () => { |
| 1815 | isLoading: () => false, | 2120 | isLoading: () => false, |
| 1816 | getZoomFactor: () => 1, | 2121 | getZoomFactor: () => 1, |
| 1817 | setZoomFactor: vi.fn(), | 2122 | setZoomFactor: vi.fn(), |
| 2123 | setAudioMuted: vi.fn(), | ||
| 2124 | isCurrentlyAudible: () => false, | ||
| 1818 | on: vi.fn(), | 2125 | on: vi.fn(), |
| 1819 | off: vi.fn(), | 2126 | off: vi.fn(), |
| 1820 | ipc: { on: vi.fn(), off: vi.fn() }, | 2127 | ipc: { on: vi.fn(), off: vi.fn() }, |
| ... | @@ -2204,6 +2511,8 @@ describe("PreviewManager", () => { | ... | @@ -2204,6 +2511,8 @@ describe("PreviewManager", () => { |
| 2204 | isFocused: () => true, | 2511 | isFocused: () => true, |
| 2205 | getZoomFactor: () => 1, | 2512 | getZoomFactor: () => 1, |
| 2206 | setZoomFactor: vi.fn(), | 2513 | setZoomFactor: vi.fn(), |
| 2514 | setAudioMuted: vi.fn(), | ||
| 2515 | isCurrentlyAudible: () => false, | ||
| 2207 | on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { | 2516 | on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { |
| 2208 | listeners.set(event, listener); | 2517 | listeners.set(event, listener); |
| 2209 | }), | 2518 | }), |
| ... | @@ -2255,6 +2564,8 @@ describe("PreviewManager", () => { | ... | @@ -2255,6 +2564,8 @@ describe("PreviewManager", () => { |
| 2255 | isLoading: () => false, | 2564 | isLoading: () => false, |
| 2256 | getZoomFactor: () => 1, | 2565 | getZoomFactor: () => 1, |
| 2257 | setZoomFactor: vi.fn(), | 2566 | setZoomFactor: vi.fn(), |
| 2567 | setAudioMuted: vi.fn(), | ||
| 2568 | isCurrentlyAudible: () => false, | ||
| 2258 | on: vi.fn(), | 2569 | on: vi.fn(), |
| 2259 | off: vi.fn(), | 2570 | off: vi.fn(), |
| 2260 | ipc: { | 2571 | ipc: { |
| ... | @@ -2387,6 +2698,8 @@ describe("PreviewManager", () => { | ... | @@ -2387,6 +2698,8 @@ describe("PreviewManager", () => { |
| 2387 | isDevToolsOpened: () => false, | 2698 | isDevToolsOpened: () => false, |
| 2388 | getZoomFactor: () => 1, | 2699 | getZoomFactor: () => 1, |
| 2389 | setZoomFactor: vi.fn(), | 2700 | setZoomFactor: vi.fn(), |
| 2701 | setAudioMuted: vi.fn(), | ||
| 2702 | isCurrentlyAudible: () => false, | ||
| 2390 | on: vi.fn(), | 2703 | on: vi.fn(), |
| 2391 | off: vi.fn(), | 2704 | off: vi.fn(), |
| 2392 | ipc: { | 2705 | ipc: { |
| ... | @@ -2485,6 +2798,8 @@ describe("PreviewManager", () => { | ... | @@ -2485,6 +2798,8 @@ describe("PreviewManager", () => { |
| 2485 | focus, | 2798 | focus, |
| 2486 | getZoomFactor: () => 1, | 2799 | getZoomFactor: () => 1, |
| 2487 | setZoomFactor: vi.fn(), | 2800 | setZoomFactor: vi.fn(), |
| 2801 | setAudioMuted: vi.fn(), | ||
| 2802 | isCurrentlyAudible: () => false, | ||
| 2488 | on: vi.fn(), | 2803 | on: vi.fn(), |
| 2489 | off: vi.fn(), | 2804 | off: vi.fn(), |
| 2490 | ipc: { | 2805 | ipc: { |
| ... | @@ -2638,6 +2953,8 @@ describe("PreviewManager", () => { | ... | @@ -2638,6 +2953,8 @@ describe("PreviewManager", () => { |
| 2638 | isDevToolsOpened: () => false, | 2953 | isDevToolsOpened: () => false, |
| 2639 | getZoomFactor: () => 1, | 2954 | getZoomFactor: () => 1, |
| 2640 | setZoomFactor: vi.fn(), | 2955 | setZoomFactor: vi.fn(), |
| 2956 | setAudioMuted: vi.fn(), | ||
| 2957 | isCurrentlyAudible: () => false, | ||
| 2641 | on: vi.fn(), | 2958 | on: vi.fn(), |
| 2642 | off: vi.fn(), | 2959 | off: vi.fn(), |
| 2643 | ipc: { | 2960 | ipc: { |
| ... | @@ -2705,6 +3022,8 @@ describe("PreviewManager", () => { | ... | @@ -2705,6 +3022,8 @@ describe("PreviewManager", () => { |
| 2705 | isDevToolsOpened: () => false, | 3022 | isDevToolsOpened: () => false, |
| 2706 | getZoomFactor: () => 1, | 3023 | getZoomFactor: () => 1, |
| 2707 | setZoomFactor: vi.fn(), | 3024 | setZoomFactor: vi.fn(), |
| 3025 | setAudioMuted: vi.fn(), | ||
| 3026 | isCurrentlyAudible: () => false, | ||
| 2708 | on: vi.fn(), | 3027 | on: vi.fn(), |
| 2709 | off: vi.fn(), | 3028 | off: vi.fn(), |
| 2710 | ipc: { on: vi.fn(), off: vi.fn() }, | 3029 | ipc: { on: vi.fn(), off: vi.fn() }, |
apps/desktop/src/preview/Manager.ts+149-5| ... | @@ -88,6 +88,10 @@ export interface PreviewTabState { | ... | @@ -88,6 +88,10 @@ export interface PreviewTabState { |
| 88 | zoomFactor: number; | 88 | zoomFactor: number; |
| 89 | pictureInPicture: boolean; | 89 | pictureInPicture: boolean; |
| 90 | colorScheme: DesktopPreviewColorScheme; | 90 | colorScheme: DesktopPreviewColorScheme; |
| 91 | /** User intent to silence this tab. Re-applied to each guest that attaches. */ | ||
| 92 | audioMuted: boolean; | ||
| 93 | /** Observed from Chromium. Stays true while a muted tab keeps playing. */ | ||
| 94 | audible: boolean; | ||
| 91 | controller: "human" | "agent" | "none"; | 95 | controller: "human" | "agent" | "none"; |
| 92 | favicon?: DesktopPreviewFavicon; | 96 | favicon?: DesktopPreviewFavicon; |
| 93 | updatedAt: string; | 97 | updatedAt: string; |
| ... | @@ -661,7 +665,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -661,7 +665,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 661 | }), | 665 | }), |
| 662 | ] as const; | 666 | ] as const; |
| 663 | }); | 667 | }); |
| 664 | if (Option.isSome(next)) yield* emit(tabId, next.value); | 668 | // emitIfCurrent, not emit: an event-driven writer such as syncTabAudible |
| 669 | // can commit between the modify above and here, and republishing this | ||
| 670 | // snapshot would roll the UI back to a value that writer will not send | ||
| 671 | // again because it suppresses unchanged audibility. | ||
| 672 | if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value); | ||
| 665 | }); | 673 | }); |
| 666 | 674 | ||
| 667 | /** | 675 | /** |
| ... | @@ -680,6 +688,62 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -680,6 +688,62 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 680 | ).pipe(Effect.ignore); | 688 | ).pipe(Effect.ignore); |
| 681 | }); | 689 | }); |
| 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 | const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* ( | 747 | const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* ( |
| 684 | tabId: string, | 748 | tabId: string, |
| 685 | ) { | 749 | ) { |
| ... | @@ -1389,6 +1453,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1389,6 +1453,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1389 | ) => { | 1453 | ) => { |
| 1390 | if (event.isMainFrame && !event.isSameDocument) cancelFaviconCapture(); | 1454 | if (event.isMainFrame && !event.isSameDocument) cancelFaviconCapture(); |
| 1391 | }; | 1455 | }; |
| 1456 | const audioStateChanged = ( | ||
| 1457 | event: Electron.Event<Electron.WebContentsAudioStateChangedEventParams>, | ||
| 1458 | ) => runFork(syncTabAudible(tabId, wc, event.audible)); | ||
| 1392 | const publishFavicon = Effect.fn("PreviewManager.publishFavicon")(function* (input: { | 1459 | const publishFavicon = Effect.fn("PreviewManager.publishFavicon")(function* (input: { |
| 1393 | readonly captureDocumentId: number; | 1460 | readonly captureDocumentId: number; |
| 1394 | readonly dataUrl: string; | 1461 | readonly dataUrl: string; |
| ... | @@ -1583,6 +1650,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1583,6 +1650,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1583 | wc.off("did-start-loading", sync); | 1650 | wc.off("did-start-loading", sync); |
| 1584 | wc.off("did-stop-loading", sync); | 1651 | wc.off("did-stop-loading", sync); |
| 1585 | wc.off("did-fail-load", failed as never); | 1652 | wc.off("did-fail-load", failed as never); |
| 1653 | wc.off("audio-state-changed", audioStateChanged); | ||
| 1586 | wc.off("before-input-event", beforeInput); | 1654 | wc.off("before-input-event", beforeInput); |
| 1587 | wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); | 1655 | wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); |
| 1588 | wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); | 1656 | wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); |
| ... | @@ -1598,6 +1666,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1598,6 +1666,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1598 | wc.on("did-start-loading", sync); | 1666 | wc.on("did-start-loading", sync); |
| 1599 | wc.on("did-stop-loading", sync); | 1667 | wc.on("did-stop-loading", sync); |
| 1600 | wc.on("did-fail-load", failed as never); | 1668 | wc.on("did-fail-load", failed as never); |
| 1669 | wc.on("audio-state-changed", audioStateChanged); | ||
| 1601 | wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); | 1670 | wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); |
| 1602 | wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); | 1671 | wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); |
| 1603 | wc.setWindowOpenHandler(({ url }) => { | 1672 | wc.setWindowOpenHandler(({ url }) => { |
| ... | @@ -1652,6 +1721,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1652,6 +1721,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1652 | zoomFactor: normalizeZoomFactor(defaults?.zoomFactor), | 1721 | zoomFactor: normalizeZoomFactor(defaults?.zoomFactor), |
| 1653 | pictureInPicture: false, | 1722 | pictureInPicture: false, |
| 1654 | colorScheme: defaults?.colorScheme ?? "system", | 1723 | colorScheme: defaults?.colorScheme ?? "system", |
| 1724 | audioMuted: false, | ||
| 1725 | audible: false, | ||
| 1655 | controller: "none", | 1726 | controller: "none", |
| 1656 | updatedAt, | 1727 | updatedAt, |
| 1657 | }; | 1728 | }; |
| ... | @@ -1718,6 +1789,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1718,6 +1789,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1718 | zoomFactor: DEFAULT_ZOOM_FACTOR, | 1789 | zoomFactor: DEFAULT_ZOOM_FACTOR, |
| 1719 | pictureInPicture: false, | 1790 | pictureInPicture: false, |
| 1720 | colorScheme: "system", | 1791 | colorScheme: "system", |
| 1792 | audioMuted: false, | ||
| 1793 | audible: false, | ||
| 1721 | controller: "none", | 1794 | controller: "none", |
| 1722 | updatedAt, | 1795 | updatedAt, |
| 1723 | }; | 1796 | }; |
| ... | @@ -1808,7 +1881,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1808,7 +1881,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1808 | yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => | 1881 | yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => |
| 1809 | wc.setZoomFactor(currentTab.zoomFactor), | 1882 | wc.setZoomFactor(currentTab.zoomFactor), |
| 1810 | ); | 1883 | ); |
| 1884 | // A replacement guest attaches unmuted, so reassert the tab's mute before it | ||
| 1885 | // is published rather than letting it emit audio the user already silenced. | ||
| 1886 | // Settled again after attach, below, the same way zoom is. | ||
| 1887 | yield* attempt({ operation: "registerWebview.restoreAudioMuted", tabId, webContentsId }, () => | ||
| 1888 | wc.setAudioMuted(currentTab.audioMuted), | ||
| 1889 | ); | ||
| 1811 | yield* attachListeners(tabId, wc); | 1890 | yield* attachListeners(tabId, wc); |
| 1891 | const readAudible = attempt( | ||
| 1892 | { operation: "registerWebview.readAudible", tabId, webContentsId }, | ||
| 1893 | () => wc.isCurrentlyAudible(), | ||
| 1894 | ).pipe(Effect.orElseSucceed(() => false)); | ||
| 1895 | const attachedAudible = yield* readAudible; | ||
| 1812 | const registeredAt = yield* currentIso; | 1896 | const registeredAt = yield* currentIso; |
| 1813 | const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) => | 1897 | const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) => |
| 1814 | Effect.gen(function* () { | 1898 | Effect.gen(function* () { |
| ... | @@ -1831,6 +1915,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1831,6 +1915,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1831 | navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, | 1915 | navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, |
| 1832 | canGoBack: wc.navigationHistory.canGoBack(), | 1916 | canGoBack: wc.navigationHistory.canGoBack(), |
| 1833 | canGoForward: wc.navigationHistory.canGoForward(), | 1917 | canGoForward: wc.navigationHistory.canGoForward(), |
| 1918 | audible: attachedAudible, | ||
| 1834 | updatedAt: registeredAt, | 1919 | updatedAt: registeredAt, |
| 1835 | }; | 1920 | }; |
| 1836 | return [ | 1921 | return [ |
| ... | @@ -1852,11 +1937,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1852,11 +1937,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1852 | return yield* new PreviewTabNotFoundError({ tabId }); | 1937 | return yield* new PreviewTabNotFoundError({ tabId }); |
| 1853 | } | 1938 | } |
| 1854 | const { state: registered, pendingUrl } = registration.value; | 1939 | const { state: registered, pendingUrl } = registration.value; |
| 1855 | // A zoom action that landed while this attach was in flight addressed the | 1940 | // A zoom or mute action that landed while this attach was in flight |
| 1856 | // guest this one replaced, so settle the new guest on the committed factor. | 1941 | // addressed the guest this one replaced, so settle the new guest on the |
| 1942 | // committed values. | ||
| 1857 | yield* assertTabZoom(tabId); | 1943 | yield* assertTabZoom(tabId); |
| 1944 | // Best-effort here, unlike in setAudioMuted: a guest that dies mid-attach | ||
| 1945 | // must not fail the registration it was attaching for. | ||
| 1946 | yield* assertTabAudioMuted(tabId).pipe(Effect.ignore); | ||
| 1858 | runFork(restoreControlSession(tabId, wc)); | 1947 | runFork(restoreControlSession(tabId, wc)); |
| 1859 | yield* emit(tabId, registered); | 1948 | // emitIfCurrent, not emit: audio-state-changed can land between the commit |
| 1949 | // above and here, and republishing this snapshot would roll the UI back to | ||
| 1950 | // a superseded audibility that syncTabAudible will not re-send. | ||
| 1951 | yield* emitIfCurrent(tabId, registered); | ||
| 1952 | // Transitions that fired before the tab owned this guest were dropped by | ||
| 1953 | // syncTabAudible's ownership check, so re-read and reconcile through the | ||
| 1954 | // same path the event uses. | ||
| 1955 | yield* syncTabAudible(tabId, wc, yield* readAudible); | ||
| 1860 | yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => | 1956 | yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => |
| 1861 | wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), | 1957 | wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), |
| 1862 | ); | 1958 | ); |
| ... | @@ -1906,6 +2002,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1906,6 +2002,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1906 | zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, | 2002 | zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, |
| 1907 | pictureInPicture: current?.pictureInPicture ?? false, | 2003 | pictureInPicture: current?.pictureInPicture ?? false, |
| 1908 | colorScheme: current?.colorScheme ?? "system", | 2004 | colorScheme: current?.colorScheme ?? "system", |
| 2005 | // Both carry across navigation. Mute is user intent, and the old | ||
| 2006 | // document keeps playing until loadURL actually replaces it, so | ||
| 2007 | // clearing audibility here would drop the speaker with no transition | ||
| 2008 | // left to restore it. Chromium reports the change when it happens. | ||
| 2009 | audioMuted: current?.audioMuted ?? false, | ||
| 2010 | audible: current?.audible ?? false, | ||
| 1909 | controller: current?.controller ?? "none", | 2011 | controller: current?.controller ?? "none", |
| 1910 | ...(current?.favicon ? { favicon: current.favicon } : {}), | 2012 | ...(current?.favicon ? { favicon: current.favicon } : {}), |
| 1911 | updatedAt, | 2013 | updatedAt, |
| ... | @@ -1917,7 +2019,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1917,7 +2019,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1917 | }), | 2019 | }), |
| 1918 | ] as const; | 2020 | ] as const; |
| 1919 | }); | 2021 | }); |
| 1920 | yield* emit(tabId, pending); | 2022 | // emitIfCurrent for the same reason as update: this snapshot carries |
| 2023 | // audibility forward, and an audio-state-changed landing in between would | ||
| 2024 | // otherwise be rolled back with no follow-up transition to correct it. | ||
| 2025 | yield* emitIfCurrent(tabId, pending); | ||
| 1921 | if (pending.webContentsId == null) return; | 2026 | if (pending.webContentsId == null) return; |
| 1922 | const webContentsId = pending.webContentsId; | 2027 | const webContentsId = pending.webContentsId; |
| 1923 | const wc = webContents.fromId(webContentsId); | 2028 | const wc = webContents.fromId(webContentsId); |
| ... | @@ -2251,6 +2356,39 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -2251,6 +2356,39 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2251 | yield* applyColorScheme(tabId, wc, colorScheme); | 2356 | yield* applyColorScheme(tabId, wc, colorScheme); |
| 2252 | }); | 2357 | }); |
| 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 | const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* ( | 2392 | const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* ( |
| 2255 | tabId: string, | 2393 | tabId: string, |
| 2256 | ) { | 2394 | ) { |
| ... | @@ -3543,6 +3681,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -3543,6 +3681,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 3543 | revealArtifact, | 3681 | revealArtifact, |
| 3544 | saveRecording, | 3682 | saveRecording, |
| 3545 | setAnnotationTheme, | 3683 | setAnnotationTheme, |
| 3684 | setAudioMuted, | ||
| 3546 | setColorScheme, | 3685 | setColorScheme, |
| 3547 | setMainWindow, | 3686 | setMainWindow, |
| 3548 | startRecording, | 3687 | startRecording, |
| ... | @@ -3846,6 +3985,10 @@ export class PreviewManager extends Context.Service< | ... | @@ -3846,6 +3985,10 @@ export class PreviewManager extends Context.Service< |
| 3846 | tabId: string, | 3985 | tabId: string, |
| 3847 | colorScheme: DesktopPreviewColorScheme, | 3986 | colorScheme: DesktopPreviewColorScheme, |
| 3848 | ) => Effect.Effect<void, PreviewManagerError>; | 3987 | ) => Effect.Effect<void, PreviewManagerError>; |
| 3988 | readonly setAudioMuted: ( | ||
| 3989 | tabId: string, | ||
| 3990 | audioMuted: boolean, | ||
| 3991 | ) => Effect.Effect<void, PreviewManagerError>; | ||
| 3849 | readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | 3992 | readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 3850 | readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>; | 3993 | readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>; |
| 3851 | readonly clearCache: () => Effect.Effect<void, PreviewManagerError>; | 3994 | readonly clearCache: () => Effect.Effect<void, PreviewManagerError>; |
| ... | @@ -3944,6 +4087,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -3944,6 +4087,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 3944 | reapplyZoom: operations.reapplyZoom, | 4087 | reapplyZoom: operations.reapplyZoom, |
| 3945 | hardReload: operations.hardReload, | 4088 | hardReload: operations.hardReload, |
| 3946 | setColorScheme: operations.setColorScheme, | 4089 | setColorScheme: operations.setColorScheme, |
| 4090 | setAudioMuted: operations.setAudioMuted, | ||
| 3947 | openDevTools: operations.openDevTools, | 4091 | openDevTools: operations.openDevTools, |
| 3948 | clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { | 4092 | clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { |
| 3949 | yield* browserSession | 4093 | yield* browserSession |
apps/web/src/components/ChatView.tsx+11| ... | @@ -136,6 +136,7 @@ import { | ... | @@ -136,6 +136,7 @@ import { |
| 136 | setActivePreviewTab, | 136 | setActivePreviewTab, |
| 137 | useThreadPreviewState, | 137 | useThreadPreviewState, |
| 138 | } from "../previewStateStore"; | 138 | } from "../previewStateStore"; |
| 139 | import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; | ||
| 139 | import { addBrowserSurface } from "./preview/addBrowserSurface"; | 140 | import { addBrowserSurface } from "./preview/addBrowserSurface"; |
| 140 | import { closePreviewSession } from "./preview/closePreviewSession"; | 141 | import { closePreviewSession } from "./preview/closePreviewSession"; |
| 141 | import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; | 142 | import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; |
| ... | @@ -1636,6 +1637,14 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1636,6 +1637,14 @@ function ChatViewContent(props: ChatViewProps) { |
| 1636 | const activeFileSurface = | 1637 | const activeFileSurface = |
| 1637 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; | 1638 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; |
| 1638 | const activePreviewState = useThreadPreviewState(activeThreadRef); | 1639 | const activePreviewState = useThreadPreviewState(activeThreadRef); |
| 1640 | const activePreviewServerEpoch = activePreviewState.serverEpoch; | ||
| 1641 | const resolvePreviewRuntimeTabId = useMemo( | ||
| 1642 | () => | ||
| 1643 | activeThreadRef | ||
| 1644 | ? (tabId: string) => previewRuntimeTabId(activeThreadRef, activePreviewServerEpoch, tabId) | ||
| 1645 | : undefined, | ||
| 1646 | [activeThreadRef, activePreviewServerEpoch], | ||
| 1647 | ); | ||
| 1639 | const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) => | 1648 | const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) => |
| 1640 | selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef), | 1649 | selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef), |
| 1641 | ); | 1650 | ); |
| ... | @@ -6627,6 +6636,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -6627,6 +6636,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 6627 | pendingSurfaceIds={pendingFileSurfaceIds} | 6636 | pendingSurfaceIds={pendingFileSurfaceIds} |
| 6628 | previewSessions={activePreviewState.sessions} | 6637 | previewSessions={activePreviewState.sessions} |
| 6629 | desktopByTabId={activePreviewState.desktopByTabId} | 6638 | desktopByTabId={activePreviewState.desktopByTabId} |
| 6639 | previewRuntimeTabId={resolvePreviewRuntimeTabId} | ||
| 6630 | terminalLabelsById={activeTerminalLabelsById} | 6640 | terminalLabelsById={activeTerminalLabelsById} |
| 6631 | onActivate={activateRightPanelSurface} | 6641 | onActivate={activateRightPanelSurface} |
| 6632 | onCloseSurface={closeRightPanelSurface} | 6642 | onCloseSurface={closeRightPanelSurface} |
| ... | @@ -6666,6 +6676,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -6666,6 +6676,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 6666 | pendingSurfaceIds={pendingFileSurfaceIds} | 6676 | pendingSurfaceIds={pendingFileSurfaceIds} |
| 6667 | previewSessions={activePreviewState.sessions} | 6677 | previewSessions={activePreviewState.sessions} |
| 6668 | desktopByTabId={activePreviewState.desktopByTabId} | 6678 | desktopByTabId={activePreviewState.desktopByTabId} |
| 6679 | previewRuntimeTabId={resolvePreviewRuntimeTabId} | ||
| 6669 | terminalLabelsById={activeTerminalLabelsById} | 6680 | terminalLabelsById={activeTerminalLabelsById} |
| 6670 | onActivate={activateRightPanelSurface} | 6681 | onActivate={activateRightPanelSurface} |
| 6671 | onCloseSurface={closeRightPanelSurface} | 6682 | onCloseSurface={closeRightPanelSurface} |
apps/web/src/components/RightPanelTabs.test.tsx+85-4| ... | @@ -2,7 +2,7 @@ import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/con | ... | @@ -2,7 +2,7 @@ import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/con |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | 2 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | import { describe, expect, it } from "vite-plus/test"; | 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 | const previewSurface = { | 7 | const previewSurface = { |
| 8 | id: "browser:tab-1" as const, | 8 | id: "browser:tab-1" as const, |
| ... | @@ -39,7 +39,10 @@ const favicon = (dataUrl: string, pageUrl: string): DesktopPreviewFavicon => ({ | ... | @@ -39,7 +39,10 @@ const favicon = (dataUrl: string, pageUrl: string): DesktopPreviewFavicon => ({ |
| 39 | capturedAt: 1, | 39 | capturedAt: 1, |
| 40 | }); | 40 | }); |
| 41 | 41 | ||
| 42 | function overlay(icon: DesktopPreviewFavicon | null) { | 42 | function overlay( |
| 43 | icon: DesktopPreviewFavicon | null, | ||
| 44 | audio?: { audible?: boolean; audioMuted?: boolean }, | ||
| 45 | ) { | ||
| 43 | return { | 46 | return { |
| 44 | hasWebContents: true, | 47 | hasWebContents: true, |
| 45 | canGoBack: false, | 48 | canGoBack: false, |
| ... | @@ -48,12 +51,19 @@ function overlay(icon: DesktopPreviewFavicon | null) { | ... | @@ -48,12 +51,19 @@ function overlay(icon: DesktopPreviewFavicon | null) { |
| 48 | zoomFactor: 1, | 51 | zoomFactor: 1, |
| 49 | pictureInPicture: false, | 52 | pictureInPicture: false, |
| 50 | colorScheme: "system" as const, | 53 | colorScheme: "system" as const, |
| 54 | audioMuted: audio?.audioMuted ?? false, | ||
| 55 | audible: audio?.audible ?? false, | ||
| 51 | controller: "none" as const, | 56 | controller: "none" as const, |
| 52 | favicon: icon, | 57 | favicon: icon, |
| 53 | }; | 58 | }; |
| 54 | } | 59 | } |
| 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 | return renderToStaticMarkup( | 67 | return renderToStaticMarkup( |
| 58 | <RightPanelTabs | 68 | <RightPanelTabs |
| 59 | mode="inline" | 69 | mode="inline" |
| ... | @@ -62,9 +72,10 @@ function renderTabs(first: DesktopPreviewFavicon | null, second?: DesktopPreview | ... | @@ -62,9 +72,10 @@ function renderTabs(first: DesktopPreviewFavicon | null, second?: DesktopPreview |
| 62 | pendingSurfaceIds={new Set()} | 72 | pendingSurfaceIds={new Set()} |
| 63 | previewSessions={sessions} | 73 | previewSessions={sessions} |
| 64 | desktopByTabId={{ | 74 | desktopByTabId={{ |
| 65 | "tab-1": overlay(first), | 75 | "tab-1": overlay(first, audio), |
| 66 | ...(second ? { "tab-2": overlay(second) } : {}), | 76 | ...(second ? { "tab-2": overlay(second) } : {}), |
| 67 | }} | 77 | }} |
| 78 | {...(previewRuntimeTabId ? { previewRuntimeTabId } : {})} | ||
| 68 | terminalLabelsById={new Map()} | 79 | terminalLabelsById={new Map()} |
| 69 | onActivate={() => undefined} | 80 | onActivate={() => undefined} |
| 70 | onCloseSurface={() => undefined} | 81 | onCloseSurface={() => undefined} |
| ... | @@ -113,3 +124,73 @@ describe("RightPanelTabs preview favicon", () => { | ... | @@ -113,3 +124,73 @@ describe("RightPanelTabs preview favicon", () => { |
| 113 | expect(html).not.toContain("data:image/png;base64,AAAA"); | 124 | expect(html).not.toContain("data:image/png;base64,AAAA"); |
| 114 | }); | 125 | }); |
| 115 | }); | 126 | }); |
| 127 | |||
| 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,6 +8,8 @@ import { |
| 8 | Globe2, | 8 | Globe2, |
| 9 | Plus, | 9 | Plus, |
| 10 | TerminalSquare, | 10 | TerminalSquare, |
| 11 | Volume2, | ||
| 12 | VolumeOff, | ||
| 11 | X, | 13 | X, |
| 12 | } from "lucide-react"; | 14 | } from "lucide-react"; |
| 13 | import { | 15 | import { |
| ... | @@ -37,6 +39,7 @@ import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; | ... | @@ -37,6 +39,7 @@ import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; |
| 37 | 39 | ||
| 38 | import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; | 40 | import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; |
| 39 | import { FaviconImage } from "./preview/PreviewFaviconIcon"; | 41 | import { FaviconImage } from "./preview/PreviewFaviconIcon"; |
| 42 | import { previewBridge } from "./preview/previewBridge"; | ||
| 40 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; | 43 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; |
| 41 | 44 | ||
| 42 | interface RightPanelTabsProps { | 45 | interface RightPanelTabsProps { |
| ... | @@ -52,6 +55,12 @@ interface RightPanelTabsProps { | ... | @@ -52,6 +55,12 @@ interface RightPanelTabsProps { |
| 52 | pendingSurfaceIds: ReadonlySet<string>; | 55 | pendingSurfaceIds: ReadonlySet<string>; |
| 53 | previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>; | 56 | previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>; |
| 54 | desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>; | 57 | desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>; |
| 58 | /** | ||
| 59 | * Maps a server session tab id to the desktop runtime tab id the Electron | ||
| 60 | * preview manager is keyed by. Session ids are only unique within one server | ||
| 61 | * process, so desktop operations must not be addressed with them. | ||
| 62 | */ | ||
| 63 | previewRuntimeTabId?: ((tabId: string) => string) | undefined; | ||
| 55 | terminalLabelsById: ReadonlyMap<string, string>; | 64 | terminalLabelsById: ReadonlyMap<string, string>; |
| 56 | onActivate: (surface: RightPanelSurface) => void; | 65 | onActivate: (surface: RightPanelSurface) => void; |
| 57 | onCloseSurface: (surface: RightPanelSurface) => void; | 66 | onCloseSurface: (surface: RightPanelSurface) => void; |
| ... | @@ -116,7 +125,53 @@ const SURFACE_UNAVAILABLE_HINTS = { | ... | @@ -116,7 +125,53 @@ const SURFACE_UNAVAILABLE_HINTS = { |
| 116 | agents: "Available from a thread.", | 125 | agents: "Available from a thread.", |
| 117 | } as const; | 126 | } as const; |
| 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 | function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { | 176 | function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { |
| 122 | return ( | 177 | return ( |
| ... | @@ -534,6 +589,25 @@ export function RightPanelTabs(props: RightPanelTabsProps) { | ... | @@ -534,6 +589,25 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 534 | if (surface.kind === "file") { | 589 | if (surface.kind === "file") { |
| 535 | items.push({ id: "copy-path", label: "Copy path" }); | 590 | items.push({ id: "copy-path", label: "Copy path" }); |
| 536 | } | 591 | } |
| 592 | const menuPreviewTabId = previewTabIdOf(surface, props.previewSessions); | ||
| 593 | // Desktop overlay state only arrives once the preview manager has created | ||
| 594 | // the tab. A server session id alone can still be ahead of that, and | ||
| 595 | // muting then fails with PreviewTabNotFoundError that nobody surfaces. | ||
| 596 | const menuOverlay = menuPreviewTabId | ||
| 597 | ? (props.desktopByTabId[menuPreviewTabId] ?? null) | ||
| 598 | : null; | ||
| 599 | const menuMuted = menuOverlay?.audioMuted ?? false; | ||
| 600 | if (surface.kind === "preview") { | ||
| 601 | // Not gated on audibility: silencing a quiet tab ahead of time is the | ||
| 602 | // point, so the item is offered whenever the tab is mutable at all. | ||
| 603 | items.push({ | ||
| 604 | id: "toggle-mute", | ||
| 605 | ...tabMuteMenuItem({ | ||
| 606 | overlay: menuOverlay, | ||
| 607 | canResolveRuntimeTabId: props.previewRuntimeTabId !== undefined, | ||
| 608 | }), | ||
| 609 | }); | ||
| 610 | } | ||
| 537 | items.push( | 611 | items.push( |
| 538 | { id: "close", label: "Close" }, | 612 | { id: "close", label: "Close" }, |
| 539 | { | 613 | { |
| ... | @@ -558,6 +632,18 @@ export function RightPanelTabs(props: RightPanelTabsProps) { | ... | @@ -558,6 +632,18 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 558 | case "copy-path": | 632 | case "copy-path": |
| 559 | if (surface.kind === "file") props.onCopyFilePath(surface.relativePath); | 633 | if (surface.kind === "file") props.onCopyFilePath(surface.relativePath); |
| 560 | break; | 634 | break; |
| 635 | case "toggle-mute": { | ||
| 636 | // menuOverlay repeats the disabled gate above: the desktop tab must | ||
| 637 | // exist before it can be addressed, however the menu was dismissed. | ||
| 638 | const runtimeTabId = | ||
| 639 | menuPreviewTabId && menuOverlay | ||
| 640 | ? (props.previewRuntimeTabId?.(menuPreviewTabId) ?? null) | ||
| 641 | : null; | ||
| 642 | if (runtimeTabId) { | ||
| 643 | void previewBridge?.setAudioMuted(runtimeTabId, !menuMuted).catch(() => undefined); | ||
| 644 | } | ||
| 645 | break; | ||
| 646 | } | ||
| 561 | case "close": | 647 | case "close": |
| 562 | props.onCloseSurface(surface); | 648 | props.onCloseSurface(surface); |
| 563 | break; | 649 | break; |
| ... | @@ -626,6 +712,15 @@ export function RightPanelTabs(props: RightPanelTabsProps) { | ... | @@ -626,6 +712,15 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 626 | const active = surface.id === props.activeSurfaceId; | 712 | const active = surface.id === props.activeSurfaceId; |
| 627 | const pending = props.pendingSurfaceIds.has(surface.id); | 713 | const pending = props.pendingSurfaceIds.has(surface.id); |
| 628 | const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); | 714 | const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); |
| 715 | const previewTabId = previewTabIdOf(surface, props.previewSessions); | ||
| 716 | // Desktop state is keyed by the session id, but desktop actions | ||
| 717 | // must be addressed with the runtime id. | ||
| 718 | const audio = tabAudioState( | ||
| 719 | previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, | ||
| 720 | ); | ||
| 721 | const audioRuntimeTabId = previewTabId | ||
| 722 | ? (props.previewRuntimeTabId?.(previewTabId) ?? null) | ||
| 723 | : null; | ||
| 629 | return ( | 724 | return ( |
| 630 | <div | 725 | <div |
| 631 | key={surface.id} | 726 | key={surface.id} |
| ... | @@ -663,6 +758,34 @@ export function RightPanelTabs(props: RightPanelTabsProps) { | ... | @@ -663,6 +758,34 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 663 | </span> | 758 | </span> |
| 664 | <X className="hidden size-3 group-hover/tab:block group-focus-visible/close:block" /> | 759 | <X className="hidden size-3 group-hover/tab:block group-focus-visible/close:block" /> |
| 665 | </button> | 760 | </button> |
| 761 | {audio === "none" || !audioRuntimeTabId ? null : ( | ||
| 762 | <Tooltip> | ||
| 763 | <TooltipTrigger | ||
| 764 | render={ | ||
| 765 | <button | ||
| 766 | type="button" | ||
| 767 | className="cursor-pointer flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted" | ||
| 768 | aria-label={audio === "muted" ? `Unmute ${title}` : `Mute ${title}`} | ||
| 769 | onClick={(event) => { | ||
| 770 | // Sibling of the close button, inside a tab that | ||
| 771 | // activates on click: keep this to the toggle. | ||
| 772 | event.stopPropagation(); | ||
| 773 | void previewBridge | ||
| 774 | ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") | ||
| 775 | .catch(() => undefined); | ||
| 776 | }} | ||
| 777 | > | ||
| 778 | {audio === "muted" ? ( | ||
| 779 | <VolumeOff className="size-3" /> | ||
| 780 | ) : ( | ||
| 781 | <Volume2 className="size-3" /> | ||
| 782 | )} | ||
| 783 | </button> | ||
| 784 | } | ||
| 785 | /> | ||
| 786 | <TooltipPopup>{audio === "muted" ? "Unmute tab" : "Mute tab"}</TooltipPopup> | ||
| 787 | </Tooltip> | ||
| 788 | )} | ||
| 666 | <Tooltip> | 789 | <Tooltip> |
| 667 | <TooltipTrigger | 790 | <TooltipTrigger |
| 668 | render={ | 791 | render={ |
apps/web/src/components/preview/PreviewView.test.tsx+2| ... | @@ -107,6 +107,8 @@ vi.mock("~/previewStateStore", () => ({ | ... | @@ -107,6 +107,8 @@ vi.mock("~/previewStateStore", () => ({ |
| 107 | zoomFactor: 1, | 107 | zoomFactor: 1, |
| 108 | pictureInPicture: mocks.pictureInPicture, | 108 | pictureInPicture: mocks.pictureInPicture, |
| 109 | colorScheme: "system", | 109 | colorScheme: "system", |
| 110 | audioMuted: false, | ||
| 111 | audible: false, | ||
| 110 | controller: "none", | 112 | controller: "none", |
| 111 | }, | 113 | }, |
| 112 | }, | 114 | }, |
apps/web/src/components/preview/usePreviewBridge.test.ts+2| ... | @@ -19,6 +19,8 @@ function state(navStatus: DesktopPreviewTabState["navStatus"]): DesktopPreviewTa | ... | @@ -19,6 +19,8 @@ function state(navStatus: DesktopPreviewTabState["navStatus"]): DesktopPreviewTa |
| 19 | zoomFactor: 1, | 19 | zoomFactor: 1, |
| 20 | pictureInPicture: false, | 20 | pictureInPicture: false, |
| 21 | colorScheme: "system", | 21 | colorScheme: "system", |
| 22 | audioMuted: false, | ||
| 23 | audible: false, | ||
| 22 | controller: "none", | 24 | controller: "none", |
| 23 | favicon, | 25 | favicon, |
| 24 | updatedAt: "2026-08-09T00:00:00.000Z", | 26 | updatedAt: "2026-08-09T00:00:00.000Z", |
apps/web/src/components/preview/usePreviewBridge.ts+2| ... | @@ -121,6 +121,8 @@ export function projectDesktopState(state: DesktopPreviewTabState): DesktopPrevi | ... | @@ -121,6 +121,8 @@ export function projectDesktopState(state: DesktopPreviewTabState): DesktopPrevi |
| 121 | zoomFactor: state.zoomFactor, | 121 | zoomFactor: state.zoomFactor, |
| 122 | pictureInPicture: state.pictureInPicture, | 122 | pictureInPicture: state.pictureInPicture, |
| 123 | colorScheme: state.colorScheme, | 123 | colorScheme: state.colorScheme, |
| 124 | audioMuted: state.audioMuted, | ||
| 125 | audible: state.audible, | ||
| 124 | controller: state.controller, | 126 | controller: state.controller, |
| 125 | favicon: state.favicon && originOf(state.favicon.pageUrl) === navOrigin ? state.favicon : null, | 127 | favicon: state.favicon && originOf(state.favicon.pageUrl) === navOrigin ? state.favicon : null, |
| 126 | }; | 128 | }; |
apps/web/src/previewStateStore.test.ts+8| ... | @@ -321,6 +321,8 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -321,6 +321,8 @@ describe("previewStateStore (single-tab)", () => { |
| 321 | zoomFactor: 1, | 321 | zoomFactor: 1, |
| 322 | pictureInPicture: false, | 322 | pictureInPicture: false, |
| 323 | colorScheme: "system", | 323 | colorScheme: "system", |
| 324 | audioMuted: false, | ||
| 325 | audible: false, | ||
| 324 | controller: "none", | 326 | controller: "none", |
| 325 | favicon: null, | 327 | favicon: null, |
| 326 | }); | 328 | }); |
| ... | @@ -342,6 +344,8 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -342,6 +344,8 @@ describe("previewStateStore (single-tab)", () => { |
| 342 | zoomFactor: 1, | 344 | zoomFactor: 1, |
| 343 | pictureInPicture: false, | 345 | pictureInPicture: false, |
| 344 | colorScheme: "system", | 346 | colorScheme: "system", |
| 347 | audioMuted: false, | ||
| 348 | audible: false, | ||
| 345 | controller: "none", | 349 | controller: "none", |
| 346 | favicon: null, | 350 | favicon: null, |
| 347 | }); | 351 | }); |
| ... | @@ -391,6 +395,8 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -391,6 +395,8 @@ describe("previewStateStore (single-tab)", () => { |
| 391 | zoomFactor: 1, | 395 | zoomFactor: 1, |
| 392 | pictureInPicture: false, | 396 | pictureInPicture: false, |
| 393 | colorScheme: "system", | 397 | colorScheme: "system", |
| 398 | audioMuted: false, | ||
| 399 | audible: false, | ||
| 394 | controller: "none", | 400 | controller: "none", |
| 395 | favicon: null, | 401 | favicon: null, |
| 396 | }); | 402 | }); |
| ... | @@ -506,6 +512,8 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -506,6 +512,8 @@ describe("previewStateStore (single-tab)", () => { |
| 506 | zoomFactor: 1, | 512 | zoomFactor: 1, |
| 507 | pictureInPicture: false, | 513 | pictureInPicture: false, |
| 508 | colorScheme: "system", | 514 | colorScheme: "system", |
| 515 | audioMuted: false, | ||
| 516 | audible: false, | ||
| 509 | controller: "none", | 517 | controller: "none", |
| 510 | favicon: null, | 518 | favicon: null, |
| 511 | }); | 519 | }); |
apps/web/src/previewStateStore.ts+2| ... | @@ -28,6 +28,8 @@ export interface DesktopPreviewOverlay { | ... | @@ -28,6 +28,8 @@ export interface DesktopPreviewOverlay { |
| 28 | zoomFactor: number; | 28 | zoomFactor: number; |
| 29 | pictureInPicture: boolean; | 29 | pictureInPicture: boolean; |
| 30 | colorScheme: DesktopPreviewColorScheme; | 30 | colorScheme: DesktopPreviewColorScheme; |
| 31 | audioMuted: boolean; | ||
| 32 | audible: boolean; | ||
| 31 | controller: "human" | "agent" | "none"; | 33 | controller: "human" | "agent" | "none"; |
| 32 | favicon: DesktopPreviewFavicon | null; | 34 | favicon: DesktopPreviewFavicon | null; |
| 33 | } | 35 | } |
packages/contracts/src/ipc.ts+26| ... | @@ -558,6 +558,19 @@ export interface DesktopPreviewTabState { | ... | @@ -558,6 +558,19 @@ export interface DesktopPreviewTabState { |
| 558 | /** Whether this tab is currently mirrored into a desktop picture-in-picture window. */ | 558 | /** Whether this tab is currently mirrored into a desktop picture-in-picture window. */ |
| 559 | pictureInPicture: boolean; | 559 | pictureInPicture: boolean; |
| 560 | colorScheme: DesktopPreviewColorScheme; | 560 | colorScheme: DesktopPreviewColorScheme; |
| 561 | /** | ||
| 562 | * Whether the user has silenced this tab. Per tab rather than per origin, so | ||
| 563 | * two tabs on the same site mute independently. Survives navigation and | ||
| 564 | * webview swaps, but is dropped when the tab closes. | ||
| 565 | */ | ||
| 566 | audioMuted: boolean; | ||
| 567 | /** | ||
| 568 | * Whether the guest is currently emitting audio. Observed from Chromium, and | ||
| 569 | * independent of {@link audioMuted}: a muted tab that is playing still reports | ||
| 570 | * `true`, which is what lets the tab strip distinguish "muted and making | ||
| 571 | * sound" from "muted and silent". | ||
| 572 | */ | ||
| 573 | audible: boolean; | ||
| 561 | controller: "human" | "agent" | "none"; | 574 | controller: "human" | "agent" | "none"; |
| 562 | favicon?: DesktopPreviewFavicon; | 575 | favicon?: DesktopPreviewFavicon; |
| 563 | updatedAt: string; | 576 | updatedAt: string; |
| ... | @@ -597,6 +610,8 @@ export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState> | ... | @@ -597,6 +610,8 @@ export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState> |
| 597 | zoomFactor: Schema.Number, | 610 | zoomFactor: Schema.Number, |
| 598 | pictureInPicture: Schema.Boolean, | 611 | pictureInPicture: Schema.Boolean, |
| 599 | colorScheme: DesktopPreviewColorSchemeSchema, | 612 | colorScheme: DesktopPreviewColorSchemeSchema, |
| 613 | audioMuted: Schema.Boolean, | ||
| 614 | audible: Schema.Boolean, | ||
| 600 | controller: Schema.Literals(["human", "agent", "none"]), | 615 | controller: Schema.Literals(["human", "agent", "none"]), |
| 601 | favicon: Schema.optionalKey(DesktopPreviewFaviconSchema), | 616 | favicon: Schema.optionalKey(DesktopPreviewFaviconSchema), |
| 602 | updatedAt: Schema.String, | 617 | updatedAt: Schema.String, |
| ... | @@ -993,6 +1008,11 @@ export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({ | ... | @@ -993,6 +1008,11 @@ export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({ |
| 993 | colorScheme: DesktopPreviewColorSchemeSchema, | 1008 | colorScheme: DesktopPreviewColorSchemeSchema, |
| 994 | }); | 1009 | }); |
| 995 | 1010 | ||
| 1011 | export const DesktopPreviewSetAudioMutedInputSchema = Schema.Struct({ | ||
| 1012 | tabId: DesktopPreviewTabIdSchema, | ||
| 1013 | audioMuted: Schema.Boolean, | ||
| 1014 | }); | ||
| 1015 | |||
| 996 | export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({ | 1016 | export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({ |
| 997 | theme: DesktopPreviewAnnotationThemeSchema, | 1017 | theme: DesktopPreviewAnnotationThemeSchema, |
| 998 | }); | 1018 | }); |
| ... | @@ -1144,6 +1164,12 @@ export interface DesktopPreviewBridge { | ... | @@ -1144,6 +1164,12 @@ export interface DesktopPreviewBridge { |
| 1144 | * override). Persists per tab and is re-applied across webview swaps. | 1164 | * override). Persists per tab and is re-applied across webview swaps. |
| 1145 | */ | 1165 | */ |
| 1146 | setColorScheme: (tabId: string, colorScheme: DesktopPreviewColorScheme) => Promise<void>; | 1166 | setColorScheme: (tabId: string, colorScheme: DesktopPreviewColorScheme) => Promise<void>; |
| 1167 | /** | ||
| 1168 | * Silence the tab's audio output. Persists per tab and is re-applied across | ||
| 1169 | * webview swaps, but is dropped when the tab closes. Muting a silent tab is | ||
| 1170 | * allowed; it simply takes effect once the page plays something. | ||
| 1171 | */ | ||
| 1172 | setAudioMuted: (tabId: string, audioMuted: boolean) => Promise<void>; | ||
| 1147 | /** Open the guest webview's DevTools (detached). */ | 1173 | /** Open the guest webview's DevTools (detached). */ |
| 1148 | openDevTools: (tabId: string) => Promise<void>; | 1174 | openDevTools: (tabId: string) => Promise<void>; |
| 1149 | /** Drop cookies + storage data for the preview partition (all tabs). */ | 1175 | /** Drop cookies + storage data for the preview partition (all tabs). */ |