feat(browser): speaker icon on tabs playing sound, with tab mute (#32981)

This commit is contained in:
Kelvin Amoaba
2026-10-25 02:51:37 +00:00
committed by GitHub
parent b7476f5457
commit 76f0f35bad
41 changed files with 425 additions and 22 deletions
@@ -72,6 +72,7 @@ function createGuest(id: number) {
return {
id,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: browserMocks.guestSetBackgroundThrottlingMock,
setWindowOpenHandler: browserMocks.guestSetWindowOpenHandlerMock,
@@ -93,6 +93,8 @@ describe('browserManager', () => {
const guest = {
id: 408,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -158,6 +160,8 @@ describe('browserManager', () => {
const guest = {
id: 409,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -208,6 +212,8 @@ describe('browserManager', () => {
const guest = {
id: 418,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -343,6 +349,8 @@ describe('browserManager', () => {
const guest = {
id: 419,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -386,6 +394,8 @@ describe('browserManager', () => {
const guest = {
id: 420,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -318,6 +318,7 @@ function createGuest(id: number) {
return {
id,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: browserMocks.guestSetBackgroundThrottlingMock,
setWindowOpenHandler: browserMocks.guestSetWindowOpenHandlerMock,
@@ -61,6 +61,9 @@ export class DestroyedGuestTestContents extends EventEmitter {
setUserAgent(): void {}
setWindowOpenHandler(): void {}
setBackgroundThrottling(): void {}
isCurrentlyAudible(): boolean {
return false
}
destroy(): void {
this.destroyed = true
@@ -74,6 +74,7 @@ describe('browserManager', () => {
const guest = {
id: 407,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -154,6 +155,7 @@ describe('browserManager', () => {
const guest = {
id: 408,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -224,6 +226,7 @@ describe('browserManager', () => {
const guest = {
id: 409,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -256,16 +259,19 @@ describe('browserManager', () => {
rendererWebContentsId
})
expect(rendererSendMock.mock.calls.map(([channel]) => channel)).toEqual([
'browser:download-requested',
'browser:download-finished'
])
// Registration also reports audio state; only download sends matter here.
expect(
rendererSendMock.mock.calls
.map(([channel]) => channel)
.filter((channel) => channel.startsWith('browser:download-'))
).toEqual(['browser:download-requested', 'browser:download-finished'])
})
it('drops pending download records when an unregistered guest is destroyed', () => {
const guest = {
id: 412,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -296,6 +302,7 @@ describe('browserManager', () => {
const guest = {
id: 410,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -340,6 +347,7 @@ describe('browserManager', () => {
const guest = {
id: 411,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -371,10 +379,12 @@ describe('browserManager', () => {
browserManager.handleGuestWillDownload({ guestWebContentsId: guest.id, item })
expect(item.cancel).toHaveBeenCalledTimes(1)
expect(rendererSendMock.mock.calls.map(([channel]) => channel)).toEqual([
'browser:download-requested',
'browser:download-finished'
])
// Registration also reports audio state; only download sends matter here.
expect(
rendererSendMock.mock.calls
.map(([channel]) => channel)
.filter((channel) => channel.startsWith('browser:download-'))
).toEqual(['browser:download-requested', 'browser:download-finished'])
expect(rendererSendMock).toHaveBeenCalledWith(
'browser:download-finished',
expect.objectContaining({
@@ -393,6 +403,7 @@ describe('browserManager', () => {
const guest = {
id: 420,
isDestroyed: () => false,
isCurrentlyAudible: () => false,
getType: () => 'webview',
on: guestOnMock,
off: guestOffMock,
@@ -413,10 +424,12 @@ describe('browserManager', () => {
getDownloadItemEventHandler(item, 'on', 'updated')?.(event, 'interrupted')
await new Promise<void>((resolve) => setImmediate(resolve))
expect(item.cancel).toHaveBeenCalledTimes(1)
expect(rendererSendMock.mock.calls.map(([channel]) => channel)).toEqual([
'browser:download-requested',
'browser:download-finished'
])
// Registration also reports audio state; only download sends matter here.
expect(
rendererSendMock.mock.calls
.map(([channel]) => channel)
.filter((channel) => channel.startsWith('browser:download-'))
).toEqual(['browser:download-requested', 'browser:download-finished'])
expect(rendererSendMock).toHaveBeenCalledWith(
'browser:download-finished',
expect.objectContaining({ status: 'failed', error: 'Download was interrupted.' })
@@ -196,6 +196,8 @@ describe('browserManager grab operations', () => {
webContentsId: GRAB_GUEST_WEB_CONTENTS_ID,
rendererWebContentsId: GRAB_RENDERER_WEB_CONTENTS_ID
})
// Registration also reports audio state; this test is about the chord.
rendererSendMock.mockClear()
const preventDefault = pressAnnotateChord()
@@ -22,9 +22,11 @@ export type GrabTestMocks = {
}
export function makeGuest(id: number, mocks: GrabTestMocks): Electron.WebContents {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: grab tests drive only the guest members listed here.
return {
id,
isDestroyed: mocks.guestIsDestroyedMock,
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: mocks.guestSetBackgroundThrottlingMock,
setWindowOpenHandler: mocks.guestSetWindowOpenHandlerMock,
@@ -75,5 +77,7 @@ export function resetGrabTestEnvironment(mocks: GrabTestMocks): Electron.WebCont
webContentsId: GRAB_GUEST_WEB_CONTENTS_ID,
rendererWebContentsId: GRAB_RENDERER_WEB_CONTENTS_ID
})
// Registration also reports audio state; grab tests count only grab-driven sends.
mocks.rendererSendMock.mockClear()
return guest
}
@@ -95,6 +95,8 @@ describe('browserManager', () => {
id: 104,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
@@ -123,6 +125,8 @@ describe('browserManager', () => {
id: 129,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
@@ -196,6 +200,8 @@ describe('browserManager', () => {
id: 139,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
@@ -231,6 +237,8 @@ describe('browserManager', () => {
const guest = {
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
getURL: vi.fn(() => 'https://example.com/'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -259,6 +267,8 @@ describe('browserManager', () => {
const guest = {
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
getURL: vi.fn(() => currentUrl),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -283,6 +293,8 @@ describe('browserManager', () => {
id: 101,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
@@ -344,6 +356,8 @@ describe('browserManager', () => {
id: 777,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
@@ -367,6 +381,8 @@ describe('browserManager', () => {
id: 303,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
@@ -391,6 +407,8 @@ describe('browserManager', () => {
id: 304,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
@@ -421,6 +439,8 @@ describe('browserManager', () => {
id: 306,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
@@ -453,6 +473,8 @@ describe('browserManager', () => {
id: 305,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
@@ -513,6 +535,8 @@ describe('browserManager', () => {
id: 501,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: oldGuestOnMock,
@@ -524,6 +548,8 @@ describe('browserManager', () => {
id: 502,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: newGuestOnMock,
@@ -558,6 +584,8 @@ describe('browserManager', () => {
webContentsId: newGuest.id,
rendererWebContentsId
})
// Registration also reports audio state; this test is about load-failure routing.
rendererSendMock.mockClear()
const oldDidFailLoadHandler = oldGuestOnMock.mock.calls.find(
([event]) => event === 'did-fail-load'
@@ -608,6 +636,8 @@ describe('browserManager', () => {
id: 808,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
@@ -654,6 +684,8 @@ describe('browserManager', () => {
id: 809,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
@@ -691,4 +723,49 @@ describe('browserManager', () => {
expect(debuggerHandlers.has('detach')).toBe(false)
expect(debuggerAttachMock).not.toHaveBeenCalled()
})
// The renderer re-applies a tab's mute on each registration report and clears the
// speaker on the unregister report, so neither may be skipped.
it('reports page audio on registration and on change, and silence on unregister', () => {
const rendererSend = vi.fn()
const guest = {
id: 301,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
isCurrentlyAudible: vi.fn(() => true),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock
}
webContentsFromIdMock.mockImplementation((id: number) =>
id === guest.id
? guest
: id === rendererWebContentsId
? { isDestroyed: () => false, send: rendererSend }
: null
)
browserManager.attachGuestPolicies(guest as never)
const onAudioStateChanged: (event: { audible: boolean }) => void = guestOnMock.mock.calls.find(
([event]) => event === 'audio-state-changed'
)?.[1]
browserManager.registerGuest({
browserPageId: 'page-1',
webContentsId: guest.id,
rendererWebContentsId
})
onAudioStateChanged({ audible: false })
browserManager.unregisterGuest('page-1')
onAudioStateChanged({ audible: true })
expect(
rendererSend.mock.calls.filter(([channel]) => channel === 'browser:audio-state-changed')
).toEqual([
['browser:audio-state-changed', { browserPageId: 'page-1', audible: true }],
['browser:audio-state-changed', { browserPageId: 'page-1', audible: false }],
['browser:audio-state-changed', { browserPageId: 'page-1', audible: false }]
])
})
})
@@ -41,6 +41,8 @@ type GuestFake = {
windowOpenHandler: ((details: { url: string; frameName: string }) => unknown) | null
webRtcPolicy: string | null
isDestroyed: () => boolean
isCurrentlyAudible: () => boolean
send: ReturnType<typeof vi.fn>
isFocused: () => boolean
getURL: () => string
getType: () => string
@@ -68,6 +70,8 @@ function createGuest(id: number, url: string): GuestFake {
windowOpenHandler: null,
webRtcPolicy: null,
isDestroyed: () => false,
isCurrentlyAudible: () => false,
send: vi.fn(),
isFocused: () => true,
getURL: () => guest.url,
getType: () => 'webview',
@@ -31,6 +31,15 @@ export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestClean
guest.setBackgroundThrottling(false)
const disposePopupPolicy = this.installGuestPopupPolicy(guest)
const disposeNavigationPolicy = this.installGuestNavigationPolicy(guest)
const forwardAudioState = (
event: Electron.Event<Electron.WebContentsAudioStateChangedEventParams>
): void => {
const browserPageId = this.tabIdByWebContentsId.get(guest.id)
if (browserPageId) {
this.notifyAudioStateChanged(browserPageId, event.audible)
}
}
guest.on('audio-state-changed', forwardAudioState)
const disposeDevToolsWindow = installGuestDevToolsWindowBehavior({
guest,
getKeybindings: () => this.settingsResolver?.().keybindings,
@@ -45,6 +54,16 @@ export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestClean
disposePopupPolicy()
disposeNavigationPolicy()
disposeDevToolsWindow()
if (!guest.isDestroyed()) {
guest.off('audio-state-changed', forwardAudioState)
}
})
}
protected notifyAudioStateChanged(browserPageId: string, audible: boolean): void {
this.resolveRendererForBrowserTab(browserPageId)?.send('browser:audio-state-changed', {
browserPageId,
audible
})
}
@@ -70,6 +70,7 @@ describe('browserManager', () => {
const guest = {
id: 405,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -94,6 +95,8 @@ describe('browserManager', () => {
webContentsId: guest.id,
rendererWebContentsId
})
// Registration also reports audio state; this test is about shortcut forwarding.
rendererSendMock.mockClear()
const beforeInputHandler = guestOnMock.mock.calls.findLast(
([event]) => event === 'before-input-event'
@@ -161,6 +164,7 @@ describe('browserManager', () => {
const guest = {
id: 406,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -185,6 +189,8 @@ describe('browserManager', () => {
webContentsId: guest.id,
rendererWebContentsId
})
// Registration also reports audio state; this test is about shortcut forwarding.
rendererSendMock.mockClear()
const beforeInputHandler = guestOnMock.mock.calls.findLast(
([event]) => event === 'before-input-event'
@@ -332,6 +338,7 @@ describe('browserManager', () => {
const guest = {
id: 407,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -362,6 +369,8 @@ describe('browserManager', () => {
webContentsId: guest.id,
rendererWebContentsId
})
// Registration also reports audio state; this test is about shortcut forwarding.
rendererSendMock.mockClear()
const beforeInputHandler = guestOnMock.mock.calls.findLast(
([event]) => event === 'before-input-event'
@@ -436,6 +445,7 @@ describe('browserManager', () => {
const guest = {
id: 407,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -460,6 +470,8 @@ describe('browserManager', () => {
webContentsId: guest.id,
rendererWebContentsId
})
// Registration also reports audio state; this test is about shortcut forwarding.
rendererSendMock.mockClear()
const beforeInputHandler = guestOnMock.mock.calls.findLast(
([event]) => event === 'before-input-event'
@@ -506,6 +518,7 @@ describe('browserManager', () => {
const guest = {
id: 408,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -70,6 +70,7 @@ describe('browserManager', () => {
const guest = {
id: 1707,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -102,6 +103,8 @@ describe('browserManager', () => {
webContentsId: guest.id,
rendererWebContentsId
})
// Registration also reports audio state; this test is about paint holds.
renderer.send.mockClear()
// Synchronous: a capture never waits on the desktop renderer.
const releaseFirst = browserManager.holdPaintForCapture(guest.id)
@@ -80,6 +80,7 @@ describe('browserManager', () => {
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'window'),
setBackgroundThrottling: vi.fn(),
isCurrentlyAudible: vi.fn(() => false),
setWindowOpenHandler: vi.fn(),
on: vi.fn(),
off: vi.fn(),
@@ -119,6 +120,7 @@ describe('browserManager', () => {
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
isCurrentlyAudible: vi.fn(() => false),
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
@@ -165,7 +167,9 @@ describe('browserManager', () => {
rendererWebContentsId
})
expect(rendererSendMock).toHaveBeenCalledTimes(1)
expect(
rendererSendMock.mock.calls.filter(([channel]) => channel === 'browser:guest-load-failed')
).toHaveLength(1)
expect(rendererSendMock).toHaveBeenCalledWith('browser:guest-load-failed', {
browserPageId: 'browser-1',
loadError: {
@@ -196,6 +200,7 @@ describe('browserManager', () => {
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
isCurrentlyAudible: vi.fn(() => false),
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
@@ -248,6 +253,7 @@ describe('browserManager', () => {
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
isCurrentlyAudible: vi.fn(() => false),
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
@@ -291,6 +297,7 @@ describe('browserManager', () => {
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
isCurrentlyAudible: vi.fn(() => false),
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
@@ -71,6 +71,7 @@ describe('browserManager page prompt routing', () => {
const guest = {
id: 107,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
getURL: vi.fn(() => 'https://trusted.example/home'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
@@ -162,6 +163,7 @@ describe('browserManager page prompt routing', () => {
const guest = {
id: 108,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -206,6 +208,7 @@ describe('browserManager page prompt routing', () => {
const guest = {
id: 109,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
getURL: vi.fn(() => ''),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
@@ -68,6 +68,7 @@ describe('browserManager popup child policies', () => {
const childGuest = {
id: 4040,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: childSetBackgroundThrottlingMock,
setWindowOpenHandler: childSetWindowOpenHandlerMock,
@@ -78,6 +79,7 @@ describe('browserManager popup child policies', () => {
const guest = {
id: 404,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -82,6 +82,7 @@ function attachClickedGuest(): WindowOpenHandler {
id: 2200,
session: guestSession,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: vi.fn(),
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -76,6 +76,7 @@ describe('browserManager', () => {
it('validates popup URLs before opening externally', () => {
const guest = {
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -109,6 +110,7 @@ describe('browserManager', () => {
const guest = {
id: 103,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -133,6 +135,8 @@ describe('browserManager', () => {
webContentsId: guest.id,
rendererWebContentsId
})
// Registration also reports audio state; this test is about popup routing.
rendererSendMock.mockClear()
const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: {
url: string
@@ -184,6 +188,8 @@ describe('browserManager', () => {
const guest = {
id: 140,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -249,6 +255,7 @@ describe('browserManager', () => {
const guest = {
id: 142,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -325,6 +332,7 @@ describe('browserManager', () => {
const guest = {
id: 143,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -384,6 +392,7 @@ describe('browserManager', () => {
const guest = {
id: 144,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -470,6 +479,7 @@ describe('browserManager', () => {
const guest = {
id: 150,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -510,6 +520,7 @@ describe('browserManager', () => {
const child = {
id: 1500 + index,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: vi.fn(),
setWindowOpenHandler: childSetWindowOpenHandlerMock,
@@ -569,6 +580,7 @@ describe('browserManager', () => {
session: { marker: 'synthetic-opener-session' },
id: 150,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -670,6 +682,7 @@ describe('browserManager', () => {
const guest = {
id: 106,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -719,6 +732,7 @@ describe('browserManager', () => {
const guest = {
id: 105,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -744,6 +758,7 @@ describe('browserManager', () => {
const guest = {
id: 104,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
@@ -72,6 +72,8 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli
}
this.certificateTrustController?.onGuestRegistered(webContentsId, browserTabId)
this.siteDisplayController?.onGuestRegistered(guest, sessionProfileId ?? null)
// Earlier audio events had no page; this report also cues the renderer to re-apply a tab's mute.
this.notifyAudioStateChanged(browserTabId, guest.isCurrentlyAudible())
this.setupContextMenu(browserTabId, guest)
this.setupGrabShortcut(browserTabId, guest)
@@ -99,6 +101,7 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli
// Resolve pending grabs before removing their registration.
this.cancelGrabOp(browserTabId, 'evicted')
cancelBrowserPagePrompts(browserTabId)
this.notifyAudioStateChanged(browserTabId, false)
const guestWebContentsId = this.webContentsIdByTabId.get(browserTabId)
if (guestWebContentsId !== undefined) {
@@ -79,6 +79,8 @@ export function createViewportGuestFactory(
id,
isDestroyed: vi.fn(() => false),
isCrashed: vi.fn(() => rendererCrashed),
isCurrentlyAudible: vi.fn(() => false),
send: vi.fn(),
getType: vi.fn(() => 'webview'),
getURL: vi.fn(() => committedUrl),
getUserAgent: vi.fn(() => currentUa),
@@ -184,6 +184,7 @@ function createMockGuest(
const guest = {
id,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
getURL: vi.fn(() => currentUrl),
getTitle: vi.fn(() => currentTitle),
+13
View File
@@ -44,6 +44,7 @@ export function registerBrowserGuestViewHandlers(): void {
ipcMain.removeHandler('browser:getPageSiteDisplay')
ipcMain.removeHandler('browser:zoomPage')
ipcMain.removeHandler('browser:setPageSiteColorScheme')
ipcMain.removeHandler('browser:setPageAudioMuted')
// Page/session operations exclude document previews.
const pageGuestId = (event: IpcMainInvokeEvent, browserPageId: unknown): number | null =>
@@ -95,6 +96,18 @@ export function registerBrowserGuestViewHandlers(): void {
}
)
ipcMain.handle(
'browser:setPageAudioMuted',
(event, args: { browserPageId: string; muted: unknown }): boolean => {
const guest = pageGuest(event, args?.browserPageId)
if (!guest || typeof args.muted !== 'boolean') {
return false
}
guest.setAudioMuted(args.muted)
return true
}
)
ipcMain.handle(
'browser:openDevTools',
(event, args: { browserPageId: string; inspectAt?: unknown }) => {
@@ -128,6 +128,7 @@ const BROWSER_PAGE_CHANNELS = [
'browser:setViewportOverride',
'browser:getPageSiteDisplay',
'browser:zoomPage',
'browser:setPageAudioMuted',
'browser:setPageSiteColorScheme',
'browser:publishClientPageMetadata',
'browser:cancelDownload',
+4
View File
@@ -122,6 +122,7 @@ export type BrowserApi = {
browserPageId: string
colorScheme: BrowserSiteColorScheme | null
}) => Promise<BrowserPageSiteDisplay | null>
setPageAudioMuted: (args: { browserPageId: string; muted: boolean }) => Promise<boolean>
reportViewportScrollState?: (args: {
browserPageId: string
state: BrowserViewportScrollState
@@ -138,6 +139,9 @@ export type BrowserApi = {
onCertificateFailureChanged: (
callback: (event: { browserPageId: string; failure: BrowserCertificateFailure | null }) => void
) => () => void
onAudioStateChanged: (
callback: (event: { browserPageId: string; audible: boolean }) => void
) => () => void
proceedCertificate: (args: {
browserPageId: string
challengeId: string
@@ -98,6 +98,8 @@ export const browserGuestRegistrationAndDownloadsApi = {
colorScheme: BrowserSiteColorScheme | null
}): Promise<BrowserPageSiteDisplay | null> =>
ipcRenderer.invoke('browser:setPageSiteColorScheme', args),
setPageAudioMuted: (args: { browserPageId: string; muted: boolean }): Promise<boolean> =>
ipcRenderer.invoke('browser:setPageAudioMuted', args),
reportViewportScrollState: (args: {
browserPageId: string
state: {
@@ -135,6 +137,14 @@ export const browserGuestRegistrationAndDownloadsApi = {
ipcRenderer.on('browser:certificate-failure-changed', listener)
return () => ipcRenderer.removeListener('browser:certificate-failure-changed', listener)
},
onAudioStateChanged: (callback): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: Parameters<typeof callback>[0]
): void => callback(data)
ipcRenderer.on('browser:audio-state-changed', listener)
return () => ipcRenderer.removeListener('browser:audio-state-changed', listener)
},
proceedCertificate: (args) => ipcRenderer.invoke('browser:proceedCertificate', args),
onPermissionDenied: (
callback: (event: { browserPageId: string; permission: string; origin: string }) => void
@@ -2,12 +2,27 @@ import type * as ReactModule from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isValidElement } from 'react'
import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types'
import type * as StoreModule from '@/store'
const reactHookRuntime = vi.hoisted(() => ({
states: [] as unknown[],
index: 0
}))
// Why: the shallow harness has no React dispatcher, so the store hook reads state directly.
vi.mock('@/store', async () => {
const actual = await vi.importActual<typeof StoreModule>('@/store')
const store = actual.useAppStore
return {
...actual,
useAppStore: Object.assign(
<T,>(selector: (state: ReturnType<typeof store.getState>) => T): T =>
selector(store.getState()),
store
)
}
})
vi.mock('react', async () => {
const actual = await vi.importActual<typeof ReactModule>('react')
return {
@@ -90,6 +105,12 @@ vi.mock('lucide-react', () => ({
Rows2: function Rows2(props: Record<string, unknown>) {
return { type: 'Rows2', props }
},
Volume2: function Volume2(props: Record<string, unknown>) {
return { type: 'Volume2', props }
},
VolumeX: function VolumeX(props: Record<string, unknown>) {
return { type: 'VolumeX', props }
},
X: function X(props: Record<string, unknown>) {
return { type: 'X', props }
}
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { X, ExternalLink, Copy, CopyX, Pin, PinOff } from 'lucide-react'
import { X, ExternalLink, Copy, CopyX, Pin, PinOff, Volume2, VolumeX } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -33,6 +33,7 @@ import { cn } from '@/lib/utils'
import { BrowserFavicon } from '@/components/browser-favicon'
import { CopyTabIdMenuItem } from './CopyTabIdMenuItem'
import { CloseSplitPaneMenuItem } from './CloseSplitPaneMenuItem'
import { useAppStore } from '@/store'
export function formatBrowserTabUrlLabel(url: string): string {
if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') {
@@ -117,6 +118,16 @@ export default function BrowserTab({
// invalid URL — leave disabled
}
const tabLabel = getBrowserTabLabel(tab)
const audible = useAppStore((s) =>
(s.browserPagesByWorkspace[tab.id] ?? []).some((page) => s.browserAudiblePageIds[page.id])
)
const audioMuted = useAppStore((s) => Boolean(s.browserAudioMutedTabIds[tab.id]))
// Why: a muted tab keeps its icon after sound stops, so it can be unmuted from the strip.
const showAudioControl = audible || audioMuted
const setBrowserTabAudioMuted = useAppStore((s) => s.setBrowserTabAudioMuted)
const toggleAudioMutedLabel = audioMuted
? translate('components.tab.bar.BrowserTab.unmuteTab', 'Unmute Tab')
: translate('components.tab.bar.BrowserTab.muteTab', 'Mute Tab')
useEffect(() => {
const closeMenu = (): void => setMenuOpen(false)
@@ -191,6 +202,21 @@ export default function BrowserTab({
/>
{isPinned && <Pin className="mr-1 size-3 shrink-0 text-muted-foreground" aria-hidden />}
<span className={`${TAB_LABEL_WIDTH_CLASSES} mr-1`}>{tabLabel}</span>
{showAudioControl && (
// Why: unlike close, stays visible on inactive tabs; finding the playing tab is the point.
<button
type="button"
aria-label={toggleAudioMutedLabel}
className="flex items-center justify-center w-4 h-4 rounded-sm shrink-0 text-muted-foreground hover:text-foreground hover:bg-muted"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
setBrowserTabAudioMuted(tab.id, !audioMuted)
}}
>
{audioMuted ? <VolumeX className="size-3" /> : <Volume2 className="size-3" />}
</button>
)}
{!isPinned && (
<button
className={`flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
@@ -275,6 +301,12 @@ export default function BrowserTab({
? translate('auto.components.tab.bar.BrowserTab.c5aaee8c39', 'Unpin Tab')
: translate('auto.components.tab.bar.BrowserTab.911542656f', 'Pin Tab')}
</DropdownMenuItem>
{showAudioControl ? (
<DropdownMenuItem onSelect={() => setBrowserTabAudioMuted(tab.id, !audioMuted)}>
{audioMuted ? <Volume2 className="size-3.5" /> : <VolumeX className="size-3.5" />}
{toggleAudioMutedLabel}
</DropdownMenuItem>
) : null}
<CopyTabIdMenuItem unifiedTabId={dragData.unifiedTabId} />
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => !isPinned && onClose()} disabled={isPinned}>
@@ -110,13 +110,21 @@ vi.mock('@/store', () => ({
settings: null
unreadTerminalTabs: Record<string, boolean>
agentStatusByPaneKey: Record<string, never>
browserPagesByWorkspace: Record<string, never>
browserAudiblePageIds: Record<string, never>
browserAudioMutedTabIds: Record<string, never>
setBrowserTabAudioMuted: () => void
}) => unknown
) =>
selector({
openMarkdownPreview: vi.fn(),
settings: null,
unreadTerminalTabs: {},
agentStatusByPaneKey: {}
agentStatusByPaneKey: {},
browserPagesByWorkspace: {},
browserAudiblePageIds: {},
browserAudioMutedTabIds: {},
setBrowserTabAudioMuted: vi.fn()
})
}))
@@ -76,6 +76,8 @@ export type IpcEventsHarnessOptions = {
clientHostedBrowserRowsSnapshot?: ClientHostedBrowserRowsEvent[]
/** Rejects the hydration round trip instead of resolving it. */
clientHostedBrowserRowsSnapshotError?: Error
/** Extra `window.api.browser` members, e.g. a listener capture for a channel under test. */
browserApi?: Record<string, unknown>
}
/**
@@ -233,6 +235,7 @@ export async function loadIpcEventsHarness(
onClearDismissal: () => () => {}
},
browser: createApiNamespaceStub({
...options.browserApi,
onNavigationUpdate: (
listener: (event: { browserPageId: string; url: string; title: string }) => void
) => {
@@ -4,6 +4,7 @@ import { openUrlInTabBesideBrowserPage } from '@/lib/browser-page-tab-opening'
import { redactKagiSessionToken } from '../../../../shared/browser-url'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { useAppStore } from '../../store'
import { findPage } from '../../store/slices/browser-page-records'
import {
acquireBrowserAutomationVisibility,
releaseBrowserAutomationVisibility
@@ -62,6 +63,21 @@ export function registerBrowserStateIpcBridge(
if (unsubscribeCertificateFailure) {
unsubs.push(unsubscribeCertificateFailure)
}
// Why no runtime guard: only guests in this process report, and streamed pages have none here.
const unsubscribeAudioState = window.api.browser.onAudioStateChanged?.(
({ browserPageId, audible }) => {
const store = useAppStore.getState()
store.setBrowserPageAudible(browserPageId, audible)
// Why: main reports each newly registered guest, and a new guest (eviction, crash) starts unmuted.
const tabId = findPage(store.browserPagesByWorkspace, browserPageId)?.workspaceId
if (tabId && store.browserAudioMutedTabIds[tabId]) {
void window.api.browser.setPageAudioMuted({ browserPageId, muted: true })
}
}
)
if (unsubscribeAudioState) {
unsubs.push(unsubscribeAudioState)
}
unsubs.push(
window.api.browser.onNavigationUpdate(({ browserPageId, url, title }) => {
if (isRuntimeEnvironmentActive()) {
@@ -74,6 +74,54 @@ describe('browser certificate failures while a runtime environment is active', (
})
})
type AudioStateEvent = { browserPageId: string; audible: boolean }
// A rebuilt guest (eviction, crash recovery, client-hosted rebind) starts unmuted; main's
// registration report is the renderer's only cue to carry the tab's mute over to it. Local pages
// (e.g. floating browser tabs) still report while a remote environment is active.
describe('browser audio state', () => {
it('re-applies a muted tab to each reporting page, even with a remote environment active', async () => {
const setBrowserPageAudible = vi.fn()
const setPageAudioMuted = vi.fn(() => Promise.resolve(true))
let audioStateChanged: (event: AudioStateEvent) => void = () => {}
const harness = await loadIpcEventsHarness(
createHarnessStoreState({
tabsByWorktree: {},
remoteBrowserPageHandlesByPageId: {},
browserPagesByWorkspace: {
'tab-muted': [{ id: 'page-muted', workspaceId: 'tab-muted' }],
'tab-loud': [{ id: 'page-loud', workspaceId: 'tab-loud' }]
},
browserAudioMutedTabIds: { 'tab-muted': true },
setBrowserPageAudible,
settings: {
terminalFontSize: 13,
experimentalNativeChat: false,
activeRuntimeEnvironmentId: 'env-a'
}
}),
{
browserApi: {
onAudioStateChanged: (listener: (event: AudioStateEvent) => void) => {
audioStateChanged = listener
return () => {}
},
setPageAudioMuted
}
}
)
harness.useIpcEvents()
audioStateChanged({ browserPageId: 'page-muted', audible: false })
audioStateChanged({ browserPageId: 'page-loud', audible: true })
expect(setBrowserPageAudible).toHaveBeenCalledWith('page-muted', false)
expect(setBrowserPageAudible).toHaveBeenCalledWith('page-loud', true)
expect(setPageAudioMuted).toHaveBeenCalledTimes(1)
expect(setPageAudioMuted).toHaveBeenCalledWith({ browserPageId: 'page-muted', muted: true })
})
})
function storeStateWithRuntime(
setBrowserPageCertificateFailure: ReturnType<typeof vi.fn>,
remoteBrowserPageHandlesByPageId: Record<string, unknown>
+3 -1
View File
@@ -19702,7 +19702,9 @@
"switchToChatView": "Continue in Chat UI"
},
"BrowserTab": {
"closeOthers": "Close Others"
"closeOthers": "Close Others",
"muteTab": "Mute Tab",
"unmuteTab": "Unmute Tab"
},
"EditorFileTabContextMenu": {
"closeOthers": "Close Others"
+3 -1
View File
@@ -19373,7 +19373,9 @@
"switchToChatView": "Continuar en la interfaz de chat"
},
"BrowserTab": {
"closeOthers": "Cerrar otras"
"closeOthers": "Cerrar otras",
"muteTab": "Silenciar pestaña",
"unmuteTab": "Reactivar sonido de la pestaña"
},
"EditorFileTabContextMenu": {
"closeOthers": "Cerrar otras"
+3 -1
View File
@@ -19513,7 +19513,9 @@
"switchToChatView": "Continuer dans l’interface de chat"
},
"BrowserTab": {
"closeOthers": "Fermer les autres"
"closeOthers": "Fermer les autres",
"muteTab": "Couper le son de l'onglet",
"unmuteTab": "Réactiver le son de l'onglet"
},
"EditorFileTabContextMenu": {
"closeOthers": "Fermer les autres"
+3 -1
View File
@@ -19407,7 +19407,9 @@
"switchToChatView": "チャット UI で続ける"
},
"BrowserTab": {
"closeOthers": "その他を閉じる"
"closeOthers": "その他を閉じる",
"muteTab": "タブをミュート",
"unmuteTab": "タブのミュートを解除"
},
"EditorFileTabContextMenu": {
"closeOthers": "その他を閉じる"
+3 -1
View File
@@ -19407,7 +19407,9 @@
"switchToChatView": "채팅 UI에서 계속하기"
},
"BrowserTab": {
"closeOthers": "다른 탭 닫기"
"closeOthers": "다른 탭 닫기",
"muteTab": "탭 음소거",
"unmuteTab": "탭 음소거 해제"
},
"EditorFileTabContextMenu": {
"closeOthers": "다른 탭 닫기"
+3 -1
View File
@@ -19936,7 +19936,9 @@
"switchToChatView": "Alternar para visualização de chat"
},
"BrowserTab": {
"closeOthers": "Fechar Outras"
"closeOthers": "Fechar Outras",
"muteTab": "Silenciar Aba",
"unmuteTab": "Reativar Som da Aba"
},
"EditorFileTabContextMenu": {
"closeOthers": "Fechar Outras"
+3 -1
View File
@@ -19372,7 +19372,9 @@
"switchToChatView": "在聊天界面中继续"
},
"BrowserTab": {
"closeOthers": "关闭其他"
"closeOthers": "关闭其他",
"muteTab": "静音标签",
"unmuteTab": "取消静音标签"
},
"EditorFileTabContextMenu": {
"closeOthers": "关闭其他"
@@ -0,0 +1,38 @@
import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract'
import { omitRecordKeys } from '../worktrees/teardown/record-key-omission'
export function createBrowserAudioActions(
set: BrowserSliceSet,
get: BrowserSliceGet
): Pick<BrowserSlice, 'setBrowserPageAudible' | 'setBrowserTabAudioMuted'> {
return {
setBrowserPageAudible: (pageId, audible) => {
set((s) => {
if (audible === Boolean(s.browserAudiblePageIds[pageId])) {
return s
}
return {
browserAudiblePageIds: audible
? { ...s.browserAudiblePageIds, [pageId]: true }
: omitRecordKeys(s.browserAudiblePageIds, [pageId])
}
})
},
setBrowserTabAudioMuted: (tabId, muted) => {
set((s) => {
if (muted === Boolean(s.browserAudioMutedTabIds[tabId])) {
return s
}
return {
browserAudioMutedTabIds: muted
? { ...s.browserAudioMutedTabIds, [tabId]: true }
: omitRecordKeys(s.browserAudioMutedTabIds, [tabId])
}
})
for (const page of get().browserPagesByWorkspace[tabId] ?? []) {
void window.api.browser.setPageAudioMuted({ browserPageId: page.id, muted })
}
}
}
}
@@ -186,6 +186,7 @@ export function createBrowserCloseActions(
recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace,
remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId,
browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId,
browserAudioMutedTabIds: omitRecordKeys(s.browserAudioMutedTabIds, [tabId]),
browserAnnotationsByPageId: nextBrowserAnnotationsByPageId,
browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId
}
@@ -123,6 +123,10 @@ export type BrowserSlice = {
browserTabsByWorktree: Record<string, BrowserWorkspace[]>
browserPagesByWorkspace: Record<string, BrowserPage[]>
browserCertificateFailuresByPageId: Record<string, BrowserCertificateFailure>
/** May precede the page record (client-hosted pages); main's false on guest teardown clears it. */
browserAudiblePageIds: Record<string, true>
/** Tab mute lives for the tab's life; each new guest of its pages is muted again. */
browserAudioMutedTabIds: Record<string, true>
browserAnnotationsByPageId: Record<string, BrowserPageAnnotation[]>
browserAnnotationMarkerIdsByPageId: Record<string, string[]>
remoteBrowserPageHandlesByPageId: Record<string, RemoteBrowserPageHandle>
@@ -198,6 +202,8 @@ export type BrowserSlice = {
pageId: string,
failure: BrowserCertificateFailure | null
) => void
setBrowserPageAudible: (pageId: string, audible: boolean) => void
setBrowserTabAudioMuted: (tabId: string, muted: boolean) => void
setBrowserTabUrl: (pageId: string, url: string) => void
setBrowserPageUrl: (pageId: string, url: string, options?: SetBrowserPageUrlOptions) => void
setRemoteBrowserPageHandle: (pageId: string, handle: RemoteBrowserPageHandle) => void
@@ -9,6 +9,7 @@ import { createBrowserPageCreateActions } from './browser-page-create-actions'
import { createBrowserPageConversionActions } from './browser-page-conversion-actions'
import { createBrowserPageFocusActions } from './browser-page-focus-actions'
import { createBrowserPageStateActions } from './browser-page-state-actions'
import { createBrowserAudioActions } from './browser-audio-actions'
import { createBrowserPageMetadataActions } from './browser-page-metadata-actions'
import { createBrowserHydrationActions } from './browser-hydration-actions'
import { createBrowserProfileListActions } from './browser-profile-list-actions'
@@ -21,6 +22,8 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
browserTabsByWorktree: {},
browserPagesByWorkspace: {},
browserCertificateFailuresByPageId: {},
browserAudiblePageIds: {},
browserAudioMutedTabIds: {},
browserAnnotationsByPageId: {},
browserAnnotationMarkerIdsByPageId: {},
remoteBrowserPageHandlesByPageId: {},
@@ -52,6 +55,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
...createBrowserPageConversionActions(set, get),
...createBrowserPageFocusActions(set, get),
...createBrowserPageStateActions(set, get),
...createBrowserAudioActions(set, get),
...createBrowserPageMetadataActions(set, get),
...createBrowserHydrationActions(set, get),
...createBrowserProfileListActions(set, get),