Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest'

const focusGroup = vi.hoisted(() => vi.fn())
const dismissAddressBarSuggestions = vi.hoisted(() => vi.fn())

vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({
unifiedTabsByWorktree: {
'wt-1': [{ contentType: 'browser', entityId: 'workspace-1', groupId: 'group-9' }]
},
focusGroup
})
}
}))

type Listener = () => void

function fakeWebview(): {
listeners: Map<string, Set<Listener>>
addEventListener(type: 'focus', listener: Listener): void
removeEventListener(type: 'focus', listener: Listener): void
} {
const listeners = new Map<string, Set<Listener>>()
return {
listeners,
addEventListener(type: 'focus', listener: Listener) {
const bucket = listeners.get(type) ?? new Set()
bucket.add(listener)
listeners.set(type, bucket)
},
removeEventListener(type: 'focus', listener: Listener) {
listeners.get(type)?.delete(listener)
}
}
}

describe('bindBrowserGuestFocus', () => {
it('focuses the workspace tab and removes that same callback on cleanup', async () => {
const { bindBrowserGuestFocus } = await import('./bind-browser-page-webview-listeners')
const webview = fakeWebview()
const cleanup = bindBrowserGuestFocus({
webview,
dismissAddressBarSuggestions,
worktreeId: 'wt-1',
workspaceId: 'workspace-1'
})

const focusListeners = webview.listeners.get('focus')
expect(focusListeners?.size).toBe(1)
focusListeners?.forEach((listener) => listener())
expect(dismissAddressBarSuggestions).toHaveBeenCalledTimes(1)
expect(focusGroup).toHaveBeenCalledWith('wt-1', 'group-9')

cleanup()
expect(webview.listeners.get('focus')?.size ?? 0).toBe(0)
})
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useAppStore } from '@/store'
import { normalizeBrowserNavigationUrl } from '../../../../../shared/browser-url'
import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants'
import { focusOwningGroupForBrowserGuest } from './browser-guest-owning-group'
import { parkBrowserPageViewport } from './browser-page-viewport'
import { subscribeBrowserSystemResume } from './browser-system-resume'
import {
Expand All @@ -11,6 +13,34 @@ import { createBrowserPageWebviewGuestSession } from './browser-page-webview-gue
import { createBrowserPageWebviewLoadingHandlers } from './browser-page-webview-loading-handlers'
import { createBrowserPageWebviewNavigationHandlers } from './browser-page-webview-navigation-handlers'

type BrowserGuestFocusTarget = {
addEventListener(type: 'focus', listener: () => void): void
removeEventListener(type: 'focus', listener: () => void): void
}

/** Registers one focus callback and returns the cleanup that removes that same function. */
export function bindBrowserGuestFocus(input: {
webview: BrowserGuestFocusTarget
dismissAddressBarSuggestions: () => void
worktreeId: string
workspaceId: string
}): () => void {
// Why: the guest does not bubble focus to the overlay, so the owning split
// stays stale and Ctrl+Tab targets the previous group (#22144).
const handleGuestFocus = (): void => {
input.dismissAddressBarSuggestions()
const state = useAppStore.getState()
focusOwningGroupForBrowserGuest({
worktreeId: input.worktreeId,
workspaceId: input.workspaceId,
unifiedTabsByWorktree: state.unifiedTabsByWorktree,
focusGroup: state.focusGroup
})
}
input.webview.addEventListener('focus', handleGuestFocus)
return () => input.webview.removeEventListener('focus', handleGuestFocus)
}

export function bindBrowserPageWebviewListeners({
container,
webview,
Expand Down Expand Up @@ -147,7 +177,13 @@ export function bindBrowserPageWebviewListeners({
webview.addEventListener('dom-ready', handleDomReady)
webview.addEventListener('render-process-gone', guestRecovery.recoverRenderer)
webview.addEventListener('destroyed', handleGuestDestroyed)
webview.addEventListener('focus', dismissAddressBarSuggestions)
// The same function is removed on cleanup; the webview outlives detach.
const removeGuestFocus = bindBrowserGuestFocus({
webview,
dismissAddressBarSuggestions,
worktreeId,
workspaceId
})
webview.addEventListener('did-start-loading', handleDidStartLoading)
webview.addEventListener('did-start-navigation', handleDidStartNavigation)
webview.addEventListener('did-redirect-navigation', handleDidRedirectNavigation)
Expand Down Expand Up @@ -185,7 +221,7 @@ export function bindBrowserPageWebviewListeners({
webview.removeEventListener('dom-ready', handleDomReady)
webview.removeEventListener('render-process-gone', guestRecovery.recoverRenderer)
webview.removeEventListener('destroyed', handleGuestDestroyed)
webview.removeEventListener('focus', dismissAddressBarSuggestions)
removeGuestFocus()
webview.removeEventListener('did-start-loading', handleDidStartLoading)
webview.removeEventListener('did-start-navigation', handleDidStartNavigation)
webview.removeEventListener('did-redirect-navigation', handleDidRedirectNavigation)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest'
import { focusOwningGroupForBrowserGuest } from './browser-guest-owning-group'

const browserTab = {
contentType: 'browser' as const,
entityId: 'workspace-1',
groupId: 'group-2'
}

describe('focusOwningGroupForBrowserGuest', () => {
it('focuses the split that owns the browser page', () => {
const focusGroup = vi.fn()
focusOwningGroupForBrowserGuest({
worktreeId: 'wt-1',
workspaceId: 'workspace-1',
unifiedTabsByWorktree: { 'wt-1': [browserTab] },
focusGroup
})
expect(focusGroup).toHaveBeenCalledTimes(1)
expect(focusGroup).toHaveBeenCalledWith('wt-1', 'group-2')
})

it('does not match a page id against the workspace id stored on the tab', () => {
const focusGroup = vi.fn()
focusOwningGroupForBrowserGuest({
worktreeId: 'wt-1',
workspaceId: 'page-1',
unifiedTabsByWorktree: {
'wt-1': [{ contentType: 'browser', entityId: 'workspace-1', groupId: 'group-1' }]
},
focusGroup
})
expect(focusGroup).not.toHaveBeenCalled()
})

it('ignores the same page id on another worktree', () => {
const focusGroup = vi.fn()
focusOwningGroupForBrowserGuest({
worktreeId: 'wt-1',
workspaceId: 'workspace-1',
unifiedTabsByWorktree: { 'wt-2': [browserTab] },
focusGroup
})
expect(focusGroup).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Tab } from '../../../../../shared/tab-types'

type BrowserGuestTab = Pick<Tab, 'contentType' | 'entityId' | 'groupId'>

/**
* The split that owns this browser workspace, or undefined while the tab is mid-move.
* Unified browser tabs store the workspace id in `entityId`, not the live page id.
*/
export function owningGroupIdForBrowserGuest(
tabs: readonly BrowserGuestTab[] | undefined,
workspaceId: string
): string | undefined {
for (const tab of tabs ?? []) {
if (tab.contentType === 'browser' && tab.entityId === workspaceId) {
return tab.groupId
}
}
return undefined
}

/**
* Guest webview focus does not bubble to the overlay, so Ctrl+Tab would keep
* the previously focused split (#22144). Address-bar focus never reaches here.
*/
export function focusOwningGroupForBrowserGuest(args: {
worktreeId: string
workspaceId: string
unifiedTabsByWorktree: Readonly<Record<string, readonly BrowserGuestTab[] | undefined>>
focusGroup: (worktreeId: string, groupId: string) => void
}): void {
const groupId = owningGroupIdForBrowserGuest(
args.unifiedTabsByWorktree[args.worktreeId],
args.workspaceId
)
if (!groupId) {
return
}
args.focusGroup(args.worktreeId, groupId)
}