From ce321f0bb5f07241e98547813832cf435ab37925 Mon Sep 17 00:00:00 2001
From: Neil
Date: Thu, 12 Nov 2026 17:36:32 -0800
Subject: [PATCH] feat(open-external): Copy link split button on every sign-in,
account, billing and GitHub browser hop
Every button that opens a URL in the external browser gets a chevron with
"Copy link" that copies the exact URL (minted the same way for lazy links),
for incognito windows or another browser profile.
---
src/main/billing/billing-hosted-links.ts | 21 +++
src/main/billing/billing-open-result.ts | 4 +-
src/main/billing/billing-service-link.test.ts | 35 +++++
src/main/billing/billing-service.ts | 53 ++++---
.../billing/billing-setup-session-fallback.ts | 18 +++
src/main/hosted-sandbox/hosted-account-ipc.ts | 36 +++--
.../hosted-github-link-ipc.test.ts | 93 +++++++++++
.../hosted-sandbox/hosted-github-user-ipc.ts | 26 +++-
.../profiles/orca-profile-billing-handlers.ts | 14 ++
.../orca-cloud-agents-ipc.ts | 10 +-
.../orca-cloud-agents-service.test.ts | 4 +
.../orca-cloud-agents-service.ts | 24 ++-
src/preload/api/orca-cloud-agents-bridge.ts | 3 +
.../api/orca-cloud-workspace-bridge.ts | 2 +
src/preload/api/orca-profile-api.ts | 5 +
src/preload/api/orca-profiles-bridge.ts | 2 +
.../src/components/OrcaSignInProgress.tsx | 26 +++-
.../ssh-classic/SshCodeSignIn.tsx | 47 ++++--
.../CloudAgentSignInStrip.test.tsx | 42 +++++
.../CloudAgentSignInStrip.tsx | 44 +++---
.../CloudStripPrimaryButton.tsx | 72 +++++++++
.../cloud-setup-strip-actions.ts | 6 +
.../agent-sign-in/AgentSignInCard.tsx | 37 ++---
.../components/billing/PaymentIssueNotice.tsx | 15 +-
.../components/billing/PlanBillingSection.tsx | 14 +-
.../components/billing/billing-link.test.ts | 56 +++++++
.../src/components/billing/billing-link.ts | 35 +++++
.../billing/checkout/CheckoutPanel.tsx | 22 ++-
.../billing/checkout/use-checkout-flow.ts | 48 ++++--
.../billing/plans/EnterprisePlanCard.tsx | 11 +-
.../billing/plans/PlansDialogHost.tsx | 8 +
.../billing/plans/PlansView.test.tsx | 7 +
.../components/billing/plans/PlansView.tsx | 11 ++
.../components/billing/plans/TeamPlanCard.tsx | 65 ++++++--
.../components/billing/team/TeamPageView.tsx | 7 +-
.../billing/team/TeamPlanSummary.tsx | 8 +-
.../ProjectComboboxCloudAddProjectEntry.tsx | 23 ++-
.../cloud-setup/CloudSetupErrorNotice.tsx | 22 ++-
.../cloud-setup-tracker-actions.ts | 35 ++---
.../run-on/use-cloud-start-steps.ts | 4 +-
.../OpenExternalSplitButton.test.tsx | 119 ++++++++++++++
.../open-external/OpenExternalSplitButton.tsx | 146 ++++++++++++++++++
.../open-external/copy-external-link.ts | 48 ++++++
.../CloudCapabilityOffActions.tsx | 9 +-
.../CloudSetupBrowserButton.tsx | 26 ++++
.../CloudSetupPage.fixture.tsx | 7 +-
.../orca-cloud-setup/CloudSetupPage.tsx | 36 +++--
.../orca-cloud-setup/use-cloud-setup-flow.ts | 20 +--
.../orca-cloud/CloudSetupNotices.tsx | 17 +-
.../orca-cloud/cloud-toast-stack.tsx | 18 ++-
.../accounts/orca/GitHubIdentityRow.tsx | 32 ++--
.../agent-accounts/OrcaCloudSetupRow.tsx | 20 ++-
.../src/components/ui/button-group.tsx | 15 +-
.../VmBuildAgentSignInRow.tsx | 10 +-
.../VmBuildFailureActions.tsx | 55 +++++--
src/renderer/src/i18n/locales/en.json | 9 +-
.../orca-cloud/cloud-glossary-allowlist.json | 2 +-
.../src/lib/orca-cloud/github-link-sources.ts | 23 +++
...ca-cloud-workspace-store-git-setup.test.ts | 2 +
.../orca-cloud-workspace-store-wake.test.ts | 2 +
.../orca-cloud-workspace-store.test.ts | 2 +
.../orca-cloud/use-orca-cloud-repo-access.ts | 22 ++-
.../web/preload-api/web-orca-profiles-api.ts | 2 +
.../hosted-sandbox/cloud-workspace-api.ts | 9 ++
src/shared/orca-billing.ts | 6 +
65 files changed, 1398 insertions(+), 274 deletions(-)
create mode 100644 src/main/billing/billing-hosted-links.ts
create mode 100644 src/main/billing/billing-service-link.test.ts
create mode 100644 src/main/billing/billing-setup-session-fallback.ts
create mode 100644 src/main/hosted-sandbox/hosted-github-link-ipc.test.ts
create mode 100644 src/renderer/src/components/agent-sign-in-strip/CloudStripPrimaryButton.tsx
create mode 100644 src/renderer/src/components/billing/billing-link.test.ts
create mode 100644 src/renderer/src/components/billing/billing-link.ts
create mode 100644 src/renderer/src/components/open-external/OpenExternalSplitButton.test.tsx
create mode 100644 src/renderer/src/components/open-external/OpenExternalSplitButton.tsx
create mode 100644 src/renderer/src/components/open-external/copy-external-link.ts
create mode 100644 src/renderer/src/components/orca-cloud-setup/CloudSetupBrowserButton.tsx
create mode 100644 src/renderer/src/lib/orca-cloud/github-link-sources.ts
diff --git a/src/main/billing/billing-hosted-links.ts b/src/main/billing/billing-hosted-links.ts
new file mode 100644
index 00000000000..83072c8740c
--- /dev/null
+++ b/src/main/billing/billing-hosted-links.ts
@@ -0,0 +1,21 @@
+// "Copy link" on billing buttons: the Stripe Checkout or Customer Portal URL the button would open,
+// minted the same way (same account, same order, same setup-session fallback) but returned instead.
+import type { OrcaBillingCheckoutArgs, OrcaBillingLinkResult } from '../../shared/orca-billing'
+import { createCheckoutUrl, createPortalUrl } from './billing-client'
+import { mintHosted } from './billing-service'
+import { withSetupSessionFallback } from './billing-setup-session-fallback'
+
+export function getOrcaBillingCheckoutLink(
+ userDataPath: string,
+ args: OrcaBillingCheckoutArgs
+): Promise {
+ return withSetupSessionFallback(args, (checkoutArgs) =>
+ mintHosted(userDataPath, (token, endpoints) =>
+ createCheckoutUrl(endpoints, token, checkoutArgs)
+ )
+ )
+}
+
+export function getOrcaBillingPortalLink(userDataPath: string): Promise {
+ return mintHosted(userDataPath, (token, endpoints) => createPortalUrl(endpoints, token))
+}
diff --git a/src/main/billing/billing-open-result.ts b/src/main/billing/billing-open-result.ts
index 3ebff7201dc..631d545bc87 100644
--- a/src/main/billing/billing-open-result.ts
+++ b/src/main/billing/billing-open-result.ts
@@ -1,10 +1,10 @@
// What a failed Checkout/Portal open means, as a typed result the plans dialog can act on.
-import type { OrcaBillingOpenResult } from '../../shared/orca-billing'
+import type { OrcaBillingOpenRefusal } from '../../shared/orca-billing'
import { BillingServiceError } from './billing-client'
const message = (error: unknown): string => (error instanceof Error ? error.message : String(error))
-export function openResultForError(error: unknown): OrcaBillingOpenResult {
+export function openResultForError(error: unknown): OrcaBillingOpenRefusal {
if (error instanceof BillingServiceError) {
if (error.statusCode === 403) {
return { status: 'forbidden' }
diff --git a/src/main/billing/billing-service-link.test.ts b/src/main/billing/billing-service-link.test.ts
new file mode 100644
index 00000000000..2313cace95d
--- /dev/null
+++ b/src/main/billing/billing-service-link.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', () => ({ app: { isPackaged: false }, shell: { openExternal: vi.fn() } }))
+
+import { withSetupSessionFallback } from './billing-setup-session-fallback'
+import type { OrcaBillingCheckoutArgs, OrcaBillingLinkResult } from '../../shared/orca-billing'
+
+const ARGS: OrcaBillingCheckoutArgs = {
+ plan: 'team',
+ interval: 'month',
+ seats: 3,
+ setupSessionId: 'cs_x'
+}
+
+describe('checkout "Copy link" setup-session fallback', () => {
+ it('retries without the setup session when billing says it is for someone else', async () => {
+ const run = vi.fn(async (args: OrcaBillingCheckoutArgs): Promise =>
+ args.setupSessionId
+ ? { status: 'failed', error: 'setup_session_mismatch' }
+ : { status: 'ok', url: 'https://checkout.stripe.com/c/pay/cs_live_1' }
+ )
+ await expect(withSetupSessionFallback(ARGS, run)).resolves.toEqual({
+ status: 'ok',
+ url: 'https://checkout.stripe.com/c/pay/cs_live_1'
+ })
+ expect(run).toHaveBeenCalledTimes(2)
+ expect(run.mock.calls[1][0]).not.toHaveProperty('setupSessionId')
+ })
+
+ it('keeps any other refusal as it is', async () => {
+ const run = vi.fn(async (): Promise => ({ status: 'forbidden' }))
+ await expect(withSetupSessionFallback(ARGS, run)).resolves.toEqual({ status: 'forbidden' })
+ expect(run).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/main/billing/billing-service.ts b/src/main/billing/billing-service.ts
index 4c510cb940f..c0845051b7a 100644
--- a/src/main/billing/billing-service.ts
+++ b/src/main/billing/billing-service.ts
@@ -7,6 +7,7 @@ import { join } from 'node:path'
import { app, shell } from 'electron'
import type {
OrcaBillingCheckoutArgs,
+ OrcaBillingLinkResult,
OrcaBillingOpenResult,
OrcaBillingOverageArgs,
OrcaBillingOverageResult,
@@ -18,6 +19,7 @@ import type {
import type { OrcaPlanCatalogResult } from '../../shared/orca-billing-plans'
import { loadPlanCatalog } from './plan-catalog-client'
import { openResultForError } from './billing-open-result'
+import { withSetupSessionFallback } from './billing-setup-session-fallback'
import { getOrcaCloudAuthConfig } from '../orca-profiles/cloud-auth/profile-cloud-auth-config'
import { runWithFreshOrcaCloudSession } from '../orca-profiles/cloud-session/profile-cloud-session-refresh'
import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store'
@@ -217,52 +219,57 @@ export async function getOrcaBillingTeamPlans(
}
}
-async function openHosted(
+type CreateHostedUrl = (token: string, endpoints: BillingEndpoints) => Promise
+
+/** Mints a Stripe-hosted Checkout/Portal URL for the active account without opening it. */
+export async function mintHosted(
userDataPath: string,
- createUrl: (token: string, endpoints: BillingEndpoints) => Promise
-): Promise {
+ createUrl: CreateHostedUrl
+): Promise {
const ready = prepare(userDataPath)
if (!ready.ok) {
return { status: ready.status }
}
try {
const url = await ready.run(createUrl)
- if (url === 'signed-out') {
- return { status: 'signed-out' }
- }
// createUrl already restricted this to Stripe's hosted Checkout/Portal hosts.
+ return url === 'signed-out' ? { status: 'signed-out' } : { status: 'ok', url }
+ } catch (error) {
+ return openResultForError(error)
+ }
+}
+
+async function openHosted(
+ userDataPath: string,
+ createUrl: CreateHostedUrl
+): Promise {
+ const minted = await mintHosted(userDataPath, createUrl)
+ if (minted.status !== 'ok') {
+ return minted
+ }
+ try {
const devUrlFile = app.isPackaged
? ''
: (process.env.ORCA_BILLING_DEV_OPEN_URL_FILE?.trim() ?? '')
// Dev automation only: hand the Stripe URL to a test driver instead of the user's browser.
await (devUrlFile
- ? appendFile(devUrlFile, `${url}\n`, { mode: 0o600 })
- : shell.openExternal(url))
+ ? appendFile(devUrlFile, `${minted.url}\n`, { mode: 0o600 })
+ : shell.openExternal(minted.url))
return { status: 'opened' }
} catch (error) {
return openResultForError(error)
}
}
-export async function openOrcaBillingCheckout(
+export function openOrcaBillingCheckout(
userDataPath: string,
args: OrcaBillingCheckoutArgs
): Promise {
- const result = await openHosted(userDataPath, (token, endpoints) =>
- createCheckoutUrl(endpoints, token, args)
- )
- // A setup session for another team or user (or an expired one) must not block buying.
- if (
- args.setupSessionId &&
- result.status === 'failed' &&
- /setup_session_mismatch/.test(result.error)
- ) {
- const { setupSessionId: _dropped, ...plain } = args
- return openHosted(userDataPath, (token, endpoints) =>
- createCheckoutUrl(endpoints, token, plain)
+ return withSetupSessionFallback(args, (checkoutArgs) =>
+ openHosted(userDataPath, (token, endpoints) =>
+ createCheckoutUrl(endpoints, token, checkoutArgs)
)
- }
- return result
+ )
}
export function openOrcaBillingPortal(userDataPath: string): Promise {
diff --git a/src/main/billing/billing-setup-session-fallback.ts b/src/main/billing/billing-setup-session-fallback.ts
new file mode 100644
index 00000000000..d29a0244a09
--- /dev/null
+++ b/src/main/billing/billing-setup-session-fallback.ts
@@ -0,0 +1,18 @@
+import type { OrcaBillingCheckoutArgs } from '../../shared/orca-billing'
+
+/** A setup session for another team or user (or an expired one) must not block buying. */
+export async function withSetupSessionFallback(
+ args: OrcaBillingCheckoutArgs,
+ run: (args: OrcaBillingCheckoutArgs) => Promise
+): Promise {
+ const result = await run(args)
+ if (
+ args.setupSessionId &&
+ result.status === 'failed' &&
+ /setup_session_mismatch/.test(result.error ?? '')
+ ) {
+ const { setupSessionId: _dropped, ...plain } = args
+ return run(plain)
+ }
+ return result
+}
diff --git a/src/main/hosted-sandbox/hosted-account-ipc.ts b/src/main/hosted-sandbox/hosted-account-ipc.ts
index 90721076d6b..53b21078fed 100644
--- a/src/main/hosted-sandbox/hosted-account-ipc.ts
+++ b/src/main/hosted-sandbox/hosted-account-ipc.ts
@@ -18,6 +18,7 @@ const SETUP_SESSION_ID = /^cs_[A-Za-z0-9_-]{22}$/
export const HOSTED_ACCOUNT_CHANNELS = [
CH.getConfig,
CH.connectGitHub,
+ CH.gitHubInstallLink,
CH.getRepoAccess,
CH.listGitHubRepositories,
CH.createFromGitHub,
@@ -75,25 +76,38 @@ export function registerHostedAccountHandlers(
return config.value
})
+ // A fresh single-use install page; only ever a GitHub page, whatever the response says.
+ const mintInstallUrl = async (args: RepoArgs): Promise => {
+ const target = repoTarget(args)
+ const ids = target ? await repoIds(target.repo) : null
+ const { installUrl } = await client.connectGitHub(
+ undefined,
+ target ? { ...target, ...ids } : undefined
+ )
+ if (new URL(installUrl).origin !== 'https://github.com') {
+ throw new Error('Unexpected GitHub connect URL.')
+ }
+ return installUrl
+ }
+
ipcMain.handle(CH.connectGitHub, async (_event, args?: RepoArgs) => {
try {
- const target = repoTarget(args)
- const ids = target ? await repoIds(target.repo) : null
- const { installUrl } = await client.connectGitHub(
- undefined,
- target ? { ...target, ...ids } : undefined
- )
- // Only ever hand the browser a GitHub page, whatever the response says.
- if (new URL(installUrl).origin !== 'https://github.com') {
- return { ok: false, code: 'failed', message: 'Unexpected GitHub connect URL.' }
- }
- await shell.openExternal(installUrl)
+ await shell.openExternal(await mintInstallUrl(args))
return { ok: true, value: null }
} catch (error) {
return failure(error)
}
})
+ // "Copy link": the same install page, minted the same way, for another browser or profile.
+ ipcMain.handle(CH.gitHubInstallLink, async (_event, args?: RepoArgs) => {
+ try {
+ return { ok: true, value: await mintInstallUrl(args) }
+ } catch (error) {
+ return failure(error)
+ }
+ })
+
ipcMain.handle(CH.getRepoAccess, async (_event, args?: RepoArgs) => {
const target = repoTarget(args)
if (!target) {
diff --git a/src/main/hosted-sandbox/hosted-github-link-ipc.test.ts b/src/main/hosted-sandbox/hosted-github-link-ipc.test.ts
new file mode 100644
index 00000000000..7cf0b49e226
--- /dev/null
+++ b/src/main/hosted-sandbox/hosted-github-link-ipc.test.ts
@@ -0,0 +1,93 @@
+// "Copy link" twins of Connect GitHub (App install) and Connect your GitHub (OAuth): the same page,
+// minted the same way, returned instead of opened, and still only ever a github.com page.
+import { afterEach, expect, it, vi } from 'vitest'
+
+const handlers = new Map unknown>()
+const { openExternal } = vi.hoisted(() => ({ openExternal: vi.fn(async () => {}) }))
+
+vi.mock('electron', () => ({
+ ipcMain: {
+ handle: (channel: string, handler: (...args: unknown[]) => unknown) =>
+ handlers.set(channel, handler)
+ },
+ shell: { openExternal }
+}))
+
+import { ORCA_CLOUD_WORKSPACE_CHANNELS as CH } from '../../shared/hosted-sandbox/cloud-workspace-api'
+import { HOSTED_ACCOUNT_CHANNELS, registerHostedAccountHandlers } from './hosted-account-ipc'
+
+const REPO = 'https://github.com/acme/web'
+const SESSION = 'cs_abcdefghijklmnopqrstuv'
+
+afterEach(() => {
+ handlers.clear()
+ vi.clearAllMocks()
+})
+
+function register(url: string) {
+ const connectGitHub = vi.fn(async () => ({ installUrl: url, authorizeUrl: url }))
+ const connectGitHubUser = vi.fn(async () => ({ authorizeUrl: url }))
+ registerHostedAccountHandlers(
+ {
+ config: async () => null as never,
+ connectGitHub,
+ githubRepoAccess: async () => ({ access: 'granted', owner: 'acme', repo: REPO }),
+ githubRepositories: async () => ({ repositories: [], nextCursor: null }),
+ create: async () => {
+ throw new Error('unused')
+ },
+ githubUser: async () => ({ connected: false as const }),
+ connectGitHubUser,
+ disconnectGitHubUser: async () => ({ connected: false as const })
+ },
+ { repoIds: async () => ({ repoId: 7, ownerId: 50 }) }
+ )
+ return { connectGitHub, connectGitHubUser }
+}
+
+it('registers the link channels with the window allowlist', () => {
+ expect(HOSTED_ACCOUNT_CHANNELS).toEqual(
+ expect.arrayContaining([CH.gitHubInstallLink, CH.gitHubUserConnectLink])
+ )
+})
+
+it('returns the install page minted like Connect GitHub, without opening it', async () => {
+ const url = 'https://github.com/apps/x/installations/new?state=s'
+ const { connectGitHub } = register(url)
+ expect(
+ await handlers.get(CH.gitHubInstallLink)?.({}, { repoUrl: REPO, setupSessionId: SESSION })
+ ).toEqual({ ok: true, value: url })
+ expect(connectGitHub).toHaveBeenLastCalledWith(undefined, {
+ repo: REPO,
+ setupSessionId: SESSION,
+ repoId: 7,
+ ownerId: 50
+ })
+ expect(openExternal).not.toHaveBeenCalled()
+})
+
+it('returns the OAuth page minted like Connect your GitHub, without opening it', async () => {
+ const url = 'https://github.com/login/oauth/authorize?client_id=x&state=s'
+ register(url)
+ expect(await handlers.get(CH.gitHubUserConnectLink)?.()).toEqual({ ok: true, value: url })
+ expect(openExternal).not.toHaveBeenCalled()
+})
+
+it('never hands out a page that is not on github.com', async () => {
+ for (const url of [
+ 'https://github.com.evil.example/install',
+ 'http://github.com/login/oauth/authorize',
+ 'https://evil.example/?next=https://github.com'
+ ]) {
+ handlers.clear()
+ register(url)
+ expect(await handlers.get(CH.gitHubInstallLink)?.({}, {})).toMatchObject({
+ ok: false,
+ code: 'failed'
+ })
+ expect(await handlers.get(CH.gitHubUserConnectLink)?.()).toMatchObject({
+ ok: false,
+ code: 'failed'
+ })
+ }
+})
diff --git a/src/main/hosted-sandbox/hosted-github-user-ipc.ts b/src/main/hosted-sandbox/hosted-github-user-ipc.ts
index 54063ffaa78..36e03804b7e 100644
--- a/src/main/hosted-sandbox/hosted-github-user-ipc.ts
+++ b/src/main/hosted-sandbox/hosted-github-user-ipc.ts
@@ -9,6 +9,7 @@ import { failure } from './hosted-workspace-runtime'
export const HOSTED_GITHUB_USER_CHANNELS = [
CH.getGitHubUser,
CH.connectGitHubUser,
+ CH.gitHubUserConnectLink,
CH.disconnectGitHubUser
] as const
@@ -26,20 +27,33 @@ export function registerHostedGitHubUserHandlers(
}
})
+ // Only ever hand out a GitHub page, whatever the response says.
+ const mintAuthorizeUrl = async (): Promise => {
+ const { authorizeUrl } = await client.connectGitHubUser()
+ if (new URL(authorizeUrl).origin !== 'https://github.com') {
+ throw new Error('Unexpected GitHub connect URL.')
+ }
+ return authorizeUrl
+ }
+
ipcMain.handle(CH.connectGitHubUser, async () => {
try {
- const { authorizeUrl } = await client.connectGitHubUser()
- // Only ever hand the browser a GitHub page, whatever the response says.
- if (new URL(authorizeUrl).origin !== 'https://github.com') {
- return { ok: false, code: 'failed', message: 'Unexpected GitHub connect URL.' }
- }
- await shell.openExternal(authorizeUrl)
+ await shell.openExternal(await mintAuthorizeUrl())
return { ok: true, value: null }
} catch (error) {
return failure(error)
}
})
+ // "Copy link": the same OAuth page, for another browser or profile; nothing opens here.
+ ipcMain.handle(CH.gitHubUserConnectLink, async () => {
+ try {
+ return { ok: true, value: await mintAuthorizeUrl() }
+ } catch (error) {
+ return failure(error)
+ }
+ })
+
ipcMain.handle(CH.disconnectGitHubUser, async () => {
try {
return { ok: true, value: await client.disconnectGitHubUser() }
diff --git a/src/main/ipc/profiles/orca-profile-billing-handlers.ts b/src/main/ipc/profiles/orca-profile-billing-handlers.ts
index cb8289c22cf..7b277f07486 100644
--- a/src/main/ipc/profiles/orca-profile-billing-handlers.ts
+++ b/src/main/ipc/profiles/orca-profile-billing-handlers.ts
@@ -2,6 +2,7 @@ import { ipcMain } from 'electron'
import { z } from 'zod'
import type {
OrcaBillingCheckoutArgs,
+ OrcaBillingLinkResult,
OrcaBillingOpenResult,
OrcaBillingOverageArgs,
OrcaBillingOverageResult,
@@ -26,6 +27,10 @@ import {
openOrcaBillingPortal,
setOrcaBillingOverage
} from '../../billing/billing-service'
+import {
+ getOrcaBillingCheckoutLink,
+ getOrcaBillingPortalLink
+} from '../../billing/billing-hosted-links'
import { getProfileUserDataPath } from '../../orca-profiles/profile-storage-paths'
import { ensureActiveOrcaProfile } from '../../orca-profiles/profile-index-store'
import { notifyUsageAlertIfDue } from '../../billing/usage-alert-notifier'
@@ -217,4 +222,13 @@ export function registerOrcaProfileBillingHandlers(store: Store): void {
ipcMain.handle('orcaProfiles:billingOpenPortal', async (): Promise =>
openOrcaBillingPortal(getProfileUserDataPath())
)
+ // "Copy link": the same Stripe URLs, minted but not opened (paste into another browser profile).
+ ipcMain.handle('orcaProfiles:billingPortalLink', async (): Promise =>
+ getOrcaBillingPortalLink(getProfileUserDataPath())
+ )
+ ipcMain.handle(
+ 'orcaProfiles:billingCheckoutLink',
+ async (_event, rawArgs: unknown): Promise =>
+ getOrcaBillingCheckoutLink(getProfileUserDataPath(), billingCheckoutArgsFromUnknown(rawArgs))
+ )
}
diff --git a/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts b/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts
index 2ba56a5b15d..b4b8a4420e8 100644
--- a/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts
+++ b/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts
@@ -9,7 +9,10 @@ import {
import { withCloudBuildDefaults } from '../cloud-build-defaults'
import { getHostedSandboxConfig } from '../hosted-sandbox/hosted-sandbox-config'
import { orcaCloudSandboxIdFor } from '../hosted-sandbox/orca-cloud-sandbox-id'
-import { claudeStateFile, readClaudeFolderLogin } from '../claude-accounts/sign-in/claude-account-folder'
+import {
+ claudeStateFile,
+ readClaudeFolderLogin
+} from '../claude-accounts/sign-in/claude-account-folder'
import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store'
import { getOrcaProfileIndexGeneration } from '../orca-profiles/profile-index-generation'
import { createAgentCredentialsClient } from './agent-credentials-client'
@@ -40,6 +43,7 @@ const CHANNELS = [
'orcaCloudAgents:start',
'orcaCloudAgents:cancel',
'orcaCloudAgents:reopen',
+ 'orcaCloudAgents:signInLink',
'orcaCloudAgents:usePaste',
'orcaCloudAgents:pasteClaudeToken',
'orcaCloudAgents:acknowledge',
@@ -137,6 +141,10 @@ export function registerOrcaCloudAgentsHandlers(userDataPath: string): void {
const request = AgentRequest.safeParse(raw)
return request.success ? service.reopen(request.data.agent) : false
})
+ ipcMain.handle('orcaCloudAgents:signInLink', (_event, raw: unknown) => {
+ const request = AgentRequest.safeParse(raw)
+ return request.success ? service.signInLink(request.data.agent) : null
+ })
ipcMain.handle('orcaCloudAgents:usePaste', () => service.usePaste())
ipcMain.handle('orcaCloudAgents:submitClaudeCode', (_event, raw: unknown) => {
const request = CodeRequest.safeParse(raw)
diff --git a/src/main/orca-cloud-agents/orca-cloud-agents-service.test.ts b/src/main/orca-cloud-agents/orca-cloud-agents-service.test.ts
index cca5c5ecd22..12f57b6a794 100644
--- a/src/main/orca-cloud-agents/orca-cloud-agents-service.test.ts
+++ b/src/main/orca-cloud-agents/orca-cloud-agents-service.test.ts
@@ -108,6 +108,10 @@ describe('orca cloud agents service', () => {
await tick()
child.emit(`Opening browser\n${AUTHORIZE}\n`)
expect(service.setups().claude).toMatchObject({ phase: 'browser', canReopen: true })
+ // "Copy link": the same page reopen opens, without opening it.
+ expect(service.signInLink('claude')).toBe(AUTHORIZE)
+ expect(service.signInLink('codex')).toBeNull()
+ expect(openExternal).not.toHaveBeenCalled()
expect(await service.reopen('claude')).toBe(true)
expect(openExternal).toHaveBeenCalledWith(AUTHORIZE)
child.emit(`Your OAuth token (valid for 1 year):\n\n${TOKEN}\n\nStore this token`)
diff --git a/src/main/orca-cloud-agents/orca-cloud-agents-service.ts b/src/main/orca-cloud-agents/orca-cloud-agents-service.ts
index 90c13604ac6..8cfeae10a72 100644
--- a/src/main/orca-cloud-agents/orca-cloud-agents-service.ts
+++ b/src/main/orca-cloud-agents/orca-cloud-agents-service.ts
@@ -204,6 +204,18 @@ export function createOrcaCloudAgentsService(deps: OrcaCloudAgentsServiceDeps) {
})
}
+ /** The provider's sign-in page for a running setup, never anything else; null when none. */
+ const providerPage = (agent: OrcaCloudAccountAgent): string | null => {
+ const current = setups[agent]
+ const url =
+ agent === 'claude'
+ ? (claudeRun?.url() ?? null)
+ : current.phase === 'device'
+ ? current.verificationUrl
+ : null
+ return url && isProviderSignInUrl(agent, url) ? url : null
+ }
+
return {
setups: (): OrcaCloudAgentSetups => {
followIdentity()
@@ -246,6 +258,9 @@ export function createOrcaCloudAgentsService(deps: OrcaCloudAgentsServiceDeps) {
await session?.cancel()
},
+ /** "Copy link": the page `reopen` opens, not opened (for incognito or another profile). */
+ signInLink: providerPage,
+
/** "Open again" (Claude) / "Open page" (Codex): the provider's page, never anything else. */
reopen: async (agent: OrcaCloudAccountAgent): Promise => {
const current = setups[agent]
@@ -255,13 +270,8 @@ export function createOrcaCloudAgentsService(deps: OrcaCloudAgentsServiceDeps) {
startClaude()
return true
}
- const url =
- agent === 'claude'
- ? (claudeRun?.url() ?? null)
- : current.phase === 'device'
- ? current.verificationUrl
- : null
- if (!url || !isProviderSignInUrl(agent, url)) {
+ const url = providerPage(agent)
+ if (!url) {
return false
}
if (agent === 'claude') {
diff --git a/src/preload/api/orca-cloud-agents-bridge.ts b/src/preload/api/orca-cloud-agents-bridge.ts
index ce88eaa8d7c..6d7be25c902 100644
--- a/src/preload/api/orca-cloud-agents-bridge.ts
+++ b/src/preload/api/orca-cloud-agents-bridge.ts
@@ -16,6 +16,8 @@ export type OrcaCloudAgentsApi = {
start: (request: { agent: OrcaCloudAccountAgent }) => Promise>
cancel: (request: { agent: OrcaCloudAccountAgent }) => Promise
reopen: (request: { agent: OrcaCloudAccountAgent }) => Promise
+ /** The provider page `reopen` would open, without opening it ("Copy link"); null when none. */
+ signInLink: (request: { agent: OrcaCloudAccountAgent }) => Promise
usePaste: () => Promise
/** The code from Claude's sign-in page, typed into the waiting setup; the reply is a code only. */
submitClaudeCode: (request: { code: string }) => Promise>
@@ -48,6 +50,7 @@ export const orcaCloudAgentsApi: OrcaCloudAgentsApi = {
start: (request) => ipcRenderer.invoke('orcaCloudAgents:start', request),
cancel: (request) => ipcRenderer.invoke('orcaCloudAgents:cancel', request),
reopen: (request) => ipcRenderer.invoke('orcaCloudAgents:reopen', request),
+ signInLink: (request) => ipcRenderer.invoke('orcaCloudAgents:signInLink', request),
usePaste: () => ipcRenderer.invoke('orcaCloudAgents:usePaste'),
submitClaudeCode: (request) => ipcRenderer.invoke('orcaCloudAgents:submitClaudeCode', request),
pasteClaudeToken: (request) => ipcRenderer.invoke('orcaCloudAgents:pasteClaudeToken', request),
diff --git a/src/preload/api/orca-cloud-workspace-bridge.ts b/src/preload/api/orca-cloud-workspace-bridge.ts
index a54b06a45f4..891d4c7cbe7 100644
--- a/src/preload/api/orca-cloud-workspace-bridge.ts
+++ b/src/preload/api/orca-cloud-workspace-bridge.ts
@@ -20,8 +20,10 @@ export const orcaCloudWorkspaceApi = {
listCloudWorkspaceIds: () => ipcRenderer.invoke(CH.listCloudWorkspaceIds),
getConfig: () => ipcRenderer.invoke(CH.getConfig),
connectGitHub: (args) => ipcRenderer.invoke(CH.connectGitHub, args),
+ gitHubInstallLink: (args) => ipcRenderer.invoke(CH.gitHubInstallLink, args),
getGitHubUser: () => ipcRenderer.invoke(CH.getGitHubUser),
connectGitHubUser: () => ipcRenderer.invoke(CH.connectGitHubUser),
+ gitHubUserConnectLink: () => ipcRenderer.invoke(CH.gitHubUserConnectLink),
disconnectGitHubUser: () => ipcRenderer.invoke(CH.disconnectGitHubUser),
getRepoAccess: (args) => ipcRenderer.invoke(CH.getRepoAccess, args),
listGitHubRepositories: (args) => ipcRenderer.invoke(CH.listGitHubRepositories, args),
diff --git a/src/preload/api/orca-profile-api.ts b/src/preload/api/orca-profile-api.ts
index e9b061d58d9..71572197248 100644
--- a/src/preload/api/orca-profile-api.ts
+++ b/src/preload/api/orca-profile-api.ts
@@ -45,6 +45,7 @@ import type {
} from '../../shared/orca-profiles'
import type {
OrcaBillingCheckoutArgs,
+ OrcaBillingLinkResult,
OrcaBillingOpenResult,
OrcaBillingOverageArgs,
OrcaBillingOverageResult,
@@ -128,6 +129,10 @@ export type OrcaProfileApi = {
cloudSetupLink: (args: { sessionId: string }) => Promise
/** Opens the Stripe Customer Portal in the browser. */
billingOpenPortal: () => Promise
+ /** "Copy link": a fresh Customer Portal URL, minted but not opened. */
+ billingPortalLink: () => Promise
+ /** "Copy link": the Checkout URL billingOpenCheckout would open, minted but not opened. */
+ billingCheckoutLink: (args: OrcaBillingCheckoutArgs) => Promise
/** The last 30 days of sandbox hours (breakdowns for owners and admins only). */
billingUsageHistory: (args: {
utcOffsetMinutes: number
diff --git a/src/preload/api/orca-profiles-bridge.ts b/src/preload/api/orca-profiles-bridge.ts
index 497069b5105..22837449f8d 100644
--- a/src/preload/api/orca-profiles-bridge.ts
+++ b/src/preload/api/orca-profiles-bridge.ts
@@ -102,6 +102,8 @@ export const orcaProfilesApi = {
billingPlans: () => ipcRenderer.invoke('orcaProfiles:billingPlans'),
billingOpenCheckout: (args) => ipcRenderer.invoke('orcaProfiles:billingOpenCheckout', args),
billingOpenPortal: () => ipcRenderer.invoke('orcaProfiles:billingOpenPortal'),
+ billingPortalLink: () => ipcRenderer.invoke('orcaProfiles:billingPortalLink'),
+ billingCheckoutLink: (args) => ipcRenderer.invoke('orcaProfiles:billingCheckoutLink', args),
billingTeamPlans: (args) => ipcRenderer.invoke('orcaProfiles:billingTeamPlans', args),
billingUsageHistory: (args) => ipcRenderer.invoke('orcaProfiles:billingUsageHistory', args),
cloudSetupStart: (args) => ipcRenderer.invoke('orcaProfiles:cloudSetupStart', args),
diff --git a/src/renderer/src/components/OrcaSignInProgress.tsx b/src/renderer/src/components/OrcaSignInProgress.tsx
index d8c2e55b36c..a7e857ff597 100644
--- a/src/renderer/src/components/OrcaSignInProgress.tsx
+++ b/src/renderer/src/components/OrcaSignInProgress.tsx
@@ -3,6 +3,7 @@ import { ExternalLink, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { registerSignInNotice } from '@/lib/orca-cloud/sign-in-notice-visibility'
import { Button } from './ui/button'
+import { OpenExternalSplitButton } from './open-external/OpenExternalSplitButton'
import { translate } from '@/i18n/i18n'
import { useTranslate } from '@/i18n/use-translate'
import { cn } from '@/lib/class-names'
@@ -39,16 +40,25 @@ export function OrcaSignInButton({
}
void window.api.shell.openUrl(link).catch(() => void connect())
}
+ if (link) {
+ // The pending sign-in's link, for an incognito window or another browser profile.
+ return (
+
+ {t('auto.components.OrcaSignInProgress.reopenBrowser', 'Reopen browser')}
+
+
+ )
+ }
return (
)
}
diff --git a/src/renderer/src/components/agent-host-panel/ssh-classic/SshCodeSignIn.tsx b/src/renderer/src/components/agent-host-panel/ssh-classic/SshCodeSignIn.tsx
index 1204c30b1d0..3882efed5f0 100644
--- a/src/renderer/src/components/agent-host-panel/ssh-classic/SshCodeSignIn.tsx
+++ b/src/renderer/src/components/agent-host-panel/ssh-classic/SshCodeSignIn.tsx
@@ -4,6 +4,7 @@
import React, { useState } from 'react'
import { useTranslate, type Translate } from '@/i18n/use-translate'
import { Button } from '@/components/ui/button'
+import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton'
import { Input } from '@/components/ui/input'
import { InlineError } from '@/components/ui/inline-error'
import { HOST_AGENT_SIGN_IN_AGENT_NAME } from '../../../../../shared/host-agent-sign-in-status'
@@ -135,14 +136,20 @@ export function SshCodeSignIn({
{GUARD(t)}
-
+ {flow.url ? (
+ flow.url && actions.openLink(flow.url)}
+ >
+ {t('agentHostPanel.ssh.openClaude', 'Open claude.ai')}
+
+ ) : (
+
+ )}
{flow.userCode}
-
+ {flow.url ? (
+
+ flow.userCode && flow.url && actions.copyAndOpen(flow.userCode, flow.url)
+ }
+ >
+ {t('agentSignInStrip.copyAndOpen', 'Copy code and open')}
+
+ ) : (
+
+ )}
{cancel}
diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.test.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.test.tsx
index 5c35ebce490..9a459846492 100644
--- a/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.test.tsx
+++ b/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.test.tsx
@@ -4,6 +4,7 @@ import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { CloudAgentSignInStrip, type CloudStripActions } from './CloudAgentSignInStrip'
+import { stripPrimaryLink } from './CloudStripPrimaryButton'
import type { CloudStripModel } from './cloud-agent-strip-model'
afterEach(() => cleanup())
@@ -52,3 +53,44 @@ describe('CloudAgentSignInStrip wrap', () => {
expect(title?.textContent).toBe(model.title)
})
})
+
+describe('CloudAgentSignInStrip Copy link', () => {
+ const deviceUrl = 'https://auth.openai.com/codex/device'
+ const device: CloudStripModel = {
+ ...model,
+ status: 'need',
+ title: 'Enter this code on auth.openai.com',
+ detail: 'Only continue if you started this sign-in in Orca.',
+ primary: {
+ label: 'Copy code and open',
+ action: { kind: 'copyAndOpen', url: deviceUrl, code: 'EY7X-YDTV6' }
+ },
+ code: 'EY7X-YDTV6'
+ }
+ const renderStrip = (m: CloudStripModel): HTMLElement =>
+ render(
+
+
+
+ ).container
+
+ it('gives a primary that opens a page the Copy link chevron', () => {
+ expect(renderStrip(device).querySelector('[data-open-external-chevron]')).not.toBeNull()
+ })
+
+ it('keeps a primary that opens nothing a plain button', () => {
+ expect(renderStrip(model).querySelector('[data-open-external-chevron]')).toBeNull()
+ })
+
+ it('copies the verification URL, a lazy setup link, or nothing', () => {
+ expect(stripPrimaryLink(device.primary!.action, {})).toBe(deviceUrl)
+ expect(stripPrimaryLink({ kind: 'openAgain', url: 'https://claude.ai/x' }, {})).toBe(
+ 'https://claude.ai/x'
+ )
+ expect(stripPrimaryLink({ kind: 'reopenSetup' }, {})).toBeNull()
+ const lazy = async (): Promise => 'https://claude.ai/setup'
+ const linkFor = (action: { kind: string }) => (action.kind === 'reopenSetup' ? lazy : null)
+ expect(stripPrimaryLink({ kind: 'reopenSetup' }, { linkFor })).toBe(lazy)
+ expect(stripPrimaryLink({ kind: 'restart' }, {})).toBeNull()
+ })
+})
diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.tsx
index 1add2e8f00a..3132c871845 100644
--- a/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.tsx
+++ b/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStrip.tsx
@@ -1,5 +1,5 @@
// The 56 px strip at the top of an Orca Cloud pane (redesign §6). Fixed slots: status 16, two text
-// lines, the queued prompt chip (140, hidden below 720 px), actions (280, or 200 below 560 px).
+// lines, the queued prompt chip (140, hidden below 720 px), actions (304, or 200 below 560 px).
// Laid out from its own live width (RA-1): a pane narrowed after mount gets the narrow layout too.
// A region, never a dialog; one polite status sentence per state.
import React, { useLayoutEffect, useRef, useState } from 'react'
@@ -15,7 +15,9 @@ import {
} from 'lucide-react'
import { useTranslate } from '@/i18n/use-translate'
import { Button } from '@/components/ui/button'
+import type { ExternalLinkSource } from '@/components/open-external/copy-external-link'
import { CloudStripPasteForm } from './CloudStripPasteForm'
+import { StripPrimaryButton, stripPrimaryLink } from './CloudStripPrimaryButton'
import {
DropdownMenu,
DropdownMenuContent,
@@ -96,6 +98,11 @@ export type CloudStripActions = {
pasteCode: () => void
useApiKey: () => void
cancel: () => void
+ /**
+ * "Copy link" for a primary that opens a page whose URL the action doesn't carry (e.g. the
+ * setup's sign-in page, known only to main). Actions carrying a `url` copy it without this.
+ */
+ linkFor?: (action: CloudStripPrimary) => ExternalLinkSource | null
}
/** GA-P3t: the strip's severity look; anything that isn't a warning or an error reads as info. */
@@ -151,8 +158,9 @@ function withInlineCode(text: string): React.ReactNode {
}
const GRID: Record = {
- wide: 'grid-cols-[16px_minmax(0,1fr)_140px_280px]',
- narrow: 'grid-cols-[16px_minmax(0,1fr)_280px]',
+ // 304: room for a primary with its "Copy link" chevron beside the device code.
+ wide: 'grid-cols-[16px_minmax(0,1fr)_140px_304px]',
+ narrow: 'grid-cols-[16px_minmax(0,1fr)_304px]',
tiny: 'grid-cols-[16px_minmax(0,1fr)_200px]',
compact: 'grid-cols-[16px_minmax(0,1fr)_auto]'
}
@@ -280,22 +288,20 @@ export function CloudAgentSignInStrip({
/>
) : null}
{primary ? (
-
+
) : null}
{model.otherWays ? (
@@ -362,19 +368,15 @@ export function CloudAgentSignInStrip({
/>
{model.primary ? (
-
+
) : null}
) : null}
diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudStripPrimaryButton.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudStripPrimaryButton.tsx
new file mode 100644
index 00000000000..654c16b6c04
--- /dev/null
+++ b/src/renderer/src/components/agent-sign-in-strip/CloudStripPrimaryButton.tsx
@@ -0,0 +1,72 @@
+// The sign-in strip's primary button. One that opens a page in the browser ("Open again", "Copy
+// code and open", the setup's "Open again") gets the "Copy link" chevron (Neil's rule).
+import type React from 'react'
+import { Button } from '@/components/ui/button'
+import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton'
+import type { ExternalLinkSource } from '@/components/open-external/copy-external-link'
+import type { CloudStripActions } from './CloudAgentSignInStrip'
+import type { CloudStripModel, CloudStripPrimary } from './cloud-agent-strip-model'
+
+/** The URL a primary opens in the browser, for its "Copy link"; null when it opens nothing. */
+export function stripPrimaryLink(
+ action: CloudStripPrimary,
+ actions: Pick
+): ExternalLinkSource | null {
+ const custom = actions.linkFor?.(action)
+ if (custom) {
+ return custom
+ }
+ return action.kind === 'openAgain' || action.kind === 'copyAndOpen' ? action.url : null
+}
+
+export function StripPrimaryButton({
+ primary,
+ link,
+ ariaLabel,
+ variant,
+ quiet,
+ onActivate,
+ children
+}: {
+ primary: NonNullable
+ link: ExternalLinkSource | null
+ ariaLabel?: string
+ variant: 'default' | 'outline' | 'ghost'
+ /** The stacked row's quiet button marks busy as unavailable only. */
+ quiet?: boolean
+ onActivate: () => void
+ children: React.ReactNode
+}): React.JSX.Element {
+ const busy = primary.busy || undefined
+ const onClick = (): void => {
+ if (!primary.busy) {
+ onActivate()
+ }
+ }
+ if (link && !primary.busy) {
+ return (
+
+ {children}
+
+ )
+ }
+ return (
+
+ )
+}
diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts b/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts
index 36404a1ecf7..b80504a954f 100644
--- a/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts
+++ b/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts
@@ -11,6 +11,7 @@ import {
} from '@/lib/orca-cloud/orca-cloud-agent-accounts-store'
import type { HostAgentSignInAgent } from '../../../../shared/host-agent-sign-in-status'
import type { CloudStripActions } from './CloudAgentSignInStrip'
+import { requireLink } from '@/components/open-external/copy-external-link'
export function cloudSetupStripActions(args: {
agent: HostAgentSignInAgent
@@ -51,6 +52,11 @@ export function cloudSetupStripActions(args: {
// The CLI's answer comes back as a setup update (checking, then saved or rejected).
void api?.submitClaudeCode({ code }).catch(() => undefined)
},
+ // "Copy link" beside "Open again": the provider page main would reopen, minted the same way.
+ linkFor: (action) =>
+ action.kind === 'reopenSetup' && api
+ ? () => api.signInLink({ agent }).then(requireLink)
+ : null,
useCode: () => undefined,
pasteCode: () => void api?.usePaste(),
useApiKey: args.useApiKey,
diff --git a/src/renderer/src/components/agent-sign-in/AgentSignInCard.tsx b/src/renderer/src/components/agent-sign-in/AgentSignInCard.tsx
index fd6b2ecf5bb..8b7188f2909 100644
--- a/src/renderer/src/components/agent-sign-in/AgentSignInCard.tsx
+++ b/src/renderer/src/components/agent-sign-in/AgentSignInCard.tsx
@@ -2,6 +2,7 @@ import { CircleAlert, Copy, ExternalLink, TriangleAlert } from 'lucide-react'
import { useTranslate } from '@/i18n/use-translate'
import { AgentIcon } from '@/components/agent/icons/AgentIcon'
import { Button } from '@/components/ui/button'
+import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { PasteCodeForm } from './AgentSignInPasteCodeForm'
import type { AgentSignInGate } from '@/lib/agent-sign-in/agent-sign-in-gate'
@@ -172,24 +173,15 @@ export function AgentSignInCard({
{view.step === 'paste' ? (
<>
-
-
+
-
+
) : null}
@@ -244,24 +236,15 @@ export function AgentSignInCard({
{view.step === 'signed' ? null : view.step === 'waiting' ? (
-
-
+
{cancel}
diff --git a/src/renderer/src/components/billing/PaymentIssueNotice.tsx b/src/renderer/src/components/billing/PaymentIssueNotice.tsx
index 88996b7aabf..7f7d9f8167f 100644
--- a/src/renderer/src/components/billing/PaymentIssueNotice.tsx
+++ b/src/renderer/src/components/billing/PaymentIssueNotice.tsx
@@ -1,6 +1,7 @@
import { ExternalLink } from 'lucide-react'
-import { Button } from '@/components/ui/button'
import { InlineError } from '@/components/ui/inline-error'
+import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton'
+import { billingPortalLink } from './billing-link'
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
import type { OrcaBillingEntitlement } from '../../../../shared/orca-billing'
@@ -62,16 +63,20 @@ export function PaymentIssueNotice({
}
action={
entitlement.canManageBilling ? (
-
+
) : undefined
}
/>
diff --git a/src/renderer/src/components/billing/PlanBillingSection.tsx b/src/renderer/src/components/billing/PlanBillingSection.tsx
index 322accf6cfa..a719d090fd6 100644
--- a/src/renderer/src/components/billing/PlanBillingSection.tsx
+++ b/src/renderer/src/components/billing/PlanBillingSection.tsx
@@ -14,6 +14,8 @@ import {
} from '../../../../shared/orca-billing'
import { openPlansPage } from './plans/plans-page-store'
import { PaymentIssueNotice } from './PaymentIssueNotice'
+import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton'
+import { billingPortalLink } from './billing-link'
import { OverageControls, UsageSummary } from './PlanBillingUsage'
import { denyReasonCopy, openFailureCopy, overageFailureCopy } from './plan-billing-copy'
import { PlanStatusBadge } from './PlanStatusBadge'
@@ -282,16 +284,20 @@ export function PlanBillingView({
)}
{hasPlan && entitlement.canManageBilling && entitlement.payment.pastDueSince === null ? (
-
+
) : null}
{hasPlan ? (
// Members have no other action here, so it gets the button's chrome (and its alignment).
diff --git a/src/renderer/src/components/billing/billing-link.test.ts b/src/renderer/src/components/billing/billing-link.test.ts
new file mode 100644
index 00000000000..3da376018f3
--- /dev/null
+++ b/src/renderer/src/components/billing/billing-link.test.ts
@@ -0,0 +1,56 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+const toastError = vi.fn()
+vi.mock('sonner', () => ({ toast: { error: (...args: unknown[]) => toastError(...args) } }))
+
+import { billingCheckoutLink, billingPortalLink } from './billing-link'
+
+function stubApi(api: Record): void {
+ ;(globalThis as { window?: unknown }).window = { api: { orcaProfiles: api } }
+}
+
+afterEach(() => {
+ toastError.mockReset()
+ delete (globalThis as { window?: unknown }).window
+})
+
+describe('billing "Copy link"', () => {
+ it('returns the minted Portal URL without opening anything', async () => {
+ const billingPortalLink$ = vi.fn(async () => ({
+ status: 'ok' as const,
+ url: 'https://billing.stripe.com/p/session/x'
+ }))
+ const billingOpenPortal = vi.fn()
+ stubApi({ billingPortalLink: billingPortalLink$, billingOpenPortal })
+ await expect(billingPortalLink()).resolves.toBe('https://billing.stripe.com/p/session/x')
+ expect(billingOpenPortal).not.toHaveBeenCalled()
+ expect(toastError).not.toHaveBeenCalled()
+ })
+
+ it('mints Checkout for the same order the button would open', async () => {
+ const mint = vi.fn(async () => ({
+ status: 'ok' as const,
+ url: 'https://checkout.stripe.com/c/pay/cs_1'
+ }))
+ stubApi({ billingCheckoutLink: mint })
+ const args = { plan: 'team', interval: 'year' as const, seats: 5 }
+ await expect(billingCheckoutLink(args)).resolves.toBe('https://checkout.stripe.com/c/pay/cs_1')
+ expect(mint).toHaveBeenCalledWith(args)
+ })
+
+ it('says why there is no link with the open path’s copy, then yields null', async () => {
+ stubApi({ billingPortalLink: async () => ({ status: 'forbidden' }) })
+ await expect(billingPortalLink()).resolves.toBeNull()
+ expect(toastError).toHaveBeenCalledWith('Only an owner or admin can manage billing.')
+ })
+
+ it('treats a rejected IPC as a failed billing read', async () => {
+ stubApi({
+ billingPortalLink: async () => {
+ throw new Error('boom')
+ }
+ })
+ await expect(billingPortalLink()).resolves.toBeNull()
+ expect(toastError).toHaveBeenCalledWith('Could not reach billing. Try again.')
+ })
+})
diff --git a/src/renderer/src/components/billing/billing-link.ts b/src/renderer/src/components/billing/billing-link.ts
new file mode 100644
index 00000000000..7bdae0ed29b
--- /dev/null
+++ b/src/renderer/src/components/billing/billing-link.ts
@@ -0,0 +1,35 @@
+// "Copy link" for the Stripe buttons: mints the same Checkout/Portal URL the button opens, without
+// opening it. A refusal (signed out, not an admin, no billing account…) is said in a toast with the
+// open path's own copy, then null, so the split button adds nothing of its own.
+import { toast } from 'sonner'
+import { translate } from '@/i18n/i18n'
+import type {
+ OrcaBillingCheckoutArgs,
+ OrcaBillingLinkResult
+} from '../../../../shared/orca-billing'
+import { openFailureCopy } from './plan-billing-copy'
+
+async function linkOrToast(mint: () => Promise): Promise {
+ const result = await mint().catch((error: unknown): OrcaBillingLinkResult => ({
+ status: 'failed',
+ error: String(error)
+ }))
+ if (result.status === 'ok') {
+ return result.url
+ }
+ const failure = openFailureCopy(result, translate)
+ if (failure) {
+ toast.error(failure)
+ }
+ return null
+}
+
+/** The Customer Portal URL "Manage billing" / "Update payment method" would open. */
+export function billingPortalLink(): Promise {
+ return linkOrToast(() => window.api.orcaProfiles.billingPortalLink())
+}
+
+/** The Checkout URL a plan's buy button would open for these args. */
+export function billingCheckoutLink(args: OrcaBillingCheckoutArgs): Promise {
+ return linkOrToast(() => window.api.orcaProfiles.billingCheckoutLink(args))
+}
diff --git a/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx b/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx
index 8ab5a50efe0..4794fc340e2 100644
--- a/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx
+++ b/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx
@@ -2,6 +2,7 @@ import { useEffect, useId, useRef } from 'react'
import { AlertCircle, Check, ExternalLink, Lock, RotateCcw, XCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { LoadingSpinner } from '@/components/LoadingSpinner'
+import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton'
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
import { cn } from '@/lib/class-names'
import { openFailureCopy } from '../plan-billing-copy'
@@ -20,6 +21,8 @@ export type CheckoutPanelProps = {
/** When the order was placed; the renewal date counts from it. */
now: number
onReopen: () => void
+ /** "Copy link" on Reopen / Try again: a fresh Checkout URL for this order (absent: no chevron). */
+ reopenLink?: () => Promise
onKeepWaiting: () => void
onBack: () => void
/** Where "Contact us" goes when billing can't sell the plan (the plan's supportUrl, else Enterprise's contact). */
@@ -221,13 +224,22 @@ export function CheckoutPanel(props: CheckoutPanelProps): React.JSX.Element {
break
}
- const reopen = (
-