Route terminal links based on pane ownership, not global state (#12233)

* fix(terminal): route remote-runtime link clicks to the system browser

Terminal link clicks classified ownership from the global
activeRuntimeEnvironmentId, which is null when runtimes are bound per
workspace, so a link clicked in a remote-hosted pane opened a local-only
Orca browser tab and never reached the host. Thread each pane's resolved
runtimeEnvironmentId into openHttpLink as sourceOwner across the OSC 8,
WebLinksAddon, and click-fallback paths.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): route link clicks based on pane ownership, not global sta

Clicking links on remote-hosted panes was routing based on global runtime state, causing unexpected reconnections. Now link routing decisions (where to open: Orca vs system browser) are based on the actual pane's owner — local, SSH connection, remote runtime, or unknown — regardless of whether any runtime is globally active. This ensures a local pane can route to Orca while another pane's remote runtime is active, and a remote pane always routes to the system browser.

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing 2026-08-02 23:27:58 -07:00 committed by GitHub
parent 93a2ad8fd8
commit db5325204f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 816 additions and 187 deletions

View File

@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
import { toAppSshPtyId } from '../../../../shared/ssh-pty-id'
import { resolveTerminalHttpLinkSourceOwner } from './terminal-http-link-source-owner'
function transport(
ptyId: string | null,
runtimeEnvironmentId?: string | null,
connectionId?: string | null
) {
return {
getPtyId: () => ptyId,
getRuntimeEnvironmentId: () => runtimeEnvironmentId ?? null,
getConnectionId: () => connectionId ?? null
}
}
describe('resolveTerminalHttpLinkSourceOwner', () => {
it('keeps ordinary local PTYs local', () => {
expect(resolveTerminalHttpLinkSourceOwner(transport('local-pty'))).toEqual({ kind: 'local' })
})
it('classifies direct SSH PTYs by their embedded connection', () => {
expect(resolveTerminalHttpLinkSourceOwner(transport(toAppSshPtyId('ssh-1', 'pty-2')))).toEqual({
kind: 'ssh',
connectionId: 'ssh-1'
})
})
it('keeps direct SSH ownership while its PTY id is unavailable', () => {
expect(resolveTerminalHttpLinkSourceOwner(transport(null, null, 'ssh-recovering'))).toEqual({
kind: 'ssh',
connectionId: 'ssh-recovering'
})
})
it('prefers the transport runtime owner while its recovery PTY id is null', () => {
expect(resolveTerminalHttpLinkSourceOwner(transport(null, 'env-recovering'))).toEqual({
kind: 'runtime',
runtimeEnvironmentId: 'env-recovering'
})
})
it('uses the retained runtime owner for legacy ownerless remote PTY ids', () => {
expect(
resolveTerminalHttpLinkSourceOwner(transport('remote:legacy-handle', 'env-legacy'))
).toEqual({
kind: 'runtime',
runtimeEnvironmentId: 'env-legacy'
})
})
it('falls back to the owner encoded in current remote PTY ids', () => {
expect(
resolveTerminalHttpLinkSourceOwner(transport(toRemoteRuntimePtyId('handle-1', 'env-encoded')))
).toEqual({ kind: 'runtime', runtimeEnvironmentId: 'env-encoded' })
})
it('does not classify an ownerless remote PTY as local without retained ownership', () => {
expect(resolveTerminalHttpLinkSourceOwner(transport('remote:legacy-handle'))).toEqual({
kind: 'unknown'
})
})
})

View File

@ -0,0 +1,46 @@
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
import {
getRemoteRuntimePtyEnvironmentId,
parseRemoteRuntimePtyId
} from '@/runtime/runtime-terminal-stream'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import type { PtyTransport } from './pty-transport-types'
type OwnerTransport = Pick<PtyTransport, 'getPtyId' | 'getRuntimeEnvironmentId' | 'getConnectionId'>
export function resolveTerminalHttpLinkSourceOwner(
transport: OwnerTransport | null | undefined
): HttpLinkSourceOwner {
const retainedRuntimeEnvironmentId = transport?.getRuntimeEnvironmentId?.()?.trim()
if (retainedRuntimeEnvironmentId) {
return { kind: 'runtime', runtimeEnvironmentId: retainedRuntimeEnvironmentId }
}
const ptyId = transport?.getPtyId() ?? null
const retainedSshConnectionId = transport?.getConnectionId?.()?.trim()
if (!ptyId) {
return retainedSshConnectionId
? { kind: 'ssh', connectionId: retainedSshConnectionId }
: { kind: 'local' }
}
const runtimeEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId)
if (runtimeEnvironmentId) {
return { kind: 'runtime', runtimeEnvironmentId }
}
const sshPty = parseAppSshPtyId(ptyId)
if (sshPty) {
return { kind: 'ssh', connectionId: sshPty.connectionId }
}
if (retainedSshConnectionId) {
return { kind: 'ssh', connectionId: retainedSshConnectionId }
}
// Why: legacy remote ids without a retained transport owner are not evidence of local ownership.
if (parseRemoteRuntimePtyId(ptyId)) {
return { kind: 'unknown' }
}
return { kind: 'local' }
}

View File

@ -0,0 +1,156 @@
import { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits'
type ParsedTerminalHttpLink = {
url: string
startIndex: number
endIndex: number
}
const HTTP_SCHEME_PREFIXES = ['https://', 'http://'] as const
export function extractTerminalHttpLinks(lineText: string): ParsedTerminalHttpLink[] {
const links: ParsedTerminalHttpLink[] = []
for (const candidate of iterateTerminalHttpUrlCandidates(lineText)) {
let parsed: URL
try {
parsed = new URL(candidate.url)
} catch {
continue
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
continue
}
links.push({
url: parsed.toString(),
startIndex: candidate.startIndex,
endIndex: candidate.endIndex
})
}
return links
}
function* iterateTerminalHttpUrlCandidates(
lineText: string
): Generator<{ url: string; startIndex: number; endIndex: number }> {
let searchStart = 0
while (searchStart < lineText.length) {
const startIndex = findNextHttpSchemeIndex(lineText, searchStart)
if (startIndex === -1) {
return
}
if (!hasHttpUrlWordBoundary(lineText, startIndex)) {
searchStart = startIndex + 1
continue
}
const rawEndIndex = findHttpUrlCandidateEnd(lineText, startIndex)
const endIndex = trimHttpUrlTrailingPunctuation(lineText, startIndex, rawEndIndex)
searchStart = Math.max(rawEndIndex, startIndex + 1)
if (endIndex <= startIndex || rawEndIndex - startIndex > TERMINAL_HTTP_URL_MAX_LENGTH) {
continue
}
yield {
url: lineText.slice(startIndex, endIndex),
startIndex,
endIndex
}
}
}
function findNextHttpSchemeIndex(lineText: string, searchStart: number): number {
let nextIndex = -1
for (const prefix of HTTP_SCHEME_PREFIXES) {
const candidateIndex = lineText.indexOf(prefix, searchStart)
if (candidateIndex !== -1 && (nextIndex === -1 || candidateIndex < nextIndex)) {
nextIndex = candidateIndex
}
}
return nextIndex
}
function hasHttpUrlWordBoundary(lineText: string, startIndex: number): boolean {
return startIndex === 0 || !isAsciiWordCode(lineText.charCodeAt(startIndex - 1))
}
function findHttpUrlCandidateEnd(lineText: string, startIndex: number): number {
const scanEnd = Math.min(lineText.length, startIndex + TERMINAL_HTTP_URL_MAX_LENGTH + 1)
for (let index = startIndex; index < scanEnd; index += 1) {
if (isHttpUrlBodyTerminator(lineText.charCodeAt(index))) {
return index
}
}
return scanEnd
}
function trimHttpUrlTrailingPunctuation(
lineText: string,
startIndex: number,
rawEndIndex: number
): number {
let endIndex = rawEndIndex
while (endIndex > startIndex && isHttpUrlTrailingPunctuation(lineText.charCodeAt(endIndex - 1))) {
endIndex -= 1
}
return endIndex
}
function isHttpUrlBodyTerminator(code: number): boolean {
return (
isAsciiWhitespace(code) ||
code === 0x22 ||
code === 0x27 ||
code === 0x21 ||
code === 0x2a ||
code === 0x28 ||
code === 0x29 ||
code === 0x7b ||
code === 0x7d ||
code === 0x7c ||
code === 0x5c ||
code === 0x5e ||
code === 0x3c ||
code === 0x3e ||
code === 0x60
)
}
function isHttpUrlTrailingPunctuation(code: number): boolean {
return (
isAsciiWhitespace(code) ||
code === 0x22 ||
code === 0x27 ||
code === 0x3a ||
code === 0x2c ||
code === 0x2e ||
code === 0x21 ||
code === 0x3f ||
code === 0x7b ||
code === 0x7d ||
code === 0x7c ||
code === 0x5c ||
code === 0x5e ||
code === 0x7e ||
code === 0x5b ||
code === 0x5d ||
code === 0x28 ||
code === 0x29 ||
code === 0x3c ||
code === 0x3e ||
code === 0x60
)
}
function isAsciiWhitespace(code: number): boolean {
return code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32
}
function isAsciiWordCode(code: number): boolean {
return (
(code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
code === 95 ||
(code >= 97 && code <= 122)
)
}

View File

@ -86,4 +86,43 @@ describe('terminalUrlOpenHintOptionsFor', () => {
modifierInverts: false
})
})
// Why: a workspace-bound remote pane routes externally even with no globally
// active runtime, so the global setting alone would advertise an impossible
// "open in Orca" destination.
it.each([
['runtime', { kind: 'runtime', runtimeEnvironmentId: 'env-1' }] as const,
['ssh', { kind: 'ssh', connectionId: 'conn-1' }] as const,
['unknown', { kind: 'unknown' }] as const
])('drops inversion for a %s-owned pane without an active runtime', (_kind, sourceOwner) => {
stubPlatform(true)
const options = terminalUrlOpenHintOptionsFor(
{
openLinksInApp: false,
openLinksInAppModifierInverts: true,
activeRuntimeEnvironmentId: null
},
sourceOwner
)
expect(options.modifierInverts).toBe(false)
expect(getTerminalUrlOpenHint(options)).toContain('for system browser')
})
// Why: the clicked pane's owner wins over the global runtime — a local pane
// can still reach Orca while some other pane's runtime is active.
it('keeps inversion for a local pane while a remote runtime is active', () => {
stubPlatform(true)
const options = terminalUrlOpenHintOptionsFor(
{
openLinksInApp: false,
openLinksInAppModifierInverts: true,
activeRuntimeEnvironmentId: 'remote-1'
},
{ kind: 'local' }
)
expect(options.modifierInverts).toBe(true)
expect(getTerminalUrlOpenHint(options)).toContain('to open in Orca')
})
})

View File

@ -1,3 +1,5 @@
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
export function isMacPlatform(): boolean {
return navigator.userAgent.includes('Mac')
}
@ -25,8 +27,10 @@ export type TerminalUrlOpenHintOptions = {
modifierInverts?: boolean
}
// Why: openHttpLink only routes to Orca when the source is local, so a remote runtime
// pins every link to the system browser and inverting cannot reach Orca there.
// Why: openHttpLink only routes to Orca when the source is local, so a remote pane
// pins every link to the system browser and inverting cannot reach Orca there. The
// clicked pane's owner decides that, not the global active runtime — a workspace-bound
// remote pane is remote even when no runtime is globally active.
export function terminalUrlOpenHintOptionsFor(
settings:
| {
@ -35,13 +39,15 @@ export function terminalUrlOpenHintOptionsFor(
activeRuntimeEnvironmentId?: string | null
}
| null
| undefined
| undefined,
sourceOwner?: HttpLinkSourceOwner
): TerminalUrlOpenHintOptions {
const sourceIsLocal = sourceOwner
? sourceOwner.kind === 'local'
: !settings?.activeRuntimeEnvironmentId?.trim()
return {
openLinksInApp: settings?.openLinksInApp === true,
modifierInverts:
settings?.openLinksInAppModifierInverts === true &&
!settings?.activeRuntimeEnvironmentId?.trim()
modifierInverts: settings?.openLinksInAppModifierInverts === true && sourceIsLocal
}
}

View File

@ -8,6 +8,7 @@ import {
openTerminalHttpLink,
type TerminalLinkRoutingPreferenceRequester
} from './terminal-url-link-hit-testing'
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
type TerminalLinkEvent = Pick<MouseEvent, 'metaKey' | 'ctrlKey'> &
Partial<Pick<MouseEvent, 'button' | 'shiftKey' | 'preventDefault' | 'stopPropagation'>>
@ -29,6 +30,7 @@ export function handleOscLink(
event: TerminalLinkEvent | undefined,
deps: Pick<LinkHandlerDeps, 'worktreeId' | 'worktreePath'> &
Partial<Pick<LinkHandlerDeps, 'runtimeEnvironmentId' | 'startupCwd' | 'terminalHomePath'>> & {
sourceOwner?: HttpLinkSourceOwner
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
}
): boolean {
@ -81,6 +83,11 @@ export function handleOscLink(
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
openTerminalHttpLink(parsed.toString(), {
worktreeId: deps.worktreeId,
sourceOwner:
deps.sourceOwner ??
(deps.runtimeEnvironmentId
? { kind: 'runtime', runtimeEnvironmentId: deps.runtimeEnvironmentId }
: { kind: 'local' }),
modifierHeld: Boolean(event?.shiftKey),
requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
})

View File

@ -0,0 +1,238 @@
import type { IBufferLine, Terminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
import { handleOscLink } from './terminal-osc-link-routing'
import { handleTerminalWebLinkClick } from './terminal-web-link-click'
import { installHttpLinkClickFallback } from './terminal-url-link-hit-testing'
const URL = 'http://example.com/'
const COLS = 80
const ROWS = 24
const openUrlMock = vi.fn()
const setActiveWorktreeMock = vi.fn()
const createBrowserTabMock = vi.fn()
const runtimeSourceOwner = { kind: 'runtime', runtimeEnvironmentId: 'env-1' } as const
const sshSourceOwner = { kind: 'ssh', connectionId: 'ssh-1' } as const
type ListenerRegistration = [string, EventListener, AddEventListenerOptions | boolean | undefined]
function makeBufferLine(text: string): IBufferLine {
const padded = text.padEnd(COLS)
return {
isWrapped: false,
length: COLS,
translateToString: (
_trimRight?: boolean,
startColumn = 0,
endColumn = padded.length,
outColumns?: number[]
) => {
if (outColumns) {
outColumns.splice(
0,
outColumns.length,
...Array.from(
{ length: endColumn - startColumn + 1 },
(_value, index) => index + startColumn
)
)
}
return padded.slice(startColumn, endColumn)
}
} as IBufferLine
}
function makeTerminal(): { terminal: Terminal; registrations: ListenerRegistration[] } {
const registrations: ListenerRegistration[] = []
const screen = {
getBoundingClientRect: () => ({ left: 0, top: 0, width: COLS * 10, height: ROWS * 10 })
}
return {
terminal: {
cols: COLS,
rows: ROWS,
options: { mouseEventsRequireAlt: false },
element: {
ownerDocument: {
defaultView: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn()
},
querySelector: vi.fn(() => screen),
addEventListener: vi.fn(
(name: string, listener: EventListener, options?: AddEventListenerOptions | boolean) => {
registrations.push([name, listener, options])
}
),
removeEventListener: vi.fn()
},
buffer: {
active: {
viewportY: 0,
getLine: (y: number) => (y === 0 ? makeBufferLine(URL) : undefined)
}
},
clearSelection: vi.fn()
} as unknown as Terminal,
registrations
}
}
function clickEvent(): MouseEvent {
return {
button: 0,
metaKey: true,
ctrlKey: false,
altKey: false,
shiftKey: false,
defaultPrevented: false,
clientX: 15,
clientY: 5,
preventDefault: vi.fn()
} as unknown as MouseEvent
}
// Why: runtimes bind per workspace, so the global activeRuntimeEnvironmentId is
// null even while the clicked pane lives on a remote host.
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } })
registerHttpLinkStoreAccessor(() => ({
settings: { openLinksInApp: true, activeRuntimeEnvironmentId: null },
setActiveWorktree: setActiveWorktreeMock,
createBrowserTab: createBrowserTabMock
}))
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('terminal HTTP links on a runtime-hosted pane', () => {
const baseDeps = { worktreeId: 'wt-1', worktreePath: '/tmp', startupCwd: '/tmp' }
it('sends an OSC 8 hyperlink to the system browser', () => {
expect(handleOscLink(URL, clickEvent(), { ...baseDeps, sourceOwner: runtimeSourceOwner })).toBe(
true
)
expect(openUrlMock).toHaveBeenCalledWith(URL)
expect(createBrowserTabMock).not.toHaveBeenCalled()
expect(setActiveWorktreeMock).not.toHaveBeenCalled()
})
it('sends a WebLinksAddon click to the system browser', () => {
const { terminal } = makeTerminal()
expect(
handleTerminalWebLinkClick(URL, clickEvent(), {
...baseDeps,
terminal,
sourceOwner: runtimeSourceOwner
})
).toBe(true)
expect(openUrlMock).toHaveBeenCalledWith(URL)
expect(createBrowserTabMock).not.toHaveBeenCalled()
})
it('sends a click-fallback activation to the system browser', () => {
const { terminal, registrations } = makeTerminal()
const disposable = installHttpLinkClickFallback(terminal, {
worktreeId: 'wt-1',
getSourceOwner: () => runtimeSourceOwner
})
registrations.find(
([name, _listener, options]) => name === 'mouseup' && options === undefined
)?.[1](clickEvent())
expect(openUrlMock).toHaveBeenCalledWith(URL)
expect(createBrowserTabMock).not.toHaveBeenCalled()
disposable.dispose()
})
it('never prompts for the in-app routing preference it could not honor', () => {
const requestOpenLinksInAppPreference = vi.fn(() => Promise.resolve(true))
handleOscLink(URL, clickEvent(), {
...baseDeps,
sourceOwner: runtimeSourceOwner,
requestOpenLinksInAppPreference
})
expect(requestOpenLinksInAppPreference).not.toHaveBeenCalled()
expect(openUrlMock).toHaveBeenCalledWith(URL)
})
})
describe('terminal HTTP links on a direct SSH pane', () => {
const baseDeps = { worktreeId: 'wt-1', worktreePath: '/tmp', startupCwd: '/tmp' }
it('sends an OSC 8 hyperlink to the system browser', () => {
expect(handleOscLink(URL, clickEvent(), { ...baseDeps, sourceOwner: sshSourceOwner })).toBe(
true
)
expect(openUrlMock).toHaveBeenCalledWith(URL)
expect(createBrowserTabMock).not.toHaveBeenCalled()
expect(setActiveWorktreeMock).not.toHaveBeenCalled()
})
it('sends a WebLinksAddon click to the system browser', () => {
const { terminal } = makeTerminal()
expect(
handleTerminalWebLinkClick(URL, clickEvent(), {
...baseDeps,
terminal,
sourceOwner: sshSourceOwner
})
).toBe(true)
expect(openUrlMock).toHaveBeenCalledWith(URL)
expect(createBrowserTabMock).not.toHaveBeenCalled()
})
it('sends a click-fallback activation to the system browser', () => {
const { terminal, registrations } = makeTerminal()
const disposable = installHttpLinkClickFallback(terminal, {
worktreeId: 'wt-1',
getSourceOwner: () => sshSourceOwner
})
registrations.find(
([name, _listener, options]) => name === 'mouseup' && options === undefined
)?.[1](clickEvent())
expect(openUrlMock).toHaveBeenCalledWith(URL)
expect(createBrowserTabMock).not.toHaveBeenCalled()
disposable.dispose()
})
})
describe('terminal HTTP links on a local pane', () => {
const baseDeps = { worktreeId: 'wt-1', worktreePath: '/tmp', startupCwd: '/tmp' }
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(openUrlMock).not.toHaveBeenCalled()
})
it('still opens a click-fallback activation in an Orca browser tab', () => {
const { terminal, registrations } = makeTerminal()
const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' })
registrations.find(
([name, _listener, options]) => name === 'mouseup' && options === undefined
)?.[1](clickEvent())
expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', URL, { activate: true })
expect(openUrlMock).not.toHaveBeenCalled()
disposable.dispose()
})
})

View File

@ -1,23 +1,29 @@
import type { IBufferLine, IBufferRange, IDisposable, Terminal } from '@xterm/xterm'
import { openHttpLink } from '@/lib/http-link-routing'
import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
import { buildEdgeWrappedHttpLogicalLineCandidates } from './edge-wrapped-terminal-http-links'
import { buildHardWrappedHttpLogicalLineCandidates } from './hard-wrapped-terminal-http-links'
import { dedupeLogicalLines } from './terminal-file-link-hit-testing'
import { isTerminalHttpLinkActivation } from './terminal-http-link-activation'
import { installTerminalLinkPtyMouseSuppression } from './terminal-link-pty-mouse-suppression'
import { getTerminalBufferPositionForMouseEvent } from './terminal-mouse-buffer-position'
import { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits'
import { extractTerminalHttpLinks } from './terminal-http-url-extraction'
import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges'
import { isTerminalLinkifierHoverActive } from '@/lib/pane-manager/terminal-linkifier-hover-reset'
export { extractTerminalHttpLinks } from './terminal-http-url-extraction'
export { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits'
type UrlLinkHitTestDeps = {
worktreeId: string
sourceOwner?: HttpLinkSourceOwner
modifierHeld?: boolean
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
}
type UrlLinkClickFallbackDeps = {
worktreeId: string
/** Resolved per click: the pane's PTY (and its runtime binding) may not exist at install time. */
getSourceOwner?: () => HttpLinkSourceOwner
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
}
@ -25,36 +31,6 @@ export type TerminalLinkRoutingPreferenceRequester = (
url: string
) => boolean | Promise<boolean> | null | undefined
type ParsedTerminalHttpLink = {
url: string
startIndex: number
endIndex: number
}
const HTTP_SCHEME_PREFIXES = ['https://', 'http://'] as const
export { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits'
export function extractTerminalHttpLinks(lineText: string): ParsedTerminalHttpLink[] {
const links: ParsedTerminalHttpLink[] = []
for (const candidate of iterateTerminalHttpUrlCandidates(lineText)) {
let parsed: URL
try {
parsed = new URL(candidate.url)
} catch {
continue
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
continue
}
links.push({
url: parsed.toString(),
startIndex: candidate.startIndex,
endIndex: candidate.endIndex
})
}
return links
}
function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean {
if (event.defaultPrevented || event.button !== 0) {
return false
@ -65,132 +41,6 @@ function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean {
return isTerminalHttpLinkActivation(event)
}
function* iterateTerminalHttpUrlCandidates(
lineText: string
): Generator<{ url: string; startIndex: number; endIndex: number }> {
let searchStart = 0
while (searchStart < lineText.length) {
const startIndex = findNextHttpSchemeIndex(lineText, searchStart)
if (startIndex === -1) {
return
}
if (!hasHttpUrlWordBoundary(lineText, startIndex)) {
searchStart = startIndex + 1
continue
}
const rawEndIndex = findHttpUrlCandidateEnd(lineText, startIndex)
const endIndex = trimHttpUrlTrailingPunctuation(lineText, startIndex, rawEndIndex)
searchStart = Math.max(rawEndIndex, startIndex + 1)
if (endIndex <= startIndex || rawEndIndex - startIndex > TERMINAL_HTTP_URL_MAX_LENGTH) {
continue
}
yield {
url: lineText.slice(startIndex, endIndex),
startIndex,
endIndex
}
}
}
function findNextHttpSchemeIndex(lineText: string, searchStart: number): number {
let nextIndex = -1
for (const prefix of HTTP_SCHEME_PREFIXES) {
const candidateIndex = lineText.indexOf(prefix, searchStart)
if (candidateIndex !== -1 && (nextIndex === -1 || candidateIndex < nextIndex)) {
nextIndex = candidateIndex
}
}
return nextIndex
}
function hasHttpUrlWordBoundary(lineText: string, startIndex: number): boolean {
return startIndex === 0 || !isAsciiWordCode(lineText.charCodeAt(startIndex - 1))
}
function findHttpUrlCandidateEnd(lineText: string, startIndex: number): number {
const scanEnd = Math.min(lineText.length, startIndex + TERMINAL_HTTP_URL_MAX_LENGTH + 1)
for (let index = startIndex; index < scanEnd; index += 1) {
if (isHttpUrlBodyTerminator(lineText.charCodeAt(index))) {
return index
}
}
return scanEnd
}
function trimHttpUrlTrailingPunctuation(
lineText: string,
startIndex: number,
rawEndIndex: number
): number {
let endIndex = rawEndIndex
while (endIndex > startIndex && isHttpUrlTrailingPunctuation(lineText.charCodeAt(endIndex - 1))) {
endIndex -= 1
}
return endIndex
}
function isHttpUrlBodyTerminator(code: number): boolean {
return (
isAsciiWhitespace(code) ||
code === 0x22 ||
code === 0x27 ||
code === 0x21 ||
code === 0x2a ||
code === 0x28 ||
code === 0x29 ||
code === 0x7b ||
code === 0x7d ||
code === 0x7c ||
code === 0x5c ||
code === 0x5e ||
code === 0x3c ||
code === 0x3e ||
code === 0x60
)
}
function isHttpUrlTrailingPunctuation(code: number): boolean {
return (
isAsciiWhitespace(code) ||
code === 0x22 ||
code === 0x27 ||
code === 0x3a ||
code === 0x2c ||
code === 0x2e ||
code === 0x21 ||
code === 0x3f ||
code === 0x7b ||
code === 0x7d ||
code === 0x7c ||
code === 0x5c ||
code === 0x5e ||
code === 0x7e ||
code === 0x5b ||
code === 0x5d ||
code === 0x28 ||
code === 0x29 ||
code === 0x3c ||
code === 0x3e ||
code === 0x60
)
}
function isAsciiWhitespace(code: number): boolean {
return code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32
}
function isAsciiWordCode(code: number): boolean {
return (
(code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
code === 95 ||
(code >= 97 && code <= 122)
)
}
export function openHttpLinkAtTerminalMouseEvent(
terminal: Terminal,
event: MouseEvent,
@ -229,6 +79,7 @@ export function installHttpLinkClickFallback(
// never established, while defaultPrevented avoids duplicate opens.
const opened = openHttpLinkAtTerminalMouseEvent(terminal, event, {
worktreeId: deps.worktreeId,
sourceOwner: deps.getSourceOwner?.() ?? { kind: 'local' },
modifierHeld: event.shiftKey,
requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
})
@ -307,16 +158,22 @@ function rangeContainsBufferPosition(
}
export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): void {
// Why: Orca browser tabs are local-only, so a link clicked in a runtime-hosted
// pane must be classified by its pane's host, not the global active runtime.
const sourceOwner = deps.sourceOwner ?? { kind: 'local' }
if (deps.modifierHeld) {
// Why: the modifier states a destination outright, so it also skips the
// one-time routing prompt; openHttpLink resolves which destination it means.
openHttpLink(url, { worktreeId: deps.worktreeId, modifierHeld: true })
openHttpLink(url, { worktreeId: deps.worktreeId, modifierHeld: true, sourceOwner })
return
}
const preferenceDecision = deps.requestOpenLinksInAppPreference?.(url)
// Why: a runtime-hosted link can only reach the system browser, so prompting
// would persist an in-app preference this click cannot honor.
const preferenceDecision =
sourceOwner.kind === 'local' ? deps.requestOpenLinksInAppPreference?.(url) : null
if (preferenceDecision === null || preferenceDecision === undefined) {
openHttpLink(url, { worktreeId: deps.worktreeId })
openHttpLink(url, { worktreeId: deps.worktreeId, sourceOwner })
return
}
@ -327,10 +184,11 @@ export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): voi
.then((openInOrca) => {
openHttpLink(url, {
worktreeId: deps.worktreeId,
forceSystemBrowser: !openInOrca
forceSystemBrowser: !openInOrca,
sourceOwner
})
})
.catch(() => {
openHttpLink(url, { worktreeId: deps.worktreeId, forceSystemBrowser: true })
openHttpLink(url, { worktreeId: deps.worktreeId, forceSystemBrowser: true, sourceOwner })
})
}

View File

@ -6,12 +6,14 @@ import {
openHttpLinkAtTerminalMouseEvent,
type TerminalLinkRoutingPreferenceRequester
} from './terminal-url-link-hit-testing'
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
type TerminalWebLinkClickDeps = Pick<
LinkHandlerDeps,
'worktreeId' | 'worktreePath' | 'startupCwd' | 'runtimeEnvironmentId' | 'terminalHomePath'
> & {
terminal: Terminal | null
sourceOwner?: HttpLinkSourceOwner
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
}
@ -29,6 +31,11 @@ export function handleTerminalWebLinkClick(
deps.terminal &&
openHttpLinkAtTerminalMouseEvent(deps.terminal, event, {
worktreeId: deps.worktreeId,
sourceOwner:
deps.sourceOwner ??
(deps.runtimeEnvironmentId
? { kind: 'runtime', runtimeEnvironmentId: deps.runtimeEnvironmentId }
: { kind: 'local' }),
modifierHeld: Boolean(event.shiftKey),
requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
})

View File

@ -42,7 +42,11 @@ import {
type TerminalLinkRoutingPreferenceRequester
} from './terminal-url-link-hit-testing'
import { installTerminalLinkifierClickPriming } from './terminal-linkifier-click-priming'
import { resolveLocalhostHttpLinkDisplayUrl } from '@/lib/http-link-routing'
import {
resolveLocalhostHttpLinkDisplayUrl,
type HttpLinkSourceOwner
} from '@/lib/http-link-routing'
import { resolveTerminalHttpLinkSourceOwner } from './terminal-http-link-source-owner'
import type {
GlobalSettings,
SetupSplitDirection,
@ -110,7 +114,6 @@ import {
reconcileMissingSessions,
type ReconcilableBinding
} from './terminal-dead-session-reconcile'
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
import { getConnectionId } from '@/lib/connection-context'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard'
@ -199,8 +202,12 @@ function reportActiveRendererPtyForPane(
}
}
async function formatTerminalUrlTooltip(url: string, openLinkHint: string): Promise<string | null> {
const labeledUrl = await resolveLocalhostHttpLinkDisplayUrl(url)
async function formatTerminalUrlTooltip(
url: string,
openLinkHint: string,
sourceOwner: HttpLinkSourceOwner
): Promise<string | null> {
const labeledUrl = await resolveLocalhostHttpLinkDisplayUrl(url, sourceOwner)
if (!labeledUrl) {
return null
}
@ -719,6 +726,8 @@ export function useTerminalPaneLifecycle({
const terminalHomePath = resolveTerminalHomePathFromEnv(startup?.env)
const getPaneLinkCwd = (paneId: number): string =>
resolvePaneLinkCwd(paneCwdRef.current, paneId, startupCwd)
const getHttpLinkSourceOwnerForPane = (paneId: number) =>
resolveTerminalHttpLinkSourceOwner(paneTransportsRef.current.get(paneId))
// Why: lifecycle-scoped cache for cross-SSH/runtime existence probes; may hold temporarily stale entries.
const pathExistsCache = new Map<string, boolean>()
const linkDeps: LinkHandlerDeps = {
@ -731,8 +740,8 @@ export function useTerminalPaneLifecycle({
linkProviderDisposablesRef,
pathExistsCache,
getRuntimeEnvironmentIdForPane: (paneId) => {
const ptyId = paneTransportsRef.current.get(paneId)?.getPtyId()
return ptyId ? getRemoteRuntimePtyEnvironmentId(ptyId) : null
const sourceOwner = getHttpLinkSourceOwnerForPane(paneId)
return sourceOwner.kind === 'runtime' ? sourceOwner.runtimeEnvironmentId : null
}
}
let resizeRaf: number | null = null
@ -836,8 +845,10 @@ export function useTerminalPaneLifecycle({
const fileOpenLinkHint = getTerminalFileOpenHint()
// Why: read settingsRef at fire time so toggling link routing applies without recreating panes.
const getUrlOpenLinkHint = (): string =>
getTerminalUrlOpenHint(terminalUrlOpenHintOptionsFor(settingsRef.current))
const getUrlOpenLinkHint = (paneId: number): string =>
getTerminalUrlOpenHint(
terminalUrlOpenHintOptionsFor(settingsRef.current, getHttpLinkSourceOwnerForPane(paneId))
)
const osc7UncHost = extractUncHost(startupCwd)
let releaseWebviewDragPassthrough: (() => void) | null = null
@ -1060,6 +1071,7 @@ export function useTerminalPaneLifecycle({
fileLinkClickFallbackDisposablesRef.current.set(pane.id, fileLinkClickFallbackDisposable)
const httpLinkClickFallbackDisposable = installHttpLinkClickFallback(pane.terminal, {
...linkDeps,
getSourceOwner: () => getHttpLinkSourceOwnerForPane(pane.id),
requestOpenLinksInAppPreference
})
httpLinkClickFallbackDisposables.set(pane.id, httpLinkClickFallbackDisposable)
@ -1129,6 +1141,7 @@ export function useTerminalPaneLifecycle({
...linkDeps,
startupCwd: getPaneLinkCwd(pane.id),
runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null,
sourceOwner: getHttpLinkSourceOwnerForPane(pane.id),
requestOpenLinksInAppPreference
})
// Why: link activation can steal focus before the click's mouseup reaches xterm, stranding its drag-select
@ -1141,10 +1154,11 @@ export function useTerminalPaneLifecycle({
hover: (_event, text) => {
oscTooltipHoverToken += 1
const hoverToken = oscTooltipHoverToken
const urlOpenLinkHint = getUrlOpenLinkHint()
const urlOpenLinkHint = getUrlOpenLinkHint(pane.id)
pane.linkTooltip.textContent = `${text} (${urlOpenLinkHint})`
pane.linkTooltip.style.display = ''
void formatTerminalUrlTooltip(text, urlOpenLinkHint).then((nextText) => {
const sourceOwner = getHttpLinkSourceOwnerForPane(pane.id)
void formatTerminalUrlTooltip(text, urlOpenLinkHint, sourceOwner).then((nextText) => {
if (hoverToken === oscTooltipHoverToken && nextText) {
pane.linkTooltip.textContent = nextText
}
@ -1454,11 +1468,15 @@ export function useTerminalPaneLifecycle({
runtimeEnvironmentId: activePane
? (linkDeps.getRuntimeEnvironmentIdForPane?.(activePane.id) ?? null)
: null,
sourceOwner: activePane
? getHttpLinkSourceOwnerForPane(activePane.id)
: { kind: 'local' },
requestOpenLinksInAppPreference
})
},
linkOpenHint: getUrlOpenLinkHint,
formatLinkTooltip: (url, openLinkHint) => formatTerminalUrlTooltip(url, openLinkHint),
formatLinkTooltip: (paneId, url, openLinkHint) =>
formatTerminalUrlTooltip(url, openLinkHint, getHttpLinkSourceOwnerForPane(paneId)),
// Why: hidden panes stay mounted so PTYs survive navigation, but their WebGL contexts drain Chromium's budget and can blank visible panes.
initialRenderingSuspended: !isVisibleRef.current,
// Why: remote-runtime panes honor the GPU setting too; late snapshots are handled by post-replay rebuildPaneWebgl in pty-connection.

View File

@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { resolveModifierRouting } from './http-link-routing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
openHttpLink,
registerHttpLinkStoreAccessor,
resolveModifierRouting
} from './http-link-routing'
describe('resolveModifierRouting', () => {
it('is inert without the modifier regardless of settings', () => {
@ -49,3 +53,62 @@ describe('resolveModifierRouting', () => {
)
})
})
describe('modifier routing across link source owners', () => {
const openUrlMock = vi.fn()
const setActiveWorktreeMock = vi.fn()
const createBrowserTabMock = vi.fn()
const storeState = {
settings: {} as {
openLinksInApp?: boolean
openLinksInAppModifierInverts?: boolean
activeRuntimeEnvironmentId?: string | null
},
setActiveWorktree: setActiveWorktreeMock,
createBrowserTab: createBrowserTabMock
}
beforeEach(() => {
vi.clearAllMocks()
registerHttpLinkStoreAccessor(() => storeState)
vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } })
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('still lets the inverting modifier pull a local link into Orca', () => {
storeState.settings = { openLinksInApp: false, openLinksInAppModifierInverts: true }
openHttpLink('https://example.com/', {
worktreeId: 'wt-1',
modifierHeld: true,
sourceOwner: { kind: 'local' }
})
expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', {
activate: true
})
})
it('never lets a modifier pull a runtime-owned link into Orca', () => {
for (const inverts of [true, false]) {
vi.clearAllMocks()
storeState.settings = {
openLinksInApp: false,
openLinksInAppModifierInverts: inverts,
activeRuntimeEnvironmentId: null
}
openHttpLink('https://example.com/', {
worktreeId: 'wt-1',
modifierHeld: true,
sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' }
})
expect(openUrlMock).toHaveBeenCalledWith('https://example.com/')
expect(createBrowserTabMock).not.toHaveBeenCalled()
}
})
})

View File

@ -145,6 +145,21 @@ describe('openHttpLink', () => {
expect(registerLocalhostLabelMock).not.toHaveBeenCalled()
})
// Why: runtimes bind per workspace, so activeRuntimeEnvironmentId is commonly
// null while a pane is remote — ownership must come from the click source.
it('keeps a runtime-owned link out of Orca when no runtime is globally active', () => {
storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: null }
openHttpLink('https://example.com/', {
worktreeId: 'wt-1',
sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' }
})
expect(openUrlMock).toHaveBeenCalledWith('https://example.com/')
expect(createBrowserTabMock).not.toHaveBeenCalled()
expect(setActiveWorktreeMock).not.toHaveBeenCalled()
})
it('labels explicit local links from the local scan instead of a merged remote port', async () => {
storeState.settings = {
openLinksInApp: true,
@ -380,6 +395,89 @@ describe('openHttpLink', () => {
await expect(resolveLocalhostHttpLinkDisplayUrl('http://localhost:5180/')).resolves.toBe(null)
expect(registerLocalhostLabelMock).not.toHaveBeenCalled()
})
// Why: the hover label must describe the click's real destination — a remote pane's
// loopback URL opens raw in the system browser, so a local worktree label would lie.
it.each([
['runtime', { kind: 'runtime', runtimeEnvironmentId: 'env-1' }] as const,
['ssh', { kind: 'ssh', connectionId: 'conn-1' }] as const
])('does not label a %s-owned localhost link without an active runtime', async (_kind, owner) => {
storeState.settings = {
localhostWorktreeLabelsEnabled: true,
activeRuntimeEnvironmentId: null
}
storeState.repos = [{ id: 'repo-1', displayName: 'snapstudio' }]
storeState.worktreesByRepo = { 'repo-1': [{ id: 'wt-main', projectId: 'repo-1' }] }
storeState.workspacePortScan = {
result: {
platform: 'darwin',
scannedAt: 1,
ports: [
{
id: 'tcp:5180',
kind: 'workspace',
port: 5180,
protocol: 'http',
bindHost: '127.0.0.1',
connectHost: 'localhost',
owner: {
repoId: 'repo-1',
worktreeId: 'wt-main',
displayName: 'main',
path: '/repo/main',
confidence: 'cwd'
}
}
]
}
}
await expect(resolveLocalhostHttpLinkDisplayUrl('http://localhost:5180/', owner)).resolves.toBe(
null
)
expect(registerLocalhostLabelMock).not.toHaveBeenCalled()
})
// Why: a local pane keeps its label from the local scan even while another pane's
// runtime is globally active — the same scan the click resolves.
it('labels a local-owned localhost link from the local scan', async () => {
storeState.settings = {
localhostWorktreeLabelsEnabled: true,
activeRuntimeEnvironmentId: 'env-other'
}
storeState.repos = [{ id: 'repo-1', displayName: 'snapstudio' }]
storeState.worktreesByRepo = { 'repo-1': [{ id: 'wt-main', projectId: 'repo-1' }] }
storeState.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: 'repo-1',
worktreeId: 'wt-main',
displayName: 'main',
path: '/repo/main',
confidence: 'cwd'
}
}
]
}
}
registerLocalhostLabelMock.mockResolvedValue({
url: 'http://snapstudio-main.orca.localhost:60016/'
})
await expect(
resolveLocalhostHttpLinkDisplayUrl('http://localhost:5180/', { kind: 'local' })
).resolves.toBe('http://snapstudio-main.orca.localhost:60016/')
})
})
describe('openHttpLink modifier routing', () => {

View File

@ -160,12 +160,17 @@ function localhostLabelRouteForHttpLink(
return localhostLabelRouteForTerminalLink(url, state, sourceOwner?.kind === 'local', sourceScan)
}
export async function resolveLocalhostHttpLinkDisplayUrl(url: string): Promise<string | null> {
export async function resolveLocalhostHttpLinkDisplayUrl(
url: string,
sourceOwner?: HttpLinkSourceOwner
): Promise<string | null> {
const state = storeAccessor?.()
if (!state) {
return null
}
const localhostRoute = localhostLabelRouteForTerminalLink(url, state)
// Why: the hover label must resolve the same route the click will take, or a
// remote pane's loopback URL gets shown with a local worktree's label.
const localhostRoute = localhostLabelRouteForHttpLink(url, state, sourceOwner)
if (!localhostRoute) {
return null
}

View File

@ -113,4 +113,26 @@ describe('createPaneDOM link tooltips', () => {
expect(pane.linkTooltip.textContent).toBe(labeledText)
})
// Why: the hovered pane's host decides where its links can go, so both hooks must
// receive that pane's id rather than resolving against global state.
it('identifies the hovered pane to both tooltip hooks', () => {
const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId
const linkOpenHint = vi.fn(() => 'open hint')
const formatLinkTooltip = vi.fn(() => null)
createPaneDOM(
7,
leafId,
{ linkOpenHint, formatLinkTooltip },
{ active: null } as never,
{} as never,
vi.fn(),
vi.fn()
)
webLinksAddonMock.options?.hover?.({} as MouseEvent, 'http://localhost:5180/')
expect(linkOpenHint).toHaveBeenCalledWith(7)
expect(formatLinkTooltip).toHaveBeenCalledWith(7, 'http://localhost:5180/', 'open hint')
})
})

View File

@ -76,10 +76,10 @@ export function createPaneDOM(
if (uri) {
linkTooltipHoverToken += 1
const hoverToken = linkTooltipHoverToken
const openLinkHint = options.linkOpenHint()
const openLinkHint = options.linkOpenHint(id)
linkTooltip.textContent = defaultLinkTooltipText(uri, openLinkHint)
linkTooltip.style.display = ''
const formatted = options.formatLinkTooltip?.(uri, openLinkHint)
const formatted = options.formatLinkTooltip?.(id, uri, openLinkHint)
if (formatted && typeof formatted === 'object' && 'then' in formatted) {
void formatted.then(
(nextText) => {

View File

@ -62,8 +62,10 @@ export type PaneManagerOptions = {
/** Resolved per hover so link-routing setting changes apply without recreating panes. */
// Why: required so dropping the wiring is a compile error — an optional hint with a
// default would silently serve stale copy that no test can distinguish.
linkOpenHint: () => string
// Why: paneId-scoped because the hovered pane's host decides where its links can go.
linkOpenHint: (paneId: number) => string
formatLinkTooltip?: (
paneId: number,
url: string,
openLinkHint: string
) => string | null | undefined | Promise<string | null | undefined>