fix(browser): stop webview reload when moving tab between groups (#959)

This commit is contained in:
Brennan Benson
2026-04-22 22:22:26 -07:00
committed by GitHub
parent f5ada40afd
commit ccf37484a0
5 changed files with 303 additions and 39 deletions
+51 -12
View File
@@ -26,7 +26,9 @@ import {
} from './editor/editor-autosave'
import { isUpdaterQuitAndInstallInProgress } from '@/lib/updater-beforeunload'
import EditorAutosaveController from './editor/EditorAutosaveController'
import type { TabGroupLayoutNode } from '../../../shared/types'
import BrowserPane, { destroyPersistentWebview } from './browser-pane/BrowserPane'
import BrowserPaneOverlayLayer from './browser-pane/BrowserPaneOverlayLayer'
import { reconcileTabOrder } from './tab-bar/reconcile-order'
import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout'
import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal'
@@ -1013,19 +1015,13 @@ function Terminal(): React.JSX.Element | null {
// so the terminal/browser surface hides on the tasks page too.
const isVisible = activeView === 'terminal' && worktree.id === activeWorktreeId
return (
<div
<WorktreeSplitSurface
key={`tab-groups-${worktree.id}`}
className={isVisible ? 'absolute inset-0 flex' : 'absolute inset-0 hidden'}
aria-hidden={!isVisible}
>
<CodexRestartChip worktreeId={worktree.id} />
<TabGroupSplitLayout
layout={layout}
worktreeId={worktree.id}
focusedGroupId={activeGroupIdByWorktree[worktree.id]}
isWorktreeActive={isVisible}
/>
</div>
worktreeId={worktree.id}
layout={layout}
focusedGroupId={activeGroupIdByWorktree[worktree.id]}
isVisible={isVisible}
/>
)
})}
</div>
@@ -1237,4 +1233,47 @@ function Terminal(): React.JSX.Element | null {
)
}
// Why: each TabGroupPanel tags its body element with an `anchor-name`, and
// a single worktree-level BrowserPaneOverlayLayer renders every browser tab
// for this worktree once — keyed by browserTab.id only — and pins each pane
// to the owning group's anchor via CSS `position-anchor`. Moving a tab
// between groups now only changes which anchor-name the overlay references,
// so the `<webview>` is never reparented (and never reloads). Mirrors
// VS Code's OverlayWebview claim/release pattern, with the browser doing all
// layout tracking for free.
//
// Why `React.memo`: Terminal.tsx has many store subscriptions and re-renders
// on unrelated updates (terminal keystrokes, editor edits, focus changes).
// Without memoization, every Terminal re-render would cascade into
// BrowserPaneOverlayLayer and its BrowserPane subtrees. Memoizing here means
// the surface only re-renders when its own props (worktreeId / layout /
// focusedGroupId / isVisible) actually change.
const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
worktreeId,
layout,
focusedGroupId,
isVisible
}: {
worktreeId: string
layout: TabGroupLayoutNode
focusedGroupId?: string
isVisible: boolean
}): React.JSX.Element {
return (
<div
className={isVisible ? 'absolute inset-0 flex' : 'absolute inset-0 hidden'}
aria-hidden={!isVisible}
>
<CodexRestartChip worktreeId={worktreeId} />
<TabGroupSplitLayout
layout={layout}
worktreeId={worktreeId}
focusedGroupId={focusedGroupId}
isWorktreeActive={isVisible}
/>
<BrowserPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />
</div>
)
})
export default React.memo(Terminal)
@@ -0,0 +1,195 @@
import { memo, useCallback, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '../../store'
import type { BrowserTab as BrowserTabState, Tab, TabGroup } from '../../../../shared/types'
import BrowserPane from './BrowserPane'
import { browserSlotAnchorName } from './browser-pane-slots'
// Why: Electron `<webview>` destroys its guest contents whenever its DOM
// parent changes. Rendering one BrowserPane per tab at the worktree level
// (keyed only by browserTab.id) means moving a tab between groups never
// remounts the pane and never reparents the webview — it only updates the
// overlay's CSS `position-anchor` so the pane tracks the new owning group's
// body via native CSS anchor positioning.
type BrowserOverlayAssignment = {
groupId: string
isActiveInGroup: boolean
}
const EMPTY_BROWSER_TABS: readonly BrowserTabState[] = []
const EMPTY_UNIFIED_TABS: readonly Tab[] = []
const EMPTY_GROUPS: readonly TabGroup[] = []
type BrowserOverlaySlotProps = {
browserTab: BrowserTabState
// Why: `undefined` means this browser tab has no owning group (an "orphan" —
// present in `browserTabs` but not referenced by any group's unified-tab
// list). See the fallback branch below for why we keep such tabs mounted.
groupId: string | undefined
isActive: boolean
// Why: the legacy architecture rendered BrowserPane inside TabGroupPanel, so
// React events from the pane bubbled through TabGroupPanel's
// `onPointerDown={focusGroup}` / `onFocusCapture={focusGroup}`. Now that
// BrowserPane lives in a worktree-level overlay that is a SIBLING of
// TabGroupSplitLayout, those events no longer reach TabGroupPanel — so in
// split view, clicking the browser chrome would leave
// `activeGroupIdByWorktree` stale. The overlay slot re-implements that
// focus sync directly, targeting the owning group.
onFocusOwningGroup: ((groupId: string) => void) | undefined
}
// Why: each overlay slot is memoized so its BrowserPane subtree only re-renders
// when its own `browserTab`, `groupId`, or `isActive` changes. Without this,
// any unrelated worktree mutation (terminal keystrokes, editor updates, etc.)
// that re-renders the parent overlay layer would cascade into every
// BrowserPane — defeating the "never reparent/reload the webview" goal of
// this layer by constantly re-running props diffing on heavy subtrees.
const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
browserTab,
groupId,
isActive,
onFocusOwningGroup
}: BrowserOverlaySlotProps): React.JSX.Element {
const anchorName = groupId !== undefined ? browserSlotAnchorName(groupId) : undefined
// Why: each overlay pins itself to the owning TabGroupPanel's body via CSS
// anchor positioning. `anchor()` resolves top/left relative to the viewport,
// and the overlay's own `position: absolute` inside a positioned ancestor
// (the worktree surface div) converts those to the surface's coordinate
// space. `anchor-size()` fills the slot exactly. When the tab moves between
// groups, only `positionAnchor` changes and the browser relayouts on its
// own — no measurement or state updates.
//
// The orphan branch (no anchorName) keeps the pane mounted at 0×0
// display:none so the DOM parent stays stable and the `<webview>` guest
// survives until the tab is reassigned (e.g. mid-move) or explicitly
// destroyed via `closeBrowserTab`.
const style: React.CSSProperties = useMemo(
() =>
anchorName
? {
position: 'absolute',
positionAnchor: anchorName,
top: `anchor(${anchorName} top)`,
left: `anchor(${anchorName} left)`,
width: `anchor-size(${anchorName} width)`,
height: `anchor-size(${anchorName} height)`,
display: isActive ? 'flex' : 'none',
pointerEvents: isActive ? 'auto' : 'none'
}
: {
position: 'absolute',
top: 0,
left: 0,
width: 0,
height: 0,
display: 'none',
pointerEvents: 'none'
},
[anchorName, isActive]
)
const handleFocus = useCallback(() => {
if (groupId !== undefined && onFocusOwningGroup) {
onFocusOwningGroup(groupId)
}
}, [groupId, onFocusOwningGroup])
return (
<div
style={style}
data-browser-overlay-tab-id={browserTab.id}
onPointerDown={handleFocus}
onFocusCapture={handleFocus}
>
<BrowserPane browserTab={browserTab} isActive={isActive} />
</div>
)
})
// Why: memoize so parent re-renders (e.g. `WorktreeSplitSurface` re-rendering
// because `focusedGroupId` changed — a prop this component doesn't consume)
// don't rerun the overlay's zustand selector or the assignments mapping.
// The child `BrowserOverlaySlot` is already memoized, but skipping this layer
// entirely when its own props are unchanged keeps the fast path fastest.
const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({
worktreeId,
isWorktreeActive
}: {
worktreeId: string
isWorktreeActive: boolean
}): React.JSX.Element {
const { browserTabs, unifiedTabs, groups } = useAppStore(
useShallow((state) => ({
browserTabs: state.browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS,
unifiedTabs: state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_UNIFIED_TABS,
groups: state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS
}))
)
const focusGroup = useAppStore((state) => state.focusGroup)
// Why: stable callback identity so BrowserOverlaySlot's memo isn't broken by
// a fresh function reference every render. The group id is passed in at call
// time so the same callback serves every slot regardless of which group owns
// that tab.
const focusOwningGroup = useCallback(
(groupId: string) => focusGroup(worktreeId, groupId),
[focusGroup, worktreeId]
)
// Why: derive the lookup OUTSIDE the zustand selector so shallow equality
// holds across unrelated store mutations. If we built the object inside the
// selector, every store change would create a new reference and useShallow
// would never find equality — the overlay would re-render on every
// keystroke in an unrelated terminal.
const groupActiveTabById = useMemo(() => {
const lookup: Record<string, string | null | undefined> = {}
for (const group of groups) {
lookup[group.id] = group.activeTabId
}
return lookup
}, [groups])
// Map each browser tab to the group that owns it (if any) and whether it's
// the currently active tab in that group. Tabs that exist in `browserTabs`
// but are not referenced by any group's unified-tab list are "orphans": we
// still render the pane (at 0×0 display:none — see fallback branch below)
// so the `<webview>` survives until the tab is either reassigned or
// explicitly destroyed. In normal flows this is a transient mid-move
// state, not a steady state: closing a tab calls `closeBrowserTab` which
// removes it from `browserTabs` (and `destroyPersistentWebview` tears
// down the guest), and "Close Group" closes each browser tab before
// collapsing the group shell — no follow-to-sibling migration happens.
const assignments = useMemo(() => {
const entries = new Map<string, BrowserOverlayAssignment>()
for (const tab of unifiedTabs) {
if (tab.contentType !== 'browser') {
continue
}
entries.set(tab.entityId, {
groupId: tab.groupId,
isActiveInGroup: groupActiveTabById[tab.groupId] === tab.id
})
}
return entries
}, [groupActiveTabById, unifiedTabs])
return (
<>
{browserTabs.map((browserTab) => {
const assignment = assignments.get(browserTab.id)
const isActive = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
return (
<BrowserOverlaySlot
key={browserTab.id}
browserTab={browserTab}
groupId={assignment?.groupId}
isActive={isActive}
onFocusOwningGroup={focusOwningGroup}
/>
)
})}
</>
)
})
export default BrowserPaneOverlayLayer
@@ -0,0 +1,29 @@
// Why: Electron `<webview>` guest contents are destroyed whenever the host
// element is reparented in the DOM. So browser panes cannot live inside
// TabGroupPanel (which would unmount/remount them when a tab moves between
// groups). Instead, a single worktree-level BrowserPaneOverlayLayer renders
// one stable `<BrowserPane>` per browser tab and positions it over the
// owning group's body via CSS anchor positioning. This module provides the
// anchor-name bridge between the two:
//
// TabGroupPanel body → `anchor-name: --orca-browser-slot-<groupId>`
// BrowserPaneOverlayLayer overlay → `position-anchor: --orca-browser-slot-<groupId>`
// `top: anchor(--… top); left: anchor(--… left);`
// `width: anchor-size(--… width); height: anchor-size(--… height);`
//
// The browser does all layout tracking for free — no ResizeObserver, no
// rect state, no subscribe/notify machinery. Moving a tab between groups
// only changes which anchor-name the overlay references, so the `<webview>`
// is never reparented (and never reloads). Mirrors VS Code's
// `OverlayWebview` claim/release pattern, with CSS doing the positioning.
const ANCHOR_PREFIX = '--orca-browser-slot-'
/**
* Returns the CSS anchor name for a given tab-group id. Anchor names must be
* `<dashed-ident>`; groupIds are UUIDs (hex + `-`) so they are already safe
* as suffixes. Prefixed so they cannot collide with unrelated anchors.
*/
export function browserSlotAnchorName(groupId: string): string {
return `${ANCHOR_PREFIX}${groupId}`
}
@@ -1,4 +1,4 @@
import { lazy, Suspense } from 'react'
import { lazy, Suspense, useMemo } from 'react'
import { useDroppable } from '@dnd-kit/core'
import { Columns2, Ellipsis, Rows2, X } from 'lucide-react'
import { useAppStore } from '../../store'
@@ -11,7 +11,7 @@ import {
} from '@/components/ui/dropdown-menu'
import TabBar from '../tab-bar/TabBar'
import TerminalPane from '../terminal-pane/TerminalPane'
import BrowserPane from '../browser-pane/BrowserPane'
import { browserSlotAnchorName } from '../browser-pane/browser-pane-slots'
import { useTabGroupWorkspaceModel } from './useTabGroupWorkspaceModel'
import TabGroupDropOverlay from './TabGroupDropOverlay'
import { getTabPaneBodyDroppableId, type TabDropZone } from './useTabDragSplit'
@@ -44,7 +44,6 @@ export default function TabGroupPanel({
const model = useTabGroupWorkspaceModel({ groupId, worktreeId })
const {
activeBrowserTab,
activeTab,
browserItems,
commands,
@@ -63,6 +62,21 @@ export default function TabGroupPanel({
},
disabled: !isTabDragActive
})
// Why: browser panes for this worktree are rendered once at the worktree
// level (BrowserPaneOverlayLayer) and positioned over the owning group's
// body via CSS anchor positioning. Tagging this body with a per-group
// `anchor-name` lets the overlay reference it via `position-anchor`;
// moving a tab between groups only swaps which anchor-name the overlay
// targets, never reparenting the `<webview>` (which would reload it).
const bodyAnchorName = browserSlotAnchorName(groupId)
// Why: memoize the style object so the literal isn't recreated on every
// render. A fresh object every render would make the body `<div>` appear
// to have a new `style` prop on every parent re-render, which defeats any
// downstream memoization keyed on referential equality.
const bodyAnchorStyle = useMemo(
() => ({ anchorName: bodyAnchorName }) as React.CSSProperties,
[bodyAnchorName]
)
const tabBar = (
<TabBar
@@ -268,7 +282,11 @@ export default function TabGroupPanel({
</div>
</div>
<div ref={setBodyDropRef} className="relative flex-1 min-h-0 overflow-hidden">
<div
ref={setBodyDropRef}
className="relative flex-1 min-h-0 overflow-hidden"
style={bodyAnchorStyle}
>
{activeDropZone ? <TabGroupDropOverlay zone={activeDropZone} /> : null}
{model.groupTabs
.filter((item) => item.contentType === 'terminal')
@@ -325,20 +343,12 @@ export default function TabGroupPanel({
</div>
)}
{browserItems.map((bt) => (
<div
key={bt.id}
className="absolute inset-0 flex min-h-0 min-w-0"
style={{
display: isWorktreeActive && activeBrowserTab?.id === bt.id ? undefined : 'none'
}}
>
<BrowserPane
browserTab={bt}
isActive={isWorktreeActive && activeBrowserTab?.id === bt.id}
/>
</div>
))}
{/* Why: browser panes are rendered at the worktree level by
BrowserPaneOverlayLayer and absolutely positioned over this body
element via the slot registered above. Rendering them per-group
here caused moving a browser tab between groups to unmount and
remount the pane, reparenting the Electron `<webview>` — which
destroys its guest contents and reloads the page. */}
</div>
</div>
)
@@ -145,14 +145,6 @@ export function useTabGroupWorkspaceModel({
[groupTabs, worktreeState.browserTabs]
)
const activeBrowserTab = useMemo(
() =>
activeTab?.contentType === 'browser'
? (worktreeState.browserTabs.find((bt) => bt.id === activeTab.entityId) ?? null)
: null,
[activeTab, worktreeState.browserTabs]
)
const runtimeTerminalTabById = useMemo(
() => new Map(worktreeState.runtimeTerminalTabs.map((tab) => [tab.id, tab])),
[worktreeState.runtimeTerminalTabs]
@@ -381,7 +373,6 @@ export function useTabGroupWorkspaceModel({
return {
group,
activeTab,
activeBrowserTab,
browserItems,
editorItems,
terminalTabs,