Allow browser pages to zoom independently (#4646)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-04 14:00:10 -04:00 committed by GitHub
parent 45705b0a7a
commit 8b66785e66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 558 additions and 4 deletions

View File

@ -9,7 +9,7 @@ vi.mock('electron', () => ({
webContents: { fromId: vi.fn() }
}))
import { setupGuestContextMenu } from './browser-guest-ui'
import { setupGuestContextMenu, setupGuestShortcutForwarding } from './browser-guest-ui'
describe('setupGuestContextMenu', () => {
const browserTabId = 'tab-1'
@ -252,3 +252,114 @@ describe('setupGuestContextMenu', () => {
})
})
})
describe('setupGuestShortcutForwarding', () => {
const browserTabId = 'tab-1'
let rendererSendMock: ReturnType<typeof vi.fn>
let guestOnMock: ReturnType<typeof vi.fn>
let guestOffMock: ReturnType<typeof vi.fn>
function makeGuest() {
return {
on: guestOnMock,
off: guestOffMock
} as unknown as Electron.WebContents
}
function makeRenderer() {
return { send: rendererSendMock } as unknown as Electron.WebContents
}
function triggerBeforeInput(input: Partial<Electron.Input>): ReturnType<typeof vi.fn> {
const handler = guestOnMock.mock.calls.find((call) => call[0] === 'before-input-event')?.[1] as
| ((event: Electron.Event, input: Electron.Input) => void)
| undefined
expect(handler).toBeTypeOf('function')
const preventDefault = vi.fn()
handler!(
{ preventDefault } as unknown as Electron.Event,
{
type: 'keyDown',
alt: false,
meta: process.platform === 'darwin',
control: process.platform !== 'darwin',
shift: false,
...input
} as Electron.Input
)
return preventDefault
}
beforeEach(() => {
rendererSendMock = vi.fn()
guestOnMock = vi.fn()
guestOffMock = vi.fn()
})
it('forwards browser page zoom shortcuts from focused guest pages', () => {
setupGuestShortcutForwarding({
browserTabId,
guest: makeGuest(),
resolveRenderer: () => makeRenderer()
})
const zoomInPreventDefault = triggerBeforeInput({ code: 'Equal', key: '=' })
const shiftedPlusPreventDefault = triggerBeforeInput({
code: 'Equal',
key: '+',
shift: true
})
const zoomOutPreventDefault = triggerBeforeInput({ code: 'Minus', key: '-' })
const numpadSubtractPreventDefault = triggerBeforeInput({ code: 'NumpadSubtract', key: '-' })
const resetPreventDefault = triggerBeforeInput({ code: 'Digit0', key: '0' })
const repeatPreventDefault = triggerBeforeInput({
code: 'NumpadAdd',
key: '+',
isAutoRepeat: true
})
expect(zoomInPreventDefault).toHaveBeenCalledTimes(1)
expect(shiftedPlusPreventDefault).toHaveBeenCalledTimes(1)
expect(zoomOutPreventDefault).toHaveBeenCalledTimes(1)
expect(numpadSubtractPreventDefault).toHaveBeenCalledTimes(1)
expect(resetPreventDefault).toHaveBeenCalledTimes(1)
expect(repeatPreventDefault).toHaveBeenCalledTimes(1)
expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:zoomBrowserPage', 'in')
expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:zoomBrowserPage', 'in')
expect(rendererSendMock).toHaveBeenNthCalledWith(3, 'ui:zoomBrowserPage', 'out')
expect(rendererSendMock).toHaveBeenNthCalledWith(4, 'ui:zoomBrowserPage', 'out')
expect(rendererSendMock).toHaveBeenNthCalledWith(5, 'ui:zoomBrowserPage', 'reset')
expect(rendererSendMock).toHaveBeenNthCalledWith(6, 'ui:zoomBrowserPage', 'in')
})
it('consumes guest zoom shortcuts even when the renderer is unavailable', () => {
setupGuestShortcutForwarding({
browserTabId,
guest: makeGuest(),
resolveRenderer: () => null
})
const preventDefault = triggerBeforeInput({ code: 'Equal', key: '=' })
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(rendererSendMock).not.toHaveBeenCalled()
})
it('uses customized zoom keybindings when forwarding guest shortcuts', () => {
setupGuestShortcutForwarding({
browserTabId,
guest: makeGuest(),
resolveRenderer: () => makeRenderer(),
getKeybindings: () => ({
'zoom.in': ['Mod+Alt+Z']
})
})
const defaultPreventDefault = triggerBeforeInput({ code: 'Equal', key: '=' })
const customPreventDefault = triggerBeforeInput({ code: 'KeyZ', key: 'z', alt: true })
expect(defaultPreventDefault).not.toHaveBeenCalled()
expect(customPreventDefault).toHaveBeenCalledTimes(1)
expect(rendererSendMock).toHaveBeenCalledWith('ui:zoomBrowserPage', 'in')
})
})

View File

@ -255,6 +255,14 @@ export function setupGuestShortcutForwarding(args: {
// which rejects Alt. Every other chord handled further down can reuse
// the same `action` rather than re-running the full predicate chain.
const action = resolveWindowShortcutAction(input, process.platform, keybindings)
if (action?.type === 'zoom') {
// Why: browser page zoom must consume repeats and teardown races before
// Chromium or the guest page can apply its own shortcut behavior.
event.preventDefault()
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:zoomBrowserPage', action.direction)
return
}
if (input.isAutoRepeat) {
if (action?.type === 'dictationKeyDown' && shouldForwardDictationShortcut?.()) {
event.preventDefault()

View File

@ -2072,6 +2072,7 @@ export type PreloadApi = {
onFocusBrowserAddressBar: (callback: () => void) => () => void
onFindInBrowserPage: (callback: () => void) => () => void
onReloadBrowserPage: (callback: () => void) => () => void
onZoomBrowserPage: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
onHardReloadBrowserPage: (callback: () => void) => () => void
onCloseActiveTab: (callback: () => void) => () => void
onSwitchTab: (callback: (direction: 1 | -1) => void) => () => void

View File

@ -2600,6 +2600,12 @@ const api = {
ipcRenderer.on('ui:reloadBrowserPage', listener)
return () => ipcRenderer.removeListener('ui:reloadBrowserPage', listener)
},
onZoomBrowserPage: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') =>
callback(direction)
ipcRenderer.on('ui:zoomBrowserPage', listener)
return () => ipcRenderer.removeListener('ui:zoomBrowserPage', listener)
},
onHardReloadBrowserPage: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('ui:hardReloadBrowserPage', listener)

View File

@ -113,6 +113,12 @@ import {
ORCA_BROWSER_FOCUS_REQUEST_EVENT,
type BrowserFocusRequestDetail
} from './browser-focus'
import {
addBrowserPageZoomEventListener,
applyBrowserPageZoom,
browserPageZoomLevelToPercent,
type BrowserPageZoomDirection
} from './browser-page-zoom'
import {
isRemoteRuntimeFileOperation,
statRuntimePath,
@ -181,6 +187,7 @@ const BROWSER_ANNOTATION_INTENT_OPTIONS = [
// Why: priority remains in the persisted annotation shape for backwards
// compatibility, but the annotation UI no longer exposes urgency choices.
const DEFAULT_BROWSER_ANNOTATION_PRIORITY: BrowserAnnotationPriority = 'important'
const BROWSER_PAGE_ZOOM_FEEDBACK_MS = 1400
type BrowserOverlayViewport = {
scrollX: number
@ -2493,6 +2500,7 @@ function BrowserPagePane({
const containerRef = useRef<HTMLDivElement | null>(null)
const grabToastTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const annotationCopyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const browserZoomFeedbackTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const setContainerRef = useCallback((node: HTMLDivElement | null): void => {
containerRef.current = node
if (node !== null) {
@ -2502,6 +2510,7 @@ function BrowserPagePane({
// after the DOM owner is detached.
clearTimeout(grabToastTimerRef.current)
clearTimeout(annotationCopyTimerRef.current)
clearTimeout(browserZoomFeedbackTimerRef.current)
}, [])
const addressBarInputRef = useRef<HTMLInputElement | null>(null)
const webviewRef = useRef<Electron.WebviewTag | null>(null)
@ -2537,6 +2546,8 @@ function BrowserPagePane({
const [resourceNotice, setResourceNotice] = useState<string | null>(null)
const [downloadState, setDownloadState] = useState<BrowserDownloadState | null>(null)
const downloadStateRef = useRef<BrowserDownloadState | null>(null)
const [browserZoomPercent, setBrowserZoomPercent] = useState(100)
const [browserZoomFeedbackVisible, setBrowserZoomFeedbackVisible] = useState(false)
const [contextMenu, setContextMenu] = useState<{
x: number
y: number
@ -2599,6 +2610,14 @@ function BrowserPagePane({
const webviewPartition = sessionProfile?.partition ?? ORCA_BROWSER_PARTITION
const browserSessionImportState = useAppStore((s) => s.browserSessionImportState)
const clearBrowserSessionImportState = useAppStore((s) => s.clearBrowserSessionImportState)
const showBrowserZoomFeedback = useCallback((level: number): void => {
setBrowserZoomPercent(browserPageZoomLevelToPercent(level))
setBrowserZoomFeedbackVisible(true)
clearTimeout(browserZoomFeedbackTimerRef.current)
browserZoomFeedbackTimerRef.current = setTimeout(() => {
setBrowserZoomFeedbackVisible(false)
}, BROWSER_PAGE_ZOOM_FEEDBACK_MS)
}, [])
useEffect(() => {
if (!browserSessionImportState) {
@ -3179,6 +3198,32 @@ function BrowserPagePane({
})
}, [isActive])
useEffect(() => {
if (!isActive) {
return
}
const applyActivePageZoom = (direction: BrowserPageZoomDirection): void => {
if (!isActiveRef.current) {
return
}
const nextLevel = applyBrowserPageZoom(webviewRef.current, direction)
if (nextLevel !== null) {
showBrowserZoomFeedback(nextLevel)
}
}
const removeGuestListener = window.api.ui.onZoomBrowserPage(applyActivePageZoom)
const removeLocalListener = addBrowserPageZoomEventListener((detail) => {
if (detail.browserPageId !== browserTabIdRef.current) {
return
}
applyActivePageZoom(detail.direction)
})
return () => {
removeGuestListener()
removeLocalListener()
}
}, [isActive, showBrowserZoomFeedback])
useEffect(() => {
if (!isActive) {
return
@ -4193,6 +4238,7 @@ function BrowserPagePane({
}
return received
})()
const showBrowserZoomIndicator = browserZoomFeedbackVisible || browserZoomPercent !== 100
useEffect(() => {
const webview = webviewRef.current
@ -4717,6 +4763,21 @@ function BrowserPagePane({
onDragOver={handleInternalFileDragOver}
onDrop={handleInternalFileDrop}
>
<div
role="status"
aria-live="polite"
aria-hidden={!showBrowserZoomIndicator}
className={cn(
'pointer-events-none absolute top-3 right-3 z-30 rounded-md border border-border bg-popover/95 px-2.5 py-1 text-xs font-medium text-popover-foreground shadow-xs transition-opacity duration-300 ease-out',
browserZoomFeedbackVisible
? 'opacity-100'
: browserZoomPercent === 100
? 'opacity-0'
: 'opacity-80'
)}
>
{browserZoomPercent}%
</div>
<BrowserFind isOpen={findOpen} onClose={() => setFindOpen(false)} webviewRef={webviewRef} />
{showFailureOverlay ? (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.02),transparent_58%)] px-6">

View File

@ -0,0 +1,69 @@
import { describe, expect, it, vi } from 'vitest'
import {
applyBrowserPageZoom,
browserPageZoomLevelToPercent,
nextBrowserPageZoomLevel
} from './browser-page-zoom'
describe('browserPageZoomLevelToPercent', () => {
it('maps Electron zoom levels to Chromium-style percentages', () => {
expect(browserPageZoomLevelToPercent(0)).toBe(100)
expect(browserPageZoomLevelToPercent(0.5)).toBe(110)
expect(browserPageZoomLevelToPercent(-0.5)).toBe(91)
expect(browserPageZoomLevelToPercent(5)).toBe(249)
})
})
describe('nextBrowserPageZoomLevel', () => {
it('steps, clamps, and resets browser page zoom levels', () => {
expect(nextBrowserPageZoomLevel(0, 'in')).toBe(0.5)
expect(nextBrowserPageZoomLevel(0, 'out')).toBe(-0.5)
expect(nextBrowserPageZoomLevel(3, 'reset')).toBe(0)
expect(nextBrowserPageZoomLevel(5, 'in')).toBe(5)
expect(nextBrowserPageZoomLevel(-3, 'out')).toBe(-3)
})
})
describe('applyBrowserPageZoom', () => {
it('applies the next zoom level to a live webview', () => {
const webview = {
getZoomLevel: vi.fn(() => 1),
setZoomLevel: vi.fn()
}
expect(applyBrowserPageZoom(webview, 'in')).toBe(1.5)
expect(webview.setZoomLevel).toHaveBeenCalledWith(1.5)
})
it('returns null for missing or destroyed webviews', () => {
expect(applyBrowserPageZoom(null, 'in')).toBeNull()
expect(
applyBrowserPageZoom(
{
isDestroyed: () => true,
getZoomLevel: vi.fn(() => 0),
setZoomLevel: vi.fn()
},
'out'
)
).toBeNull()
})
it('returns null when webview zoom methods throw', () => {
const getZoomFailure = {
getZoomLevel: vi.fn(() => {
throw new Error('detached')
}),
setZoomLevel: vi.fn()
}
const setZoomFailure = {
getZoomLevel: vi.fn(() => 0),
setZoomLevel: vi.fn(() => {
throw new Error('destroyed')
})
}
expect(applyBrowserPageZoom(getZoomFailure, 'in')).toBeNull()
expect(applyBrowserPageZoom(setZoomFailure, 'out')).toBeNull()
})
})

View File

@ -0,0 +1,73 @@
export type BrowserPageZoomDirection = 'in' | 'out' | 'reset'
export const ORCA_BROWSER_PAGE_ZOOM_EVENT = 'orca:browser-page-zoom'
export type BrowserPageZoomEventDetail = {
browserPageId: string
direction: BrowserPageZoomDirection
}
type BrowserPageZoomWebview = {
getZoomLevel: () => number
setZoomLevel: (level: number) => void
isDestroyed?: () => boolean
}
const BROWSER_PAGE_ZOOM_STEP = 0.5
const BROWSER_PAGE_ZOOM_MIN = -3
const BROWSER_PAGE_ZOOM_MAX = 5
const BROWSER_PAGE_ZOOM_RESET = 0
export function browserPageZoomLevelToPercent(level: number): number {
// Why: Electron zoom levels are exponential; show the same percentage users
// expect from Chromium browser zoom controls.
return Math.round(100 * Math.pow(1.2, level))
}
export function nextBrowserPageZoomLevel(
current: number,
direction: BrowserPageZoomDirection
): number {
const rawNext =
direction === 'in'
? current + BROWSER_PAGE_ZOOM_STEP
: direction === 'out'
? current - BROWSER_PAGE_ZOOM_STEP
: BROWSER_PAGE_ZOOM_RESET
return Math.max(BROWSER_PAGE_ZOOM_MIN, Math.min(BROWSER_PAGE_ZOOM_MAX, rawNext))
}
export function applyBrowserPageZoom(
webview: BrowserPageZoomWebview | null | undefined,
direction: BrowserPageZoomDirection
): number | null {
try {
if (!webview || webview.isDestroyed?.()) {
return null
}
const next = nextBrowserPageZoomLevel(webview.getZoomLevel(), direction)
webview.setZoomLevel(next)
return next
} catch {
return null
}
}
export function dispatchBrowserPageZoomEvent(detail: BrowserPageZoomEventDetail): void {
window.dispatchEvent(
new CustomEvent<BrowserPageZoomEventDetail>(ORCA_BROWSER_PAGE_ZOOM_EVENT, {
detail
})
)
}
export function addBrowserPageZoomEventListener(
callback: (detail: BrowserPageZoomEventDetail) => void
): () => void {
const listener = (event: Event): void => {
callback((event as CustomEvent<BrowserPageZoomEventDetail>).detail)
}
window.addEventListener(ORCA_BROWSER_PAGE_ZOOM_EVENT, listener)
return () => window.removeEventListener(ORCA_BROWSER_PAGE_ZOOM_EVENT, listener)
}

View File

@ -13,9 +13,10 @@ export function resolveZoomTarget(args: {
| 'skills'
| 'mobile'
activeTabType: 'terminal' | 'editor' | 'browser'
activeBrowserPageId?: string | null
activeElement: unknown
}): 'terminal' | 'editor' | 'ui' {
const { activeView, activeTabType, activeElement } = args
}): 'terminal' | 'editor' | 'browser' | 'ui' {
const { activeView, activeTabType, activeBrowserPageId, activeElement } = args
const terminalInputFocused =
typeof activeElement === 'object' &&
activeElement !== null &&
@ -43,6 +44,11 @@ export function resolveZoomTarget(args: {
if (activeView !== 'terminal') {
return 'ui'
}
// Why: a browser tab owns zoom shortcuts even if DOM focus still points at a
// just-deactivated editor or terminal during tab switches.
if (activeTabType === 'browser' && activeBrowserPageId) {
return 'browser'
}
if (activeTabType === 'editor' || editorFocused) {
return 'editor'
}

View File

@ -74,6 +74,196 @@ describe('resolveZoomTarget', () => {
})
).toBe('ui')
})
it('routes to browser zoom for active browser tabs before stale DOM focus', () => {
expect(
resolveZoomTarget({
activeView: 'terminal',
activeTabType: 'browser',
activeBrowserPageId: 'page-1',
activeElement: makeTarget({ editorClosest: true, hasXtermClass: true })
})
).toBe('browser')
})
it('does not route to browser zoom without an active browser page', () => {
expect(
resolveZoomTarget({
activeView: 'terminal',
activeTabType: 'browser',
activeBrowserPageId: null,
activeElement: makeTarget({})
})
).toBe('ui')
})
})
describe('useIpcEvents zoom routing', () => {
beforeEach(() => {
vi.resetModules()
vi.unstubAllGlobals()
})
it('dispatches browser page zoom without applying or persisting UI zoom', async () => {
const terminalZoomListenerRef: {
current: ((direction: 'in' | 'out' | 'reset') => void) | null
} = { current: null }
const dispatchEvent = vi.fn()
const setUI = vi.fn()
vi.doMock('react', async () => {
const actual = await vi.importActual<typeof ReactModule>('react')
return {
...actual,
useEffect: (effect: () => void | (() => void)) => {
effect()
}
}
})
vi.doMock('../store', () => ({
useAppStore: {
subscribe: vi.fn(() => () => {}),
getState: () => ({
activeView: 'terminal',
activeTabType: 'browser',
activeWorktreeId: 'wt-1',
activeBrowserTabId: 'workspace-1',
activeBrowserTabIdByWorktree: { 'wt-1': 'workspace-1' },
browserTabsByWorktree: {
'wt-1': [
{
id: 'workspace-1',
activePageId: 'page-1',
pageIds: ['page-1']
}
]
},
browserPagesByWorkspace: {
'workspace-1': [{ id: 'page-1', worktreeId: 'wt-1' }]
},
editorFontZoomLevel: 0,
setEditorFontZoomLevel: vi.fn(),
settings: { terminalFontSize: 13 },
setUpdateStatus: vi.fn(),
fetchRepos: vi.fn(),
fetchWorktrees: vi.fn(),
setActiveView: vi.fn(),
activeModal: null,
closeModal: vi.fn(),
openModal: vi.fn(),
setActiveRepo: vi.fn(),
setActiveWorktree: vi.fn(),
revealWorktreeInSidebar: vi.fn(),
setIsFullScreen: vi.fn(),
setRateLimitsFromPush: vi.fn()
})
}
}))
vi.doMock('@/lib/ui-zoom', () => ({ applyUIZoom: vi.fn() }))
vi.doMock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn(),
ensureWorktreeHasInitialTerminal: vi.fn()
}))
vi.doMock('@/components/sidebar/visible-worktrees', () => ({
getVisibleWorktreeIds: () => []
}))
vi.doMock('@/lib/editor-font-zoom', () => ({
nextEditorFontZoomLevel: vi.fn(() => 0),
computeEditorFontSize: vi.fn(() => 13)
}))
vi.doMock('@/components/settings/SettingsConstants', () => ({
zoomLevelToPercent: vi.fn(() => 100),
ZOOM_MIN: -3,
ZOOM_MAX: 3
}))
vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged: vi.fn() }))
const makeEvents = (target: Record<string, unknown> = {}): Record<string, unknown> =>
new Proxy(target, {
get: (namespace, prop) => {
if (prop in namespace) {
return Reflect.get(namespace, prop)
}
return () => () => {}
}
})
vi.stubGlobal('document', {
activeElement: makeTarget({ editorClosest: true })
})
vi.stubGlobal('window', {
dispatchEvent,
setTimeout: vi.fn(() => 1),
clearTimeout: vi.fn(),
api: {
repos: makeEvents(),
worktrees: makeEvents(),
keybindings: makeEvents(),
settings: makeEvents(),
updater: {
getStatus: () => Promise.resolve({ state: 'idle' }),
onStatus: () => () => {},
onClearDismissal: () => () => {}
},
browser: makeEvents(),
rateLimits: {
get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }),
onUpdate: () => () => {}
},
ssh: {
listTargets: () => Promise.resolve([]),
listPortForwards: () => Promise.resolve([]),
listDetectedPorts: () => Promise.resolve([]),
getState: () => Promise.resolve(null),
onStateChanged: () => () => {},
onCredentialRequest: () => () => {},
onCredentialResolved: () => () => {},
onPortForwardsChanged: () => () => {},
onDetectedPortsChanged: () => () => {}
},
runtime: {
getTerminalFitOverrides: () => Promise.resolve([]),
getTerminalDrivers: () => Promise.resolve([]),
getBrowserDrivers: () => Promise.resolve([]),
onTerminalFitOverrideChanged: () => () => {},
onTerminalDriverChanged: () => () => {},
onBrowserDriverChanged: () => () => {}
},
agentStatus: { onSet: () => () => {} },
ui: makeEvents({
onTerminalZoom: (listener: (direction: 'in' | 'out' | 'reset') => void) => {
terminalZoomListenerRef.current = listener
return () => {}
},
getZoomLevel: vi.fn(() => 0),
set: setUI
})
}
})
const { useIpcEvents } = await import('./useIpcEvents')
const { applyUIZoom } = await import('@/lib/ui-zoom')
useIpcEvents()
expect(terminalZoomListenerRef.current).toBeTypeOf('function')
const listener = terminalZoomListenerRef.current
if (!listener) {
throw new Error('Expected terminal zoom listener to be registered')
}
listener('reset')
expect(dispatchEvent).toHaveBeenCalledTimes(1)
const event = dispatchEvent.mock.calls[0]?.[0] as CustomEvent<{
browserPageId: string
direction: string
}>
expect(event.type).toBe('orca:browser-page-zoom')
expect(event.detail).toEqual({ browserPageId: 'page-1', direction: 'reset' })
expect(applyUIZoom).not.toHaveBeenCalled()
expect(setUI).not.toHaveBeenCalledWith(
expect.objectContaining({ uiZoomLevel: expect.anything() })
)
})
})
describe('resolveBrowserSessionTabTarget', () => {

View File

@ -65,6 +65,7 @@ import {
setDriverForBrowserPage
} from '@/lib/pane-manager/browser-mobile-driver-state'
import { destroyPersistentWebview } from '@/components/browser-pane/webview-registry'
import { dispatchBrowserPageZoomEvent } from '@/components/browser-pane/browser-page-zoom'
import {
acquireBrowserAutomationVisibility,
releaseBrowserAutomationVisibility
@ -215,6 +216,23 @@ function getVisibleWorktreeIdsForRepo(state: AppState, repoId: string): Set<stri
return new Set((state.worktreesByRepo[repoId] ?? []).map((worktree) => worktree.id))
}
function resolveActiveBrowserPageId(state: AppState): string | null {
const worktreeId = state.activeWorktreeId
if (!worktreeId) {
return null
}
const activeWorkspaceId =
state.activeBrowserTabIdByWorktree[worktreeId] ?? state.activeBrowserTabId ?? null
const browserWorkspaces = state.browserTabsByWorktree[worktreeId] ?? []
const workspace =
browserWorkspaces.find((candidate) => candidate.id === activeWorkspaceId) ?? null
if (!workspace) {
return null
}
const pages = state.browserPagesByWorkspace[workspace.id] ?? []
return workspace.activePageId ?? workspace.pageIds?.[0] ?? pages[0]?.id ?? null
}
type TerminalSplitDirection = 'horizontal' | 'vertical'
function insertLeafAfterSource(
@ -2144,16 +2162,26 @@ export function useIpcEvents(): void {
// Zoom handling for menu accelerators and keyboard fallback paths.
unsubs.push(
window.api.ui.onTerminalZoom((direction) => {
const store = useAppStore.getState()
const { activeView, activeTabType, editorFontZoomLevel, setEditorFontZoomLevel, settings } =
useAppStore.getState()
store
const activeBrowserPageId = resolveActiveBrowserPageId(store)
const target = resolveZoomTarget({
activeView,
activeTabType,
activeBrowserPageId,
activeElement: document.activeElement
})
if (target === 'terminal') {
return
}
if (target === 'browser') {
if (!activeBrowserPageId) {
return
}
dispatchBrowserPageZoomEvent({ browserPageId: activeBrowserPageId, direction })
return
}
if (target === 'editor') {
const next = nextEditorFontZoomLevel(editorFontZoomLevel, direction)
setEditorFontZoomLevel(next)

View File

@ -1820,6 +1820,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
onFocusBrowserAddressBar: () => noopUnsubscribe,
onFindInBrowserPage: () => noopUnsubscribe,
onReloadBrowserPage: () => noopUnsubscribe,
onZoomBrowserPage: () => noopUnsubscribe,
onHardReloadBrowserPage: () => noopUnsubscribe,
onCloseActiveTab: () => noopUnsubscribe,
onSwitchTab: () => noopUnsubscribe,