diff --git a/src/preload/browser-window-close-installation.ts b/src/preload/browser-window-close-installation.ts index adf2fdb09bf..374c717ea61 100644 --- a/src/preload/browser-window-close-installation.ts +++ b/src/preload/browser-window-close-installation.ts @@ -1,15 +1,24 @@ -export function installBrowserWindowCloseGuard(): void { - const ignoreWindowClose = (): void => {} +/** Why written out: the sandboxed preload can't load shared chunks; the test pins it to the shared constant. */ +export const PRELOAD_BROWSER_GUEST_WINDOW_CLOSE_CHANNEL = 'orca:guest-window-close' + +/** + * Runs serialized in the page's main world. The native close stays blocked: it destroyed the + * guest under a live (#11763, #12040). Orca decides whether to close the tab instead. + */ +export function installBrowserWindowCloseGuard(requestClose: () => void): void { + const close = (): void => { + requestClose() + } try { Object.defineProperty(window, 'close', { configurable: false, enumerable: false, writable: false, - value: ignoreWindowClose + value: close }) } catch { try { - window.close = ignoreWindowClose + window.close = close } catch {} } } diff --git a/src/preload/browser-window-close.test.ts b/src/preload/browser-window-close.test.ts index 1b35a45f8df..91ff509cadd 100644 --- a/src/preload/browser-window-close.test.ts +++ b/src/preload/browser-window-close.test.ts @@ -1,20 +1,30 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { installBrowserWindowCloseGuard } from './browser-window-close-installation' +import { + installBrowserWindowCloseGuard, + PRELOAD_BROWSER_GUEST_WINDOW_CLOSE_CHANNEL +} from './browser-window-close-installation' +import { BROWSER_GUEST_WINDOW_CLOSE_CHANNEL } from '../shared/browser-guest-window-close' describe('browser window close preload', () => { afterEach(() => { vi.unstubAllGlobals() }) - it('installs a non-replaceable window.close no-op in the page world', () => { + it('replaces the native close with a non-replaceable request to Orca', () => { const nativeClose = vi.fn() + const requestClose = vi.fn() vi.stubGlobal('window', { close: nativeClose }) - installBrowserWindowCloseGuard() + installBrowserWindowCloseGuard(requestClose) expect(window.close()).toBeUndefined() - expect(window.close).not.toBe(nativeClose) + expect(nativeClose).not.toHaveBeenCalled() + expect(requestClose).toHaveBeenCalledTimes(1) expect(Reflect.set(window, 'close', nativeClose)).toBe(false) }) + + it('sends on the channel the renderer listens on', () => { + expect(PRELOAD_BROWSER_GUEST_WINDOW_CLOSE_CHANNEL).toBe(BROWSER_GUEST_WINDOW_CLOSE_CHANNEL) + }) }) diff --git a/src/preload/browser-window-close.ts b/src/preload/browser-window-close.ts index 00935d8c1db..4687f8fb92e 100644 --- a/src/preload/browser-window-close.ts +++ b/src/preload/browser-window-close.ts @@ -1,7 +1,17 @@ -import { installBrowserWindowCloseGuard } from './browser-window-close-installation' -import type { ContextBridge } from 'electron' +import { + installBrowserWindowCloseGuard, + PRELOAD_BROWSER_GUEST_WINDOW_CLOSE_CHANNEL +} from './browser-window-close-installation' +import type { ContextBridge, IpcRenderer } from 'electron' // Raw require keeps the sandboxed preload standalone in the CJS build. -const { contextBridge } = require('electron') as { contextBridge: ContextBridge } +const electron: { + contextBridge: ContextBridge + ipcRenderer: Pick +} = require('electron') +const { contextBridge, ipcRenderer } = electron -contextBridge.executeInMainWorld({ func: installBrowserWindowCloseGuard }) +contextBridge.executeInMainWorld({ + func: installBrowserWindowCloseGuard, + args: [() => ipcRenderer.sendToHost(PRELOAD_BROWSER_GUEST_WINDOW_CLOSE_CHANNEL)] +}) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-context-menu-items.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-context-menu-items.tsx index 58643c05328..49651d1afe2 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-context-menu-items.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-context-menu-items.tsx @@ -77,8 +77,9 @@ function buildMenuSections({ webviewRef.current?.[command]() } }) - const openInNewTab = (url: string, activate: boolean): void => + const openInNewTab = (url: string, activate: boolean): void => { openUrlInTabBesideBrowserPage({ sourcePageId: browserPageId, worktreeId, url, activate }) + } const openExternally = (url: string): void => { const targetUrl = normalizeExternalBrowserUrl(url) if (targetUrl) { diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-guest-focus.ts b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-guest-focus.ts index 4f29acd7d40..d767711c248 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-guest-focus.ts +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-guest-focus.ts @@ -1,4 +1,5 @@ import { useMemo, type RefObject } from 'react' +import { hasBrowserClientPageAttachedGuest } from '../browser-client-page-retained-elements' /** * How a browser pane hands focus to whatever renders the page. Panes differ in what that @@ -20,7 +21,9 @@ export function useWebviewGuestFocus( return useMemo( () => ({ blur: () => webviewRef.current?.blur(), - isAttached: () => webviewRef.current !== null, + // Why the live guest: focusing a webview before its guest attaches is lost once it does. + isAttached: () => + webviewRef.current !== null && hasBrowserClientPageAttachedGuest(webviewRef.current), focus: () => { const webview = webviewRef.current if (!webview) { diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx index 69ca863f9f2..a40d0af378e 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx @@ -300,6 +300,68 @@ describe('useBrowserPageChromeFocus', () => { expect(document.activeElement).toBe(addressBar()) }) + // Why: #18832 — a link's new tab mounts before its guest attaches, so the request must outlast that. + it('focuses the page of a new tab once its guest attaches', () => { + act(() => requestBrowserFocus({ pageId: PAGE_ID, target: 'webview' })) + + const view = render() + act(() => flushFrames(20)) + view.rerender() + act(() => flushFrames()) + + expect(document.activeElement).toBe(guest()) + }) + + // Why: a remote link's request can arrive after the pane mounted but before its guest attached. + it('focuses the page when a request event lands before its guest attaches', () => { + const view = render() + act(() => flushFrames()) + act(() => requestBrowserFocus({ pageId: PAGE_ID, target: 'webview' })) + act(() => flushFrames(3)) + view.rerender() + act(() => flushFrames()) + + expect(document.activeElement).toBe(guest()) + }) + + it('leaves focus the user moves in the frame the guest attaches', () => { + act(() => requestBrowserFocus({ pageId: PAGE_ID, target: 'webview' })) + + const view = render() + act(() => flushFrames(1)) + act(() => addressBar().focus()) + view.rerender() + act(() => flushFrames()) + + expect(document.activeElement).toBe(addressBar()) + }) + + it('drops a pending page focus when the user leaves the tab before its guest attaches', () => { + act(() => requestBrowserFocus({ pageId: PAGE_ID, target: 'webview' })) + + const view = render() + act(() => flushFrames(2)) + view.rerender() + act(() => addressBar().focus()) + view.rerender() + act(() => flushFrames()) + + expect(document.activeElement).toBe(addressBar()) + }) + + it('leaves focus the user moves while the guest of a new tab attaches', () => { + act(() => requestBrowserFocus({ pageId: PAGE_ID, target: 'webview' })) + + const view = render() + act(() => flushFrames(2)) + act(() => addressBar().focus()) + act(() => flushFrames(2)) + view.rerender() + act(() => flushFrames()) + + expect(document.activeElement).toBe(addressBar()) + }) + it('sends a durable palette request aimed at the page to the guest', () => { act(() => requestBrowserFocus({ pageId: PAGE_ID, target: 'webview' })) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.ts b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.ts index 6188ab7ae9f..62600bd05e8 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.ts +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.ts @@ -1,9 +1,17 @@ -import { useCallback, useEffect, useRef, type MutableRefObject, type RefObject } from 'react' +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + type MutableRefObject, + type RefObject +} from 'react' import { useAppStore } from '@/store' import { getShortcutPlatform } from '@/hooks/useShortcutLabel' import { keybindingMatchesAction } from '../../../../../shared/keybindings' import { consumeBrowserFocusRequest, + queueBrowserFocusRequest, ORCA_BROWSER_FOCUS_REQUEST_EVENT, type BrowserFocusRequestDetail } from '../host-guest/browser-focus' @@ -15,6 +23,8 @@ import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-rout /** Frames an address-bar grab keeps retrying before it lets the guest have focus back. */ const ADDRESS_BAR_FOCUS_FRAMES = 6 +/** Frames a queued page-focus request waits for a new tab's guest to attach. */ +const GUEST_ATTACH_WAIT_FRAMES = 120 /** * Who owns focus in a browser pane's chrome. Every pane — local, client-hosted and streamed — @@ -50,6 +60,11 @@ export function useBrowserPageChromeFocus({ const keybindings = useAppStore((s) => s.keybindings) const keepAddressBarFocusRef = useRef(false) const addressBarFocusGrabRef = useRef<(() => void) | null>(null) + // Why a layout effect: it lands before the previous render's passive cleanups read it. + const isActiveRef = useRef(isActive) + useLayoutEffect(() => { + isActiveRef.current = isActive + }, [isActive]) const cancelAddressBarFocusGrab = useCallback((): void => { addressBarFocusGrabRef.current?.() @@ -187,6 +202,60 @@ export function useBrowserPageChromeFocus({ return () => removeAppKeyListener('keydown', handleKeyDown) }, [chromeShortcutScope, focusAddressBarNow, keybindings, workspaceId]) + /** + * Focuses the page, waiting for a new tab's guest to attach first. Returns a canceller that + * requeues an unfinished request unless `{ requeue: false }`, so a re-run still finds it. + */ + const startGuestFocusRetry = useCallback( + ({ firstAttempt }: { firstAttempt: 'now' | 'next-frame' }) => { + let cancelled = false + let settled = false + let frameId = 0 + let attempts = 0 + let waitedFrames = 0 + const focusAtRequest = document.activeElement + const runFocus = (): void => { + if (cancelled) { + return + } + // Why: the retry budget starts once there is a guest; focus the user moves meanwhile + // stays where they put it. + if (attempts === 0) { + const active = document.activeElement + if (active !== focusAtRequest && active !== document.body) { + settled = true + return + } + if (!guestFocus.isAttached() && waitedFrames < GUEST_ATTACH_WAIT_FRAMES) { + waitedFrames += 1 + frameId = window.requestAnimationFrame(runFocus) + return + } + } + attempts += 1 + settled = focusGuestNow() + if (!settled && attempts < ADDRESS_BAR_FOCUS_FRAMES) { + frameId = window.requestAnimationFrame(runFocus) + } + } + if (firstAttempt === 'now') { + runFocus() + } else { + frameId = window.requestAnimationFrame(runFocus) + } + return ({ requeue = true }: { requeue?: boolean } = {}): void => { + cancelled = true + window.cancelAnimationFrame(frameId) + // Why: a re-run (StrictMode, a dependency change) mid-wait must still find the request; a + // pane the user left drops it, so coming back later doesn't pull focus into the page. + if (requeue && !settled && waitedFrames < GUEST_ATTACH_WAIT_FRAMES && isActiveRef.current) { + queueBrowserFocusRequest({ pageId: browserTabId, target: 'webview' }) + } + } + }, + [browserTabId, focusGuestNow, guestFocus] + ) + useEffect(() => { if (!isActive) { return @@ -201,25 +270,15 @@ export function useBrowserPageChromeFocus({ // Why: lowering the latch is not enough — a grab already in flight would spend its remaining // frames dragging focus back off the page this request just aimed at. cancelAddressBarFocusGrab() - let cancelled = false - let frameId = 0 - let attempts = 0 - const runFocus = (): void => { - if (cancelled) { - return - } - attempts += 1 - if (!focusGuestNow() && attempts < ADDRESS_BAR_FOCUS_FRAMES) { - frameId = window.requestAnimationFrame(runFocus) - } - } - // Why: focus can be queued before the pane mounts; persisting outside React lets it be claimed on mount instead of racing an event. - frameId = window.requestAnimationFrame(runFocus) - return () => { - cancelled = true - window.cancelAnimationFrame(frameId) - } - }, [browserTabId, cancelAddressBarFocusGrab, focusGuestNow, isActive, startAddressBarFocusGrab]) + // Why a frame first: focus can be queued before the pane mounts and is claimed here. + return startGuestFocusRetry({ firstAttempt: 'next-frame' }) + }, [ + browserTabId, + cancelAddressBarFocusGrab, + isActive, + startAddressBarFocusGrab, + startGuestFocusRetry + ]) useEffect(() => { if (!isActive) { @@ -241,15 +300,26 @@ export function useBrowserPageChromeFocus({ return } cancelAddressBarFocusGrab() - focusGuestNow() + cancelGuestFocus?.({ requeue: false }) + // Why the same retry as on mount: a remote link's request can land after the pane mounted + // but before its guest attached. + cancelGuestFocus = startGuestFocusRetry({ firstAttempt: 'now' }) } + let cancelGuestFocus: ReturnType | null = null // Why: an already-active page never remounts, so listen for the event to consume the durable focus request immediately. window.addEventListener(ORCA_BROWSER_FOCUS_REQUEST_EVENT, handleBrowserFocusRequest) return () => { window.removeEventListener(ORCA_BROWSER_FOCUS_REQUEST_EVENT, handleBrowserFocusRequest) cancelAddressBarFocusGrab() + cancelGuestFocus?.() } - }, [browserTabId, cancelAddressBarFocusGrab, focusGuestNow, isActive, startAddressBarFocusGrab]) + }, [ + browserTabId, + cancelAddressBarFocusGrab, + isActive, + startAddressBarFocusGrab, + startGuestFocusRetry + ]) return { focusAddressBarNow, focusGuestNow, startAddressBarFocusGrab, keepAddressBarFocusRef } } diff --git a/src/renderer/src/components/browser-pane/browser-client-page-retained-elements.ts b/src/renderer/src/components/browser-pane/browser-client-page-retained-elements.ts index 9bb5ebef7f7..7a3e185ec39 100644 --- a/src/renderer/src/components/browser-pane/browser-client-page-retained-elements.ts +++ b/src/renderer/src/components/browser-pane/browser-client-page-retained-elements.ts @@ -49,17 +49,35 @@ export function hideRetainedHostWhileGuestIsBlank( } } +type BrowserClientPageGuestMessageHandler = ( + browserPageId: string, + webview: Electron.WebviewTag, + event: Electron.IpcMessageEvent +) => void + +let guestMessageHandler: BrowserClientPageGuestMessageHandler | null = null + +// Why injected: this module also runs in a bare Electron fixture that cannot load the app store. +export function registerBrowserClientPageGuestMessageHandler( + handler: BrowserClientPageGuestMessageHandler | null +): void { + guestMessageHandler = handler +} + export function createBrowserClientPageWebview(options: { createWebview?: () => Electron.WebviewTag document: Document - partition: string + identity: { browserPageId: string; partition: string } }): Electron.WebviewTag { const webview = options.createWebview?.() ?? (options.document.createElement('webview') as Electron.WebviewTag) - webview.setAttribute('partition', options.partition) + webview.setAttribute('partition', options.identity.partition) webview.setAttribute('webpreferences', ORCA_BROWSER_PAGE_GUEST_WEB_PREFERENCES_ATTRIBUTE) webview.setAttribute('src', 'about:blank') Object.assign(webview.style, { display: 'flex', width: '100%', height: '100%' }) + webview.addEventListener('ipc-message', (event) => + guestMessageHandler?.(options.identity.browserPageId, webview, event) + ) return webview } diff --git a/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.ts b/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.ts index f0241dc5c82..681781745ed 100644 --- a/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.ts +++ b/src/renderer/src/components/browser-pane/browser-client-page-retained-registry.ts @@ -163,7 +163,7 @@ export class BrowserClientPageRetainedRegistry { const webview = createBrowserClientPageWebview({ createWebview: this.options.createWebview, document: this.options.document, - partition: identity.partition + identity }) let resolveMount!: RetainedPage['resolveMount'] diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview.ts index fc345a8ee1b..e9854e43708 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview.ts @@ -6,6 +6,7 @@ import { replacePersistentWebview, webviewRegistry } from './webview-registry' +import { handleGuestWindowCloseMessage } from './browser-page-window-close' export function setBrowserPageWebviewInputLock( webview: Electron.WebviewTag, @@ -80,6 +81,9 @@ export function ensureBrowserPageWebview({ guest.addEventListener('render-process-gone', () => { guest.style.visibility = 'hidden' }) + guest.addEventListener('ipc-message', (event) => { + handleGuestWindowCloseMessage(browserTabId, guest, event) + }) registerPersistentWebview(browserTabId, webview) activeContainer.appendChild(webview) created = true diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-window-close.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-window-close.test.ts new file mode 100644 index 00000000000..a87a4eaec97 --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-window-close.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { dispatchMock, closeBrowserPageMock, storeState } = vi.hoisted(() => { + const value: Record = {} + return { dispatchMock: vi.fn(), closeBrowserPageMock: vi.fn(), storeState: { value } } +}) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => storeState.value } })) +vi.mock('@/lib/workspace-tab-commands', () => ({ dispatchWorkspaceTabCommand: dispatchMock })) + +import { + handleGuestWindowCloseMessage, + markBrowserPageOpenedByPage +} from './browser-page-window-close' +import { BROWSER_GUEST_WINDOW_CLOSE_CHANNEL } from '../../../../../shared/browser-guest-window-close' + +function seedTab(pageId: string, isPinned = false, siblingPageIds: string[] = []): void { + storeState.value = { + closeBrowserPage: closeBrowserPageMock, + browserTabsByWorktree: { 'wt-1': [{ id: 'ws-1' }] }, + browserPagesByWorkspace: { 'ws-1': [{ id: pageId }, ...siblingPageIds.map((id) => ({ id }))] }, + unifiedTabsByWorktree: { + 'wt-1': [{ id: 'tab-1', contentType: 'browser', entityId: 'ws-1', isPinned }] + } + } +} + +function closeFrom(pageId: string, history: { back: boolean; forward: boolean }): void { + const webview = { canGoBack: () => history.back, canGoForward: () => history.forward } + handleGuestWindowCloseMessage(pageId, webview, { channel: BROWSER_GUEST_WINDOW_CLOSE_CHANNEL }) +} + +describe('window.close() from a browser page', () => { + beforeEach(() => { + dispatchMock.mockReset() + closeBrowserPageMock.mockReset() + }) + + it('closes a tab with a single history entry through the Cmd/Ctrl+W command', () => { + seedTab('page-a') + closeFrom('page-a', { back: false, forward: false }) + expect(dispatchMock).toHaveBeenCalledWith({ + type: 'close', + target: { kind: 'tab', worktreeId: 'wt-1', tabId: 'tab-1' } + }) + }) + + it('ignores a page the user navigated in that no page opened, like Chrome', () => { + seedTab('page-b') + closeFrom('page-b', { back: true, forward: false }) + expect(dispatchMock).not.toHaveBeenCalled() + }) + + it('closes a page-opened tab even after it navigated', () => { + seedTab('page-c') + markBrowserPageOpenedByPage('page-c') + closeFrom('page-c', { back: true, forward: false }) + expect(dispatchMock).toHaveBeenCalledTimes(1) + }) + + it('never closes a pinned tab', () => { + seedTab('page-d', true) + closeFrom('page-d', { back: false, forward: false }) + expect(dispatchMock).not.toHaveBeenCalled() + }) + + it('closes only the calling page when its Orca tab holds other pages', () => { + seedTab('page-e', false, ['page-sibling']) + closeFrom('page-e', { back: false, forward: false }) + expect(closeBrowserPageMock).toHaveBeenCalledWith('page-e') + expect(dispatchMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-window-close.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-window-close.ts new file mode 100644 index 00000000000..be5da1e5c68 --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-window-close.ts @@ -0,0 +1,53 @@ +import { useAppStore } from '@/store' +import { dispatchWorkspaceTabCommand } from '@/lib/workspace-tab-commands' +import { resolveBrowserWorkspaceOwner } from '@/lib/browser-workspace-source-resolution' +import { BROWSER_GUEST_WINDOW_CLOSE_CHANNEL } from '../../../../../shared/browser-guest-window-close' + +// Why not saved: like Chrome, a restored tab no longer counts as opened by its page. +const pagesOpenedByPage = new Set() + +export function markBrowserPageOpenedByPage(pageId: string): void { + pagesOpenedByPage.add(pageId) +} + +/** Closes the page's Orca tab when its guest calls window.close() and the web would allow it. */ +export function handleGuestWindowCloseMessage( + pageId: string, + webview: Pick, + event: Pick +): void { + if (event.channel !== BROWSER_GUEST_WINDOW_CLOSE_CHANNEL) { + return + } + // Why: the web's rule — a script may close a tab a page opened, or one with a single history entry. + const closable = + pagesOpenedByPage.has(pageId) || (!webview.canGoBack() && !webview.canGoForward()) + if (!closable) { + return + } + const state = useAppStore.getState() + const owner = resolveBrowserWorkspaceOwner(state, pageId) + if (!owner) { + return + } + const tab = (state.unifiedTabsByWorktree[owner.worktreeId] ?? []).find( + (candidate) => candidate.contentType === 'browser' && candidate.entityId === owner.workspaceId + ) + // Why: a pinned tab is one the user chose to keep; a page script can't overrule that. + if (tab?.isPinned) { + return + } + pagesOpenedByPage.delete(pageId) + // Why: the page closes, not the Orca tab around it, when that tab holds other pages too. + if ((state.browserPagesByWorkspace[owner.workspaceId] ?? []).length > 1) { + state.closeBrowserPage(pageId) + return + } + // Why the tab itself: the floating panel's shortcut bridge only reaches its focused split. + dispatchWorkspaceTabCommand({ + type: 'close', + target: tab + ? { kind: 'tab', worktreeId: owner.worktreeId, tabId: tab.id } + : { kind: 'browser-source', sourceId: pageId } + }) +} diff --git a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts index 3159874748c..3eeef905789 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts @@ -33,7 +33,7 @@ vi.mock('@/lib/workspace-browser-tab-open', () => ({ const openUrl = vi.fn() const openFileUri = vi.fn() const createBrowserTab = vi.fn() -const openOwnedBrowser = vi.fn(async () => {}) +const openOwnedBrowser = vi.fn(async (): Promise => null) const activateMarkdownLink = vi.fn() let editor: Editor | null = null diff --git a/src/renderer/src/components/settings/BrowserLinkRoutingBackgroundSetting.tsx b/src/renderer/src/components/settings/BrowserLinkRoutingBackgroundSetting.tsx new file mode 100644 index 00000000000..a36e8a64723 --- /dev/null +++ b/src/renderer/src/components/settings/BrowserLinkRoutingBackgroundSetting.tsx @@ -0,0 +1,38 @@ +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { SearchableSetting } from './SearchableSetting' +import { SettingsSwitchRow } from './SettingsFormControls' +import { getLinkRoutingBackgroundSearchEntry } from './browser-link-routing-copy' + +type BrowserLinkRoutingBackgroundSettingProps = { + settings: Pick + updateSettings: (updates: Partial) => void +} + +export function BrowserLinkRoutingBackgroundSetting({ + settings, + updateSettings +}: BrowserLinkRoutingBackgroundSettingProps): React.JSX.Element { + const entry = getLinkRoutingBackgroundSearchEntry() + + return ( + + {/* Nested under Link Routing: it only changes how an Orca-routed link opens. */} +
+ + updateSettings({ + openLinksInAppInBackground: settings.openLinksInAppInBackground !== true + }) + } + /> +
+
+ ) +} diff --git a/src/renderer/src/components/settings/BrowserPane.search-catalog.test.tsx b/src/renderer/src/components/settings/BrowserPane.search-catalog.test.tsx index 3ce2be66fbd..7bfa605f476 100644 --- a/src/renderer/src/components/settings/BrowserPane.search-catalog.test.tsx +++ b/src/renderer/src/components/settings/BrowserPane.search-catalog.test.tsx @@ -69,6 +69,9 @@ vi.mock('./BrowserLinkRoutingSetting', () => ({ vi.mock('./BrowserLinkRoutingModifierSetting', () => ({ BrowserLinkRoutingModifierSetting: () => Modifier row })) +vi.mock('./BrowserLinkRoutingBackgroundSetting', () => ({ + BrowserLinkRoutingBackgroundSetting: () => Background links row +})) vi.mock('./BrowserTerminalLinkActionsSetting', () => ({ BrowserTerminalLinkActionsSetting: () => Terminal links row })) diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index 18196ba9437..6f2074beea3 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -12,6 +12,7 @@ import { BrowserUseSetup } from './BrowserUsePane' import { BrowserSearchEngineSetting } from './BrowserSearchEngineSetting' import { BrowserLinkRoutingSetting } from './BrowserLinkRoutingSetting' import { BrowserLinkRoutingModifierSetting } from './BrowserLinkRoutingModifierSetting' +import { BrowserLinkRoutingBackgroundSetting } from './BrowserLinkRoutingBackgroundSetting' import { BrowserTerminalLinkActionsSetting } from './BrowserTerminalLinkActionsSetting' import { BrowserLocalhostWorktreeLabelsSetting } from './BrowserLocalhostWorktreeLabelsSetting' import { BrowserClientHostedRemoteSetting } from './BrowserClientHostedRemoteSetting' @@ -119,6 +120,7 @@ export function BrowserPane({ const showUserAgent = matchesSettingsSearch(searchQuery, [browserSearchEntries[10]]) const showSitePermissions = matchesSettingsSearch(searchQuery, [browserSearchEntries[11]]) const showProfileScope = matchesSettingsSearch(searchQuery, [browserSearchEntries[12]]) + const showLinkRoutingBackground = matchesSettingsSearch(searchQuery, [browserSearchEntries[13]]) const showBrowserUse = matchesSettingsSearch(searchQuery, getBrowserUsePaneSearchEntries()) const isMac = isMacUserAgent() const linkRoutingDescription = getBrowserLinkRoutingDescription( @@ -271,6 +273,10 @@ export function BrowserPane({ /> ) : null} + {showLinkRoutingBackground ? ( + + ) : null} + {showTerminalLinkActions ? ( { 'SSH workspaces', 'Browser identity', 'Site permissions', - 'Profile for new tabs' + 'Profile for new tabs', + 'Open links in the background' ]) }) diff --git a/src/renderer/src/components/settings/browser-search.ts b/src/renderer/src/components/settings/browser-search.ts index 719b321d155..5afddaee817 100644 --- a/src/renderer/src/components/settings/browser-search.ts +++ b/src/renderer/src/components/settings/browser-search.ts @@ -6,6 +6,7 @@ import { getBrowserProfileScopeSearchEntry } from './browser-profile-scope-searc import { getBrowserSitePermissionsSearchEntry } from './browser-site-permissions-search' import { getBrowserLinkRoutingDescription, + getLinkRoutingBackgroundSearchEntry, getLinkRoutingModifierDescription, getLinkRoutingModifierTitle } from './browser-link-routing-copy' @@ -300,6 +301,7 @@ export function getBrowserPaneSearchEntries( }, getBrowserUserAgentSearchEntry(), getBrowserSitePermissionsSearchEntry(), - getBrowserProfileScopeSearchEntry() + getBrowserProfileScopeSearchEntry(), + getLinkRoutingBackgroundSearchEntry() ] } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts index be546428c90..ef4aec667ae 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts @@ -15,7 +15,7 @@ import { handleTerminalHttpLink } from './terminal-url-link-hit-testing' const openUrl = vi.fn() const createBrowserTab = vi.fn() const setActiveWorktree = vi.fn() -const openRuntimeBrowserTab = vi.fn(() => Promise.resolve()) +const openRuntimeBrowserTab = vi.fn((): Promise => Promise.resolve(null)) function plainEvent(): MouseEvent { return { diff --git a/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts index 9bd10474235..6ceec37e086 100644 --- a/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts @@ -15,7 +15,7 @@ const ROWS = 24 const openUrlMock = vi.fn() const setActiveWorktreeMock = vi.fn() const createBrowserTabMock = vi.fn() -const openRuntimeBrowserTabMock = vi.fn(() => Promise.resolve()) +const openRuntimeBrowserTabMock = vi.fn((): Promise => Promise.resolve(null)) const runtimeSourceOwner = { kind: 'runtime', runtimeEnvironmentId: 'env-1' } as const const sshSourceOwner = { kind: 'ssh', connectionId: 'ssh-1' } as const diff --git a/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts index 4be4180d097..955c01db304 100644 --- a/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts @@ -8,6 +8,7 @@ import { releaseBrowserAutomationVisibility } from '@/components/browser-pane/host-guest/browser-automation-visibility' import { acquireBrowserAutomationBootstrapLease } from './browser-automation-bootstrap-lease' +import { markBrowserPageOpenedByPage } from '@/components/browser-pane/host-guest/browser-page-window-close' import { beginAgentInputFocusGuard, clearAgentInputFocusGuards, @@ -141,12 +142,15 @@ export function registerBrowserStateIpcBridge( if (!sourcePage || runtimeEnvironmentIdOrLocalUnlessTwin(store, sourcePage.worktreeId)) { return } - openUrlInTabBesideBrowserPage({ + const openedPageId = openUrlInTabBesideBrowserPage({ sourcePageId: browserPageId, worktreeId: sourcePage.worktreeId, url, activate: activate ?? true }) + if (openedPageId) { + markBrowserPageOpenedByPage(openedPageId) + } }) ) } diff --git a/src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts b/src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts index 7512ec8bd4b..888115e4595 100644 --- a/src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts +++ b/src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { createBrowserTabMock, storeState } = vi.hoisted(() => ({ +const { createBrowserTabMock, dispatchEventMock, storeState } = vi.hoisted(() => ({ createBrowserTabMock: vi.fn(), + dispatchEventMock: vi.fn(), storeState: { value: {} as Record } @@ -52,7 +53,7 @@ function captureOpenLinkHandler(): (event: { ? target[property as string] : new Proxy({}, { get: () => () => noopUnsubscribe }) }) - ;(globalThis as { window?: unknown }).window = { api } + vi.stubGlobal('window', { api, dispatchEvent: dispatchEventMock }) registerBrowserStateIpcBridge([], () => false) if (!handler) { @@ -64,6 +65,8 @@ function captureOpenLinkHandler(): (event: { describe('link-opened Orca tabs', () => { beforeEach(() => { createBrowserTabMock.mockReset() + dispatchEventMock.mockReset() + createBrowserTabMock.mockReturnValue({ id: 'new-tab', activePageId: 'new-page' }) }) it('inherits the opener tab session so an isolated profile cannot leak into the default one', () => { @@ -138,4 +141,28 @@ describe('link-opened Orca tabs', () => { expect.objectContaining({ activate: false }) ) }) + + // Why: #18832 — focus left on the opener made Cmd/Ctrl+W close the opener instead. + it('moves keyboard focus into a foreground link tab but not a background one', () => { + storeState.value = { + browserPagesByWorkspace: { + 'workspace-1': [{ id: 'page-1', workspaceId: 'workspace-1', worktreeId: 'worktree-1' }] + }, + browserTabsByWorktree: {}, + createBrowserTab: createBrowserTabMock + } + const handler = captureOpenLinkHandler() + + handler({ + browserPageId: 'page-1', + url: 'https://docs.example.com/background', + activate: false + }) + expect(dispatchEventMock).not.toHaveBeenCalled() + + handler({ browserPageId: 'page-1', url: 'https://docs.example.com/foreground' }) + expect(dispatchEventMock).toHaveBeenCalledWith( + expect.objectContaining({ detail: { pageId: 'new-page', target: 'webview' } }) + ) + }) }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2da8050d744..53188a159a7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10005,6 +10005,11 @@ "invert": "invert", "opposite": "opposite" }, + "linkRoutingBackground": { + "background": "background", + "focus": "focus", + "tab": "tab" + }, "terminalLinkActions": { "terminal": "terminal", "chat": "chat", @@ -12137,6 +12142,10 @@ "descriptionSystem": "Links open in Orca, so {{chord}}+click sends one to your system browser instead.", "descriptionOrca": "Links open in your system browser. When enabled, {{chord}}+click opens one in Orca's built-in browser instead." }, + "BrowserLinkRoutingBackgroundSetting": { + "title": "Open links in the background", + "description": "Links from the terminal, chat, and markdown join the browser without taking focus from where you clicked." + }, "BrowserTerminalLinkActionsSetting": { "title": "Terminal URL clicks", "descriptionV2": "Control clicks on detected URLs printed in terminal panes and chat transcripts.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 54a163c0783..b0b66af1967 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9709,6 +9709,11 @@ "invert": "invertir", "opposite": "opuesto" }, + "linkRoutingBackground": { + "background": "segundo plano", + "focus": "foco", + "tab": "pestaña" + }, "72c58f7792": "vista web", "82ba1c80ea": "localhost", "bea27bac4b": "enlaces", @@ -11905,6 +11910,10 @@ "descriptionSystem": "Los enlaces se abren en Orca, por lo que {{chord}}+clic envía uno al navegador de su sistema.", "descriptionOrca": "Los enlaces se abren en el navegador de su sistema. Cuando está habilitado, {{chord}}+clic abre uno en el navegador integrado de Orca." }, + "BrowserLinkRoutingBackgroundSetting": { + "title": "Abrir enlaces en segundo plano", + "description": "Los enlaces de la terminal, el chat y markdown se abren en el navegador sin quitar el foco de donde hiciste clic." + }, "PluginConsentDialog": { "workerTrust": "Proceso en segundo plano — se ejecuta en su propio proceso", "instructionalTrust": "Contenido instructivo — se ejecuta más tarde con la autoridad del usuario o del agente", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 45e05dd70e1..d0fe10a3c9f 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -9895,6 +9895,11 @@ "invert": "inverser", "opposite": "opposé" }, + "linkRoutingBackground": { + "background": "arrière-plan", + "focus": "focus", + "tab": "onglet" + }, "terminalLinkActions": { "terminal": "terminal", "click": "clic", @@ -12027,6 +12032,10 @@ "descriptionSystem": "Les liens s'ouvrent dans Orca ; {{chord}}+clic les envoie plutôt à votre navigateur système.", "descriptionOrca": "Les liens s'ouvrent dans votre navigateur système. Une fois activé, {{chord}}+clic en ouvre un dans le navigateur intégré d'Orca." }, + "BrowserLinkRoutingBackgroundSetting": { + "title": "Ouvrir les liens en arrière-plan", + "description": "Les liens du terminal, du chat et du markdown s'ouvrent dans le navigateur sans retirer le focus de l'endroit où vous avez cliqué." + }, "BrowserTerminalLinkActionsSetting": { "title": "Afficher les actions de lien du terminal", "descriptionV2": "Contrôlez les clics sur les URL détectées imprimées dans les volets du terminal et les transcriptions de discussion.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 88a8c4a0058..c9f15b0b891 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9715,6 +9715,11 @@ "invert": "反転", "opposite": "逆" }, + "linkRoutingBackground": { + "background": "バックグラウンド", + "focus": "フォーカス", + "tab": "タブ" + }, "72c58f7792": "ウェブビュー", "82ba1c80ea": "ローカルホスト", "bea27bac4b": "リンク", @@ -11889,6 +11894,10 @@ "descriptionSystem": "リンクは Orca で開きます。{{chord}}+クリックすると、システムのブラウザで開きます。", "descriptionOrca": "リンクはシステムのブラウザで開きます。有効にすると、{{chord}}+クリックで Orca 内蔵のブラウザで開きます。" }, + "BrowserLinkRoutingBackgroundSetting": { + "title": "リンクをバックグラウンドで開く", + "description": "ターミナル、チャット、Markdown のリンクを、クリックした場所からフォーカスを移さずにブラウザーで開きます。" + }, "PluginConsentDialog": { "workerTrust": "バックグラウンドワーカー — 自身のプロセスを実行", "instructionalTrust": "インストラクショナルコンテンツ — ユーザーまたは Agent の権限で後から実行", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index cca794ccb48..0d58b686417 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9678,6 +9678,11 @@ "invert": "반전", "opposite": "반대" }, + "linkRoutingBackground": { + "background": "백그라운드", + "focus": "포커스", + "tab": "탭" + }, "72c58f7792": "웹뷰", "82ba1c80ea": "로컬호스트", "bea27bac4b": "링크", @@ -11889,6 +11894,10 @@ "descriptionSystem": "링크가 Orca에서 열리므로, {{chord}}+클릭 시 시스템 기본 브라우저에서 대신 열립니다.", "descriptionOrca": "링크가 시스템 기본 브라우저에서 열립니다. 활성화하면 {{chord}}+클릭 시 Orca 내장 브라우저에서 대신 열립니다." }, + "BrowserLinkRoutingBackgroundSetting": { + "title": "링크를 백그라운드에서 열기", + "description": "터미널, 채팅, 마크다운의 링크를 클릭한 위치에서 포커스를 옮기지 않고 브라우저에서 엽니다." + }, "PluginConsentDialog": { "workerTrust": "백그라운드 워커 — 자체 프로세스 실행", "instructionalTrust": "지시 내용 — 사용자 또는 에이전트 권한으로 나중에 실행", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index c3b5ef7ec38..85c1fc5b0ab 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -3660,6 +3660,11 @@ "invert": "inverter", "opposite": "oposto" }, + "linkRoutingBackground": { + "background": "segundo plano", + "focus": "foco", + "tab": "aba" + }, "terminalLinkActions": { "terminal": "terminal", "chat": "chat", @@ -8189,6 +8194,10 @@ "descriptionSystem": "Os links abrem no Orca, então {{chord}}+clique envia um para o seu navegador do sistema.", "descriptionOrca": "Os links abrem no seu navegador do sistema. Quando ativado, {{chord}}+clique abre um no navegador integrado do Orca." }, + "BrowserLinkRoutingBackgroundSetting": { + "title": "Abrir links em segundo plano", + "description": "Links do terminal, do chat e do markdown abrem no navegador sem tirar o foco de onde você clicou." + }, "PluginKeybindingConsentPreview": { "heading": "Atalhos de teclado", "worktree": "Executa somente enquanto um espaço de trabalho está ativo.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a54ce6dc937..463097f8f4c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9678,6 +9678,11 @@ "invert": "反转", "opposite": "相反" }, + "linkRoutingBackground": { + "background": "后台", + "focus": "焦点", + "tab": "标签页" + }, "72c58f7792": "网页视图", "82ba1c80ea": "本地主机", "bea27bac4b": "链接", @@ -11889,6 +11894,10 @@ "descriptionSystem": "链接在 Orca 中打开,因此 {{chord}}+点击会将其发送到系统浏览器。", "descriptionOrca": "链接在系统浏览器中打开。启用后,{{chord}}+点击会改在 Orca 内置浏览器中打开。" }, + "BrowserLinkRoutingBackgroundSetting": { + "title": "在后台打开链接", + "description": "终端、聊天和 Markdown 中的链接会在浏览器中打开,焦点仍停留在你点击的位置。" + }, "PluginConsentDialog": { "workerTrust": "后台工作进程 — 运行自己的进程", "instructionalTrust": "指令内容 — 在用户或智能体权限下运行", diff --git a/src/renderer/src/lib/browser-page-tab-opening.ts b/src/renderer/src/lib/browser-page-tab-opening.ts index 962f09f7505..6aa4c674b6e 100644 --- a/src/renderer/src/lib/browser-page-tab-opening.ts +++ b/src/renderer/src/lib/browser-page-tab-opening.ts @@ -2,6 +2,7 @@ import { useAppStore } from '@/store' import { findPage, findWorkspace } from '@/store/slices/browser-page-records' import { redactKagiSessionToken } from '../../../shared/browser-url' import { resolveBrowserSourceUnifiedTab } from './browser-workspace-source-resolution' +import { requestBrowserFocus } from '@/components/browser-pane/host-guest/browser-focus' /** Opens `url` in a new Orca browser tab right after the page's tab, in the page's own profile. */ export function openUrlInTabBesideBrowserPage(args: { @@ -9,7 +10,7 @@ export function openUrlInTabBesideBrowserPage(args: { worktreeId: string url: string activate: boolean -}): void { +}): string | null { const { sourcePageId, worktreeId, url, activate } = args const store = useAppStore.getState() const sourcePage = findPage(store.browserPagesByWorkspace, sourcePageId) @@ -18,7 +19,7 @@ export function openUrlInTabBesideBrowserPage(args: { ? findWorkspace(store.browserTabsByWorktree, sourcePage.workspaceId) : null const sourceUnifiedTab = resolveBrowserSourceUnifiedTab(store, sourcePageId, worktreeId) - store.createBrowserTab(worktreeId, url, { + const created = store.createBrowserTab(worktreeId, url, { // Why: a Kagi private-session search URL carries a token; only the stored URL is redacted. title: redactKagiSessionToken(url), activate, @@ -33,4 +34,9 @@ export function openUrlInTabBesideBrowserPage(args: { } : {}) }) + // Why: keyboard focus follows a foreground tab, so Cmd/Ctrl+W closes it instead of its opener. + if (activate && created.activePageId) { + requestBrowserFocus({ pageId: created.activePageId, target: 'webview' }) + } + return created.activePageId ?? null } diff --git a/src/renderer/src/lib/http-link-browser-placement.test.ts b/src/renderer/src/lib/http-link-browser-placement.test.ts new file mode 100644 index 00000000000..91e6d3072bc --- /dev/null +++ b/src/renderer/src/lib/http-link-browser-placement.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { resolveHttpLinkBrowserPlacement } from './http-link-browser-placement' + +const WT = 'wt-1' +const terminal = { id: 'term', contentType: 'terminal' } as const +const browser = { id: 'browser', contentType: 'browser' } as const +const split = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'left' }, + second: { type: 'leaf', groupId: 'right' } +} as const + +function state(overrides: { + rightActiveTabId: string + activeGroupId: string + tabs?: { id: string; contentType: 'terminal' | 'browser' | 'editor' }[] +}): Parameters[0] { + return { + layoutByWorktree: { [WT]: split }, + activeGroupIdByWorktree: { [WT]: overrides.activeGroupId }, + groupsByWorktree: { + [WT]: [ + { id: 'left', activeTabId: 'term' }, + { id: 'right', activeTabId: overrides.rightActiveTabId } + ] + }, + unifiedTabsByWorktree: { [WT]: overrides.tabs ?? [terminal, browser] } + } +} + +describe('resolveHttpLinkBrowserPlacement', () => { + it('adds the link to a browser already on screen, whichever pane the link came from', () => { + expect( + resolveHttpLinkBrowserPlacement( + state({ rightActiveTabId: 'browser', activeGroupId: 'left' }), + WT + ) + ).toEqual({ targetGroupId: 'right' }) + }) + + it('ignores a browser hidden behind another tab and splits beside the source instead', () => { + const editor = { id: 'editor', contentType: 'editor' } as const + expect( + resolveHttpLinkBrowserPlacement( + state({ + rightActiveTabId: 'editor', + activeGroupId: 'left', + tabs: [terminal, browser, editor] + }), + WT + ) + ).toEqual({ + split: { sourceGroupId: 'left', splitDirection: 'right', activate: false } + }) + }) + + it('fills an empty source pane rather than splitting beside it', () => { + expect( + resolveHttpLinkBrowserPlacement( + state({ rightActiveTabId: '', activeGroupId: 'right', tabs: [terminal] }), + WT + ) + ).toEqual({ targetGroupId: 'right' }) + }) +}) diff --git a/src/renderer/src/lib/http-link-browser-placement.ts b/src/renderer/src/lib/http-link-browser-placement.ts new file mode 100644 index 00000000000..45673db1ed4 --- /dev/null +++ b/src/renderer/src/lib/http-link-browser-placement.ts @@ -0,0 +1,114 @@ +import type { AppState } from '@/store/types' +import type { CreateBrowserTabOptions } from '@/store/slices/browser/browser-slice-contract' +import type { TabSplitTarget } from '@/store/slices/tabs/tabs-slice-contract' +import type { Tab, TabGroup } from '../../../shared/tab-types' +import { liveLayoutGroupIds } from '@/store/slices/editor/tabs/editor-open-target-group' +import { requestBrowserFocus } from '@/components/browser-pane/host-guest/browser-focus' +import type { OpenWorkspaceBrowserTabRequest } from './workspace-browser-tab-open' +import type { BrowserWorkspace } from '../../../shared/browser-workspace-types' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree' + +export type HttpLinkBrowserPlacement = { targetGroupId: string } | { split: TabSplitTarget } + +export type HttpLinkBrowserTabState = Partial< + Pick +> & { + groupsByWorktree?: Record[]> + unifiedTabsByWorktree?: Record< + string, + readonly (Pick & Partial>)[] + > + settings?: Partial> | null + createBrowserTab: ( + worktreeId: string, + url: string, + opts: Pick + ) => Pick | undefined +} + +/** How the remote opener places and focuses a link's tab; same rule as a local link. */ +export type HttpLinkBrowserRequestFields = Pick< + OpenWorkspaceBrowserTabRequest, + 'targetGroupId' | 'split' | 'focusOnCreate' | 'selectWorktree' +> + +/** + * Where a link opened from an Orca surface lands: the browser already on screen, else a split + * beside the clicked pane so the terminal or agent stays visible. Clicking a pane focuses its + * group, so the worktree's active group is the link's source. + */ +export function resolveHttpLinkBrowserPlacement( + state: Omit, + worktreeId: string +): HttpLinkBrowserPlacement | null { + const groups = state.groupsByWorktree?.[worktreeId] ?? [] + const tabsById = new Map((state.unifiedTabsByWorktree?.[worktreeId] ?? []).map((t) => [t.id, t])) + const visibleGroupIds = liveLayoutGroupIds(state, worktreeId) + for (const groupId of visibleGroupIds) { + const activeTabId = groups.find((group) => group.id === groupId)?.activeTabId + if (activeTabId && tabsById.get(activeTabId)?.contentType === 'browser') { + return { targetGroupId: groupId } + } + } + const sourceGroupId = state.activeGroupIdByWorktree?.[worktreeId] + if (!sourceGroupId || !visibleGroupIds.has(sourceGroupId)) { + return null + } + // Why: an empty pane covers nothing, so the link fills it instead of splitting beside it. + if (!groups.find((group) => group.id === sourceGroupId)?.activeTabId) { + return { targetGroupId: sourceGroupId } + } + // Why unactivated: a host snapshot reads an activated empty group as a terminal pane (see file-preview.ts). + return { split: { sourceGroupId, splitDirection: 'right', activate: false } } +} + +export function isHttpLinkBackgroundOpen( + state: Pick +): boolean { + return state.settings?.openLinksInAppInBackground === true +} + +export function openLocalHttpLinkTab( + state: HttpLinkBrowserTabState, + worktreeId: string, + url: string, + placement = resolveHttpLinkBrowserPlacement(state, worktreeId) +): void { + const background = isHttpLinkBackgroundOpen(state) + const tab = state.createBrowserTab(worktreeId, url, { activate: !background, ...placement }) + if (background) { + return + } + showWorkspaceTabs(state, worktreeId) + focusHttpLinkPage(tab?.activePageId) +} + +/** Keyboard focus follows a link's tab, so Cmd/Ctrl+W closes the link rather than its source. */ +export function focusHttpLinkPage(pageId: string | null | undefined): void { + if (pageId) { + requestBrowserFocus({ pageId, target: 'webview' }) + } +} + +export function prepareRemoteHttpLinkTab( + state: Omit, + worktreeId: string +): HttpLinkBrowserRequestFields { + const placement = resolveHttpLinkBrowserPlacement(state, worktreeId) + if (isHttpLinkBackgroundOpen(state)) { + return { ...placement, focusOnCreate: false, selectWorktree: false } + } + showWorkspaceTabs(state, worktreeId) + return placement ?? {} +} + +/** A link clicked in Agents View lands in the workspace's tab layout, so bring that layout on screen. */ +function showWorkspaceTabs( + state: Pick, + worktreeId: string +): void { + if (!isFloatingWorkspaceId(worktreeId) && state.activeView !== 'terminal') { + state.setActiveView?.('terminal') + } +} diff --git a/src/renderer/src/lib/http-link-modifier-routing.test.ts b/src/renderer/src/lib/http-link-modifier-routing.test.ts index 3cd6dcd2aaa..fbfb50b99e4 100644 --- a/src/renderer/src/lib/http-link-modifier-routing.test.ts +++ b/src/renderer/src/lib/http-link-modifier-routing.test.ts @@ -9,7 +9,7 @@ describe('modifier routing across link source owners', () => { const openUrlMock = vi.fn() const setActiveWorktreeMock = vi.fn() const createBrowserTabMock = vi.fn() - const openRuntimeBrowserTabMock = vi.fn(() => Promise.resolve()) + const openRuntimeBrowserTabMock = vi.fn((): Promise => Promise.resolve(null)) const storeState = { settings: {} as { openLinksInApp?: boolean diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts index 93d9c61a44e..6f76968c03e 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -18,7 +18,9 @@ const openUrlMock = vi.fn() const registerLocalhostLabelMock = vi.fn() const setActiveWorktreeMock = vi.fn() const createBrowserTabMock = vi.fn() -const openRuntimeBrowserTabMock = vi.fn(() => Promise.resolve()) +const setActiveViewMock = vi.fn() +const openRuntimeBrowserTabMock = vi.fn((): Promise => Promise.resolve(null)) +const dispatchEventMock = vi.fn() function noActiveWorkspace(): { activeWorktreeId: string | null @@ -27,17 +29,22 @@ function noActiveWorkspace(): { return { activeWorktreeId: null, activeWorkspaceExecutionHostId: null } } +type LinkTestSettings = { + openLinksInApp?: boolean + openLinksInAppModifierInverts?: boolean + openLinksInAppInBackground?: boolean + openLinksInAppPreferencePrompted?: boolean + activeRuntimeEnvironmentId?: string | null + localhostWorktreeLabelsEnabled?: boolean +} +const noSettings = (): LinkTestSettings | undefined => undefined +const terminalView = (): 'terminal' | 'activity' => 'terminal' + const storeState = { - settings: undefined as - | { - openLinksInApp?: boolean - openLinksInAppModifierInverts?: boolean - openLinksInAppPreferencePrompted?: boolean - activeRuntimeEnvironmentId?: string | null - localhostWorktreeLabelsEnabled?: boolean - } - | undefined, + settings: noSettings(), setActiveWorktree: setActiveWorktreeMock, + activeView: terminalView(), + setActiveView: setActiveViewMock, ...noActiveWorkspace(), createBrowserTab: createBrowserTabMock, repos: [] as { id: string; displayName: string; repoIcon?: null; badgeColor?: string }[], @@ -56,11 +63,13 @@ const storeState = { beforeEach(() => { vi.clearAllMocks() storeState.settings = undefined + storeState.activeView = 'terminal' Object.assign(storeState, noActiveWorkspace()) storeState.workspacePortScansByKey = {} registerHttpLinkStoreAccessor(() => storeState) registerWorkspaceHttpLinkBrowserOpener(openRuntimeBrowserTabMock) vi.stubGlobal('window', { + dispatchEvent: dispatchEventMock, api: { shell: { openUrl: openUrlMock @@ -100,6 +109,28 @@ describe('openHttpLink', () => { expect(openUrlMock).not.toHaveBeenCalled() }) + // Why: #31530 — queued links must not pull focus or the selected workspace away from the source. + it('opens in the background without selecting the tab or its workspace', () => { + storeState.settings = { openLinksInApp: true, openLinksInAppInBackground: true } + + openHttpLink('https://example.com/', { worktreeId: 'wt-1' }) + + expect(setActiveWorktreeMock).not.toHaveBeenCalled() + expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { + activate: false + }) + }) + + // Why: #7813 — a link clicked in Agents View landed in a tab layout that was not on screen. + it('brings the workspace tabs on screen for a link clicked in Agents View', () => { + storeState.settings = { openLinksInApp: true } + storeState.activeView = 'activity' + + openHttpLink('https://example.com/', { worktreeId: 'wt-1' }) + + expect(setActiveViewMock).toHaveBeenCalledWith('terminal') + }) + it('defaults to the system browser when settings have not hydrated', () => { storeState.settings = undefined @@ -219,6 +250,24 @@ describe('openHttpLink', () => { expect(openUrlMock).not.toHaveBeenCalled() }) + // Why: an SSH page renders on this desktop, so its link tab takes keyboard focus like a local one. + it('focuses the page an SSH link opened on this desktop', async () => { + storeState.settings = { openLinksInApp: true } + openRuntimeBrowserTabMock.mockResolvedValueOnce('ssh-page') + + openHttpLink('https://example.com/', { + allowRemoteInApp: true, + worktreeId: 'wt-1', + sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' } + }) + + await vi.waitFor(() => + expect(dispatchEventMock).toHaveBeenCalledWith( + expect.objectContaining({ detail: { pageId: 'ssh-page', target: 'webview' } }) + ) + ) + }) + it('routes runtime and SSH document owners through their workspace browsers', () => { storeState.settings = { openLinksInApp: true, localhostWorktreeLabelsEnabled: true } diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts index 81057fca002..0aa5931ce13 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -9,6 +9,15 @@ import type { WorkspacePort, WorkspacePortScanResult } from '../../../shared/wor import { toast } from 'sonner' import type { ExecutionHostId } from '../../../shared/execution-host' import { reselectWorkspaceKeepingHost } from './active-workspace-host' +import { + focusHttpLinkPage, + isHttpLinkBackgroundOpen, + openLocalHttpLinkTab, + prepareRemoteHttpLinkTab, + resolveHttpLinkBrowserPlacement, + type HttpLinkBrowserRequestFields, + type HttpLinkBrowserTabState +} from './http-link-browser-placement' export type OpenHttpLinkOptions = { worktreeId?: string | null @@ -29,7 +38,7 @@ export type HttpLinkSourceOwner = | { kind: 'ssh'; connectionId: string } | { kind: 'unknown' } -type StoreAccessor = () => { +type StoreAccessor = () => HttpLinkBrowserTabState & { settings?: Partial< Pick< GlobalSettings, @@ -42,7 +51,6 @@ type StoreAccessor = () => { setActiveWorktree: (worktreeId: string, executionHostId?: ExecutionHostId) => void activeWorktreeId?: string | null activeWorkspaceExecutionHostId?: ExecutionHostId | null - createBrowserTab: (worktreeId: string, url: string, opts: { activate: boolean }) => unknown repos?: readonly LocalhostLinkRepo[] projects?: readonly LocalhostLinkProject[] worktreesByRepo?: Record @@ -51,7 +59,7 @@ type StoreAccessor = () => { workspacePortScansByKey?: Record } -type WorkspaceHttpLinkBrowserRequest = { +type WorkspaceHttpLinkBrowserRequest = HttpLinkBrowserRequestFields & { workspaceId: string url: string intent: { kind: 'url' } @@ -59,7 +67,10 @@ type WorkspaceHttpLinkBrowserRequest = { expectedSshConnectionId?: string } -type WorkspaceHttpLinkBrowserOpener = (request: WorkspaceHttpLinkBrowserRequest) => Promise +/** Resolves to the page id when the page renders on this desktop, else null. */ +type WorkspaceHttpLinkBrowserOpener = ( + request: WorkspaceHttpLinkBrowserRequest +) => Promise type LocalhostLinkRepo = { id: string @@ -155,23 +166,28 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void effectiveSourceOwner?.kind === 'ssh' || (!effectiveSourceOwner && remoteRuntimeActive)) ) { - if (workspaceHttpLinkBrowserOpener) { + if (workspaceHttpLinkBrowserOpener && state) { + const fields = prepareRemoteHttpLinkTab(state, worktreeId) void workspaceHttpLinkBrowserOpener({ workspaceId: worktreeId, url, intent: { kind: 'url' }, + ...fields, ...(effectiveSourceOwner?.kind === 'runtime' ? { expectedRuntimeEnvironmentId: effectiveSourceOwner.runtimeEnvironmentId } : effectiveSourceOwner?.kind === 'ssh' ? { expectedSshConnectionId: effectiveSourceOwner.connectionId } : {}) - }).catch((error) => { - toast.error( - error instanceof Error - ? error.message - : translate('auto.lib.workspace.browser.tab.open.urlFailed', 'Unable to open URL.') - ) - }) + }).then( + (pageId) => focusHttpLinkPage(fields.focusOnCreate === false ? null : pageId), + (error) => { + toast.error( + error instanceof Error + ? error.message + : translate('auto.lib.workspace.browser.tab.open.urlFailed', 'Unable to open URL.') + ) + } + ) } return } @@ -181,18 +197,20 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void // history entry — the user isn't changing worktrees, they're opening a tab // in the one they're already in. activateAndRevealWorktree is reserved for // file-link jumps that genuinely switch worktrees. - if (!isFloatingWorkspaceId(worktreeId)) { + if (!isHttpLinkBackgroundOpen(state) && !isFloatingWorkspaceId(worktreeId)) { // Why: the floating workspace uses a synthetic worktree id. Promoting it // to the global activeWorktreeId deselects the real repo workspace. reselectWorkspaceKeepingHost(state, worktreeId) } const localhostRoute = localhostLabelRouteForHttpLink(url, state, sourceOwner) if (!localhostRoute) { - state.createBrowserTab(worktreeId, url, { activate: true }) + openLocalHttpLinkTab(state, worktreeId, url) return } + // Why placed now: the clicked pane is the active group only until the user clicks elsewhere. + const placement = resolveHttpLinkBrowserPlacement(state, worktreeId) void openLabeledLocalhostLink(url, localhostRoute, (labeledUrl) => { - state.createBrowserTab(worktreeId, labeledUrl, { activate: true }) + openLocalHttpLinkTab(storeAccessor?.() ?? state, worktreeId, labeledUrl, placement) }) return } diff --git a/src/renderer/src/lib/workspace-browser-tab-open.test.ts b/src/renderer/src/lib/workspace-browser-tab-open.test.ts index 1613931aa75..b176baa3bca 100644 --- a/src/renderer/src/lib/workspace-browser-tab-open.test.ts +++ b/src/renderer/src/lib/workspace-browser-tab-open.test.ts @@ -13,6 +13,12 @@ import { openWorkspaceBrowserTab } from './workspace-browser-tab-open' +function createBrowserTabMock() { + return vi.fn((_worktreeId: string, _url: string, _options: Record) => ({ + activePageId: 'page-1' + })) +} + const mocks = vi.hoisted(() => ({ createRemote: vi.fn(), getState: vi.fn(), @@ -69,7 +75,7 @@ beforeEach(() => { describe('openWorkspaceBrowserTab', () => { it('opens client-owned searches with a safe title and host-specific profile', async () => { - const createBrowserTab = vi.fn() + const createBrowserTab = createBrowserTabMock() const sshHost = toSshExecutionHostId('ssh-target') mocks.state = { ...ownerState(sshHost), @@ -107,7 +113,7 @@ describe('openWorkspaceBrowserTab', () => { }) it('creates a client tab without activating it when requested', async () => { - const createBrowserTab = vi.fn() + const createBrowserTab = createBrowserTabMock() const sshHost = toSshExecutionHostId('ssh-target') mocks.state = { ...ownerState(sshHost), @@ -137,7 +143,7 @@ describe('openWorkspaceBrowserTab', () => { mocks.state = { ...ownerState(sshHost), settings: { browserSshWorkspaceRoutingDisabledTargetIds: ['ssh-target'] }, - createBrowserTab: vi.fn(), + createBrowserTab: createBrowserTabMock(), defaultBrowserSessionProfileId: 'focused-profile', defaultBrowserSessionProfileIdByHostId: { [sshHost]: 'ssh-profile' } } @@ -160,7 +166,7 @@ describe('openWorkspaceBrowserTab', () => { }) it('surfaces the opening workspace and titles runtime-owned URL tabs by target', async () => { - const createBrowserTab = vi.fn() + const createBrowserTab = createBrowserTabMock() mocks.state = { ...ownerState(toRuntimeExecutionHostId('hub-a')), ...browserCapableRuntime('hub-a'), @@ -198,7 +204,7 @@ describe('openWorkspaceBrowserTab', () => { mocks.state = { ...ownerState(toRuntimeExecutionHostId('hub-a')), ...browserCapableRuntime('hub-a'), - createBrowserTab: vi.fn(), + createBrowserTab: createBrowserTabMock(), defaultBrowserSessionProfileId: 'client-profile', defaultBrowserSessionProfileIdByHostId: {} } @@ -223,7 +229,7 @@ describe('openWorkspaceBrowserTab', () => { }) it('waits for host registration before reconciling an asserted runtime link', async () => { - const createBrowserTab = vi.fn() + const createBrowserTab = createBrowserTabMock() mocks.state = { ...ownerState(toRuntimeExecutionHostId('hub-a')), ...browserCapableRuntime('hub-a'), @@ -249,7 +255,7 @@ describe('openWorkspaceBrowserTab', () => { }) it('keeps an asserted runtime link on its owner when policy selects the local client', async () => { - const createBrowserTab = vi.fn() + const createBrowserTab = createBrowserTabMock() mocks.state = { ...ownerState(toRuntimeExecutionHostId('hub-a')), ...browserCapableRuntime('hub-a'), @@ -289,7 +295,7 @@ describe('openWorkspaceBrowserTab', () => { mocks.state = { ...ownerState(toRuntimeExecutionHostId('hub-a')), ...browserCapableRuntime('hub-a'), - createBrowserTab: vi.fn(), + createBrowserTab: createBrowserTabMock(), defaultBrowserSessionProfileId: 'client-profile', defaultBrowserSessionProfileIdByHostId: {} } @@ -311,7 +317,7 @@ describe('openWorkspaceBrowserTab', () => { mocks.state = { ...ownerState(toRuntimeExecutionHostId('hub-a')), ...browserCapableRuntime('hub-a'), - createBrowserTab: vi.fn(), + createBrowserTab: createBrowserTabMock(), defaultBrowserSessionProfileId: 'client-profile', defaultBrowserSessionProfileIdByHostId: {} } @@ -320,7 +326,7 @@ describe('openWorkspaceBrowserTab', () => { mocks.state = { ...ownerState(toRuntimeExecutionHostId('hub-b')), ...browserCapableRuntime('hub-b'), - createBrowserTab: vi.fn(), + createBrowserTab: createBrowserTabMock(), defaultBrowserSessionProfileId: 'client-profile', defaultBrowserSessionProfileIdByHostId: {} } @@ -341,7 +347,7 @@ describe('openWorkspaceBrowserTab', () => { mocks.state = { ...ownerState(toRuntimeExecutionHostId('hub-a')), ...browserCapableRuntime('hub-a'), - createBrowserTab: vi.fn(), + createBrowserTab: createBrowserTabMock(), defaultBrowserSessionProfileId: 'client-profile', defaultBrowserSessionProfileIdByHostId: {} } @@ -363,7 +369,7 @@ describe('openWorkspaceBrowserTab', () => { mocks.state = { ...ownerState(sshHost, 'hub-a'), ...browserCapableRuntime('hub-a'), - createBrowserTab: vi.fn(), + createBrowserTab: createBrowserTabMock(), defaultBrowserSessionProfileId: 'client-profile', defaultBrowserSessionProfileIdByHostId: {} } @@ -384,7 +390,7 @@ describe('openWorkspaceBrowserTab', () => { it('does not fall back to a client browser for an incapable asserted runtime', async () => { mocks.state = { ...ownerState(toRuntimeExecutionHostId('hub-a')), - createBrowserTab: vi.fn(), + createBrowserTab: createBrowserTabMock(), defaultBrowserSessionProfileId: 'client-profile', defaultBrowserSessionProfileIdByHostId: {} } @@ -408,7 +414,7 @@ describe('openWorkspaceBrowserTab', () => { mocks.state = { ...ownerState(toRuntimeExecutionHostId('hub-a')), ...browserCapableRuntime('hub-a'), - createBrowserTab: vi.fn(), + createBrowserTab: createBrowserTabMock(), defaultBrowserSessionProfileId: 'client-profile', defaultBrowserSessionProfileIdByHostId: {} } @@ -429,7 +435,7 @@ describe('openWorkspaceBrowserTab', () => { }) it('does not route an asserted floating-terminal source to either runtime or local browser', async () => { - const createBrowserTab = vi.fn() + const createBrowserTab = createBrowserTabMock() mocks.state = { activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID, activeWorkspaceExecutionHostId: toRuntimeExecutionHostId('hub-a'), @@ -465,7 +471,7 @@ describe('openWorkspaceBrowserTab', () => { activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID, activeWorkspaceExecutionHostId: toRuntimeExecutionHostId('hub-a'), ...browserCapableRuntime('hub-a'), - createBrowserTab: vi.fn(), + createBrowserTab: createBrowserTabMock(), defaultBrowserSessionProfileId: 'client-profile', defaultBrowserSessionProfileIdByHostId: {} } @@ -491,7 +497,7 @@ describe('openWorkspaceBrowserTab', () => { // Two dev servers on one host must not share a tab title. it('keeps the port in local-dev tab titles', async () => { - const createBrowserTab = vi.fn() + const createBrowserTab = createBrowserTabMock() mocks.state = { ...ownerState(LOCAL_EXECUTION_HOST_ID), createBrowserTab, @@ -510,7 +516,7 @@ describe('openWorkspaceBrowserTab', () => { }) it('uses the desktop provider when the runtime cannot stream browsers', async () => { - const createBrowserTab = vi.fn() + const createBrowserTab = createBrowserTabMock() const sshHost = toSshExecutionHostId('ssh-target') mocks.state = { ...ownerState(sshHost, 'hub-a'), @@ -535,7 +541,7 @@ describe('openWorkspaceBrowserTab', () => { }) it('does not fall back locally when the runtime create outcome is unknown', async () => { - const createBrowserTab = vi.fn() + const createBrowserTab = createBrowserTabMock() mocks.state = { ...ownerState(toRuntimeExecutionHostId('hub-a')), ...browserCapableRuntime('hub-a'), @@ -592,7 +598,7 @@ describe('openWorkspaceBrowserTab', () => { mocks.state = { ...ownerState(toRuntimeExecutionHostId('hub-a')), ...browserCapableRuntime('hub-a'), - createBrowserTab: vi.fn(), + createBrowserTab: createBrowserTabMock(), defaultBrowserSessionProfileId: 'focused-profile', defaultBrowserSessionProfileIdByHostId: { local: 'local-profile' } } @@ -615,7 +621,7 @@ describe('links from a managed server reached over SSH', () => { ['orcad-env', { status: { capabilities: [] }, checkedAt: 1 }] ]), settings, - createBrowserTab: vi.fn(), + createBrowserTab: createBrowserTabMock(), defaultBrowserSessionProfileId: 'client-profile', defaultBrowserSessionProfileIdByHostId: {} } diff --git a/src/renderer/src/lib/workspace-browser-tab-open.ts b/src/renderer/src/lib/workspace-browser-tab-open.ts index 3b773e15020..517333cedc9 100644 --- a/src/renderer/src/lib/workspace-browser-tab-open.ts +++ b/src/renderer/src/lib/workspace-browser-tab-open.ts @@ -1,4 +1,5 @@ import { createWebRuntimeSessionBrowserTab } from '@/runtime/web-runtime-browser-creation' +import { resolveWebRuntimeBrowserClientTargetGroup } from '@/runtime/web-runtime-browser-client-target-group' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { resolveNewBrowserTabProfile } from '@/store/slices/browser/browser-new-tab-profile' @@ -11,6 +12,7 @@ import type { SearchEngine } from '../../../shared/browser-url' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { BROWSER_SCREENCAST_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import type { BrowserClientHostPlacementPreference } from '../../../shared/browser-client-host-placement' +import type { TabSplitTarget } from '@/store/slices/tabs/tabs-slice-contract' import { getClientCreationActionPolicy, runtimeHostsPagesOnThisDesktop, @@ -30,6 +32,8 @@ export type WorkspaceBrowserTabIntent = { kind: 'url' } | { kind: 'search'; engi export type OpenWorkspaceBrowserTabRequest = { workspaceId: string targetGroupId?: string + /** Opens the tab in a new split beside this group instead of targetGroupId. */ + split?: TabSplitTarget /** Client-local unified tab id of the source; a source-following open omits targetGroupId. */ afterTabId?: string url: string @@ -174,18 +178,20 @@ function createClientBrowserTab( // Why: a desktop-hosted page in a runtime workspace stays owned by that runtime, or the // unified tab is disowned from its worktree (palette and activation skip it). ownerHostId: ExecutionHostId = hostId -): void { +): string | null { try { - state.createBrowserTab(request.workspaceId, request.url, { + const created = state.createBrowserTab(request.workspaceId, request.url, { activate: request.focusOnCreate !== false, browserRuntimeEnvironmentId: null, focusAddressBar: false, executionHostId: ownerHostId, ...resolveNewBrowserTabProfile(state, request.workspaceId, hostId), targetGroupId: request.targetGroupId, + ...(request.split ? { split: request.split } : {}), ...(request.afterTabId ? { afterTabId: request.afterTabId } : {}), title: presentation.title }) + return created.activePageId ?? null } catch (error) { throw openFailure(presentation.error, 'client tab creation rejected', error) } @@ -200,9 +206,10 @@ function assertManagedBrowserEnabled( } } +/** Resolves to the page id when the page renders on this desktop, else null. */ export async function openWorkspaceBrowserTab( request: OpenWorkspaceBrowserTabRequest -): Promise { +): Promise { const presentation = intentPresentation(request.intent, request.url) if (!validateTarget(request.url)) { throw openFailure(presentation.error, 'target is not an http(s) URL') @@ -263,8 +270,7 @@ export async function openWorkspaceBrowserTab( if (!host || host.kind === 'runtime') { throw openFailure(presentation.error, `unresolved client host: ${route.executionHostId}`) } - createClientBrowserTab(state, request, host.id, presentation) - return + return createClientBrowserTab(state, request, host.id, presentation) } if ( route.executionHostId && @@ -285,8 +291,7 @@ export async function openWorkspaceBrowserTab( const localHostId = host && host.kind !== 'runtime' ? host.id : LOCAL_EXECUTION_HOST_ID const ownerHostId = host?.kind === 'runtime' && route.executionHostId ? route.executionHostId : localHostId - createClientBrowserTab(state, request, localHostId, presentation, ownerHostId) - return + return createClientBrowserTab(state, request, localHostId, presentation, ownerHostId) } let created = false try { @@ -294,9 +299,7 @@ export async function openWorkspaceBrowserTab( worktreeId: request.workspaceId, environmentId, url: request.url, - targetGroupId: request.targetGroupId, - // Client placement survives older hosts publishing a page before the create response. - ...(request.targetGroupId ? { clientTargetGroupId: request.targetGroupId } : {}), + ...resolveWebRuntimeBrowserClientTargetGroup(state, request, environmentId), ...(request.afterTabId ? { clientAfterTabId: request.afterTabId } : {}), // Owner-pinned links need the host tab published before client reconciliation. ...(expectedEnvironmentId !== null ? { waitForRegistration: true } : {}), @@ -317,4 +320,5 @@ export async function openWorkspaceBrowserTab( if (!created) { throw openFailure(presentation.error, 'runtime browser tab creation was unavailable') } + return null } diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index b7c92fbf7ee..ce013ca68d0 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -25,6 +25,8 @@ import { getOrCreateRendererRoot } from './lib/react-renderer-root' import { primeTerminalWebglAddon } from './lib/pane-manager/pane-webgl-renderer' import { SkillWarningPreviewLauncher } from './components/skills/SkillWarningPreviewLauncher' import { installBrowserClientPageRenderer } from './components/browser-pane/browser-client-page-renderer-installation' +import { registerBrowserClientPageGuestMessageHandler } from './components/browser-pane/browser-client-page-retained-elements' +import { handleGuestWindowCloseMessage } from './components/browser-pane/host-guest/browser-page-window-close' import { installOsFileDropCancellationGuard } from './lib/os-file-drop-cancellation-guard' const disposeOsFileDropGuard = installOsFileDropCancellationGuard() @@ -47,6 +49,7 @@ if ( } applyDocumentTheme('system', { disableTransitions: false }) +registerBrowserClientPageGuestMessageHandler(handleGuestWindowCloseMessage) const browserClientPageRenderer = installBrowserClientPageRenderer() import.meta.hot?.dispose(() => browserClientPageRenderer?.dispose()) diff --git a/src/renderer/src/runtime/web-runtime-browser-client-target-group.ts b/src/renderer/src/runtime/web-runtime-browser-client-target-group.ts new file mode 100644 index 00000000000..ee82d76f3e5 --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-browser-client-target-group.ts @@ -0,0 +1,35 @@ +import type { AppState } from '@/store/types' +import type { TabSplitTarget } from '@/store/slices/tabs/tabs-slice-contract' +import type { CreateWebRuntimeSessionBrowserTabArgs } from './web-runtime-browser-creation-context' +import { isWebRuntimeSessionActive } from './web-runtime-session-environment' + +/** + * The client group a runtime-hosted tab lands in. A requested split becomes a client-only group + * the host never sees; runtime creation removes it again when the create fails. + */ +export function resolveWebRuntimeBrowserClientTargetGroup( + state: Pick, + request: { workspaceId: string; targetGroupId?: string; split?: TabSplitTarget }, + environmentId: string +): Pick< + CreateWebRuntimeSessionBrowserTabArgs, + 'targetGroupId' | 'clientTargetGroupId' | 'clientTargetGroupCreated' +> { + const splitGroupId = + request.split && isWebRuntimeSessionActive(environmentId) + ? state.createEmptySplitGroup( + request.workspaceId, + request.split.sourceGroupId, + request.split.splitDirection, + { activate: false } + ) + : null + if (splitGroupId) { + return { clientTargetGroupId: splitGroupId, clientTargetGroupCreated: true } + } + return { + targetGroupId: request.targetGroupId, + // Client placement survives older hosts publishing a page before the create response. + ...(request.targetGroupId ? { clientTargetGroupId: request.targetGroupId } : {}) + } +} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index f9b0498a146..9288dd5dca2 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -154,7 +154,7 @@ attachEditorBufferIdentityEvents(() => useAppStore.getState()) registerWindowsPathAccessEnvironments(() => useAppStore.getState().runtimeEnvironments) registerWorkspaceHttpLinkBrowserOpener(async (request) => { const { openWorkspaceBrowserTab } = await import('@/lib/workspace-browser-tab-open') - await openWorkspaceBrowserTab(request) + return openWorkspaceBrowserTab(request) }) // Why: names the fattest store slices in renderer_memory_highwater breadcrumbs diff --git a/src/renderer/src/store/slices/editor/tabs/editor-open-target-group.ts b/src/renderer/src/store/slices/editor/tabs/editor-open-target-group.ts index 7a1ff3b80fe..049db5283d4 100644 --- a/src/renderer/src/store/slices/editor/tabs/editor-open-target-group.ts +++ b/src/renderer/src/store/slices/editor/tabs/editor-open-target-group.ts @@ -118,7 +118,13 @@ export function captureEditorOpenLinkIntent( } } -function liveLayoutGroupIds(state: LinkPlacementState, worktreeId: string): Set { +export function liveLayoutGroupIds( + state: { + layoutByWorktree?: LinkPlacementState['layoutByWorktree'] + groupsByWorktree?: Record[]> + }, + worktreeId: string +): Set { const layout = state.layoutByWorktree?.[worktreeId] const groupIds = new Set((state.groupsByWorktree?.[worktreeId] ?? []).map((group) => group.id)) if (!layout) { diff --git a/src/shared/browser-guest-window-close.ts b/src/shared/browser-guest-window-close.ts new file mode 100644 index 00000000000..53510bd7382 --- /dev/null +++ b/src/shared/browser-guest-window-close.ts @@ -0,0 +1,2 @@ +/** Guest-to-embedder message a page's window.close() sends instead of closing natively. */ +export const BROWSER_GUEST_WINDOW_CLOSE_CHANNEL = 'orca:guest-window-close' diff --git a/src/shared/browser-link-opening-settings.ts b/src/shared/browser-link-opening-settings.ts new file mode 100644 index 00000000000..c39328f8835 --- /dev/null +++ b/src/shared/browser-link-opening-settings.ts @@ -0,0 +1,4 @@ +export type BrowserLinkOpeningSettings = { + /** Links from Orca surfaces join the browser without taking focus from the pane they came from. */ + openLinksInAppInBackground?: boolean +} diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 33230785a0d..67f48ef54f3 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -131,6 +131,7 @@ export function buildDefaultSettings(args: { localhostWorktreeLabelsEnabled: false, openLinksInAppPreferencePrompted: false, openLinksInAppModifierInverts: false, + openLinksInAppInBackground: false, terminalLinkActionPopoverEnabled: true, terminalLinkClickBehavior: 'actions', terminalUrlMiddleClickBehavior: 'open', diff --git a/src/shared/global-settings-base.ts b/src/shared/global-settings-base.ts index e2f49a6803d..af5c2ca0777 100644 --- a/src/shared/global-settings-base.ts +++ b/src/shared/global-settings-base.ts @@ -1,3 +1,4 @@ +import type { BrowserLinkOpeningSettings } from './browser-link-opening-settings' import type { ClaudeAccountSwitchSettings } from './claude-account-switch-settings' import type { ExternalConversationSettings } from './external-conversation-settings' import type { NativeChatGlobalSettings } from './native-chat-appearance-settings' @@ -7,4 +8,5 @@ import type { WorktreeDeletionPreferences } from './worktree-deletion-preference export type GlobalSettingsBase = NativeChatGlobalSettings & WorktreeDeletionPreferences & ClaudeAccountSwitchSettings & - ExternalConversationSettings + ExternalConversationSettings & + BrowserLinkOpeningSettings diff --git a/src/shared/telemetry-property-schemas.ts b/src/shared/telemetry-property-schemas.ts index bb15a8c4150..a06f94fb37a 100644 --- a/src/shared/telemetry-property-schemas.ts +++ b/src/shared/telemetry-property-schemas.ts @@ -200,6 +200,7 @@ export const SETTINGS_CHANGED_WHITELIST = [ 'editorAutoSave', 'openLinksInApp', 'openLinksInAppModifierInverts', + 'openLinksInAppInBackground', 'terminalLinkActionPopoverEnabled', 'experimentalMobile', 'experimentalPet', diff --git a/tests/e2e/ssh-orcad-terminal-browser-link.spec.ts b/tests/e2e/ssh-orcad-terminal-browser-link.spec.ts index 69c77ce9a68..54762eabf30 100644 --- a/tests/e2e/ssh-orcad-terminal-browser-link.spec.ts +++ b/tests/e2e/ssh-orcad-terminal-browser-link.spec.ts @@ -60,7 +60,9 @@ async function terminalLinkPoint( /** Prints origin+path as one link; split apart so the typed command never shows the whole URL. */ async function printLink(page: Page, tabId: string, origin: string, path: string): Promise { - await typeInTerminal(page, tabId, `printf '%s%s\\n' '${origin}' '${path}'`) + // Why the blank row: a link opened beside the terminal narrows it, and a URL that exactly fills a + // row would read the prompt below as its edge-wrapped continuation. + await typeInTerminal(page, tabId, `printf '%s%s\\n\\n' '${origin}' '${path}'`) await expectPaneText(page, tabId, `${origin}${path}`) }