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.
This commit is contained in:
Neil
2026-11-12 17:36:32 -08:00
parent 00205419f8
commit ce321f0bb5
65 changed files with 1398 additions and 274 deletions
+21
View File
@@ -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<OrcaBillingLinkResult> {
return withSetupSessionFallback(args, (checkoutArgs) =>
mintHosted(userDataPath, (token, endpoints) =>
createCheckoutUrl(endpoints, token, checkoutArgs)
)
)
}
export function getOrcaBillingPortalLink(userDataPath: string): Promise<OrcaBillingLinkResult> {
return mintHosted(userDataPath, (token, endpoints) => createPortalUrl(endpoints, token))
}
+2 -2
View File
@@ -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' }
@@ -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<OrcaBillingLinkResult> =>
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<OrcaBillingLinkResult> => ({ status: 'forbidden' }))
await expect(withSetupSessionFallback(ARGS, run)).resolves.toEqual({ status: 'forbidden' })
expect(run).toHaveBeenCalledTimes(1)
})
})
+30 -23
View File
@@ -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<string>
/** 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<string>
): Promise<OrcaBillingOpenResult> {
createUrl: CreateHostedUrl
): Promise<OrcaBillingLinkResult> {
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<OrcaBillingOpenResult> {
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<OrcaBillingOpenResult> {
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<OrcaBillingOpenResult> {
@@ -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<T extends { status: string; error?: string }>(
args: OrcaBillingCheckoutArgs,
run: (args: OrcaBillingCheckoutArgs) => Promise<T>
): Promise<T> {
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
}
+25 -11
View File
@@ -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<string> => {
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) {
@@ -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<string, (...args: unknown[]) => 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'
})
}
})
@@ -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<string> => {
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() }
@@ -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<OrcaBillingOpenResult> =>
openOrcaBillingPortal(getProfileUserDataPath())
)
// "Copy link": the same Stripe URLs, minted but not opened (paste into another browser profile).
ipcMain.handle('orcaProfiles:billingPortalLink', async (): Promise<OrcaBillingLinkResult> =>
getOrcaBillingPortalLink(getProfileUserDataPath())
)
ipcMain.handle(
'orcaProfiles:billingCheckoutLink',
async (_event, rawArgs: unknown): Promise<OrcaBillingLinkResult> =>
getOrcaBillingCheckoutLink(getProfileUserDataPath(), billingCheckoutArgsFromUnknown(rawArgs))
)
}
@@ -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)
@@ -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`)
@@ -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<boolean> => {
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') {
@@ -16,6 +16,8 @@ export type OrcaCloudAgentsApi = {
start: (request: { agent: OrcaCloudAccountAgent }) => Promise<OrcaCloudAgentsResult<true>>
cancel: (request: { agent: OrcaCloudAccountAgent }) => Promise<void>
reopen: (request: { agent: OrcaCloudAccountAgent }) => Promise<boolean>
/** The provider page `reopen` would open, without opening it ("Copy link"); null when none. */
signInLink: (request: { agent: OrcaCloudAccountAgent }) => Promise<string | null>
usePaste: () => Promise<void>
/** The code from Claude's sign-in page, typed into the waiting setup; the reply is a code only. */
submitClaudeCode: (request: { code: string }) => Promise<OrcaCloudAgentsResult<true>>
@@ -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),
@@ -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),
+5
View File
@@ -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<CloudSetupLinkResult>
/** Opens the Stripe Customer Portal in the browser. */
billingOpenPortal: () => Promise<OrcaBillingOpenResult>
/** "Copy link": a fresh Customer Portal URL, minted but not opened. */
billingPortalLink: () => Promise<OrcaBillingLinkResult>
/** "Copy link": the Checkout URL billingOpenCheckout would open, minted but not opened. */
billingCheckoutLink: (args: OrcaBillingCheckoutArgs) => Promise<OrcaBillingLinkResult>
/** The last 30 days of sandbox hours (breakdowns for owners and admins only). */
billingUsageHistory: (args: {
utcOffsetMinutes: number
+2
View File
@@ -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),
@@ -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 (
<OpenExternalSplitButton
{...props}
// The caller places the whole pair; the main half fills what the chevron leaves.
groupClassName={props.className}
className="flex-1"
link={link}
onClick={onClick}
>
{t('auto.components.OrcaSignInProgress.reopenBrowser', 'Reopen browser')}
<ExternalLink aria-hidden />
</OpenExternalSplitButton>
)
}
return (
<Button {...props} type="button" onClick={onClick}>
{link ? (
<>
{t('auto.components.OrcaSignInProgress.reopenBrowser', 'Reopen browser')}
<ExternalLink aria-hidden />
</>
) : (
children
)}
{children}
</Button>
)
}
@@ -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({
</p>
<p className="text-xs text-muted-foreground">{GUARD(t)}</p>
<div className="flex items-center gap-1.5">
<Button
type="button"
variant="outline"
size="xs"
onClick={() => flow.url && actions.openLink(flow.url)}
>
{t('agentHostPanel.ssh.openClaude', 'Open claude.ai')}
</Button>
{flow.url ? (
<OpenExternalSplitButton
link={flow.url}
variant="outline"
size="xs"
onClick={() => flow.url && actions.openLink(flow.url)}
>
{t('agentHostPanel.ssh.openClaude', 'Open claude.ai')}
</OpenExternalSplitButton>
) : (
<Button type="button" variant="outline" size="xs" disabled>
{t('agentHostPanel.ssh.openClaude', 'Open claude.ai')}
</Button>
)}
<Input
variant="code"
autoComplete="off"
@@ -177,15 +184,21 @@ export function SshCodeSignIn({
<code className="shrink-0 select-all rounded bg-muted px-1.5 font-mono text-xs tracking-wider">
{flow.userCode}
</code>
<Button
type="button"
size="xs"
onClick={() =>
flow.userCode && flow.url && actions.copyAndOpen(flow.userCode, flow.url)
}
>
{t('agentSignInStrip.copyAndOpen', 'Copy code and open')}
</Button>
{flow.url ? (
<OpenExternalSplitButton
link={flow.url}
size="xs"
onClick={() =>
flow.userCode && flow.url && actions.copyAndOpen(flow.userCode, flow.url)
}
>
{t('agentSignInStrip.copyAndOpen', 'Copy code and open')}
</OpenExternalSplitButton>
) : (
<Button type="button" size="xs" disabled>
{t('agentSignInStrip.copyAndOpen', 'Copy code and open')}
</Button>
)}
{cancel}
</div>
</div>
@@ -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(
<TooltipProvider>
<CloudAgentSignInStrip model={m} prompt={null} actions={actions} width="wide" />
</TooltipProvider>
).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<string> => '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()
})
})
@@ -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<CloudStripWidth, string> = {
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 ? (
<Button
type="button"
<StripPrimaryButton
primary={primary}
link={stripPrimaryLink(primary.action, actions)}
// The short label still names what it does for a screen reader.
aria-label={
ariaLabel={
(width === 'tiny' || compact) && primary.narrowLabel ? primary.label : undefined
}
size="xs"
variant={model.status === 'error' ? 'outline' : 'default'}
aria-busy={primary.busy || undefined}
aria-disabled={primary.busy || undefined}
onClick={() => (primary.busy ? undefined : actions.primary(primary.action))}
onActivate={() => actions.primary(primary.action)}
>
{(width === 'tiny' || compact) && primary.narrowLabel
? primary.narrowLabel
: primary.label}
</Button>
</StripPrimaryButton>
) : null}
{model.otherWays ? (
<DropdownMenu>
@@ -362,19 +368,15 @@ export function CloudAgentSignInStrip({
/>
</div>
{model.primary ? (
<Button
type="button"
<StripPrimaryButton
primary={model.primary}
link={stripPrimaryLink(model.primary.action, actions)}
variant="ghost"
size="xs"
aria-disabled={model.primary.busy || undefined}
onClick={() =>
model.primary && !model.primary.busy
? actions.primary(model.primary.action)
: undefined
}
quiet
onActivate={() => model.primary && actions.primary(model.primary.action)}
>
{model.primary.label}
</Button>
</StripPrimaryButton>
) : null}
</div>
) : null}
@@ -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<CloudStripActions, 'linkFor'>
): 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<CloudStripModel['primary']>
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 (
<OpenExternalSplitButton
link={link}
aria-label={ariaLabel}
size="xs"
variant={variant}
onClick={onClick}
>
{children}
</OpenExternalSplitButton>
)
}
return (
<Button
type="button"
aria-label={ariaLabel}
size="xs"
variant={variant}
aria-busy={quiet ? undefined : busy}
aria-disabled={busy}
onClick={onClick}
>
{children}
</Button>
)
}
@@ -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,
@@ -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' ? (
<>
<div className="flex items-center gap-2">
<Button
type="button"
<OpenExternalSplitButton
link={view.url}
variant="outline"
size="sm"
onClick={() => actions.openLink(view.url)}
>
<ExternalLink />
{t('agentSignIn.openPage', 'Open sign-in page')}
</Button>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => actions.copyText(view.url)}
>
<Copy />
{t('agentSignIn.copyLink', 'Copy link')}
</Button>
</OpenExternalSplitButton>
</div>
<PasteCodeForm
submitting={view.submitting}
@@ -214,15 +206,15 @@ export function AgentSignInCard({
{t('agentSignIn.copyCode', 'Copy code')}
</Button>
<span className="flex-1" />
<Button
type="button"
<OpenExternalSplitButton
link={view.url}
variant="outline"
size="sm"
onClick={() => actions.openLink(view.url)}
>
<ExternalLink />
{t('agentSignIn.openPageShort', 'Open page')}
</Button>
</OpenExternalSplitButton>
</div>
) : null}
@@ -244,24 +236,15 @@ export function AgentSignInCard({
{view.step === 'signed' ? null : view.step === 'waiting' ? (
<div className="flex items-center gap-1">
<Button
type="button"
<OpenExternalSplitButton
link={view.url}
variant="ghost"
size="xs"
onClick={() => actions.openLink(view.url)}
>
<ExternalLink />
{t('agentSignIn.openAgain', 'Open again')}
</Button>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => actions.copyText(view.url)}
>
<Copy />
{t('agentSignIn.copyLink', 'Copy link')}
</Button>
</OpenExternalSplitButton>
<span className="flex-1" />
{cancel}
</div>
@@ -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 ? (
<Button
type="button"
<OpenExternalSplitButton
variant="outline"
size="xs"
aria-disabled={busy || undefined}
onClick={busy ? undefined : onUpdatePayment}
link={billingPortalLink}
onClick={() => {
if (!busy) {
onUpdatePayment()
}
}}
>
{t('auto.components.billing.updatePayment', 'Update payment method')}
<ExternalLink />
</Button>
</OpenExternalSplitButton>
) : undefined
}
/>
@@ -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({
</Button>
)}
{hasPlan && entitlement.canManageBilling && entitlement.payment.pastDueSince === null ? (
<Button
type="button"
<OpenExternalSplitButton
variant="outline"
size="sm"
aria-disabled={busy || undefined}
onClick={busy ? undefined : onPortal}
link={billingPortalLink}
onClick={() => {
if (!busy) {
onPortal()
}
}}
>
{t('auto.components.billing.manageBilling', 'Manage billing')}
<ExternalLink />
</Button>
</OpenExternalSplitButton>
) : null}
{hasPlan ? (
// Members have no other action here, so it gets the button's chrome (and its alignment).
@@ -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<string, unknown>): 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.')
})
})
@@ -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<OrcaBillingLinkResult>): Promise<string | null> {
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<string | null> {
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<string | null> {
return linkOrToast(() => window.api.orcaProfiles.billingCheckoutLink(args))
}
@@ -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<string | null>
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 = (
<Button type="button" onClick={props.onReopen}>
const reopenLabel = (
<>
{phase.kind === 'canceled' || phase.kind === 'open-failed' ? <RotateCcw /> : null}
{phase.kind === 'canceled' || phase.kind === 'open-failed'
? t('auto.components.billing.checkout.tryAgain', 'Try again')
: t('auto.components.billing.checkout.reopen', 'Reopen checkout')}
{phase.kind === 'canceled' || phase.kind === 'open-failed' ? null : <ExternalLink />}
</>
)
const reopen = props.reopenLink ? (
<OpenExternalSplitButton link={props.reopenLink} onClick={props.onReopen}>
{reopenLabel}
</OpenExternalSplitButton>
) : (
<Button type="button" onClick={props.onReopen}>
{reopenLabel}
</Button>
)
const back = (
@@ -275,14 +287,14 @@ export function CheckoutPanel(props: CheckoutPanelProps): React.JSX.Element {
phase.result.status === 'plan-unavailable' &&
props.contactUrl &&
props.onContact ? (
<Button
type="button"
<OpenExternalSplitButton
variant="outline"
link={props.contactUrl}
onClick={() => props.contactUrl && props.onContact?.(props.contactUrl)}
>
{t('auto.components.billing.checkout.contactUs', 'Contact us')}
<ExternalLink />
</Button>
</OpenExternalSplitButton>
) : null}
{phase.kind === 'timed-out' ? (
<Button type="button" variant="outline" onClick={props.onKeepWaiting}>
@@ -1,5 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { OrcaBillingOpenResult } from '../../../../../shared/orca-billing'
import type {
OrcaBillingCheckoutArgs,
OrcaBillingOpenResult
} from '../../../../../shared/orca-billing'
import { billingCheckoutLink } from '../billing-link'
import { getBillingGeneration, recordBillingStatusIfCurrent } from '../billing-summary-store'
import { installWindowVisibilityInterval } from '@/lib/window-activity/window-visibility-interval'
import {
@@ -28,6 +32,21 @@ export type CheckoutFlow = {
/** Restarts the watch window after a timeout. */
keepWaiting: () => void
reset: () => void
/** "Copy link" on the checkout button: mints this order's Checkout URL and starts the watch. */
copyLink: (order: CheckoutOrder) => Promise<string | null>
/** "Copy link" on Reopen: a fresh Checkout URL for the same order. */
copyReopenLink: () => Promise<string | null>
}
/** Joins an open setup session, so Checkout returns to its next step (T2 Compare plans). */
function checkoutArgsFor(order: CheckoutOrder): OrcaBillingCheckoutArgs {
const setupSessionId = activeCloudSetupSessionId()
return {
plan: order.planId,
interval: order.interval,
seats: order.seats,
...(setupSessionId ? { setupSessionId } : {})
}
}
/**
@@ -46,15 +65,8 @@ export function useCheckoutFlow({ onPaid }: { onPaid: (seats: number) => void })
const start = useCallback((order: CheckoutOrder) => {
const id = ++runId.current
setPhase({ kind: 'opening', order })
// Joins an open setup session, so Checkout returns to its next step (T2 Compare plans).
const setupSessionId = activeCloudSetupSessionId()
void window.api.orcaProfiles
.billingOpenCheckout({
plan: order.planId,
interval: order.interval,
seats: order.seats,
...(setupSessionId ? { setupSessionId } : {})
})
.billingOpenCheckout(checkoutArgsFor(order))
.catch((error: unknown): OrcaBillingOpenResult => ({
status: 'failed',
error: String(error)
@@ -75,6 +87,17 @@ export function useCheckoutFlow({ onPaid }: { onPaid: (seats: number) => void })
})
}, [])
// "Copy link": the same Checkout session, minted but not opened. Paying from the pasted link
// (another browser profile) is watched exactly like an opened Checkout.
const copyLink = useCallback(async (order: CheckoutOrder): Promise<string | null> => {
const url = await billingCheckoutLink(checkoutArgsFor(order))
if (url) {
++runId.current
setPhase(nextPhaseAfterOpen(order, { status: 'opened' }, Date.now()))
}
return url
}, [])
const reset = useCallback(() => {
runId.current++
setPhase({ kind: 'idle' })
@@ -183,5 +206,10 @@ export function useCheckoutFlow({ onPaid }: { onPaid: (seats: number) => void })
}
}, [order])
return { phase, start, reopen, keepWaiting, reset }
const copyReopenLink = useCallback(
(): Promise<string | null> => (order ? copyLink(order) : Promise.resolve(null)),
[order, copyLink]
)
return { phase, start, reopen, keepWaiting, reset, copyLink, copyReopenLink }
}
@@ -1,5 +1,5 @@
import { Building2, ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton'
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
import { PlanCardShell } from './PlanCardShell'
import { planFeatureLines } from './plan-feature-copy'
@@ -46,15 +46,16 @@ export function EnterprisePlanCard({
footer={
contactUrl ? (
<>
<Button
type="button"
<OpenExternalSplitButton
variant="outline"
className="w-full"
groupClassName="w-full"
className="flex-1"
link={contactUrl}
onClick={() => onContactSales(contactUrl)}
>
{t('auto.components.billing.plans.contactSales', 'Contact sales')}
<ExternalLink />
</Button>
</OpenExternalSplitButton>
<p className="text-center text-xs text-muted-foreground">
{t(
'auto.components.billing.plans.entHint',
@@ -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}
@@ -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(<PlansView {...view({ checkoutLink })} />)
// 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(<PlansView {...view({ catalog: monthlyOnlyCatalogResult })} />)
expect(screen.queryByRole('radiogroup')).toBeNull()
@@ -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<string | null>
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 ? (
@@ -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<string | null>
onManage: () => void
}): React.JSX.Element {
const t = useTranslate()
@@ -126,20 +131,41 @@ export function TeamPlanCard({
</p>
)
} else if (mode === 'choose') {
const checkoutLabel = (
<>
{busy ? <LoadingSpinner /> : null}
{busy
? t('auto.components.billing.plans.waiting', 'Waiting for checkout…')
: t('auto.components.billing.plans.checkout', 'Continue to checkout')}
{busy ? null : <ExternalLink />}
</>
)
footer = (
<>
<Button
type="button"
className="w-full"
aria-disabled={busy || undefined}
onClick={busy ? undefined : onCheckout}
>
{busy ? <LoadingSpinner /> : null}
{busy
? t('auto.components.billing.plans.waiting', 'Waiting for checkout…')
: t('auto.components.billing.plans.checkout', 'Continue to checkout')}
{busy ? null : <ExternalLink />}
</Button>
{checkoutLink ? (
<OpenExternalSplitButton
groupClassName="w-full"
className="flex-1"
aria-disabled={busy || undefined}
link={checkoutLink}
onClick={() => {
if (!busy) {
onCheckout()
}
}}
>
{checkoutLabel}
</OpenExternalSplitButton>
) : (
<Button
type="button"
className="w-full"
aria-disabled={busy || undefined}
onClick={busy ? undefined : onCheckout}
>
{checkoutLabel}
</Button>
)}
{notice?.kind === 'error' ? (
<InlineError title={notice.text} />
) : (
@@ -163,16 +189,21 @@ export function TeamPlanCard({
</p>
) : (
<>
<Button
type="button"
<OpenExternalSplitButton
variant="outline"
className="w-full"
groupClassName="w-full"
className="flex-1"
aria-disabled={busy || undefined}
onClick={busy ? undefined : onManage}
link={billingPortalLink}
onClick={() => {
if (!busy) {
onManage()
}
}}
>
{t('auto.components.billing.manageBilling', 'Manage billing')}
<ExternalLink />
</Button>
</OpenExternalSplitButton>
{notice?.kind === 'error' ? (
<InlineError title={notice.text} />
) : (
@@ -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 {
)}
</p>
</div>
<Button
type="button"
<OpenExternalSplitButton
variant="outline"
size="sm"
link={enterprise.contactUrl}
onClick={() => props.onContactSales(enterprise.contactUrl)}
>
{t('auto.components.billing.enterpriseContactSales', 'Contact sales')}
<ExternalLink />
</Button>
</OpenExternalSplitButton>
</section>
) : null}
{selfRow ? (
@@ -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({
<Button type="button" variant="outline" size="sm" onClick={onSeePlans}>
{t('auto.components.billing.team.usage', 'Usage & plan')}
</Button>
<Button
type="button"
<OpenExternalSplitButton
variant="outline"
size="sm"
disabled={busy}
link={billingPortalLink}
onClick={onManageBilling}
>
{t('auto.components.billing.manageBilling', 'Manage billing')}
<ExternalLink />
</Button>
</OpenExternalSplitButton>
</>
) : null}
</div>
@@ -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…')}
</span>
</DropdownMenuItem>
{/* A menu row can't carry a chevron: the same GitHub page, copied for another browser. */}
<DropdownMenuItem onSelect={onCopyLink}>
<Link2 />
<span className="truncate">
{t('components.newWorkspace.cloudOnly.copyAddRepositoryLink', 'Copy GitHub link')}
</span>
</DropdownMenuItem>
</>
)
}
@@ -45,6 +60,10 @@ export function cloudAddProjectExtraItems(
closeMenus()
onMoreCloudAccess()
}}
onCopyLink={() => {
closeMenus()
void copyExternalLink(gitHubInstallLinkSource())
}}
/>
)
}
@@ -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 ? (
<Button type="button" variant="outline" size="xs" onClick={() => onAction(action)}>
{actionLabel(action, t)}
</Button>
action === 'reopen-browser' && reopenLink ? (
<OpenExternalSplitButton
variant="outline"
size="xs"
link={reopenLink}
onClick={() => onAction(action)}
>
{actionLabel(action, t)}
</OpenExternalSplitButton>
) : (
<Button type="button" variant="outline" size="xs" onClick={() => onAction(action)}>
{actionLabel(action, t)}
</Button>
)
) : undefined
}
className={className}
@@ -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<string | null> {
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<void> {
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
}
}
@@ -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 }
}
@@ -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<void>>()
beforeEach(() => {
writeClipboardText.mockReset().mockResolvedValue(undefined)
toast.success.mockReset()
toast.error.mockReset()
Object.assign(window, { api: { ui: { writeClipboardText } } })
})
afterEach(cleanup)
async function copyLink(): Promise<void> {
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(
<OpenExternalSplitButton link="https://auth.openai.com/codex/device" onClick={onOpen}>
Copy code and open
</OpenExternalSplitButton>
)
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(
<OpenExternalSplitButton link="https://auth.openai.com/codex/device" onClick={onOpen}>
Open page
</OpenExternalSplitButton>
)
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(
<OpenExternalSplitButton link={mint} onClick={onOpen}>
Update payment method
</OpenExternalSplitButton>
)
// 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(
<OpenExternalSplitButton link={async () => null} onClick={() => {}}>
Open
</OpenExternalSplitButton>
)
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(
<OpenExternalSplitButton
link={async () => {
throw new Error('offline')
}}
onClick={() => {}}
>
Open
</OpenExternalSplitButton>
)
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(
<OpenExternalSplitButton link="https://claude.ai/oauth" onClick={() => {}}>
Open again
</OpenExternalSplitButton>
)
await copyLink()
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Couldn’t copy the link.'))
expect(toast.success).not.toHaveBeenCalled()
})
})
@@ -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<typeof Button>
export type OpenExternalSplitButtonProps = Omit<ButtonProps, 'onClick' | 'type' | 'asChild'> & {
/**
* 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<HTMLButtonElement>) => 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<ReturnType<typeof setTimeout> | null>(null)
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
return () => {
mounted.current = false
if (resetTimer.current) {
clearTimeout(resetTimer.current)
}
}
}, [])
const copy = async (): Promise<void> => {
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 (
<Button
{...buttonProps}
type="button"
variant={variant}
size={size}
disabled={disabled}
className={className}
onClick={onClick}
>
{children}
</Button>
)
}
return (
<DropdownMenu>
{/* The group squares the inner corners; the chevron half is narrow, behind a 1px divider. */}
<div
role="group"
data-slot="button-group"
data-open-external-split
className={cn(buttonGroupVariants({ split: solid ? 'solid' : 'subtle' }), groupClassName)}
>
<Button
{...buttonProps}
type="button"
variant={variant}
size={size}
disabled={disabled}
className={className}
onClick={onClick}
>
{children}
</Button>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant={variant}
size={size}
disabled={disabled}
aria-label={t('openExternal.moreOptions', 'More link options')}
aria-busy={state === 'copying' || undefined}
data-open-external-chevron
>
{state === 'copied' ? (
<Check aria-hidden />
) : state === 'copying' ? (
<Loader2 aria-hidden className="motion-safe:animate-spin" />
) : (
<ChevronDown aria-hidden />
)}
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => void copy()}>
<Link2 aria-hidden />
{copyLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -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<string | null>)
/** 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<CopyExternalLinkResult> {
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'
}
@@ -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')}
</Button>
{contactUrl ? (
<Button type="button" variant="outline" onClick={() => openAppHttpLink(contactUrl)}>
<OpenExternalSplitButton
variant="outline"
link={contactUrl}
onClick={() => openAppHttpLink(contactUrl)}
>
{t('cloudSetupPage.capability.contact', 'Contact us')}
<ExternalLink aria-hidden />
</Button>
</OpenExternalSplitButton>
) : null}
</div>
)
@@ -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 (
<OpenExternalSplitButton link={link ?? null} onClick={onClick} disabled={busy}>
{busy ? <LoaderCircle className="size-4 animate-spin" aria-hidden /> : null}
{label}
<ExternalLink aria-hidden />
</OpenExternalSplitButton>
)
}
@@ -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' }
)
})
@@ -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…')}
</span>
<span className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={flow.reopenBrowser}>
{/* "Copy link" under the chevron: a fresh one-time link for another browser or profile. */}
<OpenExternalSplitButton
variant="outline"
size="sm"
link={flow.reopenLink}
onClick={flow.reopenBrowser}
>
{t('cloudSetupPage.reopenBrowser', 'Reopen browser')}
<ExternalLink aria-hidden />
</Button>
{flow.copyLink ? (
<Button type="button" variant="ghost" size="sm" onClick={flow.copyLink}>
<Copy aria-hidden />
{t('cloudSetupPage.copyLink', 'Copy link')}
</Button>
) : null}
</OpenExternalSplitButton>
{flow.signingIn ? (
<Button type="button" variant="ghost" size="sm" onClick={flow.cancelSignIn}>
{t('cloudSetupPage.cancelSignIn', 'Cancel')}
@@ -169,12 +172,12 @@ function StepActions({
onStart?: (() => void) | null
}): React.JSX.Element | null {
const t = useTranslate()
const browser = (label: string, onClick: () => void): React.JSX.Element => (
<Button type="button" onClick={onClick} disabled={flow.busy}>
{flow.busy ? <LoaderCircle className="size-4 animate-spin" aria-hidden /> : null}
{label}
<ExternalLink aria-hidden />
</Button>
const browser = (
label: string,
onClick: () => void,
link?: ExternalLinkSource | null
): React.JSX.Element => (
<CloudSetupBrowserButton label={label} busy={flow.busy} onClick={onClick} link={link} />
)
const inBrowserAction =
flow.action === 'sign-in' ||
@@ -209,7 +212,7 @@ function StepActions({
</Button>
)
case 'github-install':
return browser(continueLabel, flow.installGitHub)
return browser(continueLabel, flow.installGitHub, flow.installGitHubLink)
case 'start-trial':
return (
<Button type="button" onClick={flow.startTrial} disabled={flow.busy}>
@@ -407,6 +410,7 @@ export function CloudSetupPage({
<CloudSetupErrorNotice
error={flow.error}
canManageBilling={flow.canManageBilling}
reopenLink={flow.reopenLink}
onAction={(action) =>
action === 'reopen-browser' ? flow.reopenBrowser() : flow.continueInBrowser()
}
@@ -18,12 +18,12 @@ import { useCloudStartSteps } from '@/components/new-workspace/run-on/use-cloud-
import { useSwitchTeamSteps } from '@/components/new-workspace/cloud-setup/use-cloud-setup-tracker'
import { parseCloudSetupError } from '@/components/new-workspace/cloud-setup/cloud-setup-errors'
import {
canCopyCloudSetupLink,
copyCloudSetupLink,
cloudSetupLinkSource,
reopenCloudSetup
} from '@/components/new-workspace/cloud-setup/cloud-setup-tracker-actions'
import { getCloudSetupStarter } from '@/lib/orca-cloud/cloud-setup-session-seam'
import type { CloudSetupPageRequest } from '@/lib/orca-cloud/cloud-setup-page-store'
import type { ExternalLinkSource } from '@/components/open-external/copy-external-link'
import { reportCloudSetupProgress } from '@/lib/orca-cloud/cloud-setup-telemetry'
import { installWindowVisibilityInterval } from '@/lib/window-activity/window-visibility-interval'
import type { OrcaCloudAvailability } from '../../../../shared/orca-yaml-hook-types'
@@ -101,8 +101,11 @@ export type CloudSetupFlow = CloudSetupPageModel & {
checkAgain: () => void
/** Where "Contact us" goes when Orca Cloud isn't turned on for the team; null hides it. */
contactUrl: string | null
copyLink: (() => void) | null
reopenBrowser: () => void
/** "Copy link" beside Reopen browser: a fresh one-time setup link; null without a session. */
reopenLink: ExternalLinkSource | null
/** "Copy link" beside the GitHub step's button: the same install page, minted without opening. */
installGitHubLink: ExternalLinkSource | null
}
export function useCloudSetupFlow(
@@ -129,7 +132,8 @@ export function useCloudSetupFlow(
const {
steps: rowSteps,
session,
openInstall
openInstall,
installLink
} = useCloudStartSteps({
availability,
access,
@@ -293,10 +297,8 @@ export function useCloudSetupFlow(
installGitHub,
checkAgain,
contactUrl,
copyLink:
sessionId && canCopyCloudSetupLink(sessionId)
? () => void copyCloudSetupLink(sessionId)
: null,
reopenBrowser: () => void reopenCloudSetup(sessionId, continueInBrowser)
reopenBrowser: () => void reopenCloudSetup(sessionId, continueInBrowser),
reopenLink: cloudSetupLinkSource(sessionId),
installGitHubLink: installLink
}
}
@@ -17,6 +17,8 @@ import {
repoSlug,
runCloudSetupScript
} from '@/lib/orca-cloud/cloud-setup-notice-actions'
import { gitHubInstallLinkSource } from '@/lib/orca-cloud/github-link-sources'
import type { ExternalLinkSource } from '@/components/open-external/copy-external-link'
import { hideCloudToast, showCloudToast, type CloudToastView } from './cloud-toast-stack'
// After Install: GitHub's webhook usually lands within seconds, then the owner may take a while.
@@ -27,7 +29,14 @@ const GIVE_UP_MS = 30 * 60_000
export type CloudSetupNoticeLine = {
text: string
action: { label: string; icon: React.ReactNode; run: () => void; pending?: boolean } | null
action: {
label: string
icon: React.ReactNode
run: () => void
pending?: boolean
/** Opens this page in the browser: "Copy link" beside it. */
link?: ExternalLinkSource
} | null
}
export function cloudSetupNoticeLine(
@@ -56,7 +65,8 @@ export function cloudSetupNoticeLine(
<GitPullRequestArrow />
),
run: handlers.install,
pending: handlers.installing
pending: handlers.installing,
link: gitHubInstallLinkSource({ repoUrl: record.repoUrl })
}
}
}
@@ -181,7 +191,8 @@ export function cloudSetupNoticesView(
label: line.action.label,
icon: line.action.icon,
onClick: line.action.run,
...(line.action.pending ? { pending: true } : {})
...(line.action.pending ? { pending: true } : {}),
...(line.action.link ? { link: line.action.link } : {})
}
}
: {}),
@@ -13,6 +13,8 @@ import React, { useSyncExternalStore } from 'react'
import { TriangleAlert, X } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton'
import type { ExternalLinkSource } from '@/components/open-external/copy-external-link'
import { useTranslate } from '@/i18n/use-translate'
import { cn } from '@/lib/class-names'
import { BANNER_BODY_TEXT, BANNER_TONE } from '@/components/ui/banner-tone'
@@ -27,6 +29,8 @@ export type CloudToastAction = {
primary?: boolean
icon?: React.ReactNode
pending?: boolean
/** The action opens this page in the browser: its chevron offers "Copy link". */
link?: ExternalLinkSource
}
export type CloudToastLine = {
@@ -364,7 +368,19 @@ export function CloudToastCard({
<p className={cn('text-xs', BANNER_BODY_TEXT)}>{line.description}</p>
) : null}
</div>
{line.action ? (
{line.action?.link ? (
<OpenExternalSplitButton
size="xs"
variant={line.action.primary ? 'default' : 'outline'}
link={line.action.link}
onClick={() => (line.action?.pending ? undefined : line.action?.onClick())}
aria-disabled={line.action.pending || undefined}
aria-busy={line.action.pending || undefined}
>
{line.action.icon}
{line.action.label}
</OpenExternalSplitButton>
) : line.action ? (
<Button
type="button"
size="xs"
@@ -1,6 +1,9 @@
import { useCallback } from 'react'
import { Loader2 } 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 { gitHubUserConnectLinkSource } from '@/lib/orca-cloud/github-link-sources'
import { GitHubIcon } from '@/components/icons/GitHubIcon'
import {
useConfirmationDialog,
@@ -28,6 +31,8 @@ export type GitHubIdentityRowViewProps = {
state: GitHubIdentityState
disconnecting?: boolean
onConnect: () => void
/** "Copy link" beside Connect: GitHub's OAuth page, minted without opening it. */
connectLink?: ExternalLinkSource
onCancel: () => void
onDisconnect: () => void
onRetryDisconnect: () => void
@@ -38,11 +43,23 @@ export function GitHubIdentityRowView({
state,
disconnecting = false,
onConnect,
connectLink,
onCancel,
onDisconnect,
onRetryDisconnect
}: GitHubIdentityRowViewProps): React.JSX.Element | null {
const t = useTranslate()
// Connect opens GitHub in the browser; with a link source its chevron copies that page instead.
const connectButton = (label: string): React.JSX.Element =>
connectLink ? (
<OpenExternalSplitButton variant="outline" size="sm" link={connectLink} onClick={onConnect}>
{label}
</OpenExternalSplitButton>
) : (
<Button type="button" variant="outline" size="sm" onClick={onConnect}>
{label}
</Button>
)
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 = (
<Button type="button" variant="outline" size="sm" onClick={onConnect}>
{t('auto.components.settings.orcaAccount.github.tryAgain', 'Try again')}
</Button>
)
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 = (
<Button type="button" variant="outline" size="sm" onClick={onConnect}>
{t('auto.components.settings.orcaAccount.github.connect', 'Connect GitHub')}
</Button>
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()}
@@ -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<string> {
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 ? (
<Button
type="button"
<OpenExternalSplitButton
link={() => setupSignInLink(agent)}
variant="ghost"
size="sm"
onClick={() => actions.reopen(agent)}
>
{t('orcaCloudAccounts.openAgain', 'Open again')}
</Button>
</OpenExternalSplitButton>
) : null}
{cancel}
</>
@@ -238,8 +246,8 @@ export function SetupRow({
)}
actions={
<>
<Button
type="button"
<OpenExternalSplitButton
link={setup.verificationUrl}
variant="outline"
size="sm"
onClick={() => {
@@ -248,7 +256,7 @@ export function SetupRow({
}}
>
{t('orcaCloudAccounts.copyAndOpen', 'Copy code and open')}
</Button>
</OpenExternalSplitButton>
{cancel}
</>
}
@@ -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<typeof buttonGroupVariants>) {
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}
/>
)
@@ -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' ? (
<Button type="button" variant="ghost" size="xs" onClick={() => openSignInPage(view.url)}>
<OpenExternalSplitButton
link={view.url}
variant="ghost"
size="xs"
onClick={() => openSignInPage(view.url)}
>
<ExternalLink />
{t('agentSignIn.openAgain', 'Open again')}
</Button>
</OpenExternalSplitButton>
) : null}
<Button type="button" variant="ghost" size="xs" onClick={actions.cancel}>
{t('agentSignIn.cancel', 'Cancel')}
@@ -21,6 +21,9 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import type { Translate } from '@/i18n/use-translate'
import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton'
import type { ExternalLinkSource } from '@/components/open-external/copy-external-link'
import { gitHubInstallLinkSource } from '@/lib/orca-cloud/github-link-sources'
import type { VmBuildFailure } from './vm-build-failure'
export type VmBuildFailureActions = {
@@ -52,6 +55,8 @@ type Action = {
pending?: boolean
/** Outline even when first: a way around the problem, not its fix. */
secondary?: boolean
/** Opens this page in the browser: a chevron beside it offers "Copy link". */
link?: ExternalLinkSource
}
/** At most two visible actions; the rest sit in a More menu so the way forward stays obvious. */
@@ -245,7 +250,9 @@ function failureActions(
),
icon: connectingGitHub ? <Loader2 className="animate-spin" /> : <Github />,
onClick: actions.onConnectGitHub,
pending: connectingGitHub
pending: connectingGitHub,
// The same install page connectOrcaCloudGitHub opens (no repository preselected).
link: gitHubInstallLinkSource()
},
tryAgain
],
@@ -311,21 +318,37 @@ export function FailureActionRow({
const destroys = vmKept
return (
<div className="flex flex-wrap items-center gap-2">
{primary.map((action, index) => (
<Button
key={action.key}
type="button"
size="sm"
variant={index === 0 && !action.secondary ? 'default' : 'outline'}
onClick={action.pending ? undefined : action.onClick}
disabled={action.disabled}
aria-disabled={action.pending || undefined}
aria-busy={action.pending || undefined}
>
{action.icon}
{action.label}
</Button>
))}
{primary.map((action, index) =>
action.link ? (
<OpenExternalSplitButton
key={action.key}
size="sm"
variant={index === 0 && !action.secondary ? 'default' : 'outline'}
link={action.link}
onClick={() => (action.pending ? undefined : action.onClick())}
disabled={action.disabled}
aria-disabled={action.pending || undefined}
aria-busy={action.pending || undefined}
>
{action.icon}
{action.label}
</OpenExternalSplitButton>
) : (
<Button
key={action.key}
type="button"
size="sm"
variant={index === 0 && !action.secondary ? 'default' : 'outline'}
onClick={action.pending ? undefined : action.onClick}
disabled={action.disabled}
aria-disabled={action.pending || undefined}
aria-busy={action.pending || undefined}
>
{action.icon}
{action.label}
</Button>
)
)}
{more.length > 0 ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
+8 -1
View File
@@ -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"
}
}
@@ -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"
},
@@ -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)
}
}
@@ -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),
@@ -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),
@@ -42,6 +42,8 @@ function stubApi(overrides: Partial<OrcaCloudWorkspaceApi> = {}): 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' })
@@ -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<boolean> } {
): OrcaCloudRepoAccess & {
refresh: () => void
openInstall: () => Promise<boolean>
/** "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
}
}
@@ -85,6 +85,8 @@ export function createWebOrcaProfilesApi(): Partial<PreloadApi> {
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' }),
@@ -93,10 +93,17 @@ export type OrcaCloudWorkspaceApi = {
repoUrl?: string
setupSessionId?: string
}) => Promise<CloudWorkspaceResult<null>>
/** "Copy link" for connectGitHub: the same single-use GitHub install page, minted but not opened. */
gitHubInstallLink: (args?: {
repoUrl?: string
setupSessionId?: string
}) => Promise<CloudWorkspaceResult<string>>
/** Whether the signed-in person connected their own GitHub (cloud commit attribution). */
getGitHubUser: () => Promise<CloudWorkspaceResult<HostedGithubUser>>
/** Opens GitHub's OAuth page in the browser so the person connects their own GitHub identity. */
connectGitHubUser: () => Promise<CloudWorkspaceResult<null>>
/** "Copy link" for connectGitHubUser: the same GitHub OAuth page, minted but not opened. */
gitHubUserConnectLink: () => Promise<CloudWorkspaceResult<string>>
/** Forgets the person's GitHub identity; cloud commits use Orca Cloud's name again. */
disconnectGitHubUser: () => Promise<CloudWorkspaceResult<HostedGithubUser>>
/** 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',
+6
View File
@@ -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<OrcaBillingOpenResult, { status: 'opened' }>
/** 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' }