Open browser links beside their source (#32907)

* Open links beside their source and reuse matching browser pages

- Preserve source-group placement across terminal, chat, file, and runtime links
- Add explicit “Open in this group” actions with safe refusal handling
- Match browser pages by host, session, resource, and exact page identity

* Preserve file link ownership when opening beside source

* Isolate browser link sessions by workspace profile

* Wait for browser tabs to render before activating links

* Respect explicit group requests when opening detected files

* Preserve legacy link ownership when opening browser tabs

* Clean up unactivated browser tabs after failed link opens

* Reveal routed browser links without stealing focus

* Preserve browser profile reuse and foreground ownership during link open

* Prefer visible browser panes and reuse exact link destinations

Reuse matching pages within the same workspace, execution host and browser
profile. Otherwise open in the focused or most recently used visible browser,
or create a split beside the source when no browser is visible. Keep explicit
group placement and background/focus ownership protections.

Incorporate visible-browser selection and popup routing from #22686 into
#32907, including selector regression coverage.

Co-authored-by: Luca Critelli <lucacri@gmail.com>

* Route browser links by workspace without reading global focus

* Validate SSH browser ownership before opening links

* fix branch file size limit

* fix(ci): track extracted browser link ratchet entries

* fix(test): follow source-aware browser link opening

---------

Co-authored-by: Luca Critelli <lucacri@gmail.com>
This commit is contained in:
Jinjing
2026-10-25 09:30:37 -07:00
committed by GitHub
co-authored by Luca Critelli
parent dd797fc65e
commit 7423928beb
97 changed files with 6669 additions and 826 deletions
+4 -9
View File
@@ -23,7 +23,6 @@
1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-stream.ts
1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.ts
2 src/renderer/src/components/cmd-j/quick-action-context.ts
1 src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx
1 src/renderer/src/components/editor/csv/csv-link-routing.ts
2 src/renderer/src/components/editor/EditorContent.tsx
1 src/renderer/src/components/editor/EditorDiffFileSurface.tsx
@@ -32,7 +31,6 @@
1 src/renderer/src/components/editor/markdown-preview-link-actions.ts
1 src/renderer/src/components/editor/markdown-preview-system-link-action.ts
1 src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.ts
2 src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts
2 src/renderer/src/components/editor/useEditorPanelContentState.ts
1 src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts
2 src/renderer/src/components/editor/useMarkdownDocuments.ts
@@ -59,8 +57,6 @@
2 src/renderer/src/components/LinearIssueTextEditor.tsx
3 src/renderer/src/components/LinearIssueWorkspace.tsx
3 src/renderer/src/components/LinearItemDrawer.tsx
2 src/renderer/src/components/native-chat/native-chat-http-link-source-owner.ts
1 src/renderer/src/components/native-chat/native-chat-link-target-actions.ts
1 src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx
1 src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx
1 src/renderer/src/components/new-workspace/use-jira-source-connection.ts
@@ -74,7 +70,6 @@
1 src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx
1 src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-context-menu.tsx
1 src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts
1 src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts
1 src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
1 src/renderer/src/components/settings/BaseRefPicker.tsx
1 src/renderer/src/components/settings/bitbucket-credentials-dialog.tsx
@@ -180,7 +175,7 @@
1 src/renderer/src/components/terminal-pane/terminal-fit-restore.ts
1 src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts
2 src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts
3 src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts
1 src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts
2 src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts
1 src/renderer/src/components/terminal-pane/use-terminal-pane-controller.ts
1 src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
@@ -225,9 +220,9 @@
1 src/renderer/src/lib/client-creation-action-error.ts
2 src/renderer/src/lib/client-creation-action-policy.ts
1 src/renderer/src/lib/default-creation-host.ts # the sanctioned reader of the default host
2 src/renderer/src/lib/file-preview.ts
6 src/renderer/src/lib/http-link-destinations.ts
3 src/renderer/src/lib/http-link-routing.ts
1 src/renderer/src/lib/http-link-localhost-route.ts # extracted from http-link-routing.ts
1 src/renderer/src/lib/http-link-routing.ts
1 src/renderer/src/lib/launch-agent-in-new-tab.ts
2 src/renderer/src/lib/launch-agent-web-host-tab.ts
1 src/renderer/src/lib/launch-ai-vault-session.ts
@@ -242,7 +237,7 @@
4 src/renderer/src/lib/terminal-worktree-route.ts # resolveTerminalPtyOwner: teardown, close and PTY identity read spawn's attribution (round 3 F8), which retired the deprecated resolver's readers
3 src/renderer/src/lib/unread-badge-count-selector.ts
1 src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts
4 src/renderer/src/lib/workspace-browser-tab-open.ts
2 src/renderer/src/lib/workspace-browser-tab-open.ts
1 src/renderer/src/lib/worktree-activation.ts
1 src/renderer/src/lib/worktree-jump-navigation.ts
1 src/renderer/src/lib/worktree-runtime-owner.ts # a setup terminal with no row spawns on the focused runtime
+2 -1
View File
@@ -53,7 +53,8 @@
1 src/renderer/src/store/slices/browser/browser-hydration-state.ts
5 src/renderer/src/store/slices/browser/browser-page-focus-actions.ts
2 src/renderer/src/store/slices/browser/browser-page-state-actions.ts
6 src/renderer/src/store/slices/browser/browser-tab-actions.ts
1 src/renderer/src/store/slices/browser/browser-tab-actions.ts
5 src/renderer/src/store/slices/browser/browser-tab-open-view.ts # extracted from browser-tab-actions.ts
4 src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts
2 src/renderer/src/store/slices/browser/create-browser-slice.ts
9 src/renderer/src/store/slices/editor/actions/check-run-details-actions.ts
+1 -1
View File
@@ -36,7 +36,7 @@
2 src/renderer/src/lib/direct-ssh-target-scope.ts # P5b-2 must clear: optional input fields default to empty
3 src/renderer/src/lib/duplicate-paired-servers.ts # P5b-2 must clear: optional state fields default to empty
1 src/renderer/src/lib/folder-workspace-runtime-owner.ts # P5b-2 must clear: loosely typed state param
1 src/renderer/src/lib/http-link-routing.ts # P5b-2 must clear: optional state field defaults to empty
1 src/renderer/src/lib/http-link-localhost-route.ts # P5b-2 must clear: optional state field defaults to empty; extracted from http-link-routing.ts
2 src/renderer/src/lib/local-project-runtime-workspace.ts # P5b-2 must clear: optional state field defaults to empty
3 src/renderer/src/lib/resolve-owner.ts # P5b-2 must clear: optional state fields; a missing copy reads as no match
3 src/renderer/src/lib/resolved-worktree-execution-host.ts # P5b-2 must clear: loosely typed state param
+3 -1
View File
@@ -18,7 +18,7 @@ Every Orca worktree has its own browser. It's a real Chromium window — address
- `Cmd-F` — find in page.
- `Cmd-T` — new tab, scoped to this worktree.
- `Cmd-Shift-T` — reopen last closed tab.
- Page links that use `target=_blank` and ordinary unnamed popups open in new Orca tabs, using the opener tab's browser profile. Named or OAuth-style popups can still open in a separate window.
- Page links that use `target=_blank` and ordinary unnamed popups open in new Orca tabs in a visible browser pane when available, using the opener tab's browser profile. Named or OAuth-style popups can still open in a separate window.
## Worktree scoping
@@ -36,6 +36,8 @@ For SSH workspaces, **Browse through SSH workspace hosts** controls whether brow
Under [Settings → Browser → Link Routing](/docs/settings), choose whether http(s) links from the terminal, markdown, and editor open in Orca's per-worktree browser or the system browser.
Links opened in Orca first reuse an existing page for the exact destination in the same workspace, execution host, and browser profile. Otherwise, they open a new tab in a pane already showing a browser. When several browser panes are visible, the focused one wins, then the most recently used one. Browser tabs hidden behind other tabs do not attract new pages. With no browser visible, a new split opens beside the source pane, keeping its contents visible. Explicit **Open in this group** actions use the requested pane instead.
A nested **Hold Shift…** toggle inverts that default for one click with the platform modifier (`⇧⌘-click` on macOS, `Shift+Ctrl+click` on Windows / Linux):
- When links open in **Orca**, the modifier sends one link to the system browser.
@@ -110,25 +110,33 @@ describe('AgentBrowserBridge', () => {
})
describe('worktree filtering', () => {
it('returns only matching worktree tabs', () => {
const tabs = new Map([
['tab-a', 1],
['tab-b', 2]
])
const worktrees = new Map([
['tab-a', 'wt-1'],
['tab-b', 'wt-2']
])
const wc1 = mockWebContents(1, 'https://a.com', 'A')
const wc2 = mockWebContents(2, 'https://b.com', 'B')
webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : wc2))
it.each([null, 'workspace-login'])(
'lists the matching workspace with profile %s',
(profileId) => {
const tabs = new Map([
['tab-a', 1],
['tab-b', 2]
])
const worktrees = new Map([
['tab-a', 'wt-1'],
['tab-b', 'wt-2']
])
const wc1 = mockWebContents(1, 'https://a.com', 'A')
const wc2 = mockWebContents(2, 'https://b.com', 'B')
webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : wc2))
const b = new AgentBrowserBridge(mockBrowserManager(tabs, worktrees))
const result = b.tabList('wt-1')
expect(result.tabs).toHaveLength(1)
expect(result.tabs[0].browserPageId).toBe('tab-a')
expect(result.tabs[0].url).toBe('https://a.com')
})
const b = new AgentBrowserBridge(
mockBrowserManager(tabs, worktrees, {
getSessionProfileIdForTab: vi.fn(() => profileId)
})
)
const result = b.tabList('wt-1')
expect(result.tabs).toHaveLength(1)
expect(result.tabs[0].browserPageId).toBe('tab-a')
expect(result.tabs[0].url).toBe('https://a.com')
expect(result.tabs[0].profileId).toBe(profileId ?? 'default')
}
)
it('surfaces the browser-manager load error on each listed tab', () => {
const tabs = new Map([['tab-a', 1]])
@@ -102,6 +102,7 @@ export abstract class AgentBrowserBridgeTabs extends AgentBrowserBridgeState {
const certificateFailure = this.browserManager.getBrowserPageCertificateFailure(tabId)
result.push({
browserPageId: tabId,
profileId: this.browserManager.getSessionProfileIdForTab(tabId) ?? 'default',
index: index++,
// Failed pages report chrome-error:// instead of the requested URL.
url: loadError?.validatedUrl ?? wc.getURL() ?? '',
@@ -18,6 +18,7 @@ export function mockBrowserManager(
worktrees = new Map<string, string>(),
overrides: Partial<BrowserManager> = {}
): BrowserManager {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Bridge tests supply only the BrowserManager methods they exercise.
return {
getWebContentsIdByTabId: () => tabs,
getTabIdForWebContentsId: (webContentsId: number) => {
@@ -30,6 +31,7 @@ export function mockBrowserManager(
},
getWorktreeIdForTab: (tabId: string) => worktrees.get(tabId),
getGuestWebContentsId: vi.fn(() => null),
getSessionProfileIdForTab: vi.fn(() => null),
getBrowserPageLoadError: vi.fn(() => null),
getBrowserPageCertificateFailure: vi.fn(() => null),
unregisterGuest: vi.fn(),
@@ -17,6 +17,7 @@ function restartedServe(session: WorkspaceSessionState = savedServeTabs()) {
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(saved)
Object.assign(runtimeStore, { getWorkspaceSessionHostIds: () => ['local'] })
const runtime = new OrcaRuntimeService(runtimeStore)
let profileId = 'default'
const livePages: string[] = []
const createTab = vi.fn(async ({ browserPageId }: { browserPageId?: string }) => {
livePages.push(browserPageId!)
@@ -30,6 +31,7 @@ function restartedServe(session: WorkspaceSessionState = savedServeTabs()) {
tabList: vi.fn(() => ({
tabs: livePages.map((browserPageId, index) => ({
browserPageId,
profileId,
index,
url: 'https://example.com/',
title: 'Example',
@@ -46,7 +48,14 @@ function restartedServe(session: WorkspaceSessionState = savedServeTabs()) {
}))
} as never
)
return { runtime, createTab, getSession }
return {
runtime,
createTab,
getSession,
setProfileId: (id: string) => {
profileId = id
}
}
}
function savedServeTabs(): WorkspaceSessionState {
@@ -135,6 +144,26 @@ describe('a saved browser tab on a host with no window', () => {
title: 'Example'
})
expect(createTab).not.toHaveBeenCalled()
const browserTab = listed.tabs.find((tab) => tab.type === 'browser')
expect(browserTab?.type === 'browser' && browserTab.browserProfileId).toBeUndefined()
})
it('publishes the registered page profile and refreshes when only that identity changes', async () => {
const { runtime, setProfileId } = restartedServe()
await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'page-1')
const initial = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(initial.tabs.find((tab) => tab.id === 'page-1')).toMatchObject({
browserProfileId: 'default'
})
setProfileId('registered-login')
const changed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(changed.tabs.find((tab) => tab.id === 'page-1')).toMatchObject({
browserProfileId: 'registered-login'
})
expect(changed.snapshotVersion).toBeGreaterThan(initial.snapshotVersion)
const dormant = changed.tabs.find((tab) => tab.id === 'page-2')
expect(dormant?.type === 'browser' && dormant.browserProfileId).toBeUndefined()
})
it('starts its page once, at its saved address, when a client opens it', async () => {
@@ -52,6 +52,7 @@ export abstract class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs exten
title: tab.title || tab.url || 'Browser',
browserWorkspaceId: tab.browserPageId,
browserPageId: tab.browserPageId,
browserProfileId: tab.profileId ?? undefined,
url: tab.url || 'about:blank',
loading: false,
canGoBack: false,
@@ -114,8 +114,11 @@ function clickLink({
describe('rich Markdown alternate browser click', () => {
it.each([true, false])('opens Orca when the system browser is primary (Mac: %s)', (isMac) => {
expect(clickLink({ isMac })).toBe(true)
expect(createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/docs', {
activate: true
expect(openOwnedBrowser).toHaveBeenCalledWith({
workspaceId: 'wt-1',
url: 'https://example.com/docs',
intent: { kind: 'url' },
linkContext: { linkIntent: null, canReveal: expect.any(Function) }
})
expect(openUrl).not.toHaveBeenCalled()
expect(activateMarkdownLink).not.toHaveBeenCalled()
@@ -143,6 +146,7 @@ describe('rich Markdown alternate browser click', () => {
workspaceId: 'wt-1',
url: 'https://example.com/docs',
intent: { kind: 'url' },
linkContext: { linkIntent: null, canReveal: expect.any(Function) },
...(sourceOwner.kind === 'ssh'
? { expectedSshConnectionId: sourceOwner.connectionId }
: sourceOwner.kind === 'runtime'
@@ -0,0 +1,112 @@
// @vitest-environment happy-dom
import type * as BrowserFocusModule from '@/components/browser-pane/host-guest/browser-focus'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { NativeChatInlineVisual } from './NativeChatInlineVisual'
import { NativeChatVisualOwnerProvider, useNativeChatVisualOwner } from './native-chat-visual-owner'
import { TooltipProvider } from '@/components/ui/tooltip'
import { seedBrowserLinkTestStore, WORKSPACE } from '@/lib/browser-link-open.test-fixture'
import { createStoreCascadesMockApi } from '@/store/slices/store-cascades-test-harness'
vi.mock('./use-native-chat-visual-document', () => ({
useNativeChatVisualDocument: () => ({
status: 'ready',
document: { path: '/repo/chart.html', revision: '1', html: '<p>chart</p>' }
})
}))
vi.mock('./NativeChatTranscriptChrome', () => ({
observeTranscriptVisibility: (_element: Element, visible: (value: boolean) => void) => {
visible(true)
return () => {}
}
}))
vi.mock('@/components/browser-pane/host-guest/browser-focus', async (importOriginal) => ({
...(await importOriginal<typeof BrowserFocusModule>()),
requestBrowserFocus: vi.fn()
}))
vi.mock('sonner', () => ({
toast: { error: vi.fn(), info: vi.fn(), success: vi.fn(), warning: vi.fn() }
}))
const domWindow = window
const api = createStoreCascadesMockApi()
vi.stubGlobal('window', domWindow)
Object.defineProperty(domWindow, 'api', { value: api, configurable: true })
afterEach(cleanup)
beforeEach(() => {
seedBrowserLinkTestStore(useAppStore)
useAppStore.setState((state) => ({
unifiedTabsByWorktree: {
[WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) =>
tab.id === 'source' ? { ...tab, contentType: 'agent-session' as const } : tab
)
}
}))
})
function OwnedVisual() {
const owner = useNativeChatVisualOwner()
return owner ? <NativeChatInlineVisual owner={owner} file="chart.html" title="Chart" /> : null
}
function showVisual() {
render(
<TooltipProvider>
<NativeChatVisualOwnerProvider target={{ kind: 'local' }} sessionId="session" tabId="source">
<OwnedVisual />
</NativeChatVisualOwnerProvider>
</TooltipProvider>
)
}
function wrapperForActiveBrowser() {
const state = useAppStore.getState()
return state.unifiedTabsByWorktree[WORKSPACE]?.find(
(tab) => tab.entityId === state.activeBrowserTabIdByWorktree[WORKSPACE]
)
}
it('the visible button opens beside its owning chat even while another group has focus', () => {
showVisual()
fireEvent.click(screen.getByRole('button', { name: 'Open in new tab' }))
const groupId = wrapperForActiveBrowser()?.groupId
expect(groupId).toBeTruthy()
expect(['a', 'b', 'c']).not.toContain(groupId)
expect(useAppStore.getState().layoutByWorktree[WORKSPACE]).toMatchObject({
first: { type: 'split', first: { groupId: 'a' }, second: { groupId } }
})
expect(
useAppStore.getState().groupsByWorktree[WORKSPACE]?.find((group) => group.id === 'a')
?.activeTabId
).toBe('source')
expect(screen.getByRole('button', { name: 'Open in new tab' })).toBeTruthy()
})
it('the button follows a moved source and reuses the exact inactive page', () => {
const browser = useAppStore.getState().createBrowserTab(WORKSPACE, 'file:///repo/chart.html', {
targetGroupId: 'c',
executionHostId: 'local',
browserRuntimeEnvironmentId: null,
activate: false
})
useAppStore.getState().createBrowserPage(browser.id, 'https://other.example/', {
activate: true,
browserRuntimeEnvironmentId: null
})
showVisual()
act(() => {
useAppStore.setState((state) => ({
unifiedTabsByWorktree: {
[WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) =>
tab.id === 'source' ? { ...tab, groupId: 'b' } : tab
)
}
}))
})
fireEvent.click(screen.getByRole('button', { name: 'Open in new tab' }))
expect(wrapperForActiveBrowser()?.entityId).toBe(browser.id)
expect(wrapperForActiveBrowser()?.groupId).toBe('c')
expect(
useAppStore.getState().browserTabsByWorktree[WORKSPACE]?.find((tab) => tab.id === browser.id)
?.activePageId
).toBe(browser.activePageId)
})
@@ -1,3 +1,6 @@
import { useAppStore } from '@/store'
import { captureLinkOpenIntent } from '@/lib/link-open-intent'
import { beginLinkOpenRequest } from '@/lib/link-open-request'
import { useContext, useEffect, useRef, useState } from 'react'
import { SquareArrowOutUpRight } from 'lucide-react'
import { toast } from 'sonner'
@@ -27,8 +30,14 @@ export function NativeChatVisualUnavailable(): React.JSX.Element {
}
/** Opens the visual like any HTML file of the chat's workspace: rendered, its source a click away. */
function openNativeChatVisualFile(worktreeId: string, path: string): void {
const plan = openFileInBrowserTab({ filePath: path, worktreeId })
function openNativeChatVisualFile(owner: NativeChatVisualOwner, path: string): void {
const isCurrentRequest = beginLinkOpenRequest()
const linkIntent = captureLinkOpenIntent(useAppStore.getState(), owner.worktreeId, owner.tabId)
const plan = openFileInBrowserTab({
filePath: path,
worktreeId: owner.worktreeId,
...(owner.tabId ? { linkContext: { linkIntent, isCurrentRequest } } : {})
})
if (plan.status === 'unsupported') {
toast.error(plan.message)
}
@@ -117,7 +126,7 @@ export function NativeChatInlineVisual({
variant="outline"
size="icon-xs"
aria-label={openLabel}
onClick={() => openNativeChatVisualFile(owner.worktreeId, state.document.path)}
onClick={() => openNativeChatVisualFile(owner, state.document.path)}
>
<SquareArrowOutUpRight />
</Button>
@@ -256,7 +256,8 @@ describe('native chat Markdown image preview', () => {
worktreeId: 'wt-1',
forceInApp: openLinksInApp,
forceSystemBrowser: !openLinksInApp,
sourceOwner: { kind: 'local' }
sourceOwner: { kind: 'local' },
linkContext: { linkIntent: null, isCurrentRequest: expect.any(Function) }
})
}
)
@@ -85,12 +85,35 @@ export function nativeChatLinkActions(
return {
destination: route.url,
kind: 'url',
...buildHttpLinkActions(routing.destinations, (destination) =>
openNativeChatWebLink(
route.url,
{ worktreeId: context.worktreeId, sourceOwner: routing.sourceOwner },
destination
)
...buildHttpLinkActions(
routing.destinations,
(destination) =>
openNativeChatWebLink(
route.url,
{
worktreeId: context.worktreeId,
sourceTabId: context.sourceTabId,
sourceOwner: routing.sourceOwner
},
destination
),
{
worktreeId: context.worktreeId,
sourceTabId: context.sourceTabId,
sourceOwner: routing.sourceOwner,
url: route.url,
openInSourceGroup: () =>
openNativeChatWebLink(
route.url,
{
worktreeId: context.worktreeId,
sourceTabId: context.sourceTabId,
sourceOwner: routing.sourceOwner
},
'orca',
true
)
}
)
}
}
@@ -6,6 +6,7 @@ export type NativeChatVisualOwner = {
target: RuntimeClientTarget
sessionId: string
worktreeId: string
tabId?: string
}
export const NativeChatVisualOwnerContext = createContext<NativeChatVisualOwner | null>(null)
@@ -36,10 +37,11 @@ export function NativeChatVisualOwnerProvider({
target:
environmentId === null ? { kind: 'local' } : { kind: 'environment', environmentId },
sessionId,
tabId,
worktreeId
}
: null,
[environmentId, sessionId, worktreeId]
[environmentId, sessionId, tabId, worktreeId]
)
return (
<NativeChatVisualOwnerContext.Provider value={owner}>
@@ -16,6 +16,7 @@ import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
export type NativeChatWebLinkDeps = {
worktreeId: string
sourceTabId?: string | null
sourceOwner: HttpLinkSourceOwner
destinations: HttpLinkActionDestinations
/** Off: a plain click opens the routed destination outright, as it did before actions existed. */
@@ -37,11 +38,14 @@ type ChatLinkMouseEvent = Pick<
export function openNativeChatWebLink(
url: string,
deps: Pick<NativeChatWebLinkDeps, 'worktreeId' | 'sourceOwner'>,
destination: HttpLinkDestination | undefined
deps: Pick<NativeChatWebLinkDeps, 'worktreeId' | 'sourceOwner' | 'sourceTabId'>,
destination: HttpLinkDestination | undefined,
openInSourceGroup = false
): void {
openRoutedHttpLink(url, {
worktreeId: deps.worktreeId,
...(deps.sourceTabId ? { sourceTabId: deps.sourceTabId } : {}),
...(openInSourceGroup ? { openInSourceGroup: true } : {}),
sourceOwner: deps.sourceOwner,
modifierHeld: false,
...(destination ? { forceDestination: destination } : {})
@@ -82,7 +86,11 @@ export function handleNativeChatWebLink(
destination: url,
kind: 'url',
restoreFocus: deps.restoreFocus,
...buildHttpLinkActions(deps.destinations, open)
...buildHttpLinkActions(deps.destinations, open, {
...deps,
url,
openInSourceGroup: () => openNativeChatWebLink(url, deps, 'orca', true)
})
})
return true
}
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { LinkActionPopover } from '@/components/link-actions/LinkActionPopover'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { useNativeChatLinkActions } from './use-native-chat-link-actions'
import type * as HttpLinkRoutingModule from '@/lib/http-link-routing'
const mocks = vi.hoisted(() => ({
openHttpLink: vi.fn(),
@@ -16,7 +17,10 @@ const mocks = vi.hoisted(() => ({
}
}))
vi.mock('@/lib/http-link-routing', () => ({ openHttpLink: mocks.openHttpLink }))
vi.mock('@/lib/http-link-routing', async (importOriginal) => ({
...(await importOriginal<typeof HttpLinkRoutingModule>()),
openHttpLink: mocks.openHttpLink
}))
vi.mock('./native-chat-http-link-source-owner', () => ({
resolveNativeChatHttpLinkSourceOwner: () => ({ kind: 'local' }),
@@ -59,6 +59,7 @@ export function useNativeChatLinkActions(
const anchor = event.currentTarget
handleNativeChatWebLink(event, route.url, {
worktreeId: context.worktreeId,
sourceTabId: context.sourceTabId,
...resolveNativeChatHttpLinkRouting(state, context.worktreeId),
actionsEnabled: plainClickBehavior === 'actions',
plainClickBehavior,
@@ -2,7 +2,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { activateAndRevealWorkspace } from '@/lib/worktree-activation'
import { openHttpLink, registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
import {
openHttpLink,
registerHttpLinkStoreAccessor,
registerWorkspaceHttpLinkBrowserOpener
} from '@/lib/http-link-routing'
import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants'
import { openDetectedFilePath } from './terminal-file-open-routing'
import { buildFileLinkActions, handleTerminalFileLink } from './terminal-file-link-actions'
@@ -77,6 +81,7 @@ beforeEach(() => {
})
afterEach(() => {
registerWorkspaceHttpLinkBrowserOpener(null)
useAppStore.setState(initialState, true)
vi.unstubAllGlobals()
})
@@ -175,16 +180,20 @@ describe('floating file links preserve main-window selection', () => {
})
it('keeps HTTP links in the floating browser as well', () => {
const openBrowserTab = vi.fn(async () => null)
registerWorkspaceHttpLinkBrowserOpener(openBrowserTab)
const before = mainSelection()
openHttpLink('https://example.com', {
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
sourceOwner: { kind: 'local' }
})
expect(createBrowserTab).toHaveBeenCalledWith(
FLOATING_TERMINAL_WORKTREE_ID,
'https://example.com',
{ activate: true }
)
expect(openBrowserTab).toHaveBeenCalledExactlyOnceWith({
workspaceId: FLOATING_TERMINAL_WORKTREE_ID,
url: 'https://example.com',
intent: { kind: 'url' },
linkContext: { linkIntent: null, canReveal: expect.any(Function) }
})
expect(createBrowserTab).not.toHaveBeenCalled()
expect(mainSelection()).toEqual(before)
})
})
@@ -364,7 +364,7 @@ describe('open in this group', () => {
rows.find((row: { label: string }) => row.label === 'Open in this group').run()
expect(mocks.openDetectedFilePath).toHaveBeenCalledWith('/repo/src/main.ts', 3, null, {
...sourceDeps,
openInSourceGroup: true
openInSourceGroupSurface: 'internal'
})
})
@@ -160,7 +160,10 @@ export function buildFileLinkActions(
'Open in this group'
),
run: () =>
openDetectedFilePath(filePath, line, column, { ...deps, openInSourceGroup: true })
openDetectedFilePath(filePath, line, column, {
...deps,
openInSourceGroupSurface: 'internal'
})
}
: null
const secondaryActions = [openInGroupRow, revealRow].filter((row) => row !== null)
@@ -0,0 +1,99 @@
import { getWorkspaceClientCreationActionPolicy } from '@/lib/client-creation-action-policy'
import { absolutePathToFileUri } from '@/components/editor/markdown-internal-links'
import { useAppStore } from '@/store'
import {
openPlannedFileBrowserLink,
type FileBrowserLinkTarget
} from '@/lib/file-preview-link-open'
import type { BrowserLinkOpenContext, BrowserOpenOutcome } from '@/lib/browser-link-destination'
import { activateAndRevealWorkspace } from '@/lib/worktree-activation'
import { getWorkspaceFilePreviewPlan, openFileInBrowserTab } from '@/lib/file-preview'
export async function openResolvedHtmlFileInBrowser(
params: FileBrowserLinkTarget & {
clientLocal: boolean
linkContext?: BrowserLinkOpenContext
}
): Promise<BrowserOpenOutcome | null> {
const state = useAppStore.getState()
const url = absolutePathToFileUri(params.filePath)
const title = params.filePath.split(/[/\\]/).pop() ?? params.filePath
if (params.linkContext) {
const context = params.clientLocal
? { ...params.linkContext, linkIntent: null }
: params.linkContext
// A client-local discovery from a remote source changes file scope, even in the same wrapper workspace.
if (params.clientLocal && params.linkContext.linkIntent?.targetGroupId) {
return { status: 'refused' }
}
return openPlannedFileBrowserLink(
state,
params,
{ status: 'browser-tab', url, title },
context,
(current) =>
getWorkspaceClientCreationActionPolicy(current, params.worktreeId)['managed-browser']
.state === 'enabled'
)
}
if (params.worktreeId) {
activateAndRevealWorkspace(params.worktreeId, { providesInitialSurface: true })
}
state.createBrowserTab(params.worktreeId, url, {
title,
activate: true,
...(params.clientLocal ? { browserRuntimeEnvironmentId: null } : {})
})
return null
}
type TerminalHtmlFileBrowserTarget = FileBrowserLinkTarget & {
clientLocal: boolean
canOpenWithSystemDefault: boolean
linkContext?: BrowserLinkOpenContext
refuseOpenInSourceGroup?: () => boolean
}
/** Returns true when the HTML link was opened or deliberately refused. */
export function openTerminalHtmlFileInBrowser(params: TerminalHtmlFileBrowserTarget): boolean {
if (params.canOpenWithSystemDefault) {
void openResolvedHtmlFileInBrowser(params).then((outcome) => {
if (outcome?.status === 'refused' || outcome?.status === 'unsupported') {
params.refuseOpenInSourceGroup?.()
}
})
return true
}
const plan = getWorkspaceFilePreviewPlan(
useAppStore.getState(),
params.worktreeId,
params.filePath
)
if (plan.status !== 'doc-preview') {
return false
}
if (!params.linkContext) {
activateAndRevealWorkspace(params.worktreeId, { providesInitialSurface: true })
}
openFileInBrowserTab({
filePath: params.filePath,
worktreeId: params.worktreeId,
...(params.linkContext ? { fileExecutionHostId: params.fileExecutionHostId } : {}),
...(params.linkContext
? {
linkContext: params.linkContext,
onLinkOutcome: (outcome) => {
if (outcome.status === 'refused' || outcome.status === 'unsupported') {
params.refuseOpenInSourceGroup?.()
}
}
}
: {})
})
return true
}
export function isHtmlFilePath(filePath: string): boolean {
return /\.html?$/i.test(filePath)
}
@@ -1,7 +1,7 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
/** The file resolved to something other than an editor in the captured group, so nothing opened. */
/** The resolved route cannot honor the captured group. */
export function showOpenInGroupRefusedToast(filePath: string): void {
toast.error(
translate(
@@ -11,3 +11,17 @@ export function showOpenInGroupRefusedToast(filePath: string): void {
)
)
}
export function refuseOpenInGroupRequest(
filePath: string,
explicit: boolean,
isCurrent: () => boolean
): boolean {
if (!explicit) {
return false
}
if (isCurrent()) {
showOpenInGroupRefusedToast(filePath)
}
return true
}
@@ -1,17 +1,18 @@
import { getWorkspaceFilePreviewPlan, openFileInBrowserTab } from '@/lib/file-preview'
import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open'
import { findWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route'
import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links'
import { canClientOsOpenWorkspaceFile } from '@/lib/workspace-file-host-routing'
import { isMissingRuntimePathError } from '@/runtime/runtime-file-metadata-client'
import { useAppStore } from '@/store'
import { activateAndRevealWorkspace, activateAndRevealWorktree } from '@/lib/worktree-activation'
import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link'
import {
resolveKnownWorktreeRootPathLink,
terminalFileSourceHost
} from './terminal-worktree-path-link'
import {
getTerminalFileContext,
mapTerminalFilePath,
terminalLinkWslDistro,
type TerminalFileContext
shouldOpenTerminalFileWithSystemDefault,
terminalLinkWslDistro
} from './terminal-file-path-mapping'
import {
LOCAL_EXECUTION_HOST_ID,
@@ -27,13 +28,15 @@ import {
import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-worktree'
import { captureEditorOpenLinkIntent } from '@/store/slices/editor/tabs/editor-open-target-group'
import { reserveDetectedFilePathOpenTurn } from './terminal-file-open-turn'
import { openHtmlFileInBrowser } from './terminal-html-file-browser-open'
import { showOpenInGroupRefusedToast } from './terminal-file-open-in-group-toast'
import { openTerminalHtmlFileInBrowser } from './terminal-file-link-browser-open'
export { isHtmlFilePath } from './local-html-file-destinations'
import { refuseOpenInGroupRequest } from './terminal-file-open-in-group-toast'
import { isExplicitEditorScopeKept, openLinkedFileInEditor } from './terminal-file-link-editor-open'
export {
getTerminalFileContext,
mapTerminalFilePath,
shouldOpenTerminalFileWithSystemDefault,
terminalLinkWslDistro,
terminalPathWslDistro,
type TerminalFileContext
@@ -68,11 +71,8 @@ type TerminalFileOpenDeps = {
sourceTabId?: string | null
/** "Open in this group": pins the source tab's group at activation, or refuses. */
openInSourceGroup?: boolean
}
export function shouldOpenTerminalFileWithSystemDefault(fileContext: TerminalFileContext): boolean {
// Why: an unresolved owner has no connectionId either, which would otherwise read as local.
return fileContext.sourceHostResolved && canClientOsOpenWorkspaceFile(fileContext)
/** Opt-in menu request; the legacy flag remains editor-only. */
openInSourceGroupSurface?: 'internal' | 'browser'
}
export function openDetectedFilePath(
@@ -88,34 +88,38 @@ export function openDetectedFilePath(
worktreePath,
terminalLinkWslDistro(deps.wslDistro, runtimeEnvironmentId)
)
const isCurrentOpen = reserveDetectedFilePathOpenTurn()
const isCurrentRequest = reserveDetectedFilePathOpenTurn()
const explicitGroupRequest =
deps.openInSourceGroup === true || deps.openInSourceGroupSurface !== undefined
// Why: capture at activation; focus may move while the host resolves the path.
const linkIntent = captureEditorOpenLinkIntent(
useAppStore.getState(),
deps.worktreeId,
deps.sourceTabId,
{ explicitSourceGroup: deps.openInSourceGroup === true }
{ explicitSourceGroup: explicitGroupRequest }
)
const explicitGroupRequest = deps.openInSourceGroup === true
// Why: an explicit group request never substitutes a browser, system app, or download.
const refuseExplicitNonEditorRoute = (): boolean => {
if (!explicitGroupRequest) {
return false
}
if (isCurrentOpen()) {
showOpenInGroupRefusedToast(mappedFilePath)
}
return true
const refuseExplicitNonEditorRoute = (): boolean =>
refuseOpenInGroupRequest(mappedFilePath, explicitGroupRequest, isCurrentRequest)
const linkContext = deps.sourceTabId ? { linkIntent, isCurrentRequest } : undefined
if (explicitGroupRequest && !linkIntent) {
refuseExplicitNonEditorRoute()
return
}
void (async () => {
let statResult
let fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId)
const sourceFileHostId =
terminalFileSourceHost(useAppStore.getState(), fileContext) ??
getConnectionExecutionHostId(fileContext.connectionId)
const sourceRuntimeOwnerId = runtimeTargetEnvironmentId(fileContext.target)
let hostWorkspaceFile: HostWorkspaceFile | null = null
let hostGrantId: string | null = null
let clientLocal = false
if (!fileContext.sourceHostResolved) {
if (refuseExplicitNonEditorRoute()) {
return
}
// Why: with no owner the host is unknown — refuse rather than touch this machine's files.
deps.onOpenFailure?.({
verdict: 'unverifiable',
@@ -135,7 +139,7 @@ export function openDetectedFilePath(
}
// Switch root workspace for SSH/runtime paths without local auth/stat; coalesce provider and fallback clicks.
await Promise.resolve()
if (!isCurrentOpen()) {
if (!isCurrentRequest()) {
return
}
activateAndRevealWorktree(worktreeRootLink.id, {
@@ -173,7 +177,7 @@ export function openDetectedFilePath(
statResult = await statUserOpenedPath(fileContext, mappedFilePath)
}
} catch (error) {
if (isCurrentOpen() && deps.onOpenFailure) {
if (isCurrentRequest() && deps.onOpenFailure) {
// Why: loss of contact with the host is not evidence the file is gone.
deps.onOpenFailure({
verdict: isMissingRuntimePathError(error) ? 'missing' : 'unverifiable',
@@ -183,11 +187,15 @@ export function openDetectedFilePath(
return
}
if (!isCurrentOpen()) {
if (!isCurrentRequest()) {
return
}
const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault(fileContext)
if (explicitGroupRequest && openWithSystemDefault) {
refuseExplicitNonEditorRoute()
return
}
// Remote hints promise Orca before resolution; explicit group requests must reach HTML branch to be refused.
const pageDestination =
canOpenWithSystemDefault &&
@@ -231,26 +239,31 @@ export function openDetectedFilePath(
// Route local HTML to Orca browser, including system-browser opens OS could not launch.
if (isHtmlFilePath(mappedFilePath) && hostGrantId === null) {
if (canOpenWithSystemDefault) {
// Why: Orca's browser has no group placement in v1.
if (refuseExplicitNonEditorRoute()) {
return
}
openHtmlFileInBrowser(mappedFilePath, worktreeId, clientLocal)
if (deps.openInSourceGroup) {
refuseExplicitNonEditorRoute()
return
}
// Render remote HTML via doc preview; fall back to source only for unsupported plans such as paired docs outside worktree.
const plan = getWorkspaceFilePreviewPlan(useAppStore.getState(), worktreeId, mappedFilePath)
if (plan.status === 'doc-preview') {
if (refuseExplicitNonEditorRoute()) {
return
}
activateAndRevealWorktree(worktreeId, { providesInitialSurface: true })
openFileInBrowserTab({ filePath: mappedFilePath, worktreeId })
const fileExecutionHostId = hostWorkspaceFile?.executionHostId ?? sourceFileHostId
if (
openTerminalHtmlFileInBrowser({
filePath: mappedFilePath,
worktreeId,
clientLocal,
canOpenWithSystemDefault,
...(linkContext && !clientLocal ? { fileExecutionHostId } : {}),
linkContext,
...(explicitGroupRequest ? { refuseOpenInSourceGroup: refuseExplicitNonEditorRoute } : {})
})
) {
return
}
}
if (deps.openInSourceGroupSurface === 'browser') {
refuseExplicitNonEditorRoute()
return
}
const store = useAppStore.getState()
let targetWorktreeId = worktreeId
const runtimeOwnerId = runtimeTargetEnvironmentId(fileContext.target) ?? sourceRuntimeOwnerId
@@ -318,7 +331,7 @@ export function openDetectedFilePath(
},
linkIntent,
{ line, column },
isCurrentOpen
isCurrentRequest
)
if (!opened) {
refuseExplicitNonEditorRoute()
@@ -1,10 +1,6 @@
import { cancelPendingEditorLineRevealFrames } from '@/store/slices/editor/focus/editor-focus-reveal'
let latestOpenDetectedFilePathRequestId = 0
import { beginLinkOpenRequest } from '@/lib/link-open-request'
/** Supersedes earlier opens now, for a click still deciding what to open; true until a later open. */
export function reserveDetectedFilePathOpenTurn(): () => boolean {
const requestId = ++latestOpenDetectedFilePathRequestId
cancelPendingEditorLineRevealFrames()
return () => requestId === latestOpenDetectedFilePathRequestId
return beginLinkOpenRequest()
}
@@ -1,7 +1,10 @@
import { useAppStore } from '@/store'
import { getConnectionId } from '@/lib/connection-context'
import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route'
import { buildWorkspaceFileContext } from '@/lib/workspace-file-host-routing'
import {
buildWorkspaceFileContext,
canClientOsOpenWorkspaceFile
} from '@/lib/workspace-file-host-routing'
import { getRuntimeTargetForWorktreeOwner } from '@/lib/file-owner-runtime-target'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types'
import { parseExecutionHostId } from '../../../../shared/execution-host'
@@ -13,6 +16,11 @@ export type TerminalFileContext = RuntimeFileOperationArgs & {
sourceHostResolved: boolean
}
export function shouldOpenTerminalFileWithSystemDefault(fileContext: TerminalFileContext): boolean {
// Why: an unresolved owner has no connectionId either, which would otherwise read as local.
return fileContext.sourceHostResolved && canClientOsOpenWorkspaceFile(fileContext)
}
// Why its own module: resolving a link's host path must not load the open flow (worktree activation).
export function getTerminalFileContext(
worktreeId: string,
@@ -216,8 +216,11 @@ describe('terminal link action routing', () => {
expect(openUrl).toHaveBeenCalledWith('https://example.com/path')
request.mock.calls[0][0].alternate.run()
expect(createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/path', {
activate: true
expect(openRuntimeBrowserTab).toHaveBeenCalledWith({
workspaceId: 'wt-1',
url: 'https://example.com/path',
intent: { kind: 'url' },
linkContext: { linkIntent: null, canReveal: expect.any(Function) }
})
})
@@ -236,8 +239,11 @@ describe('terminal link action routing', () => {
expect(request.mock.calls[0][0].alternate.external).toBe(true)
request.mock.calls[0][0].primary.run()
expect(createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/path', {
activate: true
expect(openRuntimeBrowserTab).toHaveBeenCalledWith({
workspaceId: 'wt-1',
url: 'https://example.com/path',
intent: { kind: 'url' },
linkContext: { linkIntent: null, canReveal: expect.any(Function) }
})
request.mock.calls[0][0].alternate.run()
@@ -276,6 +282,7 @@ describe('terminal link action routing', () => {
workspaceId: 'wt-1',
url: 'https://example.com/path',
intent: { kind: 'url' },
linkContext: { linkIntent: null, canReveal: expect.any(Function) },
expectedRuntimeEnvironmentId: 'env-1'
})
expect(createBrowserTab).not.toHaveBeenCalled()
@@ -297,6 +304,7 @@ describe('terminal link action routing', () => {
workspaceId: 'wt-1',
url,
intent: { kind: 'url' },
linkContext: { linkIntent: null, canReveal: expect.any(Function) },
expectedSshConnectionId: 'ssh-1'
})
expect(createBrowserTab).not.toHaveBeenCalled()
@@ -311,8 +319,11 @@ describe('terminal link action routing', () => {
actionDestinations: { primary: 'system', alternate: 'orca' }
})
expect(createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/path', {
activate: true
expect(openRuntimeBrowserTab).toHaveBeenCalledWith({
workspaceId: 'wt-1',
url: 'https://example.com/path',
intent: { kind: 'url' },
linkContext: { linkIntent: null, canReveal: expect.any(Function) }
})
expect(openUrl).not.toHaveBeenCalled()
})
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { activateAndRevealWorkspace, activateAndRevealWorktree } from '@/lib/worktree-activation'
import { activateAndRevealWorkspace } from '@/lib/worktree-activation'
import { openDetectedFilePath } from './terminal-link-handlers'
import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures'
import {
@@ -67,7 +67,7 @@ describe('handleOscLink', () => {
)
expect(openFilePathMock).not.toHaveBeenCalled()
// The browser is the surface; activation must not seed a shell into an emptied workspace.
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {
expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', {
providesInitialSurface: true
})
})
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { openDetectedFilePath } from './terminal-link-handlers'
import * as filePreview from '@/lib/file-preview'
import { activateAndRevealWorkspace } from '@/lib/worktree-activation'
import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open'
import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures'
@@ -83,6 +84,53 @@ describe('a paired-server link outside its own workspace', () => {
)
})
it('retains the host-resolved HTML owner when opening in another workspace', async () => {
storeState.unifiedTabsByWorktree = {
'wt-feat': [
{
id: 'source',
entityId: 'term-1',
groupId: 'source-group',
contentType: 'terminal',
executionHostId: 'runtime:env-1'
}
]
}
hostAnswers({
worktree: 'wt-main',
relativePath: 'report.html',
absolutePath: '/repo/report.html',
exists: true,
isDirectory: false
})
const plan = { status: 'doc-preview' } as const
const previewPlan = vi
.spyOn(filePreview, 'getWorkspaceFilePreviewPlan')
.mockReturnValueOnce(plan)
const openPreview = vi.spyOn(filePreview, 'openFileInBrowserTab').mockReturnValueOnce(plan)
try {
openDetectedFilePath('/repo/report.html', null, null, {
...featureWorktree,
sourceTabId: 'source'
})
await flushDoubleRaf()
expect(openPreview).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/repo/report.html',
worktreeId: 'wt-main',
fileExecutionHostId: 'runtime:env-1',
linkContext: expect.objectContaining({
linkIntent: expect.objectContaining({ workspaceId: 'wt-feat', hostId: 'runtime:env-1' })
})
})
)
} finally {
previewPlan.mockRestore()
openPreview.mockRestore()
}
})
it("opens the host's spelling of the path so the tab stays a workspace file", async () => {
hostAnswers({
worktree: 'wt-main',
@@ -1,3 +1,5 @@
import { openRoutedHttpLink } from '@/lib/http-link-destinations'
import * as browserLinkOpen from './terminal-file-link-browser-open'
import { describe, expect, it, vi } from 'vitest'
import { activateAndRevealWorkspace } from '@/lib/worktree-activation'
import { openDetectedFilePath } from './terminal-link-handlers'
@@ -122,6 +124,26 @@ describe('terminal file links carry their source tab', () => {
expect(toastErrorMock).toHaveBeenCalledTimes(1)
})
it('reports a refused HTML placement for the internal Open in this group action', async () => {
seedSourceTab()
const stat = createDeferred<{ isDirectory: boolean }>()
statMock.mockImplementationOnce(() => stat.promise)
openDetectedFilePath('/tmp/report.html', null, null, {
...sourceDeps,
openInSourceGroupSurface: 'internal'
})
storeState.groupsByWorktree = {}
stat.resolve({ isDirectory: false })
await flushAsyncWork()
expect(createBrowserTabMock).not.toHaveBeenCalled()
expect(openFileForLinkMock).not.toHaveBeenCalled()
expect(toastErrorMock).toHaveBeenCalledExactlyOnceWith(
"Couldn't open /tmp/report.html in this group"
)
})
it('refuses a closed explicit group before activating its workspace', async () => {
setPlatform('Macintosh')
seedSourceTab()
@@ -187,10 +209,23 @@ describe('terminal file links carry their source tab', () => {
storeState.settings = { openLinksInApp: true }
seedSourceTab()
const browserOpen = vi
.spyOn(browserLinkOpen, 'openTerminalHtmlFileInBrowser')
.mockReturnValueOnce(true)
openDetectedFilePath('/tmp/report.html', null, null, sourceDeps)
await flushAsyncWork()
expect(createBrowserTabMock).toHaveBeenCalledTimes(1)
expect(browserOpen).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/tmp/report.html',
worktreeId: 'wt-1',
fileExecutionHostId: 'local',
linkContext: expect.objectContaining({
linkIntent: expect.objectContaining({ sourceTabId: 'unified-term' })
})
})
)
browserOpen.mockRestore()
expect(toastErrorMock).not.toHaveBeenCalled()
})
})
@@ -213,6 +248,16 @@ describe('terminal file link reveals target the opened view', () => {
expect(reveal.canApply?.()).toBe(true)
})
it('invalidates an editor line reveal when a browser link is activated', async () => {
setPlatform('Macintosh')
openDetectedFilePath('/tmp/src/a.ts', 12, 3, deps)
await flushAsyncWork()
await flushDoubleRaf()
const reveal = lastReveal()
openRoutedHttpLink('https://example.com/', { worktreeId: 'wt-1', forceDestination: 'orca' })
expect(reveal.canApply?.()).toBe(false)
})
it('invalidates a published reveal when a newer click has no line', async () => {
setPlatform('Macintosh')
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { openDetectedFilePath } from './terminal-link-handlers'
import { getConnectionId } from '@/lib/connection-context'
import { getWorkspaceFilePreviewPlan, openFileInBrowserTab } from '@/lib/file-preview'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { activateAndRevealWorkspace } from '@/lib/worktree-activation'
import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open'
import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures'
import {
@@ -253,7 +253,7 @@ describe('handleOscLink', () => {
worktreeId: 'wt-1'
})
// The preview is the surface; activation must not seed a shell into an emptied workspace.
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {
expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', {
providesInitialSurface: true
})
})
@@ -109,6 +109,7 @@ export function handleOscLink(
return finish(
handleTerminalHttpLink(parsed.toString(), event as MouseEvent, {
worktreeId: deps.worktreeId,
sourceTabId: deps.sourceTabId,
sourceOwner:
deps.sourceOwner ??
(deps.runtimeEnvironmentId
@@ -127,6 +127,7 @@ describe('terminal HTTP links on a runtime-hosted pane', () => {
workspaceId: 'wt-1',
url: URL,
intent: { kind: 'url' },
linkContext: { linkIntent: null, canReveal: expect.any(Function) },
expectedRuntimeEnvironmentId: 'env-1'
})
expect(openUrlMock).not.toHaveBeenCalled()
@@ -198,6 +199,7 @@ describe('terminal HTTP links on a direct SSH pane', () => {
workspaceId: 'wt-1',
url: URL,
intent: { kind: 'url' },
linkContext: { linkIntent: null, canReveal: expect.any(Function) },
expectedSshConnectionId: 'ssh-1'
})
expect(openUrlMock).not.toHaveBeenCalled()
@@ -249,7 +251,12 @@ describe('terminal HTTP links on a local pane', () => {
it('still opens an OSC 8 hyperlink in an Orca browser tab', () => {
expect(handleOscLink(URL, clickEvent(), { ...baseDeps, runtimeEnvironmentId: null })).toBe(true)
expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', URL, { activate: true })
expect(openRuntimeBrowserTabMock).toHaveBeenCalledWith({
workspaceId: 'wt-1',
url: URL,
intent: { kind: 'url' },
linkContext: { linkIntent: null, canReveal: expect.any(Function) }
})
expect(openUrlMock).not.toHaveBeenCalled()
})
@@ -261,7 +268,12 @@ describe('terminal HTTP links on a local pane', () => {
([name, _listener, options]) => name === 'mouseup' && options === undefined
)?.[1](clickEvent())
expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', URL, { activate: true })
expect(openRuntimeBrowserTabMock).toHaveBeenCalledWith({
workspaceId: 'wt-1',
url: URL,
intent: { kind: 'url' },
linkContext: { linkIntent: null, canReveal: expect.any(Function) }
})
expect(openUrlMock).not.toHaveBeenCalled()
disposable.dispose()
})
@@ -31,6 +31,7 @@ export { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits'
type UrlLinkHitTestDeps = {
worktreeId: string
sourceTabId?: string | null
sourceOwner?: HttpLinkSourceOwner
modifierHeld?: boolean
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
@@ -42,6 +43,7 @@ type UrlLinkHitTestDeps = {
type UrlLinkClickFallbackDeps = {
worktreeId: string
sourceTabId?: string | null
/** Resolved per click: the pane's PTY (and its runtime binding) may not exist at install time. */
getSourceOwner?: () => HttpLinkSourceOwner
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
@@ -87,8 +89,16 @@ export function handleTerminalHttpLink(
return requestTerminalLinkAction(event, deps.linkActionContext, {
destination: deps.actionDestination ?? url,
kind: 'url',
...buildHttpLinkActions(deps.actionDestinations, (destination) =>
openRoutedHttpLink(url, { ...deps, modifierHeld: false, forceDestination: destination })
...buildHttpLinkActions(
deps.actionDestinations,
(destination) =>
openRoutedHttpLink(url, { ...deps, modifierHeld: false, forceDestination: destination }),
{
...deps,
url,
openInSourceGroup: () =>
openRoutedHttpLink(url, { ...deps, forceDestination: 'orca', openInSourceGroup: true })
}
)
})
}
@@ -154,6 +164,7 @@ export function installHttpLinkClickFallback(
url &&
handleTerminalHttpLink(url, event, {
worktreeId: deps.worktreeId,
sourceTabId: deps.sourceTabId,
sourceOwner: deps.getSourceOwner?.() ?? { kind: 'local' },
requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference,
linkActionContext: deps.getLinkActionContext?.(),
@@ -13,7 +13,12 @@ import type { TerminalLinkActionContext } from './terminal-link-action-request'
type TerminalWebLinkClickDeps = Pick<
LinkHandlerDeps,
'worktreeId' | 'worktreePath' | 'startupCwd' | 'runtimeEnvironmentId' | 'terminalHomePath'
| 'worktreeId'
| 'worktreePath'
| 'startupCwd'
| 'runtimeEnvironmentId'
| 'terminalHomePath'
| 'sourceTabId'
> & {
terminal: Terminal | null
sourceOwner?: HttpLinkSourceOwner
@@ -36,6 +41,7 @@ export function handleTerminalWebLinkClick(
if (completeUrl) {
handled = handleTerminalHttpLink(completeUrl, event, {
worktreeId: deps.worktreeId,
sourceTabId: deps.sourceTabId,
sourceOwner:
deps.sourceOwner ??
(deps.runtimeEnvironmentId
@@ -3,7 +3,7 @@ import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/tab-types'
export function getEffectiveLayoutForWorktree(
worktreeId: string,
layoutByWorktree: Record<string, TabGroupLayoutNode | undefined>,
groupsByWorktree: Record<string, TabGroup[]>,
groupsByWorktree: Record<string, readonly Pick<TabGroup, 'id'>[]>,
activeGroupIdByWorktree: Record<string, string | undefined>
): TabGroupLayoutNode | undefined {
const layout = layoutByWorktree[worktreeId]
@@ -1,5 +1,6 @@
import { rememberLiveBrowserUrl } from '@/components/browser-pane/describe-page/live-browser-url-registry'
import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/worktree-runtime-owner'
import { findVisibleBrowserLinkTarget } from '@/lib/visible-browser-link-target'
import { openUrlInTabBesideBrowserPage } from '@/lib/browser-page-tab-opening'
import { redactKagiSessionToken } from '../../../../shared/browser-url'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
@@ -155,14 +156,19 @@ export function registerBrowserStateIpcBridge(
if (!sourcePage || runtimeEnvironmentIdOrLocalUnlessTwin(store, sourcePage.worktreeId)) {
return
}
const placement = findVisibleBrowserLinkTarget(store, sourcePage.worktreeId)
const openedPageId = openUrlInTabBesideBrowserPage({
sourcePageId: browserPageId,
worktreeId: sourcePage.worktreeId,
url,
activate: activate ?? true
activate: activate ?? true,
placement
})
if (openedPageId) {
markBrowserPageOpenedByPage(openedPageId)
if (placement && activate !== false) {
store.focusGroup(sourcePage.worktreeId, placement.targetGroupId)
}
}
})
)
@@ -165,4 +165,61 @@ describe('link-opened Orca tabs', () => {
expect.objectContaining({ detail: { pageId: 'new-page', target: 'webview' } })
)
})
it.each([true, false])(
'routes a popup to a visible browser with the opener profile (activate: %s)',
(activate) => {
const focusGroup = vi.fn()
storeState.value = {
browserPagesByWorkspace: {
opener: [{ id: 'page-1', workspaceId: 'opener', worktreeId: 'worktree-1' }]
},
browserTabsByWorktree: {
'worktree-1': [
{ id: 'opener', sessionProfileId: 'profile-a', sessionPartition: 'persist:a' }
]
},
layoutByWorktree: {
'worktree-1': {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'source' },
second: { type: 'leaf', groupId: 'visible' }
}
},
groupsByWorktree: {
'worktree-1': [
{ id: 'source', activeTabId: 'terminal' },
{ id: 'visible', activeTabId: 'browser' }
]
},
unifiedTabsByWorktree: {
'worktree-1': [{ id: 'browser', groupId: 'visible', contentType: 'browser' }]
},
activeGroupIdByWorktree: { 'worktree-1': 'source' },
createBrowserTab: createBrowserTabMock,
focusGroup
}
captureOpenLinkHandler()({
browserPageId: 'page-1',
url: 'https://docs.example.com/new',
activate
})
expect(createBrowserTabMock).toHaveBeenCalledWith(
'worktree-1',
'https://docs.example.com/new',
expect.objectContaining({
activate,
targetGroupId: 'visible',
sessionProfileId: 'profile-a',
sessionPartition: 'persist:a'
})
)
if (activate) {
expect(focusGroup).toHaveBeenCalledExactlyOnceWith('worktree-1', 'visible')
} else {
expect(focusGroup).not.toHaveBeenCalled()
expect(dispatchEventMock).not.toHaveBeenCalled()
}
}
)
})
@@ -0,0 +1,127 @@
import type { TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types'
type LayoutRect = { left: number; top: number; right: number; bottom: number }
type LayoutLeaf = { groupId: string; rect: LayoutRect; layoutOrder: number }
type NeighborSide = 'right' | 'left' | 'below' | 'above'
type Neighbor = { side: NeighborSide; position: number }
type Candidate = {
id: string
neighbor: Neighbor | null
hasMatchingDestination: boolean
showsDestinationSurface: boolean
layoutOrder: number
}
type Rank = readonly [number, number, number, number]
const EPSILON = 1e-9
const SIDE_ORDER = { right: 0, left: 1, below: 2, above: 3 } as const
function clampRatio(ratio: number | undefined): number {
return typeof ratio === 'number' && Number.isFinite(ratio) && ratio > 0 && ratio < 1 ? ratio : 0.5
}
/** Leaves in stable tree order, each projected into the unit square by its split ratios. */
function projectLayoutLeaves(layout: TabGroupLayoutNode): LayoutLeaf[] {
const leaves: LayoutLeaf[] = []
const visit = (node: TabGroupLayoutNode, rect: LayoutRect): void => {
if (node.type === 'leaf') {
if (rect.right - rect.left > EPSILON && rect.bottom - rect.top > EPSILON) {
leaves.push({ groupId: node.groupId, rect, layoutOrder: leaves.length })
}
return
}
const ratio = clampRatio(node.ratio)
if (node.direction === 'horizontal') {
const split = rect.left + (rect.right - rect.left) * ratio
visit(node.first, { ...rect, right: split })
visit(node.second, { ...rect, left: split })
} else {
const split = rect.top + (rect.bottom - rect.top) * ratio
visit(node.first, { ...rect, bottom: split })
visit(node.second, { ...rect, top: split })
}
}
visit(layout, { left: 0, top: 0, right: 1, bottom: 1 })
return leaves
}
function overlaps(a0: number, a1: number, b0: number, b1: number): boolean {
return Math.min(a1, b1) - Math.max(a0, b0) > EPSILON
}
function touches(a: number, b: number): boolean {
return Math.abs(a - b) <= EPSILON
}
/** Shared edge with positive overlap; corner-only contact is not adjacency. */
export function findNeighbor(source: LayoutRect, target: LayoutRect): Neighbor | null {
const verticalOverlap = overlaps(source.top, source.bottom, target.top, target.bottom)
const horizontalOverlap = overlaps(source.left, source.right, target.left, target.right)
if (verticalOverlap && touches(source.right, target.left)) {
return { side: 'right', position: target.top }
}
if (verticalOverlap && touches(source.left, target.right)) {
return { side: 'left', position: target.top }
}
if (horizontalOverlap && touches(source.bottom, target.top)) {
return { side: 'below', position: target.left }
}
if (horizontalOverlap && touches(source.top, target.bottom)) {
return { side: 'above', position: target.left }
}
return null
}
function rankCandidate(candidate: Candidate, sourceGroupId: string): Rank | null {
const { neighbor, hasMatchingDestination, showsDestinationSurface, layoutOrder } = candidate
if (candidate.id === sourceGroupId) {
return [4, 0, 0, layoutOrder]
}
if (!neighbor) {
return hasMatchingDestination ? [1, 0, 0, layoutOrder] : null
}
const priority = hasMatchingDestination ? 0 : showsDestinationSurface ? 2 : 3
return [priority, SIDE_ORDER[neighbor.side], neighbor.position, layoutOrder]
}
function compareRanks(a: Rank, b: Rank): number {
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2] || a[3] - b[3]
}
export function pickAdjacentLinkOpenGroupId(input: {
layout: TabGroupLayoutNode
groups: readonly TabGroup[]
sourceGroupId: string
facts: ReadonlyMap<string, { hasMatchingDestination: boolean; showsDestinationSurface: boolean }>
}): string | null {
const leaves = projectLayoutLeaves(input.layout)
const liveGroups = new Set(input.groups.map((group) => group.id))
const source = leaves.find((leaf) => leaf.groupId === input.sourceGroupId)
if (!source || !liveGroups.has(source.groupId)) {
return null
}
let bestId: string | null = null
let bestRank: Rank | null = null
for (const leaf of leaves) {
if (!liveGroups.has(leaf.groupId)) {
continue
}
const facts = input.facts.get(leaf.groupId)
const rank = rankCandidate(
{
id: leaf.groupId,
neighbor: leaf.groupId === source.groupId ? null : findNeighbor(source.rect, leaf.rect),
hasMatchingDestination: facts?.hasMatchingDestination ?? false,
showsDestinationSurface: facts?.showsDestinationSurface ?? false,
layoutOrder: leaf.layoutOrder
},
source.groupId
)
if (rank && (!bestRank || compareRanks(rank, bestRank) < 0)) {
bestRank = rank
bestId = leaf.groupId
}
}
return bestId
}
@@ -0,0 +1,418 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { buildBrowserPage, buildWorkspaceFromPage } from '@/store/slices/browser-page-records'
import {
browserLinkPageMatches,
browserLinkUrlIdentity,
browserOpenResultForPage,
selectBrowserLinkDestination
} from './browser-link-destination'
import {
browserLinkTestDestination,
createBrowserLinkTestStore,
WORKSPACE,
URL_TO_OPEN,
type BrowserLinkTestStore
} from './browser-link-open.test-fixture'
import { captureLinkOpenIntent } from './link-open-intent'
import { makeUnifiedTab, makeWorktree } from '@/store/slices/store-test-helpers'
import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures'
import { canOfferHttpLinkGroupAction } from './http-link-in-group-availability'
let store: BrowserLinkTestStore
beforeEach(() => {
store = createBrowserLinkTestStore()
})
it('normalizes only explicit HTTP URLs and keeps path, query and fragment identity', () => {
expect(browserLinkUrlIdentity('https://EXAMPLE.com:443')).toBe('https://example.com/')
for (const input of [
'example.com',
'search words',
'file:///tmp/a.html',
'javascript:alert(1)',
'https://user:password@example.com/',
'https://kagi.com/search?token=secret&q=a'
]) {
expect(browserLinkUrlIdentity(input)).toBeNull()
}
const identities = ['/a', '/a/', '/A', '/a?a=1&b=2', '/a?b=2&a=1', '/a#one', '/a#two'].map(
(path) => browserLinkUrlIdentity(`https://example.com${path}`)
)
expect(new Set(identities).size).toBe(identities.length)
expect(browserLinkUrlIdentity('http://localhost:3000')).not.toBe(
browserLinkUrlIdentity('http://127.0.0.1:3000')
)
})
describe('browser destination scope', () => {
it.each([
['worktree', 'local', undefined, 'local'],
['worktree', 'ssh:target', undefined, 'ssh:target'],
['worktree', 'runtime:server', undefined, 'runtime:server'],
['worktree', 'ssh:target', 'server', 'ssh:target'],
['worktree', 'ssh:target', 'server', 'runtime:server'],
['folder', 'local', undefined, 'local'],
['folder', 'runtime:server', undefined, 'runtime:server']
] as const)(
'places a hostless %s source owned by %s (%s) on %s',
(kind, hostId, runtimeOwner, destinationHostId) => {
const workspaceId = kind === 'folder' ? 'folder:legacy' : WORKSPACE
store.setState((state) => ({
activeWorktreeId: workspaceId,
activeWorkspaceExecutionHostId: hostId,
worktreesByRepo: {
repo1: [
makeWorktree({
id: WORKSPACE,
repoId: 'repo1',
hostId,
runtimeOwnerEnvironmentId: runtimeOwner
})
]
},
folderWorkspaces: [makeFolderWorkspace({ id: 'legacy', executionHostId: hostId })],
unifiedTabsByWorktree: {
[workspaceId]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) => ({
...tab,
worktreeId: workspaceId,
executionHostId: undefined
}))
},
groupsByWorktree: { [workspaceId]: state.groupsByWorktree[WORKSPACE]! },
layoutByWorktree: { [workspaceId]: state.layoutByWorktree[WORKSPACE]! },
activeGroupIdByWorktree: { [workspaceId]: 'c' }
}))
const state = store.getState()
expect(
selectBrowserLinkDestination(
state,
browserLinkTestDestination({
worktreeId: workspaceId,
executionHostId: destinationHostId
}),
captureLinkOpenIntent(state, workspaceId, 'source')
)
).toMatchObject({
status: 'ready',
split: { sourceGroupId: 'a', splitDirection: 'right' },
match: null
})
}
)
it('places hostless sources beside their group and reuses hostless browser wrappers', () => {
const existing = store.getState().createBrowserTab(WORKSPACE, URL_TO_OPEN, {
targetGroupId: 'b',
executionHostId: 'local',
browserRuntimeEnvironmentId: null,
sessionProfileId: null,
activate: false
})
store.setState((state) => ({
unifiedTabsByWorktree: {
[WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) => ({
...tab,
executionHostId: undefined
}))
}
}))
const state = store.getState()
const intent = captureLinkOpenIntent(state, WORKSPACE, 'source')
const destination = browserLinkTestDestination()
expect(selectBrowserLinkDestination(state, destination, intent)).toMatchObject({
status: 'ready',
groupId: 'b',
match: { browserWorkspaceId: existing.id, browserPageId: existing.activePageId }
})
expect(
browserOpenResultForPage(state, destination, existing.id, existing.activePageId!)
).toMatchObject({ groupId: 'b', browserWorkspaceId: existing.id })
store.setState({ browserTabsByWorktree: {} })
expect(selectBrowserLinkDestination(store.getState(), destination, intent)).toMatchObject({
status: 'ready',
split: { sourceGroupId: 'a', splitDirection: 'right' },
match: null
})
})
it.each(['listed', 'detected'] as const)(
'refuses hostless ownership for %s twins even when the active workspace names one host',
(catalog) => {
const existing = store.getState().createBrowserTab(WORKSPACE, URL_TO_OPEN, {
targetGroupId: 'b',
executionHostId: 'local',
browserRuntimeEnvironmentId: null,
sessionProfileId: null,
activate: false
})
store.setState((state) => ({
...(catalog === 'listed'
? {
worktreesByRepo: {
repo1: [
...state.worktreesByRepo.repo1!,
makeWorktree({ id: WORKSPACE, repoId: 'repo1', hostId: 'runtime:other' })
]
}
}
: {
detectedWorktreesByRepo: {
repo1: {
repoId: 'repo1',
authoritative: true,
source: 'git' as const,
worktrees: [
{
...makeWorktree({ id: WORKSPACE, repoId: 'repo1', hostId: 'runtime:other' }),
ownership: 'external' as const,
selectedCheckout: false,
visible: true
}
]
}
}
}),
unifiedTabsByWorktree: {
[WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) => ({
...tab,
executionHostId: undefined
}))
}
}))
const state = store.getState()
const destination = browserLinkTestDestination()
expect(
selectBrowserLinkDestination(
state,
destination,
captureLinkOpenIntent(state, WORKSPACE, 'source')
)
).toMatchObject({ status: 'ready', split: { sourceGroupId: 'c' }, match: null })
expect(
browserOpenResultForPage(state, destination, existing.id, existing.activePageId!)
).toBeNull()
expect(
canOfferHttpLinkGroupAction(state, {
workspaceId: WORKSPACE,
sourceTabId: 'source',
sourceOwner: { kind: 'local' },
url: URL_TO_OPEN
})
).toBe(false)
expect(
selectBrowserLinkDestination(
state,
browserLinkTestDestination(),
captureLinkOpenIntent(state, WORKSPACE, 'source', { explicitSourceGroup: true })
)
).toEqual({ status: 'refused' })
}
)
it('refuses hostless folder twins despite an active host selection', () => {
const workspaceId = 'folder:legacy'
store.setState((state) => ({
activeWorktreeId: workspaceId,
folderWorkspaces: [
makeFolderWorkspace({ id: 'legacy', executionHostId: 'local' }),
makeFolderWorkspace({ id: 'legacy', executionHostId: 'runtime:other' })
],
unifiedTabsByWorktree: {
[workspaceId]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) => ({
...tab,
worktreeId: workspaceId,
executionHostId: undefined
}))
},
groupsByWorktree: { [workspaceId]: state.groupsByWorktree[WORKSPACE]! },
layoutByWorktree: { [workspaceId]: state.layoutByWorktree[WORKSPACE]! }
}))
const state = store.getState()
expect(
selectBrowserLinkDestination(
state,
browserLinkTestDestination({ worktreeId: workspaceId }),
captureLinkOpenIntent(state, workspaceId, 'source', { explicitSourceGroup: true })
)
).toEqual({ status: 'refused' })
})
it('compares effective partition and profile without changing the existing profile', () => {
const page = buildBrowserPage('browser', WORKSPACE, URL_TO_OPEN, undefined, null)
const workspace = buildWorkspaceFromPage('browser', WORKSPACE, page, [page.id], null)
const state = store.getState()
const destination = browserLinkTestDestination()
expect(browserLinkPageMatches(state, destination, workspace, page)).toBe(true)
expect(
browserLinkPageMatches(
state,
destination,
{ ...workspace, sessionProfileId: undefined },
page
)
).toBe(false)
expect(
browserLinkPageMatches(state, destination, { ...workspace, sessionProfileId: 'other' }, page)
).toBe(false)
expect(
browserLinkPageMatches(
state,
destination,
{ ...workspace, sessionPartition: 'persist:other-login' },
page
)
).toBe(false)
expect(workspace.sessionProfileId).toBeNull()
})
it('does not equate credential requests through the redacted stored URL', () => {
const raw = 'https://kagi.com/search?q=test&token=secret'
const page = buildBrowserPage('browser', WORKSPACE, raw, undefined, null)
const workspace = buildWorkspaceFromPage('browser', WORKSPACE, page, [page.id], null)
expect(page.url).not.toContain('secret')
expect(
browserLinkPageMatches(
store.getState(),
browserLinkTestDestination({ resource: { kind: 'website', url: raw } }),
workspace,
page
)
).toBe(false)
})
it('keeps local, SSH and runtime owners distinct even when URL or file text agrees', () => {
const existing = store.getState().createBrowserTab(WORKSPACE, URL_TO_OPEN, {
targetGroupId: 'b',
executionHostId: 'ssh:another',
browserRuntimeEnvironmentId: null,
sessionProfileId: null,
activate: false
})
const state = store.getState()
const intent = captureLinkOpenIntent(state, WORKSPACE, 'source')
expect(selectBrowserLinkDestination(state, browserLinkTestDestination(), intent)).toMatchObject(
{ match: null }
)
const page = state.browserPagesByWorkspace[existing.id]![0]!
expect(
browserLinkPageMatches(state, browserLinkTestDestination(), existing, {
...page,
browserRuntimeEnvironmentId: 'other-runtime'
})
).toBe(false)
})
it('does not let detached records or a browser wrapper outside the source layout attract placement', () => {
const existing = store.getState().createBrowserTab(WORKSPACE, URL_TO_OPEN, {
targetGroupId: 'c',
executionHostId: 'local',
browserRuntimeEnvironmentId: null,
sessionProfileId: null,
activate: false
})
const tabs = store.getState().unifiedTabsByWorktree[WORKSPACE]!
store.setState({
unifiedTabsByWorktree: { [WORKSPACE]: tabs.filter((tab) => tab.entityId !== existing.id) }
})
const intent = captureLinkOpenIntent(store.getState(), WORKSPACE, 'source')
expect(
selectBrowserLinkDestination(store.getState(), browserLinkTestDestination(), intent)
).toMatchObject({ split: { sourceGroupId: 'a' }, match: null })
store.setState({
unifiedTabsByWorktree: {
[WORKSPACE]: [
...store.getState().unifiedTabsByWorktree[WORKSPACE]!,
makeUnifiedTab({
id: 'detached',
entityId: existing.id,
worktreeId: WORKSPACE,
groupId: 'missing',
contentType: 'browser',
executionHostId: 'local'
})
]
}
})
expect(
selectBrowserLinkDestination(store.getState(), browserLinkTestDestination(), intent)
).toMatchObject({ split: { sourceGroupId: 'a' }, match: null })
})
it('uses a visible browser and splits beside the source when that browser becomes hidden', () => {
const existing = store.getState().createBrowserTab(WORKSPACE, 'https://unrelated.example/', {
targetGroupId: 'a',
executionHostId: 'local',
browserRuntimeEnvironmentId: null,
activate: true
})
store.setState((state) => ({
unifiedTabsByWorktree: {
[WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) =>
tab.id === 'source' ? { ...tab, groupId: 'b' } : tab
)
}
}))
const intent = captureLinkOpenIntent(store.getState(), WORKSPACE, 'source')
expect(
selectBrowserLinkDestination(store.getState(), browserLinkTestDestination(), intent)
).toMatchObject({ groupId: 'a', match: null })
store.setState((state) => ({
groupsByWorktree: {
[WORKSPACE]: state.groupsByWorktree[WORKSPACE]!.map((group) =>
group.id === 'a' ? { ...group, activeTabId: 'source' } : group
)
}
}))
expect(
selectBrowserLinkDestination(store.getState(), browserLinkTestDestination(), intent)
).toMatchObject({ split: { sourceGroupId: 'b' }, match: null })
expect(
store
.getState()
.browserTabsByWorktree[WORKSPACE]?.some((workspace) => workspace.id === existing.id)
).toBe(true)
})
it('requires doc ownership and path identity, excluding current website URLs on mixed containers', () => {
const docLocation = {
kind: 'workspace-doc' as const,
worktreeId: WORKSPACE,
filePath: '/repo/doc.html'
}
const page = buildBrowserPage(
'browser',
WORKSPACE,
URL_TO_OPEN,
undefined,
null,
undefined,
docLocation
)
const workspace = buildWorkspaceFromPage('browser', WORKSPACE, page, [page.id], null)
expect(
browserLinkPageMatches(store.getState(), browserLinkTestDestination(), workspace, page)
).toBe(false)
expect(
browserLinkPageMatches(
store.getState(),
browserLinkTestDestination({ resource: { kind: 'document', docLocation } }),
workspace,
page
)
).toBe(true)
expect(
browserLinkPageMatches(
store.getState(),
browserLinkTestDestination({
resource: {
kind: 'document',
docLocation: { ...docLocation, filePath: '/repo/DOC.html' }
}
}),
workspace,
page
)
).toBe(false)
})
})
@@ -0,0 +1,308 @@
import type { Tab } from '../../../shared/tab-types'
import { createBrowserLinkHostMatcher } from './browser-link-owner'
import { createBrowserLinkSessionMatcher } from './browser-link-session'
import type { AppState } from '@/store/types'
import type {
BrowserPage,
BrowserPageDocLocation,
BrowserWorkspace
} from '../../../shared/browser-workspace-types'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { normalizeBrowserNavigationUrl, redactKagiSessionToken } from '../../../shared/browser-url'
import { browserPageDocLocationsEqual } from '../../../shared/browser-page-doc-location'
import { findVisibleBrowserLinkTarget } from './visible-browser-link-target'
import type { TabSplitTarget } from '@/store/slices/tabs/tabs-slice-contract'
import { isLiveLinkOpenGroup, liveLinkOpenGroupIds, type LinkOpenIntent } from './link-open-intent'
import { runtimeEnvironmentIdOrLocalUnlessTwin } from './worktree-runtime-owner'
export type BrowserLinkDestination = {
worktreeId: string
executionHostId: ExecutionHostId
browserRuntimeEnvironmentId: string | null
profileHostId: ExecutionHostId
sessionProfileId: string | null
sessionPartition: string | null
resource:
| { kind: 'website'; url: string }
| { kind: 'local-file'; url: string }
| { kind: 'document'; docLocation: BrowserPageDocLocation }
}
export type BrowserOpenResult = {
worktreeId: string
executionHostId: ExecutionHostId
browserWorkspaceId: string
browserPageId: string
unifiedTabId: string
groupId: string
}
export type BrowserOpenOutcome =
| { status: 'opened'; result: BrowserOpenResult }
| { status: 'refused' | 'unsupported' | 'superseded' | 'failed' }
export type BrowserLinkOpenContext = {
linkIntent: LinkOpenIntent | null
isCurrentRequest?: () => boolean
canReveal?: () => boolean
}
/** Credential-bearing requests cannot use their redacted stored URL as an identity. */
export function browserLinkUrlIdentity(url: string): string | null {
if (!/^https?:\/\//i.test(url.trim())) {
return null
}
const normalized = normalizeBrowserNavigationUrl(url)
if (!normalized || redactKagiSessionToken(normalized) !== normalized) {
return null
}
const parsed = new URL(normalized)
return parsed.username || parsed.password ? null : normalized
}
export function createBrowserLinkPageMatcher(
state: AppState,
destination: BrowserLinkDestination
): (workspace: BrowserWorkspace, page: BrowserPage) => boolean {
const matchesSession = createBrowserLinkSessionMatcher(
state,
destination.profileHostId,
destination.sessionProfileId,
destination.sessionPartition
)
const inferredRuntimeId =
runtimeEnvironmentIdOrLocalUnlessTwin(state, destination.worktreeId) ?? null
const resource = destination.resource
const urlIdentity = resource.kind === 'website' ? browserLinkUrlIdentity(resource.url) : null
return (workspace, page) => {
if (page.workspaceId !== workspace.id || page.worktreeId !== destination.worktreeId) {
return false
}
const handle = state.remoteBrowserPageHandlesByPageId[page.id]
if (handle?.staged || handle?.restoredFromSession) {
return false
}
const runtimeId =
handle?.environmentId ??
(page.browserRuntimeEnvironmentId === undefined
? inferredRuntimeId
: page.browserRuntimeEnvironmentId)
if (runtimeId !== destination.browserRuntimeEnvironmentId) {
return false
}
// Mirrored workspace metadata cannot prove which login the host's page actually uses.
if (
runtimeId
? handle?.browserProfileId !== (destination.sessionProfileId ?? 'default')
: !matchesSession(workspace)
) {
return false
}
if (resource.kind === 'document') {
return browserPageDocLocationsEqual(page.docLocation ?? null, resource.docLocation)
}
if (page.docLocation) {
return false
}
return resource.kind === 'local-file'
? page.url === resource.url
: urlIdentity !== null && browserLinkUrlIdentity(page.url) === urlIdentity
}
}
export function browserLinkPageMatches(
state: AppState,
destination: BrowserLinkDestination,
workspace: BrowserWorkspace,
page: BrowserPage
): boolean {
return createBrowserLinkPageMatcher(state, destination)(workspace, page)
}
export function browserOpenResultForPage(
state: AppState,
destination: Pick<BrowserLinkDestination, 'worktreeId' | 'executionHostId'>,
workspaceId: string,
pageId: string
): BrowserOpenResult | null {
const matchesHost = createBrowserLinkHostMatcher(
state,
destination.worktreeId,
destination.executionHostId
)
const wrapper = (state.unifiedTabsByWorktree[destination.worktreeId] ?? []).find(
(tab) =>
tab.contentType === 'browser' &&
tab.entityId === workspaceId &&
matchesHost(tab.executionHostId)
)
const workspace = (state.browserTabsByWorktree[destination.worktreeId] ?? []).find(
(tab) => tab.id === workspaceId
)
const page = (state.browserPagesByWorkspace[workspaceId] ?? []).find((item) => item.id === pageId)
if (
!workspace ||
!wrapper ||
!page ||
page.workspaceId !== workspaceId ||
page.worktreeId !== destination.worktreeId ||
!isLiveLinkOpenGroup(state, destination.worktreeId, wrapper.groupId)
) {
return null
}
return {
worktreeId: destination.worktreeId,
executionHostId: destination.executionHostId,
browserWorkspaceId: workspaceId,
browserPageId: pageId,
unifiedTabId: wrapper.id,
groupId: wrapper.groupId
}
}
export function currentBrowserLinkMatch(
state: AppState,
destination: BrowserLinkDestination,
match: BrowserOpenResult
): BrowserOpenResult | null {
const actual = browserOpenResultForPage(
state,
destination,
match.browserWorkspaceId,
match.browserPageId
)
const workspace = state.browserTabsByWorktree[destination.worktreeId]?.find(
(item) => item.id === match.browserWorkspaceId
)
const page = state.browserPagesByWorkspace[match.browserWorkspaceId]?.find(
(item) => item.id === match.browserPageId
)
return actual &&
actual.groupId === match.groupId &&
workspace &&
page &&
browserLinkPageMatches(state, destination, workspace, page)
? actual
: null
}
export function selectBrowserLinkDestination(
state: AppState,
destination: BrowserLinkDestination,
intent: LinkOpenIntent | null
):
| { status: 'ready'; groupId?: string; split?: TabSplitTarget; match: BrowserOpenResult | null }
| { status: 'refused' } {
const { worktreeId, executionHostId } = destination
const groups = state.groupsByWorktree[worktreeId] ?? []
const matchesHost = createBrowserLinkHostMatcher(state, worktreeId, executionHostId)
if (
intent?.targetGroupId &&
(intent.workspaceId !== worktreeId ||
!matchesHost(intent.hostId) ||
!isLiveLinkOpenGroup(state, worktreeId, intent.targetGroupId))
) {
return { status: 'refused' }
}
const workspaces = new Map(
(state.browserTabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab])
)
const tabs = state.unifiedTabsByWorktree[worktreeId] ?? []
const tabsById = new Map<string, Tab>()
const wrappersByWorkspace = new Map<string, Tab | null>()
for (const tab of tabs) {
tabsById.set(tab.id, tab)
if (tab.contentType === 'browser') {
wrappersByWorkspace.set(tab.entityId, wrappersByWorkspace.has(tab.entityId) ? null : tab)
}
}
const matchesByTab = new Map<string, BrowserOpenResult>()
const matchesPage = createBrowserLinkPageMatcher(state, destination)
const matchingGroups = new Set<string>()
const liveGroups = liveLinkOpenGroupIds(state, worktreeId)
for (const tab of wrappersByWorkspace.values()) {
if (
!tab ||
tab.contentType !== 'browser' ||
!matchesHost(tab.executionHostId) ||
!liveGroups.has(tab.groupId)
) {
continue
}
const workspace = workspaces.get(tab.entityId)
if (!workspace) {
continue
}
const matchingPages = (state.browserPagesByWorkspace[workspace.id] ?? []).filter((page) =>
matchesPage(workspace, page)
)
const pagesById = new Map(matchingPages.map((page) => [page.id, page]))
const page =
(workspace.activePageId ? pagesById.get(workspace.activePageId) : undefined) ??
workspace.pageIds?.map((id) => pagesById.get(id)).find((item) => item !== undefined) ??
matchingPages.reduce<BrowserPage | undefined>(
(best, item) => (!best || item.id < best.id ? item : best),
undefined
)
if (!page) {
continue
}
matchesByTab.set(tab.id, {
worktreeId,
executionHostId,
browserWorkspaceId: workspace.id,
browserPageId: page.id,
unifiedTabId: tab.id,
groupId: tab.groupId
})
matchingGroups.add(tab.groupId)
}
let groupId = intent?.targetGroupId
const focusedGroupId = state.activeGroupIdByWorktree[worktreeId]
if (!groupId && matchesByTab.size > 0) {
groupId =
findVisibleBrowserLinkTarget(state, worktreeId, (tab) => matchesByTab.has(tab.id))
?.targetGroupId ??
(focusedGroupId && matchingGroups.has(focusedGroupId) ? focusedGroupId : undefined) ??
[...liveGroups].find((id) => matchingGroups.has(id))
}
if (!groupId) {
groupId = findVisibleBrowserLinkTarget(
state,
worktreeId,
(tab) =>
matchesHost(tab.executionHostId) && workspaces.has(tabsById.get(tab.id)?.entityId ?? '')
)?.targetGroupId
}
if (!groupId) {
const source =
intent?.workspaceId === worktreeId && matchesHost(intent.hostId)
? tabsById.get(intent.sourceTabId)
: undefined
const sourceGroupId =
source &&
(source.executionHostId ?? null) === intent?.hostId &&
liveGroups.has(source.groupId)
? source.groupId
: focusedGroupId && liveGroups.has(focusedGroupId)
? focusedGroupId
: [...liveGroups][0]
if (sourceGroupId && groups.find((group) => group.id === sourceGroupId)?.activeTabId) {
return {
status: 'ready',
split: { sourceGroupId, splitDirection: 'right', activate: false },
match: null
}
}
groupId = sourceGroupId
}
const group = groups.find((item) => item.id === groupId)
const ids = group ? [group.activeTabId, ...group.tabOrder] : []
for (const id of ids) {
const match = id ? matchesByTab.get(id) : undefined
if (match && match.groupId === groupId) {
return { status: 'ready', groupId, match }
}
}
return { status: 'ready', groupId, match: null }
}
@@ -0,0 +1,26 @@
import { requestBrowserFocus } from '@/components/browser-pane/host-guest/browser-focus'
import { useAppStore } from '@/store'
import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree'
import type { BrowserOpenResult } from './browser-link-destination'
/** Completion may focus only the browser surface the user still has selected. */
export function focusOpenedBrowserLink(result: BrowserOpenResult): void {
const state = useAppStore.getState()
const group = state.groupsByWorktree[result.worktreeId]?.find(
(item) => item.id === result.groupId
)
const workspace = state.browserTabsByWorktree[result.worktreeId]?.find(
(item) => item.id === result.browserWorkspaceId
)
if (
(!isFloatingWorkspaceId(result.worktreeId) && state.activeWorktreeId !== result.worktreeId) ||
state.activeGroupIdByWorktree[result.worktreeId] !== result.groupId ||
group?.activeTabId !== result.unifiedTabId ||
workspace?.activePageId !== result.browserPageId
) {
return
}
state.consumeAddressBarFocusRequest(result.browserPageId)
state.consumeAddressBarFocusRequest(result.browserWorkspaceId)
requestBrowserFocus({ pageId: result.browserPageId, target: 'webview' })
}
@@ -0,0 +1,270 @@
import type * as BrowserFocusModule from '@/components/browser-pane/host-guest/browser-focus'
import { requestBrowserFocus } from '@/components/browser-pane/host-guest/browser-focus'
import { isBrowserPageMountAdmitted } from '@/components/browser-pane/host-guest/browser-page-mount-admission'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { createStoreCascadesMockApi } from '@/store/slices/store-cascades-test-harness'
import {
drawTabsFromMain,
type TabsDrawnFromMain
} from '@/store/slices/tabs-drawn-from-main.test-fixture'
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
import {
createBrowserLinkTestStore,
WORKSPACE,
URL_TO_OPEN,
type BrowserLinkTestStore
} from './browser-link-open.test-fixture'
import { captureLinkOpenIntent } from './link-open-intent'
import { beginLinkOpenRequest } from './link-open-request'
import { openWorkspaceBrowserTabForLink } from './workspace-browser-tab-open'
import { openFileInBrowserTab } from './file-preview'
import type { BrowserOpenOutcome } from './browser-link-destination'
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
const mocks = vi.hoisted(() => ({ getState: vi.fn(), subscribe: vi.fn() }))
vi.mock('@/store', () => ({
useAppStore: { getState: () => mocks.getState(), subscribe: mocks.subscribe }
}))
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeLayoutClientWithTestFrames(await importOriginal())
)
vi.mock('@/components/browser-pane/host-guest/browser-focus', async (importOriginal) => ({
...(await importOriginal<typeof BrowserFocusModule>()),
requestBrowserFocus: vi.fn()
}))
vi.mock('sonner', () => ({
toast: { error: vi.fn(), info: vi.fn(), success: vi.fn(), warning: vi.fn() }
}))
createStoreCascadesMockApi()
let store: BrowserLinkTestStore
let main: TabsDrawnFromMain
beforeEach(async () => {
store = createBrowserLinkTestStore()
store.setState({
tabsByWorktree: {
[WORKSPACE]: store.getState().unifiedTabsByWorktree[WORKSPACE]!.map((tab) => ({
id: tab.entityId,
ptyId: null,
worktreeId: WORKSPACE,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: tab.sortOrder,
createdAt: tab.createdAt
}))
}
})
mocks.getState.mockImplementation(() => store.getState())
mocks.subscribe.mockImplementation(store.subscribe)
vi.mocked(requestBrowserFocus).mockClear()
main = await drawTabsFromMain(store)
layoutApplyTestTransport.answerWith('pending')
})
afterEach(() => main.close())
function open(surface: 'http' | 'html', explicit = true, background = false) {
const linkContext = {
linkIntent: captureLinkOpenIntent(store.getState(), WORKSPACE, 'source', {
explicitSourceGroup: explicit
}),
isCurrentRequest: beginLinkOpenRequest()
}
if (surface === 'http') {
return openWorkspaceBrowserTabForLink({
workspaceId: WORKSPACE,
url: URL_TO_OPEN,
intent: { kind: 'url' },
...(background ? { focusOnCreate: false, selectWorktree: false } : {}),
linkContext
})
}
return new Promise<BrowserOpenOutcome>((resolve) =>
openFileInBrowserTab({
worktreeId: WORKSPACE,
filePath: '/repo/index.html',
linkContext,
onLinkOutcome: resolve
})
)
}
function pendingBrowserPageId(): string {
const pageId = layoutApplyTestTransport.sentOfType('openBrowserTab')[0]?.pageId
if (!pageId) {
throw new Error('Expected a pending browser page')
}
return pageId
}
it.each([
['http', false],
['http', true],
['html', false],
['html', true]
] as const)(
'waits for main to draw a %s link before reporting and activating its exact page (explicit: %s)',
async (surface, explicit) => {
const reported = vi.fn()
const opening = open(surface, explicit).then((outcome) => {
reported(outcome)
return outcome
})
await Promise.resolve()
expect(reported).not.toHaveBeenCalled()
expect(store.getState().browserPagesByWorkspace).toEqual({})
expect(store.getState().activeGroupIdByWorktree[WORKSPACE]).toBe('c')
expect(requestBrowserFocus).not.toHaveBeenCalled()
const pending = layoutApplyTestTransport.sentOfType('openBrowserTab')[0]!
const targetGroupId = pending.groupId
if (explicit) {
expect(targetGroupId).toBe('a')
} else {
expect(pending.split).toMatchObject({ besideGroupId: 'a', direction: 'right' })
}
layoutApplyTestTransport.answerWith('apply')
layoutApplyTestTransport.answerPending()
await main.settle()
const outcome = await opening
expect(outcome).toMatchObject({
status: 'opened',
result: { worktreeId: WORKSPACE, groupId: targetGroupId }
})
if (outcome.status !== 'opened') {
throw new Error('Expected a materialized browser link')
}
const { result } = outcome
const state = store.getState()
expect(
state.groupsByWorktree[WORKSPACE]?.find((group) => group.id === targetGroupId)?.activeTabId
).toBe(result.unifiedTabId)
expect(state.activeGroupIdByWorktree[WORKSPACE]).toBe(targetGroupId)
expect(state.activeBrowserTabId).toBe(result.browserWorkspaceId)
expect(
state.browserTabsByWorktree[WORKSPACE]?.find((tab) => tab.id === result.browserWorkspaceId)
?.activePageId
).toBe(result.browserPageId)
expect(requestBrowserFocus).toHaveBeenCalledExactlyOnceWith({
pageId: result.browserPageId,
target: 'webview'
})
}
)
it('admits a background HTTP page only after main materializes it without activating it', async () => {
const opening = open('http', false, true)
const pageId = pendingBrowserPageId()
expect(isBrowserPageMountAdmitted(pageId)).toBe(false)
layoutApplyTestTransport.answerWith('apply')
layoutApplyTestTransport.answerPending()
await main.settle()
expect(await opening).toMatchObject({ status: 'opened' })
expect(isBrowserPageMountAdmitted(pageId)).toBe(true)
expect(store.getState().activeGroupIdByWorktree[WORKSPACE]).toBe('c')
expect(requestBrowserFocus).not.toHaveBeenCalled()
})
it('does not activate a link superseded while main was opening its tab', async () => {
const opening = open('http')
const pageId = pendingBrowserPageId()
beginLinkOpenRequest()
layoutApplyTestTransport.answerWith('apply')
layoutApplyTestTransport.answerPending()
await main.settle()
expect(await opening).toEqual({ status: 'superseded' })
await main.settle()
expect(store.getState().browserTabsByWorktree[WORKSPACE] ?? []).toEqual([])
expect(store.getState().browserPagesByWorkspace).toEqual({})
expect(
store.getState().unifiedTabsByWorktree[WORKSPACE]?.some((tab) => tab.contentType === 'browser')
).toBe(false)
expect(store.getState().recentlyClosedBrowserTabsByWorktree[WORKSPACE] ?? []).toEqual([])
expect(store.getState().activeGroupIdByWorktree[WORKSPACE]).toBe('c')
expect(isBrowserPageMountAdmitted(pageId)).toBe(false)
expect(requestBrowserFocus).not.toHaveBeenCalled()
})
it.each(['workspace', 'view', 'view-return'] as const)(
'retains a tab without revealing or focusing it after navigation during layout acknowledgement: %s',
async (navigation) => {
const opening = open('http', false)
const pageId = pendingBrowserPageId()
if (navigation === 'workspace') {
store.getState().setActiveWorktree(null)
} else {
const previousView = store.getState().activeView
store.getState().setActiveView('skills')
if (navigation === 'view-return') {
store.getState().setActiveView(previousView)
}
}
const selected = store.getState()
layoutApplyTestTransport.answerWith('apply')
layoutApplyTestTransport.answerPending()
await main.settle()
expect(await opening).toMatchObject({ status: 'opened' })
const state = store.getState()
expect(state.browserTabsByWorktree[WORKSPACE]).toHaveLength(1)
expect(state.activeWorktreeId).toBe(selected.activeWorktreeId)
expect(state.activeView).toBe(selected.activeView)
expect(state.activeGroupIdByWorktree[WORKSPACE]).toBe('c')
expect(state.groupsByWorktree[WORKSPACE]?.find((group) => group.id === 'b')?.activeTabId).toBe(
'terminal-b'
)
expect(isBrowserPageMountAdmitted(pageId)).toBe(true)
expect(requestBrowserFocus).not.toHaveBeenCalled()
}
)
it('does not activate a link whose owner changed while main was opening its tab', async () => {
const opening = open('http')
const pageId = pendingBrowserPageId()
store.setState((state) => ({
worktreesByRepo: {
repo1: state.worktreesByRepo.repo1!.map((worktree) => ({
...worktree,
hostId: 'ssh:changed'
}))
}
}))
layoutApplyTestTransport.answerWith('apply')
layoutApplyTestTransport.answerPending()
await main.settle()
expect(await opening).toEqual({ status: 'refused' })
await main.settle()
expect(store.getState().browserTabsByWorktree[WORKSPACE] ?? []).toEqual([])
expect(store.getState().browserPagesByWorkspace).toEqual({})
expect(
store.getState().unifiedTabsByWorktree[WORKSPACE]?.some((tab) => tab.contentType === 'browser')
).toBe(false)
expect(store.getState().recentlyClosedBrowserTabsByWorktree[WORKSPACE] ?? []).toEqual([])
expect(store.getState().activeGroupIdByWorktree[WORKSPACE]).toBe('c')
expect(isBrowserPageMountAdmitted(pageId)).toBe(false)
expect(requestBrowserFocus).not.toHaveBeenCalled()
})
it('reports failure when main refuses the browser creation', async () => {
const opening = open('http')
const pageId = pendingBrowserPageId()
layoutApplyTestTransport.answerWith({ refuse: 'workspace_not_found' })
layoutApplyTestTransport.answerPending()
await main.settle()
expect(await opening).toEqual({ status: 'failed' })
expect(store.getState().browserPagesByWorkspace).toEqual({})
expect(isBrowserPageMountAdmitted(pageId)).toBe(false)
expect(requestBrowserFocus).not.toHaveBeenCalled()
})
@@ -0,0 +1,102 @@
import {
createTestStore,
makeTabGroup,
makeUnifiedTab,
makeWorktree,
seedStore
} from '@/store/slices/store-test-helpers'
import type { BrowserLinkDestination } from './browser-link-destination'
export const WORKSPACE = 'repo1::/repo'
export const URL_TO_OPEN = 'https://example.com/a#one'
export type BrowserLinkTestStore = ReturnType<typeof createTestStore>
export function createBrowserLinkTestStore(): BrowserLinkTestStore {
const store = createTestStore()
seedBrowserLinkTestStore(store)
return store
}
export function seedBrowserLinkTestStore(store: BrowserLinkTestStore): void {
seedStore(store, {
browserTabsByWorktree: {},
browserPagesByWorkspace: {},
remoteBrowserPageHandlesByPageId: {},
tabBarOrderByWorktree: {},
tabsByWorktree: {},
openFiles: [],
pendingAddressBarFocusByPageId: {},
pendingAddressBarFocusByTabId: {},
activeWorktreeId: WORKSPACE,
activeWorkspaceExecutionHostId: 'local',
worktreesByRepo: {
repo1: [makeWorktree({ id: WORKSPACE, repoId: 'repo1', path: '/repo', hostId: 'local' })]
},
groupsByWorktree: {
[WORKSPACE]: ['a', 'b', 'c'].map((id) =>
makeTabGroup({
id,
worktreeId: WORKSPACE,
activeTabId: id === 'a' ? 'source' : `terminal-${id}`,
tabOrder: [id === 'a' ? 'source' : `terminal-${id}`]
})
)
},
unifiedTabsByWorktree: {
[WORKSPACE]: ['a', 'b', 'c'].map((groupId) =>
makeUnifiedTab({
id: groupId === 'a' ? 'source' : `terminal-${groupId}`,
entityId: groupId === 'a' ? 'source-terminal' : `terminal-${groupId}`,
contentType: 'terminal',
groupId,
worktreeId: WORKSPACE,
executionHostId: 'local'
})
)
},
activeGroupIdByWorktree: { [WORKSPACE]: 'c' },
layoutByWorktree: {
[WORKSPACE]: {
type: 'split',
direction: 'horizontal',
ratio: 0.3,
first: { type: 'leaf', groupId: 'a' },
second: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'b' },
second: { type: 'leaf', groupId: 'c' }
}
}
}
})
store.setState((state) => ({
tabsByWorktree: {
[WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) => ({
id: tab.entityId,
ptyId: null,
worktreeId: WORKSPACE,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: tab.sortOrder,
createdAt: tab.createdAt
}))
}
}))
}
export function browserLinkTestDestination(
overrides: Partial<BrowserLinkDestination> = {}
): BrowserLinkDestination {
return {
worktreeId: WORKSPACE,
executionHostId: 'local',
browserRuntimeEnvironmentId: null,
profileHostId: 'local',
sessionProfileId: null,
sessionPartition: null,
resource: { kind: 'website', url: URL_TO_OPEN },
...overrides
}
}
@@ -0,0 +1,569 @@
import type * as BrowserFocusModule from '@/components/browser-pane/host-guest/browser-focus'
import { requestBrowserFocus } from '@/components/browser-pane/host-guest/browser-focus'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createStoreCascadesMockApi } from '@/store/slices/store-cascades-test-harness'
import { makeTabGroup, makeUnifiedTab } from '@/store/slices/store-test-helpers'
import { buildBrowserPage } from '@/store/slices/browser-page-records'
import { captureLinkOpenIntent } from './link-open-intent'
import { beginLinkOpenRequest } from './link-open-request'
import { openLocalBrowserLink } from './browser-link-open'
import { selectBrowserLinkDestination } from './browser-link-destination'
import {
browserLinkTestDestination,
createBrowserLinkTestStore,
WORKSPACE,
URL_TO_OPEN,
type BrowserLinkTestStore
} from './browser-link-open.test-fixture'
import { openFileInBrowserTab } from './file-preview'
import { openWorkspaceBrowserTabForLink } from './workspace-browser-tab-open'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
const mocks = vi.hoisted(() => ({ getState: vi.fn() }))
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.getState() } }))
vi.mock('@/components/browser-pane/host-guest/browser-focus', async (importOriginal) => ({
...(await importOriginal<typeof BrowserFocusModule>()),
requestBrowserFocus: vi.fn()
}))
vi.mock('sonner', () => ({
toast: { error: vi.fn(), info: vi.fn(), success: vi.fn(), warning: vi.fn() }
}))
createStoreCascadesMockApi()
let store: BrowserLinkTestStore
beforeEach(() => {
store = createBrowserLinkTestStore()
mocks.getState.mockImplementation(() => store.getState())
})
function context(explicit = false) {
return {
linkIntent: captureLinkOpenIntent(store.getState(), WORKSPACE, 'source', {
explicitSourceGroup: explicit
}),
isCurrentRequest: beginLinkOpenRequest()
}
}
function open(linkContext = context()) {
return openLocalBrowserLink({
destination: browserLinkTestDestination(),
context: linkContext,
url: URL_TO_OPEN,
ownsRoute: () => true
})
}
function browser(groupId: string, url = URL_TO_OPEN) {
return store.getState().createBrowserTab(WORKSPACE, url, {
targetGroupId: groupId,
executionHostId: 'local',
browserRuntimeEnvironmentId: null,
sessionProfileId: null,
activate: false
})
}
describe('browser link placement and exact page activation', () => {
it('opens beside the source despite focus in a nonadjacent group, returning the real identities', async () => {
const result = await open()
expect(result.status).toBe('opened')
if (result.status !== 'opened') {
throw new Error('Expected open')
}
const state = store.getState()
expect(['a', 'b', 'c']).not.toContain(result.result.groupId)
expect(state.groupsByWorktree[WORKSPACE]?.find((group) => group.id === 'a')?.activeTabId).toBe(
'source'
)
expect(
state.groupsByWorktree[WORKSPACE]?.find((group) => group.id === result.result.groupId)
?.activeTabId
).toBe(result.result.unifiedTabId)
expect(state.browserPagesByWorkspace[result.result.browserWorkspaceId]?.[0]?.id).toBe(
result.result.browserPageId
)
expect(state.pendingAddressBarFocusByPageId).toEqual({})
expect(requestBrowserFocus).toHaveBeenLastCalledWith({
pageId: result.result.browserPageId,
target: 'webview'
})
expect(state.groupsByWorktree[WORKSPACE]).toHaveLength(4)
expect(state.layoutByWorktree[WORKSPACE]).toMatchObject({
first: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'a' },
second: { type: 'leaf', groupId: result.result.groupId }
}
})
expect(state.groupsByWorktree[WORKSPACE]?.find((group) => group.id === 'b')?.activeTabId).toBe(
'terminal-b'
)
})
it('uses a distant visible browser and preserves the neighboring terminal', async () => {
store.getState().createBrowserTab(WORKSPACE, 'https://unrelated.example/', {
targetGroupId: 'c',
executionHostId: 'local',
browserRuntimeEnvironmentId: null,
activate: true
})
expect(await open()).toMatchObject({ status: 'opened', result: { groupId: 'c' } })
expect(
store.getState().groupsByWorktree[WORKSPACE]?.find((group) => group.id === 'a')?.activeTabId
).toBe('source')
})
it('prefers the focused visible browser, otherwise the most recently focused one', async () => {
const left = browser('b', 'https://left.example/')
const right = browser('c', 'https://right.example/')
store.setState((state) => ({
unifiedTabsByWorktree: {
[WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) => ({
...tab,
lastFocusedAt: tab.entityId === left.id ? 200 : 100
}))
},
groupsByWorktree: {
[WORKSPACE]: state.groupsByWorktree[WORKSPACE]!.map((group) => ({
...group,
activeTabId:
state.unifiedTabsByWorktree[WORKSPACE]!.find(
(tab) =>
tab.entityId === (group.id === 'b' ? left.id : right.id) && tab.groupId === group.id
)?.id ?? group.activeTabId
}))
},
activeGroupIdByWorktree: { [WORKSPACE]: 'c' }
}))
expect(await open()).toMatchObject({ status: 'opened', result: { groupId: 'c' } })
store.getState().focusGroup(WORKSPACE, 'a')
const destination = browserLinkTestDestination({
resource: { kind: 'website', url: 'https://another.example/' }
})
expect(
selectBrowserLinkDestination(store.getState(), destination, context().linkIntent)
).toMatchObject({ groupId: 'c' })
store.setState((state) => ({
unifiedTabsByWorktree: {
[WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) => ({
...tab,
lastFocusedAt: tab.entityId === left.id ? 1000 : 100
}))
}
}))
expect(
selectBrowserLinkDestination(store.getState(), destination, context().linkIntent)
).toMatchObject({ groupId: 'b' })
})
it('prefers an old inactive nonadjacent match over a nearby browser without navigation or creation', async () => {
store.getState().createBrowserTab(WORKSPACE, 'https://nearby.example/', {
targetGroupId: 'b',
executionHostId: 'local',
browserRuntimeEnvironmentId: null,
activate: true
})
const existing = browser('c')
store.getState().createBrowserPage(existing.id, 'https://other.example/', {
activate: true,
browserRuntimeEnvironmentId: null
})
const navigate = vi.spyOn(store.getState(), 'setBrowserPageUrl')
const create = vi.spyOn(store.getState(), 'createBrowserTab')
const result = await open()
expect(result).toMatchObject({
status: 'opened',
result: {
browserWorkspaceId: existing.id,
browserPageId: existing.activePageId,
groupId: 'c'
}
})
expect(
store.getState().browserTabsByWorktree[WORKSPACE]?.find((tab) => tab.id === existing.id)
?.activePageId
).toBe(existing.activePageId)
expect(navigate).not.toHaveBeenCalled()
expect(create).not.toHaveBeenCalled()
})
it('reusing a page clears a pending address-bar request and focuses the page', async () => {
const existing = browser('b')
const pageId = existing.activePageId
if (!pageId) {
throw new Error('Expected page')
}
store.setState({
pendingAddressBarFocusByPageId: { [pageId]: true },
pendingAddressBarFocusByTabId: { [existing.id]: true, [pageId]: true }
})
expect(await open()).toMatchObject({ status: 'opened', result: { browserPageId: pageId } })
expect(store.getState().pendingAddressBarFocusByPageId).toEqual({})
expect(store.getState().pendingAddressBarFocusByTabId).toEqual({})
expect(requestBrowserFocus).toHaveBeenLastCalledWith({ pageId, target: 'webview' })
})
it('rechecks a reused page after workspace activation without navigating it back', async () => {
const existing = browser('b')
const pageId = existing.activePageId
if (!pageId) {
throw new Error('Expected page')
}
const outcome = await openLocalBrowserLink({
destination: browserLinkTestDestination(),
context: context(),
url: URL_TO_OPEN,
ownsRoute: () => true,
reveal: () => store.getState().setBrowserPageUrl(pageId, 'https://navigated-away.example/')
})
expect(outcome).toEqual({ status: 'refused' })
expect(store.getState().browserPagesByWorkspace[existing.id]?.[0]?.url).toBe(
'https://navigated-away.example/'
)
expect(store.getState().activeGroupIdByWorktree[WORKSPACE]).toBe('c')
})
it('reuses an exact match in the source group without creating another page', async () => {
const existing = browser('a')
const result = await open()
expect(result).toMatchObject({ status: 'opened', result: { groupId: 'a' } })
if (result.status !== 'opened') {
throw new Error('Expected open')
}
expect(result.result.browserPageId).toBe(existing.activePageId)
expect(store.getState().browserPagesByWorkspace[existing.id]?.[0]?.url).toBe(URL_TO_OPEN)
})
it('removes a newly created tab after an activation error while preserving existing tabs', async () => {
const existing = browser('c', 'https://unrelated.example/')
const before = store.getState()
const outcome = await openLocalBrowserLink({
destination: browserLinkTestDestination(),
context: context(),
url: URL_TO_OPEN,
ownsRoute: () => true,
reveal: () => {
throw new Error('Workspace activation failed')
}
})
expect(outcome).toEqual({ status: 'failed' })
const state = store.getState()
expect(state.browserTabsByWorktree[WORKSPACE]?.map((tab) => tab.id)).toEqual([existing.id])
expect(state.browserPagesByWorkspace).toEqual(before.browserPagesByWorkspace)
expect(state.unifiedTabsByWorktree[WORKSPACE]).toEqual(before.unifiedTabsByWorktree[WORKSPACE])
expect(state.recentlyClosedBrowserTabsByWorktree[WORKSPACE] ?? []).toEqual([])
})
it('retains a successfully created background tab without activating it', async () => {
const outcome = await openLocalBrowserLink({
destination: browserLinkTestDestination(),
context: context(),
url: URL_TO_OPEN,
options: { activate: false },
ownsRoute: () => true
})
expect(outcome.status).toBe('opened')
if (outcome.status !== 'opened') {
throw new Error('Expected open')
}
const state = store.getState()
expect(state.browserTabsByWorktree[WORKSPACE]?.map((tab) => tab.id)).toEqual([
outcome.result.browserWorkspaceId
])
expect(state.browserPagesByWorkspace[outcome.result.browserWorkspaceId]?.[0]?.url).toBe(
URL_TO_OPEN
)
expect(state.activeGroupIdByWorktree[WORKSPACE]).toBe('c')
})
it('uses the selected matching wrapper, then active page, instead of recency', async () => {
browser('b')
const selected = browser('b')
const second = store.getState().createBrowserPage(selected.id, URL_TO_OPEN, {
activate: true,
browserRuntimeEnvironmentId: null
})
const wrapper = store
.getState()
.unifiedTabsByWorktree[WORKSPACE]?.find((tab) => tab.entityId === selected.id)
if (!wrapper || !second) {
throw new Error('Missing fixture')
}
store.getState().activateTab(wrapper.id, { worktreeId: WORKSPACE })
expect(await open()).toMatchObject({
status: 'opened',
result: { unifiedTabId: wrapper.id, browserPageId: second.id }
})
})
it('follows source movement while an explicit request stays pinned', async () => {
const automatic = context()
store.setState({
unifiedTabsByWorktree: {
[WORKSPACE]: store
.getState()
.unifiedTabsByWorktree[WORKSPACE]!.map((tab) =>
tab.id === 'source' ? { ...tab, groupId: 'b' } : tab
)
}
})
const moved = await open(automatic)
expect(moved.status).toBe('opened')
if (moved.status !== 'opened') {
throw new Error('Expected open')
}
expect(store.getState().layoutByWorktree[WORKSPACE]).toMatchObject({
second: {
first: { type: 'split', first: { groupId: 'b' }, second: { groupId: moved.result.groupId } }
}
})
const explicit = context(true)
store.setState({
unifiedTabsByWorktree: {
[WORKSPACE]: store
.getState()
.unifiedTabsByWorktree[WORKSPACE]!.map((tab) =>
tab.id === 'source' ? { ...tab, groupId: 'a' } : tab
)
}
})
expect(await open(explicit)).toMatchObject({ status: 'opened', result: { groupId: 'b' } })
})
it('uses destination defaults after the source closes, instead of its captured group', async () => {
const captured = context()
store.setState({
unifiedTabsByWorktree: {
[WORKSPACE]: store
.getState()
.unifiedTabsByWorktree[WORKSPACE]!.filter((tab) => tab.id !== 'source')
}
})
const opened = await open(captured)
expect(opened.status).toBe('opened')
if (opened.status !== 'opened') {
throw new Error('Expected open')
}
expect(store.getState().layoutByWorktree[WORKSPACE]).toMatchObject({
second: {
second: {
type: 'split',
first: { groupId: 'c' },
second: { groupId: opened.result.groupId }
}
}
})
})
it('refuses an expired explicit group before creating records or activating a destination', async () => {
const captured = context(true)
store.setState({
groupsByWorktree: {
[WORKSPACE]: store
.getState()
.groupsByWorktree[WORKSPACE]!.filter((group) => group.id !== 'a')
}
})
const create = vi.spyOn(store.getState(), 'createBrowserTab')
expect(await open(captured)).toEqual({ status: 'refused' })
expect(create).not.toHaveBeenCalled()
expect(store.getState().browserPagesByWorkspace).toEqual({})
})
it('refuses at the unified mutation seam and removes the incomplete browser records', async () => {
const captured = context(true)
const createUnifiedTab = store.getState().createUnifiedTab
store.setState({
createUnifiedTab: (...args) => {
store.setState({
groupsByWorktree: {
[WORKSPACE]: store
.getState()
.groupsByWorktree[WORKSPACE]!.filter((group) => group.id !== 'a')
}
})
return createUnifiedTab(...args)
}
})
expect(await open(captured)).toEqual({ status: 'refused' })
expect(store.getState().browserPagesByWorkspace).toEqual({})
expect(store.getState().browserTabsByWorktree[WORKSPACE] ?? []).toEqual([])
expect(
store
.getState()
.unifiedTabsByWorktree[WORKSPACE]?.some((tab) => tab.contentType === 'browser')
).toBe(false)
})
it('reuses in one group and does not create a split', async () => {
store.setState({
groupsByWorktree: { [WORKSPACE]: [store.getState().groupsByWorktree[WORKSPACE]![0]!] },
layoutByWorktree: { [WORKSPACE]: { type: 'leaf', groupId: 'a' } }
})
const existing = browser('a')
expect(await open()).toMatchObject({
status: 'opened',
result: { browserWorkspaceId: existing.id, groupId: 'a' }
})
expect(store.getState().groupsByWorktree[WORKSPACE]).toHaveLength(1)
})
it('supersedes a pending operation before creation', async () => {
const captured = context()
beginLinkOpenRequest()
expect(await open(captured)).toEqual({ status: 'superseded' })
expect(store.getState().browserPagesByWorkspace).toEqual({})
})
})
describe('existing open routes with link intent', () => {
it('opens local HTTP through the managed route and reports the exact destination', async () => {
expect(
await openWorkspaceBrowserTabForLink({
workspaceId: WORKSPACE,
url: URL_TO_OPEN,
intent: { kind: 'url' },
linkContext: context()
})
).toMatchObject({
status: 'opened',
result: { worktreeId: WORKSPACE, executionHostId: 'local' }
})
})
it('refuses a source owner that changed while HTTP routing was pending', async () => {
const captured = context()
const host = 'ssh:changed-owner'
store.setState((state) => ({
repos: state.repos.map((repo) => ({
...repo,
executionHostId: host,
connectionId: 'changed-owner'
})),
worktreesByRepo: {
repo1: state.worktreesByRepo.repo1!.map((worktree) => ({ ...worktree, hostId: host }))
},
activeWorkspaceExecutionHostId: host
}))
expect(
await openWorkspaceBrowserTabForLink({
workspaceId: WORKSPACE,
url: URL_TO_OPEN,
intent: { kind: 'url' },
linkContext: captured
})
).toEqual({ status: 'refused' })
expect(store.getState().browserPagesByWorkspace).toEqual({})
})
it('creates a pinned local HTML preview even when another group already shows the file', async () => {
const filePath = '/repo/index.html'
browser('b', 'file:///repo/index.html')
const outcome = vi.fn()
await new Promise((resolve) =>
openFileInBrowserTab({
filePath,
worktreeId: WORKSPACE,
linkContext: context(true),
onLinkOutcome: (result) => {
outcome(result)
resolve(result)
}
})
)
expect(outcome).toHaveBeenCalledWith(
expect.objectContaining({
status: 'opened',
result: expect.objectContaining({ groupId: 'a' })
})
)
expect(store.getState().browserTabsByWorktree[WORKSPACE]).toHaveLength(2)
})
it('finds inactive document pages through page records rather than workspace mirrors', () => {
const existing = browser('c', 'https://other.example/')
const docLocation = {
kind: 'workspace-doc' as const,
worktreeId: WORKSPACE,
filePath: '/repo/doc.html'
}
const doc = buildBrowserPage(
existing.id,
WORKSPACE,
'',
undefined,
null,
'inactive-doc',
docLocation
)
store.setState({
browserPagesByWorkspace: {
...store.getState().browserPagesByWorkspace,
[existing.id]: [...store.getState().browserPagesByWorkspace[existing.id]!, doc]
}
})
const selection = selectBrowserLinkDestination(
store.getState(),
browserLinkTestDestination({ resource: { kind: 'document', docLocation } }),
context().linkIntent
)
expect(selection).toMatchObject({
status: 'ready',
groupId: 'c',
match: { browserPageId: 'inactive-doc' }
})
})
it('keeps floating browser activation inside its own layout and leaves the selected workspace alone', async () => {
const floating = FLOATING_TERMINAL_WORKTREE_ID
store.setState({
groupsByWorktree: {
...store.getState().groupsByWorktree,
[floating]: ['fa', 'fb'].map((id) =>
makeTabGroup({
id,
worktreeId: floating,
activeTabId: id === 'fa' ? 'floating-source' : null,
tabOrder: id === 'fa' ? ['floating-source'] : []
})
)
},
unifiedTabsByWorktree: {
...store.getState().unifiedTabsByWorktree,
[floating]: [
makeUnifiedTab({
id: 'floating-source',
worktreeId: floating,
groupId: 'fa',
executionHostId: 'local',
contentType: 'terminal'
})
]
},
layoutByWorktree: {
...store.getState().layoutByWorktree,
[floating]: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'fa' },
second: { type: 'leaf', groupId: 'fb' }
}
}
})
const result = await openLocalBrowserLink({
destination: browserLinkTestDestination({ worktreeId: floating }),
context: { linkIntent: captureLinkOpenIntent(store.getState(), floating, 'floating-source') },
url: URL_TO_OPEN,
ownsRoute: () => true
})
expect(result).toMatchObject({
status: 'opened',
result: { worktreeId: floating }
})
expect(store.getState().activeWorktreeId).toBe(WORKSPACE)
})
})
+195
View File
@@ -0,0 +1,195 @@
import { focusOpenedBrowserLink } from './browser-link-focus'
import { releaseBrowserPageMount } from '@/components/browser-pane/host-guest/browser-page-mount-admission'
import { createBrowserLinkHostMatcher } from './browser-link-owner'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import type { CreateBrowserTabOptions } from '@/store/slices/browser/browser-slice-contract'
import { activateBrowserWorkspaceTab } from './browser-workspace-tab-activation'
import {
browserLinkPageMatches,
currentBrowserLinkMatch,
browserOpenResultForPage,
selectBrowserLinkDestination,
type BrowserLinkDestination,
type BrowserLinkOpenContext,
type BrowserOpenOutcome
} from './browser-link-destination'
import { isLiveLinkOpenGroup } from './link-open-intent'
import { afterPendingEntityTabs, hasPendingEntityTab } from '@/runtime/layout-owner-pending-tabs'
import { captureBrowserLinkReveal } from './browser-link-reveal'
export class BrowserLinkPlacementRefused extends Error {}
export function browserLinkCommitIsValid(
state: AppState,
destination: BrowserLinkDestination,
context: BrowserLinkOpenContext,
groupId: string | undefined,
ownsRoute: (state: AppState) => boolean
): boolean {
if (context.isCurrentRequest?.() === false || !ownsRoute(state)) {
return false
}
if (groupId && !isLiveLinkOpenGroup(state, destination.worktreeId, groupId)) {
return false
}
const intent = context.linkIntent
const matchesHost = createBrowserLinkHostMatcher(
state,
destination.worktreeId,
destination.executionHostId
)
if (
intent?.workspaceId === destination.worktreeId &&
intent.hostId &&
!matchesHost(intent.hostId)
) {
return false
}
return (
!intent?.targetGroupId ||
(intent.workspaceId === destination.worktreeId &&
matchesHost(intent.hostId) &&
intent.targetGroupId === groupId)
)
}
/** Selection and creation run together; activation waits for main to draw the tab. */
export async function openLocalBrowserLink(params: {
destination: BrowserLinkDestination
context: BrowserLinkOpenContext
url: string
options?: CreateBrowserTabOptions
ownsRoute: (state: AppState) => boolean
reveal?: () => void
}): Promise<BrowserOpenOutcome> {
const { destination, context } = params
if (context.isCurrentRequest?.() === false) {
return { status: 'superseded' }
}
const state = useAppStore.getState()
const selection = selectBrowserLinkDestination(state, destination, context.linkIntent)
if (selection.status === 'refused') {
return selection
}
const reveal = captureBrowserLinkReveal(
useAppStore.getState,
useAppStore.subscribe,
context.canReveal
)
const validate = (current: AppState): void => {
if (
!browserLinkCommitIsValid(
current,
destination,
context,
selection.groupId ?? selection.split?.sourceGroupId,
params.ownsRoute
)
) {
throw new BrowserLinkPlacementRefused('The browser link destination is no longer available.')
}
}
let createdTabId: string | undefined
let createdPageId: string | undefined
let retainCreatedTab = false
try {
validate(state)
let result = selection.match
if (result) {
const match = result
const workspace = (state.browserTabsByWorktree[destination.worktreeId] ?? []).find(
(item) => item.id === match.browserWorkspaceId
)
const page = (state.browserPagesByWorkspace[match.browserWorkspaceId] ?? []).find(
(item) => item.id === match.browserPageId
)
if (!workspace || !page || !browserLinkPageMatches(state, destination, workspace, page)) {
return { status: 'refused' }
}
} else {
const created = state.createBrowserTab(destination.worktreeId, params.url, {
...params.options,
targetGroupId: selection.groupId,
...(selection.split ? { split: selection.split } : {}),
executionHostId: destination.executionHostId,
browserRuntimeEnvironmentId: destination.browserRuntimeEnvironmentId,
sessionProfileId: destination.sessionProfileId,
sessionPartition: destination.sessionPartition,
activate: false,
focusAddressBar: false,
validateLinkPlacement: validate
})
createdTabId = created.id
createdPageId = created.activePageId ?? undefined
if (hasPendingEntityTab(destination.worktreeId, created.id)) {
await new Promise<void>((resolve) =>
afterPendingEntityTabs(destination.worktreeId, created.id, resolve)
)
}
validate(useAppStore.getState())
const pageId = created.activePageId
result = pageId
? browserOpenResultForPage(useAppStore.getState(), destination, created.id, pageId)
: null
}
if (!result) {
return { status: 'failed' }
}
if (context.linkIntent?.targetGroupId && result.groupId !== context.linkIntent.targetGroupId) {
return { status: 'refused' }
}
validate(useAppStore.getState())
if (params.options?.activate === false || !reveal.canReveal()) {
retainCreatedTab = true
return { status: 'opened', result }
}
params.reveal?.()
const current = useAppStore.getState()
validate(current)
const actual = browserOpenResultForPage(
current,
destination,
result.browserWorkspaceId,
result.browserPageId
)
if (
!actual ||
(context.linkIntent?.targetGroupId && actual.groupId !== context.linkIntent.targetGroupId)
) {
return { status: 'refused' }
}
if (selection.match && !currentBrowserLinkMatch(current, destination, actual)) {
return { status: 'refused' }
}
if (
!activateBrowserWorkspaceTab({
worktreeId: actual.worktreeId,
workspaceId: actual.browserWorkspaceId,
pageId: actual.browserPageId,
executionHostId: actual.executionHostId
})
) {
return { status: 'refused' }
}
focusOpenedBrowserLink(actual)
retainCreatedTab = true
return { status: 'opened', result: actual }
} catch (error) {
if (context.isCurrentRequest?.() === false) {
return { status: 'superseded' }
}
if (error instanceof BrowserLinkPlacementRefused) {
return { status: 'refused' }
}
return { status: 'failed' }
} finally {
reveal.dispose()
if (createdTabId && !retainCreatedTab) {
if (createdPageId) {
releaseBrowserPageMount(createdPageId)
}
useAppStore.getState().closeBrowserTab(createdTabId, { reason: 'cleanup' })
}
}
}
@@ -0,0 +1,43 @@
import type { AppState } from '@/store/types'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { authorityKey } from '../../../shared/host-authority'
import { isExecutionHostAliasForWorktree } from './worktree-execution-host-alias'
import { resolveOwner } from './resolve-owner'
export function createBrowserLinkHostMatcher(
state: AppState,
worktreeId: string,
executionHostId: ExecutionHostId
): (hostId: ExecutionHostId | null | undefined) => boolean {
const worktree = state.getKnownWorktreeById(worktreeId, executionHostId)
let matchesLegacyHost: boolean | undefined
return (hostId) => {
if (hostId) {
return (
hostId === executionHostId ||
Boolean(
worktree &&
isExecutionHostAliasForWorktree(executionHostId, worktree) &&
isExecutionHostAliasForWorktree(hostId, worktree)
)
)
}
if (matchesLegacyHost === undefined) {
// A selected host cannot disambiguate a legacy tab that never named its owner.
const owner = resolveOwner(
{ ...state, activeWorkspaceExecutionHostId: null },
{ workspaceId: worktreeId }
)
const destinationOwner = resolveOwner(state, {
workspaceId: worktreeId,
hostId: executionHostId
})
matchesLegacyHost =
owner.kind === 'resolved' &&
destinationOwner.kind === 'resolved' &&
(authorityKey(owner.owner) === authorityKey(destinationOwner.owner) ||
Boolean(worktree && isExecutionHostAliasForWorktree(executionHostId, worktree)))
}
return matchesLegacyHost
}
}
@@ -0,0 +1,195 @@
import type * as BrowserFocusModule from '@/components/browser-pane/host-guest/browser-focus'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createStoreCascadesMockApi } from '@/store/slices/store-cascades-test-harness'
import type { BrowserSessionProfile } from '../../../shared/browser-workspace-types'
import { getDefaultSettings } from '../../../shared/constants'
import { openFileInBrowserTab } from './file-preview'
import { openWorkspaceBrowserTabForLink } from './workspace-browser-tab-open'
import { captureLinkOpenIntent } from './link-open-intent'
import type { BrowserOpenOutcome } from './browser-link-destination'
import {
createBrowserLinkTestStore,
WORKSPACE,
URL_TO_OPEN,
type BrowserLinkTestStore
} from './browser-link-open.test-fixture'
const mocks = vi.hoisted(() => ({ getState: vi.fn(), ensureProfile: vi.fn() }))
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.getState() } }))
vi.mock('@/components/browser-pane/host-guest/browser-focus', async (importOriginal) => ({
...(await importOriginal<typeof BrowserFocusModule>()),
requestBrowserFocus: vi.fn()
}))
vi.mock('sonner', () => ({
toast: { error: vi.fn(), info: vi.fn(), success: vi.fn(), warning: vi.fn() }
}))
let store: BrowserLinkTestStore
beforeEach(() => {
createStoreCascadesMockApi()
Object.defineProperty(window.api, 'browser', {
value: {
sessionEnsureManagedProfile: mocks.ensureProfile,
notifyActiveTabChanged: vi.fn().mockResolvedValue(undefined)
},
configurable: true
})
mocks.ensureProfile.mockReset()
store = createBrowserLinkTestStore()
mocks.getState.mockImplementation(() => store.getState())
store.setState({
defaultBrowserSessionProfileIdByHostId: { local: 'default' },
browserSessionProfilesByHostId: {
local: [
{
id: 'default',
scope: 'default',
partition: 'persist:shared',
label: 'Default',
source: null
}
]
}
})
})
function open(surface: 'http' | 'html'): Promise<BrowserOpenOutcome> {
const linkContext = {
linkIntent: captureLinkOpenIntent(store.getState(), WORKSPACE, 'source')
}
if (surface === 'http') {
return openWorkspaceBrowserTabForLink({
workspaceId: WORKSPACE,
url: URL_TO_OPEN,
intent: { kind: 'url' },
linkContext
})
}
return new Promise((resolve) =>
openFileInBrowserTab({
filePath: '/repo/report.html',
worktreeId: WORKSPACE,
linkContext,
onLinkOutcome: resolve
})
)
}
describe.each(['repo', 'worktree'] as const)('%s browser profile isolation', (scope) => {
it.each(['http', 'html'] as const)(
'opens %s links in the managed profile and only reuses pages in that profile',
async (surface) => {
const profile: BrowserSessionProfile = {
id: 'managed',
scope: 'isolated',
partition: 'persist:managed',
label: 'Workspace login',
source: null,
owner: { kind: scope, id: scope === 'repo' ? 'repo1' : WORKSPACE }
}
mocks.ensureProfile.mockResolvedValue(profile)
store.setState((state) => ({
settings: { ...getDefaultSettings('/home/test'), browserProfileScope: scope },
browserSessionProfilesByHostId: {
local: [...state.browserSessionProfilesByHostId.local!, profile]
}
}))
const shared = store
.getState()
.createBrowserTab(
WORKSPACE,
surface === 'http' ? URL_TO_OPEN : 'file:///repo/report.html',
{
targetGroupId: 'b',
executionHostId: 'local',
browserRuntimeEnvironmentId: null,
sessionProfileId: 'default',
activate: false
}
)
const opened = await open(surface)
expect(opened.status).toBe('opened')
if (opened.status !== 'opened') {
throw new Error('Expected scoped browser page')
}
expect(opened.result.browserWorkspaceId).not.toBe(shared.id)
expect(
store
.getState()
.browserTabsByWorktree[WORKSPACE]?.find(
(workspace) => workspace.id === opened.result.browserWorkspaceId
)
).toMatchObject({ sessionProfileId: profile.id, sessionPartition: profile.partition })
expect(await open(surface)).toEqual(opened)
expect(store.getState().browserTabsByWorktree[WORKSPACE]).toHaveLength(2)
}
)
})
it.each([undefined, null])(
'keeps the shared local default independent of the selected remote profile (%s)',
async (localDefault) => {
store.setState({
browserSessionHostIdOverride: 'runtime:remote',
defaultBrowserSessionProfileId: 'remote-only',
defaultBrowserSessionProfileIdByHostId: { local: localDefault }
})
const opened = await open('http')
if (opened.status !== 'opened') {
throw new Error('Expected local browser page')
}
expect(
store
.getState()
.browserTabsByWorktree[WORKSPACE]?.find(
(workspace) => workspace.id === opened.result.browserWorkspaceId
)
).toMatchObject({ sessionProfileId: null, sessionPartition: 'persist:shared' })
}
)
it('holds a link in an isolated partition until its workspace profile is minted', async () => {
let mintProfile: ((profile: BrowserSessionProfile) => void) | undefined
mocks.ensureProfile.mockImplementation(
() => new Promise<BrowserSessionProfile>((resolve) => (mintProfile = resolve))
)
store.setState({
settings: { ...getDefaultSettings('/home/test'), browserProfileScope: 'worktree' }
})
const opened = await open('http')
if (opened.status !== 'opened') {
throw new Error('Expected pending browser page')
}
const pending = store
.getState()
.browserTabsByWorktree[WORKSPACE]?.find(
(workspace) => workspace.id === opened.result.browserWorkspaceId
)
expect(pending?.sessionProfileId).toBeTruthy()
expect(pending?.sessionProfileId).not.toBe('default')
expect(pending?.sessionPartition).toBe(
`persist:orca-browser-unminted-${pending?.sessionProfileId}`
)
expect(store.getState().managedBrowserProfileStatusById[pending?.sessionProfileId ?? '']).toBe(
'pending'
)
mintProfile?.({
id: 'minted',
scope: 'isolated',
partition: 'persist:minted',
label: 'Workspace login',
source: null,
owner: { kind: 'worktree', id: WORKSPACE }
})
await vi.waitFor(() =>
expect(
store
.getState()
.browserTabsByWorktree[WORKSPACE]?.find(
(workspace) => workspace.id === opened.result.browserWorkspaceId
)
).toMatchObject({ sessionProfileId: 'minted', sessionPartition: null })
)
})
@@ -0,0 +1,28 @@
import type { AppState } from '@/store/types'
import { captureWorkspaceSelection, isWorkspaceSelected } from './active-workspace-host'
type SelectionState = Partial<
Pick<AppState, 'activeView' | 'activeWorktreeId' | 'activeWorkspaceExecutionHostId'>
>
export type BrowserLinkSelectionSubscriber = (
listener: (state: SelectionState) => void
) => () => void
/** Navigation relinquishes foreground ownership even if the user later returns. */
export function captureBrowserLinkReveal(
getState: () => SelectionState,
subscribe?: BrowserLinkSelectionSubscriber,
canReveal?: () => boolean
): { canReveal: () => boolean; dispose: () => void } {
const initial = getState()
const selection = captureWorkspaceSelection(initial)
const activeView = initial.activeView
let ownsSelection = true
const observe = (state: SelectionState): boolean => {
ownsSelection &&= isWorkspaceSelected(state, selection) && state.activeView === activeView
return ownsSelection
}
const dispose = subscribe?.(observe) ?? (() => {})
return { canReveal: () => observe(getState()) && canReveal?.() !== false, dispose }
}
@@ -0,0 +1,84 @@
import type { AppState } from '@/store/types'
import type { BrowserWorkspace } from '../../../shared/browser-workspace-types'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host'
import { ORCA_BROWSER_PARTITION } from '../../../shared/constants'
import { getOrcaProfileBrowserDefaultPartition } from '../../../shared/orca-profiles'
import { resolveNewBrowserTabProfile } from '@/store/slices/browser/browser-new-tab-profile'
import { getManagedBrowserProfileOwner } from '../../../shared/browser-profile-scope'
function createPartitionResolver(
state: AppState,
hostId: ExecutionHostId
): (profileId: string | null, explicit: string | null | undefined) => string | null {
const profiles = new Map(
(state.browserSessionProfilesByHostId[hostId] ?? []).map((profile) => [profile.id, profile])
)
const defaultPartition =
profiles.get('default')?.partition ??
(hostId === LOCAL_EXECUTION_HOST_ID
? ((state.activeOrcaProfileId
? getOrcaProfileBrowserDefaultPartition(state.activeOrcaProfileId)
: null) ?? ORCA_BROWSER_PARTITION)
: null)
return (id, explicit) => {
if (explicit) {
return explicit
}
if (id && !profiles.has(id)) {
return null
}
return (id ? profiles.get(id)?.partition : null) ?? defaultPartition
}
}
export function resolveBrowserLinkSession(
state: AppState,
worktreeId: string,
hostId: ExecutionHostId
): { sessionProfileId: string | null; sessionPartition: string | null } {
const hostKind = parseExecutionHostId(hostId)?.kind
const owner =
hostKind === 'local' || hostKind === 'ssh'
? getManagedBrowserProfileOwner(state.settings?.browserProfileScope, worktreeId)
: null
const profile = owner
? resolveNewBrowserTabProfile(state, worktreeId, hostId)
: { sessionProfileId: state.defaultBrowserSessionProfileIdByHostId[hostId] ?? null }
return {
sessionProfileId: profile.sessionProfileId,
sessionPartition: createPartitionResolver(state, hostId)(
profile.sessionProfileId,
profile.sessionPartition
)
}
}
export function createBrowserLinkSessionMatcher(
state: AppState,
hostId: ExecutionHostId,
profileId: string | null,
explicitPartition: string | null
): (workspace: BrowserWorkspace) => boolean {
const partitionFor = createPartitionResolver(state, hostId)
const expectedPartition = partitionFor(profileId, explicitPartition)
// The verified SSH route derives a partition from this target and profile even before its list loads.
const sshProfileRoute = parseExecutionHostId(hostId)?.kind === 'ssh' && explicitPartition === null
const matchesByWorkspace = new Map<string, boolean>()
return (workspace) => {
const cached = matchesByWorkspace.get(workspace.id)
if (cached !== undefined) {
return cached
}
// Absent legacy metadata does not prove the default session.
const matches =
workspace.sessionProfileId !== undefined &&
workspace.sessionProfileId === profileId &&
((expectedPartition !== null &&
partitionFor(workspace.sessionProfileId, workspace.sessionPartition) ===
expectedPartition) ||
(sshProfileRoute && !workspace.sessionPartition))
matchesByWorkspace.set(workspace.id, matches)
return matches
}
}
@@ -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 type { BrowserLinkPlacement } from './visible-browser-link-target'
import { requestBrowserFocus } from '@/components/browser-pane/host-guest/browser-focus'
export function openUrlInTabBesideBrowserPage(args: {
@@ -9,8 +10,9 @@ export function openUrlInTabBesideBrowserPage(args: {
worktreeId: string
url: string
activate: boolean
placement?: BrowserLinkPlacement
}): string | null {
const { sourcePageId, worktreeId, url, activate } = args
const { sourcePageId, worktreeId, url, activate, placement } = args
const store = useAppStore.getState()
const sourcePage = findPage(store.browserPagesByWorkspace, sourcePageId)
// Why: inherit the opener's cookie jar; the default profile would silently cross profiles.
@@ -22,6 +24,7 @@ export function openUrlInTabBesideBrowserPage(args: {
// Why: a Kagi private-session search URL carries a token; only the stored URL is redacted.
title: redactKagiSessionToken(url),
activate,
...placement,
...(sourceUnifiedTab ? { afterTabId: sourceUnifiedTab.id } : {}),
...(sourceUnifiedTab?.executionHostId
? { executionHostId: sourceUnifiedTab.executionHostId }
@@ -1,3 +1,7 @@
import {
isFloatingWorkspaceId,
floatingWorkspaceToWorktree
} from '../../../shared/floating-workspace-worktree'
import { useAppStore } from '@/store'
import type { Tab } from '../../../shared/tab-types'
import type { ExecutionHostId } from '../../../shared/execution-host'
@@ -21,7 +25,12 @@ export function getActivatableBrowserWorkspaceTab(params: BrowserWorkspaceTabTar
if (!params.executionHostId && ambiguousWorktreeIds.has(params.worktreeId)) {
return null
}
const worktree = state.getKnownWorktreeById(params.worktreeId, params.executionHostId)
const worktree =
state.getKnownWorktreeById(params.worktreeId, params.executionHostId) ??
(isFloatingWorkspaceId(params.worktreeId) &&
(!params.executionHostId || params.executionHostId === 'local')
? floatingWorkspaceToWorktree('')
: null)
if (!worktree) {
return null
}
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import { createBrowserLinkTestStore, WORKSPACE } from './browser-link-open.test-fixture'
import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../shared/constants'
import type { RuntimeStatus } from '../../../shared/runtime-types'
import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host'
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
@@ -14,6 +15,7 @@ import {
assertManagedBrowserMaterializationAllowed,
assertRuntimeManagedBrowserCreationAvailable,
getClientCreationActionPolicy,
getWorkspaceClientCreationActionPolicy,
resolveClientCreationActionPolicy,
runtimeHostsPagesOnThisDesktop,
type RuntimeBrowserHostingState
@@ -125,6 +127,26 @@ describe('resolveClientCreationActionPolicy', () => {
})
describe('client creation action guards', () => {
it.each([false, true])('does not read server focus for a workspace policy (web: %s)', (web) => {
vi.stubGlobal('__ORCA_WEB_CLIENT__', web)
const store = createBrowserLinkTestStore()
const settings = getDefaultSettings('/home/test')
Object.defineProperty(settings, 'activeRuntimeEnvironmentId', {
get: () => {
throw new Error('Workspace policy read server focus')
}
})
store.setState({ settings })
const policy = getWorkspaceClientCreationActionPolicy(store.getState(), WORKSPACE)
expect(policy['managed-browser']).toEqual(
web
? { state: 'hidden', reason: MANAGED_BROWSER_UNAVAILABLE_MESSAGE }
: { state: 'enabled', provider: 'local-client' }
)
expect(getClientCreationActionPolicy(store.getState(), WORKSPACE)).toEqual(policy)
})
it('fails closed for an older paired runtime and rejects local browser materialization', () => {
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
const state = {
@@ -72,13 +72,33 @@ export function resolveClientCreationActionPolicy(args: {
export function getClientCreationActionPolicy(
state: AppState,
worktreeId: string | null
): ClientCreationActionPolicy {
return worktreeId
? getWorkspaceClientCreationActionPolicy(state, worktreeId)
: creationPolicyForRuntime(
state,
state.settings?.activeRuntimeEnvironmentId?.trim() ?? null,
false
)
}
export function getWorkspaceClientCreationActionPolicy(
state: AppState,
worktreeId: string
): ClientCreationActionPolicy {
const floatingWorkspace = worktreeId === FLOATING_TERMINAL_WORKTREE_ID
const runtimeEnvironmentId = floatingWorkspace
? null
: worktreeId
? runtimeEnvironmentIdOrLocalUnlessTwin(state, worktreeId)
: (state.settings?.activeRuntimeEnvironmentId?.trim() ?? null)
return creationPolicyForRuntime(
state,
floatingWorkspace ? null : runtimeEnvironmentIdOrLocalUnlessTwin(state, worktreeId),
floatingWorkspace
)
}
function creationPolicyForRuntime(
state: AppState,
runtimeEnvironmentId: string | null,
floatingWorkspace: boolean
): ClientCreationActionPolicy {
const runtimeStatus = runtimeEnvironmentId
? (state.runtimeStatusByEnvironmentId?.get(runtimeEnvironmentId)?.status ?? null)
: null
@@ -15,7 +15,7 @@ const mocks = vi.hoisted(() => ({
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
vi.mock('@/lib/client-creation-action-policy', () => ({
getClientCreationActionPolicy: () => ({ 'managed-browser': mocks.browserAvailability })
getWorkspaceClientCreationActionPolicy: () => ({ 'managed-browser': mocks.browserAvailability })
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
runtimeEnvironmentIdOrLocalUnlessTwin: () => mocks.environmentId
@@ -0,0 +1,443 @@
import type * as BrowserFocusModule from '@/components/browser-pane/host-guest/browser-focus'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { createStoreCascadesMockApi } from '@/store/slices/store-cascades-test-harness'
import {
TEST_REPO,
makeTabGroup,
makeUnifiedTab,
makeWorktree
} from '@/store/slices/store-test-helpers'
import {
createBrowserLinkTestStore,
WORKSPACE,
type BrowserLinkTestStore
} from './browser-link-open.test-fixture'
import { openFileInBrowserTab } from './file-preview'
import { captureLinkOpenIntent } from './link-open-intent'
import {
buildDocPreviewGrantRequest,
ensureDocPreviewGrant,
releaseDocPreviewGrant
} from './doc-preview-grants'
import type { BrowserOpenOutcome } from './browser-link-destination'
import { openResolvedHtmlFileInBrowser } from '@/components/terminal-pane/terminal-file-link-browser-open'
import {
detectedListingFixture,
terminalTabFixture
} from '@/components/native-chat/native-chat-workspace-test-fixtures'
import { openPlannedFileBrowserLink } from './file-preview-link-open'
const mocks = vi.hoisted(() => ({ getState: vi.fn(), mint: vi.fn(), revoke: vi.fn() }))
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.getState() } }))
vi.mock('@/components/browser-pane/host-guest/browser-focus', async (importOriginal) => ({
...(await importOriginal<typeof BrowserFocusModule>()),
requestBrowserFocus: vi.fn()
}))
vi.mock('sonner', () => ({
toast: { error: vi.fn(), info: vi.fn(), success: vi.fn(), warning: vi.fn() }
}))
let store: BrowserLinkTestStore
const grants: string[] = []
beforeEach(() => {
createStoreCascadesMockApi()
Object.defineProperty(window.api, 'docPreview', {
value: { mintGrant: mocks.mint, revokeGrant: mocks.revoke },
configurable: true
})
mocks.mint.mockReset().mockImplementation(async (request: { browserPageId: string }) => ({
grantId: `grant-${request.browserPageId}`,
url: `orca-preview://grant-${request.browserPageId}/`
}))
mocks.revoke.mockReset().mockResolvedValue(undefined)
store = createBrowserLinkTestStore()
mocks.getState.mockImplementation(() => store.getState())
})
afterEach(() => {
for (const id of grants.splice(0)) {
releaseDocPreviewGrant(id)
}
})
it.each([
['local', false],
['ssh:detected-owner', false],
['ssh:detected-owner', true]
] as const)(
'opens a detected-only workspace preview beside its retained source on %s (selected twin: %s)',
async (host, selectedTwin) => {
store.setState((state) => ({
repos: state.repos.map((repo) => ({
...repo,
connectionId: host === 'local' ? null : 'detected-owner',
executionHostId: host
})),
worktreesByRepo: {},
detectedWorktreesByRepo: {
repo1: detectedListingFixture([
makeWorktree({ id: WORKSPACE, repoId: 'repo1', path: '/repo', hostId: host }),
...(selectedTwin
? [makeWorktree({ id: WORKSPACE, repoId: 'repo1', path: '/repo', hostId: 'local' })]
: [])
])
},
activeWorktreeId: selectedTwin ? WORKSPACE : null,
activeWorkspaceExecutionHostId: selectedTwin ? host : null,
tabsByWorktree: {
[WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) =>
terminalTabFixture(tab.entityId, WORKSPACE)
)
},
unifiedTabsByWorktree: {
[WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) => ({
...tab,
executionHostId: host
}))
}
}))
const outcome = await new Promise<BrowserOpenOutcome>((resolve) =>
openFileInBrowserTab({
filePath: '/repo/report.html',
worktreeId: WORKSPACE,
fileExecutionHostId: host,
linkContext: { linkIntent: captureLinkOpenIntent(store.getState(), WORKSPACE, 'source') },
onLinkOutcome: resolve
})
)
expect(outcome).toMatchObject({
status: 'opened',
result: { worktreeId: WORKSPACE, executionHostId: host }
})
if (outcome.status !== 'opened') {
throw new Error('Expected a detected-only workspace preview')
}
const page = store
.getState()
.browserPagesByWorkspace[outcome.result.browserWorkspaceId]?.find(
(candidate) => candidate.id === outcome.result.browserPageId
)
expect(page).toMatchObject(
host === 'local'
? { url: 'file:///repo/report.html' }
: {
docLocation: {
kind: 'workspace-doc',
worktreeId: WORKSPACE,
filePath: '/repo/report.html'
}
}
)
expect(store.getState().activeGroupIdByWorktree[WORKSPACE]).toBe(outcome.result.groupId)
expect(
store.getState().groupsByWorktree[WORKSPACE]?.find((group) => group.id === 'a')?.activeTabId
).toBe('source')
}
)
it('refuses a detected preview whose owner changed despite a retained active host selection', async () => {
store.setState((state) => ({
worktreesByRepo: {},
detectedWorktreesByRepo: {
repo1: detectedListingFixture(state.worktreesByRepo.repo1!)
}
}))
const initial = store.getState()
const linkIntent = captureLinkOpenIntent(initial, WORKSPACE, 'source')
store.setState({
detectedWorktreesByRepo: {
repo1: detectedListingFixture([
makeWorktree({
id: WORKSPACE,
repoId: 'repo1',
path: '/repo',
hostId: 'ssh:changed-owner'
})
])
}
})
const outcome = await openPlannedFileBrowserLink(
initial,
{ worktreeId: WORKSPACE, filePath: '/repo/report.html', fileExecutionHostId: 'local' },
{ status: 'browser-tab', url: 'file:///repo/report.html', title: 'report.html' },
{ linkIntent },
() => true
)
expect(outcome).toEqual({ status: 'refused' })
expect(store.getState().browserPagesByWorkspace).toEqual({})
expect(store.getState().activeGroupIdByWorktree[WORKSPACE]).toBe('c')
})
it('SSH document links reuse the source page and its grant without a website-browser capability', async () => {
const host = 'ssh:ssh-test'
store.setState((state) => ({
repos: state.repos.map((repo) => ({
...repo,
connectionId: 'ssh-test',
executionHostId: host
})),
worktreesByRepo: {
repo1: state.worktreesByRepo.repo1!.map((worktree) => ({ ...worktree, hostId: host }))
},
activeWorkspaceExecutionHostId: host,
unifiedTabsByWorktree: {
[WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) => ({
...tab,
executionHostId: host
}))
},
runtimeStatusByEnvironmentId: new Map()
}))
const docLocation = {
kind: 'workspace-doc' as const,
worktreeId: WORKSPACE,
filePath: '/repo/doc.html'
}
const original = store.getState().createBrowserTab(WORKSPACE, '', {
targetGroupId: 'a',
executionHostId: host,
browserRuntimeEnvironmentId: null,
docLocation,
activate: false
})
let outcome: BrowserOpenOutcome | undefined
let plan: ReturnType<typeof openFileInBrowserTab> | undefined
await new Promise<void>((resolve) => {
plan = openFileInBrowserTab({
filePath: docLocation.filePath,
worktreeId: WORKSPACE,
linkContext: { linkIntent: captureLinkOpenIntent(store.getState(), WORKSPACE, 'source') },
onLinkOutcome: (value) => {
outcome = value
resolve()
}
})
})
expect(plan?.status).toBe('doc-preview')
expect(outcome).toMatchObject({
status: 'opened',
result: { groupId: 'a', executionHostId: host }
})
if (outcome?.status !== 'opened' || !original.activePageId) {
throw new Error('Expected preview pages')
}
expect(outcome.result.browserPageId).toBe(original.activePageId)
const location = buildDocPreviewGrantRequest(store.getState(), WORKSPACE, docLocation.filePath)
if (!location) {
throw new Error('Expected grant owner')
}
grants.push(original.activePageId)
const first = await ensureDocPreviewGrant(original.activePageId, location)
expect(await ensureDocPreviewGrant(outcome.result.browserPageId, location)).toBe(first)
expect(mocks.mint).toHaveBeenCalledTimes(1)
store.getState().closeBrowserTab(original.id)
await Promise.resolve()
await Promise.resolve()
expect(mocks.revoke).toHaveBeenCalledExactlyOnceWith(first.grantId)
})
it('a folder workspace uses its own layout and activates its browser without Git metadata', async () => {
const folder = 'folder:folder-1'
store.setState({
folderWorkspaces: [
{
id: 'folder-1',
projectGroupId: 'group',
name: 'Folder',
folderPath: '/folder',
executionHostId: 'local',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
createdAt: 0,
updatedAt: 0
}
],
groupsByWorktree: {
[folder]: ['left', 'right'].map((id) =>
makeTabGroup({
id,
worktreeId: folder,
activeTabId: id === 'left' ? 'chat' : 'terminal',
tabOrder: [id === 'left' ? 'chat' : 'terminal']
})
)
},
unifiedTabsByWorktree: {
[folder]: [
makeUnifiedTab({
id: 'chat',
contentType: 'terminal',
entityId: 'chat-terminal',
worktreeId: folder,
groupId: 'left',
executionHostId: 'local'
}),
makeUnifiedTab({
id: 'terminal',
entityId: 'other-terminal',
worktreeId: folder,
groupId: 'right',
executionHostId: 'local'
})
]
},
tabsByWorktree: {
[folder]: [
terminalTabFixture('chat-terminal', folder),
terminalTabFixture('other-terminal', folder)
]
},
activeGroupIdByWorktree: { [folder]: 'left' },
layoutByWorktree: {
[folder]: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'left' },
second: { type: 'leaf', groupId: 'right' }
}
},
worktreesByRepo: {}
})
const outcome = vi.fn()
await new Promise((resolve) =>
openFileInBrowserTab({
filePath: '/folder/report.html',
worktreeId: folder,
linkContext: { linkIntent: captureLinkOpenIntent(store.getState(), folder, 'chat') },
onLinkOutcome: (result) => {
outcome(result)
resolve(result)
}
})
)
expect(outcome).toHaveBeenCalledWith(
expect.objectContaining({
status: 'opened',
result: expect.objectContaining({ worktreeId: folder })
})
)
expect(['left', 'right']).not.toContain(store.getState().activeGroupIdByWorktree[folder])
expect(store.getState().layoutByWorktree[folder]).toMatchObject({
first: { type: 'split', first: { groupId: 'left' } }
})
})
it.each([false, true])(
'refuses a replaced file owner for a captured preview (explicit: %s)',
async (explicit) => {
const linkIntent = captureLinkOpenIntent(store.getState(), WORKSPACE, 'source', {
explicitSourceGroup: explicit
})
const host = 'ssh:changed-owner'
store.setState((state) => ({
repos: state.repos.map((repo) => ({
...repo,
connectionId: 'changed-owner',
executionHostId: host
})),
worktreesByRepo: {
repo1: state.worktreesByRepo.repo1!.map((worktree) => ({ ...worktree, hostId: host }))
},
activeWorkspaceExecutionHostId: host
}))
const outcome = vi.fn()
await new Promise((resolve) =>
openFileInBrowserTab({
filePath: '/repo/index.html',
worktreeId: WORKSPACE,
linkContext: { linkIntent },
onLinkOutcome: (result) => {
outcome(result)
resolve(result)
}
})
)
expect(outcome).toHaveBeenCalledExactlyOnceWith({ status: 'refused' })
expect(store.getState().browserPagesByWorkspace).toEqual({})
expect(store.getState().activeGroupIdByWorktree[WORKSPACE]).toBe('c')
}
)
it.each([true, false])(
'validates the resolved file owner independently of a different source workspace (owner matches: %s)',
async (ownerMatches) => {
const destination = 'repo2::/other'
const host = 'ssh:file-owner'
const linkIntent = captureLinkOpenIntent(store.getState(), WORKSPACE, 'source')
store.setState((state) => ({
repos: [
...state.repos,
{ ...TEST_REPO, id: 'repo2', connectionId: 'file-owner', executionHostId: host }
],
worktreesByRepo: {
...state.worktreesByRepo,
repo2: [makeWorktree({ id: destination, repoId: 'repo2', path: '/other', hostId: host })]
}
}))
const outcome = vi.fn()
await new Promise((resolve) =>
openFileInBrowserTab({
filePath: '/other/report.html',
worktreeId: destination,
fileExecutionHostId: ownerMatches ? host : 'local',
linkContext: { linkIntent },
onLinkOutcome: (result) => {
outcome(result)
resolve(result)
}
})
)
if (!ownerMatches) {
expect(outcome).toHaveBeenCalledExactlyOnceWith({ status: 'refused' })
expect(store.getState().browserPagesByWorkspace).toEqual({})
expect(store.getState().activeWorktreeId).toBe(WORKSPACE)
return
}
expect(outcome).toHaveBeenCalledWith(
expect.objectContaining({
status: 'opened',
result: expect.objectContaining({ worktreeId: destination, executionHostId: host })
})
)
}
)
it('keeps deliberate client-local HTML discovery from a remote source available', async () => {
const host = 'ssh:remote-source'
store.setState((state) => ({
repos: state.repos.map((repo) => ({
...repo,
connectionId: 'remote-source',
executionHostId: host
})),
worktreesByRepo: {
repo1: state.worktreesByRepo.repo1!.map((worktree) => ({ ...worktree, hostId: host }))
},
activeWorkspaceExecutionHostId: host,
unifiedTabsByWorktree: {
[WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) => ({
...tab,
executionHostId: host
}))
}
}))
const outcome = await openResolvedHtmlFileInBrowser({
filePath: '/tmp/client-report.html',
worktreeId: WORKSPACE,
clientLocal: true,
linkContext: { linkIntent: captureLinkOpenIntent(store.getState(), WORKSPACE, 'source') }
})
expect(outcome).toMatchObject({ status: 'opened', result: { worktreeId: WORKSPACE } })
if (outcome?.status !== 'opened') {
throw new Error('Expected client-local preview')
}
expect(
store.getState().browserPagesByWorkspace[outcome.result.browserWorkspaceId]?.[0]
).toMatchObject({
url: 'file:///tmp/client-report.html',
browserRuntimeEnvironmentId: null
})
})
@@ -0,0 +1,88 @@
import { resolveBrowserLinkSession } from './browser-link-session'
import { activateAndRevealWorkspace } from './worktree-activation'
import type { AppState } from '@/store/types'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
import { ORCA_BROWSER_BLANK_URL } from '../../../shared/constants'
import type { WorkspaceFilePreviewPlan } from './file-preview'
import type { BrowserLinkOpenContext, BrowserOpenOutcome } from './browser-link-destination'
import { openLocalBrowserLink } from './browser-link-open'
import { getResolvedExecutionHostIdForWorktree } from './resolved-worktree-execution-host'
import { createBrowserLinkHostMatcher } from './browser-link-owner'
import { resolveWorktreeOperationRoute } from './worktree-operation-route'
import { selectDetectedWorktreesByRepo } from '@/store/workspace-record-selectors'
import { hasIndexedDetectedWorktree } from './worktree-runtime-owner-index'
export type FileBrowserLinkTarget = {
filePath: string
worktreeId: string
fileExecutionHostId?: ExecutionHostId
}
function resolveFileBrowserLinkExecutionHostId(
state: AppState,
worktreeId: string
): ExecutionHostId | null {
const executionHostId = getResolvedExecutionHostIdForWorktree(state, worktreeId)
if (
executionHostId ||
!hasIndexedDetectedWorktree(selectDetectedWorktreesByRepo(state), worktreeId)
) {
return executionHostId
}
const route = resolveWorktreeOperationRoute(state, worktreeId)
// A retained active host selection alone cannot prove a detected file's current owner.
return route?.executionHostId && state.getKnownWorktreeById(worktreeId, route.executionHostId)
? route.executionHostId
: null
}
export async function openPlannedFileBrowserLink(
state: AppState,
params: FileBrowserLinkTarget,
plan: Exclude<WorkspaceFilePreviewPlan, { status: 'unsupported' }>,
context: BrowserLinkOpenContext,
ownsPreviewRoute: (state: AppState) => boolean
): Promise<BrowserOpenOutcome> {
const executionHostId = resolveFileBrowserLinkExecutionHostId(state, params.worktreeId)
if (
!executionHostId ||
(params.fileExecutionHostId &&
!createBrowserLinkHostMatcher(
state,
params.worktreeId,
executionHostId
)(params.fileExecutionHostId))
) {
return { status: 'refused' }
}
const docLocation = {
kind: 'workspace-doc' as const,
worktreeId: params.worktreeId,
filePath: params.filePath
}
return openLocalBrowserLink({
context,
destination: {
worktreeId: params.worktreeId,
executionHostId,
browserRuntimeEnvironmentId: null,
profileHostId: LOCAL_EXECUTION_HOST_ID,
...resolveBrowserLinkSession(state, params.worktreeId, LOCAL_EXECUTION_HOST_ID),
resource:
plan.status === 'doc-preview'
? { kind: 'document', docLocation }
: { kind: 'local-file', url: plan.url }
},
url: plan.status === 'doc-preview' ? ORCA_BROWSER_BLANK_URL : plan.url,
options: plan.status === 'doc-preview' ? { docLocation } : { title: plan.title },
reveal: () => {
activateAndRevealWorkspace(params.worktreeId, {
providesInitialSurface: true,
executionHostId
})
},
ownsRoute: (current) =>
resolveFileBrowserLinkExecutionHostId(current, params.worktreeId) === executionHostId &&
ownsPreviewRoute(current)
})
}
@@ -0,0 +1,94 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { activateBrowserWorkspaceTab } from './browser-workspace-tab-activation'
import { selectWorkspaceSurface } from './workspace-surface-reveal'
import { webClientDocPreviewRefusal } from './doc-preview-web-client'
import { openFileInBrowserTab } from './file-preview'
import { findPage } from '@/store/slices/browser-page-records'
import type { BrowserPageConversionLeg } from '@/store/slices/browser-page-conversion'
import type { BrowserPageDocLocation } from '../../../shared/browser-workspace-types'
import { browserPageDocLocationsEqual } from '../../../shared/browser-page-doc-location'
/** The tab a document is already open in, found by every PAGE's docLocation and not just the
* workspace mirror — a mixed workspace whose doc page is inactive mirrors null. */
export function findWorkspaceShowingDoc(
state: AppState,
docLocation: BrowserPageDocLocation
): { workspaceId: string; pageId: string } | null {
for (const tab of state.browserTabsByWorktree[docLocation.worktreeId] ?? []) {
const page = (state.browserPagesByWorkspace[tab.id] ?? []).find((candidate) =>
browserPageDocLocationsEqual(candidate.docLocation ?? null, docLocation)
)
if (page) {
return { workspaceId: tab.id, pageId: page.id }
}
}
return null
}
/**
* The address bar's way into a workspace document: reuse before converting. A document already on
* screen is a request to look at it — two tabs of one document would each hold their own grant on
* the same file — so an existing tab wins and the current page stays what it was; only otherwise
* does the page convert in place.
*/
export function convertBrowserPageToWorkspaceDoc(
pageId: string,
docLocation: BrowserPageDocLocation,
options?: { leg?: BrowserPageConversionLeg }
): 'activated-existing' | 'opened-in-owning-worktree' | 'converted' | 'failed' {
const webRefusal = webClientDocPreviewRefusal()
if (webRefusal) {
toast.error(webRefusal.message)
return 'failed'
}
const state = useAppStore.getState()
// Why a history leg skips reuse: Back and Forward both mean "this tab, as it was" — activating
// another tab showing the document would leave this one a web page with live provenance, so
// history could jump there forever. A history leg's document was this tab's own, so converting
// in place is right.
const isHistoryLeg = options?.leg !== undefined
const existing = isHistoryLeg ? null : findWorkspaceShowingDoc(state, docLocation)
if (existing) {
// Why the worktree switches first: activation is deliberately scoped to the active worktree,
// so without the switch a cross-worktree reuse would happen entirely out of sight.
if (state.activeWorktreeId !== docLocation.worktreeId) {
selectWorkspaceSurface(state, docLocation.worktreeId)
}
if (
!activateBrowserWorkspaceTab({
worktreeId: docLocation.worktreeId,
workspaceId: existing.workspaceId
})
) {
state.setActiveBrowserTab(existing.workspaceId)
}
state.setActiveBrowserPage(existing.workspaceId, existing.pageId)
return 'activated-existing'
}
// Why another worktree's document opens a tab there instead of converting this one: a converted
// page keeps its workspace row, and a row whose worktree differs from its document's can never
// be the reader's surface under the per-worktree activity slots — its guest would never take
// focus, and every link in the document would be a dead end.
const owningPage = findPage(state.browserPagesByWorkspace, pageId)
if (!isHistoryLeg && owningPage && owningPage.worktreeId !== docLocation.worktreeId) {
// The reader follows the document to its worktree; opening it out of sight is indistinguishable
// from nothing having happened.
if (state.activeWorktreeId !== docLocation.worktreeId) {
selectWorkspaceSurface(state, docLocation.worktreeId)
}
const plan = openFileInBrowserTab({
filePath: docLocation.filePath,
worktreeId: docLocation.worktreeId
})
if (plan.status === 'unsupported') {
toast.error(plan.message)
return 'failed'
}
return 'opened-in-owning-worktree'
}
return state.convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation }, options)
? 'converted'
: 'failed'
}
+1 -1
View File
@@ -48,7 +48,7 @@ const mocks = vi.hoisted(() => ({
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
vi.mock('@/lib/client-creation-action-policy', () => ({
getClientCreationActionPolicy: () => ({ 'managed-browser': mocks.browserAvailability })
getWorkspaceClientCreationActionPolicy: () => ({ 'managed-browser': mocks.browserAvailability })
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
+62 -84
View File
@@ -1,6 +1,8 @@
import { openPlannedFileBrowserLink, type FileBrowserLinkTarget } from './file-preview-link-open'
import type { BrowserLinkOpenContext, BrowserOpenOutcome } from './browser-link-destination'
import { toast } from 'sonner'
import { absolutePathToFileUri } from '@/components/editor/markdown-internal-links'
import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy'
import { getWorkspaceClientCreationActionPolicy } from '@/lib/client-creation-action-policy'
import { webClientDocPreviewRefusal } from '@/lib/doc-preview-web-client'
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
@@ -13,19 +15,17 @@ import {
REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE,
TWIN_FILE_BROWSER_UNSUPPORTED_MESSAGE
} from '@/lib/workspace-file-browser-open-target'
import { selectWorkspaceSurface } from '@/lib/workspace-surface-reveal'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { findSiblingGroupId } from '@/store/slices/tabs'
import type { CreateBrowserTabOptions } from '@/store/slices/browser/browser-slice-contract'
import { browserPageDocLocationsEqual } from '../../../shared/browser-page-doc-location'
import type { BrowserPageDocLocation } from '../../../shared/browser-workspace-types'
import { findPage } from '@/store/slices/browser-page-records'
import type { BrowserPageConversionLeg } from '@/store/slices/browser-page-conversion'
import { ORCA_BROWSER_BLANK_URL } from '../../../shared/constants'
import { chatScratchFileAddress } from '@/runtime/runtime-chat-scratch-file-address'
import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors'
import { findWorkspaceShowingDoc } from './file-preview-page-conversion'
export { convertBrowserPageToWorkspaceDoc } from './file-preview-page-conversion'
export type PreviewableLanguage = 'html'
@@ -37,6 +37,22 @@ function pairedOutsideWorktreeMessage(): string {
)
}
function getLocalFilePreviewPlan(
state: AppState,
worktreeId: string,
filePath: string
): WorkspaceFilePreviewPlan {
const availability = getWorkspaceClientCreationActionPolicy(state, worktreeId)['managed-browser']
if (availability.state !== 'enabled') {
return { status: 'unsupported', message: availability.reason, reason: 'no-channel' }
}
return {
status: 'browser-tab',
url: absolutePathToFileUri(filePath),
title: basename(filePath) || filePath
}
}
/** Use embedded browser tabs locally; remote documents require this desktop’s granted orca-preview scheme because client guests refuse file: and local file URLs would read the wrong machine. */
export type WorkspaceFilePreviewPlan =
| { status: 'browser-tab'; url: string; title: string }
@@ -46,8 +62,12 @@ export type WorkspaceFilePreviewPlan =
export function getWorkspaceFilePreviewPlan(
state: AppState,
worktreeId: string,
filePath: string
filePath: string,
clientLocal = false
): WorkspaceFilePreviewPlan {
if (clientLocal) {
return getLocalFilePreviewPlan(state, worktreeId, filePath)
}
// Why: a twin reads as "no server" below, which would open this machine's copy of the path.
if (isTwinWorkspace(state, worktreeId)) {
return {
@@ -82,7 +102,7 @@ export function getWorkspaceFilePreviewPlan(
}
return webClientDocPreviewRefusal() ?? { status: 'doc-preview' }
}
const availability = getClientCreationActionPolicy(state, worktreeId)['managed-browser']
const availability = getWorkspaceClientCreationActionPolicy(state, worktreeId)['managed-browser']
if (availability.state !== 'enabled') {
return { status: 'unsupported', message: availability.reason, reason: 'no-channel' }
}
@@ -110,7 +130,7 @@ export function useWorkspaceFileBrowserActionPredicate(
useAppStore(
useShallow((state) => ({
managedBrowser: worktreeId
? getClientCreationActionPolicy(state, worktreeId)['managed-browser'].state
? getWorkspaceClientCreationActionPolicy(state, worktreeId)['managed-browser'].state
: null,
runtimeEnvironmentId: worktreeId
? runtimeEnvironmentIdOrLocalUnlessTwin(state, worktreeId)
@@ -166,14 +186,39 @@ function openDocPreviewTab(
})
}
export function openFileInBrowserTab(params: {
filePath: string
worktreeId: string
targetGroupId?: string
}): WorkspaceFilePreviewPlan {
export function openFileInBrowserTab(
params: FileBrowserLinkTarget & {
targetGroupId?: string
clientLocal?: boolean
linkContext?: BrowserLinkOpenContext
onLinkOutcome?: (outcome: BrowserOpenOutcome) => void
}
): WorkspaceFilePreviewPlan {
const state = useAppStore.getState()
const plan = getWorkspaceFilePreviewPlan(state, params.worktreeId, params.filePath)
const plan = getWorkspaceFilePreviewPlan(
state,
params.worktreeId,
params.filePath,
params.clientLocal
)
if (plan.status === 'unsupported') {
params.onLinkOutcome?.({ status: 'unsupported' })
return plan
}
if (params.linkContext) {
void openPlannedFileBrowserLink(state, params, plan, params.linkContext, (current) => {
const currentPlan = getWorkspaceFilePreviewPlan(
current,
params.worktreeId,
params.filePath,
params.clientLocal
)
return (
currentPlan.status === plan.status &&
(currentPlan.status !== 'browser-tab' ||
(plan.status === 'browser-tab' && currentPlan.url === plan.url))
)
}).then((outcome) => params.onLinkOutcome?.(outcome))
return plan
}
if (plan.status === 'doc-preview') {
@@ -184,79 +229,12 @@ export function openFileInBrowserTab(params: {
state.createBrowserTab(params.worktreeId, plan.url, {
title: plan.title,
...(params.targetGroupId ? { targetGroupId: params.targetGroupId } : {}),
activate: true
activate: true,
...(params.clientLocal ? { browserRuntimeEnvironmentId: null } : {})
})
return plan
}
/** Find documents by each page’s docLocation; mixed workspace mirrors can be null while an inactive document page exists. */
function findWorkspaceShowingDoc(
state: AppState,
docLocation: BrowserPageDocLocation
): { workspaceId: string; pageId: string } | null {
for (const tab of state.browserTabsByWorktree[docLocation.worktreeId] ?? []) {
const page = (state.browserPagesByWorkspace[tab.id] ?? []).find((candidate) =>
browserPageDocLocationsEqual(candidate.docLocation ?? null, docLocation)
)
if (page) {
return { workspaceId: tab.id, pageId: page.id }
}
}
return null
}
/** Address-bar document opens reuse an existing tab/grant before converting the current page. */
export function convertBrowserPageToWorkspaceDoc(
pageId: string,
docLocation: BrowserPageDocLocation,
options?: { leg?: BrowserPageConversionLeg }
): 'activated-existing' | 'opened-in-owning-worktree' | 'converted' | 'failed' {
const webRefusal = webClientDocPreviewRefusal()
if (webRefusal) {
toast.error(webRefusal.message)
return 'failed'
}
const state = useAppStore.getState()
// History must restore this tab in place; reusing another tab leaves conversion provenance here and can loop forever.
const isHistoryLeg = options?.leg !== undefined
const existing = isHistoryLeg ? null : findWorkspaceShowingDoc(state, docLocation)
if (existing) {
// Switch worktrees before scoped activation or cross-worktree reuse would remain out of sight.
if (state.activeWorktreeId !== docLocation.worktreeId) {
selectWorkspaceSurface(state, docLocation.worktreeId)
}
if (
!activateBrowserWorkspaceTab({
worktreeId: docLocation.worktreeId,
workspaceId: existing.workspaceId
})
) {
state.setActiveBrowserTab(existing.workspaceId)
}
state.setActiveBrowserPage(existing.workspaceId, existing.pageId)
return 'activated-existing'
}
// A converted page retains its workspace; open cross-worktree documents in their owner so guest focus and links work.
const owningPage = findPage(state.browserPagesByWorkspace, pageId)
if (!isHistoryLeg && owningPage && owningPage.worktreeId !== docLocation.worktreeId) {
if (state.activeWorktreeId !== docLocation.worktreeId) {
selectWorkspaceSurface(state, docLocation.worktreeId)
}
const plan = openFileInBrowserTab({
filePath: docLocation.filePath,
worktreeId: docLocation.worktreeId
})
if (plan.status === 'unsupported') {
toast.error(plan.message)
return 'failed'
}
return 'opened-in-owning-worktree'
}
return state.convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation }, options)
? 'converted'
: 'failed'
}
export function canPreviewLanguage(language: string): language is PreviewableLanguage {
return language === 'html'
}
@@ -7,6 +7,7 @@ import { requestBrowserFocus } from '@/components/browser-pane/host-guest/browse
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 { findVisibleBrowserLinkTarget } from './visible-browser-link-target'
import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree'
export type HttpLinkBrowserPlacement = { targetGroupId: string } | { split: TabSplitTarget }
@@ -17,7 +18,7 @@ export type HttpLinkBrowserTabState = Partial<
groupsByWorktree?: Record<string, readonly Pick<TabGroup, 'id' | 'activeTabId'>[]>
unifiedTabsByWorktree?: Record<
string,
readonly (Pick<Tab, 'id'> & Partial<Pick<Tab, 'contentType'>>)[]
readonly (Pick<Tab, 'id'> & Partial<Pick<Tab, 'contentType' | 'groupId' | 'lastFocusedAt'>>)[]
>
settings?: Partial<Pick<GlobalSettings, 'openLinksInAppInBackground'>> | null
createBrowserTab: (
@@ -37,15 +38,12 @@ 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 visibleBrowser = findVisibleBrowserLinkTarget(state, worktreeId)
if (visibleBrowser) {
return visibleBrowser
}
const groups = state.groupsByWorktree?.[worktreeId] ?? []
const visibleGroupIds = liveLayoutGroupIds(state, worktreeId)
const sourceGroupId = state.activeGroupIdByWorktree?.[worktreeId]
if (!sourceGroupId || !visibleGroupIds.has(sourceGroupId)) {
return null
@@ -68,11 +66,11 @@ export function openLocalHttpLinkTab(
state: HttpLinkBrowserTabState,
worktreeId: string,
url: string,
placement = resolveHttpLinkBrowserPlacement(state, worktreeId)
placement = resolveHttpLinkBrowserPlacement(state, worktreeId),
activate = !isHttpLinkBackgroundOpen(state)
): void {
const background = isHttpLinkBackgroundOpen(state)
const tab = state.createBrowserTab(worktreeId, url, { activate: !background, ...placement })
if (background) {
const tab = state.createBrowserTab(worktreeId, url, { activate, ...placement })
if (!activate) {
return
}
showWorkspaceTabs(state, worktreeId)
@@ -99,7 +97,7 @@ export function prepareRemoteHttpLinkTab(
}
/** A link clicked in Agents View lands in the workspace's tab layout, so bring that layout on screen. */
function showWorkspaceTabs(
export function showWorkspaceTabs(
state: Pick<HttpLinkBrowserTabState, 'activeView' | 'setActiveView'>,
worktreeId: string
): void {
+61 -5
View File
@@ -1,5 +1,12 @@
import { showOpenInGroupRefusedToast } from '@/components/terminal-pane/terminal-file-open-in-group-toast'
import { beginLinkOpenRequest } from './link-open-request'
import { translate } from '@/i18n/i18n'
import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
import {
openHttpLink,
captureHttpLinkIntent,
canHttpLinkOpenInSourceGroup,
type HttpLinkSourceOwner
} from '@/lib/http-link-routing'
// Keep original terminal catalog keys: their shipped translations are now shared with native chat.
@@ -62,8 +69,15 @@ export function httpLinkDestinationLabel(destination: HttpLinkDestination): stri
export function buildHttpLinkActions(
destinations: HttpLinkActionDestinations | undefined,
open: (destination: HttpLinkDestination | undefined) => void | Promise<void>
): { primary: HttpLinkAction; alternate?: HttpLinkAction } {
open: (destination: HttpLinkDestination | undefined) => void | Promise<void>,
source?: {
worktreeId: string
url?: string
sourceOwner?: HttpLinkSourceOwner
sourceTabId?: string | null
openInSourceGroup: () => void | Promise<void>
}
): { primary: HttpLinkAction; alternate?: HttpLinkAction; secondaryActions?: HttpLinkAction[] } {
const primaryDestination = destinations?.primary
const primary: HttpLinkAction = {
external: primaryDestination === 'system',
@@ -72,12 +86,32 @@ export function buildHttpLinkActions(
: translate('auto.components.terminal.pane.TerminalLinkActionPopover.openLink', 'Open link'),
run: () => open(primaryDestination)
}
const secondaryActions =
source &&
(destinations?.primary === 'orca' || destinations?.alternate === 'orca') &&
canHttpLinkOpenInSourceGroup(
source.worktreeId,
source.sourceTabId,
source.url,
source.sourceOwner
)
? [
{
label: translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.openInThisGroup',
'Open in this group'
),
run: source.openInSourceGroup
}
]
: undefined
const alternateDestination = destinations?.alternate
if (!alternateDestination) {
return { primary }
return { primary, ...(secondaryActions ? { secondaryActions } : {}) }
}
return {
primary,
...(secondaryActions ? { secondaryActions } : {}),
alternate: {
external: alternateDestination === 'system',
label: httpLinkDestinationLabel(alternateDestination),
@@ -92,6 +126,8 @@ export type HttpLinkRoutingPreferenceRequester = (
export type RoutedHttpLinkOptions = {
worktreeId: string
sourceTabId?: string | null
openInSourceGroup?: boolean
sourceOwner?: HttpLinkSourceOwner
modifierHeld?: boolean
forceDestination?: HttpLinkDestination
@@ -99,10 +135,22 @@ export type RoutedHttpLinkOptions = {
}
export function openRoutedHttpLink(url: string, deps: RoutedHttpLinkOptions): void {
const isCurrentRequest = beginLinkOpenRequest()
const linkIntent = captureHttpLinkIntent(
deps.worktreeId,
deps.sourceTabId,
deps.openInSourceGroup
)
if (deps.openInSourceGroup && !linkIntent) {
showOpenInGroupRefusedToast(url)
return
}
const linkContext = deps.sourceTabId ? { linkIntent, isCurrentRequest } : undefined
// Why: the clicked link's owner beats the global active runtime for both local and remote routes.
const sourceOwner = deps.sourceOwner ?? { kind: 'local' }
if (deps.forceDestination) {
openHttpLink(url, {
...(linkContext ? { linkContext } : {}),
allowRemoteInApp: true,
worktreeId: deps.worktreeId,
forceInApp: deps.forceDestination === 'orca',
@@ -114,6 +162,7 @@ export function openRoutedHttpLink(url: string, deps: RoutedHttpLinkOptions): vo
if (deps.modifierHeld) {
// Explicit modifier routing skips the one-time preference prompt; openHttpLink resolves its destination.
openHttpLink(url, {
...(linkContext ? { linkContext } : {}),
allowRemoteInApp: true,
worktreeId: deps.worktreeId,
modifierHeld: true,
@@ -126,7 +175,12 @@ export function openRoutedHttpLink(url: string, deps: RoutedHttpLinkOptions): vo
const preferenceDecision =
sourceOwner.kind === 'local' ? deps.requestOpenLinksInAppPreference?.(url) : null
if (preferenceDecision === null || preferenceDecision === undefined) {
openHttpLink(url, { allowRemoteInApp: true, worktreeId: deps.worktreeId, sourceOwner })
openHttpLink(url, {
...(linkContext ? { linkContext } : {}),
allowRemoteInApp: true,
worktreeId: deps.worktreeId,
sourceOwner
})
return
}
@@ -134,6 +188,7 @@ export function openRoutedHttpLink(url: string, deps: RoutedHttpLinkOptions): vo
void Promise.resolve(preferenceDecision)
.then((openInOrca) => {
openHttpLink(url, {
...(linkContext ? { linkContext } : {}),
allowRemoteInApp: true,
worktreeId: deps.worktreeId,
forceSystemBrowser: !openInOrca,
@@ -142,6 +197,7 @@ export function openRoutedHttpLink(url: string, deps: RoutedHttpLinkOptions): vo
})
.catch(() => {
openHttpLink(url, {
...(linkContext ? { linkContext } : {}),
allowRemoteInApp: true,
worktreeId: deps.worktreeId,
forceSystemBrowser: true,
@@ -0,0 +1,98 @@
import { resolveBrowserLinkSession } from './browser-link-session'
import { createBrowserLinkHostMatcher } from './browser-link-owner'
import type { AppState } from '@/store/types'
import type { HttpLinkGroupActionRequest } from './http-link-routing'
import { captureLinkOpenIntent } from './link-open-intent'
import { getWorkspaceClientCreationActionPolicy } from './client-creation-action-policy'
import {
getFloatingWorkspaceOperationRoute,
resolveWorktreeOperationRoute
} from './worktree-operation-route'
import {
isExpectedRuntimeBrowserRoute,
isSshDeploymentClientBrowserRoute,
isExpectedSshBrowserRoute
} from './workspace-browser-route-eligibility'
import { selectBrowserLinkDestination } from './browser-link-destination'
import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../../shared/execution-host'
/** Strict local creation is supported; an unproven runtime route can only reuse a pinned match. */
export function canOfferHttpLinkGroupAction(
state: AppState,
request: HttpLinkGroupActionRequest
): boolean {
const availability = getWorkspaceClientCreationActionPolicy(state, request.workspaceId)[
'managed-browser'
]
const route =
getFloatingWorkspaceOperationRoute(request.workspaceId) ??
resolveWorktreeOperationRoute(state, request.workspaceId)
const intent = captureLinkOpenIntent(state, request.workspaceId, request.sourceTabId, {
explicitSourceGroup: true
})
if (
!intent?.targetGroupId ||
!route?.executionHostId ||
!createBrowserLinkHostMatcher(
state,
request.workspaceId,
route.executionHostId
)(intent.hostId) ||
availability.state !== 'enabled'
) {
return false
}
const owner = request.sourceOwner
if (owner?.kind === 'unknown') {
return false
}
if (owner?.kind === 'ssh') {
return isExpectedSshBrowserRoute(
state,
availability,
route,
request.workspaceId,
owner.connectionId
)
}
if (owner?.kind !== 'runtime') {
return parseExecutionHostId(route.executionHostId)?.kind !== 'runtime'
}
if (
isSshDeploymentClientBrowserRoute(
state,
availability,
route,
request.workspaceId,
owner.runtimeEnvironmentId
)
) {
return true
}
if (
!request.url ||
!isExpectedRuntimeBrowserRoute(
state,
availability,
route,
request.workspaceId,
owner.runtimeEnvironmentId
)
) {
return false
}
const profileHostId = toRuntimeExecutionHostId(owner.runtimeEnvironmentId)
const selection = selectBrowserLinkDestination(
state,
{
worktreeId: request.workspaceId,
executionHostId: route.executionHostId,
browserRuntimeEnvironmentId: owner.runtimeEnvironmentId,
profileHostId,
...resolveBrowserLinkSession(state, request.workspaceId, profileHostId),
resource: { kind: 'website', url: request.url }
},
intent
)
return selection.status === 'ready' && selection.match !== null
}
@@ -0,0 +1,119 @@
import type { HttpLinkSourceOwner, HttpLinkStoreState } from './http-link-routing'
import {
parseLoopbackUrlWithPort,
type LocalhostWorktreeLabelRoute
} from '../../../shared/localhost-worktree-labels'
import type { WorkspacePort, WorkspacePortScanResult } from '../../../shared/workspace-ports'
export type LocalhostLinkRepo = {
id: string
displayName: string
}
export type LocalhostLinkProject = LocalhostLinkRepo
export type LocalhostLinkWorktree = {
id: string
projectId?: string
}
export function localhostLabelRouteForHttpLink(
url: string,
state: HttpLinkStoreState,
sourceOwner?: HttpLinkSourceOwner
): LocalhostWorktreeLabelRoute | null {
if (sourceOwner && sourceOwner.kind !== 'local') {
return null
}
const sourceScan =
sourceOwner?.kind === 'local'
? (state.workspacePortScansByKey?.['local:all'] ?? null)
: undefined
return localhostLabelRouteForTerminalLink(url, state, sourceOwner?.kind === 'local', sourceScan)
}
export async function openLabeledLocalhostLink(
fallbackUrl: string,
route: LocalhostWorktreeLabelRoute,
open: (url: string) => void
): Promise<void> {
try {
const result = await window.api.localhostWorktreeLabels.register(route)
open(result.url)
} catch {
open(fallbackUrl)
}
}
function localhostLabelRouteForTerminalLink(
rawUrl: string,
state: HttpLinkStoreState,
ignoreActiveRuntime = false,
sourceScan?: WorkspacePortScanResult | null
): LocalhostWorktreeLabelRoute | null {
if (
state.settings?.localhostWorktreeLabelsEnabled !== true ||
(!ignoreActiveRuntime && state.settings?.activeRuntimeEnvironmentId?.trim())
) {
return null
}
// Why: only loopback links we can attribute to a scanned workspace port
// should get a worktree label; everything else must stay as-is.
const parsed = parseLoopbackUrlWithPort(rawUrl)
if (!parsed) {
return null
}
const scan = sourceScan === undefined ? state.workspacePortScan?.result : sourceScan
const port = findWorkspacePortByNumber(scan, Number(parsed.port))
if (!port) {
return null
}
const repo = state.repos?.find((entry) => entry.id === port.owner.repoId) ?? null
if (!repo) {
return null
}
const worktree = findWorktreeById(state, port.owner.worktreeId)
const project =
worktree?.projectId && state.projects
? (state.projects.find((entry) => entry.id === worktree.projectId) ?? null)
: null
const projectSource = project ?? repo
return {
targetUrl: parsed.toString(),
projectName: projectSource.displayName,
worktreeName: port.owner.displayName,
worktreePath: port.owner.path,
repoId: repo.id,
worktreeId: port.owner.worktreeId
}
}
function findWorkspacePortByNumber(
scan: WorkspacePortScanResult | null | undefined,
portNumber: number
): (WorkspacePort & { kind: 'workspace' }) | null {
const port =
scan?.ports.find(
(candidate): candidate is WorkspacePort & { kind: 'workspace' } =>
candidate.kind === 'workspace' && candidate.port === portNumber
) ?? null
return port
}
function findWorktreeById(
state: HttpLinkStoreState,
worktreeId: string
): LocalhostLinkWorktree | null {
const fromAllWorktrees = state.allWorktrees?.().find((worktree) => worktree.id === worktreeId)
if (fromAllWorktrees) {
return fromAllWorktrees
}
const worktreesByRepo = state.worktreesByRepo ?? {}
for (const worktrees of Object.values(worktreesByRepo)) {
const worktree = worktrees.find((entry) => entry.id === worktreeId)
if (worktree) {
return worktree
}
}
return null
}
@@ -33,6 +33,7 @@ describe('modifier routing across link source owners', () => {
})
it('still lets the inverting modifier pull a local link into Orca', () => {
registerWorkspaceHttpLinkBrowserOpener(null)
storeState.settings = { openLinksInApp: false, openLinksInAppModifierInverts: true }
openHttpLink('https://example.com/', {
@@ -0,0 +1,356 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type * as BrowserFocusModule from '@/components/browser-pane/host-guest/browser-focus'
import { requestBrowserFocus } from '@/components/browser-pane/host-guest/browser-focus'
import { useAppStore } from '@/store'
import { createStoreCascadesMockApi } from '@/store/slices/store-cascades-test-harness'
import { makeWorktree } from '@/store/slices/store-test-helpers'
import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../shared/constants'
import { seedBrowserLinkTestStore, WORKSPACE, URL_TO_OPEN } from './browser-link-open.test-fixture'
import { captureLinkOpenIntent } from './link-open-intent'
import { openHttpLink } from './http-link-routing'
import { openRoutedHttpLink } from './http-link-destinations'
import * as workspaceBrowserTabOpen from './workspace-browser-tab-open'
const OTHER_WORKSPACE = 'repo1::/other'
vi.mock('@/components/browser-pane/host-guest/browser-focus', async (importOriginal) => ({
...(await importOriginal<typeof BrowserFocusModule>()),
requestBrowserFocus: vi.fn()
}))
beforeEach(() => {
createStoreCascadesMockApi()
seedBrowserLinkTestStore(useAppStore)
useAppStore.setState({
activeView: 'activity',
settings: { ...getDefaultSettings('/home/test'), openLinksInApp: true }
})
useAppStore.setState((state) => ({
worktreesByRepo: {
repo1: [
...state.worktreesByRepo.repo1!,
makeWorktree({ id: OTHER_WORKSPACE, repoId: 'repo1', path: '/other', hostId: 'local' })
]
},
tabsByWorktree: {
[WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) => ({
id: tab.entityId,
ptyId: null,
worktreeId: WORKSPACE,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: tab.sortOrder,
createdAt: tab.createdAt
}))
}
}))
vi.mocked(requestBrowserFocus).mockClear()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function deferLocalhostRegistration() {
const registration = Promise.withResolvers<{ url: string }>()
const register = vi.fn(() => registration.promise)
vi.stubGlobal('window', {
...window,
api: { ...window.api, localhostWorktreeLabels: { register } }
})
useAppStore.setState((state) => ({
settings: {
...(state.settings ?? getDefaultSettings('/home/test')),
localhostWorktreeLabelsEnabled: true
},
workspacePortScansByKey: {
'local:all': {
platform: 'darwin',
scannedAt: 1,
ports: [
{
id: 'tcp:5180',
kind: 'workspace',
port: 5180,
protocol: 'http',
bindHost: '127.0.0.1',
connectHost: 'localhost',
owner: {
repoId: 'repo1',
worktreeId: WORKSPACE,
displayName: 'main',
path: '/repo',
confidence: 'cwd'
}
}
]
}
}
}))
return { registration, register }
}
function observeBrowserLinkOpen() {
const completed =
Promise.withResolvers<
Awaited<ReturnType<typeof workspaceBrowserTabOpen.openWorkspaceBrowserTabForLink>>
>()
const open = workspaceBrowserTabOpen.openWorkspaceBrowserTabForLink
vi.spyOn(workspaceBrowserTabOpen, 'openWorkspaceBrowserTabForLink').mockImplementation(
(request) => {
const opening = open(request)
void opening.then(completed.resolve, completed.reject)
return opening
}
)
return completed.promise
}
it.each([
'unchanged',
'workspace',
'view',
'host-return',
'workspace-return',
'view-return'
] as const)(
'keeps a registered localhost tab but relinquishes foreground after navigation: %s',
async (navigation) => {
const { registration, register } = deferLocalhostRegistration()
const completed = observeBrowserLinkOpen()
openRoutedHttpLink('http://localhost:5180/', {
worktreeId: WORKSPACE,
sourceTabId: 'source',
forceDestination: 'orca'
})
expect(register).toHaveBeenCalledOnce()
expect(useAppStore.getState().browserPagesByWorkspace).toEqual({})
if (navigation.startsWith('workspace')) {
useAppStore.getState().setActiveWorktree(OTHER_WORKSPACE, 'local')
if (navigation === 'workspace-return') {
useAppStore.getState().setActiveWorktree(WORKSPACE, 'local')
}
} else if (navigation.startsWith('view')) {
useAppStore.getState().setActiveView('skills')
if (navigation === 'view-return') {
useAppStore.getState().setActiveView('activity')
}
} else if (navigation === 'host-return') {
useAppStore.setState({ activeWorkspaceExecutionHostId: 'ssh:other' })
useAppStore.setState({ activeWorkspaceExecutionHostId: 'local' })
}
const selected = useAppStore.getState()
const labeledUrl = 'http://main.orca.localhost:60016/'
registration.resolve({ url: labeledUrl })
const outcome = await completed
expect(outcome).toMatchObject({ status: 'opened' })
if (outcome.status !== 'opened') {
throw new Error('Expected the registered browser tab to be retained')
}
const state = useAppStore.getState()
expect(state.browserPagesByWorkspace[outcome.result.browserWorkspaceId]?.[0]?.url).toBe(
labeledUrl
)
expect(state.browserTabsByWorktree[WORKSPACE]).toHaveLength(1)
expect(state.groupsByWorktree[WORKSPACE]?.find((group) => group.id === 'a')?.activeTabId).toBe(
'source'
)
expect(state.groupsByWorktree[WORKSPACE]?.find((group) => group.id === 'b')?.activeTabId).toBe(
'terminal-b'
)
if (navigation === 'unchanged') {
expect(state.activeView).toBe('terminal')
expect(state.activeGroupIdByWorktree[WORKSPACE]).toBe(outcome.result.groupId)
expect(requestBrowserFocus).toHaveBeenCalledExactlyOnceWith({
pageId: outcome.result.browserPageId,
target: 'webview'
})
} else {
expect(state.activeWorktreeId).toBe(selected.activeWorktreeId)
expect(state.activeWorkspaceExecutionHostId).toBe(selected.activeWorkspaceExecutionHostId)
expect(state.activeView).toBe(selected.activeView)
expect(state.activeGroupIdByWorktree[WORKSPACE]).toBe(
selected.activeGroupIdByWorktree[WORKSPACE]
)
expect(
state.groupsByWorktree[WORKSPACE]?.find((group) => group.id === 'b')?.activeTabId
).toBe('terminal-b')
expect(requestBrowserFocus).not.toHaveBeenCalled()
}
}
)
it('lets a newer link open normally while an older localhost registration completes', async () => {
const { registration, register } = deferLocalhostRegistration()
const completed = observeBrowserLinkOpen()
const options = {
worktreeId: WORKSPACE,
sourceTabId: 'source',
forceDestination: 'orca' as const
}
openRoutedHttpLink('http://localhost:5180/', options)
expect(register).toHaveBeenCalledOnce()
openRoutedHttpLink(URL_TO_OPEN, options)
const outcome = await completed
expect(outcome).toMatchObject({ status: 'opened' })
registration.resolve({ url: 'http://main.orca.localhost:60016/' })
await registration.promise
const state = useAppStore.getState()
expect(state.browserTabsByWorktree[WORKSPACE]).toHaveLength(1)
expect(
Object.values(state.browserPagesByWorkspace)
.flat()
.map((page) => page.url)
).toEqual([URL_TO_OPEN])
expect(state.activeView).toBe('terminal')
expect(requestBrowserFocus).toHaveBeenCalledOnce()
})
it.each(['workspace', 'view'] as const)(
'retains a legacy localhost tab in the background after navigating to another %s',
async (navigation) => {
const { registration, register } = deferLocalhostRegistration()
const completed = observeBrowserLinkOpen()
openHttpLink('http://localhost:5180/', {
worktreeId: WORKSPACE,
sourceOwner: { kind: 'local' }
})
expect(register).toHaveBeenCalledOnce()
if (navigation === 'workspace') {
useAppStore.getState().setActiveWorktree(OTHER_WORKSPACE, 'local')
} else {
useAppStore.getState().setActiveView('skills')
}
const selected = useAppStore.getState()
const labeledUrl = 'http://main.orca.localhost:60016/'
registration.resolve({ url: labeledUrl })
await completed
const state = useAppStore.getState()
expect(state.browserTabsByWorktree[WORKSPACE]).toHaveLength(1)
expect(
Object.values(state.browserPagesByWorkspace)
.flat()
.map((page) => page.url)
).toEqual([labeledUrl])
expect(state.activeWorktreeId).toBe(selected.activeWorktreeId)
expect(state.activeView).toBe(selected.activeView)
expect(state.activeGroupIdByWorktree[WORKSPACE]).toBe(
selected.activeGroupIdByWorktree[WORKSPACE]
)
expect(requestBrowserFocus).not.toHaveBeenCalled()
}
)
it.each([false, true])(
'reveals the tab layout for a foreground source-aware HTTP link (reuse: %s)',
async (reuse) => {
if (reuse) {
useAppStore.getState().createBrowserTab(WORKSPACE, URL_TO_OPEN, {
targetGroupId: 'b',
activate: false,
browserRuntimeEnvironmentId: null,
sessionProfileId: null
})
}
const opening = vi.spyOn(workspaceBrowserTabOpen, 'openWorkspaceBrowserTabForLink')
openHttpLink(URL_TO_OPEN, {
worktreeId: WORKSPACE,
sourceOwner: { kind: 'local' },
linkContext: {
linkIntent: captureLinkOpenIntent(useAppStore.getState(), WORKSPACE, 'source')
}
})
await vi.waitFor(() => expect(opening).toHaveBeenCalledOnce())
const outcome = await opening.mock.results[0]!.value
expect(outcome).toMatchObject({ status: 'opened' })
if (outcome.status !== 'opened') {
throw new Error('Expected opened link')
}
expect(useAppStore.getState().activeView).toBe('terminal')
expect(useAppStore.getState().activeGroupIdByWorktree[WORKSPACE]).toBe(outcome.result.groupId)
expect(useAppStore.getState().browserTabsByWorktree[WORKSPACE]).toHaveLength(1)
}
)
it('keeps the Activity view and current pane selected for background source-aware HTTP links', async () => {
useAppStore.setState({
settings: {
...getDefaultSettings('/home/test'),
openLinksInApp: true,
openLinksInAppInBackground: true
}
})
const opening = vi.spyOn(workspaceBrowserTabOpen, 'openWorkspaceBrowserTabForLink')
openHttpLink(URL_TO_OPEN, {
worktreeId: WORKSPACE,
sourceOwner: { kind: 'local' },
linkContext: { linkIntent: captureLinkOpenIntent(useAppStore.getState(), WORKSPACE, 'source') }
})
await vi.waitFor(() => expect(opening).toHaveBeenCalledOnce())
expect(await opening.mock.results[0]!.value).toMatchObject({ status: 'opened' })
expect(useAppStore.getState().activeView).toBe('activity')
expect(useAppStore.getState().activeWorktreeId).toBe(WORKSPACE)
expect(useAppStore.getState().activeGroupIdByWorktree[WORKSPACE]).toBe('c')
expect(requestBrowserFocus).not.toHaveBeenCalled()
})
it('keeps the main view and workspace selected when revealing a floating HTTP link', async () => {
const floating = FLOATING_TERMINAL_WORKTREE_ID
const state = useAppStore.getState()
useAppStore.setState({
settings: {
...getDefaultSettings('/home/test'),
openLinksInApp: true,
floatingTerminalEnabled: true
},
floatingWorkspacePanelOpen: true,
groupsByWorktree: {
...state.groupsByWorktree,
[floating]: state.groupsByWorktree[WORKSPACE]!.map((group) => ({
...group,
worktreeId: floating
}))
},
unifiedTabsByWorktree: {
...state.unifiedTabsByWorktree,
[floating]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) => ({
...tab,
worktreeId: floating
}))
},
layoutByWorktree: { ...state.layoutByWorktree, [floating]: state.layoutByWorktree[WORKSPACE]! }
})
const opening = vi.spyOn(workspaceBrowserTabOpen, 'openWorkspaceBrowserTabForLink')
openHttpLink(URL_TO_OPEN, {
worktreeId: floating,
sourceOwner: { kind: 'local' },
linkContext: { linkIntent: captureLinkOpenIntent(useAppStore.getState(), floating, 'source') }
})
await vi.waitFor(() => expect(opening).toHaveBeenCalledOnce())
expect(await opening.mock.results[0]!.value).toMatchObject({ status: 'opened' })
expect(useAppStore.getState().activeView).toBe('activity')
expect(useAppStore.getState().activeWorktreeId).toBe(WORKSPACE)
expect(['a', 'b', 'c']).not.toContain(useAppStore.getState().activeGroupIdByWorktree[floating])
})
it('reuses an exact page for an HTTP link without source context', async () => {
const browser = useAppStore.getState().createBrowserTab(WORKSPACE, URL_TO_OPEN, {
targetGroupId: 'b',
browserRuntimeEnvironmentId: null,
sessionProfileId: null,
activate: false
})
const completed = observeBrowserLinkOpen()
openHttpLink(URL_TO_OPEN, { worktreeId: WORKSPACE, sourceOwner: { kind: 'local' } })
expect(await completed).toMatchObject({
status: 'opened',
result: { browserWorkspaceId: browser.id, browserPageId: browser.activePageId }
})
expect(useAppStore.getState().browserTabsByWorktree[WORKSPACE]).toHaveLength(1)
})
+15 -21
View File
@@ -88,6 +88,7 @@ afterEach(() => {
describe('openHttpLink', () => {
it('keeps the selected host of the active workspace a link opens in', () => {
registerWorkspaceHttpLinkBrowserOpener(null)
storeState.settings = { openLinksInApp: true }
storeState.activeWorktreeId = 'wt-1'
storeState.activeWorkspaceExecutionHostId = 'ssh:box'
@@ -98,6 +99,7 @@ describe('openHttpLink', () => {
})
it('routes into Orca when openLinksInApp is on and a worktree is known', () => {
registerWorkspaceHttpLinkBrowserOpener(null)
storeState.settings = { openLinksInApp: true }
openHttpLink('https://example.com/', { worktreeId: 'wt-1' })
@@ -111,6 +113,7 @@ describe('openHttpLink', () => {
// 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', () => {
registerWorkspaceHttpLinkBrowserOpener(null)
storeState.settings = { openLinksInApp: true, openLinksInAppInBackground: true }
openHttpLink('https://example.com/', { worktreeId: 'wt-1' })
@@ -123,6 +126,7 @@ describe('openHttpLink', () => {
// 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', () => {
registerWorkspaceHttpLinkBrowserOpener(null)
storeState.settings = { openLinksInApp: true }
storeState.activeView = 'activity'
@@ -141,6 +145,7 @@ describe('openHttpLink', () => {
})
it('routes floating workspace links into Orca without changing the active repo worktree', () => {
registerWorkspaceHttpLinkBrowserOpener(null)
storeState.settings = { openLinksInApp: true }
openHttpLink('https://example.com/', { worktreeId: FLOATING_TERMINAL_WORKTREE_ID })
@@ -164,6 +169,7 @@ describe('openHttpLink', () => {
})
it('forceInApp opens a local link in Orca when the setting is off', () => {
registerWorkspaceHttpLinkBrowserOpener(null)
storeState.settings = { openLinksInApp: false }
openHttpLink('https://example.com/', {
@@ -192,6 +198,7 @@ describe('openHttpLink', () => {
})
it('keeps explicitly local links local while a remote runtime is active', () => {
registerWorkspaceHttpLinkBrowserOpener(null)
storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: 'remote-1' }
openHttpLink('https://example.com/', {
@@ -220,7 +227,8 @@ describe('openHttpLink', () => {
expect(openRuntimeBrowserTabMock).toHaveBeenCalledExactlyOnceWith({
workspaceId: 'wt-1',
url: 'https://github.com/acme/widgets/pull/123',
intent: { kind: 'url' }
intent: { kind: 'url' },
linkContext: expect.objectContaining({ linkIntent: null, canReveal: expect.any(Function) })
})
expect(createBrowserTabMock).not.toHaveBeenCalled()
expect(openUrlMock).not.toHaveBeenCalled()
@@ -237,6 +245,7 @@ describe('openHttpLink', () => {
})
it('honors an explicit local document owner despite an unrelated active runtime', () => {
registerWorkspaceHttpLinkBrowserOpener(null)
storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: 'env-other' }
openHttpLink('https://example.com/', {
@@ -250,24 +259,6 @@ 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 }
@@ -286,13 +277,15 @@ describe('openHttpLink', () => {
workspaceId: 'wt-1',
url: 'http://localhost:5180/runtime',
intent: { kind: 'url' },
expectedRuntimeEnvironmentId: 'env-1'
expectedRuntimeEnvironmentId: 'env-1',
linkContext: expect.objectContaining({ linkIntent: null, canReveal: expect.any(Function) })
})
expect(openRuntimeBrowserTabMock).toHaveBeenNthCalledWith(2, {
workspaceId: 'wt-1',
url: 'http://localhost:5180/ssh',
intent: { kind: 'url' },
expectedSshConnectionId: 'ssh-1'
expectedSshConnectionId: 'ssh-1',
linkContext: expect.objectContaining({ linkIntent: null, canReveal: expect.any(Function) })
})
expect(openUrlMock).not.toHaveBeenCalled()
expect(createBrowserTabMock).not.toHaveBeenCalled()
@@ -697,6 +690,7 @@ describe('openHttpLink modifier routing', () => {
})
it('opens in Orca when inverting is on and links open externally', () => {
registerWorkspaceHttpLinkBrowserOpener(null)
storeState.settings = { openLinksInApp: false, openLinksInAppModifierInverts: true }
openHttpLink('https://example.com/', { worktreeId: 'wt-1', modifierHeld: true })
+125 -145
View File
@@ -1,20 +1,30 @@
import {
localhostLabelRouteForHttpLink,
openLabeledLocalhostLink,
type LocalhostLinkRepo,
type LocalhostLinkProject,
type LocalhostLinkWorktree
} from './http-link-localhost-route'
import type { WorkspacePortScanResult } from '../../../shared/workspace-ports'
import type { BrowserLinkOpenContext } from './browser-link-destination'
import type { LinkOpenIntent, LinkSourceState } from './link-open-intent'
import { captureLinkOpenIntent, canOpenLinkInSourceGroup } from './link-open-intent'
import type { AppState } from '@/store/types'
import { translate } from '@/i18n/i18n'
import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree'
import {
parseLoopbackUrlWithPort,
type LocalhostWorktreeLabelRoute
} from '../../../shared/localhost-worktree-labels'
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { WorkspacePort, WorkspacePortScanResult } from '../../../shared/workspace-ports'
import { toast } from 'sonner'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { reselectWorkspaceKeepingHost } from './active-workspace-host'
import {
focusHttpLinkPage,
captureBrowserLinkReveal,
type BrowserLinkSelectionSubscriber
} from './browser-link-reveal'
import {
isHttpLinkBackgroundOpen,
openLocalHttpLinkTab,
prepareRemoteHttpLinkTab,
resolveHttpLinkBrowserPlacement,
type HttpLinkBrowserRequestFields,
type HttpLinkBrowserTabState
} from './http-link-browser-placement'
@@ -30,6 +40,7 @@ export type OpenHttpLinkOptions = {
/** The Shift escape-hatch modifier was held; resolveModifierRouting decides what it means. */
modifierHeld?: boolean
sourceOwner?: HttpLinkSourceOwner
linkContext?: BrowserLinkOpenContext
}
export type HttpLinkSourceOwner =
@@ -38,10 +49,14 @@ export type HttpLinkSourceOwner =
| { kind: 'ssh'; connectionId: string }
| { kind: 'unknown' }
type StoreAccessor = () => HttpLinkBrowserTabState & {
export type HttpLinkStoreState = HttpLinkBrowserTabState & {
unifiedTabsByWorktree?: LinkSourceState['unifiedTabsByWorktree']
groupsByWorktree?: AppState['groupsByWorktree']
layoutByWorktree?: AppState['layoutByWorktree']
settings?: Partial<
Pick<
GlobalSettings,
| 'openLinksInAppInBackground'
| 'openLinksInApp'
| 'openLinksInAppModifierInverts'
| 'activeRuntimeEnvironmentId'
@@ -59,10 +74,26 @@ type StoreAccessor = () => HttpLinkBrowserTabState & {
workspacePortScansByKey?: Record<string, WorkspacePortScanResult>
}
export type HttpLinkGroupActionRequest = {
workspaceId: string
sourceTabId?: string | null
url?: string
sourceOwner?: HttpLinkSourceOwner
}
let groupActionAvailability: ((request: HttpLinkGroupActionRequest) => boolean) | null = null
export function registerHttpLinkGroupActionAvailability(
check: (request: HttpLinkGroupActionRequest) => boolean
): void {
groupActionAvailability = check
}
type StoreAccessor = () => HttpLinkStoreState
type WorkspaceHttpLinkBrowserRequest = HttpLinkBrowserRequestFields & {
workspaceId: string
url: string
intent: { kind: 'url' }
linkContext?: BrowserLinkOpenContext
expectedRuntimeEnvironmentId?: string
expectedSshConnectionId?: string
}
@@ -70,26 +101,19 @@ type WorkspaceHttpLinkBrowserRequest = HttpLinkBrowserRequestFields & {
/** 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
displayName: string
}
type LocalhostLinkProject = LocalhostLinkRepo
type LocalhostLinkWorktree = {
id: string
projectId?: string
}
) => Promise<string | null | void>
// Inject store access to break editor-slice → HTTP routing → store initialization cycles.
let storeAccessor: StoreAccessor | null = null
let storeSubscriber: BrowserLinkSelectionSubscriber | undefined
let workspaceHttpLinkBrowserOpener: WorkspaceHttpLinkBrowserOpener | null = null
export function registerHttpLinkStoreAccessor(fn: StoreAccessor): void {
export function registerHttpLinkStoreAccessor(
fn: StoreAccessor,
subscribe?: BrowserLinkSelectionSubscriber
): void {
storeAccessor = fn
storeSubscriber = subscribe
}
export function registerWorkspaceHttpLinkBrowserOpener(
@@ -98,6 +122,33 @@ export function registerWorkspaceHttpLinkBrowserOpener(
workspaceHttpLinkBrowserOpener = fn
}
export function captureHttpLinkIntent(
workspaceId: string,
sourceTabId?: string | null,
explicitSourceGroup = false
): LinkOpenIntent | null {
const state = storeAccessor?.()
return state
? captureLinkOpenIntent(state, workspaceId, sourceTabId, { explicitSourceGroup })
: null
}
export function canHttpLinkOpenInSourceGroup(
workspaceId: string,
sourceTabId?: string | null,
url?: string,
sourceOwner?: HttpLinkSourceOwner
): boolean {
const state = storeAccessor?.()
if (
groupActionAvailability &&
!groupActionAvailability({ workspaceId, sourceTabId, url, sourceOwner })
) {
return false
}
return state ? canOpenLinkInSourceGroup(state, workspaceId, sourceTabId) : false
}
// Route only external http(s); file/markdown targets stay with resolveMarkdownLinkTarget. Modifier-held resolves the user’s Shift choice; forceSystemBrowser is unconditional.
/** Shift traditionally forces system browser; the inversion setting instead chooses the other destination when system is already default. */
function resolveModifierRouting(
@@ -114,7 +165,18 @@ function resolveModifierRouting(
return { wantsOrca: !openLinksInApp, wantsSystemBrowser: openLinksInApp }
}
function reportHttpLinkBrowserOpenFailure(error: unknown): void {
toast.error(
error instanceof Error
? error.message
: translate('auto.lib.workspace.browser.tab.open.urlFailed', 'Unable to open URL.')
)
}
export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void {
if (opts.linkContext?.isCurrentRequest?.() === false) {
return
}
const {
worktreeId,
allowRemoteInApp,
@@ -154,46 +216,64 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
) {
if (workspaceHttpLinkBrowserOpener && state) {
const fields = prepareRemoteHttpLinkTab(state, worktreeId)
const reveal = captureBrowserLinkReveal(
() => storeAccessor?.() ?? state,
storeSubscriber,
opts.linkContext?.canReveal
)
void workspaceHttpLinkBrowserOpener({
workspaceId: worktreeId,
url,
intent: { kind: 'url' },
...fields,
linkContext: { linkIntent: null, ...opts.linkContext, canReveal: reveal.canReveal },
...(effectiveSourceOwner?.kind === 'runtime'
? { expectedRuntimeEnvironmentId: effectiveSourceOwner.runtimeEnvironmentId }
: effectiveSourceOwner?.kind === 'ssh'
? { expectedSshConnectionId: effectiveSourceOwner.connectionId }
: {})
}).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.')
)
}
)
})
.catch(reportHttpLinkBrowserOpenFailure)
.finally(reveal.dispose)
}
return
}
if (wantsOrca && sourceIsLocal && worktreeId && state) {
// Opening a tab in the same worktree must not add switch history; reserve activateAndRevealWorktree for real file-link jumps.
if (!isHttpLinkBackgroundOpen(state) && !isFloatingWorkspaceId(worktreeId)) {
// Floating workspace IDs are synthetic; global activation would deselect the real repo workspace.
reselectWorkspaceKeepingHost(state, worktreeId)
const getState = () => storeAccessor?.() ?? state
const reveal = captureBrowserLinkReveal(getState, storeSubscriber, opts.linkContext?.canReveal)
const openLocal = (chosenUrl: string): void => {
if (opts.linkContext?.isCurrentRequest?.() === false) {
reveal.dispose()
return
}
if (workspaceHttpLinkBrowserOpener) {
void workspaceHttpLinkBrowserOpener({
workspaceId: worktreeId,
url: chosenUrl,
intent: { kind: 'url' },
linkContext: { linkIntent: null, ...opts.linkContext, canReveal: reveal.canReveal },
...(isHttpLinkBackgroundOpen(state)
? { focusOnCreate: false, selectWorktree: false }
: {})
})
.catch(reportHttpLinkBrowserOpenFailure)
.finally(reveal.dispose)
return
}
const activate = !isHttpLinkBackgroundOpen(state) && reveal.canReveal()
reveal.dispose()
if (activate && !isFloatingWorkspaceId(worktreeId)) {
reselectWorkspaceKeepingHost(state, worktreeId)
}
openLocalHttpLinkTab(getState(), worktreeId, chosenUrl, undefined, activate)
}
const localhostRoute = localhostLabelRouteForHttpLink(url, state, sourceOwner)
if (!localhostRoute) {
openLocalHttpLinkTab(state, worktreeId, url)
openLocal(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) => {
openLocalHttpLinkTab(storeAccessor?.() ?? state, worktreeId, labeledUrl, placement)
})
void openLabeledLocalhostLink(url, localhostRoute, openLocal)
return
}
@@ -203,28 +283,13 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
return
}
void openLabeledLocalhostLink(url, localhostRoute, (labeledUrl) => {
if (opts.linkContext?.isCurrentRequest?.() === false) {
return
}
void window.api.shell.openUrl(labeledUrl)
})
}
function localhostLabelRouteForHttpLink(
url: string,
state: ReturnType<StoreAccessor>,
sourceOwner?: HttpLinkSourceOwner
): LocalhostWorktreeLabelRoute | null {
if (sourceOwner && sourceOwner.kind !== 'local') {
return null
}
if (!sourceOwner && state.settings?.activeRuntimeEnvironmentId?.trim()) {
return null
}
const sourceScan =
sourceOwner?.kind === 'local'
? (state.workspacePortScansByKey?.['local:all'] ?? null)
: undefined
return localhostLabelRouteForTerminalLink(url, state, sourceOwner?.kind === 'local', sourceScan)
}
export async function resolveLocalhostHttpLinkDisplayUrl(
url: string,
sourceOwner?: HttpLinkSourceOwner
@@ -245,88 +310,3 @@ export async function resolveLocalhostHttpLinkDisplayUrl(
return null
}
}
async function openLabeledLocalhostLink(
fallbackUrl: string,
route: LocalhostWorktreeLabelRoute,
open: (url: string) => void
): Promise<void> {
try {
const result = await window.api.localhostWorktreeLabels.register(route)
open(result.url)
} catch {
open(fallbackUrl)
}
}
function localhostLabelRouteForTerminalLink(
rawUrl: string,
state: ReturnType<StoreAccessor>,
ignoreActiveRuntime = false,
sourceScan?: WorkspacePortScanResult | null
): LocalhostWorktreeLabelRoute | null {
if (
state.settings?.localhostWorktreeLabelsEnabled !== true ||
(!ignoreActiveRuntime && state.settings?.activeRuntimeEnvironmentId?.trim())
) {
return null
}
// Label only scanned workspace loopback ports; leave unattributed URLs unchanged.
const parsed = parseLoopbackUrlWithPort(rawUrl)
if (!parsed) {
return null
}
const scan = sourceScan === undefined ? state.workspacePortScan?.result : sourceScan
const port = findWorkspacePortByNumber(scan, Number(parsed.port))
if (!port) {
return null
}
const repo = state.repos?.find((entry) => entry.id === port.owner.repoId) ?? null
if (!repo) {
return null
}
const worktree = findWorktreeById(state, port.owner.worktreeId)
const project =
worktree?.projectId && state.projects
? (state.projects.find((entry) => entry.id === worktree.projectId) ?? null)
: null
const projectSource = project ?? repo
return {
targetUrl: parsed.toString(),
projectName: projectSource.displayName,
worktreeName: port.owner.displayName,
worktreePath: port.owner.path,
repoId: repo.id,
worktreeId: port.owner.worktreeId
}
}
function findWorkspacePortByNumber(
scan: WorkspacePortScanResult | null | undefined,
portNumber: number
): (WorkspacePort & { kind: 'workspace' }) | null {
const port =
scan?.ports.find(
(candidate): candidate is WorkspacePort & { kind: 'workspace' } =>
candidate.kind === 'workspace' && candidate.port === portNumber
) ?? null
return port
}
function findWorktreeById(
state: ReturnType<StoreAccessor>,
worktreeId: string
): LocalhostLinkWorktree | null {
const fromAllWorktrees = state.allWorktrees?.().find((worktree) => worktree.id === worktreeId)
if (fromAllWorktrees) {
return fromAllWorktrees
}
const worktreesByRepo = state.worktreesByRepo ?? {}
for (const worktrees of Object.values(worktreesByRepo)) {
const worktree = worktrees.find((entry) => entry.id === worktreeId)
if (worktree) {
return worktree
}
}
return null
}
@@ -0,0 +1,258 @@
import { toast } from 'sonner'
import type * as BrowserFocusModule from '@/components/browser-pane/host-guest/browser-focus'
import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../shared/constants'
import { makeTabGroup, makeUnifiedTab } from '@/store/slices/store-test-helpers'
import { canOfferHttpLinkGroupAction } from './http-link-in-group-availability'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { createStoreCascadesMockApi } from '@/store/slices/store-cascades-test-harness'
import { openWorkspaceBrowserTabForLink } from './workspace-browser-tab-open'
import { openRoutedHttpLink } from './http-link-destinations'
import {
registerHttpLinkStoreAccessor,
registerWorkspaceHttpLinkBrowserOpener
} from './http-link-routing'
import { nativeChatLinkActions } from '@/components/native-chat/native-chat-link-target-actions'
import {
createBrowserLinkTestStore,
WORKSPACE,
URL_TO_OPEN,
type BrowserLinkTestStore
} from './browser-link-open.test-fixture'
import type { BrowserOpenOutcome } from './browser-link-destination'
const mocks = vi.hoisted(() => ({ getState: vi.fn() }))
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.getState() } }))
vi.mock('@/components/browser-pane/host-guest/browser-focus', async (importOriginal) => ({
...(await importOriginal<typeof BrowserFocusModule>()),
requestBrowserFocus: vi.fn()
}))
vi.mock('sonner', () => ({
toast: { error: vi.fn(), info: vi.fn(), success: vi.fn(), warning: vi.fn() }
}))
createStoreCascadesMockApi()
let store: BrowserLinkTestStore
let outcomes: BrowserOpenOutcome[]
beforeEach(() => {
store = createBrowserLinkTestStore()
outcomes = []
mocks.getState.mockImplementation(() => store.getState())
registerHttpLinkStoreAccessor(() => store.getState())
registerWorkspaceHttpLinkBrowserOpener(async (request) => {
if (request.linkContext) {
outcomes.push(
await openWorkspaceBrowserTabForLink({ ...request, linkContext: request.linkContext })
)
}
})
})
afterEach(() => {
registerWorkspaceHttpLinkBrowserOpener(null)
})
async function settle() {
for (let i = 0; i < 15; i++) {
await Promise.resolve()
}
}
it.each([false, true])(
'opens hostless legacy links relative to their source (explicit: %s)',
async (explicit) => {
store.setState((state) => ({
unifiedTabsByWorktree: {
[WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) => ({
...tab,
executionHostId: undefined
}))
}
}))
expect(
canOfferHttpLinkGroupAction(store.getState(), {
workspaceId: WORKSPACE,
sourceTabId: 'source',
sourceOwner: { kind: 'local' },
url: URL_TO_OPEN
})
).toBe(true)
openRoutedHttpLink(URL_TO_OPEN, {
worktreeId: WORKSPACE,
sourceTabId: 'source',
sourceOwner: { kind: 'local' },
forceDestination: 'orca',
openInSourceGroup: explicit
})
await settle()
expect(outcomes).toMatchObject([
{ status: 'opened', ...(explicit ? { result: { groupId: 'a' } } : {}) }
])
}
)
it('captures the link owner before an asynchronous preference decision and follows its current group', async () => {
let resolvePreference: ((value: boolean) => void) | undefined
const preference = new Promise<boolean>((resolve) => {
resolvePreference = resolve
})
openRoutedHttpLink(URL_TO_OPEN, {
worktreeId: WORKSPACE,
sourceTabId: 'source',
sourceOwner: { kind: 'local' },
requestOpenLinksInAppPreference: () => preference
})
store.setState((state) => ({
unifiedTabsByWorktree: {
[WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) =>
tab.id === 'source' ? { ...tab, groupId: 'b' } : tab
)
},
activeGroupIdByWorktree: { [WORKSPACE]: 'a' }
}))
store.setState({ settings: { ...getDefaultSettings('/home/test'), openLinksInApp: true } })
resolvePreference?.(true)
await settle()
expect(outcomes).toMatchObject([{ status: 'opened' }])
expect(store.getState().layoutByWorktree[WORKSPACE]).toMatchObject({
second: { first: { type: 'split', first: { groupId: 'b' } } }
})
})
it('a newer activation suppresses an older pending preference decision', async () => {
let resolvePreference: ((value: boolean) => void) | undefined
const preference = new Promise<boolean>((resolve) => {
resolvePreference = resolve
})
openRoutedHttpLink(URL_TO_OPEN, {
worktreeId: WORKSPACE,
sourceTabId: 'source',
requestOpenLinksInAppPreference: () => preference
})
openRoutedHttpLink('https://second.example/', {
worktreeId: WORKSPACE,
sourceTabId: 'source',
forceDestination: 'orca'
})
resolvePreference?.(true)
await settle()
expect(outcomes).toHaveLength(1)
expect(store.getState().browserTabsByWorktree[WORKSPACE]?.[0]?.url).toBe(
'https://second.example/'
)
})
it('the native chat context menu pins the owning tab when the action runs, even with a system-browser preference', async () => {
const actions = nativeChatLinkActions(URL_TO_OPEN, {
worktreeId: WORKSPACE,
worktreePath: '/repo',
runtimeEnvironmentId: null,
sourceTabId: 'source'
})
const row = actions?.secondaryActions?.find((action) => action.label === 'Open in this group')
expect(row).toBeDefined()
store.setState((state) => ({
unifiedTabsByWorktree: {
[WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE]!.map((tab) =>
tab.id === 'source' ? { ...tab, groupId: 'b' } : tab
)
}
}))
row?.run()
await settle()
expect(outcomes).toMatchObject([{ status: 'opened', result: { groupId: 'b' } }])
})
it('refuses a stale explicit chat action when its source closed before activation', async () => {
const actions = nativeChatLinkActions(URL_TO_OPEN, {
worktreeId: WORKSPACE,
worktreePath: '/repo',
runtimeEnvironmentId: null,
sourceTabId: 'source'
})
const action = actions?.secondaryActions?.find((row) => row.label === 'Open in this group')
expect(action).toBeDefined()
store.setState((state) => ({
unifiedTabsByWorktree: {
[WORKSPACE]: state.unifiedTabsByWorktree[WORKSPACE]!.filter((tab) => tab.id !== 'source')
}
}))
action?.run()
await settle()
expect(outcomes).toEqual([])
expect(store.getState().browserPagesByWorkspace).toEqual({})
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("Couldn't open"))
})
it.each([false, true])(
'opens floating HTTP links through the managed route (explicit: %s)',
async (explicit) => {
const floating = FLOATING_TERMINAL_WORKTREE_ID
store.setState((state) => ({
groupsByWorktree: {
...state.groupsByWorktree,
[floating]: ['left', 'right'].map((id) =>
makeTabGroup({
id,
worktreeId: floating,
activeTabId: id === 'left' ? 'floating-source' : null,
tabOrder: id === 'left' ? ['floating-source'] : []
})
)
},
unifiedTabsByWorktree: {
...state.unifiedTabsByWorktree,
[floating]: [
makeUnifiedTab({
id: 'floating-source',
worktreeId: floating,
groupId: 'left',
executionHostId: 'local',
contentType: 'terminal'
})
]
},
layoutByWorktree: {
...state.layoutByWorktree,
[floating]: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'left' },
second: { type: 'leaf', groupId: 'right' }
}
}
}))
expect(
canOfferHttpLinkGroupAction(store.getState(), {
workspaceId: floating,
sourceTabId: 'floating-source',
sourceOwner: { kind: 'local' },
url: URL_TO_OPEN
})
).toBe(true)
openRoutedHttpLink(URL_TO_OPEN, {
worktreeId: floating,
sourceTabId: 'floating-source',
sourceOwner: { kind: 'local' },
forceDestination: 'orca',
openInSourceGroup: explicit
})
await settle()
expect(outcomes).toMatchObject([
{
status: 'opened',
result: {
worktreeId: floating,
executionHostId: 'local',
...(explicit ? { groupId: 'left' } : {})
}
}
])
if (!explicit) {
expect(store.getState().layoutByWorktree[floating]).toMatchObject({
first: { type: 'split', first: { groupId: 'left' } }
})
}
expect(store.getState().activeWorktreeId).toBe(WORKSPACE)
expect(store.getState().activeWorkspaceExecutionHostId).toBe('local')
expect(store.getState().activeGroupIdByWorktree[WORKSPACE]).toBe('c')
expect(store.getState().browserTabsByWorktree[WORKSPACE] ?? []).toEqual([])
}
)
+85
View File
@@ -0,0 +1,85 @@
import type { AppState } from '@/store/types'
import type { Tab } from '../../../shared/tab-types'
import type { EditorOpenLinkIntent } from '@/store/slices/editor/types/editor-open-link-intent'
import { collectTabGroupLayoutGroupIds } from '../../../shared/workspace-layout/tab-group-moves'
export type LinkOpenIntent = EditorOpenLinkIntent & { initialGroupId?: string }
export type LinkSourceTab = Pick<Tab, 'id' | 'entityId' | 'groupId'> &
Partial<Pick<Tab, 'contentType' | 'executionHostId'>>
export type LinkSourceState = { unifiedTabsByWorktree?: Record<string, LinkSourceTab[]> }
export type LinkPlacementState = Pick<
AppState,
'groupsByWorktree' | 'unifiedTabsByWorktree' | 'layoutByWorktree'
>
/** The unified tab a link came from; a terminal surface may know only its backing terminal id. */
function findLinkSourceTab(
tabs: readonly LinkSourceTab[],
sourceTabId: string
): LinkSourceTab | undefined {
return (
tabs.find((tab) => tab.id === sourceTabId) ??
tabs.find((tab) => tab.contentType === 'terminal' && tab.entityId === sourceTabId)
)
}
/** Captures a link activation's source at click time, so later focus changes cannot redirect it. */
export function captureLinkOpenIntent(
state: LinkSourceState,
workspaceId: string,
sourceTabId: string | null | undefined,
options?: { explicitSourceGroup?: boolean }
): LinkOpenIntent | null {
if (!sourceTabId) {
return null
}
const tab = findLinkSourceTab(state.unifiedTabsByWorktree?.[workspaceId] ?? [], sourceTabId)
if (!tab) {
return null
}
return {
sourceTabId: tab.id,
workspaceId,
hostId: tab.executionHostId ?? null,
initialGroupId: tab.groupId,
...(options?.explicitSourceGroup ? { targetGroupId: tab.groupId } : {})
}
}
export function liveLinkOpenGroupIds(
state: Partial<Pick<LinkPlacementState, 'groupsByWorktree' | 'layoutByWorktree'>>,
worktreeId: string
): Set<string> {
const layout = state.layoutByWorktree?.[worktreeId]
const groupIds = new Set((state.groupsByWorktree?.[worktreeId] ?? []).map((group) => group.id))
if (!layout) {
return groupIds
}
return new Set([...collectTabGroupLayoutGroupIds(layout)].filter((id) => groupIds.has(id)))
}
export function isLiveLinkOpenGroup(
state: LinkPlacementState,
worktreeId: string,
groupId: string
): boolean {
return liveLinkOpenGroupIds(state, worktreeId).has(groupId)
}
/** "Open in this group" only differs from automatic placement when the source has company. */
export function canOpenLinkInSourceGroup(
state: Partial<Pick<LinkPlacementState, 'groupsByWorktree' | 'layoutByWorktree'>> &
LinkSourceState,
workspaceId: string,
sourceTabId: string | null | undefined
): boolean {
const tab = sourceTabId
? findLinkSourceTab(state.unifiedTabsByWorktree?.[workspaceId] ?? [], sourceTabId)
: undefined
if (!tab) {
return false
}
const groupIds = liveLinkOpenGroupIds(state, workspaceId)
return groupIds.has(tab.groupId) && groupIds.size > 1
}
+21
View File
@@ -0,0 +1,21 @@
import { cancelPendingEditorLineRevealFrames } from '@/store/slices/editor/focus/editor-focus-reveal'
let latestRequestId = 0
const listeners = new Set<() => void>()
export function subscribeLinkOpenRequests(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
/** All link surfaces supersede pending opens and line reveals together. */
export function beginLinkOpenRequest(): () => boolean {
const requestId = ++latestRequestId
cancelPendingEditorLineRevealFrames()
for (const listener of listeners) {
listener()
}
return () => requestId === latestRequestId
}
@@ -0,0 +1,154 @@
import { describe, expect, it } from 'vitest'
import type { TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types'
import { makeTabGroup, makeUnifiedTab } from '../store/slices/store-test-helpers'
import { findVisibleBrowserLinkTarget } from './visible-browser-link-target'
type VisibleBrowserLinkState = Parameters<typeof findVisibleBrowserLinkTarget>[0]
const WT = 'folder:/project'
const leaf = (groupId: string): TabGroupLayoutNode => ({ type: 'leaf', groupId })
const split = (first: TabGroupLayoutNode, second: TabGroupLayoutNode): TabGroupLayoutNode => ({
type: 'split',
direction: 'horizontal',
first,
second
})
/** left: browser a (focused at 10), right: browser b (focused at 20), bottom: terminal t (focused group). */
// Mutable groups so cases can swap one in place.
type MutableLinkState = Omit<VisibleBrowserLinkState, 'groupsByWorktree'> & {
groupsByWorktree: Record<string, TabGroup[]>
}
function fixture(): MutableLinkState {
const a = makeUnifiedTab({
id: 'a',
worktreeId: WT,
groupId: 'left',
contentType: 'browser',
lastFocusedAt: 10
})
const b = makeUnifiedTab({
id: 'b',
worktreeId: WT,
groupId: 'right',
contentType: 'browser',
lastFocusedAt: 20
})
const t = makeUnifiedTab({ id: 't', worktreeId: WT, groupId: 'bottom', contentType: 'terminal' })
return {
unifiedTabsByWorktree: { [WT]: [a, b, t] },
groupsByWorktree: {
[WT]: [
makeTabGroup({ id: 'left', worktreeId: WT, activeTabId: 'a', tabOrder: ['a'] }),
makeTabGroup({ id: 'right', worktreeId: WT, activeTabId: 'b', tabOrder: ['b'] }),
makeTabGroup({ id: 'bottom', worktreeId: WT, activeTabId: 't', tabOrder: ['t'] })
]
},
activeGroupIdByWorktree: { [WT]: 'bottom' },
layoutByWorktree: { [WT]: split(split(leaf('left'), leaf('right')), leaf('bottom')) }
}
}
const target = (state: VisibleBrowserLinkState, wt = WT) =>
findVisibleBrowserLinkTarget(state, wt)?.targetGroupId
describe('findVisibleBrowserLinkTarget', () => {
it('returns the most recently focused browser group', () => {
expect(findVisibleBrowserLinkTarget(fixture(), WT)).toEqual({ targetGroupId: 'right' })
})
it('prefers the focused group when it shows a browser', () => {
const s = fixture()
s.activeGroupIdByWorktree = { [WT]: 'left' }
expect(target(s)).toBe('left')
})
// Unlike resolveEditorOpenTargetGroupId (terminal-only trigger): markdown links come from editor panes.
it('redirects from a focused editor pane too', () => {
const s = fixture()
s.unifiedTabsByWorktree![WT] = s.unifiedTabsByWorktree![WT]!.map((t) =>
t.id === 't' ? { ...t, contentType: 'editor' } : t
)
expect(target(s)).toBe('right')
})
it.each([
['left', 'right'],
['right', 'left']
])('finds the browser in both mirror orders (%s | %s)', (termSide, webSide) => {
const s: VisibleBrowserLinkState = {
unifiedTabsByWorktree: {
[WT]: [
makeUnifiedTab({ id: 't', worktreeId: WT, groupId: termSide, contentType: 'terminal' }),
makeUnifiedTab({ id: 'w', worktreeId: WT, groupId: webSide, contentType: 'browser' })
]
},
groupsByWorktree: {
[WT]: [
makeTabGroup({ id: termSide, worktreeId: WT, activeTabId: 't', tabOrder: ['t'] }),
makeTabGroup({ id: webSide, worktreeId: WT, activeTabId: 'w', tabOrder: ['w'] })
]
},
activeGroupIdByWorktree: { [WT]: termSide },
layoutByWorktree: { [WT]: split(leaf('left'), leaf('right')) }
}
expect(target(s)).toBe(webSide)
})
it('ignores a browser hidden behind another tab in its group', () => {
const s = fixture()
s.groupsByWorktree![WT]![1] = makeTabGroup({
id: 'right',
worktreeId: WT,
activeTabId: null,
tabOrder: ['b']
})
expect(target(s)).toBe('left')
})
it('ignores a group that is not in the layout', () => {
const s = fixture()
s.layoutByWorktree = { [WT]: leaf('bottom') }
expect(target(s)).toBeUndefined()
})
it("does not accept another group's tab as a group's active tab", () => {
const s = fixture()
s.groupsByWorktree![WT]![0] = makeTabGroup({
id: 'left',
worktreeId: WT,
activeTabId: 'b',
tabOrder: ['a']
})
s.groupsByWorktree![WT]![1] = makeTabGroup({
id: 'right',
worktreeId: WT,
activeTabId: null,
tabOrder: ['b']
})
expect(target(s)).toBeUndefined()
})
it('uses the renderer fallback (focused group, else first group) when no layout is recorded', () => {
const s = fixture()
s.layoutByWorktree = {}
expect(target(s)).toBeUndefined() // focused group 'bottom' is the only one shown
s.activeGroupIdByWorktree = { [WT]: 'left' }
expect(target(s)).toBe('left')
s.activeGroupIdByWorktree = {}
expect(target(s)).toBe('left') // first group
})
it('breaks recency ties by layout order', () => {
const s = fixture()
s.unifiedTabsByWorktree![WT] = s.unifiedTabsByWorktree![WT]!.map((t) => ({
...t,
lastFocusedAt: undefined
}))
expect(target(s)).toBe('left')
})
it('never borrows another worktree and survives an empty state', () => {
expect(target(fixture(), 'other')).toBeUndefined()
expect(findVisibleBrowserLinkTarget({}, WT)).toBeUndefined()
})
})
@@ -0,0 +1,56 @@
import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types'
import { getEffectiveLayoutForWorktree } from '@/components/terminal/split-group-mount'
import { collectTabGroupLayoutGroupIds } from '../../../shared/workspace-layout/tab-group-moves'
type LinkTab = Pick<Tab, 'id'> &
Partial<Pick<Tab, 'groupId' | 'contentType' | 'lastFocusedAt' | 'executionHostId'>>
type VisibleBrowserLinkState = {
layoutByWorktree?: Record<string, TabGroupLayoutNode | undefined>
groupsByWorktree?: Record<string, readonly Pick<TabGroup, 'id' | 'activeTabId'>[]>
unifiedTabsByWorktree?: Record<string, readonly LinkTab[]>
activeGroupIdByWorktree?: Record<string, string | undefined>
}
export type BrowserLinkPlacement = { targetGroupId: string }
/** The focused visible browser wins, then the most recently focused, then layout order. */
export function findVisibleBrowserLinkTarget(
state: VisibleBrowserLinkState,
worktreeId: string,
acceptsTab: (tab: LinkTab) => boolean = () => true
): BrowserLinkPlacement | undefined {
const focusedGroupId = state.activeGroupIdByWorktree?.[worktreeId]
const layout = getEffectiveLayoutForWorktree(
worktreeId,
state.layoutByWorktree ?? {},
state.groupsByWorktree ?? {},
state.activeGroupIdByWorktree ?? {}
)
if (!layout) {
return undefined
}
const groups = new Map((state.groupsByWorktree?.[worktreeId] ?? []).map((g) => [g.id, g]))
const tabs = new Map((state.unifiedTabsByWorktree?.[worktreeId] ?? []).map((t) => [t.id, t]))
let best: { groupId: string; lastFocusedAt: number } | undefined
for (const groupId of collectTabGroupLayoutGroupIds(layout)) {
const activeTabId = groups.get(groupId)?.activeTabId
const tab = activeTabId ? tabs.get(activeTabId) : undefined
if (
!tab ||
(tab.groupId ?? groupId) !== groupId ||
tab.contentType !== 'browser' ||
!acceptsTab(tab)
) {
continue
}
if (groupId === focusedGroupId) {
return { targetGroupId: groupId }
}
const lastFocusedAt = tab.lastFocusedAt ?? 0
if (!best || lastFocusedAt > best.lastFocusedAt) {
best = { groupId, lastFocusedAt }
}
}
return best ? { targetGroupId: best.groupId } : undefined
}
@@ -0,0 +1,176 @@
import { focusOpenedBrowserLink } from './browser-link-focus'
import { createBrowserLinkHostMatcher } from './browser-link-owner'
import { resolveBrowserLinkSession } from './browser-link-session'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import type { TabSplitTarget } from '@/store/slices/tabs/tabs-slice-contract'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { toRuntimeExecutionHostId } from '../../../shared/execution-host'
import type { OpenWorkspaceBrowserTabRequest } from './workspace-browser-tab-open'
import { openLocalBrowserLink, browserLinkCommitIsValid } from './browser-link-open'
import {
selectBrowserLinkDestination,
browserOpenResultForPage,
currentBrowserLinkMatch,
type BrowserLinkDestination,
type BrowserOpenOutcome
} from './browser-link-destination'
import { activateBrowserWorkspaceTab } from './browser-workspace-tab-activation'
import {
getFloatingWorkspaceOperationRoute,
resolveWorktreeOperationRoute
} from './worktree-operation-route'
import { getWorkspaceClientCreationActionPolicy } from './client-creation-action-policy'
import { selectWorkspaceSurface } from './workspace-surface-reveal'
import { showWorkspaceTabs } from './http-link-browser-placement'
function ownsBrowserRoute(
state: AppState,
request: OpenWorkspaceBrowserTabRequest,
destination: BrowserLinkDestination
): boolean {
const route =
getFloatingWorkspaceOperationRoute(request.workspaceId) ??
resolveWorktreeOperationRoute(state, request.workspaceId)
if (!route?.executionHostId) {
return false
}
return (
createBrowserLinkHostMatcher(
state,
request.workspaceId,
destination.executionHostId
)(route.executionHostId) &&
(!destination.browserRuntimeEnvironmentId ||
route.runtimeEnvironmentId === destination.browserRuntimeEnvironmentId) &&
getWorkspaceClientCreationActionPolicy(state, request.workspaceId)['managed-browser'].state ===
'enabled'
)
}
export function workspaceBrowserLinkDestination(
request: OpenWorkspaceBrowserTabRequest,
executionHostId: ExecutionHostId,
runtimeId: string | null,
profileHostId: ExecutionHostId
): BrowserLinkDestination {
const state = useAppStore.getState()
const sessionHostId = runtimeId ? toRuntimeExecutionHostId(runtimeId) : profileHostId
return {
worktreeId: request.workspaceId,
executionHostId,
browserRuntimeEnvironmentId: runtimeId,
profileHostId: sessionHostId,
...resolveBrowserLinkSession(state, request.workspaceId, sessionHostId),
resource: { kind: 'website', url: request.url }
}
}
export async function openClientWorkspaceBrowserLink(
request: OpenWorkspaceBrowserTabRequest,
destination: BrowserLinkDestination,
title: string
): Promise<BrowserOpenOutcome> {
if (!request.linkContext) {
return { status: 'failed' }
}
return openLocalBrowserLink({
destination,
context: request.linkContext,
url: request.url,
options: { title, activate: request.focusOnCreate !== false },
ownsRoute: (state) => ownsBrowserRoute(state, request, destination),
reveal: () => {
const state = useAppStore.getState()
selectWorkspaceSurface(state, request.workspaceId)
showWorkspaceTabs(state, request.workspaceId)
}
})
}
export function prepareRuntimeBrowserLink(
request: OpenWorkspaceBrowserTabRequest,
destination: BrowserLinkDestination
): { status: 'create'; groupId?: string; split?: TabSplitTarget } | BrowserOpenOutcome {
const context = request.linkContext
if (!context) {
return { status: 'create', groupId: request.targetGroupId }
}
if (context.isCurrentRequest?.() === false) {
return { status: 'superseded' }
}
const state = useAppStore.getState()
const selection = selectBrowserLinkDestination(state, destination, context.linkIntent)
if (selection.status === 'refused') {
return selection
}
if (
!browserLinkCommitIsValid(
state,
destination,
context,
selection.groupId ?? selection.split?.sourceGroupId,
(current) => ownsBrowserRoute(current, request, destination)
)
) {
return { status: 'refused' }
}
if (selection.match) {
const result = selection.match
if (request.focusOnCreate === false || context.canReveal?.() === false) {
return { status: 'opened', result }
}
selectWorkspaceSurface(state, request.workspaceId)
showWorkspaceTabs(state, request.workspaceId)
if (!currentBrowserLinkMatch(useAppStore.getState(), destination, result)) {
return { status: 'refused' }
}
const activated = activateBrowserWorkspaceTab({
worktreeId: result.worktreeId,
workspaceId: result.browserWorkspaceId,
pageId: result.browserPageId,
executionHostId: result.executionHostId
})
if (!activated) {
return { status: 'refused' }
}
focusOpenedBrowserLink(result)
return { status: 'opened', result }
}
// No negotiated browser.tabCreate contract proves strict placement on every peer yet.
if (context.linkIntent?.targetGroupId) {
return { status: 'unsupported' }
}
return { status: 'create', groupId: selection.groupId, split: selection.split }
}
export function materializedBrowserLinkOutcome(
request: OpenWorkspaceBrowserTabRequest,
destination: BrowserLinkDestination,
remotePageId: string
): BrowserOpenOutcome {
if (request.linkContext?.isCurrentRequest?.() === false) {
return { status: 'superseded' }
}
const state = useAppStore.getState()
if (!ownsBrowserRoute(state, request, destination)) {
return { status: 'refused' }
}
for (const workspace of state.browserTabsByWorktree[request.workspaceId] ?? []) {
for (const page of state.browserPagesByWorkspace[workspace.id] ?? []) {
const handle = state.remoteBrowserPageHandlesByPageId[page.id]
if (
handle?.staged ||
handle?.environmentId !== destination.browserRuntimeEnvironmentId ||
handle.remotePageId !== remotePageId
) {
continue
}
const result = browserOpenResultForPage(state, destination, workspace.id, page.id)
if (result) {
return { status: 'opened', result }
}
}
}
return { status: 'failed' }
}
@@ -0,0 +1,134 @@
import type { AppState } from '@/store/types'
import { parseExecutionHostId } from '../../../shared/execution-host'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import { BROWSER_SCREENCAST_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import {
getWorkspaceClientCreationActionPolicy,
runtimeHostsPagesOnThisDesktop,
type ClientCreationActionAvailability
} from './client-creation-action-policy'
import { resolveWorktreeOperationRoute } from './worktree-operation-route'
import { getExecutionHostIdForWorktree } from './worktree-runtime-owner'
import { resolveSshWorkspaceBrowserRouteEligibility } from './ssh-workspace-browser-route-eligibility'
export function isExpectedRuntimeBrowserRoute(
state: AppState,
availability: ClientCreationActionAvailability,
route: ReturnType<typeof resolveWorktreeOperationRoute>,
workspaceId: string,
expectedRuntimeEnvironmentId: string
): boolean {
if (availability.state !== 'enabled' || workspaceId === FLOATING_TERMINAL_WORKTREE_ID || !route) {
return false
}
const expectedEnvironmentId = expectedRuntimeEnvironmentId.trim()
const environmentId = route.runtimeEnvironmentId?.trim() || null
const capabilities =
state.runtimeStatusByEnvironmentId?.get(expectedEnvironmentId)?.status?.capabilities
if (
environmentId !== expectedEnvironmentId ||
(!capabilities?.includes(BROWSER_SCREENCAST_RUNTIME_CAPABILITY) &&
!runtimeHostsPagesOnThisDesktop(state, expectedEnvironmentId))
) {
return false
}
const host = parseExecutionHostId(route.executionHostId)
return (
!route.executionHostId ||
Boolean(host && (host.kind !== 'runtime' || host.environmentId === environmentId))
)
}
// Why: a managed server reached over SSH has no browser of its own, so its links open on this
// desktop, whose page the SSH-routed browser gate sends through that host (as new tabs do).
export function isSshDeploymentClientBrowserRoute(
state: AppState,
availability: ClientCreationActionAvailability,
route: ReturnType<typeof resolveWorktreeOperationRoute>,
workspaceId: string,
expectedRuntimeEnvironmentId: string
): boolean {
if (
availability.state !== 'enabled' ||
availability.provider !== 'local-client' ||
workspaceId === FLOATING_TERMINAL_WORKTREE_ID ||
!route
) {
return false
}
const expectedEnvironmentId = expectedRuntimeEnvironmentId.trim()
const host = parseExecutionHostId(route.executionHostId)
const eligibility = resolveSshWorkspaceBrowserRouteEligibility(
route.executionHostId,
state.settings,
state.runtimeEnvironments
)
return (
Boolean(expectedEnvironmentId) &&
route.runtimeEnvironmentId?.trim() === expectedEnvironmentId &&
host?.kind === 'runtime' &&
host.environmentId === expectedEnvironmentId &&
eligibility?.eligible === true
)
}
export function canOpenWorkspaceBrowserTabOnRuntime(
state: AppState,
workspaceId: string,
expectedRuntimeEnvironmentId: string
): boolean {
const availability = getWorkspaceClientCreationActionPolicy(state, workspaceId)['managed-browser']
const route = resolveWorktreeOperationRoute(state, workspaceId)
return (
isExpectedRuntimeBrowserRoute(
state,
availability,
route,
workspaceId,
expectedRuntimeEnvironmentId
) ||
isSshDeploymentClientBrowserRoute(
state,
availability,
route,
workspaceId,
expectedRuntimeEnvironmentId
)
)
}
export function isExpectedSshBrowserRoute(
state: AppState,
availability: ClientCreationActionAvailability,
route: ReturnType<typeof resolveWorktreeOperationRoute>,
workspaceId: string,
expectedSshConnectionId: string
): boolean {
if (availability.state !== 'enabled' || workspaceId === FLOATING_TERMINAL_WORKTREE_ID || !route) {
return false
}
const expectedTargetId = expectedSshConnectionId.trim()
const eligibility = resolveSshWorkspaceBrowserRouteEligibility(
getExecutionHostIdForWorktree(state, workspaceId),
state.settings
)
const host = parseExecutionHostId(route.executionHostId)
return (
Boolean(expectedTargetId) &&
route.runtimeEnvironmentId === null &&
eligibility?.eligible === true &&
eligibility.targetId === expectedTargetId &&
host?.kind === 'ssh' &&
host.targetId === expectedTargetId
)
}
export function canOpenWorkspaceBrowserTabOnSsh(
state: AppState,
workspaceId: string,
expectedSshConnectionId: string
): boolean {
const availability = getWorkspaceClientCreationActionPolicy(state, workspaceId)['managed-browser']
const route = resolveWorktreeOperationRoute(state, workspaceId)
return isExpectedSshBrowserRoute(state, availability, route, workspaceId, expectedSshConnectionId)
}
@@ -6,6 +6,7 @@ import {
} from '../../../shared/execution-host'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import { BROWSER_SCREENCAST_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import { BROWSER_CLIENT_HOSTING_RUNTIME_CAPABILITIES } from '../../../shared/browser-client-hosting-eligibility'
import * as clientCreationActionPolicy from './client-creation-action-policy'
import {
canOpenWorkspaceBrowserTabOnRuntime,
@@ -540,6 +541,71 @@ describe('openWorkspaceBrowserTab', () => {
)
})
it('opens an asserted runtime link through desktop hosting without screencast support', async () => {
mocks.state = {
...ownerState(toRuntimeExecutionHostId('hub-a')),
runtimeStatusByEnvironmentId: new Map([
['hub-a', { status: { capabilities: [...BROWSER_CLIENT_HOSTING_RUNTIME_CAPABILITIES] } }]
]),
createBrowserTab: vi.fn()
}
expect(canOpenWorkspaceBrowserTabOnRuntime(mocks.getState(), WORKSPACE_ID, 'hub-a')).toBe(true)
await openWorkspaceBrowserTab({
workspaceId: WORKSPACE_ID,
url: 'http://localhost:3000/',
intent: { kind: 'url' },
expectedRuntimeEnvironmentId: 'hub-a'
})
expect(mocks.createRemote).toHaveBeenCalledWith(
expect.objectContaining({ environmentId: 'hub-a', waitForRegistration: true })
)
expect(mocks.state.createBrowserTab).not.toHaveBeenCalled()
})
it.each([
['desktop hosting is disabled', [...BROWSER_CLIENT_HOSTING_RUNTIME_CAPABILITIES], false, false],
['an older peer lacks capabilities', undefined, true, false],
[
'a peer lacks a hosting capability',
BROWSER_CLIENT_HOSTING_RUNTIME_CAPABILITIES.slice(1),
true,
false
],
[
'the viewing client is paired web',
[...BROWSER_CLIENT_HOSTING_RUNTIME_CAPABILITIES],
true,
true
]
])(
'refuses an asserted runtime link when %s',
async (_label, capabilities, enabled, pairedWeb) => {
mocks.pairedWeb = pairedWeb
mocks.state = {
...ownerState(toRuntimeExecutionHostId('hub-a')),
settings: { browserClientHostedRemoteEnabled: enabled },
runtimeStatusByEnvironmentId: new Map([['hub-a', { status: { capabilities } }]]),
createBrowserTab: vi.fn()
}
expect(canOpenWorkspaceBrowserTabOnRuntime(mocks.getState(), WORKSPACE_ID, 'hub-a')).toBe(
false
)
await expect(
openWorkspaceBrowserTab({
workspaceId: WORKSPACE_ID,
url: 'http://localhost:3000/',
intent: { kind: 'url' },
expectedRuntimeEnvironmentId: 'hub-a'
})
).rejects.toThrow('Unable to open URL.')
expect(mocks.createRemote).not.toHaveBeenCalled()
expect(mocks.state.createBrowserTab).not.toHaveBeenCalled()
}
)
it('does not fall back locally when the runtime create outcome is unknown', async () => {
const createBrowserTab = createBrowserTabMock()
mocks.state = {
@@ -1,3 +1,20 @@
import { createBrowserLinkHostMatcher } from './browser-link-owner'
export {
canOpenWorkspaceBrowserTabOnRuntime,
canOpenWorkspaceBrowserTabOnSsh
} from './workspace-browser-route-eligibility'
import {
isExpectedRuntimeBrowserRoute,
isSshDeploymentClientBrowserRoute,
isExpectedSshBrowserRoute
} from './workspace-browser-route-eligibility'
import type { BrowserLinkOpenContext, BrowserOpenOutcome } from './browser-link-destination'
import {
workspaceBrowserLinkDestination,
openClientWorkspaceBrowserLink,
prepareRuntimeBrowserLink,
materializedBrowserLinkOutcome
} from './workspace-browser-link-open'
import { createWebRuntimeSessionBrowserTab } from '@/runtime/web-runtime-browser-creation'
import { resolveWebRuntimeBrowserClientTargetGroup } from '@/runtime/web-runtime-browser-client-target-group'
import { useAppStore } from '@/store'
@@ -9,28 +26,28 @@ import {
type ExecutionHostId
} from '../../../shared/execution-host'
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,
type ClientCreationActionAvailability
} from './client-creation-action-policy'
import { resolveWorktreeOperationRoute } from './worktree-operation-route'
import {
getFloatingWorkspaceOperationRoute,
resolveWorktreeOperationRoute
} from './worktree-operation-route'
import {
intentPresentation,
openFailure,
validateTarget
} from './workspace-browser-tab-presentation'
import { getExecutionHostIdForWorktree } from './worktree-runtime-owner'
import { resolveSshWorkspaceBrowserRouteEligibility } from './ssh-workspace-browser-route-eligibility'
export type WorkspaceBrowserTabIntent = { kind: 'url' } | { kind: 'search'; engine: SearchEngine }
export type OpenWorkspaceBrowserTabRequest = {
workspaceId: string
linkContext?: BrowserLinkOpenContext
onLinkOutcome?: (outcome: BrowserOpenOutcome) => void
targetGroupId?: string
/** Opens the tab in a new split beside this group instead of targetGroupId. */
split?: TabSplitTarget
@@ -48,135 +65,20 @@ export type OpenWorkspaceBrowserTabRequest = {
placementPreference?: BrowserClientHostPlacementPreference
}
function isExpectedRuntimeBrowserRoute(
state: AppState,
availability: ClientCreationActionAvailability,
route: ReturnType<typeof resolveWorktreeOperationRoute>,
workspaceId: string,
expectedRuntimeEnvironmentId: string
): boolean {
if (availability.state !== 'enabled' || workspaceId === FLOATING_TERMINAL_WORKTREE_ID || !route) {
return false
}
const expectedEnvironmentId = expectedRuntimeEnvironmentId.trim()
const environmentId = route.runtimeEnvironmentId?.trim() || null
const capabilities =
state.runtimeStatusByEnvironmentId?.get(expectedEnvironmentId)?.status?.capabilities
if (
environmentId !== expectedEnvironmentId ||
(!capabilities?.includes(BROWSER_SCREENCAST_RUNTIME_CAPABILITY) &&
!runtimeHostsPagesOnThisDesktop(state, expectedEnvironmentId))
) {
return false
}
const host = parseExecutionHostId(route.executionHostId)
return (
!route.executionHostId ||
Boolean(host && (host.kind !== 'runtime' || host.environmentId === environmentId))
)
}
// SSH-deployed managed servers have no browsers; host links on this desktop through that SSH route, as new tabs do.
function isSshDeploymentClientBrowserRoute(
state: AppState,
availability: ClientCreationActionAvailability,
route: ReturnType<typeof resolveWorktreeOperationRoute>,
workspaceId: string,
expectedRuntimeEnvironmentId: string
): boolean {
if (
availability.state !== 'enabled' ||
availability.provider !== 'local-client' ||
workspaceId === FLOATING_TERMINAL_WORKTREE_ID ||
!route
) {
return false
}
const expectedEnvironmentId = expectedRuntimeEnvironmentId.trim()
const host = parseExecutionHostId(route.executionHostId)
const eligibility = resolveSshWorkspaceBrowserRouteEligibility(
route.executionHostId,
state.settings,
state.runtimeEnvironments
)
return (
Boolean(expectedEnvironmentId) &&
route.runtimeEnvironmentId?.trim() === expectedEnvironmentId &&
host?.kind === 'runtime' &&
host.environmentId === expectedEnvironmentId &&
eligibility?.eligible === true
)
}
export function canOpenWorkspaceBrowserTabOnRuntime(
state: AppState,
workspaceId: string,
expectedRuntimeEnvironmentId: string
): boolean {
const availability = getClientCreationActionPolicy(state, workspaceId)['managed-browser']
const route = resolveWorktreeOperationRoute(state, workspaceId)
return (
isExpectedRuntimeBrowserRoute(
state,
availability,
route,
workspaceId,
expectedRuntimeEnvironmentId
) ||
isSshDeploymentClientBrowserRoute(
state,
availability,
route,
workspaceId,
expectedRuntimeEnvironmentId
)
)
}
function isExpectedSshBrowserRoute(
state: AppState,
availability: ClientCreationActionAvailability,
route: ReturnType<typeof resolveWorktreeOperationRoute>,
workspaceId: string,
expectedSshConnectionId: string
): boolean {
if (availability.state !== 'enabled' || workspaceId === FLOATING_TERMINAL_WORKTREE_ID || !route) {
return false
}
const expectedTargetId = expectedSshConnectionId.trim()
const eligibility = resolveSshWorkspaceBrowserRouteEligibility(
getExecutionHostIdForWorktree(state, workspaceId),
state.settings
)
const host = parseExecutionHostId(route.executionHostId)
return (
Boolean(expectedTargetId) &&
route.runtimeEnvironmentId === null &&
eligibility?.eligible === true &&
eligibility.targetId === expectedTargetId &&
host?.kind === 'ssh' &&
host.targetId === expectedTargetId
)
}
export function canOpenWorkspaceBrowserTabOnSsh(
state: AppState,
workspaceId: string,
expectedSshConnectionId: string
): boolean {
const availability = getClientCreationActionPolicy(state, workspaceId)['managed-browser']
const route = resolveWorktreeOperationRoute(state, workspaceId)
return isExpectedSshBrowserRoute(state, availability, route, workspaceId, expectedSshConnectionId)
}
function createClientBrowserTab(
async function createClientBrowserTab(
state: AppState,
request: OpenWorkspaceBrowserTabRequest,
hostId: ExecutionHostId,
presentation: { error: string; title: string },
// Desktop-rendered pages in runtime workspaces keep runtime ownership or palette/activation disowns their tabs.
ownerHostId: ExecutionHostId = hostId
): string | null {
): Promise<string | null> {
if (request.linkContext) {
const destination = workspaceBrowserLinkDestination(request, ownerHostId, null, hostId)
const outcome = await openClientWorkspaceBrowserLink(request, destination, presentation.title)
request.onLinkOutcome?.(outcome)
return outcome.status === 'opened' ? outcome.result.browserPageId : null
}
try {
const created = state.createBrowserTab(request.workspaceId, request.url, {
activate: request.focusOnCreate !== false,
@@ -208,6 +110,10 @@ function assertManagedBrowserEnabled(
export async function openWorkspaceBrowserTab(
request: OpenWorkspaceBrowserTabRequest
): Promise<string | null> {
if (request.linkContext?.isCurrentRequest?.() === false) {
request.onLinkOutcome?.({ status: 'superseded' })
return null
}
const presentation = intentPresentation(request.intent, request.url)
if (!validateTarget(request.url)) {
throw openFailure(presentation.error, 'target is not an http(s) URL')
@@ -215,10 +121,25 @@ export async function openWorkspaceBrowserTab(
const state = useAppStore.getState()
const availability = getClientCreationActionPolicy(state, request.workspaceId)['managed-browser']
assertManagedBrowserEnabled(availability, presentation)
const route = resolveWorktreeOperationRoute(state, request.workspaceId)
const route =
getFloatingWorkspaceOperationRoute(request.workspaceId) ??
resolveWorktreeOperationRoute(state, request.workspaceId)
if (!route) {
throw openFailure(presentation.error, 'no active worktree route')
}
const capturedHost = request.linkContext?.linkIntent?.hostId
if (
capturedHost &&
(!route.executionHostId ||
!createBrowserLinkHostMatcher(
state,
request.workspaceId,
route.executionHostId
)(capturedHost))
) {
request.onLinkOutcome?.({ status: 'refused' })
return null
}
const environmentId = route.runtimeEnvironmentId?.trim() || null
const expectedEnvironmentId =
request.expectedRuntimeEnvironmentId === undefined
@@ -289,13 +210,42 @@ export async function openWorkspaceBrowserTab(
host?.kind === 'runtime' && route.executionHostId ? route.executionHostId : localHostId
return createClientBrowserTab(state, request, localHostId, presentation, ownerHostId)
}
const destination =
request.linkContext && route.executionHostId
? workspaceBrowserLinkDestination(
request,
route.executionHostId,
environmentId,
route.executionHostId
)
: null
const placement = destination
? prepareRuntimeBrowserLink(request, destination)
: { status: 'create' as const, groupId: request.targetGroupId }
if (placement.status !== 'create') {
request.onLinkOutcome?.(placement)
return placement.status === 'opened' ? placement.result.browserPageId : null
}
const targetGroupId = placement.groupId
let created = false
try {
created = await createWebRuntimeSessionBrowserTab({
worktreeId: request.workspaceId,
environmentId,
url: request.url,
...resolveWebRuntimeBrowserClientTargetGroup(state, request, environmentId),
...resolveWebRuntimeBrowserClientTargetGroup(
state,
{ ...request, targetGroupId, ...(destination ? { split: placement.split } : {}) },
environmentId
),
...(destination ? { profileId: destination.sessionProfileId } : {}),
...(request.linkContext ? { isCurrentRequest: request.linkContext.isCurrentRequest } : {}),
...(destination
? {
onMaterialized: (pageId: string) =>
request.onLinkOutcome?.(materializedBrowserLinkOutcome(request, destination, pageId))
}
: {}),
...(request.afterTabId ? { clientAfterTabId: request.afterTabId } : {}),
// Owner-pinned links need the host tab published before client reconciliation.
...(expectedEnvironmentId !== null ? { waitForRegistration: true } : {}),
@@ -317,3 +267,21 @@ export async function openWorkspaceBrowserTab(
}
return null
}
/** Detailed link results are additive to the generic page-id contract. */
export async function openWorkspaceBrowserTabForLink(
request: OpenWorkspaceBrowserTabRequest & { linkContext: BrowserLinkOpenContext }
): Promise<BrowserOpenOutcome> {
let outcome: BrowserOpenOutcome = { status: 'failed' }
try {
await openWorkspaceBrowserTab({
...request,
onLinkOutcome: (result) => {
outcome = result
}
})
} catch {
return { status: request.linkContext.isCurrentRequest?.() === false ? 'superseded' : 'failed' }
}
return outcome
}
@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import type { RuntimeMobileSessionBrowserTab } from '../../../shared/runtime-types'
import {
browserLinkTestDestination,
createBrowserLinkTestStore,
URL_TO_OPEN,
WORKSPACE
} from '../lib/browser-link-open.test-fixture'
import { selectBrowserLinkDestination } from '../lib/browser-link-destination'
import { captureLinkOpenIntent } from '../lib/link-open-intent'
import { makeWorktree } from '../store/slices/store-test-helpers'
import { buildMobileSessionTabSnapshots } from './sync-runtime-graph/mobile-session-snapshots'
import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api'
function desktopSnapshot(profileId: string | null | undefined) {
const host = createBrowserLinkTestStore()
const workspace = host.getState().createBrowserTab(WORKSPACE, URL_TO_OPEN, {
targetGroupId: 'b',
sessionProfileId: profileId,
browserRuntimeEnvironmentId: null,
executionHostId: 'local',
activate: false
})
host.setState({
browserTabsByWorktree: { [WORKSPACE]: [{ ...workspace, sessionProfileId: profileId }] }
})
const snapshot = buildMobileSessionTabSnapshots(host.getState()).find(
(candidate) => candidate.worktree === WORKSPACE
)
if (!snapshot) {
throw new Error('Missing desktop snapshot')
}
return {
...snapshot,
tabs: snapshot.tabs.filter(
(tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser'
)
}
}
function mirrorOnClient(snapshot: ReturnType<typeof desktopSnapshot>) {
const client = createBrowserLinkTestStore()
client.setState({
activeWorkspaceExecutionHostId: 'runtime:server',
worktreesByRepo: {
repo1: [makeWorktree({ id: WORKSPACE, repoId: 'repo1', hostId: 'runtime:server' })]
}
})
client.setState(applyWebSessionTabsSnapshot(client.getState(), snapshot, 'server'))
return client
}
function destination(profileId: string | null) {
return browserLinkTestDestination({
executionHostId: 'runtime:server',
browserRuntimeEnvironmentId: 'server',
profileHostId: 'runtime:server',
sessionProfileId: profileId
})
}
function selectPage(client: ReturnType<typeof mirrorOnClient>, profileId: string | null) {
const state = client.getState()
const browserTab = state.unifiedTabsByWorktree[WORKSPACE]?.find(
(tab) => tab.contentType === 'browser'
)
if (!browserTab) {
throw new Error('Missing mirrored browser tab')
}
return selectBrowserLinkDestination(
state,
destination(profileId),
captureLinkOpenIntent(state, WORKSPACE, browserTab.id, { explicitSourceGroup: true })
)
}
describe('server browser profile publication and link reuse', () => {
it.each([null, 'default', 'isolated-login'])(
'reuses a desktop-published page only for its authoritative profile (%s)',
(profileId) => {
const snapshot = desktopSnapshot(profileId)
const client = mirrorOnClient(snapshot)
const row = snapshot.tabs[0]!
expect(row.browserProfileId).toBe(profileId ?? 'default')
expect(selectPage(client, profileId)).toMatchObject({
status: 'ready',
match: { browserPageId: row.browserPageId, browserWorkspaceId: row.browserWorkspaceId }
})
expect(selectPage(client, 'another-login')).toMatchObject({ status: 'ready', match: null })
}
)
it('keeps unknown legacy metadata ineligible for both default and named-profile reuse', () => {
const snapshot = desktopSnapshot(undefined)
expect(snapshot.tabs[0]?.browserProfileId).toBeUndefined()
const client = mirrorOnClient(snapshot)
for (const profileId of [null, 'isolated-login']) {
expect(selectPage(client, profileId)).toMatchObject({ status: 'ready', match: null })
}
})
it('does not infer a remote page profile from its mirrored workspace metadata', () => {
const client = mirrorOnClient(desktopSnapshot('isolated-login'))
const republished = buildMobileSessionTabSnapshots(client.getState()).find(
(snapshot) => snapshot.worktree === WORKSPACE
)
const row = republished?.tabs.find((tab) => tab.type === 'browser')
expect(row).toBeDefined()
expect(row?.type === 'browser' && row.browserProfileId).toBeUndefined()
})
it('clears a previously known identity when an old peer republishes without it', () => {
const snapshot = desktopSnapshot('isolated-login')
const client = mirrorOnClient(snapshot)
client.setState(
applyWebSessionTabsSnapshot(
client.getState(),
{
...snapshot,
snapshotVersion: snapshot.snapshotVersion + 1,
tabs: snapshot.tabs.map(({ browserProfileId: _profileId, ...row }) => row)
},
'server'
)
)
expect(selectPage(client, 'isolated-login')).toMatchObject({ status: 'ready', match: null })
})
})
@@ -48,6 +48,37 @@ function makeBrowserWorkspace(
}
describe('browser mobile session sync', () => {
it.each([{ sessionProfileId: 'isolated-login' }, { sessionPartition: 'persist:changed-login' }])(
'invalidates profile-only changes (%o)',
(profileChange) => {
const workspace = { ...makeBrowserWorkspace(), sessionProfileId: null }
const base = makeState({ browserTabsByWorktree: { 'wt-1': [workspace] } })
const initialKey = getRuntimeMobileSessionSyncKey(base)
const initialSnapshot = buildMobileSessionTabSnapshots(base)[0]
const changed = {
...base,
browserTabsByWorktree: { 'wt-1': [{ ...workspace, ...profileChange }] }
}
expect(
runtimeMobileSessionSyncKeysEqual(
initialKey,
getRuntimeMobileSessionSyncKey(changed, base, initialKey)
)
).toBe(false)
const updatedSnapshot = buildMobileSessionTabSnapshots(changed)[0]
if (profileChange.sessionProfileId) {
expect(updatedSnapshot).not.toBe(initialSnapshot)
} else {
expect(updatedSnapshot).toBe(initialSnapshot)
}
expect(updatedSnapshot?.tabs[0]).toMatchObject({
browserProfileId: profileChange.sessionProfileId ?? 'default'
})
expect(buildMobileSessionTabSnapshots(changed)[0]).toBe(updatedSnapshot)
}
)
it('changes when browser tab page state changes', () => {
const base = makeState({
browserTabsByWorktree: {
@@ -68,6 +99,39 @@ describe('browser mobile session sync', () => {
)
})
it('withdraws local profile authority when the active page becomes a remote mirror', () => {
const workspace = { ...makeBrowserWorkspace(), sessionProfileId: 'local-login' }
const page = {
...workspace,
id: 'page-1',
workspaceId: workspace.id,
browserRuntimeEnvironmentId: null
}
const base = makeState({
browserTabsByWorktree: { 'wt-1': [workspace] },
browserPagesByWorkspace: { [workspace.id]: [page] }
})
const initialKey = getRuntimeMobileSessionSyncKey(base)
expect(buildMobileSessionTabSnapshots(base)[0]?.tabs[0]).toMatchObject({
browserProfileId: 'local-login'
})
const changed = {
...base,
browserPagesByWorkspace: {
[workspace.id]: [{ ...page, browserRuntimeEnvironmentId: 'remote-host' }]
}
}
expect(
runtimeMobileSessionSyncKeysEqual(
initialKey,
getRuntimeMobileSessionSyncKey(changed, base, initialKey)
)
).toBe(false)
const row = buildMobileSessionTabSnapshots(changed)[0]?.tabs[0]
expect(row?.type === 'browser' && row.browserProfileId).toBeUndefined()
})
it('publishes browser tabs with active page metadata', () => {
const state = makeState({
activeGroupIdByWorktree: { 'wt-1': 'group-1' },
@@ -20,6 +20,10 @@ export function buildMobileBrowserTab(
title,
browserWorkspaceId: workspace.id,
browserPageId: activePage?.id ?? workspace.activePageId ?? null,
browserProfileId:
workspace.sessionProfileId === undefined || activePage?.browserRuntimeEnvironmentId
? undefined
: (workspace.sessionProfileId ?? 'default'),
url: activePage?.url ?? workspace.url ?? 'about:blank',
loading: activePage?.loading ?? workspace.loading,
canGoBack: activePage?.canGoBack ?? workspace.canGoBack,
@@ -145,6 +145,8 @@ function buildBrowserWorkspacesProjection(
projection: JSON.stringify(
workspaces.map((workspace) => ({
id: workspace.id,
sessionProfileId: workspace.sessionProfileId,
sessionPartition: workspace.sessionPartition,
activePageId: workspace.activePageId,
title: workspace.title,
url: workspace.url,
@@ -188,6 +190,7 @@ function buildBrowserPagesProjection(
projection: JSON.stringify(
pages.map((page) => ({
id: page.id,
browserRuntimeEnvironmentId: page.browserRuntimeEnvironmentId,
title: page.title,
url: page.url,
loading: page.loading,
@@ -1,3 +1,4 @@
import { subscribeLinkOpenRequests } from '../lib/link-open-request'
import type { BrowserClientHostPlacementPreference } from '../../../shared/browser-client-host-placement'
import { BROWSER_TAB_CREATE_KNOWN_ID_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import { assertRuntimeManagedBrowserCreationAvailable } from '../lib/client-creation-action-policy'
@@ -32,6 +33,8 @@ import { selectWebRuntimeSessionBrowserWorktree } from './web-runtime-session-wo
export type CreateWebRuntimeSessionBrowserTabArgs = {
worktreeId: string
isCurrentRequest?: () => boolean
onMaterialized?: (remotePageId: string) => void
environmentId?: string | null
url?: string
profileId?: string | null
@@ -192,6 +195,19 @@ export function stageWebRuntimeBrowserCreation(context: WebRuntimeBrowserCreatio
context.unsubscribeFocusGuard()
})
}
if (args.isCurrentRequest) {
const unsubscribeState = context.unsubscribeFocusGuard
const unsubscribeRequests = subscribeLinkOpenRequests(() => {
if (args.isCurrentRequest?.() === false) {
clearWebSessionFocusIntentIfMatches(intentOwner, args.worktreeId, context.guardedPageId)
context.unsubscribeFocusGuard()
}
})
context.unsubscribeFocusGuard = () => {
unsubscribeState()
unsubscribeRequests()
}
}
}
export function restageWebRuntimeBrowserCreation(
@@ -50,6 +50,7 @@ export async function createWebRuntimeSessionBrowserTab(
}
const context = createWebRuntimeBrowserCreationContext(args, environmentId)
let stagedWorkspaceId: string | undefined
let materializedPageId: string | undefined
try {
stageWebRuntimeBrowserCreation(context)
stagedWorkspaceId = context.staged?.workspaceId
@@ -206,7 +207,8 @@ export async function createWebRuntimeSessionBrowserTab(
throw new StagedWebRuntimeBrowserTabCancelledError()
}
// Materialized rows belong to the snapshot; later failures must not unwind them as staging.
return completeWebRuntimeBrowserCreation(context)
completeWebRuntimeBrowserCreation(context)
materializedPageId = created.browserPageId
} catch (error) {
const failure = prepareWebRuntimeBrowserCreationFailure(context, error)
if (failure.cleanupPageId) {
@@ -261,6 +263,10 @@ export async function createWebRuntimeSessionBrowserTab(
forgetStagedBrowserTabHandover(stagedWorkspaceId)
}
}
if (materializedPageId) {
args.onMaterialized?.(materializedPageId)
}
return true
}
registerPairedBrowserTabCreator(createWebRuntimeSessionBrowserTab)
@@ -0,0 +1,319 @@
import type * as BrowserFocusModule from '@/components/browser-pane/host-guest/browser-focus'
import { requestBrowserFocus } from '@/components/browser-pane/host-guest/browser-focus'
import type { BrowserSessionProfile } from '../../../shared/browser-workspace-types'
import { makeUnifiedTab } from '../store/slices/store-test-helpers'
import { getDefaultSettings } from '../../../shared/constants'
import { SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY } from '../../../shared/session-tabs-runtime-capabilities'
import {
BROWSER_SCREENCAST_RUNTIME_CAPABILITY,
BROWSER_TAB_CREATE_KNOWN_ID_RUNTIME_CAPABILITY
} from '../../../shared/protocol-version'
import { canOfferHttpLinkGroupAction } from '../lib/http-link-in-group-availability'
import { createWebRuntimeSessionBrowserTab } from './web-runtime-browser-creation'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as SnapshotModule from './web-runtime-session-snapshot'
import { useAppStore } from '../store'
import { openWorkspaceBrowserTabForLink } from '../lib/workspace-browser-tab-open'
import * as workspaceBrowserTabOpen from '../lib/workspace-browser-tab-open'
import { openHttpLink } from '../lib/http-link-routing'
import { captureLinkOpenIntent } from '../lib/link-open-intent'
import { beginLinkOpenRequest } from '../lib/link-open-request'
import { makeWorktree, TEST_REPO } from '../store/slices/worktrees-slice-test-fixtures'
import { resetWebSessionBrowserPlacementsForTests } from './web-session-browser-placement'
import { resetWebSessionTabsSyncTestState, ENV, WT } from './web-session-tabs-sync-test-harness'
import {
G1,
G2,
HOST,
SOURCE,
pendingCreates,
publishHostSnapshot,
resetHostPages,
seedPairedWorktree,
installRuntimeTransport,
runtimeStatusesFor,
wrapperForRemotePage
} from './web-runtime-browser-creation-placement-test-rig'
import { captureWebSessionIntentOwner } from './web-runtime-session-environment'
import { peekWebSessionFocusIntent } from './web-session-focus-intent'
vi.mock('@/components/browser-pane/host-guest/browser-focus', async (importOriginal) => ({
...(await importOriginal<typeof BrowserFocusModule>()),
requestBrowserFocus: vi.fn()
}))
vi.mock('./web-runtime-session-snapshot', async (importOriginal) => ({
...(await importOriginal<typeof SnapshotModule>()),
refreshWebRuntimeSessionTabsSnapshot: vi.fn(async () => publishHostSnapshot())
}))
beforeEach(() => {
resetWebSessionTabsSyncTestState()
resetWebSessionBrowserPlacementsForTests()
resetHostPages()
seedPairedWorktree()
useAppStore.setState({
repos: [{ ...TEST_REPO, id: 'repo', executionHostId: HOST }],
worktreesByRepo: {
repo: [
makeWorktree({
id: WT,
repoId: 'repo',
path: '/worktree',
hostId: HOST,
runtimeOwnerEnvironmentId: ENV
})
]
}
})
installRuntimeTransport()
vi.mocked(requestBrowserFocus).mockClear()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function open(explicit = false) {
return openWorkspaceBrowserTabForLink({
workspaceId: WT,
url: 'https://example.com/link',
intent: { kind: 'url' },
expectedRuntimeEnvironmentId: ENV,
placementPreference: 'server',
selectWorktree: false,
linkContext: {
linkIntent: captureLinkOpenIntent(useAppStore.getState(), WT, SOURCE.unifiedTabId, {
explicitSourceGroup: explicit
}),
isCurrentRequest: beginLinkOpenRequest()
}
})
}
describe('runtime link opens through staging and materialization', () => {
it('does not request browser focus after a routed link completes with another pane focused', async () => {
useAppStore.setState({
settings: { ...getDefaultSettings('/home/test'), openLinksInApp: true }
})
const opening = vi.spyOn(workspaceBrowserTabOpen, 'openWorkspaceBrowserTabForLink')
openHttpLink('https://example.com/link', {
worktreeId: WT,
allowRemoteInApp: true,
sourceOwner: { kind: 'runtime', runtimeEnvironmentId: ENV },
linkContext: {
linkIntent: captureLinkOpenIntent(useAppStore.getState(), WT, SOURCE.unifiedTabId),
isCurrentRequest: beginLinkOpenRequest()
}
})
await vi.waitFor(() => expect(pendingCreates).toHaveLength(1))
const owner = captureWebSessionIntentOwner(ENV)
expect(peekWebSessionFocusIntent(owner, WT)).not.toBeNull()
useAppStore.getState().focusGroup(WT, G1)
expect(peekWebSessionFocusIntent(owner, WT)).toBeNull()
vi.mocked(requestBrowserFocus).mockClear()
pendingCreates[0]!.resolve('routed-host-page')
expect(await opening.mock.results[0]!.value).toMatchObject({ status: 'opened' })
await Promise.resolve()
await Promise.resolve()
const browser = wrapperForRemotePage('routed-host-page')!
expect(useAppStore.getState().activeGroupIdByWorktree[WT]).toBe(G1)
expect(
useAppStore.getState().groupsByWorktree[WT]?.find((group) => group.id === G2)?.activeTabId
).toBe(browser.id)
expect(requestBrowserFocus).not.toHaveBeenCalled()
})
it('stages in the visible browser group and returns the reconciled host page identity', async () => {
const creating = open()
expect(pendingCreates).toHaveLength(1)
const provisional = pendingCreates[0]!.params.page
expect(pendingCreates[0]!.params.targetGroupId).toBe(G2)
expect(wrapperForRemotePage(provisional!)?.groupId).toBe(G2)
pendingCreates[0]!.resolve('actual-host-page')
const outcome = await creating
expect(outcome).toMatchObject({
status: 'opened',
result: { groupId: G2, executionHostId: HOST, worktreeId: WT }
})
if (outcome.status !== 'opened') {
throw new Error('Expected open')
}
expect(outcome.result.unifiedTabId).toBe(wrapperForRemotePage('actual-host-page')?.id)
expect(
useAppStore.getState().remoteBrowserPageHandlesByPageId[outcome.result.browserPageId]
).toMatchObject({ remotePageId: 'actual-host-page', environmentId: ENV })
})
it('creates a client split beside the source when no browser is visible', async () => {
useAppStore.setState((state) => ({
groupsByWorktree: {
[WT]: state.groupsByWorktree[WT]!.map((group) => ({
...group,
activeTabId: `terminal-${group.id}`,
tabOrder: [...group.tabOrder, `terminal-${group.id}`]
}))
},
unifiedTabsByWorktree: {
[WT]: [
...state.unifiedTabsByWorktree[WT]!,
...[G1, G2].map((groupId) =>
makeUnifiedTab({
id: `terminal-${groupId}`,
entityId: `terminal-${groupId}`,
worktreeId: WT,
groupId,
contentType: 'terminal',
executionHostId: HOST
})
)
]
}
}))
const creating = open()
expect(pendingCreates).toHaveLength(1)
const provisional = pendingCreates[0]!.params.page!
const staged = wrapperForRemotePage(provisional)!
expect([G1, G2]).not.toContain(staged.groupId)
expect(useAppStore.getState().layoutByWorktree[WT]).toMatchObject({
first: { type: 'split', first: { groupId: G1 }, second: { groupId: staged.groupId } }
})
pendingCreates[0]!.resolve('split-host-page')
expect(await creating).toMatchObject({ status: 'opened', result: { groupId: staged.groupId } })
expect(wrapperForRemotePage('split-host-page')?.groupId).toBe(staged.groupId)
})
it('refuses new explicit creation before staging when strict peer placement is unproven', async () => {
const before = useAppStore.getState().unifiedTabsByWorktree
expect(await open(true)).toEqual({ status: 'unsupported' })
expect(pendingCreates).toHaveLength(0)
expect(useAppStore.getState().unifiedTabsByWorktree).toBe(before)
})
it('offers explicit runtime reuse only when a matching pinned page proves the route and session', async () => {
const state = useAppStore.getState()
const profile: BrowserSessionProfile = {
id: 'default',
scope: 'default',
partition: 'persist:remote-default',
label: 'Default',
source: null
}
useAppStore.setState({
browserSessionProfilesByHostId: { [HOST]: [profile] },
remoteBrowserPageHandlesByPageId: {
...state.remoteBrowserPageHandlesByPageId,
[SOURCE.pageId]: {
environmentId: ENV,
remotePageId: SOURCE.pageId,
browserProfileId: 'default'
}
},
browserTabsByWorktree: {
[WT]: state.browserTabsByWorktree[WT]!.map((workspace) => ({
...workspace,
sessionProfileId: null,
sessionPartition: 'persist:remote-default'
}))
},
browserPagesByWorkspace: {
...state.browserPagesByWorkspace,
[SOURCE.workspaceId]: state.browserPagesByWorkspace[SOURCE.workspaceId]!.map((page) => ({
...page,
url: 'https://example.com/link'
}))
}
})
expect(
canOfferHttpLinkGroupAction(useAppStore.getState(), {
workspaceId: WT,
sourceTabId: SOURCE.unifiedTabId,
url: 'https://example.com/link',
sourceOwner: { kind: 'runtime', runtimeEnvironmentId: ENV }
})
).toBe(true)
expect(await open(true)).toMatchObject({
status: 'opened',
result: { browserWorkspaceId: SOURCE.workspaceId, browserPageId: SOURCE.pageId, groupId: G1 }
})
expect(pendingCreates).toHaveLength(0)
})
it('keeps new explicit creation unavailable even if a session split capability is advertised', async () => {
useAppStore.setState({
runtimeStatusByEnvironmentId: runtimeStatusesFor([
BROWSER_SCREENCAST_RUNTIME_CAPABILITY,
BROWSER_TAB_CREATE_KNOWN_ID_RUNTIME_CAPABILITY,
SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY
])
})
expect(
canOfferHttpLinkGroupAction(useAppStore.getState(), {
workspaceId: WT,
sourceTabId: SOURCE.unifiedTabId,
url: 'https://example.com/link',
sourceOwner: { kind: 'runtime', runtimeEnvironmentId: ENV }
})
).toBe(false)
expect(await open(true)).toEqual({ status: 'unsupported' })
expect(pendingCreates).toHaveLength(0)
})
it('reconciles a page from an older peer that cannot accept a provisional ID', async () => {
useAppStore.setState({
runtimeStatusByEnvironmentId: runtimeStatusesFor([BROWSER_SCREENCAST_RUNTIME_CAPABILITY])
})
const creating = open()
expect(pendingCreates[0]!.params.page).toBeUndefined()
pendingCreates[0]!.resolve('legacy-page')
const outcome = await creating
expect(outcome).toMatchObject({ status: 'opened', result: { groupId: G2 } })
if (outcome.status !== 'opened') {
throw new Error('Expected open')
}
expect(
useAppStore.getState().remoteBrowserPageHandlesByPageId[outcome.result.browserPageId]
?.remotePageId
).toBe('legacy-page')
})
it('a delivery callback failure cannot unwind a page that already materialized', async () => {
const call = installRuntimeTransport()
const creating = createWebRuntimeSessionBrowserTab({
worktreeId: WT,
environmentId: ENV,
url: 'https://example.com/link',
placementPreference: 'server',
selectWorktree: false,
clientTargetGroupId: G2,
onMaterialized: () => {
throw new Error('Delivery failed')
}
})
pendingCreates[0]!.resolve('delivered-page')
await expect(creating).rejects.toThrow('Delivery failed')
expect(wrapperForRemotePage('delivered-page')).toBeDefined()
expect(call.mock.calls.some(([request]) => request.method === 'browser.tabClose')).toBe(false)
})
it('a newer link clears pending focus intent and suppresses stale success without claiming cancellation', async () => {
const creating = open()
const owner = captureWebSessionIntentOwner(ENV)
expect(peekWebSessionFocusIntent(owner, WT)).not.toBeNull()
beginLinkOpenRequest()
expect(peekWebSessionFocusIntent(owner, WT)).toBeNull()
pendingCreates[0]!.resolve('superseded-host-page')
expect(await creating).toEqual({ status: 'superseded' })
expect(wrapperForRemotePage('superseded-host-page')).toBeDefined()
})
it('reports the current group after the user moves the staged tab', async () => {
const creating = open()
const provisional = pendingCreates[0]!.params.page!
const staged = wrapperForRemotePage(provisional)!
useAppStore.getState().moveUnifiedTabToGroup(staged.id, G1)
pendingCreates[0]!.resolve()
expect(await creating).toMatchObject({ status: 'opened', result: { groupId: G1 } })
})
})
@@ -0,0 +1,284 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type * as BrowserFocusModule from '@/components/browser-pane/host-guest/browser-focus'
import type { RuntimeMobileSessionBrowserTab } from '../../../shared/runtime-types'
import { getDefaultSettings } from '../../../shared/constants'
import { useAppStore } from '../store'
import { openWorkspaceBrowserTabForLink } from '../lib/workspace-browser-tab-open'
import { selectBrowserLinkDestination } from '../lib/browser-link-destination'
import { workspaceBrowserLinkDestination } from '../lib/workspace-browser-link-open'
import { captureLinkOpenIntent } from '../lib/link-open-intent'
import { makeWorktree, TEST_REPO } from '../store/slices/worktrees-slice-test-fixtures'
import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api'
import type * as SnapshotModule from './web-runtime-session-snapshot'
import {
resetWebSessionTabsSyncTestState,
ENV,
WT,
NOW,
makeSnapshot
} from './web-session-tabs-sync-test-harness'
import {
seedPairedWorktree,
installRuntimeTransport,
pendingCreates,
resetHostPages,
publishHostSnapshot,
wrapperForRemotePage,
HOST,
SOURCE,
AFTER,
OTHER
} from './web-runtime-browser-creation-placement-test-rig'
vi.mock('@/components/browser-pane/host-guest/browser-focus', async (importOriginal) => ({
...(await importOriginal<typeof BrowserFocusModule>()),
requestBrowserFocus: vi.fn()
}))
vi.mock('./web-runtime-session-snapshot', async (importOriginal) => ({
...(await importOriginal<typeof SnapshotModule>()),
refreshWebRuntimeSessionTabsSnapshot: vi.fn(async () => publishHostSnapshot())
}))
let snapshotVersion = 1
const URL_TO_OPEN = 'https://example.com/link'
beforeEach(() => {
resetWebSessionTabsSyncTestState()
resetHostPages()
seedPairedWorktree()
snapshotVersion = 1
useAppStore.setState({
repos: [{ ...TEST_REPO, id: 'repo', executionHostId: HOST }],
worktreesByRepo: {
repo: [
makeWorktree({
id: WT,
repoId: 'repo',
path: '/worktree',
hostId: HOST,
runtimeOwnerEnvironmentId: ENV
})
]
},
browserSessionHostIdOverride: 'local',
defaultBrowserSessionProfileId: null,
defaultBrowserSessionProfileIdByHostId: {},
browserSessionProfilesByHostId: {
[HOST]: [
{
id: 'default',
scope: 'default' as const,
partition: 'persist:remote-default',
label: 'Default',
source: null
}
]
}
})
installRuntimeTransport()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function open() {
return openWorkspaceBrowserTabForLink({
workspaceId: WT,
url: URL_TO_OPEN,
intent: { kind: 'url' },
expectedRuntimeEnvironmentId: ENV,
placementPreference: 'server',
selectWorktree: false,
linkContext: {
linkIntent: captureLinkOpenIntent(useAppStore.getState(), WT, SOURCE.unifiedTabId)
}
})
}
function publishProfileSnapshot(profileId: string | undefined): void {
const tabs: RuntimeMobileSessionBrowserTab[] = [SOURCE, AFTER, OTHER].map((row) => ({
type: 'browser',
id: `host-${row.unifiedTabId}`,
browserPageId: row.pageId,
browserWorkspaceId: row.workspaceId,
...(row === OTHER && profileId ? { browserProfileId: profileId } : {}),
title: 'Example',
url: row === OTHER ? URL_TO_OPEN : 'https://example.com/',
loading: false,
canGoBack: false,
canGoForward: false,
isActive: false
}))
const snapshot = makeSnapshot(tabs, {
snapshotVersion: ++snapshotVersion,
activeTabType: 'browser',
activeTabId: null
})
useAppStore.setState((state) =>
applyWebSessionTabsSnapshot(state, snapshot, ENV, NOW + snapshotVersion)
)
}
function selectDestination() {
const state = useAppStore.getState()
return selectBrowserLinkDestination(
state,
workspaceBrowserLinkDestination(
{ workspaceId: WT, url: URL_TO_OPEN, intent: { kind: 'url' } },
HOST,
ENV,
HOST
),
captureLinkOpenIntent(state, WT, SOURCE.unifiedTabId)
)
}
it.each([undefined, null])(
'keeps the remote default independent of local workspace profiles (%s)',
async (remoteDefault) => {
useAppStore.setState({
settings: {
...getDefaultSettings('/home/test'),
...useAppStore.getState().settings,
browserProfileScope: 'worktree'
},
defaultBrowserSessionProfileId: 'local-only-profile',
defaultBrowserSessionProfileIdByHostId: { local: 'local-only-profile', [HOST]: remoteDefault }
})
const creating = open()
const sentProfile = pendingCreates[0]?.params.profileId
const stagedProfile = useAppStore
.getState()
.browserTabsByWorktree[WT]?.find(
(workspace) => workspace.activePageId === pendingCreates[0]?.params.page
)?.sessionProfileId
pendingCreates[0]?.resolve('profile-repro-page')
expect(await creating).toMatchObject({ status: 'opened' })
expect(sentProfile).toBeUndefined()
expect(stagedProfile).toBeNull()
}
)
it.each(['other-profile', undefined])(
'does not reuse a page published with an incompatible or missing profile (%s)',
(profileId) => {
publishProfileSnapshot(profileId)
expect(selectDestination()).toMatchObject({ status: 'ready', match: null })
}
)
it.each([null, 'remote-profile'])(
'reuses the host-confirmed default or custom profile (%s)',
async (profileId) => {
useAppStore.setState({ defaultBrowserSessionProfileIdByHostId: { [HOST]: profileId } })
publishProfileSnapshot(profileId ?? 'default')
const creating = open()
pendingCreates[0]?.resolve('unexpected-new-page')
expect(await creating).toMatchObject({
status: 'opened',
result: { browserPageId: OTHER.pageId }
})
expect(pendingCreates).toHaveLength(0)
}
)
it('updates and clears published profile evidence even when page content stays identical', () => {
publishProfileSnapshot('default')
expect(selectDestination()).toMatchObject({ match: { browserPageId: OTHER.pageId } })
const handles = useAppStore.getState().remoteBrowserPageHandlesByPageId
publishProfileSnapshot('default')
expect(useAppStore.getState().remoteBrowserPageHandlesByPageId).toBe(handles)
publishProfileSnapshot('other-profile')
expect(selectDestination()).toMatchObject({ match: null })
publishProfileSnapshot('default')
expect(selectDestination()).toMatchObject({ match: { browserPageId: OTHER.pageId } })
publishProfileSnapshot(undefined)
expect(selectDestination()).toMatchObject({ match: null })
})
it('uses the paired runtime profile catalog for its SSH-owned workspace', async () => {
const sshHost = 'ssh:ssh-target'
useAppStore.setState((state) => ({
activeWorkspaceExecutionHostId: sshHost,
repos: state.repos.map((repo) => ({ ...repo, executionHostId: sshHost })),
worktreesByRepo: {
repo: state.worktreesByRepo.repo!.map((worktree) => ({ ...worktree, hostId: sshHost }))
},
defaultBrowserSessionProfileIdByHostId: { [HOST]: 'runtime-profile', [sshHost]: 'ssh-profile' }
}))
const creating = open()
const sentProfile = pendingCreates[0]?.params.profileId
pendingCreates[0]?.resolve('ssh-profile-page')
expect(await creating).toMatchObject({ status: 'opened' })
expect(sentProfile).toBe('runtime-profile')
})
it.each(['unchanged', 'different-host', 'different-runtime'] as const)(
'validates the captured SSH owner after foreground runtime selection (%s)',
async (ownerChange) => {
const sshHost = 'ssh:ssh-target'
useAppStore.setState((state) => ({
activeWorkspaceExecutionHostId: sshHost,
repos: state.repos.map((repo) => ({ ...repo, executionHostId: sshHost })),
worktreesByRepo: {
repo: state.worktreesByRepo.repo!.map((worktree) => ({ ...worktree, hostId: sshHost }))
},
unifiedTabsByWorktree: {
[WT]: state.unifiedTabsByWorktree[WT]!.map((tab) => ({
...tab,
executionHostId: sshHost
}))
}
}))
vi.spyOn(useAppStore.getState(), 'refreshGitHubForWorktreeIfStale').mockImplementation(
() => undefined
)
const creating = openWorkspaceBrowserTabForLink({
workspaceId: WT,
url: URL_TO_OPEN,
intent: { kind: 'url' },
expectedRuntimeEnvironmentId: ENV,
placementPreference: 'server',
linkContext: {
linkIntent: captureLinkOpenIntent(useAppStore.getState(), WT, SOURCE.unifiedTabId)
}
})
expect(pendingCreates).toHaveLength(1)
expect(useAppStore.getState().activeWorkspaceExecutionHostId).toBe(HOST)
if (ownerChange === 'different-host') {
useAppStore.setState((state) => ({
activeWorkspaceExecutionHostId: 'ssh:other-target',
worktreesByRepo: {
repo: [
...state.worktreesByRepo.repo!,
...state.worktreesByRepo.repo!.map((worktree) => ({
...worktree,
hostId: 'ssh:other-target' as const
}))
]
}
}))
} else if (ownerChange === 'different-runtime') {
useAppStore.setState((state) => ({
activeWorkspaceExecutionHostId: sshHost,
worktreesByRepo: {
repo: state.worktreesByRepo.repo!.map((worktree) => ({
...worktree,
runtimeOwnerEnvironmentId: 'other-environment'
}))
}
}))
}
const remotePageId = `foreground-ssh-${ownerChange}`
pendingCreates[0]!.resolve(remotePageId)
const outcome = await creating
expect(wrapperForRemotePage(remotePageId)).toBeDefined()
expect(outcome).toMatchObject({
status: ownerChange === 'unchanged' ? 'opened' : 'refused'
})
if (outcome.status === 'opened') {
expect(outcome.result).toMatchObject({ worktreeId: WT, executionHostId: sshHost })
}
}
)
@@ -39,9 +39,7 @@ export function stageWebRuntimeBrowserTab(args: {
executionHostId: toRuntimeExecutionHostId(args.environmentId),
...(args.afterTabId ? { afterTabId: args.afterTabId } : {}),
...(args.title !== undefined ? { title: args.title } : {}),
...(args.profileId !== undefined && args.profileId !== null
? { sessionProfileId: args.profileId }
: {}),
...(args.profileId !== undefined ? { sessionProfileId: args.profileId } : {}),
...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}),
...(args.focusAddressBar !== undefined ? { focusAddressBar: args.focusAddressBar } : {})
})
@@ -75,7 +75,13 @@ export function applyBrowserRecordUpdates(context: BrowserRecordContext) {
}
}
for (const { page, certificateFailure, remotePageId, placement } of mirroredBrowserTabs) {
for (const {
page,
certificateFailure,
remotePageId,
browserProfileId,
placement
} of mirroredBrowserTabs) {
const current = nextBrowserPagesByWorkspace[page.workspaceId] ?? []
if (!sameBrowserPages(current, [page])) {
nextBrowserPagesByWorkspace =
@@ -88,6 +94,7 @@ export function applyBrowserRecordUpdates(context: BrowserRecordContext) {
if (
currentHandle?.environmentId !== environmentId ||
currentHandle.remotePageId !== remotePageId ||
currentHandle.browserProfileId !== browserProfileId ||
// A host snapshot is the adoption boundary for a staged handle.
currentHandle.staged === true ||
// Restored markers are spent when the host republishes the page.
@@ -101,6 +108,7 @@ export function applyBrowserRecordUpdates(context: BrowserRecordContext) {
nextRemoteBrowserPageHandlesByPageId[page.id] = {
environmentId,
remotePageId,
...(browserProfileId ? { browserProfileId } : {}),
...(placement ? { placement } : {})
}
}
@@ -197,6 +197,7 @@ export function buildMirroredBrowserTabs(
page,
certificateFailure: tab.certificateFailure ?? null,
remotePageId: tab.browserPageId,
...(tab.browserProfileId ? { browserProfileId: tab.browserProfileId } : {}),
...(tab.placement ? { placement: tab.placement } : {}),
unifiedTab: buildBrowserUnifiedTab(
workspace,
@@ -187,6 +187,7 @@ export type MirroredBrowserTab = {
page: BrowserPage
certificateFailure: BrowserCertificateFailure | null
remotePageId: string
browserProfileId?: string
placement?: RuntimeBrowserPlacement
unifiedTab: Tab
hostTabId: string
+30 -2
View File
@@ -1,3 +1,4 @@
import { canOfferHttpLinkGroupAction } from '@/lib/http-link-in-group-availability'
import { create, type StateCreator } from 'zustand'
import type { AppState } from './types'
import { createRepoSlice } from './slices/repos'
@@ -55,6 +56,7 @@ import { e2eConfig } from '@/lib/e2e-config'
import type { createWebRuntimeSessionTerminal } from '@/runtime/web-runtime-terminal-creation'
import {
registerHttpLinkStoreAccessor,
registerHttpLinkGroupActionAvailability,
registerWorkspaceHttpLinkBrowserOpener
} from '@/lib/http-link-routing'
import { installStoreListenerCensus } from './store-listener-census'
@@ -147,11 +149,37 @@ export const useAppStore = create<AppState>()(
)
)
registerHttpLinkStoreAccessor(() => useAppStore.getState())
registerHttpLinkStoreAccessor(() => useAppStore.getState(), useAppStore.subscribe)
registerHttpLinkGroupActionAvailability((request) =>
canOfferHttpLinkGroupAction(useAppStore.getState(), request)
)
attachEditorBufferIdentityEvents(() => useAppStore.getState())
registerWindowsPathAccessEnvironments(() => useAppStore.getState().runtimeEnvironments)
registerWorkspaceHttpLinkBrowserOpener(async (request) => {
const { openWorkspaceBrowserTab } = await import('@/lib/workspace-browser-tab-open')
const { openWorkspaceBrowserTab, openWorkspaceBrowserTabForLink } =
await import('@/lib/workspace-browser-tab-open')
if (request.linkContext) {
const outcome = await openWorkspaceBrowserTabForLink({
...request,
linkContext: request.linkContext
})
if (
outcome.status === 'failed' ||
(outcome.status === 'refused' && !request.linkContext.linkIntent?.targetGroupId)
) {
const { intentPresentation } = await import('@/lib/workspace-browser-tab-presentation')
throw new Error(intentPresentation(request.intent, request.url).error)
}
if (
request.linkContext.linkIntent?.targetGroupId &&
(outcome.status === 'refused' || outcome.status === 'unsupported')
) {
const { showOpenInGroupRefusedToast } =
await import('@/components/terminal-pane/terminal-file-open-in-group-toast')
showOpenInGroupRefusedToast(request.url)
}
return outcome.status === 'opened' ? outcome.result.browserPageId : null
}
return openWorkspaceBrowserTab(request)
})
@@ -37,6 +37,8 @@ import type { TabSplitTarget } from '../tabs/tabs-slice-contract'
import type { RuntimeBrowserPlacement } from '../../../../../shared/runtime-browser-placement'
export type CreateBrowserTabOptions = {
/** Link-only guard, checked before records and again at the unified-tab mutation. */
validateLinkPlacement?: (state: AppState) => void
activate?: boolean
browserPageId?: string
title?: string
@@ -87,6 +89,8 @@ export type ClosedBrowserWorkspaceSnapshot = {
export type RemoteBrowserPageHandle = {
environmentId: string
remotePageId: string
/** Profile identity confirmed by the host's current page snapshot. */
browserProfileId?: string
placement?: RuntimeBrowserPlacement
/** Optimistically staged by this client; the host has not published the page yet. */
staged?: true
@@ -1,11 +1,6 @@
import { createBrowserUuid } from '@/lib/browser-uuid'
import { translate } from '@/i18n/i18n'
import type {
BrowserSlice,
BrowserSliceGet,
BrowserSliceSet,
CreateBrowserTabOptions
} from './browser-slice-contract'
import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract'
import {
assertManagedBrowserMaterializationAllowed,
getClientCreationActionPolicy
@@ -27,13 +22,7 @@ import {
import { findGroupAndWorktree } from '../tab-group-state'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch'
import { openBrowserTabThroughLayoutOwner } from './browser-tab-open-through-layout-owner'
import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types'
import {
FLOATING_TERMINAL_WORKTREE_ID,
ORCA_BROWSER_BLANK_URL
} from '../../../../../shared/constants'
import type { AppState } from '../../types'
import { ownsGlobalSelection } from '../../global-selection-owner'
import { browserTabOpenView } from './browser-tab-open-view'
export function createBrowserTabActions(
set: BrowserSliceSet,
@@ -46,6 +35,7 @@ export function createBrowserTabActions(
> {
return {
createBrowserTab: (worktreeId, url, options) => {
options?.validateLinkPlacement?.(get())
assertManagedBrowserMaterializationAllowed(get(), options?.browserRuntimeEnvironmentId)
const workspaceId = createBrowserUuid()
const browserPageId = options?.browserPageId
@@ -65,7 +55,11 @@ export function createBrowserTabActions(
browserPageId,
options?.docLocation
)
if (!options?.browserRuntimeEnvironmentId && !options?.docLocation) {
if (
!options?.browserRuntimeEnvironmentId &&
!options?.docLocation &&
!options?.validateLinkPlacement
) {
admitBrowserPageMount(page.id)
}
// Why: with no explicit profile, Settings decides, so the profile scope applies to every way a tab opens.
@@ -90,18 +84,31 @@ export function createBrowserTabActions(
sessionPartition
)
const admitLinkPageMount = (): void => {
if (
options?.validateLinkPlacement &&
!options.browserRuntimeEnvironmentId &&
!options.docLocation
) {
admitBrowserPageMount(page.id)
}
}
const drawHere = (): void => {
set((s) => ({
browserTabsByWorktree: {
...s.browserTabsByWorktree,
[worktreeId]: [...(s.browserTabsByWorktree[worktreeId] ?? []), browserTab]
},
browserPagesByWorkspace: {
...s.browserPagesByWorkspace,
[workspaceId]: [page]
},
...browserTabOpenView(s, worktreeId, browserTab, page, options)
}))
set((s) => {
options?.validateLinkPlacement?.(s)
return {
browserTabsByWorktree: {
...s.browserTabsByWorktree,
[worktreeId]: [...(s.browserTabsByWorktree[worktreeId] ?? []), browserTab]
},
browserPagesByWorkspace: {
...s.browserPagesByWorkspace,
[workspaceId]: [page]
},
...browserTabOpenView(s, worktreeId, browserTab, page, options)
}
})
const state = get()
const alreadyHasUnifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).some(
@@ -109,21 +116,33 @@ export function createBrowserTabActions(
)
if (!alreadyHasUnifiedTab) {
const shouldActivate = options?.activate ?? true
const created = state.createUnifiedTab(worktreeId, 'browser', {
entityId: workspaceId,
label: browserTab.title,
targetGroupId: options?.targetGroupId,
...(options?.split ? { split: options.split } : {}),
...(options?.afterTabId ? { afterTabId: options.afterTabId } : {}),
// Why no routing-host default: a substituted host would disown the wrapper from its worktree.
...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}),
activate: shouldActivate
})
let created
try {
created = state.createUnifiedTab(worktreeId, 'browser', {
...(options?.validateLinkPlacement
? { validatePlacement: options.validateLinkPlacement }
: {}),
entityId: workspaceId,
label: browserTab.title,
targetGroupId: options?.targetGroupId,
...(options?.split ? { split: options.split } : {}),
...(options?.afterTabId ? { afterTabId: options.afterTabId } : {}),
// Why no routing-host default: a substituted host would disown the wrapper from its worktree.
...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}),
activate: shouldActivate
})
} catch (error) {
if (options?.validateLinkPlacement) {
get().closeBrowserTab(workspaceId, { reason: 'cleanup' })
}
throw error
}
// Why: unified creation already selected the tab and recorded the visit; only the group moves.
if (shouldActivate && created) {
get().focusGroup(worktreeId, created.groupId)
}
}
admitLinkPageMount()
}
// Switch point: main opens the tab under these ids and its frame draws it.
if (
@@ -133,7 +152,10 @@ export function createBrowserTabActions(
browserTab,
page,
options,
showView: () => set((s) => browserTabOpenView(s, worktreeId, browserTab, page, options)),
showView: () => {
set((s) => browserTabOpenView(s, worktreeId, browserTab, page, options))
admitLinkPageMount()
},
drawHere
})
) {
@@ -241,71 +263,3 @@ export function createBrowserTabActions(
}
}
}
// Write window-owned tab order/selection/New Tab focus only after main frames draw layout-owner tabs.
function browserTabOpenView(
s: AppState,
worktreeId: string,
browserTab: BrowserWorkspace,
page: BrowserPage,
options: CreateBrowserTabOptions | undefined
): Partial<AppState> {
const workspaceId = browserTab.id
// Without this tab: a frame may already have drawn its record.
const existingTabs = (s.browserTabsByWorktree[worktreeId] ?? []).filter(
(tab) => tab.id !== workspaceId
)
const nextTabBarOrder = (() => {
const currentOrder = s.tabBarOrderByWorktree[worktreeId] ?? []
const terminalIds = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)
const editorIds = s.openFiles
.filter((file) => file.worktreeId === worktreeId)
.map((file) => file.id)
const ids = [...terminalIds, ...editorIds, ...existingTabs.map((tab) => tab.id)]
const allExistingIds = new Set(ids)
const base = currentOrder.filter((entryId) => allExistingIds.has(entryId))
const inBase = new Set(base)
return [...base, ...[...allExistingIds].filter((entryId) => !inBase.has(entryId)), workspaceId]
})()
const shouldActivate = options?.activate ?? true
const shouldUpdateGlobalActiveSurface = shouldActivate && ownsGlobalSelection(s, worktreeId)
const shouldFocusFloatingTab = shouldActivate && worktreeId === FLOATING_TERMINAL_WORKTREE_ID
const shouldFocusAddressBar =
(shouldUpdateGlobalActiveSurface || shouldFocusFloatingTab) &&
// Documents are blank by design but have no address bar; URL alone must not trigger New Tab focus.
!page.docLocation &&
(options?.focusAddressBar ??
(page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL))
return {
tabBarOrderByWorktree: {
...s.tabBarOrderByWorktree,
[worktreeId]: nextTabBarOrder
},
activeBrowserTabId: shouldUpdateGlobalActiveSurface ? workspaceId : s.activeBrowserTabId,
activeBrowserTabIdByWorktree: {
...s.activeBrowserTabIdByWorktree,
[worktreeId]: shouldActivate
? workspaceId
: (s.activeBrowserTabIdByWorktree[worktreeId] ?? null)
},
activeTabType: shouldUpdateGlobalActiveSurface ? 'browser' : s.activeTabType,
activeTabTypeByWorktree: shouldActivate
? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' }
: s.activeTabTypeByWorktree,
pendingAddressBarFocusByPageId: shouldFocusAddressBar
? {
...s.pendingAddressBarFocusByPageId,
[page.id]: true
}
: s.pendingAddressBarFocusByPageId,
pendingAddressBarFocusByTabId: shouldFocusAddressBar
? {
...s.pendingAddressBarFocusByTabId,
[workspaceId]: true,
[page.id]: true
}
: s.pendingAddressBarFocusByTabId
}
}
@@ -0,0 +1,78 @@
import type { CreateBrowserTabOptions } from './browser-slice-contract'
import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types'
import {
FLOATING_TERMINAL_WORKTREE_ID,
ORCA_BROWSER_BLANK_URL
} from '../../../../../shared/constants'
import type { AppState } from '../../types'
import { ownsGlobalSelection } from '../../global-selection-owner'
// The window's own view of a browser tab it opens: its tab-bar order entry, browser selection and
// the address-bar focus a blank New Tab asks for; a layout-owner build writes it once drawn.
export function browserTabOpenView(
s: AppState,
worktreeId: string,
browserTab: BrowserWorkspace,
page: BrowserPage,
options: CreateBrowserTabOptions | undefined
): Partial<AppState> {
const workspaceId = browserTab.id
// Without this tab: a frame may already have drawn its record.
const existingTabs = (s.browserTabsByWorktree[worktreeId] ?? []).filter(
(tab) => tab.id !== workspaceId
)
const nextTabBarOrder = (() => {
const currentOrder = s.tabBarOrderByWorktree[worktreeId] ?? []
const terminalIds = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)
const editorIds = s.openFiles
.filter((file) => file.worktreeId === worktreeId)
.map((file) => file.id)
const ids = [...terminalIds, ...editorIds, ...existingTabs.map((tab) => tab.id)]
const allExistingIds = new Set(ids)
const base = currentOrder.filter((entryId) => allExistingIds.has(entryId))
const inBase = new Set(base)
return [...base, ...[...allExistingIds].filter((entryId) => !inBase.has(entryId)), workspaceId]
})()
const shouldActivate = options?.activate ?? true
const shouldUpdateGlobalActiveSurface = shouldActivate && ownsGlobalSelection(s, worktreeId)
const shouldFocusFloatingTab = shouldActivate && worktreeId === FLOATING_TERMINAL_WORKTREE_ID
const shouldFocusAddressBar =
(shouldUpdateGlobalActiveSurface || shouldFocusFloatingTab) &&
// Why the doc check and not just the url: a document page is blank by construction, and
// the blank url is exactly what marks a New Tab as wanting the address bar it does not have.
!page.docLocation &&
(options?.focusAddressBar ??
(page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL))
return {
tabBarOrderByWorktree: {
...s.tabBarOrderByWorktree,
[worktreeId]: nextTabBarOrder
},
activeBrowserTabId: shouldUpdateGlobalActiveSurface ? workspaceId : s.activeBrowserTabId,
activeBrowserTabIdByWorktree: {
...s.activeBrowserTabIdByWorktree,
[worktreeId]: shouldActivate
? workspaceId
: (s.activeBrowserTabIdByWorktree[worktreeId] ?? null)
},
activeTabType: shouldUpdateGlobalActiveSurface ? 'browser' : s.activeTabType,
activeTabTypeByWorktree: shouldActivate
? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' }
: s.activeTabTypeByWorktree,
pendingAddressBarFocusByPageId: shouldFocusAddressBar
? {
...s.pendingAddressBarFocusByPageId,
[page.id]: true
}
: s.pendingAddressBarFocusByPageId,
pendingAddressBarFocusByTabId: shouldFocusAddressBar
? {
...s.pendingAddressBarFocusByTabId,
[workspaceId]: true,
[page.id]: true
}
: s.pendingAddressBarFocusByTabId
}
}
@@ -1,95 +1,8 @@
import { pickAdjacentLinkOpenGroupId } from '@/lib/adjacent-link-open-group'
export { findNeighbor } from '@/lib/adjacent-link-open-group'
import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../../../shared/tab-types'
import { isEditorTabContentType, type EditorTabContentType } from './editor-tab-content-type'
type LayoutRect = { left: number; top: number; right: number; bottom: number }
type LayoutLeaf = { groupId: string; rect: LayoutRect; layoutOrder: number }
type NeighborSide = 'right' | 'left' | 'below' | 'above'
type Neighbor = { side: NeighborSide; position: number }
type Candidate = {
id: string
neighbor: Neighbor | null
hasMatchingFile: boolean
showsEditor: boolean
layoutOrder: number
}
type Rank = readonly [number, number, number, number]
const EPSILON = 1e-9
const SIDE_ORDER = { right: 0, left: 1, below: 2, above: 3 } as const
function clampRatio(ratio: number | undefined): number {
return typeof ratio === 'number' && Number.isFinite(ratio) && ratio > 0 && ratio < 1 ? ratio : 0.5
}
/** Leaves in stable tree order, each projected into the unit square by its split ratios. */
function projectLayoutLeaves(layout: TabGroupLayoutNode): LayoutLeaf[] {
const leaves: LayoutLeaf[] = []
const visit = (node: TabGroupLayoutNode, rect: LayoutRect): void => {
if (node.type === 'leaf') {
if (rect.right - rect.left > EPSILON && rect.bottom - rect.top > EPSILON) {
leaves.push({ groupId: node.groupId, rect, layoutOrder: leaves.length })
}
return
}
const ratio = clampRatio(node.ratio)
if (node.direction === 'horizontal') {
const split = rect.left + (rect.right - rect.left) * ratio
visit(node.first, { ...rect, right: split })
visit(node.second, { ...rect, left: split })
} else {
const split = rect.top + (rect.bottom - rect.top) * ratio
visit(node.first, { ...rect, bottom: split })
visit(node.second, { ...rect, top: split })
}
}
visit(layout, { left: 0, top: 0, right: 1, bottom: 1 })
return leaves
}
function overlaps(a0: number, a1: number, b0: number, b1: number): boolean {
return Math.min(a1, b1) - Math.max(a0, b0) > EPSILON
}
function touches(a: number, b: number): boolean {
return Math.abs(a - b) <= EPSILON
}
/** Shared edge with positive overlap; corner-only contact is not adjacency. */
export function findNeighbor(source: LayoutRect, target: LayoutRect): Neighbor | null {
const verticalOverlap = overlaps(source.top, source.bottom, target.top, target.bottom)
const horizontalOverlap = overlaps(source.left, source.right, target.left, target.right)
if (verticalOverlap && touches(source.right, target.left)) {
return { side: 'right', position: target.top }
}
if (verticalOverlap && touches(source.left, target.right)) {
return { side: 'left', position: target.top }
}
if (horizontalOverlap && touches(source.bottom, target.top)) {
return { side: 'below', position: target.left }
}
if (horizontalOverlap && touches(source.top, target.bottom)) {
return { side: 'above', position: target.left }
}
return null
}
function rankCandidate(candidate: Candidate, sourceGroupId: string): Rank | null {
const { neighbor, hasMatchingFile, showsEditor, layoutOrder } = candidate
if (candidate.id === sourceGroupId) {
return [4, 0, 0, layoutOrder]
}
if (!neighbor) {
return hasMatchingFile ? [1, 0, 0, layoutOrder] : null
}
const priority = hasMatchingFile ? 0 : showsEditor ? 2 : 3
return [priority, SIDE_ORDER[neighbor.side], neighbor.position, layoutOrder]
}
function compareRanks(a: Rank, b: Rank): number {
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2] || a[3] - b[3]
}
type AdjacentEditorOpenInput = {
layout: TabGroupLayoutNode
groups: readonly TabGroup[]
@@ -102,12 +15,6 @@ type AdjacentEditorOpenInput = {
/** Rank live-source destinations: neighboring file copy, any file copy, neighboring editor, any neighbor, source. Null for nonlive source leaves. */
export function pickAdjacentEditorOpenGroupId(input: AdjacentEditorOpenInput): string | null {
const leaves = projectLayoutLeaves(input.layout)
const source = leaves.find((leaf) => leaf.groupId === input.sourceGroupId)
const liveGroups = new Map(input.groups.map((group) => [group.id, group]))
if (!source || !liveGroups.has(source.groupId)) {
return null
}
const groupsWithFile = new Set<string>()
const tabsById = new Map<string, Tab>()
for (const tab of input.tabs) {
@@ -116,31 +23,17 @@ export function pickAdjacentEditorOpenGroupId(input: AdjacentEditorOpenInput): s
groupsWithFile.add(tab.groupId)
}
}
// Why: the source keeps its conversation visible, so a source-only copy earns no preference.
groupsWithFile.delete(source.groupId)
let bestId: string | null = null
let bestRank: Rank | null = null
for (const leaf of leaves) {
const group = liveGroups.get(leaf.groupId)
if (!group) {
continue
}
const activeTab = group.activeTabId ? tabsById.get(group.activeTabId) : undefined
const rank = rankCandidate(
{
id: leaf.groupId,
neighbor: leaf.groupId === source.groupId ? null : findNeighbor(source.rect, leaf.rect),
hasMatchingFile: groupsWithFile.has(leaf.groupId),
showsEditor: activeTab ? isEditorTabContentType(activeTab.contentType) : false,
layoutOrder: leaf.layoutOrder
},
source.groupId
)
if (rank && (!bestRank || compareRanks(rank, bestRank) < 0)) {
bestRank = rank
bestId = leaf.groupId
}
}
return bestId
const facts = new Map(
input.groups.map((group) => {
const activeTab = group.activeTabId ? tabsById.get(group.activeTabId) : undefined
return [
group.id,
{
hasMatchingDestination: groupsWithFile.has(group.id),
showsDestinationSurface: activeTab ? isEditorTabContentType(activeTab.contentType) : false
}
]
})
)
return pickAdjacentLinkOpenGroupId({ ...input, facts })
}
@@ -5,7 +5,34 @@ import type { OpenFile } from '../types/open-file'
import { ownsGlobalSelection } from '../../../global-selection-owner'
import type { EditorOpenLinkIntent } from '../types/editor-open-link-intent'
import { pickAdjacentEditorOpenGroupId } from './editor-open-adjacent-group'
import {
captureLinkOpenIntent,
isLiveLinkOpenGroup as isLiveEditorOpenGroup
} from '@/lib/link-open-intent'
import type { LinkPlacementState } from '@/lib/link-open-intent'
import { collectTabGroupLayoutGroupIds } from '../../../../../../shared/workspace-layout/tab-group-moves'
export {
isLiveLinkOpenGroup as isLiveEditorOpenGroup,
canOpenLinkInSourceGroup
} from '@/lib/link-open-intent'
export function captureEditorOpenLinkIntent(
state: Pick<AppState, 'unifiedTabsByWorktree'>,
workspaceId: string,
sourceTabId: string | null | undefined,
options?: { explicitSourceGroup?: boolean }
): EditorOpenLinkIntent | null {
const captured = captureLinkOpenIntent(state, workspaceId, sourceTabId, options)
if (!captured) {
return null
}
return {
workspaceId: captured.workspaceId,
sourceTabId: captured.sourceTabId,
hostId: captured.hostId,
...(captured.targetGroupId ? { targetGroupId: captured.targetGroupId } : {})
}
}
export function getGroupActiveTab(group: TabGroup, tabsById: Map<string, Tab>): Tab | null {
return group.activeTabId ? (tabsById.get(group.activeTabId) ?? null) : null
@@ -81,41 +108,6 @@ export function resolveEditorOpenTargetGroupId(
return recentEditorGroup?.id ?? activeGroup.id
}
type LinkPlacementState = Pick<
AppState,
'groupsByWorktree' | 'unifiedTabsByWorktree' | 'layoutByWorktree'
>
/** The unified tab a link came from; a terminal surface may know only its backing terminal id. */
function findLinkSourceTab(tabs: readonly Tab[], sourceTabId: string): Tab | undefined {
return (
tabs.find((tab) => tab.id === sourceTabId) ??
tabs.find((tab) => tab.contentType === 'terminal' && tab.entityId === sourceTabId)
)
}
/** Captures a link activation's source at click time, so later focus changes cannot redirect it. */
export function captureEditorOpenLinkIntent(
state: Pick<AppState, 'unifiedTabsByWorktree'>,
workspaceId: string,
sourceTabId: string | null | undefined,
options?: { explicitSourceGroup?: boolean }
): EditorOpenLinkIntent | null {
if (!sourceTabId) {
return null
}
const tab = findLinkSourceTab(state.unifiedTabsByWorktree?.[workspaceId] ?? [], sourceTabId)
if (!tab) {
return null
}
return {
sourceTabId: tab.id,
workspaceId,
hostId: tab.executionHostId ?? null,
...(options?.explicitSourceGroup ? { targetGroupId: tab.groupId } : {})
}
}
export function liveLayoutGroupIds(
state: {
layoutByWorktree?: LinkPlacementState['layoutByWorktree']
@@ -131,30 +123,6 @@ export function liveLayoutGroupIds(
return new Set([...collectTabGroupLayoutGroupIds(layout)].filter((id) => groupIds.has(id)))
}
export function isLiveEditorOpenGroup(
state: LinkPlacementState,
worktreeId: string,
groupId: string
): boolean {
return liveLayoutGroupIds(state, worktreeId).has(groupId)
}
/** "Open in this group" only differs from automatic placement when the source has company. */
export function canOpenLinkInSourceGroup(
state: LinkPlacementState,
workspaceId: string,
sourceTabId: string | null | undefined
): boolean {
const tab = sourceTabId
? findLinkSourceTab(state.unifiedTabsByWorktree?.[workspaceId] ?? [], sourceTabId)
: undefined
if (!tab) {
return false
}
const groupIds = liveLayoutGroupIds(state, workspaceId)
return groupIds.has(tab.groupId) && groupIds.size > 1
}
export type EditorLinkOpenPlacement =
| { kind: 'group'; groupId: string }
| { kind: 'default' }
@@ -33,6 +33,7 @@ export function createTabsCreateActions(
const splitGroupId = init?.split ? createBrowserUuid() : undefined
let created!: Tab
set((current) => {
init?.validatePlacement?.(current)
const state =
init?.split && splitGroupId
? {
@@ -47,6 +47,7 @@ export type TabsSlice = {
| 'isPreview'
| 'isPinned'
> & {
validatePlacement: (state: AppState) => void
targetGroupId: string
/** Creates the tab in a new split group instead; wins over targetGroupId. */
split: TabSplitTarget