fix: address review findings (#5275)

This commit is contained in:
Jinjing 2026-06-12 11:43:39 -07:00 committed by GitHub
parent 528a9292aa
commit d2a3f12baa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 667 additions and 28 deletions

View File

@ -82,6 +82,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
localAccountRuntime: 'host',
localAccountWslDistro: null,
openLinksInApp: false,
openLinksInAppPreferencePrompted: false,
rightSidebarOpenByDefault: true,
sourceControlViewMode: 'list',
showTitlebarAppName: true,

View File

@ -86,6 +86,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
localAccountRuntime: 'host',
localAccountWslDistro: null,
openLinksInApp: false,
openLinksInAppPreferencePrompted: false,
rightSidebarOpenByDefault: true,
sourceControlViewMode: 'list',
showTitlebarAppName: true,

View File

@ -2229,6 +2229,14 @@ export class Store {
parsed.settings?.disabledTuiAgents
)
const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings)
const openLinksInAppWasPersisted = Object.prototype.hasOwnProperty.call(
parsed.settings ?? {},
'openLinksInApp'
)
const migratedOpenLinksInAppPreferencePrompted =
typeof parsed.settings?.openLinksInAppPreferencePrompted === 'boolean'
? parsed.settings.openLinksInAppPreferencePrompted
: openLinksInAppWasPersisted
if (
parsed.settings?.agentYoloDefaultsMigrated !== true ||
hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) ||
@ -2245,6 +2253,12 @@ export class Store {
if (!autoRenameBranchFromWorkDefaultedOn) {
this.loadNeedsSave = true
}
if (
parsed.settings?.openLinksInAppPreferencePrompted !==
migratedOpenLinksInAppPreferencePrompted
) {
this.loadNeedsSave = true
}
const normalizedOnboarding = normalizeLoadedOnboardingState(
parsed.onboarding,
defaults.onboarding
@ -2322,6 +2336,7 @@ export class Store {
openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, {
seedDefaults: true
}),
openLinksInAppPreferencePrompted: migratedOpenLinksInAppPreferencePrompted,
notifications: normalizeNotificationSettings(parsed.settings?.notifications),
sourceControlAi: migratedSourceControlAi,
// Why: new builds read sourceControlAi, but rollback builds still

View File

@ -61,6 +61,7 @@ import { WorkspacePortScanner } from './components/ports/WorkspacePortScanner'
import { CrashReportDialog } from './components/crash-report/CrashReportDialog'
import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary'
import { ConfirmationDialogProvider } from './components/confirmation-dialog'
import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog'
import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher'
import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling'
import { useEditorExternalWatch } from './hooks/useEditorExternalWatch'
@ -1773,7 +1774,8 @@ function App(): React.JSX.Element {
>
<TooltipProvider delayDuration={400}>
<ConfirmationDialogProvider>
<WorkspacePortScanner enabled={workspaceSessionReady} />
<LinkRoutingPreferenceDialogProvider>
<WorkspacePortScanner enabled={workspaceSessionReady} />
{/* Why: leaf-mounted retention sync keeps agent-status retention
subscriptions from re-rendering the App tree. */}
<RetainedAgentsSyncGate />
@ -2287,6 +2289,7 @@ function App(): React.JSX.Element {
>
<RecentTabSwitcher />
</RecoverableRenderErrorBoundary>
</LinkRoutingPreferenceDialogProvider>
</ConfirmationDialogProvider>
</TooltipProvider>
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />

View File

@ -0,0 +1,260 @@
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { ExternalLink, Settings } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
type LinkRoutingPreferenceDialogOptions = {
url?: string
preview?: boolean
openLinksInAppDefault?: boolean
}
type LinkRoutingPreferenceDialogRequest = {
id: number
options: LinkRoutingPreferenceDialogOptions
resolve: (openInOrca: boolean) => void
}
type LinkRoutingPreferenceDialogContextValue = (
options?: LinkRoutingPreferenceDialogOptions
) => Promise<boolean>
const PREVIEW_STORAGE_KEY = 'orca.previewLinkRoutingPreferenceDialog'
const PREVIEW_DEFAULT_STORAGE_KEY = `${PREVIEW_STORAGE_KEY}.default`
const LinkRoutingPreferenceDialogContext =
createContext<LinkRoutingPreferenceDialogContextValue | null>(null)
function displayHostForUrl(url: string | undefined): string | null {
if (!url) {
return null
}
try {
return new URL(url).host
} catch {
return null
}
}
export function LinkRoutingPreferenceDialogProvider({
children
}: {
children: React.ReactNode
}): React.JSX.Element {
const nextIdRef = useRef(0)
const [queue, setQueue] = useState<LinkRoutingPreferenceDialogRequest[]>([])
const activeRequest = queue[0] ?? null
const activeRequestRef = useRef<LinkRoutingPreferenceDialogRequest | null>(activeRequest)
const setContextualToursBlockingSurfaceVisible = useAppStore(
(s) => s.setContextualToursBlockingSurfaceVisible
)
const lastDisplayedRequestRef = useRef<LinkRoutingPreferenceDialogRequest | null>(activeRequest)
activeRequestRef.current = activeRequest
if (activeRequest) {
lastDisplayedRequestRef.current = activeRequest
}
// Why: Radix keeps dialog content mounted while closing; keep copy stable during exit animation.
const displayedRequest = activeRequest ?? lastDisplayedRequestRef.current
const displayHost = displayHostForUrl(displayedRequest?.options.url)
const openLinksInAppDefault = displayedRequest?.options.openLinksInAppDefault === true
const isMac = navigator.userAgent.includes('Mac')
const systemBrowserShortcutKeys = isMac ? ['⇧', '⌘'] : ['Shift', 'Ctrl']
useEffect(() => {
setContextualToursBlockingSurfaceVisible(activeRequest !== null)
return () => setContextualToursBlockingSurfaceVisible(false)
}, [activeRequest, setContextualToursBlockingSurfaceVisible])
const requestPreference = useCallback<LinkRoutingPreferenceDialogContextValue>((options = {}) => {
return new Promise((resolve) => {
const request: LinkRoutingPreferenceDialogRequest = {
id: nextIdRef.current,
options,
resolve
}
nextIdRef.current += 1
setQueue((currentQueue) => [...currentQueue, request])
})
}, [])
useEffect(() => {
if (!import.meta.env.DEV || typeof window === 'undefined') {
return
}
if (window.sessionStorage.getItem(PREVIEW_STORAGE_KEY) !== '1') {
return
}
const previewDefault = window.sessionStorage.getItem(PREVIEW_DEFAULT_STORAGE_KEY)
window.sessionStorage.removeItem(PREVIEW_STORAGE_KEY)
window.sessionStorage.removeItem(PREVIEW_DEFAULT_STORAGE_KEY)
void requestPreference({
openLinksInAppDefault: previewDefault === 'orca',
preview: true,
url: 'https://github.com/stablyai/orca/pull/1234'
})
}, [requestPreference])
const settleActiveRequest = useCallback((openInOrca: boolean) => {
const request = activeRequestRef.current
if (!request) {
return
}
request.resolve(openInOrca)
setQueue((currentQueue) => {
if (currentQueue[0]?.id === request.id) {
return currentQueue.slice(1)
}
return currentQueue.filter((queuedRequest) => queuedRequest.id !== request.id)
})
}, [])
return (
<LinkRoutingPreferenceDialogContext.Provider value={requestPreference}>
{children}
<Dialog
open={activeRequest !== null}
onOpenChange={(open) => !open && settleActiveRequest(false)}
>
<DialogContent
showCloseButton={false}
overlayClassName="!z-[140]"
className="!z-[150] gap-4 p-0 sm:max-w-[520px]"
>
<div className="rounded-t-lg border-b border-border bg-muted/30 px-6 pt-5 pb-4">
<DialogHeader className="gap-3">
<div className="flex items-center justify-between gap-3">
<Badge variant="outline" className="bg-background/70 text-muted-foreground">
{translate(
'auto.components.link.routing.preference.dialog.badge',
'Terminal link'
)}
</Badge>
{displayedRequest?.options.preview ? (
<Badge variant="secondary">
{translate('auto.components.link.routing.preference.dialog.preview', 'Preview')}
</Badge>
) : null}
</div>
<div className="space-y-2">
<DialogTitle className="text-xl leading-tight">
{openLinksInAppDefault
? translate(
'auto.components.link.routing.preference.dialog.keep.title',
"Keep terminal links in Orca's browser?"
)
: translate(
'auto.components.link.routing.preference.dialog.title',
"Open terminal links in Orca's browser?"
)}
</DialogTitle>
<DialogDescription className="text-sm leading-relaxed">
{openLinksInAppDefault
? translate(
'auto.components.link.routing.preference.dialog.keep.description',
'Or use your system browser by default.'
)
: translate(
'auto.components.link.routing.preference.dialog.description',
"Use Orca's browser for terminal links, or keep your system browser."
)}
</DialogDescription>
</div>
</DialogHeader>
</div>
<div className="space-y-3 px-6">
{displayHost ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{translate('auto.components.link.routing.preference.dialog.link.label', 'Link')}
</span>
<span className="rounded-md border border-border bg-muted/30 px-2 py-1 font-mono">
{displayHost}
</span>
</div>
) : null}
<div className="flex gap-2 rounded-lg border border-border bg-muted/20 p-3 text-xs leading-relaxed text-muted-foreground">
<Settings className="mt-0.5 size-3.5 shrink-0" />
<div className="space-y-1">
<p>
{translate(
'auto.components.link.routing.preference.dialog.orca.note',
'Orca can use imported cookies for logged-in sites.'
)}
</p>
<p>
{translate(
'auto.components.link.routing.preference.dialog.settings.note',
'Change this later in Settings → Browser.'
)}
</p>
<p className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<span>
{translate(
'auto.components.link.routing.preference.dialog.shortcut.note.prefix',
'When links open in Orca,'
)}
</span>
<ShortcutKeyCombo
keys={systemBrowserShortcutKeys}
keyCapClassName="min-w-0 px-1 py-0 text-[10px] shadow-none"
separatorClassName="text-[10px] text-muted-foreground"
/>
<span>
{translate(
'auto.components.link.routing.preference.dialog.shortcut.note.suffix',
'click opens system browser once.'
)}
</span>
</p>
</div>
</div>
</div>
<DialogFooter className="border-t border-border bg-muted/20 px-6 py-4 sm:justify-between">
<Button variant="outline" onClick={() => settleActiveRequest(false)}>
<ExternalLink className="size-4" />
{translate(
'auto.components.link.routing.preference.dialog.system.button',
'Use system browser'
)}
</Button>
<Button autoFocus onClick={() => settleActiveRequest(true)}>
{openLinksInAppDefault
? translate(
'auto.components.link.routing.preference.dialog.keep.orca.button',
'Keep Orca'
)
: translate(
'auto.components.link.routing.preference.dialog.orca.button',
'Open in Orca'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</LinkRoutingPreferenceDialogContext.Provider>
)
}
export function useLinkRoutingPreferenceDialog(): LinkRoutingPreferenceDialogContextValue {
const requestPreference = useContext(LinkRoutingPreferenceDialogContext)
if (!requestPreference) {
throw new Error(
'useLinkRoutingPreferenceDialog must be used inside LinkRoutingPreferenceDialogProvider'
)
}
return requestPreference
}

View File

@ -42,7 +42,12 @@ export function BrowserLinkRoutingSetting({
<button
role="switch"
aria-checked={settings.openLinksInApp}
onClick={() => updateSettings({ openLinksInApp: !settings.openLinksInApp })}
onClick={() =>
updateSettings({
openLinksInApp: !settings.openLinksInApp,
openLinksInAppPreferencePrompted: true
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.openLinksInApp ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}

View File

@ -7,6 +7,7 @@ import { X } from 'lucide-react'
import { useAppStore } from '../../store'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useLinkRoutingPreferenceDialog } from '@/components/link-routing-preference-dialog'
import { DaemonActionDialog, useDaemonActions } from '@/components/shared/useDaemonActions'
import {
DEFAULT_TERMINAL_DIVIDER_DARK,
@ -400,6 +401,7 @@ export default function TerminalPane({
const refreshWorkspaceSpace = useAppStore((store) => store.refreshWorkspaceSpace)
const settings = useAppStore((store) => store.settings)
const updateSettings = useAppStore((store) => store.updateSettings)
const requestLinkRoutingPreference = useLinkRoutingPreferenceDialog()
const keybindings = useAppStore((store) => store.keybindings)
// Why: Windows is the only platform where bare right-click is repurposed as
// a paste gesture; on macOS/Linux the terminal still owns right-click for the
@ -483,6 +485,38 @@ export default function TerminalPane({
const settingsRef = useRef(settings)
settingsRef.current = settings
const openLinksInAppPreferencePromiseRef = useRef<Promise<boolean> | null>(null)
const requestOpenLinksInAppPreference = useCallback(
(url: string): Promise<boolean> | null => {
if (settingsRef.current?.openLinksInAppPreferencePrompted === true) {
return null
}
if (!settingsRef.current) {
return null
}
if (openLinksInAppPreferencePromiseRef.current) {
return openLinksInAppPreferencePromiseRef.current
}
const preferencePromise = (async () => {
const openInOrca = await requestLinkRoutingPreference({
openLinksInAppDefault: settingsRef.current?.openLinksInApp === true,
url
})
await updateSettings({
openLinksInApp: openInOrca,
openLinksInAppPreferencePrompted: true
})
return openInOrca
})()
openLinksInAppPreferencePromiseRef.current = preferencePromise
void preferencePromise.finally(() => {
openLinksInAppPreferencePromiseRef.current = null
})
return preferencePromise
},
[requestLinkRoutingPreference, updateSettings]
)
// Why: the persisted setting can be 'auto' (default) or one of the four
// explicit modes. useEffectiveMacOptionAsAlt resolves 'auto' into
// 'true' | 'false' based on the probe's current layout category (US → 'true',
@ -781,6 +815,7 @@ export default function TerminalPane({
systemPrefersDark,
settings,
settingsRef,
requestOpenLinksInAppPreference,
effectiveMacOptionAsAlt,
effectiveMacOptionAsAltRef: macOptionAsAltRef,
initialLayoutRef,

View File

@ -39,7 +39,11 @@ const setPendingEditorRevealMock = vi.fn()
const deps = { worktreeId: 'wt-1', worktreePath: '/tmp' }
const storeState = {
settings: undefined as
| { openLinksInApp?: boolean; activeRuntimeEnvironmentId?: string | null }
| {
openLinksInApp?: boolean
openLinksInAppPreferencePrompted?: boolean
activeRuntimeEnvironmentId?: string | null
}
| undefined,
setActiveWorktree: setActiveWorktreeMock,
createBrowserTab: createBrowserTabMock,
@ -178,16 +182,40 @@ describe('handleOscLink', () => {
expect(stopPropagation).not.toHaveBeenCalled()
})
it('defaults to Orca when settings have not hydrated yet', () => {
it('defaults to the system browser when settings have not hydrated yet', () => {
setPlatform('Macintosh')
storeState.settings = undefined
handleOscLink('https://example.com', { metaKey: true, ctrlKey: false, shiftKey: false }, deps)
expect(openUrlMock).toHaveBeenCalledWith('https://example.com/')
expect(createBrowserTabMock).not.toHaveBeenCalled()
expect(setActiveWorktreeMock).not.toHaveBeenCalled()
})
it('waits for the first-use preference before routing terminal http links', async () => {
setPlatform('Macintosh')
storeState.settings = { openLinksInApp: false, openLinksInAppPreferencePrompted: false }
const requestOpenLinksInAppPreference = vi.fn(async () => {
storeState.settings = { openLinksInApp: true, openLinksInAppPreferencePrompted: true }
return true
})
handleOscLink(
'https://example.com',
{ metaKey: true, ctrlKey: false, shiftKey: false },
{ ...deps, requestOpenLinksInAppPreference }
)
expect(requestOpenLinksInAppPreference).toHaveBeenCalledWith('https://example.com/')
expect(openUrlMock).not.toHaveBeenCalled()
expect(createBrowserTabMock).not.toHaveBeenCalled()
await flushAsyncWork()
expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', {
activate: true
})
expect(setActiveWorktreeMock).toHaveBeenCalledWith('wt-1')
expect(openUrlMock).not.toHaveBeenCalled()
})
@ -1367,6 +1395,55 @@ describe('createFilePathLinkProvider range bounds', () => {
expect(element.removeEventListener).toHaveBeenCalledWith('mouseup', mouseUp)
})
it('asks for the first-use preference from the direct URL click fallback', async () => {
setPlatform('Macintosh')
storeState.settings = { openLinksInApp: false, openLinksInAppPreferencePrompted: false }
const rows = [
makeBufferLine('PR opened: https://github.com/stablyai/orca-marketing-website/pull/82')
]
const requestOpenLinksInAppPreference = vi.fn(async () => {
storeState.settings = { openLinksInApp: true, openLinksInAppPreferencePrompted: true }
return true
})
const { terminal, element } = makeFallbackTerminal(rows)
const disposable = installHttpLinkClickFallback(terminal, {
worktreeId: 'wt-1',
requestOpenLinksInAppPreference
})
const mouseUp = getRegisteredBubbleMouseUpHandler(element)
const preventDefault = vi.fn()
mouseUp({
button: 0,
metaKey: true,
ctrlKey: false,
shiftKey: false,
defaultPrevented: false,
clientX: 230,
clientY: 25,
preventDefault,
stopPropagation: vi.fn()
} as unknown as MouseEvent)
expect(requestOpenLinksInAppPreference).toHaveBeenCalledWith(
'https://github.com/stablyai/orca-marketing-website/pull/82'
)
expect(openUrlMock).not.toHaveBeenCalled()
expect(createBrowserTabMock).not.toHaveBeenCalled()
await flushAsyncWork()
expect(createBrowserTabMock).toHaveBeenCalledWith(
'wt-1',
'https://github.com/stablyai/orca-marketing-website/pull/82',
{ activate: true }
)
expect(preventDefault).toHaveBeenCalled()
expect(terminal.clearSelection).toHaveBeenCalled()
disposable.dispose()
})
it('does not double-open URLs when xterm already handled the mouseup', () => {
setPlatform('Macintosh')
storeState.settings = { openLinksInApp: false }

View File

@ -1,10 +1,13 @@
import { resolveTerminalFileLinkText } from '@/lib/terminal-links'
import { openHttpLink } from '@/lib/http-link-routing'
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
import type { LinkHandlerDeps } from './terminal-link-handlers'
import { isTerminalLinkActivation } from './terminal-link-handlers'
import { resolveTerminalFileUrlTarget } from './terminal-file-url-target'
import { openDetectedFilePath } from './terminal-file-open-routing'
import {
openTerminalHttpLink,
type TerminalLinkRoutingPreferenceRequester
} from './terminal-url-link-hit-testing'
type TerminalLinkEvent = Pick<MouseEvent, 'metaKey' | 'ctrlKey'> &
Partial<Pick<MouseEvent, 'shiftKey' | 'preventDefault' | 'stopPropagation'>>
@ -13,7 +16,9 @@ export function handleOscLink(
rawText: string,
event: TerminalLinkEvent | undefined,
deps: Pick<LinkHandlerDeps, 'worktreeId' | 'worktreePath'> &
Partial<Pick<LinkHandlerDeps, 'runtimeEnvironmentId' | 'startupCwd' | 'terminalHomePath'>>
Partial<Pick<LinkHandlerDeps, 'runtimeEnvironmentId' | 'startupCwd' | 'terminalHomePath'>> & {
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
}
): void {
if (!isTerminalLinkActivation(event)) {
return
@ -65,9 +70,10 @@ export function handleOscLink(
}
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
openHttpLink(parsed.toString(), {
openTerminalHttpLink(parsed.toString(), {
worktreeId: deps.worktreeId,
forceSystemBrowser: Boolean(event?.shiftKey)
forceSystemBrowser: Boolean(event?.shiftKey),
requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
})
return
}

View File

@ -6,12 +6,18 @@ import { rangeForParsedFileLink } from './wrapped-terminal-link-ranges'
type UrlLinkHitTestDeps = {
worktreeId: string
forceSystemBrowser?: boolean
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
}
type UrlLinkClickFallbackDeps = {
worktreeId: string
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
}
export type TerminalLinkRoutingPreferenceRequester = (
url: string
) => boolean | Promise<boolean> | null | undefined
type ParsedTerminalHttpLink = {
url: string
startIndex: number
@ -100,7 +106,8 @@ export function installHttpLinkClickFallback(
// that xterm already handled.
const opened = openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, {
worktreeId: deps.worktreeId,
forceSystemBrowser: event.shiftKey
forceSystemBrowser: event.shiftKey,
requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
})
if (opened) {
event.preventDefault()
@ -134,10 +141,7 @@ export function openHttpLinkAtBufferPosition(
if (!range || !rangeContainsBufferPosition(range, position, terminalColumns)) {
continue
}
openHttpLink(parsed.url, {
worktreeId: deps.worktreeId,
forceSystemBrowser: deps.forceSystemBrowser
})
openTerminalHttpLink(parsed.url, deps)
return true
}
}
@ -145,6 +149,33 @@ export function openHttpLinkAtBufferPosition(
return false
}
export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): void {
if (deps.forceSystemBrowser) {
openHttpLink(url, { worktreeId: deps.worktreeId, forceSystemBrowser: true })
return
}
const preferenceDecision = deps.requestOpenLinksInAppPreference?.(url)
if (preferenceDecision === null || preferenceDecision === undefined) {
openHttpLink(url, { worktreeId: deps.worktreeId })
return
}
// Why: the first terminal link click may need an async preference dialog.
// Suppress the browser's default link handling first, then route after the
// persisted choice is available.
void Promise.resolve(preferenceDecision)
.then((openInOrca) => {
openHttpLink(url, {
worktreeId: deps.worktreeId,
forceSystemBrowser: !openInOrca
})
})
.catch(() => {
openHttpLink(url, { worktreeId: deps.worktreeId, forceSystemBrowser: true })
})
}
function rangeContainsBufferPosition(
range: IBufferRange,
position: { x: number; y: number },

View File

@ -16,7 +16,10 @@ import {
import { createTerminalHandleLinkProvider } from './terminal-handle-links'
import type { LinkHandlerDeps } from './terminal-link-handlers'
import { handleOscLink } from './terminal-osc-link-routing'
import { installHttpLinkClickFallback } from './terminal-url-link-hit-testing'
import {
installHttpLinkClickFallback,
type TerminalLinkRoutingPreferenceRequester
} from './terminal-url-link-hit-testing'
import type {
GlobalSettings,
SetupSplitDirection,
@ -133,6 +136,7 @@ type UseTerminalPaneLifecycleDeps = {
systemPrefersDark: boolean
settings: GlobalSettings | null | undefined
settingsRef: React.RefObject<GlobalSettings | null | undefined>
requestOpenLinksInAppPreference: TerminalLinkRoutingPreferenceRequester
/** Resolved Option-as-Alt value: `'auto'` has already been mapped to
* `'true' | 'false'` via the keyboard-layout probe. Passed separately
* from `settings` because the probe lives outside the settings store. */
@ -322,6 +326,7 @@ export function useTerminalPaneLifecycle({
systemPrefersDark,
settings,
settingsRef,
requestOpenLinksInAppPreference,
effectiveMacOptionAsAlt,
effectiveMacOptionAsAltRef,
initialLayoutRef,
@ -696,10 +701,10 @@ export function useTerminalPaneLifecycle({
linkDeps
)
fileLinkClickFallbackDisposablesRef.current.set(pane.id, fileLinkClickFallbackDisposable)
const httpLinkClickFallbackDisposable = installHttpLinkClickFallback(
pane.terminal,
linkDeps
)
const httpLinkClickFallbackDisposable = installHttpLinkClickFallback(pane.terminal, {
...linkDeps,
requestOpenLinksInAppPreference
})
httpLinkClickFallbackDisposables.set(pane.id, httpLinkClickFallbackDisposable)
// Why: skip empty selections so clicking to deselect doesn't clobber
// whatever the user last copied elsewhere.
@ -766,7 +771,8 @@ export function useTerminalPaneLifecycle({
activate: (event, text) => {
handleOscLink(text, event as MouseEvent | undefined, {
...linkDeps,
runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null
runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null,
requestOpenLinksInAppPreference
})
// Why: Cmd/Ctrl+clicking a link activates Orca handling (open file,
// new browser tab, system browser) which can steal focus from the
@ -1033,7 +1039,8 @@ export function useTerminalPaneLifecycle({
...linkDeps,
runtimeEnvironmentId: activePane
? (linkDeps.getRuntimeEnvironmentIdForPane?.(activePane.id) ?? null)
: null
: null,
requestOpenLinksInAppPreference
})
// Why: Cmd/Ctrl+click on a plain-text URL (WebLinksAddon) takes focus
// away from the terminal before the click's mouseup reaches

View File

@ -10517,6 +10517,44 @@
"8388bdea2b": "Connect Jira site"
}
}
},
"link": {
"routing": {
"preference": {
"dialog": {
"badge": "Terminal link",
"preview": "Preview",
"title": "Open terminal links in Orca's browser?",
"description": "Use Orca's browser for terminal links, or keep your system browser.",
"orca": {
"button": "Open in Orca",
"note": "Orca can use imported cookies for logged-in sites."
},
"settings": {
"note": "Change this later in Settings → Browser."
},
"system": {
"button": "Use system browser"
},
"link": {
"label": "Link"
},
"shortcut": {
"note": {
"prefix": "When links open in Orca,",
"suffix": "click opens system browser once."
}
},
"keep": {
"title": "Keep terminal links in Orca's browser?",
"description": "Or use your system browser by default.",
"orca": {
"button": "Keep Orca"
}
}
}
}
}
}
},
"i18n": {

View File

@ -10517,6 +10517,44 @@
"8388bdea2b": "Connect Jira site"
}
}
},
"link": {
"routing": {
"preference": {
"dialog": {
"badge": "Enlace del terminal",
"preview": "Vista previa",
"title": "¿Abrir enlaces del terminal en el navegador de Orca?",
"description": "Usa el navegador de Orca para los enlaces del terminal o conserva tu navegador del sistema.",
"orca": {
"button": "Abrir en Orca",
"note": "Orca puede usar cookies importadas para sitios con sesión iniciada."
},
"settings": {
"note": "Cámbialo después en Configuración → Navegador."
},
"system": {
"button": "Usar navegador del sistema"
},
"link": {
"label": "Enlace"
},
"shortcut": {
"note": {
"prefix": "Cuando los enlaces se abren en Orca,",
"suffix": "clic abre el navegador del sistema una vez."
}
},
"keep": {
"title": "¿Mantener los enlaces del terminal en el navegador de Orca?",
"description": "O usa tu navegador del sistema de forma predeterminada.",
"orca": {
"button": "Mantener Orca"
}
}
}
}
}
}
},
"i18n": {

View File

@ -10517,6 +10517,44 @@
"8388bdea2b": "Connect Jira site"
}
}
},
"link": {
"routing": {
"preference": {
"dialog": {
"badge": "ターミナルリンク",
"preview": "プレビュー",
"title": "ターミナルのリンクを Orca のブラウザで開きますか?",
"description": "ターミナルのリンクを Orca のブラウザで開くか、システムブラウザを使い続けます。",
"orca": {
"button": "Orca で開く",
"note": "Orca はインポート済み Cookie を使ってログイン済みサイトを開けます。"
},
"settings": {
"note": "後で 設定 → ブラウザ から変更できます。"
},
"system": {
"button": "システムブラウザを使用"
},
"link": {
"label": "リンク"
},
"shortcut": {
"note": {
"prefix": "リンクを Orca で開く場合、",
"suffix": "クリックで今回だけシステムブラウザで開きます。"
}
},
"keep": {
"title": "ターミナルのリンクを Orca のブラウザで開き続けますか?",
"description": "またはシステムブラウザを既定で使用します。",
"orca": {
"button": "Orca のままにする"
}
}
}
}
}
}
},
"i18n": {

View File

@ -10517,6 +10517,44 @@
"8388bdea2b": "Connect Jira site"
}
}
},
"link": {
"routing": {
"preference": {
"dialog": {
"badge": "터미널 링크",
"preview": "미리보기",
"title": "터미널 링크를 Orca 브라우저에서 열까요?",
"description": "터미널 링크를 Orca 브라우저에서 열거나 시스템 브라우저를 계속 사용합니다.",
"orca": {
"button": "Orca에서 열기",
"note": "Orca는 가져온 쿠키를 사용해 로그인된 사이트를 열 수 있습니다."
},
"settings": {
"note": "나중에 설정 → 브라우저에서 변경할 수 있습니다."
},
"system": {
"button": "시스템 브라우저 사용"
},
"link": {
"label": "링크"
},
"shortcut": {
"note": {
"prefix": "링크가 Orca에서 열릴 때,",
"suffix": "클릭하면 한 번만 시스템 브라우저에서 열립니다."
}
},
"keep": {
"title": "터미널 링크를 Orca 브라우저에서 계속 열까요?",
"description": "또는 시스템 브라우저를 기본값으로 사용합니다.",
"orca": {
"button": "Orca 유지"
}
}
}
}
}
}
},
"i18n": {

View File

@ -10517,6 +10517,44 @@
"8388bdea2b": "Connect Jira site"
}
}
},
"link": {
"routing": {
"preference": {
"dialog": {
"badge": "终端链接",
"preview": "预览",
"title": "在 Orca 浏览器中打开终端链接?",
"description": "用 Orca 浏览器打开终端链接,或继续使用系统浏览器。",
"orca": {
"button": "在 Orca 中打开",
"note": "Orca 可以使用已导入的 Cookie 打开已登录的网站。"
},
"settings": {
"note": "之后可在设置 → 浏览器中更改。"
},
"system": {
"button": "使用系统浏览器"
},
"link": {
"label": "链接"
},
"shortcut": {
"note": {
"prefix": "当链接在 Orca 中打开时,",
"suffix": "点击可临时改用系统浏览器。"
}
},
"keep": {
"title": "继续在 Orca 浏览器中打开终端链接?",
"description": "或默认使用系统浏览器。",
"orca": {
"button": "继续使用 Orca"
}
}
}
}
}
}
},
"i18n": {

View File

@ -8,7 +8,11 @@ const createBrowserTabMock = vi.fn()
const storeState = {
settings: undefined as
| { openLinksInApp?: boolean; activeRuntimeEnvironmentId?: string | null }
| {
openLinksInApp?: boolean
openLinksInAppPreferencePrompted?: boolean
activeRuntimeEnvironmentId?: string | null
}
| undefined,
setActiveWorktree: setActiveWorktreeMock,
createBrowserTab: createBrowserTabMock
@ -44,13 +48,13 @@ describe('openHttpLink', () => {
expect(openUrlMock).not.toHaveBeenCalled()
})
it('defaults to Orca routing when settings have not hydrated', () => {
it('defaults to the system browser when settings have not hydrated', () => {
storeState.settings = undefined
openHttpLink('https://example.com/', { worktreeId: 'wt-1' })
expect(createBrowserTabMock).toHaveBeenCalled()
expect(openUrlMock).not.toHaveBeenCalled()
expect(openUrlMock).toHaveBeenCalledWith('https://example.com/')
expect(createBrowserTabMock).not.toHaveBeenCalled()
})
it('routes floating workspace links into Orca without changing the active repo worktree', () => {

View File

@ -35,7 +35,7 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
!remoteRuntimeActive &&
!forceSystemBrowser &&
Boolean(worktreeId) &&
state?.settings?.openLinksInApp !== false
state?.settings?.openLinksInApp === true
if (routeToOrca && worktreeId && state) {
// Why: http clicks from inside a worktree should not push a worktree-switch

View File

@ -39,7 +39,7 @@ function delay(ms: number): Promise<void> {
export function shouldOpenWorkspacePortInOrcaBrowser(
settings: { openLinksInApp?: boolean } | null | undefined
): boolean {
return settings?.openLinksInApp !== false
return settings?.openLinksInApp === true
}
export function workspacePortOwnerWorktreeId(port: WorkspacePort): string | null {

View File

@ -245,7 +245,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
httpProxyUrl: '',
httpProxyBypassRules: '',
electronHttp1CompatibilityMode: false,
openLinksInApp: true,
openLinksInApp: false,
openLinksInAppPreferencePrompted: false,
openInApplications: [...DEFAULT_OPEN_IN_APPLICATIONS],
rightSidebarOpenByDefault: true,
showGitIgnoredFiles: true,

View File

@ -2185,6 +2185,9 @@ export type GlobalSettings = {
* The setting stays opt-in so existing workflows continue to use the system browser
* until the user explicitly wants worktree-scoped in-app browsing. */
openLinksInApp: boolean
/** Why: terminal link routing asks once at first use instead of silently
* changing where links open for new users. */
openLinksInAppPreferencePrompted: boolean
/** Extra launcher rows for the worktree "Open in" submenu. VS Code is always shown first. */
openInApplications?: OpenInApplication[]
/** Deprecated: migration/backward-compat only. Use PersistedUIState.rightSidebarOpen. */