From 255d033750038dd7ceac32ca217f7b1cf787b253 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 12 Aug 2026 17:59:06 -0400 Subject: [PATCH] Fix npm server connection actions --- docs/reference/headless-linux-server.md | 6 +- docs/reference/npm-server-design.md | 5 +- src/main/server/serve-readiness.test.ts | 4 +- src/main/server/serve-readiness.ts | 2 +- .../settings/RuntimeEnvironmentsPane.tsx | 24 +++--- .../src/components/settings/Settings.tsx | 14 +++- .../terminal-link-action-routing.test.ts | 75 +++++++++++++++++++ .../terminal-osc-link-routing.ts | 33 +++++++- .../terminal-pairing-link-actions.test.ts | 53 +++++++++++++ .../terminal-pairing-link-actions.ts | 21 ++++++ src/renderer/src/i18n/locales/en.json | 3 +- .../src/lib/settings-navigation-types.ts | 5 +- src/renderer/src/store/slices/ui.test.ts | 24 ++++++ 13 files changed, 242 insertions(+), 27 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-pairing-link-actions.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pairing-link-actions.ts diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index 192aa1a1d5f..af2c2c60dd7 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -154,7 +154,7 @@ render credential-bearing URLs as compact clickable actions: ```text Orca server is ready -Connect +Connect (click for options, or Cmd/Ctrl-click) Web browser Open web client Desktop app Add this host @@ -163,7 +163,9 @@ Server details Pairing uses ws://100.64.1.20:6768 ``` -Redirected output prints the full web client and pairing URLs. +Redirected output prints the full web client and pairing URLs. In Orca terminals, the desktop +action opens the Remote Orca Servers form with the access link prefilled; its action menu also +provides a copy fallback. For supervisors, request the versioned single-line JSON contract: diff --git a/docs/reference/npm-server-design.md b/docs/reference/npm-server-design.md index ff4a7e2ebb4..eb534322497 100644 --- a/docs/reference/npm-server-design.md +++ b/docs/reference/npm-server-design.md @@ -83,7 +83,7 @@ Supported interactive terminals render credential-bearing URLs as compact clicka ```text Orca server is ready -Connect +Connect (click for options, or Cmd/Ctrl-click) Web browser Open web client Desktop app Add this host @@ -93,7 +93,8 @@ Server details ``` Redirected output prints the full web client and pairing URLs. `--json` remains the stable -machine-readable contract. +machine-readable contract. In Orca terminals, the desktop action opens the Remote Orca Servers +form with the access link prefilled; its action menu also provides a copy fallback. ### Automation diff --git a/src/main/server/serve-readiness.test.ts b/src/main/server/serve-readiness.test.ts index 8b4cbc33576..30a32d9b2c9 100644 --- a/src/main/server/serve-readiness.test.ts +++ b/src/main/server/serve-readiness.test.ts @@ -61,7 +61,9 @@ describe('ServeReadinessPublisher', () => { await publisher.publish(ready, { mode: 'human' }) const output = write.mock.calls[0]?.[0] ?? '' - expect(output).toContain('Orca server is ready\n\nConnect') + expect(output).toContain( + 'Orca server is ready\n\nConnect (click for options, or Cmd/Ctrl-click)' + ) expect(output).toContain( '\u001B]8;;https://orca.example.test/runtime/web-index.html#pairing=secret\u001B\\Open web client\u001B]8;;\u001B\\' ) diff --git a/src/main/server/serve-readiness.ts b/src/main/server/serve-readiness.ts index d51785d6166..6cea31975f3 100644 --- a/src/main/server/serve-readiness.ts +++ b/src/main/server/serve-readiness.ts @@ -110,7 +110,7 @@ function renderHumanReadiness( const lines = ['Orca server is ready', ''] if (readiness.pairing.available) { - lines.push('Connect') + lines.push('Connect (click for options, or Cmd/Ctrl-click)') if (readiness.pairing.webClientUrl) { lines.push( ` Web browser ${terminalHyperlink('Open web client', readiness.pairing.webClientUrl)}` diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index bd7d31f9652..6be8c65ad35 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -71,7 +71,7 @@ type RuntimeEnvironmentsPaneProps = { setActiveRuntimeEnvironmentPreference: (environmentId: string | null) => Promise canGeneratePairingUrl?: boolean allowLocalRuntime?: boolean - addServerIntentSignal?: number + addServerIntent?: { signal: number; accessLink?: string } } export type RuntimeHostDetails = { @@ -263,7 +263,7 @@ export function RuntimeEnvironmentsPane({ setActiveRuntimeEnvironmentPreference, canGeneratePairingUrl = true, allowLocalRuntime = true, - addServerIntentSignal + addServerIntent }: RuntimeEnvironmentsPaneProps): React.JSX.Element { const [environments, setEnvironments] = useState([]) const [isLoading, setIsLoading] = useState(false) @@ -294,6 +294,13 @@ export function RuntimeEnvironmentsPane({ (state) => state.setRemoteServerUpdateDialogOpen ) const consumedAddServerIntentSignalRef = useRef(0) + if (addServerIntent && consumedAddServerIntentSignalRef.current !== addServerIntent.signal) { + // Why: consume Settings deep links before paint so the pairing form never flashes empty. + consumedAddServerIntentSignalRef.current = addServerIntent.signal + setPairingCode(addServerIntent.accessLink ?? '') + setAddServerFailure(null) + setAddServerFormOpen(true) + } const mountedRef = useMountedRef() const updateCheckHint = getUpdateCheckHint() const activeValue = @@ -430,19 +437,6 @@ export function RuntimeEnvironmentsPane({ useEffect(() => { void refreshRemoteServerUpdates() }, [environmentIdsKey, refreshRemoteServerUpdates]) - useEffect(() => { - if ( - !addServerIntentSignal || - consumedAddServerIntentSignalRef.current === addServerIntentSignal - ) { - return - } - consumedAddServerIntentSignalRef.current = addServerIntentSignal - // Why: composer deep-links should land on the existing pairing form, not just - // the server list. - setAddServerFormOpen(true) - }, [addServerIntentSignal]) - const closeAddServerForm = (): void => { if (isSaving) { return diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index cf07ff934cd..b2aad66fae0 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -381,7 +381,10 @@ function Settings(): React.JSX.Element { ) const [quickCommandAddIntentSignal, setQuickCommandAddIntentSignal] = useState(0) const [sshHostAddIntentSignal, setSshHostAddIntentSignal] = useState(0) - const [remoteServerAddIntentSignal, setRemoteServerAddIntentSignal] = useState(0) + const [remoteServerAddIntent, setRemoteServerAddIntent] = useState<{ + signal: number + accessLink?: string + }>({ signal: 0 }) const [hasUnsavedCommitPromptChanges, setHasUnsavedCommitPromptChanges] = useState(false) const [hasUnsavedBranchPromptChanges, setHasUnsavedBranchPromptChanges] = useState(false) const [sourceControlAiPromptDiscardSignal, setSourceControlAiPromptDiscardSignal] = useState(0) @@ -696,7 +699,12 @@ function Settings(): React.JSX.Element { } else if (settingsNavigationTarget.intent === 'add-ssh-host') { setSshHostAddIntentSignal((signal) => signal + 1) } else if (settingsNavigationTarget.intent === 'add-remote-orca-server') { - setRemoteServerAddIntentSignal((signal) => signal + 1) + setRemoteServerAddIntent((current) => ({ + signal: current.signal + 1, + ...(settingsNavigationTarget.accessLink + ? { accessLink: settingsNavigationTarget.accessLink } + : {}) + })) } setMountedSectionIds((previous) => { if (previous.has(paneSectionId)) { @@ -1728,7 +1736,7 @@ function Settings(): React.JSX.Element { setActiveRuntimeEnvironmentPreference={setActiveRuntimeEnvironmentPreference} canGeneratePairingUrl={!isWebClient} allowLocalRuntime={!isWebClient} - addServerIntentSignal={remoteServerAddIntentSignal} + addServerIntent={remoteServerAddIntent} /> ) : null} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts index 8350ec17fec..882e17c210e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts @@ -11,6 +11,7 @@ import { } from './terminal-link-action-request' import { handleOscLink } from './terminal-osc-link-routing' import { handleTerminalHttpLink } from './terminal-url-link-hit-testing' +import { encodePairingOffer } from '../../../../shared/pairing' const openUrl = vi.fn() const createBrowserTab = vi.fn() @@ -250,4 +251,78 @@ describe('terminal link action routing', () => { ).toBe(true) expect(request.mock.calls[0][0].destination).toBe(destination) }) + + it.each([ + ['macOS', 'Macintosh', { metaKey: true, ctrlKey: false }], + ['Windows', 'Windows', { metaKey: false, ctrlKey: true }], + ['Linux', 'Linux', { metaKey: false, ctrlKey: true }] + ])('opens a valid pairing link with the platform modifier on %s', (_name, userAgent, keys) => { + vi.stubGlobal('navigator', { userAgent }) + const openPairingLink = vi.fn(() => true) + const accessLink = encodePairingOffer({ + v: 2, + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'device-token', + publicKeyB64: 'public-key' + }) + + expect( + handleOscLink( + accessLink, + { ...plainEvent(), ...keys }, + { + worktreeId: 'wt-1', + worktreePath: '/repo', + openPairingLink + } + ) + ).toBe(true) + expect(openPairingLink).toHaveBeenCalledExactlyOnceWith(accessLink) + }) + + it('offers add and copy actions for a plain pairing-link click', () => { + const request = vi.fn() + const openPairingLink = vi.fn(() => true) + const accessLink = encodePairingOffer({ + v: 2, + endpoint: 'wss://server.example.test/runtime', + deviceToken: 'device-token', + publicKeyB64: 'public-key' + }) + + expect( + handleOscLink(accessLink, plainEvent(), { + worktreeId: 'wt-1', + worktreePath: '/repo', + linkActionContext: actionContext(request), + openPairingLink + }) + ).toBe(true) + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + destination: accessLink, + kind: 'url', + primary: expect.objectContaining({ label: 'Add host' }) + }) + ) + request.mock.calls[0][0].primary.run() + expect(openPairingLink).toHaveBeenCalledExactlyOnceWith(accessLink) + }) + + it('rejects unrelated custom-protocol OSC links', () => { + const openPairingLink = vi.fn(() => true) + + expect( + handleOscLink( + 'orca://unknown?code=secret', + { ...plainEvent(), metaKey: true }, + { + worktreeId: 'wt-1', + worktreePath: '/repo', + openPairingLink + } + ) + ).toBe(false) + expect(openPairingLink).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts index b041cb65f0d..a6874754f51 100644 --- a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts @@ -12,8 +12,13 @@ import { type TerminalLinkRoutingPreferenceRequester } from './terminal-url-link-hit-testing' import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' -import type { TerminalLinkActionContext } from './terminal-link-action-request' +import { + requestTerminalLinkAction, + type TerminalLinkActionContext +} from './terminal-link-action-request' import { handleTerminalFileLink } from './terminal-file-link-actions' +import { isTerminalPairingLink, openTerminalPairingLink } from './terminal-pairing-link-actions' +import { translate } from '@/i18n/i18n' type TerminalLinkEvent = Pick & Partial< @@ -55,6 +60,7 @@ export function handleOscLink( requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester linkActionContext?: TerminalLinkActionContext | null actionDestinations?: TerminalHttpLinkActionDestinations + openPairingLink?: (accessLink: string) => boolean } ): boolean { if (!isDesktopOscLinkActivation(event)) { @@ -124,6 +130,31 @@ export function handleOscLink( ) } + if (parsed.protocol === 'orca:' && isTerminalPairingLink(rawText)) { + const openPairingLink = (): boolean => { + return (deps.openPairingLink ?? openTerminalPairingLink)(rawText) + } + const runPairingLink = (): void => { + openPairingLink() + } + if (isTerminalLinkDirectActivation(event)) { + return finish(openPairingLink()) + } + return finish( + requestTerminalLinkAction(event as MouseEvent, deps.linkActionContext, { + destination: rawText, + kind: 'url', + primary: { + label: translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.addHost', + 'Add host' + ), + run: runPairingLink + } + }) + ) + } + if (parsed.protocol === 'file:') { // Why: file:// URIs should open inside Orca, not via the OS default editor // (shell.openPath). We extract the path from the URI and route it through diff --git a/src/renderer/src/components/terminal-pane/terminal-pairing-link-actions.test.ts b/src/renderer/src/components/terminal-pane/terminal-pairing-link-actions.test.ts new file mode 100644 index 00000000000..2818c3cb285 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pairing-link-actions.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { encodePairingOffer } from '../../../../shared/pairing' +import { openTerminalPairingLink } from './terminal-pairing-link-actions' + +const mocks = vi.hoisted(() => ({ + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mocks + } +})) + +describe('openTerminalPairingLink', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('opens the remote server form with the access link prefilled', () => { + const accessLink = encodePairingOffer({ + v: 2, + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'device-token', + publicKeyB64: 'public-key' + }) + + expect(openTerminalPairingLink(accessLink)).toBe(true) + expect(mocks.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'servers', + repoId: null, + intent: 'add-remote-orca-server', + accessLink + }) + expect(mocks.openSettingsPage).toHaveBeenCalledOnce() + }) + + it('does not route malformed or mobile-only pairing links', () => { + const mobileLink = encodePairingOffer({ + v: 2, + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'device-token', + publicKeyB64: 'public-key', + scope: 'mobile' + }) + + expect(openTerminalPairingLink('orca://unknown?code=secret')).toBe(false) + expect(openTerminalPairingLink(mobileLink)).toBe(false) + expect(mocks.openSettingsTarget).not.toHaveBeenCalled() + expect(mocks.openSettingsPage).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pairing-link-actions.ts b/src/renderer/src/components/terminal-pane/terminal-pairing-link-actions.ts new file mode 100644 index 00000000000..09b569931d3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pairing-link-actions.ts @@ -0,0 +1,21 @@ +import { parseHostAccessLink } from '../../../../shared/remote-pairing-address' +import { useAppStore } from '@/store' + +export function openTerminalPairingLink(accessLink: string): boolean { + if (!isTerminalPairingLink(accessLink)) { + return false + } + const { openSettingsPage, openSettingsTarget } = useAppStore.getState() + openSettingsTarget({ + pane: 'servers', + repoId: null, + intent: 'add-remote-orca-server', + accessLink + }) + openSettingsPage() + return true +} + +export function isTerminalPairingLink(accessLink: string): boolean { + return parseHostAccessLink(accessLink).ok +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8ca34180ccf..a00fe415c36 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2875,7 +2875,8 @@ "copyLink": "Copy link", "copied": "Copied", "copiedLink": "Copied link", - "copyLinkFailed": "Failed to copy link" + "copyLinkFailed": "Failed to copy link", + "addHost": "Add host" }, "TerminalSessionStateSaveFailureDialog": { "6bee0c8f17": "Open Disk Space Analyzer", diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts index b901ffb0cce..ed004453b37 100644 --- a/src/renderer/src/lib/settings-navigation-types.ts +++ b/src/renderer/src/lib/settings-navigation-types.ts @@ -68,6 +68,7 @@ export type SettingsNavigationTarget = { hostId?: ExecutionHostId sectionId?: string intent?: (typeof SETTINGS_NAV_INTENTS)[number] + accessLink?: string } export function isSettingsNavigationTarget(value: unknown): value is SettingsNavigationTarget { @@ -83,7 +84,9 @@ export function isSettingsNavigationTarget(value: unknown): value is SettingsNav (typeof target.hostId === 'string' && parseExecutionHostId(target.hostId) !== null)) && (target.sectionId === undefined || typeof target.sectionId === 'string') && (target.intent === undefined || - (typeof target.intent === 'string' && SETTINGS_NAV_INTENT_SET.has(target.intent))) + (typeof target.intent === 'string' && SETTINGS_NAV_INTENT_SET.has(target.intent))) && + (target.accessLink === undefined || + (target.intent === 'add-remote-orca-server' && typeof target.accessLink === 'string')) ) } diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index b8bccc235a4..77601009788 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -1985,6 +1985,30 @@ describe('createUISlice settings navigation', () => { }) }) + it('accepts an access-link prefill only for the remote server intent', () => { + const store = createUIStore() + const accessLink = 'orca://pair?code=secret' + store.getState().openSettingsTarget({ + pane: 'servers', + repoId: null, + intent: 'add-remote-orca-server', + accessLink + }) + expect(store.getState().settingsNavigationTarget).toEqual({ + pane: 'servers', + repoId: null, + intent: 'add-remote-orca-server', + accessLink + }) + + const openSettingsTarget = store.getState().openSettingsTarget as unknown as ( + target: unknown + ) => void + expect(() => openSettingsTarget({ pane: 'servers', repoId: null, accessLink })).toThrowError( + 'openSettingsTarget received an invalid navigation target' + ) + }) + it('rejects malformed settings targets before storing them', () => { const store = createUIStore() const openSettingsTarget = store.getState().openSettingsTarget as unknown as (