feat(browser): open links beside their pane, focus link tabs, honor window.close() (#31844)

This commit is contained in:
Kelvin Amoaba
2026-10-23 22:20:34 +00:00
committed by GitHub
parent 4c6cfda0ac
commit 291e2e807e
48 changed files with 925 additions and 110 deletions
@@ -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 <webview> (#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 {}
}
}
+14 -4
View File
@@ -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)
})
})
+14 -4
View File
@@ -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<IpcRenderer, 'sendToHost'>
} = require('electron')
const { contextBridge, ipcRenderer } = electron
contextBridge.executeInMainWorld({ func: installBrowserWindowCloseGuard })
contextBridge.executeInMainWorld({
func: installBrowserWindowCloseGuard,
args: [() => ipcRenderer.sendToHost(PRELOAD_BROWSER_GUEST_WINDOW_CLOSE_CHANNEL)]
})
@@ -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) {
@@ -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) {
@@ -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(<ChromeHarness hasGuest={false} />)
act(() => flushFrames(20))
view.rerender(<ChromeHarness />)
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(<ChromeHarness hasGuest={false} />)
act(() => flushFrames())
act(() => requestBrowserFocus({ pageId: PAGE_ID, target: 'webview' }))
act(() => flushFrames(3))
view.rerender(<ChromeHarness />)
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(<ChromeHarness hasGuest={false} />)
act(() => flushFrames(1))
act(() => addressBar().focus())
view.rerender(<ChromeHarness />)
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(<ChromeHarness hasGuest={false} />)
act(() => flushFrames(2))
view.rerender(<ChromeHarness hasGuest={false} isActive={false} />)
act(() => addressBar().focus())
view.rerender(<ChromeHarness />)
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(<ChromeHarness hasGuest={false} />)
act(() => flushFrames(2))
act(() => addressBar().focus())
act(() => flushFrames(2))
view.rerender(<ChromeHarness />)
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' }))
@@ -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<typeof startGuestFocusRetry> | 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 }
}
@@ -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
}
@@ -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']
@@ -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
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { dispatchMock, closeBrowserPageMock, storeState } = vi.hoisted(() => {
const value: Record<string, unknown> = {}
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()
})
})
@@ -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<string>()
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<Electron.WebviewTag, 'canGoBack' | 'canGoForward'>,
event: Pick<Electron.IpcMessageEvent, 'channel'>
): 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 }
})
}
@@ -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<string | null> => null)
const activateMarkdownLink = vi.fn()
let editor: Editor | null = null
@@ -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<GlobalSettings, 'openLinksInAppInBackground'>
updateSettings: (updates: Partial<GlobalSettings>) => void
}
export function BrowserLinkRoutingBackgroundSetting({
settings,
updateSettings
}: BrowserLinkRoutingBackgroundSettingProps): React.JSX.Element {
const entry = getLinkRoutingBackgroundSearchEntry()
return (
<SearchableSetting
title={entry.title}
description={entry.description}
keywords={entry.keywords}
>
{/* Nested under Link Routing: it only changes how an Orca-routed link opens. */}
<div className="ml-4 border-l border-border pl-4">
<SettingsSwitchRow
label={entry.title}
description={entry.description}
checked={settings.openLinksInAppInBackground === true}
onChange={() =>
updateSettings({
openLinksInAppInBackground: settings.openLinksInAppInBackground !== true
})
}
/>
</div>
</SearchableSetting>
)
}
@@ -69,6 +69,9 @@ vi.mock('./BrowserLinkRoutingSetting', () => ({
vi.mock('./BrowserLinkRoutingModifierSetting', () => ({
BrowserLinkRoutingModifierSetting: () => <span>Modifier row</span>
}))
vi.mock('./BrowserLinkRoutingBackgroundSetting', () => ({
BrowserLinkRoutingBackgroundSetting: () => <span>Background links row</span>
}))
vi.mock('./BrowserTerminalLinkActionsSetting', () => ({
BrowserTerminalLinkActionsSetting: () => <span>Terminal links row</span>
}))
@@ -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 ? (
<BrowserLinkRoutingBackgroundSetting settings={settings} updateSettings={updateSettings} />
) : null}
{showTerminalLinkActions ? (
<BrowserTerminalLinkActionsSetting
settings={settings}
@@ -1,4 +1,6 @@
import { translate } from '@/i18n/i18n'
import type { SettingsSearchEntry } from './settings-search'
import { translateSearchKeyword } from './settings-search-keywords'
export function getBrowserLinkRoutingShortcutLabel(platform: { isMac: boolean }): string {
return platform.isMac ? '⇧⌘-click' : 'Shift+Ctrl+click'
@@ -65,3 +67,40 @@ export function getLinkRoutingModifierDescription({
{ chord }
)
}
function getLinkRoutingBackgroundTitle(): string {
return translate(
'auto.components.settings.BrowserLinkRoutingBackgroundSetting.title',
'Open links in the background'
)
}
function getLinkRoutingBackgroundDescription(): string {
return translate(
'auto.components.settings.BrowserLinkRoutingBackgroundSetting.description',
'Links from the terminal, chat, and markdown join the browser without taking focus from where you clicked.'
)
}
export function getLinkRoutingBackgroundSearchEntry(): SettingsSearchEntry {
return {
title: getLinkRoutingBackgroundTitle(),
description: getLinkRoutingBackgroundDescription(),
keywords: [
...translateSearchKeyword('auto.components.settings.browser.search.2d2d995c58', 'browser'),
...translateSearchKeyword('auto.components.settings.browser.search.bea27bac4b', 'links'),
...translateSearchKeyword(
'auto.components.settings.browser.search.linkRoutingBackground.background',
'background'
),
...translateSearchKeyword(
'auto.components.settings.browser.search.linkRoutingBackground.focus',
'focus'
),
...translateSearchKeyword(
'auto.components.settings.browser.search.linkRoutingBackground.tab',
'tab'
)
]
}
}
@@ -108,7 +108,8 @@ describe('browser link routing modifier copy', () => {
'SSH workspaces',
'Browser identity',
'Site permissions',
'Profile for new tabs'
'Profile for new tabs',
'Open links in the background'
])
})
@@ -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()
]
}
@@ -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<string | null> => Promise.resolve(null))
function plainEvent(): MouseEvent {
return {
@@ -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<string | null> => Promise.resolve(null))
const runtimeSourceOwner = { kind: 'runtime', runtimeEnvironmentId: 'env-1' } as const
const sshSourceOwner = { kind: 'ssh', connectionId: 'ssh-1' } as const
@@ -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)
}
})
)
}
@@ -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<string, unknown>
}
@@ -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' } })
)
})
})
+9
View File
@@ -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.",
+9
View File
@@ -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",
+9
View File
@@ -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.",
+9
View File
@@ -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 の権限で後から実行",
+9
View File
@@ -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": "지시 내용 — 사용자 또는 에이전트 권한으로 나중에 실행",
+9
View File
@@ -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.",
+9
View File
@@ -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": "指令内容 — 在用户或智能体权限下运行",
@@ -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
}
@@ -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<typeof resolveHttpLinkBrowserPlacement>[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' })
})
})
@@ -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<AppState, 'layoutByWorktree' | 'activeGroupIdByWorktree' | 'activeView' | 'setActiveView'>
> & {
groupsByWorktree?: Record<string, readonly Pick<TabGroup, 'id' | 'activeTabId'>[]>
unifiedTabsByWorktree?: Record<
string,
readonly (Pick<Tab, 'id'> & Partial<Pick<Tab, 'contentType'>>)[]
>
settings?: Partial<Pick<GlobalSettings, 'openLinksInAppInBackground'>> | null
createBrowserTab: (
worktreeId: string,
url: string,
opts: Pick<CreateBrowserTabOptions, 'activate' | 'targetGroupId' | 'split'>
) => Pick<BrowserWorkspace, 'activePageId'> | 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<HttpLinkBrowserTabState, 'createBrowserTab'>,
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<HttpLinkBrowserTabState, 'settings'>
): 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<HttpLinkBrowserTabState, 'createBrowserTab'>,
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<HttpLinkBrowserTabState, 'activeView' | 'setActiveView'>,
worktreeId: string
): void {
if (!isFloatingWorkspaceId(worktreeId) && state.activeView !== 'terminal') {
state.setActiveView?.('terminal')
}
}
@@ -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<string | null> => Promise.resolve(null))
const storeState = {
settings: {} as {
openLinksInApp?: boolean
+59 -10
View File
@@ -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<string | null> => 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 }
+33 -15
View File
@@ -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<string, LocalhostLinkWorktree[]>
@@ -51,7 +59,7 @@ type StoreAccessor = () => {
workspacePortScansByKey?: Record<string, WorkspacePortScanResult>
}
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<void>
/** Resolves to the page id when the page renders on this desktop, else null. */
type WorkspaceHttpLinkBrowserOpener = (
request: WorkspaceHttpLinkBrowserRequest
) => Promise<string | null>
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
}
@@ -13,6 +13,12 @@ import {
openWorkspaceBrowserTab
} from './workspace-browser-tab-open'
function createBrowserTabMock() {
return vi.fn((_worktreeId: string, _url: string, _options: Record<string, unknown>) => ({
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: {}
}
@@ -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<void> {
): Promise<string | null> {
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
}
+3
View File
@@ -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())
@@ -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<AppState, 'createEmptySplitGroup'>,
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 } : {})
}
}
+1 -1
View File
@@ -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
@@ -118,7 +118,13 @@ export function captureEditorOpenLinkIntent(
}
}
function liveLayoutGroupIds(state: LinkPlacementState, worktreeId: string): Set<string> {
export function liveLayoutGroupIds(
state: {
layoutByWorktree?: LinkPlacementState['layoutByWorktree']
groupsByWorktree?: Record<string, readonly Pick<TabGroup, 'id'>[]>
},
worktreeId: string
): Set<string> {
const layout = state.layoutByWorktree?.[worktreeId]
const groupIds = new Set((state.groupsByWorktree?.[worktreeId] ?? []).map((group) => group.id))
if (!layout) {
+2
View File
@@ -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'
@@ -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
}
+1
View File
@@ -131,6 +131,7 @@ export function buildDefaultSettings(args: {
localhostWorktreeLabelsEnabled: false,
openLinksInAppPreferencePrompted: false,
openLinksInAppModifierInverts: false,
openLinksInAppInBackground: false,
terminalLinkActionPopoverEnabled: true,
terminalLinkClickBehavior: 'actions',
terminalUrlMiddleClickBehavior: 'open',
+3 -1
View File
@@ -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
+1
View File
@@ -200,6 +200,7 @@ export const SETTINGS_CHANGED_WHITELIST = [
'editorAutoSave',
'openLinksInApp',
'openLinksInAppModifierInverts',
'openLinksInAppInBackground',
'terminalLinkActionPopoverEnabled',
'experimentalMobile',
'experimentalPet',
@@ -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<void> {
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}`)
}