diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx
index 791b5f75beb..05c6f1302c3 100644
--- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx
+++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx
@@ -177,13 +177,13 @@ describe('BrowserPaneOverlayLayer', () => {
expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(0)
})
- it('keeps inactive browser panes mounted for a visible worktree', () => {
+ it('defers inactive browser panes while retaining their viewport slots', () => {
const markup = renderOverlay({ isWorktreeActive: true })
expect(markup).toContain('data-browser-pane-id="browser-a"')
expect(markup).toContain('data-browser-pane-active="true"')
- expect(markup).toContain('data-browser-pane-id="browser-b"')
- expect(markup).toContain('data-browser-pane-active="false"')
+ expect(markup).not.toContain('data-browser-pane-id="browser-b"')
+ expect(markup).toContain('data-browser-overlay-tab-id="browser-b"')
})
it('marks the active browser pane focused when its own group holds focus', () => {
@@ -195,6 +195,35 @@ describe('BrowserPaneOverlayLayer', () => {
)
})
+ it('restores 200 tabs on demand and preserves viewport roots across parking and selection', () => {
+ const browsers = Array.from({ length: 200 }, (_, index) =>
+ createBrowserTab(`browser-${index}`, [`page-${index}`])
+ )
+ const tabs = browsers.map((browser, index) =>
+ createUnifiedBrowserTab(`tab-${index}`, browser.id, index)
+ )
+ mocks.state!.browserTabsByWorktree['wt-1'] = browsers
+ mocks.state!.unifiedTabsByWorktree['wt-1'] = tabs
+ const group = mocks.state!.groupsByWorktree['wt-1'][0]
+ mocks.state!.groupsByWorktree['wt-1'] = [
+ { ...group, activeTabId: tabs[0].id, tabOrder: tabs.map((tab) => tab.id) }
+ ]
+ const view = render()
+ const slot = view.container.querySelector('[data-browser-overlay-tab-id="browser-0"]')!
+ const viewport = slot.firstElementChild
+ expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(1)
+ expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(200)
+
+ view.rerender()
+ expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(0)
+ mocks.state!.groupsByWorktree['wt-1'] = [{ ...group, activeTabId: tabs[199].id }]
+ view.rerender()
+ expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(1)
+ expect(view.container.querySelector('[data-browser-pane-id="browser-199"]')).not.toBeNull()
+ expect(slot.firstElementChild).toBe(viewport)
+ expect(viewport!.isConnected).toBe(true)
+ })
+
it('keeps an active browser pane unfocused when another split holds focus (#11348)', () => {
mocks.state = createState()
mocks.state.groupsByWorktree = {
diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx
index d262e05b43a..1f74340f11a 100644
--- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx
+++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx
@@ -34,7 +34,6 @@ type BrowserOverlaySlotProps = {
chromeShortcutScope: BrowserChromeShortcutScope
// Why: overlay is a sibling of the group layout, so pane focus doesn't bubble to TabGroupPanel; re-sync it here or split-view clicks leave activeGroupIdByWorktree stale.
onFocusOwningGroup: ((groupId: string) => void) | undefined
- isWorktreeActive: boolean
}
// Why: memoize each slot so unrelated worktree mutations don't cascade a re-render into every BrowserPane subtree.
@@ -43,8 +42,7 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
groupId,
isActive,
chromeShortcutScope,
- onFocusOwningGroup,
- isWorktreeActive
+ onFocusOwningGroup
}: BrowserOverlaySlotProps): React.JSX.Element {
// Why: persistent page viewports (webview guests) live under this root so they survive BrowserPane chrome unmounts without reparenting.
const setSlotViewportRef = useCallback(
@@ -60,8 +58,8 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
: [browserTab.activePageId ?? browserTab.id]
const needsGuestPaint = useBrowserGuestPaintRetention(browserPageIds)
const isPaintable = isActive || needsGuestPaint
- // Why: hidden worktrees keep lightweight overlay slots, but park their webviews unless a remote controller or viewer needs the guest.
- const shouldMountPane = isWorktreeActive || needsGuestPaint
+ // Why: restoring a workspace must not create guests for every inactive tab.
+ const shouldMountPane = isActive || needsGuestPaint
// Why: CSS anchor positioning pins the overlay to its owning group's body — a tab move only swaps positionAnchor, no measurement/state.
// Orphan branch (no anchorName) stays display:none until the tab is reassigned or destroyed.
const style: React.CSSProperties = useMemo(
@@ -104,7 +102,7 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
onFocusCapture={handleFocus}
>
- {/* Why: hidden worktrees park the heavy pane subtree; visible ones keep stable slots so reparenting can't destroy the webview guest. */}
+ {/* Persistent viewport slots keep live guests intact when inactive chrome unmounts. */}
{shouldMountPane ? (
)
})}
diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx
index 527a05f1460..70409be3d5d 100644
--- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx
+++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx
@@ -149,14 +149,49 @@ describe('browser workspace pane retention props', () => {
hydrateBrowserRemoteViewerPages([])
})
+ it('loads only the selected page out of 200 after mounting and remounting', () => {
+ const pages = Array.from({ length: 200 }, (_, index) => createPage(`page-${index}`))
+ mocks.state!.browserPagesByWorkspace[WORKSPACE_ID] = pages
+ const workspace = { ...createWorkspace(), activePageId: pages[0].id }
+ const view = render()
+ const renderedIds = (): (string | null)[] =>
+ [...view.container.querySelectorAll('[data-browser-page-id]')].map((node) =>
+ node.getAttribute('data-browser-page-id')
+ )
+ expect(renderedIds()).toEqual(['page-0'])
+
+ view.rerender()
+ expect(renderedIds()).toEqual(['page-199'])
+ view.rerender()
+ expect(renderedIds()).toEqual([])
+ view.rerender()
+ expect(renderedIds()).toEqual(['page-0'])
+ })
+
+ it.each(['automation', 'mobile', 'viewer'])('loads an inactive page for %s only', (consumer) => {
+ const token = consumer === 'automation' ? acquireBrowserAutomationVisibility('page-b') : null
+ if (consumer === 'mobile') {
+ hydrateBrowserDrivers([
+ { browserPageId: 'page-b', driver: { kind: 'mobile', clientId: 'phone-1' } }
+ ])
+ }
+ if (consumer === 'viewer') {
+ hydrateBrowserRemoteViewerPages(['page-b'])
+ }
+ try {
+ const view = render()
+ expect(view.container.querySelector('[data-browser-page-id="page-a"]')).toBeNull()
+ expect(view.container.querySelector('[data-browser-page-id="page-b"]')).not.toBeNull()
+ } finally {
+ if (token) {
+ releaseBrowserAutomationVisibility(token)
+ }
+ }
+ })
+
it('threads all three retention terms to the page that owns them', () => {
renderWorkspacePane()
- expect(propsFor('page-b')).toEqual({
- id: 'page-b',
- isAutomationVisible: false,
- isMobileDriven: false,
- isRemotelyViewed: false
- })
+ expect(mocks.pageProps.some((props) => props.id === 'page-b')).toBe(false)
cleanup()
const token = acquireBrowserAutomationVisibility('page-b')
diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.tsx
index e5c2cc28240..05ea7e49cac 100644
--- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.tsx
+++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.tsx
@@ -55,13 +55,26 @@ export default function BrowserPane({
const automationVisiblePageIds = useBrowserAutomationVisiblePageIds(browserPageIds)
const mobileDrivenPageIds = useBrowserMobileDrivenPageIds(browserPageIds)
const remotelyViewedPageIds = useBrowserRemotelyViewedPageIds(browserPageIds)
- // Why: inactive webviews must stay mounted in their original DOM parent; unmounting/reparenting loses form text and SPA state.
+ // Why: dormant pages load on demand; live guests survive chrome unmounts in persistent viewports.
const renderedBrowserPages = useMemo(
() =>
browserPages.filter(
- (page) => !getBrowserPageRuntimeEnvironmentId(page, activeRuntimeEnvironmentId)
+ (page) =>
+ !getBrowserPageRuntimeEnvironmentId(page, activeRuntimeEnvironmentId) &&
+ ((isActive && page.id === activeBrowserPageId) ||
+ automationVisiblePageIds.has(page.id) ||
+ mobileDrivenPageIds.has(page.id) ||
+ remotelyViewedPageIds.has(page.id))
),
- [browserPages, activeRuntimeEnvironmentId]
+ [
+ browserPages,
+ activeRuntimeEnvironmentId,
+ isActive,
+ activeBrowserPageId,
+ automationVisiblePageIds,
+ mobileDrivenPageIds,
+ remotelyViewedPageIds
+ ]
)
const renderedBrowserPageIds = useMemo(
() => renderedBrowserPages.map((page) => page.id),