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 = ( - ) const back = ( @@ -275,14 +287,14 @@ export function CheckoutPanel(props: CheckoutPanelProps): React.JSX.Element { phase.result.status === 'plan-unavailable' && props.contactUrl && props.onContact ? ( - + ) : null} {phase.kind === 'timed-out' ? ( +

{t( 'auto.components.billing.plans.entHint', diff --git a/src/renderer/src/components/billing/plans/PlansDialogHost.tsx b/src/renderer/src/components/billing/plans/PlansDialogHost.tsx index 7d5619538f5..3bf8240ec1b 100644 --- a/src/renderer/src/components/billing/plans/PlansDialogHost.tsx +++ b/src/renderer/src/components/billing/plans/PlansDialogHost.tsx @@ -88,6 +88,13 @@ export function PlansDialogHost(): React.JSX.Element { setOrderedAt(Date.now()) checkout.start({ planId, interval, seats: count }) }} + checkoutLink={async ({ planId, interval, seats: count }) => { + const url = await checkout.copyLink({ planId, interval, seats: count }) + if (url) { + setOrderedAt(Date.now()) + } + return url + }} onManage={() => void openPortal()} onContactSales={(url) => void window.api.shell.openUrl(url)} onRetry={() => { @@ -119,6 +126,7 @@ export function PlansDialogHost(): React.JSX.Element { } checkout.reopen() }} + reopenLink={checkout.copyReopenLink} contactUrl={plans.state === 'ready' ? planContactUrl(plans.model) : null} onContact={(url) => void window.api.shell.openUrl(url)} onKeepWaiting={checkout.keepWaiting} diff --git a/src/renderer/src/components/billing/plans/PlansView.test.tsx b/src/renderer/src/components/billing/plans/PlansView.test.tsx index 397ad211a4d..4f5311c5299 100644 --- a/src/renderer/src/components/billing/plans/PlansView.test.tsx +++ b/src/renderer/src/components/billing/plans/PlansView.test.tsx @@ -49,6 +49,13 @@ describe("plans dialog from billing's catalog", () => { expect(props.onContactSales).toHaveBeenCalledWith('https://orca.dev/enterprise') }) + it('gives checkout and Contact sales a "Copy link" chevron for the same order', () => { + const checkoutLink = vi.fn(async () => 'https://checkout.stripe.com/c/pay/cs_1') + render() + // One chevron on the checkout button, one on Contact sales. + expect(screen.getAllByRole('button', { name: 'More link options' })).toHaveLength(2) + }) + it('offers only the intervals Stripe prices: no toggle with one', () => { render() expect(screen.queryByRole('radiogroup')).toBeNull() diff --git a/src/renderer/src/components/billing/plans/PlansView.tsx b/src/renderer/src/components/billing/plans/PlansView.tsx index 4595d32a713..3bf36cab258 100644 --- a/src/renderer/src/components/billing/plans/PlansView.tsx +++ b/src/renderer/src/components/billing/plans/PlansView.tsx @@ -36,6 +36,12 @@ export type PlansViewProps = { /** Seats Checkout will bill: org members, or 1 for a personal account. */ seats: number onCheckout: (args: { planId: string; interval: OrcaBillingInterval; seats: number }) => void + /** "Copy link" on the checkout button: the Checkout URL it would open (absent: no chevron). */ + checkoutLink?: (args: { + planId: string + interval: OrcaBillingInterval + seats: number + }) => Promise onManage: () => void onContactSales: (url: string) => void onRetry: () => void @@ -208,6 +214,11 @@ export function PlansView(props: PlansViewProps): React.JSX.Element { notice={props.notice} hideManage={paymentIssue} onCheckout={() => props.onCheckout({ planId: team.planId, interval, seats })} + checkoutLink={ + props.checkoutLink + ? () => props.checkoutLink!({ planId: team.planId, interval, seats }) + : undefined + } onManage={props.onManage} /> {enterprise ? ( diff --git a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx index dbbde991a8b..ff2ecd1f74e 100644 --- a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx +++ b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx @@ -2,6 +2,8 @@ import { ExternalLink, Lock, Users } from 'lucide-react' import { Badge } from '@/components/ui/badge' 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 type { PlanBillingNotice } from '../PlanBillingSection' import { LoadingSpinner } from '@/components/LoadingSpinner' import { useIntlLocale, useTranslate } from '@/i18n/use-translate' @@ -22,6 +24,7 @@ export function TeamPlanCard({ notice, hideManage, onCheckout, + checkoutLink, onManage }: { team: TeamPlanModel @@ -34,6 +37,8 @@ export function TeamPlanCard({ /** A payment banner already offers the same Portal action. */ hideManage: boolean onCheckout: () => void + /** "Copy link": mints the Checkout URL the button opens (absent: a plain button). */ + checkoutLink?: () => Promise onManage: () => void }): React.JSX.Element { const t = useTranslate() @@ -126,20 +131,41 @@ export function TeamPlanCard({

) } else if (mode === 'choose') { + const checkoutLabel = ( + <> + {busy ? : null} + {busy + ? t('auto.components.billing.plans.waiting', 'Waiting for checkout…') + : t('auto.components.billing.plans.checkout', 'Continue to checkout')} + {busy ? null : } + + ) footer = ( <> - + {checkoutLink ? ( + { + if (!busy) { + onCheckout() + } + }} + > + {checkoutLabel} + + ) : ( + + )} {notice?.kind === 'error' ? ( ) : ( @@ -163,16 +189,21 @@ export function TeamPlanCard({

) : ( <> - + {notice?.kind === 'error' ? ( ) : ( diff --git a/src/renderer/src/components/billing/team/TeamPageView.tsx b/src/renderer/src/components/billing/team/TeamPageView.tsx index 97657a732e3..0689adc66fa 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.tsx @@ -1,6 +1,7 @@ import { useEffect, useId, useRef } from 'react' import { ExternalLink, LogOut, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' +import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' import { InlineError } from '@/components/ui/inline-error' import { Skeleton } from '@/components/ui/skeleton' import { SettingsDisabledControlTooltip } from '@/components/settings/form-controls/SettingsDisabledControlTooltip' @@ -328,15 +329,15 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { )}

- + ) : null} {selfRow ? ( diff --git a/src/renderer/src/components/billing/team/TeamPlanSummary.tsx b/src/renderer/src/components/billing/team/TeamPlanSummary.tsx index 2e6e6b757e0..131d14a71b4 100644 --- a/src/renderer/src/components/billing/team/TeamPlanSummary.tsx +++ b/src/renderer/src/components/billing/team/TeamPlanSummary.tsx @@ -4,6 +4,8 @@ import { Button } from '@/components/ui/button' import { useIntlLocale, useTranslate } from '@/i18n/use-translate' import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing' import { PaymentIssueNotice } from '../PaymentIssueNotice' +import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' +import { billingPortalLink } from '../billing-link' import { PlanStatusBadge } from '../PlanStatusBadge' import { formatCents, perSeatMonthlyCents } from '../plans/plans-pricing' import { seatCountLabel } from './team-copy' @@ -151,16 +153,16 @@ export function TeamSeatsCard({ - + ) : null} diff --git a/src/renderer/src/components/new-workspace/ProjectComboboxCloudAddProjectEntry.tsx b/src/renderer/src/components/new-workspace/ProjectComboboxCloudAddProjectEntry.tsx index 44ba26c0267..55d5e1cf011 100644 --- a/src/renderer/src/components/new-workspace/ProjectComboboxCloudAddProjectEntry.tsx +++ b/src/renderer/src/components/new-workspace/ProjectComboboxCloudAddProjectEntry.tsx @@ -1,12 +1,20 @@ // PROJ-MENU part 2: "Give Orca Cloud access to more repositories" moves out of the project list into // the Add project menu, in its own "Orca Cloud" group: "Add a GitHub repository…". import React from 'react' -import { Github } from 'lucide-react' +import { Github, Link2 } from 'lucide-react' import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' import { useTranslate } from '@/i18n/use-translate' +import { copyExternalLink } from '@/components/open-external/copy-external-link' +import { gitHubInstallLinkSource } from '@/lib/orca-cloud/github-link-sources' import type { ProjectComboboxAddProjectMenu } from './ProjectComboboxAddProjectOption' -function FromGitHubItem({ onSelect }: { onSelect: () => void }): React.JSX.Element { +function FromGitHubItem({ + onSelect, + onCopyLink +}: { + onSelect: () => void + onCopyLink: () => void +}): React.JSX.Element { const t = useTranslate() return ( <> @@ -25,6 +33,13 @@ function FromGitHubItem({ onSelect }: { onSelect: () => void }): React.JSX.Eleme {t('components.newWorkspace.cloudOnly.addRepositoryMenu', 'Add a GitHub repository…')}
+ {/* A menu row can't carry a chevron: the same GitHub page, copied for another browser. */} + + + + {t('components.newWorkspace.cloudOnly.copyAddRepositoryLink', 'Copy GitHub link')} + + ) } @@ -45,6 +60,10 @@ export function cloudAddProjectExtraItems( closeMenus() onMoreCloudAccess() }} + onCopyLink={() => { + closeMenus() + void copyExternalLink(gitHubInstallLinkSource()) + }} /> ) } diff --git a/src/renderer/src/components/new-workspace/cloud-setup/CloudSetupErrorNotice.tsx b/src/renderer/src/components/new-workspace/cloud-setup/CloudSetupErrorNotice.tsx index bf2f93206be..263397eb077 100644 --- a/src/renderer/src/components/new-workspace/cloud-setup/CloudSetupErrorNotice.tsx +++ b/src/renderer/src/components/new-workspace/cloud-setup/CloudSetupErrorNotice.tsx @@ -3,6 +3,8 @@ // team failed. Finished steps stay done, the prompt stays in the composer, and no codes are shown. import 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 { InlineError } from '@/components/ui/inline-error' import { useTranslate, type Translate } from '@/i18n/use-translate' import { @@ -74,12 +76,15 @@ function actionLabel(action: CloudSetupErrorAction, t: Translate): string { export function CloudSetupErrorNotice({ error, canManageBilling = true, + reopenLink, onAction, className }: { error: CloudSetupError /** Members can't choose a plan: the trial notice then names who can, with no button. */ canManageBilling?: boolean + /** Reopen browser's page (a fresh setup link): its chevron offers "Copy link". */ + reopenLink?: ExternalLinkSource | null onAction: (action: CloudSetupErrorAction) => void className?: string }): React.JSX.Element { @@ -95,9 +100,20 @@ export function CloudSetupErrorNotice({ title={errorTitle(error, canManageBilling, t)} action={ showAction ? ( - + action === 'reopen-browser' && reopenLink ? ( + onAction(action)} + > + {actionLabel(action, t)} + + ) : ( + + ) ) : undefined } className={className} diff --git a/src/renderer/src/components/new-workspace/cloud-setup/cloud-setup-tracker-actions.ts b/src/renderer/src/components/new-workspace/cloud-setup/cloud-setup-tracker-actions.ts index 07bbbdeb2a9..3cc7a7adc12 100644 --- a/src/renderer/src/components/new-workspace/cloud-setup/cloud-setup-tracker-actions.ts +++ b/src/renderer/src/components/new-workspace/cloud-setup/cloud-setup-tracker-actions.ts @@ -1,9 +1,10 @@ // What the tracker's buttons do: a fresh one-time setup link (the first one was spent by the -// browser that opened it), copied or opened. Without a session (rows derived from billing), Reopen -// re-runs today's setup action and there is no link to copy. +// browser that opened it), opened, or copied from Reopen browser's "Copy link". Without a session +// (rows derived from billing), Reopen re-runs today's setup action and there is no link to copy. import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { getCloudSetupLinkProvider } from '@/lib/orca-cloud/cloud-setup-session-seam' +import type { ExternalLinkSource } from '@/components/open-external/copy-external-link' async function freshLink(sessionId: string): Promise { const provider = getCloudSetupLinkProvider() @@ -26,26 +27,24 @@ function linkUnavailable(): void { ) } -export function canCopyCloudSetupLink(sessionId: string | null): boolean { +function canCopyCloudSetupLink(sessionId: string | null): boolean { return sessionId !== null && getCloudSetupLinkProvider() !== null } -export async function copyCloudSetupLink(sessionId: string): Promise { - const url = await freshLink(sessionId) - if (!url) { - linkUnavailable() - return +/** + * "Copy link" beside Reopen browser: a fresh one-time link to the same session, minted the way + * Reopen mints it. Null when there is no session to link to (Reopen then re-runs today's action). + */ +export function cloudSetupLinkSource(sessionId: string | null): ExternalLinkSource | null { + if (!sessionId || !canCopyCloudSetupLink(sessionId)) { + return null } - try { - await window.api.ui.writeClipboardText(url) - toast.success( - translate( - 'components.newWorkspace.cloudSetup.linkCopied', - 'Setup link copied. Open it in the browser you use for Orca.' - ) - ) - } catch { - linkUnavailable() + return async () => { + const url = await freshLink(sessionId) + if (!url) { + linkUnavailable() + } + return url } } diff --git a/src/renderer/src/components/new-workspace/run-on/use-cloud-start-steps.ts b/src/renderer/src/components/new-workspace/run-on/use-cloud-start-steps.ts index 23aadd91bce..df7f18d4054 100644 --- a/src/renderer/src/components/new-workspace/run-on/use-cloud-start-steps.ts +++ b/src/renderer/src/components/new-workspace/run-on/use-cloud-start-steps.ts @@ -78,7 +78,7 @@ export function useCloudStartSteps({ onGranted: () => grantedRef.current?.(repoNameRef.current), ...(setupSessionId ? { setupSessionId } : {}) }) - const { state, access: grant, repoName, owner, loading, openInstall } = repoAccess + const { state, access: grant, repoName, owner, loading, openInstall, installLink } = repoAccess useEffect(() => { repoNameRef.current = repoName }, [repoName]) @@ -120,5 +120,5 @@ export function useCloudStartSteps({ ), [access, agentRow, availability, github, githubOverride, session, state] ) - return { steps, session, openInstall } + return { steps, session, openInstall, installLink } } diff --git a/src/renderer/src/components/open-external/OpenExternalSplitButton.test.tsx b/src/renderer/src/components/open-external/OpenExternalSplitButton.test.tsx new file mode 100644 index 00000000000..e1672521d1a --- /dev/null +++ b/src/renderer/src/components/open-external/OpenExternalSplitButton.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })) +vi.mock('sonner', () => ({ toast })) + +import { OpenExternalSplitButton } from './OpenExternalSplitButton' + +const writeClipboardText = vi.fn<(text: string) => Promise>() + +beforeEach(() => { + writeClipboardText.mockReset().mockResolvedValue(undefined) + toast.success.mockReset() + toast.error.mockReset() + Object.assign(window, { api: { ui: { writeClipboardText } } }) +}) +afterEach(cleanup) + +async function copyLink(): Promise { + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'More link options' })) + await user.click(await screen.findByRole('menuitem', { name: 'Copy link' })) +} + +describe('OpenExternalSplitButton', () => { + it('runs the main action unchanged and never copies on it', async () => { + const onOpen = vi.fn() + render( + + Copy code and open + + ) + await userEvent.setup().click(screen.getByRole('button', { name: 'Copy code and open' })) + expect(onOpen).toHaveBeenCalledTimes(1) + expect(writeClipboardText).not.toHaveBeenCalled() + }) + + it('copies the exact URL from the chevron without opening it', async () => { + const onOpen = vi.fn() + render( + + Open page + + ) + await copyLink() + await waitFor(() => + expect(writeClipboardText).toHaveBeenCalledWith('https://auth.openai.com/codex/device') + ) + expect(onOpen).not.toHaveBeenCalled() + expect(toast.success).toHaveBeenCalledWith('Link copied') + // A brief check on the chevron confirms the copy. + await waitFor(() => + expect(document.querySelector('[data-open-external-chevron] svg.lucide-check')).not.toBeNull() + ) + }) + + it('mints a lazy URL the same way on Copy link, then copies it without opening', async () => { + const onOpen = vi.fn() + const mint = vi.fn(async () => 'https://billing.stripe.com/p/session/test_123') + render( + + Update payment method + + ) + // Nothing is minted until someone asks for the link. + expect(mint).not.toHaveBeenCalled() + await copyLink() + await waitFor(() => + expect(writeClipboardText).toHaveBeenCalledWith( + 'https://billing.stripe.com/p/session/test_123' + ) + ) + expect(mint).toHaveBeenCalledTimes(1) + expect(onOpen).not.toHaveBeenCalled() + }) + + it('says nothing more when a lazy source already explained there is no link', async () => { + render( + null} onClick={() => {}}> + Open + + ) + await copyLink() + await waitFor(() => expect(screen.queryByRole('menu')).toBeNull()) + expect(writeClipboardText).not.toHaveBeenCalled() + expect(toast.success).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }) + + it('tells the person when minting or copying fails', async () => { + render( + { + throw new Error('offline') + }} + onClick={() => {}} + > + Open + + ) + await copyLink() + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Couldn’t copy the link.')) + expect(writeClipboardText).not.toHaveBeenCalled() + }) + + it('reports a clipboard failure instead of claiming the copy', async () => { + writeClipboardText.mockRejectedValueOnce(new Error('denied')) + render( + {}}> + Open again + + ) + await copyLink() + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Couldn’t copy the link.')) + expect(toast.success).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/open-external/OpenExternalSplitButton.tsx b/src/renderer/src/components/open-external/OpenExternalSplitButton.tsx new file mode 100644 index 00000000000..88075711253 --- /dev/null +++ b/src/renderer/src/components/open-external/OpenExternalSplitButton.tsx @@ -0,0 +1,146 @@ +// A button that opens a page in the user's browser, with a chevron at its far right whose menu +// copies the exact URL instead (Neil's rule for every open-in-browser button: people paste sign-in +// and billing links into an incognito window or another browser profile). The main action is the +// caller's, unchanged; the chevron only ever copies. +import React, { useEffect, useRef, useState } from 'react' +import { Check, ChevronDown, Link2, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { buttonGroupVariants } from '@/components/ui/button-group' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { useTranslate } from '@/i18n/use-translate' +import { cn } from '@/lib/class-names' +import { copyExternalLink, type ExternalLinkSource } from './copy-external-link' + +const COPIED_MS = 1500 + +type ButtonProps = React.ComponentProps + +export type OpenExternalSplitButtonProps = Omit & { + /** + * What "Copy link" copies: the URL the main action opens, or how to mint it without opening. + * Null (nothing to copy yet) renders the plain button, no chevron. + */ + link: ExternalLinkSource | null + onClick: (event: React.MouseEvent) => void + /** The wrapper (place it in the parent's layout); `className` styles the main button. */ + groupClassName?: string +} + +export function OpenExternalSplitButton({ + link, + onClick, + groupClassName, + className, + variant, + size, + disabled, + children, + ...buttonProps +}: OpenExternalSplitButtonProps): React.JSX.Element { + const t = useTranslate() + const [state, setState] = useState<'idle' | 'copying' | 'copied'>('idle') + const resetTimer = useRef | null>(null) + const mounted = useRef(true) + useEffect(() => { + mounted.current = true + return () => { + mounted.current = false + if (resetTimer.current) { + clearTimeout(resetTimer.current) + } + } + }, []) + + const copy = async (): Promise => { + if (state === 'copying' || !link) { + return + } + setState('copying') + const result = await copyExternalLink(link) + if (!mounted.current) { + return + } + setState(result === 'copied' ? 'copied' : 'idle') + if (result === 'copied') { + if (resetTimer.current) { + clearTimeout(resetTimer.current) + } + resetTimer.current = setTimeout(() => { + if (mounted.current) { + setState('idle') + } + }, COPIED_MS) + } + } + + const solid = variant === undefined || variant === 'default' || variant === 'destructive' + const copyLabel = t('openExternal.copyLink', 'Copy link') + if (!link) { + return ( + + ) + } + return ( + + {/* The group squares the inner corners; the chevron half is narrow, behind a 1px divider. */} +
+ + + + +
+ + void copy()}> + + {copyLabel} + + +
+ ) +} diff --git a/src/renderer/src/components/open-external/copy-external-link.ts b/src/renderer/src/components/open-external/copy-external-link.ts new file mode 100644 index 00000000000..5316dd6e32b --- /dev/null +++ b/src/renderer/src/components/open-external/copy-external-link.ts @@ -0,0 +1,48 @@ +// "Copy link" for buttons that open a page in the user's browser (Neil's rule: every such button +// offers it, so a sign-in or billing link can go to an incognito window or another browser profile). +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' + +/** + * The exact URL the button opens: known up front, or minted on demand the same way the button mints + * it (a Stripe session, a one-time setup link). A lazy source resolves to null when it already told + * the person why there is no link (signed out, no billing account); nothing more is said then. + */ +export type ExternalLinkSource = string | (() => Promise) + +/** For a lazy source with nothing else to say: no link yet reads as "Couldn't copy the link". */ +export function requireLink(url: string | null): string { + if (!url) { + throw new Error('No link to copy yet') + } + return url +} + +export type CopyExternalLinkResult = 'copied' | 'no-link' | 'failed' + +export async function copyExternalLink( + source: ExternalLinkSource +): Promise { + let url: string | null + try { + url = typeof source === 'string' ? source : await source() + } catch (error) { + console.warn('Could not get the link to copy:', error) + toast.error(translate('openExternal.copyLinkFailed', 'Couldn’t copy the link.')) + return 'failed' + } + if (!url) { + return 'no-link' + } + try { + // Through main, never navigator.clipboard: that one fails in Electron once the click's user + // activation is gone, which it is after the await above. + await window.api.ui.writeClipboardText(url) + } catch (error) { + console.warn('Could not copy the link:', error) + toast.error(translate('openExternal.copyLinkFailed', 'Couldn’t copy the link.')) + return 'failed' + } + toast.success(translate('openExternal.linkCopied', 'Link copied')) + return 'copied' +} diff --git a/src/renderer/src/components/orca-cloud-setup/CloudCapabilityOffActions.tsx b/src/renderer/src/components/orca-cloud-setup/CloudCapabilityOffActions.tsx index a8ae019622c..cb02a34cb8d 100644 --- a/src/renderer/src/components/orca-cloud-setup/CloudCapabilityOffActions.tsx +++ b/src/renderer/src/components/orca-cloud-setup/CloudCapabilityOffActions.tsx @@ -1,6 +1,7 @@ import React from 'react' import { ExternalLink } from 'lucide-react' import { Button } from '@/components/ui/button' +import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' import { useTranslate } from '@/i18n/use-translate' @@ -23,10 +24,14 @@ export function CloudCapabilityOffActions({ {t('cloudSetupPage.capability.tryAgain', 'Try again')} {contactUrl ? ( - + ) : null} ) diff --git a/src/renderer/src/components/orca-cloud-setup/CloudSetupBrowserButton.tsx b/src/renderer/src/components/orca-cloud-setup/CloudSetupBrowserButton.tsx new file mode 100644 index 00000000000..9c6c4706d8e --- /dev/null +++ b/src/renderer/src/components/orca-cloud-setup/CloudSetupBrowserButton.tsx @@ -0,0 +1,26 @@ +// A setup step's browser hop ("Continue in browser"). With a link source, its chevron offers "Copy +// link": the same page, minted the same way, for another browser or profile. +import type React from 'react' +import { ExternalLink, LoaderCircle } from 'lucide-react' +import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' +import type { ExternalLinkSource } from '@/components/open-external/copy-external-link' + +export function CloudSetupBrowserButton({ + label, + busy, + onClick, + link +}: { + label: string + busy: boolean + onClick: () => void + link?: ExternalLinkSource | null +}): React.JSX.Element { + return ( + + {busy ? : null} + {label} + + + ) +} diff --git a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx index c54dd5efd98..d8703ad0ab8 100644 --- a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx +++ b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx @@ -34,8 +34,9 @@ function flow(rows: CloudSetupStep[], teamSwitch = false): CloudSetupFlow { installGitHub: noop, checkAgain: noop, contactUrl: null, - copyLink: null, - reopenBrowser: noop + reopenBrowser: noop, + reopenLink: null, + installGitHubLink: null } } @@ -179,6 +180,6 @@ export const signedOutWaitingBrowser = defineFixture({ 'claude', 'Claude', false, - { signingIn: true, inBrowser: true, copyLink: noop } + { signingIn: true, inBrowser: true, reopenLink: 'https://orca.dev/setup/example' } ) }) diff --git a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx index 79dea8c4992..8712c75a814 100644 --- a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx +++ b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx @@ -3,8 +3,11 @@ // steps are ticks in the stepper. Browser hops only for sign-in, Stripe and GitHub. When every // step is done, the saved prompt starts by itself. import React from 'react' -import { ArrowLeft, Check, Copy, ExternalLink, LoaderCircle, X } from 'lucide-react' +import { ArrowLeft, Check, ExternalLink, LoaderCircle, X } from 'lucide-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 { CloudSetupBrowserButton } from './CloudSetupBrowserButton' import { Skeleton } from '@/components/ui/skeleton' import { cn } from '@/lib/class-names' import { useTranslate } from '@/i18n/use-translate' @@ -140,16 +143,16 @@ function BrowserWaiting({ flow }: { flow: CloudSetupFlow }): React.JSX.Element { {t('cloudSetupPage.waitingBrowser', 'Waiting for your browser…')} - - {flow.copyLink ? ( - - ) : null} + {flow.signingIn ? ( + const browser = ( + label: string, + onClick: () => void, + link?: ExternalLinkSource | null + ): React.JSX.Element => ( + ) const inBrowserAction = flow.action === 'sign-in' || @@ -209,7 +212,7 @@ function StepActions({ ) case 'github-install': - return browser(continueLabel, flow.installGitHub) + return browser(continueLabel, flow.installGitHub, flow.installGitHubLink) case 'start-trial': return ( + ) if (state.kind === 'unknown') { return null } @@ -111,20 +128,14 @@ export function GitHubIdentityRowView({ ) } else if (state.kind === 'error') { detail = t('auto.components.settings.orcaAccount.github.error', "Couldn't connect GitHub.") - action = ( - - ) + action = connectButton(t('auto.components.settings.orcaAccount.github.tryAgain', 'Try again')) } else { detail = t( 'auto.components.settings.orcaAccount.github.description', "Connect your GitHub so commits and pushes in cloud workspaces show your name. Until then they use Orca Cloud's." ) - action = ( - + action = connectButton( + t('auto.components.settings.orcaAccount.github.connect', 'Connect GitHub') ) } return ( @@ -145,6 +156,8 @@ export function GitHubIdentityRowView({ ) } +const CONNECT_LINK = gitHubUserConnectLinkSource() + /** * "GitHub" under the signed-in identity: connect the person's own GitHub for cloud commits. * Mount it only while signed in to Orca Cloud. @@ -162,6 +175,7 @@ export function GitHubIdentityRow(): React.JSX.Element | null { state={identity.state} disconnecting={identity.disconnecting} onConnect={() => void identity.connect()} + connectLink={CONNECT_LINK} onCancel={identity.cancel} onDisconnect={() => void identity.disconnect()} onRetryDisconnect={() => void identity.retryDisconnect()} diff --git a/src/renderer/src/components/settings/agent-accounts/OrcaCloudSetupRow.tsx b/src/renderer/src/components/settings/agent-accounts/OrcaCloudSetupRow.tsx index 138f9ac0a91..ceb88917bb3 100644 --- a/src/renderer/src/components/settings/agent-accounts/OrcaCloudSetupRow.tsx +++ b/src/renderer/src/components/settings/agent-accounts/OrcaCloudSetupRow.tsx @@ -15,6 +15,14 @@ import { setupFailureCopy } from './orca-cloud-accounts-copy' import { CloudStripPasteForm } from '@/components/agent-sign-in-strip/CloudStripPasteForm' import { setupCodePaste } from '@/components/agent-sign-in-strip/cloud-agent-setup-strip-model' import type { OrcaCloudAccountsActions } from './orca-cloud-accounts-types' +import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' +import { requireLink } from '@/components/open-external/copy-external-link' + +/** The provider page "Open again" reopens, minted by main the same way but not opened. */ +async function setupSignInLink(agent: OrcaCloudAccountAgent): Promise { + const api = window.api.orcaCloudAgents + return requireLink(api ? await api.signInLink({ agent }) : null) +} function PasteTokenForm({ reason, @@ -173,14 +181,14 @@ export function SetupRow({ actions={ <> {setup.canReopen ? ( - + ) : null} {cancel} @@ -238,8 +246,8 @@ export function SetupRow({ )} actions={ <> - + {cancel} } diff --git a/src/renderer/src/components/ui/button-group.tsx b/src/renderer/src/components/ui/button-group.tsx index ee38c9d991f..779a06af459 100644 --- a/src/renderer/src/components/ui/button-group.tsx +++ b/src/renderer/src/components/ui/button-group.tsx @@ -12,10 +12,20 @@ const buttonGroupVariants = cva( '[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none', vertical: 'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none' + }, + // A split button (action + chevron): the chevron half is narrow, behind a 1px divider. + // `:not(:first-child)` outranks the orientation's border-l-0 and the button size's padding. + split: { + none: '', + solid: + 'shrink-0 [&>:not(:first-child)]:border-l [&>:not(:first-child)]:border-l-primary-foreground/20 [&>:not(:first-child)]:px-1', + subtle: + 'shrink-0 [&>:not(:first-child)]:border-l [&>:not(:first-child)]:border-l-border [&>:not(:first-child)]:px-1' } }, defaultVariants: { - orientation: 'horizontal' + orientation: 'horizontal', + split: 'none' } } ) @@ -23,6 +33,7 @@ const buttonGroupVariants = cva( function ButtonGroup({ className, orientation, + split, ...props }: React.ComponentProps<'div'> & VariantProps) { return ( @@ -30,7 +41,7 @@ function ButtonGroup({ role="group" data-slot="button-group" data-orientation={orientation} - className={cn(buttonGroupVariants({ orientation }), className)} + className={cn(buttonGroupVariants({ orientation, split }), className)} {...props} /> ) diff --git a/src/renderer/src/components/worktree-creation/VmBuildAgentSignInRow.tsx b/src/renderer/src/components/worktree-creation/VmBuildAgentSignInRow.tsx index a108d170c0f..255e432b43d 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildAgentSignInRow.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildAgentSignInRow.tsx @@ -3,6 +3,7 @@ import { Circle, CircleCheck, ExternalLink, Loader2 } from 'lucide-react' import { useAppStore } from '@/store' import { useTranslate } from '@/i18n/use-translate' import { Button } from '@/components/ui/button' +import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' import { precheckHostAgentSignIn, readHostAgentSignInReport, @@ -132,10 +133,15 @@ export function VmBuildAgentSignInRowView({ meta = cardStep ? null : ( <> {view?.step === 'waiting' ? ( - + ) : null} - ))} + {primary.map((action, index) => + action.link ? ( + (action.pending ? undefined : action.onClick())} + disabled={action.disabled} + aria-disabled={action.pending || undefined} + aria-busy={action.pending || undefined} + > + {action.icon} + {action.label} + + ) : ( + + ) + )} {more.length > 0 ? ( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 368ad09f13f..0569ebf0ab6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -23929,7 +23929,8 @@ "rowPublic": "{{repo}}, cloud only", "privateLabel": "Private", "menuGroup": "Orca Cloud", - "addRepositoryMenu": "Add a GitHub repository…" + "addRepositoryMenu": "Add a GitHub repository…", + "copyAddRepositoryLink": "Copy GitHub link" }, "agentLabel": "Agent" }, @@ -29326,5 +29327,11 @@ "cloudAgentConfig": { "title": "Skills, agents & memory", "autoSyncHint": "Copies your Claude and Codex skills, commands, subagents, output styles and memory you’ve turned on into each cloud workspace. Anything that looks like a key stays on this computer, and nothing made in the sandbox is overwritten." + }, + "openExternal": { + "copyLink": "Copy link", + "moreOptions": "More link options", + "copyLinkFailed": "Couldn’t copy the link.", + "linkCopied": "Link copied" } } diff --git a/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist.json b/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist.json index 5e2467f5861..2b3ecfbf379 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist.json +++ b/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist.json @@ -13,7 +13,7 @@ }, { "path": "src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx", - "line": 365, + "line": 388, "term": "VM", "reason": "recipe-VM branch; cloud branch says sandbox" }, diff --git a/src/renderer/src/lib/orca-cloud/github-link-sources.ts b/src/renderer/src/lib/orca-cloud/github-link-sources.ts new file mode 100644 index 00000000000..33030df09d7 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/github-link-sources.ts @@ -0,0 +1,23 @@ +// "Copy link" for the GitHub handoffs: the same single-use page Connect GitHub (App install) and +// Connect your GitHub (OAuth) open, minted the same way in main but returned instead of opened. +import { requireLink, type ExternalLinkSource } from '@/components/open-external/copy-external-link' +import { cloudWorkspaceApi } from './orca-cloud-workspace-store' + +/** The GitHub App install page; with a repository, GitHub preselects it (and a setup session returns). */ +export function gitHubInstallLinkSource(args?: { + repoUrl?: string + setupSessionId?: string +}): ExternalLinkSource { + return async () => { + const result = await cloudWorkspaceApi()?.gitHubInstallLink(args) + return requireLink(result?.ok ? result.value : null) + } +} + +/** GitHub's OAuth page for connecting the person's own GitHub identity. */ +export function gitHubUserConnectLinkSource(): ExternalLinkSource { + return async () => { + const result = await cloudWorkspaceApi()?.gitHubUserConnectLink() + return requireLink(result?.ok ? result.value : null) + } +} diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-git-setup.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-git-setup.test.ts index e3063fe3f47..0e6b72d3618 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-git-setup.test.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-git-setup.test.ts @@ -47,6 +47,8 @@ function stub( connectGitHub: vi.fn(unused), getGitHubUser: vi.fn(unused), connectGitHubUser: vi.fn(unused), + gitHubInstallLink: vi.fn(unused), + gitHubUserConnectLink: vi.fn(unused), disconnectGitHubUser: vi.fn(unused), getRepoAccess: vi.fn(unused), listGitHubRepositories: vi.fn(unused), diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-wake.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-wake.test.ts index 4d7d7f88a24..2eaf4be43a6 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-wake.test.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-wake.test.ts @@ -37,6 +37,8 @@ function stub( connectGitHub: vi.fn(unused), getGitHubUser: vi.fn(unused), connectGitHubUser: vi.fn(unused), + gitHubInstallLink: vi.fn(unused), + gitHubUserConnectLink: vi.fn(unused), disconnectGitHubUser: vi.fn(unused), getRepoAccess: vi.fn(unused), listGitHubRepositories: vi.fn(unused), diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts index a9997531627..2cb54706641 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts @@ -42,6 +42,8 @@ function stubApi(overrides: Partial = {}): OrcaCloudWorks connectGitHub: vi.fn(async () => ok(null)), getGitHubUser: vi.fn(async () => ok({ connected: false as const })), connectGitHubUser: vi.fn(async () => ok(null)), + gitHubInstallLink: vi.fn(async () => ok('https://github.com/apps/x/installations/new')), + gitHubUserConnectLink: vi.fn(async () => ok('https://github.com/login/oauth/authorize')), disconnectGitHubUser: vi.fn(async () => ok({ connected: false as const })), getRepoAccess: vi.fn(async () => ok({ access: 'granted' as const, owner: 'a', repo: 'https://github.com/a/b' }) diff --git a/src/renderer/src/lib/orca-cloud/use-orca-cloud-repo-access.ts b/src/renderer/src/lib/orca-cloud/use-orca-cloud-repo-access.ts index 04e74e0aa00..d07a6a21b45 100644 --- a/src/renderer/src/lib/orca-cloud/use-orca-cloud-repo-access.ts +++ b/src/renderer/src/lib/orca-cloud/use-orca-cloud-repo-access.ts @@ -3,12 +3,14 @@ // network call; everything else comes from the control plane's GET /v1/github/repo-access (N2), // cached 5 minutes. While a repository is waiting (an install in the browser, or an org owner's // approval, D2), `poll` re-checks on the spec's cadence: every 2 s for 3 minutes, then every 10 s. -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import { normalizeGitRemoteUrl } from '../../../../shared/git/remotes/git-remote-identity' import type { HostedGithubRepoAccessResponse } from '../../../../shared/hosted-sandbox/hosted-github-wire' import type { Repo } from '../../../../shared/repo-types' +import type { ExternalLinkSource } from '@/components/open-external/copy-external-link' import { cloudWorkspaceApi } from './orca-cloud-workspace-store' +import { gitHubInstallLinkSource } from './github-link-sources' import { useOrcaCloudIdentityKey } from './orca-cloud-identity' export type OrcaCloudRepoAccessState = @@ -137,7 +139,12 @@ export function useOrcaCloudRepoAccess( /** Fired once when a repository that was waiting becomes reachable (D2's "access granted"). */ onGranted?: () => void } = {} -): OrcaCloudRepoAccess & { refresh: () => void; openInstall: () => Promise } { +): OrcaCloudRepoAccess & { + refresh: () => void + openInstall: () => Promise + /** "Copy link" for openInstall: the same single-use install page, minted without opening. */ + installLink: ExternalLinkSource | null +} { const repo = useAppStore((s) => repoId ? s.repos.find((entry) => entry.id === repoId) : undefined ) @@ -211,6 +218,14 @@ export function useOrcaCloudRepoAccess( return Boolean(result?.ok) }, [repoUrl, setupSessionId]) + const installLink = useMemo( + () => + repoUrl + ? gitHubInstallLinkSource({ repoUrl, ...(setupSessionId ? { setupSessionId } : {}) }) + : null, + [repoUrl, setupSessionId] + ) + return { state: enabled || !repoUrl ? state : 'unknown', ...(current?.installUrl ? { installUrl: current.installUrl } : {}), @@ -220,6 +235,7 @@ export function useOrcaCloudRepoAccess( repoUrl, loading: enabled && current === undefined, refresh, - openInstall + openInstall, + installLink } } diff --git a/src/renderer/src/web/preload-api/web-orca-profiles-api.ts b/src/renderer/src/web/preload-api/web-orca-profiles-api.ts index b6c17f986d6..4fa1c22b21e 100644 --- a/src/renderer/src/web/preload-api/web-orca-profiles-api.ts +++ b/src/renderer/src/web/preload-api/web-orca-profiles-api.ts @@ -85,6 +85,8 @@ export function createWebOrcaProfilesApi(): Partial { cloudSetupGet: async () => ({ status: 'unconfigured' }), cloudSetupLink: async () => ({ status: 'unconfigured' }), billingOpenPortal: async () => ({ status: 'unconfigured' }), + billingPortalLink: async () => ({ status: 'unconfigured' }), + billingCheckoutLink: async () => ({ status: 'unconfigured' }), billingTeamPlans: async () => ({ status: 'unconfigured' }), billingUsageHistory: async () => ({ status: 'unconfigured' }), billingSetOverage: async () => ({ status: 'unconfigured' }), diff --git a/src/shared/hosted-sandbox/cloud-workspace-api.ts b/src/shared/hosted-sandbox/cloud-workspace-api.ts index 70b49baf94d..31610fa5075 100644 --- a/src/shared/hosted-sandbox/cloud-workspace-api.ts +++ b/src/shared/hosted-sandbox/cloud-workspace-api.ts @@ -93,10 +93,17 @@ export type OrcaCloudWorkspaceApi = { repoUrl?: string setupSessionId?: string }) => Promise> + /** "Copy link" for connectGitHub: the same single-use GitHub install page, minted but not opened. */ + gitHubInstallLink: (args?: { + repoUrl?: string + setupSessionId?: string + }) => Promise> /** Whether the signed-in person connected their own GitHub (cloud commit attribution). */ getGitHubUser: () => Promise> /** Opens GitHub's OAuth page in the browser so the person connects their own GitHub identity. */ connectGitHubUser: () => Promise> + /** "Copy link" for connectGitHubUser: the same GitHub OAuth page, minted but not opened. */ + gitHubUserConnectLink: () => Promise> /** Forgets the person's GitHub identity; cloud commits use Orca Cloud's name again. */ disconnectGitHubUser: () => Promise> /** Can Orca Cloud clone this https GitHub repository for the signed-in account (cloud-start N2)? */ @@ -130,8 +137,10 @@ export const ORCA_CLOUD_WORKSPACE_CHANNELS = { listCloudWorkspaceIds: 'orcaCloudWorkspace:listIds', getConfig: 'orcaCloudWorkspace:getConfig', connectGitHub: 'orcaCloudWorkspace:connectGitHub', + gitHubInstallLink: 'orcaCloudWorkspace:gitHubInstallLink', getGitHubUser: 'orcaCloudWorkspace:getGitHubUser', connectGitHubUser: 'orcaCloudWorkspace:connectGitHubUser', + gitHubUserConnectLink: 'orcaCloudWorkspace:gitHubUserConnectLink', disconnectGitHubUser: 'orcaCloudWorkspace:disconnectGitHubUser', getRepoAccess: 'orcaCloudWorkspace:getRepoAccess', listGitHubRepositories: 'orcaCloudWorkspace:listGitHubRepositories', diff --git a/src/shared/orca-billing.ts b/src/shared/orca-billing.ts index b8f3f8053dc..b23d18b134d 100644 --- a/src/shared/orca-billing.ts +++ b/src/shared/orca-billing.ts @@ -190,6 +190,12 @@ export type OrcaBillingOpenResult = | { status: 'plan-unavailable' } | { status: 'failed'; error: string } +/** Why a Checkout/Portal open (or link) didn't happen. */ +export type OrcaBillingOpenRefusal = Exclude + +/** A minted Checkout/Portal URL for "Copy link" (not opened), or why there is none. */ +export type OrcaBillingLinkResult = { status: 'ok'; url: string } | OrcaBillingOpenRefusal + export type OrcaBillingOverageResult = | { status: 'ok'; entitlement: OrcaBillingEntitlement } | { status: 'unconfigured' }