mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
feat(browser): open links beside their pane, focus link tabs, honor window.close() (#31844)
This commit is contained in:
@@ -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 {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)]
|
||||
})
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+4
-1
@@ -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) {
|
||||
|
||||
+62
@@ -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' }))
|
||||
|
||||
|
||||
+92
-22
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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' } })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 の権限で後から実行",
|
||||
|
||||
@@ -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": "지시 내용 — 사용자 또는 에이전트 권한으로 나중에 실행",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -131,6 +131,7 @@ export function buildDefaultSettings(args: {
|
||||
localhostWorktreeLabelsEnabled: false,
|
||||
openLinksInAppPreferencePrompted: false,
|
||||
openLinksInAppModifierInverts: false,
|
||||
openLinksInAppInBackground: false,
|
||||
terminalLinkActionPopoverEnabled: true,
|
||||
terminalLinkClickBehavior: 'actions',
|
||||
terminalUrlMiddleClickBehavior: 'open',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user