diff --git a/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.test.ts b/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.test.ts new file mode 100644 index 000000000000..afa35da7cc8a --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.test.ts @@ -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> + addEventListener(type: 'focus', listener: Listener): void + removeEventListener(type: 'focus', listener: Listener): void +} { + const listeners = new Map>() + 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) + }) +}) diff --git a/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts b/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts index f8699324889c..e1e7329a2a52 100644 --- a/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts +++ b/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts @@ -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 { @@ -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, @@ -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) @@ -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) diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-guest-owning-group.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-guest-owning-group.test.ts new file mode 100644 index 000000000000..fd7c88a063eb --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/browser-guest-owning-group.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-guest-owning-group.ts b/src/renderer/src/components/browser-pane/host-guest/browser-guest-owning-group.ts new file mode 100644 index 000000000000..7344fe1d609e --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/browser-guest-owning-group.ts @@ -0,0 +1,39 @@ +import type { Tab } from '../../../../../shared/tab-types' + +type BrowserGuestTab = Pick + +/** + * 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> + focusGroup: (worktreeId: string, groupId: string) => void +}): void { + const groupId = owningGroupIdForBrowserGuest( + args.unifiedTabsByWorktree[args.worktreeId], + args.workspaceId + ) + if (!groupId) { + return + } + args.focusGroup(args.worktreeId, groupId) +}