mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
+17
-18
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user