fix(browser): keep browser guests painting when the workbench is hidden (#14599)

* fix(browser): keep browser guests painting when the workbench is hidden

Chromium never paints inside a display:none subtree, so an Electron <webview>
stops emitting CDP screencast frames the moment any ancestor is parked that way.
Orca already models this per pane (browser-page-paintability.ts) and per worktree
surface, using opacity:0 so a phone- or agent-driven page keeps compositing — but
three ancestors above those layers still used `hidden` unconditionally:

  - the App-level terminal workbench container, hidden whenever activeView is not
    'terminal' (opening Settings froze every mobile browser pane),
  - Terminal's root, hidden when there is no active worktree,
  - the split-surface wrapper, hidden when the active worktree has no layout.

A pane-level escape hatch cannot override an ancestor, so all of them have to
agree. Share one predicate across the chain and swap `hidden` for an out-of-flow
transparent layer while a remote controller needs frames.

The predicate ORs automation visibility with the mobile driver, matching the
per-worktree gate. That term is load-bearing, not symmetry: agent-browser
commands acquire a visibility lease and then capture, so gating on the mobile
driver alone left automation from a non-workspace view capturing a blank surface.

Mobile: a stream can report `ready` and then deliver no frames, which cleared the
loading indicator and left an unexplained black rectangle. Key it off actually
having pixels. That also retires the `ready` state and its ref.

Co-authored-by: Kaylee Williams <65376239+KayleeWilliams@users.noreply.github.com>

* fix(browser): keep paint retention off store hot paths

---------

Co-authored-by: Kaylee Williams <65376239+KayleeWilliams@users.noreply.github.com>
This commit is contained in:
Brennan Benson
2026-08-14 19:13:12 -07:00
committed by GitHub
co-authored by Kaylee Williams
parent 73bbe20ca7
commit 7558fb064a
8 changed files with 433 additions and 41 deletions
+3 -23
View File
@@ -155,7 +155,6 @@ export function MobileBrowserPane({
const [frameMetadata, setFrameMetadata] = useState<BrowserScreencastFrameMetadata | null>(
cachedInitialFrame?.metadata ?? null
)
const [ready, setReady] = useState(cachedInitialFrame !== null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [dialog, setDialog] = useState<BrowserDialogState | null>(null)
@@ -174,7 +173,6 @@ export function MobileBrowserPane({
const browserLayerRefs = useRef<[View | null, View | null]>([null, null])
const pendingFrameLayerRef = useRef<FrameLayer | null>(null)
const visibleFrameLayerRef = useRef<FrameLayer>(0)
const readyRef = useRef(cachedInitialFrame !== null)
const busyRef = useRef(false)
const lastAppliedFrameAtRef = useRef(0)
const pendingThrottledFrameRef = useRef<{
@@ -309,10 +307,6 @@ export function MobileBrowserPane({
busyRef.current = false
setBusy(false)
}
if (!readyRef.current) {
readyRef.current = true
setReady(true)
}
}, [])
const clearFrameThrottle = useCallback(() => {
@@ -400,16 +394,12 @@ export function MobileBrowserPane({
frameMetadataRef.current = cachedFrame.metadata
setFrameUri(cachedFrame.uri)
setFrameMetadata(cachedFrame.metadata)
readyRef.current = true
setReady(true)
} else {
frameUriRef.current = null
frameMountedRef.current = false
setFrameUri(null)
setFrameMetadata(null)
frameMetadataRef.current = null
readyRef.current = false
setReady(false)
}
} else {
frameMountedRef.current = true
@@ -478,10 +468,6 @@ export function MobileBrowserPane({
}
if (event.type === 'ready') {
clearStartupTimer()
if (!readyRef.current) {
readyRef.current = true
setReady(true)
}
if (busyRef.current) {
busyRef.current = false
setBusy(false)
@@ -495,10 +481,6 @@ export function MobileBrowserPane({
}
} else if (event.type === 'end') {
clearStartupTimer()
if (readyRef.current) {
readyRef.current = false
setReady(false)
}
if (busyRef.current) {
busyRef.current = false
setBusy(false)
@@ -518,10 +500,6 @@ export function MobileBrowserPane({
}
const message = event.message ?? event.error?.message ?? 'Browser stream failed.'
if (shouldSurfaceBrowserError(message)) {
if (readyRef.current) {
readyRef.current = false
setReady(false)
}
setError(message)
}
}
@@ -1209,7 +1187,9 @@ export function MobileBrowserPane({
) : null}
{!renderedFrameSource || busy || error ? (
<View pointerEvents="none" style={styles.overlay}>
{busy || (!ready && !error) ? (
{/* Why: a stream can report ready and then deliver no frames, so key the
indicator off actually having pixels or it clears into a blank pane. */}
{busy || (!renderedFrameSource && !error) ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : null}
{error ? <Text style={styles.errorText}>{error}</Text> : null}
@@ -0,0 +1,154 @@
import { Buffer } from 'buffer'
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import {
BrowserScreencastOpcode,
type BrowserScreencastFrame
} from '../transport/browser-screencast-protocol'
import type { RpcClient } from '../transport/rpc-client'
import { MobileBrowserPane, type MobileBrowserTab } from './MobileBrowserPane'
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
Image: 'Image',
PanResponder: { create: () => ({ panHandlers: {} }) },
PixelRatio: { get: () => 2 },
Platform: { OS: 'android' },
Pressable: 'Pressable',
StyleSheet: {
absoluteFillObject: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
create: (styles: unknown) => styles
},
Text: 'Text',
TextInput: 'TextInput',
View: 'View'
}))
// Why: covers icons reached transitively too (the view-mode switch), not just the pane's own
// imports — vitest throws on the first unmocked export rather than rendering without it.
vi.mock('lucide-react-native', () => ({
ArrowUp: 'ArrowUp',
ChevronLeft: 'ChevronLeft',
ChevronRight: 'ChevronRight',
Monitor: 'Monitor',
RefreshCw: 'RefreshCw',
Smartphone: 'Smartphone'
}))
type Subscription = {
listener: (payload: unknown) => void
onBinaryFrame?: (frame: BrowserScreencastFrame) => void
}
let pageCounter = 0
function makeFrame(): BrowserScreencastFrame {
return {
opcode: BrowserScreencastOpcode.Frame,
seq: 1,
format: 'jpeg',
metadata: { deviceWidth: 360, deviceHeight: 640, pageScaleFactor: 1 },
image: new TextEncoder().encode('frame')
}
}
function spinnerCount(renderer: ReactTestRenderer): number {
return renderer.root.findAllByType('ActivityIndicator').length
}
async function renderPane(): Promise<{ renderer: ReactTestRenderer; stream: Subscription }> {
pageCounter += 1
const subscriptions: Subscription[] = []
const client = {
subscribe: (
_method: string,
_params: unknown,
listener: (payload: unknown) => void,
options?: { onBinaryFrame?: (frame: BrowserScreencastFrame) => void }
) => {
subscriptions.push({ listener, onBinaryFrame: options?.onBinaryFrame })
return () => {}
},
request: vi.fn()
} as unknown as RpcClient
const tab: MobileBrowserTab = {
type: 'browser',
id: `tab-${pageCounter}`,
title: 'Dashboard',
browserWorkspaceId: 'bw-1',
browserPageId: `page-${pageCounter}`,
url: 'https://dashboard.example',
loading: false,
canGoBack: false,
canGoForward: false,
isActive: true
}
let renderer: ReactTestRenderer
await act(async () => {
renderer = create(
createElement(MobileBrowserPane, {
client,
// Why: unique worktree id keeps each test on a cold module-level frame cache.
worktreeId: `wt-${pageCounter}`,
tab,
screencastSupported: true,
keyboardLift: 0,
bottomInset: 0,
onToast: () => {}
}),
{ createNodeMock: () => ({ setNativeProps: () => {} }) }
)
await Promise.resolve()
})
const mounted: ReactTestRenderer = renderer
const viewport = mounted.root
.findAllByType('View')
.find((node) => typeof node.props.onLayout === 'function')
if (!viewport) {
throw new Error('Viewport with onLayout not found')
}
act(() => {
viewport.props.onLayout({ nativeEvent: { layout: { width: 360, height: 640 } } })
})
const stream = subscriptions[0]
if (!stream) {
throw new Error('browser.screencast subscription not created')
}
return { renderer: mounted, stream }
}
describe('MobileBrowserPane with a stream that reports ready but sends no frames', () => {
// Why: a host that stops painting still reports `ready`, so the pane used to clear its
// indicator and leave an unexplained black rectangle.
it('keeps showing the loading indicator instead of an empty black pane', async () => {
const { renderer, stream } = await renderPane()
act(() => {
stream.listener({ type: 'ready', tab: { url: 'https://dashboard.example' } })
})
expect(spinnerCount(renderer)).toBeGreaterThan(0)
})
it('clears the indicator once real pixels arrive', async () => {
const { renderer, stream } = await renderPane()
act(() => {
stream.listener({ type: 'ready', tab: { url: 'https://dashboard.example' } })
})
act(() => {
stream.onBinaryFrame?.(makeFrame())
})
expect(spinnerCount(renderer)).toBe(0)
const source = renderer.root
.findAllByType('Image')
.map((image) => (image.props.source as { uri?: string } | null)?.uri)
.find((uri) => typeof uri === 'string')
expect(source).toContain(Buffer.from(makeFrame().image).toString('base64'))
})
})
@@ -5,6 +5,7 @@ import Sidebar from '../components/Sidebar'
import RightSidebar from '../components/right-sidebar'
import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/RecoverableRenderErrorBoundary'
import { FloatingTerminalToggleButton } from '../components/floating-terminal/FloatingTerminalToggleButton'
import { TerminalWorkbenchContainer } from '../components/TerminalWorkbenchContainer'
import type { VirtualizedScrollAnchor } from '../hooks/useVirtualizedScrollAnchor'
import { TitlebarLeftControls } from './TitlebarLeftControls'
import { RightSidebarToggle, TitlebarMainStrip } from './TitlebarMainStrip'
@@ -174,13 +175,7 @@ export function AppWorkspaceShell(props: {
)}
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
{layout.shouldMountTerminalWorkbench ? (
<div
className={
layout.terminalWorkbenchVisible
? 'flex flex-1 min-w-0 min-h-0'
: 'hidden flex-1 min-w-0 min-h-0'
}
>
<TerminalWorkbenchContainer isVisible={layout.terminalWorkbenchVisible}>
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="terminal.workbench"
@@ -198,7 +193,7 @@ export function AppWorkspaceShell(props: {
<Terminal />
</RecoverableRenderErrorBoundary>
</Suspense>
</div>
</TerminalWorkbenchContainer>
) : null}
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
+26 -10
View File
@@ -4,7 +4,6 @@ import React, { useEffect, useCallback, useMemo, useRef, useState, Suspense } fr
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { createPortal } from 'react-dom'
import { toast } from 'sonner'
import { useShallow } from 'zustand/react/shallow'
import {
BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT,
TOGGLE_TERMINAL_PANE_EXPAND_EVENT,
@@ -55,6 +54,10 @@ import {
onBrowserDriverChange,
useBrowserMobileDriverForAny
} from '@/lib/pane-manager/browser-mobile-driver-state'
import {
useAnyBrowserGuestNeedsPaint,
useWorktreeBrowserPageIds
} from './browser-pane/browser-guest-paint-retention'
import TerminalPaneOverlayLayer from './terminal-pane/TerminalPaneOverlayLayer'
import {
collectBrowserWebviewIds,
@@ -445,6 +448,11 @@ function Terminal(): React.JSX.Element | null {
const effectiveActiveLayout = renderedActiveWorktreeId
? getEffectiveLayoutForWorktree(renderedActiveWorktreeId)
: undefined
// Why: both wrappers below sit above every browser <webview>, so a remote controller needs
// them to drop `hidden` too — the per-worktree surface hatch cannot override an ancestor.
const retainBrowserGuestPaint = useAnyBrowserGuestNeedsPaint(
!renderedActiveWorktreeId || !effectiveActiveLayout
)
const activeWorktreeBrowserTabIdsKey = renderedActiveWorktreeId
? (browserTabsByWorktree[renderedActiveWorktreeId] ?? []).map((tab) => tab.id).join(',')
: ''
@@ -2411,7 +2419,15 @@ function Terminal(): React.JSX.Element | null {
return (
<div
className={`flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${renderedActiveWorktreeId ? '' : ' hidden'}`}
// Why: already out of flow via the workbench container when hidden, so retention only
// has to drop `hidden` — it does not need to leave the flex column a second time.
className={`flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
renderedActiveWorktreeId
? ''
: retainBrowserGuestPaint
? ' opacity-0 pointer-events-none'
: ' hidden'
}`}
data-rendered-active-worktree-id={renderedActiveWorktreeId ?? undefined}
>
<EditorAutosaveController />
@@ -2478,7 +2494,13 @@ function Terminal(): React.JSX.Element | null {
{anyMountedWorktreeHasLayout ? (
<div
className={`relative flex flex-1 min-w-0 min-h-0 overflow-hidden${effectiveActiveLayout ? '' : ' hidden'}`}
className={`relative flex flex-1 min-w-0 min-h-0 overflow-hidden${
effectiveActiveLayout
? ''
: retainBrowserGuestPaint
? ' opacity-0 pointer-events-none'
: ' hidden'
}`}
>
{/* Why: absolutely position each mounted surface so hidden trees don't reflow the active one; the relative anchor sizes panes to the workspace body. */}
{workspaceSurfaces
@@ -2789,13 +2811,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
backgroundMountTabIds: ReadonlySet<string> | null
activationDeferredMountTabIds: ReadonlySet<string> | null
}): React.JSX.Element {
const browserPageIds = useAppStore(
useShallow((state) =>
(state.browserTabsByWorktree[worktreeId] ?? []).flatMap((tab) =>
tab.pageIds && tab.pageIds.length > 0 ? tab.pageIds : [tab.activePageId ?? tab.id]
)
)
)
const browserPageIds = useWorktreeBrowserPageIds(worktreeId)
const hasAutomationVisibleBrowser = useBrowserAutomationVisibilityForAny(browserPageIds)
const hasMobileDrivenBrowser = useBrowserMobileDriverForAny(browserPageIds)
const shouldKeepPaintable =
@@ -0,0 +1,105 @@
// @vitest-environment happy-dom
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { BrowserTab as BrowserTabState } from '../../../shared/browser-workspace-types'
type MockAppState = { browserTabsByWorktree: Record<string, readonly BrowserTabState[]> }
const mocks = vi.hoisted(() => ({ state: null as MockAppState | null }))
vi.mock('../store', () => ({
useAppStore: (selector: (state: MockAppState) => unknown) => {
if (!mocks.state) {
throw new Error('mock app state not initialized')
}
return selector(mocks.state)
}
}))
// Why: the driver and automation-lease modules are the real ones — mocking them would leave
// the wiring under test unproven, which is the whole point of this file.
const { setDriverForBrowserPage } = await import('../lib/pane-manager/browser-mobile-driver-state')
const { acquireBrowserAutomationVisibility, releaseBrowserAutomationVisibility } =
await import('./browser-pane/browser-automation-visibility')
const { TerminalWorkbenchContainer } = await import('./TerminalWorkbenchContainer')
const PAGE_ID = 'page-1'
function mountWorkbench(isVisible: boolean): HTMLElement {
mocks.state = {
browserTabsByWorktree: {
'wt-1': [{ id: 'tab-1', activePageId: PAGE_ID }] as unknown as readonly BrowserTabState[]
}
}
const { container } = render(
<TerminalWorkbenchContainer isVisible={isVisible}>
<span>workbench</span>
</TerminalWorkbenchContainer>
)
const node = container.querySelector('[data-terminal-workbench-container]')
if (!(node instanceof HTMLElement)) {
throw new Error('workbench container not rendered')
}
return node
}
afterEach(() => {
cleanup()
setDriverForBrowserPage(PAGE_ID, { kind: 'idle' })
mocks.state = null
})
describe('TerminalWorkbenchContainer', () => {
it('parks with display:none when nothing remote needs the guest painting', () => {
expect(mountWorkbench(false).className).toContain('hidden')
})
it('renders normally on the workspace view', () => {
const node = mountWorkbench(true)
expect(node.className).not.toContain('hidden')
expect(node.className).not.toContain('opacity-0')
expect(node.hasAttribute('inert')).toBe(false)
})
// Why: `hidden` is display:none, and Chromium emits no screencast frames from inside such a
// subtree — this is the exact regression that froze a phone's browser pane on Settings.
it('never applies display:none while a phone drives one of its pages', () => {
setDriverForBrowserPage(PAGE_ID, { kind: 'mobile', clientId: 'client-1' })
const node = mountWorkbench(false)
expect(node.className).not.toContain('hidden')
expect(node.className).toContain('opacity-0')
})
// Why: the cold-start deadlock. A screencast cannot start until the guest registers, and the
// guest only mounts under an automation bootstrap lease — gating on the mobile driver alone
// means the guest never mounts, so the driver never flips.
it('never applies display:none while an automation lease holds one of its pages', () => {
const token = acquireBrowserAutomationVisibility(PAGE_ID)
try {
const node = mountWorkbench(false)
expect(node.className).not.toContain('hidden')
expect(node.className).toContain('opacity-0')
} finally {
releaseBrowserAutomationVisibility(token)
}
})
it('stays out of flow and non-interactive while painting hidden', () => {
// Why: the active page is a flex sibling — an in-flow workbench would halve its height,
// and a hittable one would swallow its clicks.
setDriverForBrowserPage(PAGE_ID, { kind: 'mobile', clientId: 'client-1' })
const node = mountWorkbench(false)
expect(node.className).toContain('absolute')
expect(node.className).toContain('pointer-events-none')
expect(node.hasAttribute('inert')).toBe(true)
expect(node.getAttribute('aria-hidden')).toBe('true')
})
it('re-parks once the phone stops driving the page', () => {
setDriverForBrowserPage(PAGE_ID, { kind: 'mobile', clientId: 'client-1' })
expect(mountWorkbench(false).className).not.toContain('hidden')
cleanup()
setDriverForBrowserPage(PAGE_ID, { kind: 'idle' })
expect(mountWorkbench(false).className).toContain('hidden')
})
})
@@ -0,0 +1,34 @@
import type React from 'react'
import { useAnyBrowserGuestNeedsPaint } from './browser-pane/browser-guest-paint-retention'
// Why: the outermost ancestor of every browser <webview>. Parking it with `hidden` whenever
// the user leaves the workspace view also stops the guest compositing, which silently kills
// screencast frames for a phone or an agent driving that page.
export function TerminalWorkbenchContainer({
isVisible,
children
}: {
isVisible: boolean
children: React.ReactNode
}): React.JSX.Element {
const retainBrowserGuestPaint = useAnyBrowserGuestNeedsPaint(!isVisible)
return (
<div
className={
isVisible
? 'flex flex-1 min-w-0 min-h-0'
: retainBrowserGuestPaint
? // Why: absolute keeps the invisible workbench out of the flex column so the
// active page (Settings, Tasks, …) still gets the full content area.
'absolute inset-0 flex opacity-0 pointer-events-none'
: 'hidden flex-1 min-w-0 min-h-0'
}
// Why: a paintable-but-hidden workbench must stay unreachable by Tab / assistive tech.
inert={!isVisible}
aria-hidden={!isVisible}
data-terminal-workbench-container=""
>
{children}
</div>
)
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { collectBrowserPageIds } from './browser-guest-paint-retention'
describe('collectBrowserPageIds', () => {
it('prefers the full page list so every guest under a tab is covered', () => {
expect(
collectBrowserPageIds([
{ id: 'tab-1', activePageId: 'page-a', pageIds: ['page-a', 'page-b'] }
])
).toEqual(['page-a', 'page-b'])
})
// Why: a split tab can hold a background page a phone is driving while a different page is
// active; collecting only the active one would let that guest get parked.
it('does not drop background pages in favour of the active one', () => {
expect(
collectBrowserPageIds([{ id: 't', activePageId: 'p1', pageIds: ['p1', 'p2'] }])
).toContain('p2')
})
it('falls back to the active page id when the list is empty', () => {
expect(collectBrowserPageIds([{ id: 'tab-1', activePageId: 'page-a', pageIds: [] }])).toEqual([
'page-a'
])
})
// Why: legacy single-page tabs reuse the tab id as the page id.
it('falls back to the tab id when there is no active page', () => {
expect(collectBrowserPageIds([{ id: 'tab-1' }])).toEqual(['tab-1'])
expect(collectBrowserPageIds([{ id: 'tab-1', activePageId: null }])).toEqual(['tab-1'])
})
it('tolerates a missing worktree entry', () => {
expect(collectBrowserPageIds(undefined)).toEqual([])
expect(collectBrowserPageIds(null)).toEqual([])
})
it('flattens across tabs', () => {
expect(
collectBrowserPageIds([
{ id: 'tab-1', pageIds: ['a'] },
{ id: 'tab-2', pageIds: ['b', 'c'] }
])
).toEqual(['a', 'b', 'c'])
})
})
@@ -0,0 +1,62 @@
import { useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '../../store'
import { useBrowserMobileDriverForAny } from '../../lib/pane-manager/browser-mobile-driver-state'
import { useBrowserAutomationVisibilityForAny } from './browser-automation-visibility'
// Why: Chromium never paints inside a display:none subtree, so a browser <webview> stops
// emitting screencast frames if ANY ancestor is parked that way — the pane-level hatch in
// browser-page-paintability.ts cannot override one. Every container from the app shell down
// to the guest therefore shares this predicate; if one of them keeps using `hidden`, a phone
// or an agent driving that page silently receives no frames.
type BrowserTabPageIdSource = {
id: string
activePageId?: string | null
pageIds?: readonly string[] | null
}
export function collectBrowserPageIds(
tabs: readonly BrowserTabPageIdSource[] | null | undefined
): string[] {
return (tabs ?? []).flatMap((tab) =>
tab.pageIds && tab.pageIds.length > 0 ? tab.pageIds : [tab.activePageId ?? tab.id]
)
}
// Why: a stable identity keeps the disabled branch from re-running downstream shallow compares.
const NO_BROWSER_PAGE_IDS: string[] = []
const NO_BROWSER_TABS_BY_WORKTREE: Record<string, BrowserTabPageIdSource[]> = {}
export function useWorktreeBrowserPageIds(worktreeId: string): string[] {
return useAppStore(
useShallow((state) => collectBrowserPageIds(state.browserTabsByWorktree[worktreeId]))
)
}
export function useBrowserGuestPaintRetention(browserPageIds: readonly string[]): boolean {
const hasAutomationVisibleBrowser = useBrowserAutomationVisibilityForAny(browserPageIds)
const hasMobileDrivenBrowser = useBrowserMobileDriverForAny(browserPageIds)
return hasAutomationVisibleBrowser || hasMobileDrivenBrowser
}
// Why: `enabled` gates a scan across every worktree's tabs, which only matters while the
// caller is hidden. Automation visibility is load-bearing and not just symmetry with the
// per-worktree gate: a cold screencast cannot start without it. Main asks the renderer to
// mount a hidden guest via browser:activateView, which takes an automation bootstrap lease —
// and the mobile driver flag only flips AFTER that guest registers and streaming begins. Gate
// on the driver alone and the guest never mounts, so the driver never flips: a deadlock that
// leaves the page unreachable from the phone entirely.
export function useAnyBrowserGuestNeedsPaint(enabled: boolean): boolean {
const browserTabsByWorktree = useAppStore((state) =>
enabled ? state.browserTabsByWorktree : NO_BROWSER_TABS_BY_WORKTREE
)
const browserPageIds = useMemo(
() =>
enabled
? Object.values(browserTabsByWorktree).flatMap((tabs) => collectBrowserPageIds(tabs))
: NO_BROWSER_PAGE_IDS,
[browserTabsByWorktree, enabled]
)
return useBrowserGuestPaintRetention(browserPageIds)
}