From 92142679a514873b157cec719adf71d122607b74 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 02:17:30 -0800 Subject: [PATCH 01/10] refactor(cloud): one classifyEntitlementDenial in src/shared/orca-billing.ts (DS-23); run-on access, build failure, build log, workspace errors and the phone keep their own wording --- mobile/src/cloud/cloud-entitlement-copy.ts | 35 ++++++++-------- .../cloud-workspace-errors.ts | 6 ++- .../orca-cloud-run-target-access.ts | 18 ++++---- .../worktree-creation/orca-cloud-build-log.ts | 34 +++++++-------- .../worktree-creation/vm-build-failure.ts | 27 ++++++++---- src/shared/orca-billing-denial.test.ts | 23 ++++++++++ src/shared/orca-billing.ts | 42 +++++++++++++++++++ 7 files changed, 129 insertions(+), 56 deletions(-) create mode 100644 src/shared/orca-billing-denial.test.ts diff --git a/mobile/src/cloud/cloud-entitlement-copy.ts b/mobile/src/cloud/cloud-entitlement-copy.ts index b62e90662e8..c015fbd7165 100644 --- a/mobile/src/cloud/cloud-entitlement-copy.ts +++ b/mobile/src/cloud/cloud-entitlement-copy.ts @@ -1,3 +1,7 @@ +import { + classifyEntitlementDenial, + type OrcaEntitlementDenialKind +} from '../../../src/shared/orca-billing' import type { Translate } from '../i18n/mobile-i18n' import { readCloudRole, @@ -13,25 +17,20 @@ import { CloudRequestError, type CloudControlPlane } from './cloud-control-plane type Denial = 'hours' | 'cap' | 'payment' | 'plan' | 'teamLimit' | 'memberLimit' | 'other' +// The shared classifier names the category; the phone keeps its own wording per category. +const DENIALS: Record = { + plan: 'plan', + hours: 'hours', + spendCap: 'cap', + payment: 'payment', + teamLimit: 'teamLimit', + memberLimit: 'memberLimit', + machineSize: 'other', + other: 'other' +} + function denialOf(reason: string | undefined): Denial { - switch (reason) { - case 'included_hours_exhausted': - return 'hours' - case 'overage_cap_reached': - return 'cap' - case 'payment_past_due': - case 'payment_grace_expired': - return 'payment' - case 'no_subscription': - case 'subscription_inactive': - return 'plan' - case 'concurrency_limit': - return 'teamLimit' - case 'member_concurrency_limit': - return 'memberLimit' - default: - return 'other' - } + return DENIALS[classifyEntitlementDenial(reason)] } export function isEntitlementDenied(error: unknown): error is CloudRequestError { diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-errors.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-errors.ts index f363391ec86..71e03df197d 100644 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspace-errors.ts +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-errors.ts @@ -3,6 +3,7 @@ import { translate } from '@/i18n/i18n' import type { Translate } from '@/i18n/use-translate' import { getLastBillingStatus } from '@/components/billing/billing-summary-store' import { sandboxLimitFromBilling } from '@/components/worktree-creation/vm-build-failure-copy' +import { classifyEntitlementDenial } from '../../../../shared/orca-billing' /** * Whose seat a start counts against. The per-seat running cap counts the workspace owner's seat, @@ -23,7 +24,8 @@ export function describeError( ) } if (error === 'entitlement_denied') { - if (reason === 'concurrency_limit') { + const denial = classifyEntitlementDenial(reason) + if (denial === 'teamLimit') { const limit = sandboxLimitFromBilling(getLastBillingStatus()) return limit ? t( @@ -36,7 +38,7 @@ export function describeError( "You're at your team's limit of running sandboxes. Put one to sleep or delete it, then try again." ) } - if (reason === 'member_concurrency_limit') { + if (denial === 'memberLimit') { return describeSeatLimit(owner, t) } return t( diff --git a/src/renderer/src/components/new-workspace/orca-cloud-run-target-access.ts b/src/renderer/src/components/new-workspace/orca-cloud-run-target-access.ts index e1aca15b052..fffd884bb82 100644 --- a/src/renderer/src/components/new-workspace/orca-cloud-run-target-access.ts +++ b/src/renderer/src/components/new-workspace/orca-cloud-run-target-access.ts @@ -5,6 +5,7 @@ import { cancelOrcaCloudSelectionAfterSignIn, requestOrcaCloudSelectionAfterSignIn } from '@/lib/orca-cloud-select-after-sign-in' +import { classifyEntitlementDenial } from '../../../../shared/orca-billing' /** Signing in from the row selects Orca Cloud once main offers it (the recipe list reloads). */ function signInThenSelectOrcaCloud(): void { @@ -54,19 +55,16 @@ export function toOrcaCloudRunTargetAccess( } } const { canManageBilling } = entitlement - switch (entitlement.reason) { - case 'included_hours_exhausted': + switch (classifyEntitlementDenial(entitlement.reason)) { + case 'hours': return { state: 'out-of-hours', cause: 'hours', canManageBilling } - case 'overage_cap_reached': + case 'spendCap': return { state: 'out-of-hours', cause: 'cap', canManageBilling } - case 'payment_past_due': - case 'payment_grace_expired': + case 'payment': return { state: 'payment-issue', canManageBilling } - case 'subscription_inactive': - return { state: 'no-plan', lapsed: true } - case 'no_subscription': - case null: - return { state: 'no-plan', lapsed: false } + default: + // No plan (or a reason this build doesn't know): the plan is what fixes it. + return { state: 'no-plan', lapsed: entitlement.reason === 'subscription_inactive' } } } diff --git a/src/renderer/src/components/worktree-creation/orca-cloud-build-log.ts b/src/renderer/src/components/worktree-creation/orca-cloud-build-log.ts index 3e593a14c8c..5b506859cbd 100644 --- a/src/renderer/src/components/worktree-creation/orca-cloud-build-log.ts +++ b/src/renderer/src/components/worktree-creation/orca-cloud-build-log.ts @@ -1,4 +1,5 @@ import type { Translate } from '@/i18n/use-translate' +import { classifyEntitlementDenial } from '../../../../shared/orca-billing' // The hosted provider writes `[orca-cloud] ` progress, `Orca Cloud: ` and a few // fixed transport messages. Everything else is provider internals that the plain view hides. @@ -59,51 +60,50 @@ function phaseLabel(phase: string, t: Translate): string | null { } function denyLabel(reason: string, t: Translate): string | null { - switch (reason) { - case 'no plan': - case 'no_subscription': - case 'subscription_inactive': + // Not a plan refusal, so not in the shared classifier. + if (reason === 'github_not_connected') { + return t( + 'auto.components.worktree.creation.orcaCloudBuildLog.denyGithub', + 'GitHub isn’t connected to your Orca account.' + ) + } + switch (classifyEntitlementDenial(reason)) { + case 'plan': return t( 'auto.components.worktree.creation.orcaCloudBuildLog.denyNoPlan', 'Your account has no active Team plan.' ) - case 'included_hours_exhausted': + case 'hours': return t( 'auto.components.worktree.creation.orcaCloudBuildLog.denyHours2', 'Your team is out of sandbox hours this month.' ) - case 'overage_cap_reached': + case 'spendCap': return t( 'auto.components.worktree.creation.orcaCloudBuildLog.denySpendCap', 'This month’s spend cap is reached.' ) - case 'payment_past_due': - case 'payment_grace_expired': + case 'payment': return t( 'auto.components.worktree.creation.orcaCloudBuildLog.denyPayment2', 'Payment failed.' ) - case 'concurrency_limit': + case 'teamLimit': return t( 'auto.components.worktree.creation.orcaCloudBuildLog.denyConcurrency2', 'You’re at your team’s limit of running sandboxes.' ) - case 'member_concurrency_limit': + case 'memberLimit': return t( 'auto.components.worktree.creation.orcaCloudBuildLog.denyMemberConcurrency2', 'You’re at your limit of running sandboxes.' ) - case 'github_not_connected': - return t( - 'auto.components.worktree.creation.orcaCloudBuildLog.denyGithub', - 'GitHub isn’t connected to your Orca account.' - ) - case 'machine_size_not_in_plan': + case 'machineSize': return t( 'auto.components.worktree.creation.orcaCloudBuildLog.denyMachineSize2', 'This sandbox size isn’t part of your plan.' ) - default: + case 'other': return null } } diff --git a/src/renderer/src/components/worktree-creation/vm-build-failure.ts b/src/renderer/src/components/worktree-creation/vm-build-failure.ts index db4a9bb6bef..623a60829e9 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-failure.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-failure.ts @@ -1,5 +1,9 @@ import type { EnvironmentDoctorCheck } from '../../../../shared/environment-doctor-types' import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation' +import { + classifyEntitlementDenial, + type OrcaEntitlementDenialKind +} from '../../../../shared/orca-billing' /** Account and service problems a plain-language card can name, each with one way forward. */ export type VmBuildAccountFailureKind = @@ -36,14 +40,14 @@ const ERROR_CODE_KINDS: Partial> = { // The hosted provider logs the control plane's deny reason as `Orca Cloud: `. const DENY_REASON = /^Orca Cloud: ([a-z_]+)$/m -const DENY_REASON_KINDS: Partial> = { - included_hours_exhausted: 'hoursUsed', - overage_cap_reached: 'spendCap', - payment_past_due: 'payment', - payment_grace_expired: 'payment', - concurrency_limit: 'concurrency', - member_concurrency_limit: 'memberConcurrency' -} +const DENIAL_FAILURE_KINDS: Partial> = + { + hours: 'hoursUsed', + spendCap: 'spendCap', + payment: 'payment', + teamLimit: 'concurrency', + memberLimit: 'memberConcurrency' + } const SNAPSHOT_MISSING = /\bsnapshot\b[^\n]*\bnot found\b/i const NETWORK_FAILURE = @@ -69,7 +73,12 @@ export function classifyVmBuildFailure(args: { const coded = entry.errorCode ? ERROR_CODE_KINDS[entry.errorCode] : undefined if (coded === 'plan' || coded === 'concurrency') { const reason = DENY_REASON.exec(entry.provisioningLog ?? '')?.[1] - return { kind: (reason === undefined ? undefined : DENY_REASON_KINDS[reason]) ?? coded } + return { + kind: + (reason === undefined + ? undefined + : DENIAL_FAILURE_KINDS[classifyEntitlementDenial(reason)]) ?? coded + } } if (coded) { return { kind: coded } diff --git a/src/shared/orca-billing-denial.test.ts b/src/shared/orca-billing-denial.test.ts new file mode 100644 index 00000000000..3202b13ede2 --- /dev/null +++ b/src/shared/orca-billing-denial.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { classifyEntitlementDenial } from './orca-billing' + +describe('classifyEntitlementDenial', () => { + it('maps every billing and start-gate reason to one category', () => { + expect(classifyEntitlementDenial('no_subscription')).toBe('plan') + expect(classifyEntitlementDenial('subscription_inactive')).toBe('plan') + expect(classifyEntitlementDenial('no plan')).toBe('plan') + expect(classifyEntitlementDenial('included_hours_exhausted')).toBe('hours') + expect(classifyEntitlementDenial('overage_cap_reached')).toBe('spendCap') + expect(classifyEntitlementDenial('payment_past_due')).toBe('payment') + expect(classifyEntitlementDenial('payment_grace_expired')).toBe('payment') + expect(classifyEntitlementDenial('concurrency_limit')).toBe('teamLimit') + expect(classifyEntitlementDenial('member_concurrency_limit')).toBe('memberLimit') + expect(classifyEntitlementDenial('machine_size_not_in_plan')).toBe('machineSize') + }) + + it('falls back to other for unknown or missing reasons', () => { + expect(classifyEntitlementDenial('something_new')).toBe('other') + expect(classifyEntitlementDenial(undefined)).toBe('other') + expect(classifyEntitlementDenial(null)).toBe('other') + }) +}) diff --git a/src/shared/orca-billing.ts b/src/shared/orca-billing.ts index 4c35cffabfa..b6a11e7aede 100644 --- a/src/shared/orca-billing.ts +++ b/src/shared/orca-billing.ts @@ -16,6 +16,48 @@ export type OrcaBillingDenyReason = | 'included_hours_exhausted' | 'overage_cap_reached' +/** + * What a plan refusal means, whatever surface shows it. Billing's account reasons plus the control + * plane's start-gate reasons (concurrency, machine size) map to one category; each surface keeps its + * own wording for the category. Unknown reasons (a newer service) fall to `other`. + */ +export type OrcaEntitlementDenialKind = + | 'plan' + | 'hours' + | 'spendCap' + | 'payment' + | 'teamLimit' + | 'memberLimit' + | 'machineSize' + | 'other' + +export function classifyEntitlementDenial( + reason: string | null | undefined +): OrcaEntitlementDenialKind { + // 'no plan' is the hosted provider's older log line for a missing plan. + switch (reason) { + case 'no_subscription': + case 'subscription_inactive': + case 'no plan': + return 'plan' + case 'included_hours_exhausted': + return 'hours' + case 'overage_cap_reached': + return 'spendCap' + case 'payment_past_due': + case 'payment_grace_expired': + return 'payment' + case 'concurrency_limit': + return 'teamLimit' + case 'member_concurrency_limit': + return 'memberLimit' + case 'machine_size_not_in_plan': + return 'machineSize' + default: + return 'other' + } +} + export type OrcaBillingOffers = { team: { label: string From 10a9c7e3a3b3d531368b8a4e70b0938558a4786e Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 02:19:23 -0800 Subject: [PATCH 02/10] fix(cloud): exhaustive switches over the denial classifier --- .../new-workspace/orca-cloud-run-target-access.ts | 9 +++++++-- src/shared/orca-billing.ts | 3 +++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/new-workspace/orca-cloud-run-target-access.ts b/src/renderer/src/components/new-workspace/orca-cloud-run-target-access.ts index fffd884bb82..53d7af107cf 100644 --- a/src/renderer/src/components/new-workspace/orca-cloud-run-target-access.ts +++ b/src/renderer/src/components/new-workspace/orca-cloud-run-target-access.ts @@ -62,8 +62,13 @@ export function toOrcaCloudRunTargetAccess( return { state: 'out-of-hours', cause: 'cap', canManageBilling } case 'payment': return { state: 'payment-issue', canManageBilling } - default: - // No plan (or a reason this build doesn't know): the plan is what fixes it. + // Billing's account answer never carries the start-gate reasons; anything else is no plan, + // which the plan fixes. + case 'plan': + case 'teamLimit': + case 'memberLimit': + case 'machineSize': + case 'other': return { state: 'no-plan', lapsed: entitlement.reason === 'subscription_inactive' } } } diff --git a/src/shared/orca-billing.ts b/src/shared/orca-billing.ts index b6a11e7aede..07a0249772e 100644 --- a/src/shared/orca-billing.ts +++ b/src/shared/orca-billing.ts @@ -53,6 +53,9 @@ export function classifyEntitlementDenial( return 'memberLimit' case 'machine_size_not_in_plan': return 'machineSize' + case null: + case undefined: + return 'other' default: return 'other' } From 7de9fe8f259c13e4dea3bd0e5283a2b46b9bac67 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 02:20:29 -0800 Subject: [PATCH 03/10] fix(cloud): DS-26 run failures in plain words via InlineError; DS-36 one page-level error on Orca API keys, MCP tag contrast, Routines entry on Cloud workspaces, availability probes don't cache rejections --- .../CloudWorkspacesHome.fixture.tsx | 22 +++ .../CloudWorkspacesToolbar.tsx | 20 +- .../cloud-workspaces/use-cloud-workspaces.ts | 21 ++- .../settings/cloud-api/ApiKeyRow.tsx | 99 ++++++++++ .../cloud-api/CloudApiKeysSections.tsx | 7 +- .../settings/cloud-api/CloudApiKeysView.tsx | 174 ++++++------------ .../settings/cloud-api/CloudConnectedApps.tsx | 20 +- .../cloud-api/CloudRoutinesView.fixture.tsx | 67 ++++++- .../settings/cloud-api/RoutineRunHistory.tsx | 11 +- .../settings/cloud-api/cloud-api-copy.ts | 45 +++++ .../cloud-api/cloud-api-format.test.ts | 24 ++- .../cloud-mcp/CloudMcpSyncPanel.tsx | 4 +- .../src/hooks/useOrcaCloudApiAvailability.ts | 15 +- src/renderer/src/i18n/locales/en.json | 80 ++++---- 14 files changed, 424 insertions(+), 185 deletions(-) create mode 100644 src/renderer/src/components/settings/cloud-api/ApiKeyRow.tsx diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.fixture.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.fixture.tsx index 06bffc8fc40..8630278011c 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.fixture.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.fixture.tsx @@ -66,3 +66,25 @@ export const emptyTeam = defineFixture({ render: () => , interact: () => waitFor(() => document.querySelector('section p') !== null) }) + +/** Builds with Orca Cloud routines get a Routines entry next to the list they fill. */ +export const withRoutinesEntry = defineFixture({ + width: WIDTH, + store: { orcaProfileAuthStatus: AUTH_STATUS }, + setup: () => { + const rows = teamWorkspaces() + stubCloudWorkspacesApi(() => Promise.resolve({ ok: true, value: rows })) + Object.assign(window.api, { + cloudApi: { + availability: async () => ({ status: 'ready', controlPlaneUrl: 'https://api.orca.dev' }) + } + }) + }, + render: () => , + interact: () => + waitFor( + () => + document.querySelector('[data-cloud-workspace-row]') !== null && + [...document.querySelectorAll('button')].some((b) => b.textContent === 'Routines') + ) +}) diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesToolbar.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesToolbar.tsx index 27377f59967..605130b4307 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesToolbar.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesToolbar.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Cloud, RefreshCw } from 'lucide-react' +import { CalendarClock, Cloud, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { Select, @@ -10,12 +10,20 @@ import { } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' import { SettingsSegmentedControl } from '@/components/settings/form-controls/SettingsFormControls' +import { useOrcaCloudApiAvailable } from '@/hooks/useOrcaCloudApiAvailability' import { useTranslate } from '@/i18n/use-translate' +import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import { repoLabel, type MemberDirectory } from './cloud-workspace-format' export const ANY = '__any' +function openRoutines(): void { + const state = useAppStore.getState() + state.openSettingsTarget({ pane: 'cloud-routines', repoId: null }) + state.openSettingsPage() +} + export type CloudWorkspacesFilters = { scope: 'team' | 'mine' by: string @@ -51,6 +59,8 @@ export function CloudWorkspacesToolbar({ onRefresh: () => void }): React.JSX.Element { const t = useTranslate() + // Routines start cloud workspaces on their own, so they sit next to the list they fill. + const routinesAvailable = useOrcaCloudApiAvailable() return (
@@ -68,7 +78,13 @@ export function CloudWorkspacesToolbar({ {meta} )} - + + {routinesAvailable ? ( + + ) : null} + + ) +} diff --git a/src/renderer/src/components/settings/cloud-api/CloudApiKeysSections.tsx b/src/renderer/src/components/settings/cloud-api/CloudApiKeysSections.tsx index 1f37128ced1..c217e1546be 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudApiKeysSections.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudApiKeysSections.tsx @@ -109,8 +109,11 @@ export function AccessLinks({ export function OrgSettings({ settings, - onToggle + onToggle, + blocked = false }: { + /** The page shows one load error; this section stays quiet instead of repeating it. */ + blocked?: boolean settings: CloudLoadState onToggle: (enabled: boolean) => Promise }): React.JSX.Element { @@ -158,7 +161,7 @@ export function OrgSettings({ ) : null}

- {settings.status === 'error' ? ( + {settings.status === 'error' && !blocked ? ( ) : null} {error ? : null} diff --git a/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.tsx b/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.tsx index e2498455926..9a428951663 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.tsx @@ -1,8 +1,9 @@ import { useRef, useState } from 'react' import { toast } from 'sonner' -import { KeyRound, Plus, RefreshCw, Trash2 } from 'lucide-react' +import { KeyRound, Plus, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' -import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import { InlineError } from '@/components/ui/inline-error' +import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/utils' import type { CloudApiApproval, @@ -24,12 +25,10 @@ import { CloudConfirmDialog } from './CloudConfirmDialog' import { CloudConnectedApps } from './CloudConnectedApps' import type { CloudLoadState } from './cloud-load-state' import { CreateApiKeyDialog } from './CreateApiKeyDialog' -import { formatAbsoluteTime, formatKeyPrefix, formatRelativeFromNow } from './cloud-api-format' +import { formatKeyPrefix } from './cloud-api-format' import { PersonSentence } from './PersonName' -import { ScopeSummary } from './ScopeSummary' -import { CloudApiEmptyState, CloudApiErrorNotice, CloudApiSkeletonRows } from './cloud-api-parts' - -const EXPIRY_WARNING_MS = 7 * 86_400_000 +import { CloudApiEmptyState, CloudApiSkeletonRows } from './cloud-api-parts' +import { KeyRow } from './ApiKeyRow' export type CloudApiKeysViewProps = { controlPlaneUrl: string @@ -119,8 +118,22 @@ export function CloudApiKeysView(props: CloudApiKeysViewProps): React.JSX.Elemen ) + // One failure, one message: a load error is shown once at the top, not in every section. + const pageError = keys.status === 'error' ? keys.message : null + return (
+ {pageError ? ( + + ) : null} ) : keys.status === 'error' ? ( -
- -
+

+ {t( + 'auto.components.settings.cloudApi.keys.unavailable', + 'Your keys appear here once Orca Cloud is reachable.' + )} +

) : keys.data.keys.length === 0 ? ( setApproveOpen(true)} /> - setApproveOpen(true)} - onRevoke={props.onRevokeGrant} - onRetry={props.onRefreshGrants} - initialRevokeId={initial?.revokeGrantId} - /> - - - - {props.audit ? ( - + setApproveOpen(true)} + onRevoke={props.onRevokeGrant} + onRetry={props.onRefreshGrants} + initialRevokeId={initial?.revokeGrantId} /> - ) : null} + + + + {props.audit ? ( + + ) : null} +
) } - -function KeyRow({ - apiKey, - viewer, - now, - onRevoke -}: { - apiKey: CloudApiKey - viewer: CloudApiMe | null - now: number - onRevoke: () => void -}): React.JSX.Element { - const t = useTranslate() - const locale = useIntlLocale() - const mine = viewer?.userId === apiKey.createdBy - const expiresSoon = apiKey.expiresAt !== null && apiKey.expiresAt - now < EXPIRY_WARNING_MS - const created = formatRelativeFromNow(apiKey.createdAt, now, locale) - return ( -
  • -
    -
    -

    - {apiKey.name} -

    - - {formatKeyPrefix(apiKey.prefix)} - - -
    -

    - - · - - {apiKey.lastUsedAt - ? t('auto.components.settings.cloudApi.keys.lastUsed', 'Last used {{time}}', { - time: formatRelativeFromNow(apiKey.lastUsedAt, now, locale) - }) - : t('auto.components.settings.cloudApi.keys.neverUsed', 'Never used')} - - {apiKey.expiresAt !== null ? ( - <> - · - - {apiKey.expiresAt <= now - ? t('auto.components.settings.cloudApi.keys.expired', 'Expired') - : t('auto.components.settings.cloudApi.keys.expires', 'Expires {{time}}', { - time: formatRelativeFromNow(apiKey.expiresAt, now, locale) - })} - - - ) : null} -

    -
    - -
  • - ) -} diff --git a/src/renderer/src/components/settings/cloud-api/CloudConnectedApps.tsx b/src/renderer/src/components/settings/cloud-api/CloudConnectedApps.tsx index 9a7b3ba9886..0032ed310d3 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudConnectedApps.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudConnectedApps.tsx @@ -32,7 +32,8 @@ export function CloudConnectedApps({ onApprove, onRevoke, onRetry, - initialRevokeId + initialRevokeId, + blocked = false }: { grants: CloudLoadState viewer: CloudApiMe | null @@ -41,6 +42,8 @@ export function CloudConnectedApps({ onRevoke: (grant: CloudApiGrant) => Promise onRetry: () => void initialRevokeId?: string + /** The page shows one load error; this section stays quiet instead of repeating it. */ + blocked?: boolean }): React.JSX.Element | null { const t = useTranslate() const locale = useIntlLocale() @@ -81,9 +84,18 @@ export function CloudConnectedApps({ label={t('auto.components.settings.cloudApi.apps.loading', 'Loading connected apps')} /> ) : grants.status === 'error' ? ( -
    - -
    + blocked ? ( +

    + {t( + 'auto.components.settings.cloudApi.apps.unavailable', + 'Connected apps appear here once Orca Cloud is reachable.' + )} +

    + ) : ( +
    + +
    + ) ) : grants.data.length === 0 ? ( ({ ok: true, data: RUNS }) + +/** Every failure code the control plane sends, as people read it (codes never on screen). */ +const FAILED_RUNS: RoutineRunPage = { + nextBefore: null, + runs: [ + run({ + id: 'f1', + status: 'failed', + startedAt: NOW - 1 * HOUR, + durationMs: 21_000, + error: 'start_failed:clone_failed' + }), + run({ + id: 'f2', + status: 'failed', + startedAt: NOW - 3 * HOUR, + durationMs: 412_000, + error: 'workspace_lost:suspended' + }), + run({ + id: 'f3', + status: 'failed', + startedAt: NOW - 5 * HOUR, + durationMs: 380_000, + error: 'post_failed:github_403' + }), + run({ + id: 'f4', + status: 'cancelled', + startedAt: NOW - 8 * HOUR, + durationMs: null, + error: 'queue_expired' + }), + run({ + id: 'f5', + status: 'failed', + startedAt: NOW - 12 * HOUR, + durationMs: 95_000, + error: 'agent_interrupted' + }), + run({ + id: 'f6', + status: 'cancelled', + startedAt: NOW - 20 * HOUR, + durationMs: null, + error: 'routine_changed' + }), + run({ + id: 'f7', + status: 'failed', + startedAt: NOW - 26 * HOUR, + durationMs: 4_000, + error: 'something_new' + }) + ] +} +const loadFailedRuns: LoadRoutineRuns = async () => ({ ok: true, data: FAILED_RUNS }) const loadNoRuns: LoadRoutineRuns = async () => ({ ok: true, data: { runs: [], nextBefore: null } }) const loadRunsFailing: LoadRoutineRuns = async () => ({ ok: false, error: 'network' }) @@ -311,3 +367,10 @@ export const deleteConfirm = defineFixture({ width: 820, render: () => }) + +export const runHistoryFailures = defineFixture({ + width: 820, + render: () => ( + + ) +}) diff --git a/src/renderer/src/components/settings/cloud-api/RoutineRunHistory.tsx b/src/renderer/src/components/settings/cloud-api/RoutineRunHistory.tsx index fde74e2ca53..e7e0487156c 100644 --- a/src/renderer/src/components/settings/cloud-api/RoutineRunHistory.tsx +++ b/src/renderer/src/components/settings/cloud-api/RoutineRunHistory.tsx @@ -9,7 +9,7 @@ import type { RoutineRun, RoutineRunPage } from '../../../../../shared/cloud-api/cloud-api-types' -import { cloudApiErrorCopy, routineRunTriggerLabel } from './cloud-api-copy' +import { cloudApiErrorCopy, routineRunErrorCopy, routineRunTriggerLabel } from './cloud-api-copy' import { formatAbsoluteTime, formatRelativeFromNow, formatRunDuration } from './cloud-api-format' import { CloudApiErrorNotice, RoutineRunStatusPill } from './cloud-api-parts' import { isActiveRunStatus } from './routine-run-status' @@ -309,14 +309,7 @@ function RunRow({ {run.result?.summary ? (

    {run.result.summary}

    ) : null} - {run.error ? ( -

    - - {t('auto.components.settings.cloudRoutines.runs.why', 'Why:')} - {' '} - {run.error} -

    - ) : null} + {run.error ? : null} ) } diff --git a/src/renderer/src/components/settings/cloud-api/cloud-api-copy.ts b/src/renderer/src/components/settings/cloud-api/cloud-api-copy.ts index 9cf5ce3d993..be4aef1ab5d 100644 --- a/src/renderer/src/components/settings/cloud-api/cloud-api-copy.ts +++ b/src/renderer/src/components/settings/cloud-api/cloud-api-copy.ts @@ -170,3 +170,48 @@ export function routineRunTriggerLabel( return t('auto.components.settings.cloudRoutines.runTrigger.manual', 'Manual') } } + +/** + * A run's failure in plain words. The control plane sends a code, sometimes with detail after a + * colon ("workspace_lost:suspended"); codes and detail never reach the screen. + */ +export function routineRunErrorCopy(error: string, t: Translate = translate): string { + const code = error.split(':', 1)[0]?.trim() ?? '' + if (code === 'start_failed') { + return t( + 'auto.components.settings.cloudRoutines.runError.startFailed', + 'The workspace couldn’t start. Nothing was charged.' + ) + } + if (code === 'workspace_lost') { + return t( + 'auto.components.settings.cloudRoutines.runError.workspaceLost', + 'The workspace went to sleep or was deleted before the run finished.' + ) + } + if (code === 'post_failed') { + return t( + 'auto.components.settings.cloudRoutines.runError.postFailed', + 'The run finished, but Orca couldn’t post the results.' + ) + } + if (code === 'queue_expired') { + return t( + 'auto.components.settings.cloudRoutines.runError.queueExpired', + 'The run waited too long to start and was skipped.' + ) + } + if (code.endsWith('_interrupted')) { + return t( + 'auto.components.settings.cloudRoutines.runError.interrupted', + 'Orca Cloud restarted during the run.' + ) + } + if (code === 'routine_changed') { + return t( + 'auto.components.settings.cloudRoutines.runError.routineChanged', + 'The routine changed while this run was queued.' + ) + } + return t('auto.components.settings.cloudRoutines.runError.generic', 'The run failed.') +} diff --git a/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts b/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts index 1312784f4ab..732a5a90d85 100644 --- a/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts +++ b/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { CLOUD_API_SCOPES } from '../../../../../shared/cloud-api/cloud-api-types' -import { cloudApiErrorCopy, routineRunTone } from './cloud-api-copy' +import { cloudApiErrorCopy, routineRunErrorCopy, routineRunTone } from './cloud-api-copy' import { describeRoutineTrigger, formatCountdown, @@ -83,3 +83,25 @@ describe('copy', () => { expect(routineRunTone('cancelled')).toBe('neutral') }) }) + +describe('run failure copy', () => { + it('maps control-plane codes (with detail) to plain words and never shows the code', () => { + expect(routineRunErrorCopy('start_failed:clone_failed')).toBe( + 'The workspace couldn’t start. Nothing was charged.' + ) + expect(routineRunErrorCopy('workspace_lost:suspended')).toBe( + 'The workspace went to sleep or was deleted before the run finished.' + ) + expect(routineRunErrorCopy('post_failed')).toBe( + 'The run finished, but Orca couldn’t post the results.' + ) + expect(routineRunErrorCopy('queue_expired')).toBe( + 'The run waited too long to start and was skipped.' + ) + expect(routineRunErrorCopy('agent_interrupted')).toBe('Orca Cloud restarted during the run.') + expect(routineRunErrorCopy('routine_changed')).toBe( + 'The routine changed while this run was queued.' + ) + expect(routineRunErrorCopy('totally_new:detail')).toBe('The run failed.') + }) +}) diff --git a/src/renderer/src/components/settings/runtime-environments/cloud-mcp/CloudMcpSyncPanel.tsx b/src/renderer/src/components/settings/runtime-environments/cloud-mcp/CloudMcpSyncPanel.tsx index 7344db6d2ad..ef2ecb15032 100644 --- a/src/renderer/src/components/settings/runtime-environments/cloud-mcp/CloudMcpSyncPanel.tsx +++ b/src/renderer/src/components/settings/runtime-environments/cloud-mcp/CloudMcpSyncPanel.tsx @@ -71,9 +71,7 @@ function ServerRow({ {row.name}
    {mcpSyncRowStateLabel(row, t)} - - {AGENT_LABEL[row.agent]} - + {AGENT_LABEL[row.agent]} {row.target ? ( {row.target} diff --git a/src/renderer/src/hooks/useOrcaCloudApiAvailability.ts b/src/renderer/src/hooks/useOrcaCloudApiAvailability.ts index 6428cb32313..e431371914d 100644 --- a/src/renderer/src/hooks/useOrcaCloudApiAvailability.ts +++ b/src/renderer/src/hooks/useOrcaCloudApiAvailability.ts @@ -5,6 +5,7 @@ import type { CloudApiAvailability } from '../../../shared/cloud-api/cloud-api-t // Settings navigation and the panes share it, so the sections never flicker in and out. let availability: CloudApiAvailability | null = null let probing = false +const RETRY_MS = 5_000 const listeners = new Set<() => void>() function probe(): void { @@ -17,16 +18,20 @@ function probe(): void { return } probing = true - void bridge - .availability() - .catch((): CloudApiAvailability => ({ status: 'unconfigured' })) - .then((next) => { + void bridge.availability().then( + (next) => { availability = next probing = false for (const listener of listeners) { listener() } - }) + }, + () => { + // A failed probe is not "unconfigured": retry shortly instead of hiding the pages for good. + probing = false + setTimeout(probe, RETRY_MS) + } + ) } function subscribe(listener: () => void): () => void { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a5e002c1791..11bf894ca4c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14259,7 +14259,38 @@ "needsAutoForward": "Turn on Auto-forward ports to change this.", "autoForwardDescriptionSandboxes": "Forward dev servers in sandboxes and on remote hosts to this computer." }, + "orcaCloudPredictiveWake": { + "title": "Wake sleeping workspaces early", + "description": "A sleeping cloud workspace starts waking when you point at it or come back to Orca, so it's ready sooner. If you don't open it, it goes back to sleep after the idle time.", + "group": "Orca Cloud", + "description_one": "Starts waking a sleeping sandbox when you point at its workspace or come back to Orca, so it's ready sooner. If you don't open it, it goes back to sleep after {{count}} minute idle. Early wakes count toward your sandbox hours.", + "description_other": "Starts waking a sleeping sandbox when you point at its workspace or come back to Orca, so it's ready sooner. If you don't open it, it goes back to sleep after {{count}} minutes idle. Early wakes count toward your sandbox hours." + }, "cloudApi": { + "keys": { + "createdMine": "Created {{time}}", + "createdByPerson": "Created by {{time}}", + "lastUsed": "Last used {{time}}", + "neverUsed": "Never used", + "expired": "Expired", + "expires": "Expires {{time}}", + "revokeLabel": "Revoke {{keyName}}", + "revoke": "Revoke", + "create": "Create key", + "listTitle": "Keys", + "scopeAdmin": "As a team admin you see every key on your team and can revoke any of them.", + "scopeMember": "You see the keys you created. Team admins see every key on the team.", + "scopePersonal": "Use keys with the Orca Cloud API, the orca CLI and MCP clients.", + "refresh": "Refresh Orca API keys", + "loading": "Loading Orca API keys", + "unavailable": "Your keys appear here once Orca Cloud is reachable.", + "emptyTitle": "No Orca API keys yet", + "emptyBody": "Create a key to start workspaces, message agents and run routines from scripts, CI or any MCP client.", + "listLabel": "Orca API keys", + "customScopeCount_one": "Custom · {{count}} scope", + "customScopeCount_other": "Custom · {{count}} scopes", + "scopesLabel": "{{preset}}: {{scopes}}" + }, "approve": { "title": "Approve an app", "description": "When Claude, Cursor or another MCP client opens the Orca Cloud approval page, paste a one-time code from here instead of an API key. That page shows which website the app will return to; check it before you paste.", @@ -14333,29 +14364,8 @@ "loading": "Loading activity", "empty": "Nothing has happened yet." }, - "keys": { - "create": "Create key", - "listTitle": "Keys", - "scopeAdmin": "As a team admin you see every key on your team and can revoke any of them.", - "scopeMember": "You see the keys you created. Team admins see every key on the team.", - "scopePersonal": "Use keys with the Orca Cloud API, the orca CLI and MCP clients.", - "refresh": "Refresh Orca API keys", - "loading": "Loading Orca API keys", - "emptyTitle": "No Orca API keys yet", - "emptyBody": "Create a key to start workspaces, message agents and run routines from scripts, CI or any MCP client.", - "listLabel": "Orca API keys", - "createdMine": "Created {{time}}", - "createdByPerson": "Created by {{time}}", - "lastUsed": "Last used {{time}}", - "neverUsed": "Never used", - "expired": "Expired", - "expires": "Expires {{time}}", - "revokeLabel": "Revoke {{keyName}}", - "revoke": "Revoke", - "customScopeCount_one": "Custom · {{count}} scope", - "customScopeCount_other": "Custom · {{count}} scopes", - "scopesLabel": "{{preset}}: {{scopes}}" - }, + "pageErrorDetail": "Keys, connected apps and team settings are unavailable until Orca Cloud answers.", + "retry": "Try again", "revoke": { "title": "Revoke “{{keyName}}”?", "body": "Anything using {{keyPrefix}} stops working immediately. This cannot be undone.", @@ -14370,13 +14380,13 @@ "body": "Orca API keys and routines belong to your team. Sign in to manage them.", "signIn": "Sign in to Orca" }, - "retry": "Try again", "apps": { "unnamed": "Unnamed app", "title": "Connected apps", "descriptionAdmin": "MCP clients anyone on your team approved. A connection lasts 90 days, or until you disconnect it or revoke the key that approved it.", "description": "MCP clients you approved. A connection lasts 90 days, or until you disconnect it or revoke the key that approved it.", "loading": "Loading connected apps", + "unavailable": "Connected apps appear here once Orca Cloud is reachable.", "emptyTitle": "No apps connected", "emptyBody": "Add the MCP server URL to Claude, Cursor or another client. When it asks you to approve, use a one-time code.", "listLabel": "Connected apps", @@ -14478,8 +14488,7 @@ "more": "Show older runs", "pr": "Pull request", "comment": "Comment", - "openWorkspace": "Open workspace", - "why": "Why:" + "openWorkspace": "Open workspace" }, "new": "New routine", "listTitle": "Your routines", @@ -14604,6 +14613,15 @@ "webhook": "Webhook", "manual": "Manual" }, + "runError": { + "startFailed": "The workspace couldn’t start. Nothing was charged.", + "workspaceLost": "The workspace went to sleep or was deleted before the run finished.", + "postFailed": "The run finished, but Orca couldn’t post the results.", + "queueExpired": "The run waited too long to start and was skipped.", + "interrupted": "Orca Cloud restarted during the run.", + "routineChanged": "The routine changed while this run was queued.", + "generic": "The run failed." + }, "event": { "prOpened": "PR opened", "prLabeled": "PR labeled", @@ -14621,13 +14639,6 @@ "keywordCloud": "cloud", "sectionTitle": "Routines", "sectionDescription": "Agents that run in Orca Cloud on a schedule, on GitHub events or from a webhook." - }, - "orcaCloudPredictiveWake": { - "title": "Wake sleeping workspaces early", - "description": "A sleeping cloud workspace starts waking when you point at it or come back to Orca, so it's ready sooner. If you don't open it, it goes back to sleep after the idle time.", - "group": "Orca Cloud", - "description_one": "Starts waking a sleeping sandbox when you point at its workspace or come back to Orca, so it's ready sooner. If you don't open it, it goes back to sleep after {{count}} minute idle. Early wakes count toward your sandbox hours.", - "description_other": "Starts waking a sleeping sandbox when you point at its workspace or come back to Orca, so it's ready sooner. If you don't open it, it goes back to sleep after {{count}} minutes idle. Early wakes count toward your sandbox hours." } }, "right": { @@ -20855,7 +20866,8 @@ "workspaceLinkCopied": "Workspace link copied", "you": "You", "youAreTyping": "You are typing", - "youAreViewing": "You are viewing" + "youAreViewing": "You are viewing", + "routines": "Routines" }, "orcaCloud": { "QueuedPrompt": { From 519f6f6c9b038290f2a2f7bd59bdb1ac8cb2a564 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 02:27:05 -0800 Subject: [PATCH 04/10] fix(billing): spell out price formats per DS-18 Line items: '{{seats}} x {{price}} per seat, billed yearly' / 'per seat per month' (plural keys); totals '{{total}} per year' / 'per month'; Worker plan labels '$50 per seat per month' and '$480 per seat, billed yearly'. Drops /mo, /yr, /year and /seat/month strings. --- cloud/apps/billing/src/plans.ts | 4 +-- .../apps/billing/test/billing-config.test.ts | 6 ++-- cloud/apps/billing/test/billing-flow.test.ts | 2 +- src/main/billing/billing-client.test.ts | 4 +-- .../billing/checkout/CheckoutPanel.tsx | 35 ++++++++++++------- .../billing/plan-billing-fixtures.ts | 4 +-- .../components/billing/plans/TeamPlanCard.tsx | 8 ++--- .../billing/team/TeamInviteComposer.tsx | 4 +-- .../billing/team/TeamPlanSummary.tsx | 6 ++-- .../src/i18n/en-runtime-required.json | 6 ++++ src/renderer/src/i18n/locales/en.json | 25 ++++++------- src/shared/orca-billing.ts | 2 +- 12 files changed, 61 insertions(+), 45 deletions(-) diff --git a/cloud/apps/billing/src/plans.ts b/cloud/apps/billing/src/plans.ts index 969e14cb847..e02a322e4f6 100644 --- a/cloud/apps/billing/src/plans.ts +++ b/cloud/apps/billing/src/plans.ts @@ -64,8 +64,8 @@ export type PlanCatalog = { team: TeamPlan; enterprise: EnterpriseOffer } export const DEFAULT_PLANS: PlanCatalog = { team: { label: 'Team', - monthlyPriceLabel: '$50/seat/mo', - annualPriceLabel: '$480/seat/yr', + monthlyPriceLabel: '$50 per seat per month', + annualPriceLabel: '$480 per seat, billed yearly', monthlyPriceCentsPerSeat: 5000, annualPriceCentsPerSeat: 48000, includedHoursPerSeat: 60, diff --git a/cloud/apps/billing/test/billing-config.test.ts b/cloud/apps/billing/test/billing-config.test.ts index 762173fb759..31a2e2b15a9 100644 --- a/cloud/apps/billing/test/billing-config.test.ts +++ b/cloud/apps/billing/test/billing-config.test.ts @@ -24,7 +24,7 @@ describe('config: tax and plan catalog', () => { it('takes Team numbers and labels and the Enterprise contact from BILLING_PLANS', async () => { const h = await createHarness({ BILLING_PLANS: JSON.stringify({ - team: { includedHoursPerSeat: 40, monthlyPriceLabel: '$45/seat/mo', defaultSpendCapCents: 5000 }, + team: { includedHoursPerSeat: 40, monthlyPriceLabel: '$45 per seat per month', defaultSpendCapCents: 5000 }, enterprise: { contactUrl: 'https://example.test/sales' } }) }) @@ -33,8 +33,8 @@ describe('config: tax and plan catalog', () => { expect(entitlement.body.offers).toEqual({ team: { label: 'Team', - monthlyPriceLabel: '$45/seat/mo', - annualPriceLabel: '$480/seat/yr', + monthlyPriceLabel: '$45 per seat per month', + annualPriceLabel: '$480 per seat, billed yearly', monthlyPriceCentsPerSeat: 5000, annualPriceCentsPerSeat: 48000, largeMachineWeight: 1, diff --git a/cloud/apps/billing/test/billing-flow.test.ts b/cloud/apps/billing/test/billing-flow.test.ts index 61605eb041e..baeb58d52bb 100644 --- a/cloud/apps/billing/test/billing-flow.test.ts +++ b/cloud/apps/billing/test/billing-flow.test.ts @@ -221,7 +221,7 @@ describe('entitlements, overage and usage', () => { canManageBilling: true, period: { start: PERIOD_START * 1000, end: PERIOD_END * 1000 } }) - expect(entitlement.body.offers.team).toMatchObject({ monthlyPriceLabel: '$50/seat/mo', annualPriceLabel: '$480/seat/yr' }) + expect(entitlement.body.offers.team).toMatchObject({ monthlyPriceLabel: '$50 per seat per month', annualPriceLabel: '$480 per seat, billed yearly' }) expect(entitlement.body.offers.enterprise).toMatchObject({ label: 'Enterprise', priceLabel: 'Custom' }) for (let i = 0; i < 4; i++) { diff --git a/src/main/billing/billing-client.test.ts b/src/main/billing/billing-client.test.ts index 1bf5c759c24..14d52ed8520 100644 --- a/src/main/billing/billing-client.test.ts +++ b/src/main/billing/billing-client.test.ts @@ -132,8 +132,8 @@ describe('billing client', () => { const offers = { team: { label: 'Team', - monthlyPriceLabel: '$50/seat/mo', - annualPriceLabel: '$480/seat/yr', + monthlyPriceLabel: '$50 per seat per month', + annualPriceLabel: '$480 per seat, billed yearly', includedHoursPerSeat: 60, concurrentSandboxesPerSeat: 3, idleSleepMinutes: 15, diff --git a/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx b/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx index d3d50bb8f35..955e69bda54 100644 --- a/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx +++ b/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx @@ -8,7 +8,6 @@ import type { OrcaBillingOffers } from '../../../../../shared/orca-billing' import { openFailureCopy } from '../plan-billing-copy' import { formatCents, periodTotalCents } from '../plans/plans-pricing' import type { CheckoutPhase } from './checkout-flow' -import { seatCountLabel } from '../team/team-copy' export type CheckoutPanelProps = { phase: Exclude @@ -37,6 +36,7 @@ function OrderSummary({ renews.setMonth(renews.getMonth() + 1) } const renewsLabel = new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(renews) + const seatCount = new Intl.NumberFormat(locale).format(seats) return (
    @@ -52,18 +52,27 @@ function OrderSummary({
    {perSeat === null ? null - : t( - 'auto.components.billing.checkout.seatsTimes2', - '{{value0}} × {{value1}}{{value2}}', - { - value0: seatCountLabel(seats, t), - value1: formatCents(perSeat, locale), - value2: - interval === 'year' - ? t('auto.components.billing.checkout.perYr', '/yr') - : t('auto.components.billing.checkout.perMo', '/mo') - } - )} + : interval === 'year' + ? t( + 'auto.components.billing.checkout.lineItemYearly', + '{{seats}} × {{price}} per seat, billed yearly', + { + count: seats, + seats: seatCount, + price: formatCents(perSeat, locale), + defaultValue_one: '{{seats}} × {{price}} per seat, billed yearly' + } + ) + : t( + 'auto.components.billing.checkout.lineItemMonthly', + '{{seats}} × {{price}} per seat per month', + { + count: seats, + seats: seatCount, + price: formatCents(perSeat, locale), + defaultValue_one: '{{seats}} × {{price}} per seat per month' + } + )}
    diff --git a/src/renderer/src/components/billing/plan-billing-fixtures.ts b/src/renderer/src/components/billing/plan-billing-fixtures.ts index bed4b7c4667..1dc2a62e47b 100644 --- a/src/renderer/src/components/billing/plan-billing-fixtures.ts +++ b/src/renderer/src/components/billing/plan-billing-fixtures.ts @@ -7,8 +7,8 @@ const HOUR = 3600 export const sampleOffers: OrcaBillingOffers = { team: { label: 'Team', - monthlyPriceLabel: '$50/seat/mo', - annualPriceLabel: '$480/seat/yr', + monthlyPriceLabel: '$50 per seat per month', + annualPriceLabel: '$480 per seat, billed yearly', monthlyPriceCentsPerSeat: 5000, annualPriceCentsPerSeat: 48000, largeMachineWeight: 1, diff --git a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx index 5d91a56424c..1ab9d0ecbf8 100644 --- a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx +++ b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx @@ -130,11 +130,11 @@ export function TeamPlanCard({ {total === null ? null : (

    {interval === 'year' - ? t('auto.components.billing.plans.totalYear', '{{value0}} / year', { - value0: formatCents(total, locale) + ? t('auto.components.billing.plans.totalPerYear', '{{total}} per year', { + total: formatCents(total, locale) }) - : t('auto.components.billing.plans.totalMonth', '{{value0}} / month', { - value0: formatCents(total, locale) + : t('auto.components.billing.plans.totalPerMonth', '{{total}} per month', { + total: formatCents(total, locale) })}

    )} diff --git a/src/renderer/src/components/billing/team/TeamInviteComposer.tsx b/src/renderer/src/components/billing/team/TeamInviteComposer.tsx index 7eb546947e2..9aca7ee62cc 100644 --- a/src/renderer/src/components/billing/team/TeamInviteComposer.tsx +++ b/src/renderer/src/components/billing/team/TeamInviteComposer.tsx @@ -77,8 +77,8 @@ function impactCopy( const price = formatCents(impact.perSeatPeriodCents, locale) const then = impact.interval === 'year' - ? t('auto.components.billing.team.perYear', '{{value0}}/year', { value0: price }) - : t('auto.components.billing.team.perMonth', '{{value0}}/month', { value0: price }) + ? t('auto.components.billing.team.totalPerYear', '{{total}} per year', { total: price }) + : t('auto.components.billing.team.totalPerMonth', '{{total}} per month', { total: price }) if (count === 1) { const first = candidates[0]!.email.split('@')[0] ?? candidates[0]!.email return t( diff --git a/src/renderer/src/components/billing/team/TeamPlanSummary.tsx b/src/renderer/src/components/billing/team/TeamPlanSummary.tsx index 7b77ce5bb85..ecba2386576 100644 --- a/src/renderer/src/components/billing/team/TeamPlanSummary.tsx +++ b/src/renderer/src/components/billing/team/TeamPlanSummary.tsx @@ -246,9 +246,9 @@ export function TeamUpgradeCard({ 'Hosted sandboxes for the whole team. Agents keep running when you close your laptop.' ) : t( - 'auto.components.billing.upgrade.body2', - 'Hosted sandboxes for the whole team. Agents keep running when you close your laptop. {{value0}}/seat/month.', - { value0: formatCents(monthly, locale) } + 'auto.components.billing.upgrade.body3', + 'Hosted sandboxes for the whole team. Agents keep running when you close your laptop. {{price}} per seat per month.', + { price: formatCents(monthly, locale) } )}

    {entitlement.canManageBilling ? null : ( diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index fb5e777f89e..41616a25b9b 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -205,6 +205,12 @@ } }, "billing": { + "checkout": { + "lineItemMonthly_one": "{{seats}} × {{price}} per seat per month", + "lineItemMonthly_other": "{{seats}} × {{price}} per seat per month", + "lineItemYearly_one": "{{seats}} × {{price}} per seat, billed yearly", + "lineItemYearly_other": "{{seats}} × {{price}} per seat, billed yearly" + }, "plans": { "seatsPerMember_one": "{{count}} seat, one per team member", "seatsPerMember_other": "{{count}} seats, one per team member" diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 11bf894ca4c..b4eeb0eed29 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -20519,8 +20519,6 @@ "nextInvoice": "Next invoice (est.)", "nextInvoiceOn": "{{value0}} · {{value1}}", "notAnEmail": "Not an email address", - "perMonth": "{{value0}}/month", - "perYear": "{{value0}}/year", "planEnds": "Plan ends", "rateLimited": "Too many team changes at once. Wait a minute and try again.", "readOnly": "Only owners and admins can invite people or change roles.", @@ -20579,7 +20577,9 @@ "nowOwner": "{{value0}} is now an owner", "perSeatPerMonth": "{{value0}} per seat per month", "createInvalid2": "Use letters, numbers, spaces or underscores (up to 64).", - "nameTaken": "That team name is taken. Try another." + "nameTaken": "That team name is taken. Try another.", + "totalPerMonth": "{{total}} per month", + "totalPerYear": "{{total}} per year" }, "manageBilling": "Manage billing", "comparePlans": "Compare plans", @@ -20600,8 +20600,6 @@ "loadFailed": "Could not load plans. Try again in a moment.", "currentPlan": "Current plan", "billedMonthly": "Billed monthly", - "totalYear": "{{value0}} / year", - "totalMonth": "{{value0}} / month", "checkout": "Continue to checkout", "entLead": "Everything in Team, plus:", "entHint": "Tell us about your team and where you run.", @@ -20635,7 +20633,9 @@ "perSeatPerMonth": "per seat per month", "seatsPerMember_one": "{{count}} seat, one per team member", "seatsPerMember_other": "{{count}} seats, one per team member", - "vpWakeBody2": "Idle sandboxes sleep to save hours and wake with your files as you left them." + "vpWakeBody2": "Idle sandboxes sleep to save hours and wake with your files as you left them.", + "totalPerMonth": "{{total}} per month", + "totalPerYear": "{{total}} per year" }, "capLabel2": "Monthly spend cap", "capSaved": "Saved", @@ -20653,13 +20653,10 @@ "opening": "Opening secure checkout…", "openingBody": "Stripe’s checkout opens in your browser.", "paid": "Payment confirmed", - "perMo": "/mo", - "perYr": "/yr", "planAnnual": "{{value0}} · Annual", "planMonthly": "{{value0}} · Monthly", "renewsNote": "Renews {{value0}}. Before tax. Seats follow your team.", "reopen": "Reopen checkout", - "seatsTimes2": "{{value0}} × {{value1}}{{value2}}", "subtitle": "Pay on Stripe’s secure page in your browser. Orca picks it up the moment it goes through.", "timedOut": "Still waiting for Stripe", "timedOutBody": "If you already paid, confirmation can take a minute. Otherwise reopen checkout.", @@ -20670,7 +20667,11 @@ "secured2": "Payments by Stripe · Cards, Apple Pay and Link · Bank verification", "canceledBody2": "Nothing was charged. You can choose a plan again any time.", "titleCanceled": "Checkout canceled", - "titlePaid": "Welcome to Orca Cloud" + "titlePaid": "Welcome to Orca Cloud", + "lineItemMonthly_one": "{{seats}} × {{price}} per seat per month", + "lineItemMonthly_other": "{{seats}} × {{price}} per seat per month", + "lineItemYearly_one": "{{seats}} × {{price}} per seat, billed yearly", + "lineItemYearly_other": "{{seats}} × {{price}} per seat, billed yearly" }, "health": { "ending": "Ending", @@ -20681,11 +20682,11 @@ "paymentAskAdmin": "Ask an owner or admin to update the payment method.", "upgrade": { "askOwner": "Ask an owner or admin to upgrade.", - "body2": "Hosted sandboxes for the whole team. Agents keep running when you close your laptop. {{value0}}/seat/month.", "bodyNoPrice": "Hosted sandboxes for the whole team. Agents keep running when you close your laptop.", "cta": "See plans", "menuItem": "Upgrade to Orca Cloud", - "title": "Upgrade to use Orca Cloud" + "title": "Upgrade to use Orca Cloud", + "body3": "Hosted sandboxes for the whole team. Agents keep running when you close your laptop. {{price}} per seat per month." }, "upsellLineNoPrice": "Orca Cloud runs agents in hosted sandboxes for your whole team.", "askAdmin2": "Ask an owner or admin to change the plan.", diff --git a/src/shared/orca-billing.ts b/src/shared/orca-billing.ts index 07a0249772e..23252573f91 100644 --- a/src/shared/orca-billing.ts +++ b/src/shared/orca-billing.ts @@ -64,7 +64,7 @@ export function classifyEntitlementDenial( export type OrcaBillingOffers = { team: { label: string - /** Display only, e.g. "$50/seat/mo"; Stripe charges the configured price. */ + /** Display only, e.g. "$50 per seat per month"; Stripe charges the configured price. */ monthlyPriceLabel: string annualPriceLabel: string /** Per-seat prices in cents for totals and the annual saving; null from older services. */ From 16d2a7aebf0c5a496c2c527428cc7d71a6421fa6 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 02:27:26 -0800 Subject: [PATCH 05/10] =?UTF-8?q?fix(cloud):=20DS-26=20follow-up=20?= =?UTF-8?q?=E2=80=94=20run=20failure=20reasons=20as=20one=20quiet=20line?= =?UTF-8?q?=20(neutral=20for=20skipped/superseded/workspace=20gone),=20cap?= =?UTF-8?q?ped=20availability=20backoff=20that=20stops=20without=20subscri?= =?UTF-8?q?bers,=20"Couldn't=20reach=20Orca=20Cloud."?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cloud-workspaces/use-cloud-workspaces.ts | 19 ++++-- .../cloud-api/CloudApiKeysView.fixture.tsx | 6 +- .../cloud-api/CloudRoutinesView.fixture.tsx | 2 +- .../settings/cloud-api/RoutineRunHistory.tsx | 15 ++++- .../settings/cloud-api/cloud-api-copy.ts | 65 +++++++++++++------ .../cloud-api/cloud-api-format.test.ts | 26 +++++++- .../src/components/ui/inline-error.tsx | 27 +++++++- .../src/hooks/useOrcaCloudApiAvailability.ts | 18 +++-- src/renderer/src/i18n/locales/en.json | 4 +- .../src/lib/availability-retry.test.ts | 10 +++ src/renderer/src/lib/availability-retry.ts | 9 +++ 11 files changed, 161 insertions(+), 40 deletions(-) create mode 100644 src/renderer/src/lib/availability-retry.test.ts create mode 100644 src/renderer/src/lib/availability-retry.ts diff --git a/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.ts b/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.ts index 86b75504c4c..b17212dbaf8 100644 --- a/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.ts +++ b/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.ts @@ -3,6 +3,7 @@ import type { CloudWorkspace, CloudWorkspaceListQuery } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' +import { availabilityRetryDelay } from '@/lib/availability-retry' import { installWindowVisibilityTimeoutPoller } from '@/lib/window-visibility-timeout-poller' /** Presence is live; the list re-reads on this beat while visible (and on focus). */ @@ -27,19 +28,29 @@ function setAvailable(value: boolean): void { } } -const AVAILABILITY_RETRY_MS = 5_000 +let failedProbes = 0 +let retryTimer: ReturnType | null = null export function cloudWorkspacesAvailable(): Promise { availability ??= (window.api.cloudWorkspaces?.available() ?? Promise.resolve(false)).then( (value) => { + failedProbes = 0 setAvailable(value) return value }, () => { - // A failed probe is not an answer: forget it and ask again shortly, so one transient - // error doesn't hide Orca Cloud until the app restarts. + // A failed probe is not an answer: forget it and ask again with capped backoff, so one + // transient error doesn't hide Orca Cloud until restart. Stop while nobody is listening; + // the next subscriber asks again. availability = null - setTimeout(() => void cloudWorkspacesAvailable(), AVAILABILITY_RETRY_MS) + if (!retryTimer) { + retryTimer = setTimeout(() => { + retryTimer = null + if (availabilityListeners.size > 0) { + void cloudWorkspacesAvailable() + } + }, availabilityRetryDelay(failedProbes++)) + } return false } ) diff --git a/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.fixture.tsx b/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.fixture.tsx index bd0a0b095b3..a31156a5809 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.fixture.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.fixture.tsx @@ -192,10 +192,10 @@ export const error = defineFixture({ {...base} keys={{ status: 'error', - message: 'Could not reach Orca Cloud. Check your connection and try again.' + message: 'Couldn’t reach Orca Cloud. Check your connection and try again.' }} - settings={{ status: 'error', message: 'Could not reach Orca Cloud.' }} - grants={{ status: 'error', message: 'Could not reach Orca Cloud.' }} + settings={{ status: 'error', message: 'Couldn’t reach Orca Cloud.' }} + grants={{ status: 'error', message: 'Couldn’t reach Orca Cloud.' }} audit={null} /> ) diff --git a/src/renderer/src/components/settings/cloud-api/CloudRoutinesView.fixture.tsx b/src/renderer/src/components/settings/cloud-api/CloudRoutinesView.fixture.tsx index 403ddf350c8..6bb25768433 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudRoutinesView.fixture.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudRoutinesView.fixture.tsx @@ -262,7 +262,7 @@ export const error = defineFixture({ routines={{ status: 'error', code: 'network', - message: 'Could not reach Orca Cloud. Check your connection and try again.' + message: 'Couldn’t reach Orca Cloud. Check your connection and try again.' }} /> ) diff --git a/src/renderer/src/components/settings/cloud-api/RoutineRunHistory.tsx b/src/renderer/src/components/settings/cloud-api/RoutineRunHistory.tsx index e7e0487156c..21d2e8a2bbf 100644 --- a/src/renderer/src/components/settings/cloud-api/RoutineRunHistory.tsx +++ b/src/renderer/src/components/settings/cloud-api/RoutineRunHistory.tsx @@ -9,7 +9,11 @@ import type { RoutineRun, RoutineRunPage } from '../../../../../shared/cloud-api/cloud-api-types' -import { cloudApiErrorCopy, routineRunErrorCopy, routineRunTriggerLabel } from './cloud-api-copy' +import { + cloudApiErrorCopy, + describeRoutineRunError, + routineRunTriggerLabel +} from './cloud-api-copy' import { formatAbsoluteTime, formatRelativeFromNow, formatRunDuration } from './cloud-api-format' import { CloudApiErrorNotice, RoutineRunStatusPill } from './cloud-api-parts' import { isActiveRunStatus } from './routine-run-status' @@ -309,7 +313,7 @@ function RunRow({ {run.result?.summary ? (

    {run.result.summary}

    ) : null} - {run.error ? : null} + {run.error ? : null} ) } @@ -334,3 +338,10 @@ function RunAction({ ) } + +/** Why a run didn't succeed: one quiet line under the row; red icon only for real failures. */ +function RunFailure({ error }: { error: string }): React.JSX.Element { + const t = useTranslate() + const { text, tone } = describeRoutineRunError(error, t) + return +} diff --git a/src/renderer/src/components/settings/cloud-api/cloud-api-copy.ts b/src/renderer/src/components/settings/cloud-api/cloud-api-copy.ts index be4aef1ab5d..a0957e6f7da 100644 --- a/src/renderer/src/components/settings/cloud-api/cloud-api-copy.ts +++ b/src/renderer/src/components/settings/cloud-api/cloud-api-copy.ts @@ -71,7 +71,7 @@ export function cloudApiErrorCopy( case 'network': return t( 'auto.components.settings.cloudApi.error.network', - 'Couldn’t reach Orca Cloud, so nothing was changed. Check your connection and try again.' + 'Couldn’t reach Orca Cloud. Check your connection and try again.' ) case 'invalid-response': return t( @@ -176,42 +176,67 @@ export function routineRunTriggerLabel( * colon ("workspace_lost:suspended"); codes and detail never reach the screen. */ export function routineRunErrorCopy(error: string, t: Translate = translate): string { + return describeRoutineRunError(error, t).text +} + +/** + * Copy plus tone: things that went wrong (couldn't start, couldn't post, interrupted, unknown) are + * errors; outcomes that are expected bookkeeping (skipped, superseded, workspace gone) are neutral. + */ +export function describeRoutineRunError( + error: string, + t: Translate = translate +): { text: string; tone: 'error' | 'neutral' } { + const neutral = (text: string) => ({ text, tone: 'neutral' as const }) + const failure = (text: string) => ({ text, tone: 'error' as const }) const code = error.split(':', 1)[0]?.trim() ?? '' if (code === 'start_failed') { - return t( - 'auto.components.settings.cloudRoutines.runError.startFailed', - 'The workspace couldn’t start. Nothing was charged.' + return failure( + t( + 'auto.components.settings.cloudRoutines.runError.startFailed', + 'The workspace couldn’t start, so nothing was charged.' + ) ) } if (code === 'workspace_lost') { - return t( - 'auto.components.settings.cloudRoutines.runError.workspaceLost', - 'The workspace went to sleep or was deleted before the run finished.' + return neutral( + t( + 'auto.components.settings.cloudRoutines.runError.workspaceLost', + 'The workspace went to sleep or was deleted before the run finished.' + ) ) } if (code === 'post_failed') { - return t( - 'auto.components.settings.cloudRoutines.runError.postFailed', - 'The run finished, but Orca couldn’t post the results.' + return failure( + t( + 'auto.components.settings.cloudRoutines.runError.postFailed', + 'The run finished, but Orca couldn’t post the results.' + ) ) } if (code === 'queue_expired') { - return t( - 'auto.components.settings.cloudRoutines.runError.queueExpired', - 'The run waited too long to start and was skipped.' + return neutral( + t( + 'auto.components.settings.cloudRoutines.runError.queueExpired', + 'The run waited too long to start and was skipped.' + ) ) } if (code.endsWith('_interrupted')) { - return t( - 'auto.components.settings.cloudRoutines.runError.interrupted', - 'Orca Cloud restarted during the run.' + return failure( + t( + 'auto.components.settings.cloudRoutines.runError.interrupted', + 'Orca Cloud restarted during the run.' + ) ) } if (code === 'routine_changed') { - return t( - 'auto.components.settings.cloudRoutines.runError.routineChanged', - 'The routine changed while this run was queued.' + return neutral( + t( + 'auto.components.settings.cloudRoutines.runError.routineChanged', + 'The routine changed while this run was queued.' + ) ) } - return t('auto.components.settings.cloudRoutines.runError.generic', 'The run failed.') + return failure(t('auto.components.settings.cloudRoutines.runError.generic', 'The run failed.')) } diff --git a/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts b/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts index 732a5a90d85..38137b52967 100644 --- a/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts +++ b/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import { CLOUD_API_SCOPES } from '../../../../../shared/cloud-api/cloud-api-types' -import { cloudApiErrorCopy, routineRunErrorCopy, routineRunTone } from './cloud-api-copy' +import { + cloudApiErrorCopy, + describeRoutineRunError, + routineRunErrorCopy, + routineRunTone +} from './cloud-api-copy' import { describeRoutineTrigger, formatCountdown, @@ -87,7 +92,7 @@ describe('copy', () => { describe('run failure copy', () => { it('maps control-plane codes (with detail) to plain words and never shows the code', () => { expect(routineRunErrorCopy('start_failed:clone_failed')).toBe( - 'The workspace couldn’t start. Nothing was charged.' + 'The workspace couldn’t start, so nothing was charged.' ) expect(routineRunErrorCopy('workspace_lost:suspended')).toBe( 'The workspace went to sleep or was deleted before the run finished.' @@ -105,3 +110,20 @@ describe('run failure copy', () => { expect(routineRunErrorCopy('totally_new:detail')).toBe('The run failed.') }) }) + +describe('run failure tone', () => { + it('keeps red for real failures and neutral for expected outcomes', () => { + const tone = (code: string) => describeRoutineRunError(code).tone + expect(['start_failed', 'post_failed', 'agent_interrupted', 'mystery'].map(tone)).toEqual([ + 'error', + 'error', + 'error', + 'error' + ]) + expect(['queue_expired', 'routine_changed', 'workspace_lost:suspended'].map(tone)).toEqual([ + 'neutral', + 'neutral', + 'neutral' + ]) + }) +}) diff --git a/src/renderer/src/components/ui/inline-error.tsx b/src/renderer/src/components/ui/inline-error.tsx index ee0b67ab366..9a38d8dc069 100644 --- a/src/renderer/src/components/ui/inline-error.tsx +++ b/src/renderer/src/components/ui/inline-error.tsx @@ -17,8 +17,10 @@ type InlineErrorProps = { /** 'compact' for narrow panels (sidebar sections): tighter padding, xs text. */ density?: 'default' | 'compact' /** 'toast' leaves room for the toast's own dismiss button and puts the action under the text. - * 'strip-top' / 'strip-bottom' run edge to edge at the top or bottom of a framed panel. */ - layout?: 'inline' | 'toast' | 'strip-top' | 'strip-bottom' + * 'strip-top' / 'strip-bottom' run edge to edge at the top or bottom of a framed panel. + * 'line' is one muted line with no box, for a reason under a row in a dense list; it is a note + * (not announced) because it describes something that already happened. */ + layout?: 'inline' | 'toast' | 'strip-top' | 'strip-bottom' | 'line' /** A next step other than retry (e.g. "Sign in"), rendered inside the box after the text. * Pass an outline xs Button with a short verb-first label; never a primary button. */ action?: React.ReactNode @@ -43,6 +45,27 @@ function InlineError({ className }: InlineErrorProps): React.JSX.Element { const Icon = tone === 'error' ? CircleAlert : Info + if (layout === 'line') { + return ( +

    + + + {title} + {detail ? {detail} : null} + +

    + ) + } return (
    | null = null const listeners = new Set<() => void>() function probe(): void { - if (probing || availability) { + if (probing || availability || retryTimer) { return } const bridge = typeof window === 'undefined' ? undefined : window.api?.cloudApi @@ -22,14 +24,22 @@ function probe(): void { (next) => { availability = next probing = false + failedAttempts = 0 for (const listener of listeners) { listener() } }, () => { - // A failed probe is not "unconfigured": retry shortly instead of hiding the pages for good. + // A failed probe is not "unconfigured": retry with capped backoff, and only while something + // is subscribed (a new subscriber probes again on its own). probing = false - setTimeout(probe, RETRY_MS) + const delay = availabilityRetryDelay(failedAttempts++) + retryTimer = setTimeout(() => { + retryTimer = null + if (listeners.size > 0) { + probe() + } + }, delay) } ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 11bf894ca4c..fdc6d96f918 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14434,7 +14434,7 @@ "invalid": "Orca Cloud rejected that request. Check the fields and try again.", "rateLimitedFor": "Too many requests. Try again in {{seconds}} seconds.", "rateLimited": "Too many requests. Try again in a minute.", - "network": "Couldn’t reach Orca Cloud, so nothing was changed. Check your connection and try again.", + "network": "Couldn’t reach Orca Cloud. Check your connection and try again.", "invalidResponse": "Orca Cloud sent an answer this version of Orca does not understand. Update Orca and try again.", "server": "Orca Cloud had a problem and nothing was changed. Try again in a moment." }, @@ -14614,7 +14614,7 @@ "manual": "Manual" }, "runError": { - "startFailed": "The workspace couldn’t start. Nothing was charged.", + "startFailed": "The workspace couldn’t start, so nothing was charged.", "workspaceLost": "The workspace went to sleep or was deleted before the run finished.", "postFailed": "The run finished, but Orca couldn’t post the results.", "queueExpired": "The run waited too long to start and was skipped.", diff --git a/src/renderer/src/lib/availability-retry.test.ts b/src/renderer/src/lib/availability-retry.test.ts new file mode 100644 index 00000000000..f8ff6bebce2 --- /dev/null +++ b/src/renderer/src/lib/availability-retry.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { availabilityRetryDelay } from './availability-retry' + +describe('availabilityRetryDelay', () => { + it('backs off 5s, 10s, 30s and then holds at 60s', () => { + expect([0, 1, 2, 3, 4, 50].map(availabilityRetryDelay)).toEqual([ + 5_000, 10_000, 30_000, 60_000, 60_000, 60_000 + ]) + }) +}) diff --git a/src/renderer/src/lib/availability-retry.ts b/src/renderer/src/lib/availability-retry.ts new file mode 100644 index 00000000000..0725e685a8b --- /dev/null +++ b/src/renderer/src/lib/availability-retry.ts @@ -0,0 +1,9 @@ +// Capped backoff for "is this feature available here?" probes that failed (not answered "no"). +// A probe that always rejects (missing handler, version skew) settles at one try a minute, and +// callers stop retrying entirely while nothing on screen is waiting for the answer. +const RETRY_STEPS_MS = [5_000, 10_000, 30_000, 60_000] as const + +/** Delay before retry number `attempt` (0-based): 5s, 10s, 30s, then 60s from then on. */ +export function availabilityRetryDelay(attempt: number): number { + return RETRY_STEPS_MS[Math.min(Math.max(0, attempt), RETRY_STEPS_MS.length - 1)]! +} From c689f1eddb15bccda91a9314d26abecea49e3153 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 02:28:59 -0800 Subject: [PATCH 06/10] fix(cloud): reload billing, secrets, API keys and workspaces on team switch (DS-17) Settings and Home kept showing the previous team's plan, secrets, keys and workspaces after a team switch while saves went to the new team, and a billing read from before the switch could land in local state. - useOrcaAccountKey() gives `${userId}:${activeOrgId}`; Plan & billing, Cloud secrets, Cloud environment and Orca API keys remount on it. - useOrcaBillingStatus re-reads on a switch, clears the old plan, and drops (and re-reads) an answer whose billing generation moved. - useCloudWorkspaces keys its read by account, hides another team's list instead of dimming it; Home resets its filters on a switch. - Hook tests cover the stale response being dropped and the new team read. --- .../billing/use-orca-billing-status.test.tsx | 92 +++++++++++++++++++ .../billing/use-orca-billing-status.ts | 30 ++++-- .../cloud-workspaces/CloudWorkspacesHome.tsx | 11 ++- .../use-cloud-workspaces.test.tsx | 75 +++++++++++++++ .../cloud-workspaces/use-cloud-workspaces.ts | 28 +++++- .../accounts/orca/OrcaAccountSettingsPane.tsx | 10 +- .../settings/cloud-api/CloudApiKeysPane.tsx | 6 ++ .../src/hooks/use-orca-account-key.ts | 12 +++ 8 files changed, 249 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/components/billing/use-orca-billing-status.test.tsx create mode 100644 src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.test.tsx create mode 100644 src/renderer/src/hooks/use-orca-account-key.ts diff --git a/src/renderer/src/components/billing/use-orca-billing-status.test.tsx b/src/renderer/src/components/billing/use-orca-billing-status.test.tsx new file mode 100644 index 00000000000..c9ac1d5ae41 --- /dev/null +++ b/src/renderer/src/components/billing/use-orca-billing-status.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcaBillingStatusResult } from '../../../../shared/orca-billing' + +let accountKey = 'user-1:team-a' +vi.mock('@/hooks/use-orca-account-key', () => ({ useOrcaAccountKey: () => accountKey })) + +import { getLastBillingStatus, resetBillingSummary } from './billing-summary-store' +import { sampleTeam } from './plan-billing-fixtures' +import { useOrcaBillingStatus } from './use-orca-billing-status' + +const teamA: OrcaBillingStatusResult = { status: 'ok', entitlement: { ...sampleTeam } } +const teamB: OrcaBillingStatusResult = { + status: 'ok', + entitlement: { ...sampleTeam, plan: null, planLabel: null, status: 'none' } +} + +type Pending = { resolve: (result: OrcaBillingStatusResult) => void } +let pending: Pending[] = [] + +beforeEach(() => { + accountKey = 'user-1:team-a' + pending = [] + resetBillingSummary() + vi.stubGlobal('api', { + orcaProfiles: { + billingStatus: vi.fn( + () => + new Promise((resolve) => { + pending.push({ resolve }) + }) + ) + } + }) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +/** What the auth subscriber does on a team switch: bump the generation, then re-render. */ +function switchTeam(rerender: () => void, next: string): void { + act(() => { + resetBillingSummary() + accountKey = next + rerender() + }) +} + +describe('useOrcaBillingStatus on a team switch', () => { + it('drops the previous team plan and reads the new team', async () => { + const { result, rerender } = renderHook(() => useOrcaBillingStatus(true)) + await act(async () => pending[0].resolve(teamA)) + expect(result.current.result).toEqual(teamA) + + switchTeam(rerender, 'user-1:team-b') + expect(result.current).toMatchObject({ loading: true, result: null }) + expect(pending).toHaveLength(2) + + await act(async () => pending[1].resolve(teamB)) + expect(result.current).toMatchObject({ loading: false, result: teamB }) + expect(getLastBillingStatus()).toEqual(teamB) + }) + + it('never shows or records an answer that started before the switch', async () => { + const { result, rerender } = renderHook(() => useOrcaBillingStatus(true)) + expect(pending).toHaveLength(1) + + switchTeam(rerender, 'user-1:team-b') + await act(async () => pending[0].resolve(teamA)) + expect(result.current).toMatchObject({ loading: true, result: null }) + expect(getLastBillingStatus()).toBeNull() + + await act(async () => pending[1].resolve(teamB)) + expect(result.current).toMatchObject({ loading: false, result: teamB }) + }) + + it('re-reads when the generation moved while a read was in flight', async () => { + const { result } = renderHook(() => useOrcaBillingStatus(true)) + // The account changed under the hook (e.g. reconnect) before its key re-rendered. + act(() => resetBillingSummary()) + await act(async () => pending[0].resolve(teamA)) + expect(result.current.result).toBeNull() + expect(getLastBillingStatus()).toBeNull() + expect(pending).toHaveLength(2) + + await act(async () => pending[1].resolve(teamB)) + expect(result.current).toMatchObject({ loading: false, result: teamB }) + }) +}) diff --git a/src/renderer/src/components/billing/use-orca-billing-status.ts b/src/renderer/src/components/billing/use-orca-billing-status.ts index 41737882049..c3421c097ae 100644 --- a/src/renderer/src/components/billing/use-orca-billing-status.ts +++ b/src/renderer/src/components/billing/use-orca-billing-status.ts @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useOrcaAccountKey } from '@/hooks/use-orca-account-key' import type { OrcaBillingStatusResult } from '../../../../shared/orca-billing' import { getBillingGeneration, @@ -22,7 +23,9 @@ function readBillingStatus(): Promise { /** * Reads the billing status while `enabled`, again on `refresh()`, and whenever the window regains - * focus (the user comes back from Checkout or the Portal in their browser). + * focus (the user comes back from Checkout or the Portal in their browser). A team or account + * switch drops the previous team's status and reads the new one; an answer that started before + * the switch is never shown or recorded. */ export function useOrcaBillingStatus( enabled: boolean @@ -33,6 +36,8 @@ export function useOrcaBillingStatus( })) const [reloadKey, setReloadKey] = useState(0) const refresh = useCallback(() => setReloadKey((key) => key + 1), []) + const accountKey = useOrcaAccountKey() + const loadedAccount = useRef(accountKey) useEffect(() => { if (!enabled) { @@ -40,16 +45,27 @@ export function useOrcaBillingStatus( } let disposed = false let latest = 0 + const switched = loadedAccount.current !== accountKey + loadedAccount.current = accountKey const load = (): void => { const request = ++latest const startedGeneration = getBillingGeneration() - setState((previous) => ({ ...previous, loading: true })) + // The previous team's plan must not stay on screen while the new team's loads. + setState((previous) => + switched && request === 1 ? { loading: true, result: null } : { ...previous, loading: true } + ) void readBillingStatus().then((result) => { // A newer request, or unmounting, supersedes this one. - if (!disposed && request === latest) { - recordBillingStatusIfCurrent(startedGeneration, result) - setState({ loading: false, result }) + if (disposed || request !== latest) { + return } + // The account or team changed while this was in flight: the answer is for the old one. + if (getBillingGeneration() !== startedGeneration) { + load() + return + } + recordBillingStatusIfCurrent(startedGeneration, result) + setState({ loading: false, result }) }) } load() @@ -58,7 +74,7 @@ export function useOrcaBillingStatus( disposed = true window.removeEventListener('focus', load) } - }, [enabled, reloadKey]) + }, [enabled, reloadKey, accountKey]) return { ...state, refresh } } diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx index 7fda018ed6a..23fa0d1a19b 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx @@ -28,6 +28,7 @@ import { openCloudWorkspace } from './open-cloud-workspace' import { useCloudWorkspaces, useCloudWorkspacesAvailable } from './use-cloud-workspaces' +import { useOrcaAccountKey } from '@/hooks/use-orca-account-key' const INITIAL_FILTERS: CloudWorkspacesFilters = { scope: 'team', @@ -80,7 +81,14 @@ export function CloudWorkspacesHome({ const available = useCloudWorkspacesAvailable() const orgId = cloud?.activeOrgId const selfId = cloud?.userId ?? null + const account = useOrcaAccountKey() const [filters, setFilters] = useState(INITIAL_FILTERS) + // Owner and repo filters name the previous team's people and repos; a team switch starts over. + const [filtersAccount, setFiltersAccount] = useState(account) + if (filtersAccount !== account) { + setFiltersAccount(account) + setFilters(INITIAL_FILTERS) + } const [opening, setOpening] = useState(null) const [now, setNow] = useState(() => Date.now()) const [sectionFor, setSectionFor] = useState(null) @@ -89,7 +97,8 @@ export function CloudWorkspacesHome({ const scope = orgId ? filters.scope : 'mine' const { state, stale, reading, refresh, replace } = useCloudWorkspaces( { scope, archived: filters.archived ? 'only' : 'exclude' }, - Boolean(cloud) + Boolean(cloud), + account ) // Relative "last active" times tick while the window is visible; hidden windows skip the work. diff --git a/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.test.tsx b/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.test.tsx new file mode 100644 index 00000000000..bea0ebb3dc8 --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + CloudWorkspace, + CloudWorkspaceListQuery +} from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' +import { workspace } from './cloud-workspaces.test-fixture' +import { useCloudWorkspaces } from './use-cloud-workspaces' + +type ListResult = { ok: true; value: CloudWorkspace[] } +let pending: ((result: ListResult) => void)[] = [] +const QUERY: CloudWorkspaceListQuery = { scope: 'team', archived: 'exclude' } + +beforeEach(() => { + pending = [] + vi.stubGlobal('api', { + cloudWorkspaces: { + available: () => Promise.resolve(true), + list: vi.fn( + () => + new Promise((resolve) => { + pending.push(resolve) + }) + ) + } + }) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +const teamA = [workspace({ id: 'ws-a' })] +const teamB = [workspace({ id: 'ws-b' })] + +function render() { + return renderHook(({ account }) => useCloudWorkspaces(QUERY, true, account), { + initialProps: { account: 'user-1:team-a' } + }) +} + +describe('useCloudWorkspaces on a team switch', () => { + it('hides the previous team list and reads the new team at once', async () => { + const { result, rerender } = render() + await act(async () => {}) + await act(async () => pending[0]({ ok: true, value: teamA })) + expect(result.current.state).toMatchObject({ status: 'ready', workspaces: teamA }) + + rerender({ account: 'user-1:team-b' }) + // Not the old team's rows dimmed as "stale": nothing of team A is shown. + expect(result.current.state).toEqual({ status: 'loading' }) + expect(result.current.stale).toBe(false) + await act(async () => {}) + expect(pending).toHaveLength(2) + + await act(async () => pending[1]({ ok: true, value: teamB })) + expect(result.current.state).toMatchObject({ status: 'ready', workspaces: teamB }) + }) + + it('drops a list read for the previous team that lands after the switch', async () => { + const { result, rerender } = render() + await act(async () => {}) + expect(pending).toHaveLength(1) + + rerender({ account: 'user-1:team-b' }) + await act(async () => {}) + await act(async () => pending[0]({ ok: true, value: teamA })) + expect(result.current.state).toEqual({ status: 'loading' }) + + await act(async () => pending[1]({ ok: true, value: teamB })) + expect(result.current.state).toMatchObject({ status: 'ready', workspaces: teamB }) + }) +}) diff --git a/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.ts b/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.ts index 86b75504c4c..1e76ace8e54 100644 --- a/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.ts +++ b/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.ts @@ -58,11 +58,24 @@ export function useCloudWorkspacesAvailable(): boolean | null { ) } -export function useCloudWorkspaces(query: CloudWorkspaceListQuery, enabled: boolean) { +/** + * `account` is the signed-in `${userId}:${activeOrgId}`: it is part of the read's key, so a team + * switch re-reads at once and a list read for the previous team is never shown or kept. + */ +function accountOf(key: string): string { + const parsed: { account: string } = JSON.parse(key) + return parsed.account +} + +export function useCloudWorkspaces( + query: CloudWorkspaceListQuery, + enabled: boolean, + account: string +) { const [state, setState] = useState({ status: 'loading' }) const [reading, setReading] = useState(false) const [reloadKey, setReloadKey] = useState(0) - const key = JSON.stringify(query) + const key = JSON.stringify({ account, query }) const keyRef = useRef(key) keyRef.current = key const refresh = useCallback(() => setReloadKey((k) => k + 1), []) @@ -86,7 +99,8 @@ export function useCloudWorkspaces(query: CloudWorkspaceListQuery, enabled: bool return } setReading(true) - const listQuery: CloudWorkspaceListQuery = JSON.parse(key) + const parsed: { query: CloudWorkspaceListQuery } = JSON.parse(key) + const listQuery = parsed.query let result: Awaited> try { result = await window.api.cloudWorkspaces.list(listQuery) @@ -137,6 +151,10 @@ export function useCloudWorkspaces(query: CloudWorkspaceListQuery, enabled: bool }, []) // A list or error read for an earlier filter is shown dimmed and busy until this one lands. - const stale = (state.status === 'ready' || state.status === 'error') && state.key !== key - return { state, stale, reading: reading && enabled, refresh, replace } + // One read for another team is not shown at all: those are not this team's workspaces. + const settled = state.status === 'ready' || state.status === 'error' + const otherAccount = settled && accountOf(state.key) !== account + const shown: CloudWorkspacesState = otherAccount ? { status: 'loading' } : state + const stale = settled && !otherAccount && state.key !== key + return { state: shown, stale, reading: reading && enabled, refresh, replace } } diff --git a/src/renderer/src/components/settings/accounts/orca/OrcaAccountSettingsPane.tsx b/src/renderer/src/components/settings/accounts/orca/OrcaAccountSettingsPane.tsx index 37846fb52b9..2f52d8e2a91 100644 --- a/src/renderer/src/components/settings/accounts/orca/OrcaAccountSettingsPane.tsx +++ b/src/renderer/src/components/settings/accounts/orca/OrcaAccountSettingsPane.tsx @@ -4,6 +4,7 @@ import { BookOpen, Check, CircleUserRound, Files, Smartphone } from 'lucide-reac import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-status-refresh' +import { useOrcaAccountKey } from '@/hooks/use-orca-account-key' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { useTranslate, type Translate } from '@/i18n/use-translate' @@ -75,6 +76,8 @@ export function OrcaAccountSettingsPane(): React.JSX.Element { const [signingOut, setSigningOut] = useState(false) const connected = authStatus?.state === 'connected' const canConnect = authStatus?.configured === true + // Team-scoped sections remount on a team switch so none of them shows the previous team's data. + const accountKey = useOrcaAccountKey() useOrcaProfileAuthStatusRefresh() @@ -134,19 +137,22 @@ export function OrcaAccountSettingsPane(): React.JSX.Element { {connected ? null : }
    - {connected ? : null} + {connected ? : null} {connected && authStatus?.capabilities?.flags?.billing ? ( ) : null} - {connected ? : null} + {connected ? ( + + ) : null} {connected && authStatus?.capabilities?.flags?.sandbox ? ( ) : null} {connected ? ( diff --git a/src/renderer/src/components/settings/cloud-api/CloudApiKeysPane.tsx b/src/renderer/src/components/settings/cloud-api/CloudApiKeysPane.tsx index b49d8efe499..64ef2dda3bb 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudApiKeysPane.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudApiKeysPane.tsx @@ -13,8 +13,14 @@ import { CloudApiSignedOutNotice } from './CloudApiSignedOutNotice' import { cloudApiErrorCopy } from './cloud-api-copy' import { useOrcaCloudApiAvailability } from '@/hooks/useOrcaCloudApiAvailability' import { useRequestGeneration } from './use-request-generation' +import { useOrcaAccountKey } from '@/hooks/use-orca-account-key' +/** Keys, settings and grants belong to one team: a team switch remounts and re-reads them. */ export function CloudApiKeysPane(): React.JSX.Element | null { + return +} + +function CloudApiKeysPaneBody(): React.JSX.Element | null { const t = useTranslate() const availability = useOrcaCloudApiAvailability() const api = window.api.cloudApi diff --git a/src/renderer/src/hooks/use-orca-account-key.ts b/src/renderer/src/hooks/use-orca-account-key.ts new file mode 100644 index 00000000000..fcc0c894719 --- /dev/null +++ b/src/renderer/src/hooks/use-orca-account-key.ts @@ -0,0 +1,12 @@ +import { useAppStore } from '@/store' + +/** + * `${userId}:${activeOrgId}` for the signed-in Orca account. Team-scoped views key on it so a + * team switch remounts them: nothing read for the previous team survives into the new one. + */ +export function useOrcaAccountKey(): string { + return useAppStore((state) => { + const cloud = state.orcaProfileAuthStatus?.cloud + return cloud ? `${cloud.userId}:${cloud.activeOrgId ?? ''}` : 'signed-out' + }) +} From 046e08f60dfcba43547e0f082affb315d6e13855 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 10 Nov 2026 02:39:06 -0800 Subject: [PATCH 07/10] fix(cloud-ports): keyboard Share and More actions on port rows, W3 copy and contrast (DS-29) (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Port rows get hover/focus buttons: Open in browser, Share… (Orca Cloud), and More actions for port N, which opens the existing row menu. - Drop focus:outline-none on the port row group so the focus-ring shows. - Retry -> Try again; listen failure copy; sentence case for SSH/ports labels. - Remote desktop notCloud/notInstalled/signedOut render neutral, not red. - busy/taken -> in use on this computer. - Expired / Turned off share status keeps full text-muted-foreground. - New i18n keys for changed defaults; stale keys pruned from all locales. - Fixtures: share-links portRowFocused / portRowShareFocused / portRowMoreActions. --- .../auth-callback-forward.ts | 2 +- .../agent-sign-in/AgentSignInCard.fixture.tsx | 7 +- .../agent-sign-in/AgentSignInCard.tsx | 2 +- .../CloudRemoteDesktopToast.tsx | 11 +-- .../components/right-sidebar/PortsPanel.tsx | 13 +++- .../cloud-share/share-link-row.tsx | 12 +++- .../cloud-share/share-links.fixture.tsx | 53 ++++++++++++++ .../cloud-share/shared-links-list.tsx | 2 +- .../cloud-share/use-share-port-action.tsx | 40 +++++++++++ .../right-sidebar/local-port-row.test.tsx | 72 +++++++++++++++++++ .../right-sidebar/local-port-row.tsx | 69 ++++++++++++++---- .../right-sidebar/local-port-section.tsx | 3 + .../local-workspace-ports-panel.tsx | 10 ++- .../runtime-port-forward-status.fixture.tsx | 2 +- .../runtime-port-forward-status.tsx | 2 +- .../right-sidebar/ssh-forwarded-port-row.tsx | 4 +- .../use-runtime-port-row-forwarding.tsx | 6 +- .../components/settings/ports/PortsPane.tsx | 4 +- .../components/settings/ports/ports-search.ts | 2 +- .../cloud-ssh/CloudSshIncludeDialog.tsx | 2 +- .../cloud-ssh/CloudSshMenuItems.tsx | 4 +- .../cloud-ssh/cloud-ssh-actions.tsx | 2 +- src/renderer/src/i18n/locales/de.json | 5 -- src/renderer/src/i18n/locales/en.json | 46 ++++++------ src/renderer/src/i18n/locales/es.json | 5 -- src/renderer/src/i18n/locales/fr.json | 5 -- src/renderer/src/i18n/locales/ja.json | 5 -- src/renderer/src/i18n/locales/ko.json | 5 -- src/renderer/src/i18n/locales/pt.json | 5 -- src/renderer/src/i18n/locales/ru.json | 5 -- src/renderer/src/i18n/locales/tr.json | 5 -- src/renderer/src/i18n/locales/zh-Hant.json | 5 -- src/renderer/src/i18n/locales/zh.json | 5 -- .../runtime-port-forwards-store.ts | 4 +- .../languages/protected-copy-keys.json | 4 +- 35 files changed, 313 insertions(+), 115 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/local-port-row.test.tsx diff --git a/src/main/ipc/runtime-environments/auth-callback-forward.ts b/src/main/ipc/runtime-environments/auth-callback-forward.ts index db72bf4b5bb..689e31f7f6e 100644 --- a/src/main/ipc/runtime-environments/auth-callback-forward.ts +++ b/src/main/ipc/runtime-environments/auth-callback-forward.ts @@ -157,7 +157,7 @@ export class AuthCallbackForward { ok: false, code: busy ? 'port_busy' : 'listen_failed', message: busy - ? `Port ${this.request.port} is busy on this computer.` + ? `Port ${this.request.port} is in use on this computer.` : 'Orca could not listen for the sign-in on this computer.' } } diff --git a/src/renderer/src/components/agent-sign-in/AgentSignInCard.fixture.tsx b/src/renderer/src/components/agent-sign-in/AgentSignInCard.fixture.tsx index b3f28ab17bd..6df1f77f717 100644 --- a/src/renderer/src/components/agent-sign-in/AgentSignInCard.fixture.tsx +++ b/src/renderer/src/components/agent-sign-in/AgentSignInCard.fixture.tsx @@ -61,7 +61,12 @@ export const waitingCodex = shot( export const signedIn = shot(gate({ step: 'signed', email: 'nw@stably.ai' })) export const portBusy = shot( gate( - { step: 'error', kind: 'port_busy', message: 'Port 1455 is busy.', port: 1455 }, + { + step: 'error', + kind: 'port_busy', + message: 'Port 1455 is in use on this computer.', + port: 1455 + }, { agent: 'codex' } ) ) diff --git a/src/renderer/src/components/agent-sign-in/AgentSignInCard.tsx b/src/renderer/src/components/agent-sign-in/AgentSignInCard.tsx index ee0e671db45..fd6b2ecf5bb 100644 --- a/src/renderer/src/components/agent-sign-in/AgentSignInCard.tsx +++ b/src/renderer/src/components/agent-sign-in/AgentSignInCard.tsx @@ -336,7 +336,7 @@ function AgentSignInError({ case 'port_busy': return ( ( + + ), + [shareAvailable, openShareDialog] + ) const scopedHost = useMemo( () => host.kind === 'host-scoped' && workspaceId @@ -62,6 +72,7 @@ export default function PortsPanel({ isVisible }: { isVisible: boolean }): React runtimeTarget={host.target} sshForwardTargetId={sshForwardTargetId} portMenuItems={shareAvailable ? sharePortMenuItems : undefined} + portActions={shareAvailable ? sharePortActions : undefined} isSandbox={shareAvailable} footer={(activePorts) => (
    -
    -
    +
    + {/* Dim the icon and port only: the Expired / Turned off status keeps full muted contrast. */} +
    -
    +
    {showPort && ( :{share.port} )} diff --git a/src/renderer/src/components/right-sidebar/cloud-share/share-links.fixture.tsx b/src/renderer/src/components/right-sidebar/cloud-share/share-links.fixture.tsx index f6df5b7cc5e..62d1df888e3 100644 --- a/src/renderer/src/components/right-sidebar/cloud-share/share-links.fixture.tsx +++ b/src/renderer/src/components/right-sidebar/cloud-share/share-links.fixture.tsx @@ -4,6 +4,7 @@ import type { WorkspacePort } from '../../../../../shared/workspace-ports' import { LocalPortRow } from '../local-port-row' import { SharedLinksList, type SharedLinksListProps } from './shared-links-list' import { SharePortDialog } from './share-port-dialog' +import { SharePortButton, SharePortMenuItem } from './use-share-port-action' const NOW = Date.UTC(2026, 10, 9, 12, 0, 0) const HOUR = 3_600_000 @@ -177,6 +178,58 @@ export const besidePorts = defineFixture({ ) }) +const shareAction = { available: true, openShareDialog: noop } +// Shows the focus-ring as keyboard focus would (Chromium honors focusVisible). +const FOCUS_VISIBLE: FocusOptions & { focusVisible: boolean } = { focusVisible: true } + +/** A cloud port row as keyboard users get it: Open in browser, Share…, Copy, Stop, More actions. */ +function CloudPortRow({ tall = false }: { tall?: boolean }) { + return ( +
    + } + forwardMenuItems={} + /> +
    + ) +} + +/** Tabbing onto the row: the focus-ring outline shows and the row buttons appear. */ +export const portRowFocused = defineFixture({ + width: 300, + render: () => , + interact: (frame) => { + frame.querySelector('[role="group"]')?.focus(FOCUS_VISIBLE) + } +}) + +/** Share… reached from the keyboard: the row's Share button focused. */ +export const portRowShareFocused = defineFixture({ + width: 300, + render: () => , + interact: (frame) => { + frame + .querySelector('button[aria-label="Share port 3000…"]') + ?.focus(FOCUS_VISIBLE) + } +}) + +/** "More actions for port 3000" opens the same menu as right-click, Share… included. */ +export const portRowMoreActions = defineFixture({ + width: 300, + render: () => , + interact: async (frame) => { + frame + .querySelector('button[aria-label="More actions for port 3000"]') + ?.click() + await new Promise((resolve) => setTimeout(resolve, 50)) + } +}) + function Dialog({ port, created, diff --git a/src/renderer/src/components/right-sidebar/cloud-share/shared-links-list.tsx b/src/renderer/src/components/right-sidebar/cloud-share/shared-links-list.tsx index 0667d63a06b..611b9193640 100644 --- a/src/renderer/src/components/right-sidebar/cloud-share/shared-links-list.tsx +++ b/src/renderer/src/components/right-sidebar/cloud-share/shared-links-list.tsx @@ -142,7 +142,7 @@ export function SharedLinksList({ retryLabel={ needsSignIn ? undefined - : t('auto.components.right.sidebar.CloudShare.retry', 'Retry') + : t('auto.components.right.sidebar.CloudShare.tryAgain', 'Try again') } onRetry={needsSignIn ? undefined : onRetry} retrying={retrying} diff --git a/src/renderer/src/components/right-sidebar/cloud-share/use-share-port-action.tsx b/src/renderer/src/components/right-sidebar/cloud-share/use-share-port-action.tsx index f96f1f1dbc2..b9b94722911 100644 --- a/src/renderer/src/components/right-sidebar/cloud-share/use-share-port-action.tsx +++ b/src/renderer/src/components/right-sidebar/cloud-share/use-share-port-action.tsx @@ -1,7 +1,9 @@ import React, { useCallback, useState } from 'react' import { Share2 } from 'lucide-react' import { toast } from 'sonner' +import { Button } from '@/components/ui/button' import { ContextMenuItem } from '@/components/ui/context-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { useTranslate } from '@/i18n/use-translate' import { @@ -144,3 +146,41 @@ export function SharePortMenuItem({ ) } + +/** "Share…" as a port row hover/focus button, so sharing doesn't need a right-click. */ +export function SharePortButton({ + port, + action +}: { + port: number + action: Pick +}): React.JSX.Element | null { + const t = useTranslate() + if (!action.available || SHARE_RESERVED_PORTS.includes(port)) { + return null + } + return ( + + + + + + {t('auto.components.right.sidebar.CloudShare.menuShare', 'Share…')} + + + ) +} diff --git a/src/renderer/src/components/right-sidebar/local-port-row.test.tsx b/src/renderer/src/components/right-sidebar/local-port-row.test.tsx new file mode 100644 index 00000000000..355b2ad4468 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/local-port-row.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WorkspacePort } from '../../../../shared/workspace-ports' +import { LocalPortRow } from './local-port-row' +import { SharePortButton, SharePortMenuItem } from './cloud-share/use-share-port-action' + +const port: WorkspacePort = { + id: '127.0.0.1:3000:4242', + bindHost: '127.0.0.1', + connectHost: '127.0.0.1', + port: 3000, + pid: 4242, + processName: 'vite', + protocol: 'http', + kind: 'workspace', + owner: { + worktreeId: 'repo::/workspace/app', + repoId: 'repo', + displayName: 'app', + path: '/workspace/app', + confidence: 'cwd' + } +} + +function renderRow(openShareDialog = vi.fn(), available = true) { + const action = { available, openShareDialog } + render( + } + forwardMenuItems={} + /> + ) + return openShareDialog +} + +afterEach(cleanup) + +describe('LocalPortRow keyboard actions', () => { + it('offers Open in browser, Share… and More actions as row buttons', () => { + const openShareDialog = renderRow() + expect(screen.getByRole('button', { name: 'Open in browser' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Share port 3000…' })) + expect(openShareDialog).toHaveBeenCalledWith(3000) + expect(screen.getByRole('button', { name: 'More actions for port 3000' })).toBeTruthy() + }) + + it('hides Share… when sharing is unavailable', () => { + renderRow(vi.fn(), false) + expect(screen.queryByRole('button', { name: 'Share port 3000…' })).toBeNull() + }) + + it('opens the row menu, Share… included, from the More actions button', async () => { + const openShareDialog = renderRow() + fireEvent.click(screen.getByRole('button', { name: 'More actions for port 3000' })) + const share = await screen.findByRole('menuitem', { name: 'Share…' }) + expect(screen.getByRole('menuitem', { name: 'Copy address' })).toBeTruthy() + fireEvent.click(share) + expect(openShareDialog).toHaveBeenCalledWith(3000) + }) + + it('keeps the focus-ring outline on the row group', () => { + renderRow() + const group = screen.getByRole('group', { name: 'Port 3000 menu' }) + expect(group.className).not.toContain('focus:outline-none') + expect(group.className).toContain('focus-visible:outline-focus-ring') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/local-port-row.tsx b/src/renderer/src/components/right-sidebar/local-port-row.tsx index 3377dd1ccd5..dc83f2e85b3 100644 --- a/src/renderer/src/components/right-sidebar/local-port-row.tsx +++ b/src/renderer/src/components/right-sidebar/local-port-row.tsx @@ -1,5 +1,5 @@ -import React, { useCallback } from 'react' -import { Box, Copy, ExternalLink, Info, Server, Trash2 } from 'lucide-react' +import React, { useCallback, useRef } from 'react' +import { Box, Copy, Ellipsis, ExternalLink, Info, Server, Trash2 } from 'lucide-react' import { getPortOpenBrowserTooltipLabel } from '@/lib/workspace-ports/workspace-port-actions' import { addressForPort } from '@/lib/workspace-ports/workspace-port-urls' import { Button } from '@/components/ui/button' @@ -29,6 +29,7 @@ export function LocalPortRow({ forwardStatus, forwardMenuItems, forwardActions, + shareAction, copyAddress }: { port: WorkspacePort @@ -41,10 +42,13 @@ export function LocalPortRow({ forwardMenuItems?: React.ReactNode /** Hover actions for the forward (Stop Forwarding), next to Open/Copy. */ forwardActions?: React.ReactNode + /** Orca Cloud: a "Share…" hover button after Open (the same action as the menu's Share…). */ + shareAction?: React.ReactNode /** Overrides the copied address, e.g. with the forwarded localhost one. */ copyAddress?: string }): React.JSX.Element { const t = useTranslate() + const menuTriggerRef = useRef(null) const handleCopy = useCallback(() => { void window.api.ui.writeClipboardText(copyAddress ?? addressForPort(port)) }, [copyAddress, port]) @@ -87,6 +91,20 @@ export function LocalPortRow({ [onStop, port] ) + // The row menu is a context menu; this opens it under the button so keyboard and trackpad users reach Share… and the rest. + const handleMoreActionsClick = useCallback((event: React.MouseEvent) => { + const rect = event.currentTarget.getBoundingClientRect() + menuTriggerRef.current?.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + button: 2, + clientX: rect.left, + clientY: rect.bottom + }) + ) + }, []) + const processLabel = port.processName ?? (port.pid @@ -103,9 +121,15 @@ export function LocalPortRow({ ) : t('auto.components.right.sidebar.PortsPanel.unassigned', 'Unassigned') const openBrowserLabel = t( - 'auto.components.right.sidebar.PortsPanel.b22b128b2a', - 'Open in Browser' + 'auto.components.right.sidebar.PortsPanel.openInBrowser', + 'Open in browser' ) + const moreActionsLabel = t( + 'auto.components.right.sidebar.PortsPanel.moreActionsForPort', + 'More actions for port {{port}}', + { port: port.port } + ) + const stopProcessLabel = t('auto.components.right.sidebar.PortsPanel.stopProcess', 'Stop process') const canStopProcess = port.kind === 'workspace' && Boolean(port.pid) && port.processName !== 'Electron' @@ -114,7 +138,8 @@ export function LocalPortRow({
    )} + {shareAction} - {t('auto.components.right.sidebar.PortsPanel.f9528da632', 'Stop Process')} + {stopProcessLabel} )} + + + + + + {moreActionsLabel} + +
    @@ -231,7 +272,7 @@ export function LocalPortRow({ )} - {t('auto.components.right.sidebar.PortsPanel.792baeb7ed', 'Copy Address')} + {t('auto.components.right.sidebar.PortsPanel.copyAddress', 'Copy address')} {forwardMenuItems} - {t('auto.components.right.sidebar.PortsPanel.bdac206faf', 'Copy Details')} + {t('auto.components.right.sidebar.PortsPanel.copyDetails', 'Copy details')} onShowDetails(port)} > - {t('auto.components.right.sidebar.PortsPanel.a223459512', 'Show Details')} + {t('auto.components.right.sidebar.PortsPanel.showDetails', 'Show details')} onStop(port)} > - {t('auto.components.right.sidebar.PortsPanel.f9528da632', 'Stop Process')} + {stopProcessLabel} diff --git a/src/renderer/src/components/right-sidebar/local-port-section.tsx b/src/renderer/src/components/right-sidebar/local-port-section.tsx index fcc1551dd16..ce94860a1de 100644 --- a/src/renderer/src/components/right-sidebar/local-port-section.tsx +++ b/src/renderer/src/components/right-sidebar/local-port-section.tsx @@ -8,6 +8,8 @@ export type PortRowForwarding = { status: (port: WorkspacePort) => React.ReactNode menuItems: (port: WorkspacePort) => React.ReactNode actions: (port: WorkspacePort) => React.ReactNode + /** Host row buttons after Open (Orca Cloud: Share…). */ + hostActions?: (port: WorkspacePort) => React.ReactNode copyAddress: (port: WorkspacePort) => string | undefined } @@ -72,6 +74,7 @@ export function LocalPortSection({ forwardStatus={forwarding?.status(port)} forwardMenuItems={forwarding?.menuItems(port)} forwardActions={forwarding?.actions(port)} + shareAction={forwarding?.hostActions?.(port)} copyAddress={forwarding?.copyAddress(port)} /> )) diff --git a/src/renderer/src/components/right-sidebar/local-workspace-ports-panel.tsx b/src/renderer/src/components/right-sidebar/local-workspace-ports-panel.tsx index 27c04d9f65d..e3c0d5b3247 100644 --- a/src/renderer/src/components/right-sidebar/local-workspace-ports-panel.tsx +++ b/src/renderer/src/components/right-sidebar/local-workspace-ports-panel.tsx @@ -36,6 +36,7 @@ export function LocalWorkspacePortsPanel({ runtimeTarget, sshForwardTargetId = null, portMenuItems, + portActions, footer, isSandbox = false }: { @@ -46,6 +47,8 @@ export function LocalWorkspacePortsPanel({ sshForwardTargetId?: string | null /** Extra row actions for this host's ports (Orca Cloud: Share…); rides the forwarding menu. */ portMenuItems?: (port: WorkspacePort) => React.ReactNode + /** The same host actions as hover/focus row buttons (Orca Cloud: Share…), after Open. */ + portActions?: (port: WorkspacePort) => React.ReactNode /** Host-specific sections below the ports (Orca Cloud: shared links), given the listening ports. */ footer?: (activePorts: readonly WorkspacePort[]) => React.ReactNode /** An Orca Cloud sandbox: copy says "sandbox" and offers Wake. */ @@ -69,7 +72,12 @@ export function LocalWorkspacePortsPanel({ }) const scanKey = runtimeTarget ? `${workspacePortRuntimeTargetKey(runtimeTarget)}:all` : null - const runtimeForwarding = useRuntimePortRowForwarding(runtimeTarget, portMenuItems, isSandbox) + const runtimeForwarding = useRuntimePortRowForwarding( + runtimeTarget, + portMenuItems, + isSandbox, + portActions + ) const refresh = useCallback(() => { if (!activeRepoId || !runtimeTarget || !scanKey) { diff --git a/src/renderer/src/components/right-sidebar/runtime-port-forward-status.fixture.tsx b/src/renderer/src/components/right-sidebar/runtime-port-forward-status.fixture.tsx index b3f0505276c..ba606bb5911 100644 --- a/src/renderer/src/components/right-sidebar/runtime-port-forward-status.fixture.tsx +++ b/src/renderer/src/components/right-sidebar/runtime-port-forward-status.fixture.tsx @@ -58,7 +58,7 @@ const states: Record = { 5173: { kind: 'forwarded', forward: forward(5173) }, 3000: { kind: 'forwarded', forward: forward(3000, { localPort: 3001, reason: 'user' }) }, 8080: { kind: 'pending' }, - 4000: { kind: 'failed', message: 'localhost:4000 in use', busy: true }, + 4000: { kind: 'failed', message: 'localhost:4000 in use on this computer', busy: true }, 9000: { kind: 'failed', message: 'Host not reachable', busy: false }, 9229: { kind: 'unforwarded' } } diff --git a/src/renderer/src/components/right-sidebar/runtime-port-forward-status.tsx b/src/renderer/src/components/right-sidebar/runtime-port-forward-status.tsx index beef4ebd57f..7f65a156922 100644 --- a/src/renderer/src/components/right-sidebar/runtime-port-forward-status.tsx +++ b/src/renderer/src/components/right-sidebar/runtime-port-forward-status.tsx @@ -134,7 +134,7 @@ export function RuntimePortForwardStatus({ } : { icon: , - label: t('auto.components.right.sidebar.RuntimePortForward.retry', 'Retry') + label: t('auto.components.right.sidebar.RuntimePortForward.tryAgain', 'Try again') } : { icon: , diff --git a/src/renderer/src/components/right-sidebar/ssh-forwarded-port-row.tsx b/src/renderer/src/components/right-sidebar/ssh-forwarded-port-row.tsx index e8c3a8b26d3..0a3260b33f0 100644 --- a/src/renderer/src/components/right-sidebar/ssh-forwarded-port-row.tsx +++ b/src/renderer/src/components/right-sidebar/ssh-forwarded-port-row.tsx @@ -88,8 +88,8 @@ export function SshForwardedPortRow({ const advertisedBrowserUrl = advertisedBrowserUrlForForwardedRow(entry) const openBrowserLabel = t( - 'auto.components.right.sidebar.PortsPanel.b22b128b2a', - 'Open in Browser' + 'auto.components.right.sidebar.PortsPanel.openInBrowser', + 'Open in browser' ) const openBrowserTitle = getPortOpenBrowserTooltipLabel( advertisedBrowserUrl diff --git a/src/renderer/src/components/right-sidebar/use-runtime-port-row-forwarding.tsx b/src/renderer/src/components/right-sidebar/use-runtime-port-row-forwarding.tsx index 1bffd0d3471..d957681c442 100644 --- a/src/renderer/src/components/right-sidebar/use-runtime-port-row-forwarding.tsx +++ b/src/renderer/src/components/right-sidebar/use-runtime-port-row-forwarding.tsx @@ -57,7 +57,9 @@ export function useRuntimePortRowForwarding( /** Host actions after the forward items (Orca Cloud: Share…). */ extraMenuItems?: (port: WorkspacePort) => ReactNode, /** Orca Cloud: copy says "sandbox", and offline offers Wake. */ - isSandbox = false + isSandbox = false, + /** Host row buttons after Open (Orca Cloud: Share…). */ + extraActions?: (port: WorkspacePort) => ReactNode ): RuntimePortRowForwarding | null { const t = useTranslate() const environmentId = runtimeTarget?.kind === 'environment' ? runtimeTarget.environmentId : null @@ -197,6 +199,7 @@ export function useRuntimePortRowForwarding( ) }, + hostActions: extraActions, copyAddress: (port) => { const existing = forwardFor(port) return existing ? `localhost:${existing.localPort}` : undefined @@ -204,6 +207,7 @@ export function useRuntimePortRowForwarding( } }, [ environmentId, + extraActions, extraMenuItems, failures, forward, diff --git a/src/renderer/src/components/settings/ports/PortsPane.tsx b/src/renderer/src/components/settings/ports/PortsPane.tsx index 2e7fa14bd13..89ada6191e8 100644 --- a/src/renderer/src/components/settings/ports/PortsPane.tsx +++ b/src/renderer/src/components/settings/ports/PortsPane.tsx @@ -123,8 +123,8 @@ export function PortsPane({ settings }: { settings: GlobalSettings }): React.JSX [ { - title: translate('auto.components.settings.ports.search.title', 'Port Forwarding'), + title: translate('auto.components.settings.ports.search.titleSentenceCase', 'Port forwarding'), description: translate( 'auto.components.settings.ports.search.descriptionSandboxes', 'Forward dev servers in sandboxes and on remote hosts to this computer.' diff --git a/src/renderer/src/components/sidebar/workspace-actions/cloud-ssh/CloudSshIncludeDialog.tsx b/src/renderer/src/components/sidebar/workspace-actions/cloud-ssh/CloudSshIncludeDialog.tsx index e151b423321..df57cb1f8f6 100644 --- a/src/renderer/src/components/sidebar/workspace-actions/cloud-ssh/CloudSshIncludeDialog.tsx +++ b/src/renderer/src/components/sidebar/workspace-actions/cloud-ssh/CloudSshIncludeDialog.tsx @@ -104,7 +104,7 @@ const CloudSshIncludeDialog = React.memo(function CloudSshIncludeDialog() { {t('auto.components.sidebar.CloudSshIncludeDialog.cancel', 'Cancel')} diff --git a/src/renderer/src/components/sidebar/workspace-actions/cloud-ssh/CloudSshMenuItems.tsx b/src/renderer/src/components/sidebar/workspace-actions/cloud-ssh/CloudSshMenuItems.tsx index 0c5c986bbe3..c2ee41aa49b 100644 --- a/src/renderer/src/components/sidebar/workspace-actions/cloud-ssh/CloudSshMenuItems.tsx +++ b/src/renderer/src/components/sidebar/workspace-actions/cloud-ssh/CloudSshMenuItems.tsx @@ -136,7 +136,7 @@ export function CloudSshMenuItems({ aria-describedby={status.hasAccess ? undefined : hintId} > - {t('auto.components.sidebar.CloudSsh.copyCommand', 'Copy SSH Command')} + {t('auto.components.sidebar.CloudSsh.copySshCommand', 'Copy SSH command')} {status.hasAccess ? ( <> @@ -147,7 +147,7 @@ export function CloudSshMenuItems({ disabled={disabled} > - {t('auto.components.sidebar.CloudSsh.revoke', 'Revoke SSH Access')} + {t('auto.components.sidebar.CloudSsh.revokeSshAccess', 'Revoke SSH access')} {status.includeInstalled ? ( Date: Tue, 10 Nov 2026 02:41:14 -0800 Subject: [PATCH 08/10] copy(billing): overage reads '{{rate}} per sandbox hour' with the rate from the plan (glossary); formatRateCents always shows cents --- .../src/components/billing/PlanBillingUsage.tsx | 16 ++++++++-------- .../components/billing/plans/TeamPlanCard.tsx | 15 ++++++++++----- .../billing/plans/plans-pricing.test.ts | 6 ++++++ .../components/billing/plans/plans-pricing.ts | 10 ++++++++++ src/renderer/src/i18n/locales/en.json | 6 +++--- 5 files changed, 37 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/billing/PlanBillingUsage.tsx b/src/renderer/src/components/billing/PlanBillingUsage.tsx index 8f306414235..b56af82d4cd 100644 --- a/src/renderer/src/components/billing/PlanBillingUsage.tsx +++ b/src/renderer/src/components/billing/PlanBillingUsage.tsx @@ -11,7 +11,7 @@ import type { OrcaBillingOverageArgs } from '../../../../shared/orca-billing' import { formatHours, includedUsagePercent } from './plan-billing-format' -import { formatCents } from './plans/plans-pricing' +import { formatCents, formatRateCents } from './plans/plans-pricing' import { ORCA_BILLING_OVERAGE_SECTION_ID } from '../new-workspace/orca-cloud-run-target-access' /** "Resets and renews Dec 2" when both land the same day (monthly); separate parts otherwise. */ @@ -162,17 +162,17 @@ export function OverageControls({ const changed = capValid && capDollars.trim() !== savedDollars const paymentFailed = entitlement.payment.pastDueSince !== null const description = t( - 'auto.components.billing.overageDescription5', - 'Optional overage at {{value0}}/hour, up to a monthly spend cap you set ({{value1}} by default).', + 'auto.components.billing.overageDescription6', + 'Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set ({{cap}} by default).', { - value0: formatCents(overage.centsPerHour, locale), - value1: formatCents(entitlement.offers?.team.defaultSpendCapCents ?? 2500, locale) + rate: formatRateCents(overage.centsPerHour, locale), + cap: formatCents(entitlement.offers?.team.defaultSpendCapCents ?? 2500, locale) } ) const descriptionOn = t( - 'auto.components.billing.overageDescriptionOn2', - 'Optional overage at {{value0}}/hour, up to a monthly spend cap you set.', - { value0: formatCents(overage.centsPerHour, locale) } + 'auto.components.billing.overageDescriptionOn3', + 'Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set.', + { rate: formatRateCents(overage.centsPerHour, locale) } ) const capPercent = overage.spendCapCents > 0 diff --git a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx index 1ab9d0ecbf8..34e4e84771c 100644 --- a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx +++ b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx @@ -7,7 +7,12 @@ import { LoadingSpinner } from '@/components/LoadingSpinner' import { useIntlLocale, useTranslate } from '@/i18n/use-translate' import type { OrcaBillingInterval, OrcaBillingOffers } from '../../../../../shared/orca-billing' import { PlanCardShell } from './PlanCardShell' -import { formatCents, perSeatMonthlyCents, periodTotalCents } from './plans-pricing' +import { + formatCents, + formatRateCents, + perSeatMonthlyCents, + periodTotalCents +} from './plans-pricing' import { ORCA_CLOUD_SANDBOX_SPEC } from '../../../../../shared/hosted-sandbox/hosted-sandbox-wire' export type TeamPlanCardMode = 'choose' | 'current' | 'locked' @@ -65,11 +70,11 @@ export function TeamPlanCard({ ), t('auto.components.billing.plans.featureReady', 'Ready in seconds, nothing to set up or host'), t( - 'auto.components.billing.plans.featureOverage5', - 'Optional overage at {{value0}}/hour, up to a monthly spend cap you set ({{value1}} by default)', + 'auto.components.billing.plans.featureOverage6', + 'Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set ({{cap}} by default)', { - value0: formatCents(team.overageCentsPerHour, locale), - value1: formatCents(team.defaultSpendCapCents, locale) + rate: formatRateCents(team.overageCentsPerHour, locale), + cap: formatCents(team.defaultSpendCapCents, locale) } ) ] diff --git a/src/renderer/src/components/billing/plans/plans-pricing.test.ts b/src/renderer/src/components/billing/plans/plans-pricing.test.ts index 19dd7bb2c5b..047e7075978 100644 --- a/src/renderer/src/components/billing/plans/plans-pricing.test.ts +++ b/src/renderer/src/components/billing/plans/plans-pricing.test.ts @@ -4,6 +4,7 @@ import { annualSavingsPercent, clampSeats, formatCents, + formatRateCents, perSeatMonthlyCents, periodTotalCents } from './plans-pricing' @@ -38,6 +39,11 @@ describe('plans pricing', () => { expect(formatCents(144000, 'en-US')).toBe('$1,440') }) + it('always shows cents on a per-hour rate', () => { + expect(formatRateCents(100, 'en-US')).toBe('$1.00') + expect(formatRateCents(75, 'en-US')).toBe('$0.75') + }) + it('clamps seats to 1..max', () => { expect(clampSeats(0, 500)).toBe(1) expect(clampSeats(Number.NaN, 500)).toBe(1) diff --git a/src/renderer/src/components/billing/plans/plans-pricing.ts b/src/renderer/src/components/billing/plans/plans-pricing.ts index 5e94f0aac1b..1d83adea42b 100644 --- a/src/renderer/src/components/billing/plans/plans-pricing.ts +++ b/src/renderer/src/components/billing/plans/plans-pricing.ts @@ -46,6 +46,16 @@ export function formatCents(cents: number, locale: string): string { }).format(cents / 100) } +/** A per-hour rate always shows cents ("$1.00", "$0.75"), unlike whole-dollar prices. */ +export function formatRateCents(cents: number, locale: string): string { + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(cents / 100) +} + export const MIN_SEATS = 1 export function clampSeats(value: number, max: number): number { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b9bcc9b9409..fa9eb939091 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -20624,7 +20624,7 @@ "was": "Was", "featureHours4": "{{value0}} sandbox hours per seat each month, pooled across your team", "featureSpec": "Every sandbox gets {{value0}} vCPU · {{value1}} GB", - "featureOverage5": "Optional overage at {{value0}}/hour, up to a monthly spend cap you set ({{value1}} by default)", + "featureOverage6": "Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set ({{cap}} by default)", "footTerms4": "Prices in USD, before tax. Overage is off until an owner or admin turns it on. Cancel anytime; your plan runs to the end of the billing period.", "lockedBody2": "Only an owner or admin of your team can choose a plan. Ask one of them to open Plan & billing in Orca.", "vpSecretsBody4": "API keys you add to Orca are never written to the sandbox. Agents and terminals get them as environment variables.", @@ -20699,7 +20699,7 @@ "enterpriseContactSales": "Contact sales", "openForbidden2": "Only an owner or admin can manage billing.", "openSignedOut2": "Sign in to Orca again to manage your team.", - "overageDescription5": "Optional overage at {{value0}}/hour, up to a monthly spend cap you set ({{value1}} by default).", + "overageDescription6": "Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set ({{cap}} by default).", "overageHoursOnly": "{{value0}} overage hours this month", "overageMemberHint": "Owners and admins can change overage.", "overageOffRead": "Off", @@ -20719,7 +20719,7 @@ "sandboxHoursUsedMonth": "{{value0}} of {{value1}} sandbox hours used this month", "warn100b": "All of this month’s sandbox hours are used.", "warnPercent2": "You’ve used {{value0}}% of this month’s sandbox hours.", - "overageDescriptionOn2": "Optional overage at {{value0}}/hour, up to a monthly spend cap you set.", + "overageDescriptionOn3": "Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set.", "reasonCapMember3": "Spend cap reached. Ask an owner or admin to raise the spend cap. Your files are safe.", "reasonGraceExpired3": "Payment failed. New sandboxes can’t start and running sandboxes are asleep. Update your payment method. Your files are safe.", "reasonGraceExpiredMember2": "Payment failed. New sandboxes can’t start and running sandboxes are asleep. Ask an owner or admin to update the payment method. Your files are safe.", From 9a4bd94b66c559e1685a61a10e44cb4621068cfb Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 02:44:23 -0800 Subject: [PATCH 09/10] feat(billing): member billing walls and back-to-work after checkout (DS-15, DS-16) DS-15: no-plan access carries canManageBilling. Members see 'Ask an owner or admin' instead of Upgrade, and the hint says 'Ask an owner or admin to choose one.'; at the cap they're told to raise the spend cap. Every billing failure on the build card offers 'Create on this computer instead' (same prompt, Local), and Dismiss hands the typed prompt back to the composer. DS-16: choosing a plan from an Orca Cloud wall leaves an intent. Once paid, the plans dialog returns to the open composer with Orca Cloud picked, or retries the refused create. The no-plan card adds Try again, and the Team page's create action opens on Orca Cloud. --- .../billing/checkout/checkout-return.ts | 49 +++++++++++ .../billing/plans/PlansDialogHost.tsx | 15 ++-- .../billing/team/TeamDialogHost.tsx | 7 +- .../CloudAgentSetup.fixture.tsx | 1 + ...orkspaceComposerProjectSection.fixture.tsx | 59 ++++++++++++- .../new-workspace/OrcaCloudSelectionHint.tsx | 30 +++++-- .../RunTargetCombobox.fixture.tsx | 42 +++++++++- .../new-workspace/RunTargetCombobox.test.tsx | 13 ++- .../new-workspace/RunTargetOrcaCloudRow.tsx | 30 ++++--- .../orca-cloud-run-target-access.test.ts | 62 ++++++++++++++ .../orca-cloud-run-target-access.ts | 24 ++++-- .../use-run-target-orca-cloud.ts | 14 +++- .../VmBuildFailureActions.tsx | 46 ++++++++--- .../VmBuildFailureCard.test.tsx | 34 +++++++- .../worktree-creation/VmBuildSheet.tsx | 18 +++- .../VmBuildSheetCloudFailures.fixture.tsx | 38 +++++++-- .../WorktreeCreationPanel.tsx | 10 +++ .../vm-build-composer-return.test.ts | 49 +++++++++++ .../vm-build-composer-return.ts | 33 ++++++++ .../vm-build-sheet-fixture-support.tsx | 9 +- .../src/hooks/useEphemeralVmRecipeOptions.ts | 4 + src/renderer/src/i18n/locales/en.json | 7 +- .../src/lib/orca-cloud-after-checkout.test.ts | 62 ++++++++++++++ .../src/lib/orca-cloud-after-checkout.ts | 82 +++++++++++++++++++ 24 files changed, 670 insertions(+), 68 deletions(-) create mode 100644 src/renderer/src/components/billing/checkout/checkout-return.ts create mode 100644 src/renderer/src/components/new-workspace/orca-cloud-run-target-access.test.ts create mode 100644 src/renderer/src/components/worktree-creation/vm-build-composer-return.test.ts create mode 100644 src/renderer/src/components/worktree-creation/vm-build-composer-return.ts create mode 100644 src/renderer/src/lib/orca-cloud-after-checkout.test.ts create mode 100644 src/renderer/src/lib/orca-cloud-after-checkout.ts diff --git a/src/renderer/src/components/billing/checkout/checkout-return.ts b/src/renderer/src/components/billing/checkout/checkout-return.ts new file mode 100644 index 00000000000..e600549131a --- /dev/null +++ b/src/renderer/src/components/billing/checkout/checkout-return.ts @@ -0,0 +1,49 @@ +import { useAppStore } from '@/store/index' +import { + takeOrcaCloudCheckoutReturn, + emitOrcaCloudCheckoutReturn +} from '@/lib/orca-cloud-after-checkout' +import { requestOrcaCloudSelectionAfterSignIn } from '@/lib/orca-cloud-select-after-sign-in' +import { invalidateOrcaCloudBilling } from '@/components/new-workspace/orca-cloud-billing-cache' +import { ORCA_CLOUD_RECIPE_ID } from '../../../../../shared/hosted-sandbox/orca-cloud-recipe-id' +import { closePlansPage } from '../plans/plans-page-store' +import { closeTeamPage, openTeamPage } from '../team/team-page-store' + +/** + * A paid Checkout closes the plans page and goes back to the work that hit the wall: the open + * composer with Orca Cloud picked, or the refused create, retried. Without an intent it lands on + * the Team page's "You're on Team" confirmation. + */ +export function returnAfterPaidCheckout(paidSeats: number): void { + closePlansPage() + const store = useAppStore.getState() + const target = takeOrcaCloudCheckoutReturn({ + composerOpen: store.activeModal === 'new-workspace-composer', + hasCreation: (creationId) => Boolean(store.pendingWorktreeCreations[creationId]) + }) + if (target.kind === 'team-page') { + openTeamPage({ kind: 'checkout-success', seats: paidSeats }) + return + } + closeTeamPage() + // The Run-on row cached "no plan"; read the new entitlement now so it shows hours, not Upgrade. + invalidateOrcaCloudBilling() + switch (target.kind) { + case 'retry': + // Lazy: the creation flow's import graph is heavy and billing never needs it otherwise. + void import('@/lib/workspace/creation/worktree-creation-flow').then( + ({ retryBackgroundWorktreeCreation }) => retryBackgroundWorktreeCreation(target.creationId) + ) + return + case 'select-in-composer': + // The composer's recipe list reloads and consumes this, as it does after signing in. + requestOrcaCloudSelectionAfterSignIn() + emitOrcaCloudCheckoutReturn() + return + case 'open-composer': + store.openModal('new-workspace-composer', { + initialEphemeralVmRecipeId: ORCA_CLOUD_RECIPE_ID, + telemetrySource: 'unknown' + }) + } +} diff --git a/src/renderer/src/components/billing/plans/PlansDialogHost.tsx b/src/renderer/src/components/billing/plans/PlansDialogHost.tsx index a18897ec431..33fd15573a7 100644 --- a/src/renderer/src/components/billing/plans/PlansDialogHost.tsx +++ b/src/renderer/src/components/billing/plans/PlansDialogHost.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useState } from 'react' import { Dialog } from '@/components/ui/dialog' import { useTranslate } from '@/i18n/use-translate' import { useAppStore } from '@/store/index' @@ -7,7 +7,8 @@ import { CheckoutPanel } from '../checkout/CheckoutPanel' import { useCheckoutFlow } from '../checkout/use-checkout-flow' import { openFailureCopy } from '../plan-billing-copy' import type { PlanBillingNotice } from '../PlanBillingSection' -import { openTeamPage } from '../team/team-page-store' +import { returnAfterPaidCheckout } from '../checkout/checkout-return' +import { cancelOrcaCloudAfterCheckout } from '@/lib/orca-cloud-after-checkout' import { useOrcaBillingStatus } from '../use-orca-billing-status' import { PlansDialogContent } from './PlansDialogContent' import { closePlansPage, usePlansPageState } from './plans-page-store' @@ -17,7 +18,7 @@ import { useOrgSeatCount } from './use-org-seat-count' * The plans page. A dialog because choosing a plan is a decision step in front of Checkout; * it reads the billing service's offers, so prices and limits are never hardcoded here. * Checkout itself runs on Stripe's hosted page in the browser; this dialog follows it and, - * once paid, hands over to the Team page. + * once paid, returns to the Orca Cloud wall that opened it, or hands over to the Team page. */ export function PlansDialogHost(): React.JSX.Element { const t = useTranslate() @@ -29,11 +30,7 @@ export function PlansDialogHost(): React.JSX.Element { const [notice, setNotice] = useState(null) const [orderedAt, setOrderedAt] = useState(0) - const onPaid = useCallback((paidSeats: number) => { - closePlansPage() - openTeamPage({ kind: 'checkout-success', seats: paidSeats }) - }, []) - const checkout = useCheckoutFlow({ onPaid }) + const checkout = useCheckoutFlow({ onPaid: returnAfterPaidCheckout }) const openPortal = async (): Promise => { setBusy(true) @@ -69,6 +66,8 @@ export function PlansDialogHost(): React.JSX.Element { // Closing ends the watch: polling stops and the next open starts from the plans. checkout.reset() closePlansPage() + // Leaving without paying: nothing to return to Orca Cloud for. + cancelOrcaCloudAfterCheckout() setNotice(null) } }} diff --git a/src/renderer/src/components/billing/team/TeamDialogHost.tsx b/src/renderer/src/components/billing/team/TeamDialogHost.tsx index 5485dadf76d..dbce53b536d 100644 --- a/src/renderer/src/components/billing/team/TeamDialogHost.tsx +++ b/src/renderer/src/components/billing/team/TeamDialogHost.tsx @@ -14,6 +14,7 @@ import { createTeamFailureCopy, mutationFailureCopy } from './team-copy' import { clearTeamPageArrival, closeTeamPage, useTeamPageState } from './team-page-store' import { useOrgRoster } from './use-org-roster' import { useTeamActions } from './use-team-actions' +import { ORCA_CLOUD_RECIPE_ID } from '../../../../../shared/hosted-sandbox/orca-cloud-recipe-id' function TeamPageBody({ arrival }: { arrival: ReturnType['arrival'] }) { const t = useTranslate() @@ -199,7 +200,11 @@ function TeamPageBody({ arrival }: { arrival: ReturnType { closeTeamPage() - openModal('new-workspace-composer', { telemetrySource: 'unknown' }) + // The plan is for Orca Cloud, so the composer opens on it rather than this computer. + openModal('new-workspace-composer', { + initialEphemeralVmRecipeId: ORCA_CLOUD_RECIPE_ID, + telemetrySource: 'unknown' + }) }} onDismissArrival={clearTeamPageArrival} onContactSales={(url) => void window.api.shell.openUrl(url)} diff --git a/src/renderer/src/components/cloud-agent-setup/CloudAgentSetup.fixture.tsx b/src/renderer/src/components/cloud-agent-setup/CloudAgentSetup.fixture.tsx index 045af8c6965..d87e93644ac 100644 --- a/src/renderer/src/components/cloud-agent-setup/CloudAgentSetup.fixture.tsx +++ b/src/renderer/src/components/cloud-agent-setup/CloudAgentSetup.fixture.tsx @@ -55,6 +55,7 @@ function sheet( onCancel={noop} onRetry={noop} onDismiss={noop} + onReturnToComposer={noop} onEditRecipe={noop} cloudAgentStep={ diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.fixture.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.fixture.tsx index 25256cbdac5..7248037b064 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.fixture.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.fixture.tsx @@ -142,7 +142,16 @@ function ProjectAndHostPreview({ ) } -export const closed = defineFixture({ width: 560, render: () => }) +export const closed = defineFixture({ + width: 560, + // The browser preload answers agent keys and Cloud secrets with stubs the Orca Cloud agent hint + // can't read; without them the hint shows nothing, as it does before they load. + setup: () => { + window.api.agentApiKeys = undefined + window.api.cloudSecrets = undefined + }, + render: () => +}) export const narrow = defineFixture({ width: 360, render: () => }) export const folderOnly = defineFixture({ width: 560, @@ -186,7 +195,11 @@ export const hostMenuOrcaCloud = defineFixture({ }) export const hostMenuOrcaCloudLocked = defineFixture({ ...hostMenu, - render: () => + render: () => ( + + ) }) export const orcaCloudSelected = defineFixture({ ...closed, @@ -196,7 +209,10 @@ export const orcaCloudSelected = defineFixture({ export const orcaCloudSelectedNoPlan = defineFixture({ ...closed, render: () => ( - + ) }) export const orcaCloudSelectedOutOfHoursMember = defineFixture({ @@ -217,3 +233,40 @@ export const orcaCloudSelectedPaymentAdmin = defineFixture({ /> ) }) +// DS-15: a member can't choose a plan or raise the cap, so the hint says who can and offers no fix. +export const orcaCloudSelectedNoPlanMember = defineFixture({ + ...closed, + render: () => ( + + ) +}) +export const orcaCloudSelectedCapMember = defineFixture({ + ...closed, + render: () => ( + + ) +}) +export const hostMenuOrcaCloudNoPlanMember = defineFixture({ + ...hostMenu, + render: () => ( + + ) +}) +// DS-16: back from a paid Checkout, the composer is open again with Orca Cloud picked and its hours. +export const orcaCloudAfterCheckout = defineFixture({ + ...closed, + render: () => ( + + ) +}) diff --git a/src/renderer/src/components/new-workspace/OrcaCloudSelectionHint.tsx b/src/renderer/src/components/new-workspace/OrcaCloudSelectionHint.tsx index f353144eea6..34767afbc05 100644 --- a/src/renderer/src/components/new-workspace/OrcaCloudSelectionHint.tsx +++ b/src/renderer/src/components/new-workspace/OrcaCloudSelectionHint.tsx @@ -28,11 +28,20 @@ function useBlockCopy(access: OrcaCloudRunTargetAccess): BlockCopy | null { 'auto.components.new.workspace.RunTargetCombobox.orcaCloudNeedsTeamPlan', 'Orca Cloud needs a Team plan.' ), - detail: '', + detail: access.canManageBilling + ? '' + : t( + 'auto.components.new.workspace.RunTargetCombobox.orcaCloudAskChoosePlan', + 'Ask an owner or admin to choose one.' + ), tone: 'neutral', - action: access.lapsed - ? t('auto.components.new.workspace.RunTargetCombobox.orcaCloudRenew', 'Renew') - : t('auto.components.new.workspace.RunTargetCombobox.orcaCloudUpgrade', 'Upgrade') + ...(access.canManageBilling + ? { + action: access.lapsed + ? t('auto.components.new.workspace.RunTargetCombobox.orcaCloudRenew', 'Renew') + : t('auto.components.new.workspace.RunTargetCombobox.orcaCloudUpgrade', 'Upgrade') + } + : {}) } case 'out-of-hours': { const cap = access.cause === 'cap' @@ -49,10 +58,15 @@ function useBlockCopy(access: OrcaCloudRunTargetAccess): BlockCopy | null { ), detail: access.canManageBilling ? '' - : t( - 'auto.components.new.workspace.RunTargetCombobox.orcaCloudAskAddHoursOnly', - 'Ask an owner or admin to add hours.' - ), + : cap + ? t( + 'auto.components.new.workspace.RunTargetCombobox.orcaCloudAskRaiseSpendCap', + 'Ask an owner or admin to raise the spend cap.' + ) + : t( + 'auto.components.new.workspace.RunTargetCombobox.orcaCloudAskAddHoursOnly', + 'Ask an owner or admin to add hours.' + ), tone: 'neutral', ...(access.canManageBilling ? { diff --git a/src/renderer/src/components/new-workspace/RunTargetCombobox.fixture.tsx b/src/renderer/src/components/new-workspace/RunTargetCombobox.fixture.tsx index 92e6c1dbcc4..0037f139367 100644 --- a/src/renderer/src/components/new-workspace/RunTargetCombobox.fixture.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetCombobox.fixture.tsx @@ -121,7 +121,12 @@ export const orcaCloudEntitled = defineFixture({ }) export const orcaCloudNotEntitled = defineFixture({ width: 420, - render: () => , + render: () => ( + + ), interact: open }) export const orcaCloudOutOfHours = defineFixture({ @@ -153,7 +158,12 @@ export const orcaCloudPaymentIssue = defineFixture({ }) export const orcaCloudLapsed = defineFixture({ width: 420, - render: () => , + render: () => ( + + ), interact: open }) export const orcaCloudSelectedClosed = defineFixture({ @@ -188,7 +198,12 @@ export const orcaCloudSignedOut = defineFixture({ }) export const orcaCloudNoCapability = defineFixture({ width: 420, - render: () => , + render: () => ( + + ), interact: open }) export const orcaCloudCapReached = defineFixture({ @@ -233,3 +248,24 @@ export const needsSetupArmed = defineFixture({ render: () => , interact: pressKeys('ArrowDown', 'ArrowDown') }) +// DS-15: members see who can fix it, not Upgrade, and the row can't be picked. +export const orcaCloudNoPlanMember = defineFixture({ + width: 420, + render: () => ( + + ), + interact: open +}) +export const orcaCloudCapReachedMember = defineFixture({ + width: 420, + render: () => ( + + ), + interact: open +}) diff --git a/src/renderer/src/components/new-workspace/RunTargetCombobox.test.tsx b/src/renderer/src/components/new-workspace/RunTargetCombobox.test.tsx index 96eaf67974a..28c12a4b7c3 100644 --- a/src/renderer/src/components/new-workspace/RunTargetCombobox.test.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetCombobox.test.tsx @@ -137,13 +137,24 @@ describe('RunTargetCombobox', () => { it('opens the plans page instead of building when there is no plan', () => { const onRecipeChange = vi.fn() - render({ state: 'no-plan', lapsed: false }, onRecipeChange) + render({ state: 'no-plan', lapsed: false, canManageBilling: true }, onRecipeChange) press('ArrowDown') press('Enter') expect(openPlansPage).toHaveBeenCalledWith('sandbox') expect(onRecipeChange).not.toHaveBeenCalled() }) + it('tells a member without a plan to ask an owner or admin instead of opening plans', () => { + const onRecipeChange = vi.fn() + render({ state: 'no-plan', lapsed: false, canManageBilling: false }, onRecipeChange) + expect(container.textContent).toContain('Ask an owner or admin') + expect(container.textContent).not.toContain('Upgrade') + press('ArrowDown') + press('Enter') + expect(openPlansPage).not.toHaveBeenCalled() + expect(onRecipeChange).not.toHaveBeenCalled() + }) + it('moves keyboard focus into the recipe submenu and commits a recipe with Enter', () => { const onRecipeChange = vi.fn() render({ state: 'entitled', remainingSeconds: 3600 }, onRecipeChange) diff --git a/src/renderer/src/components/new-workspace/RunTargetOrcaCloudRow.tsx b/src/renderer/src/components/new-workspace/RunTargetOrcaCloudRow.tsx index b4beb0ceb9b..3b005030eb3 100644 --- a/src/renderer/src/components/new-workspace/RunTargetOrcaCloudRow.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetOrcaCloudRow.tsx @@ -148,6 +148,19 @@ function OrcaCloudDetail({ access }: { access: OrcaCloudRunTargetAccess }): Reac } } +/** Members can't fix billing; say who can rather than offer a pick that does nothing. */ +function AskOwnerOrAdmin(): React.JSX.Element { + const t = useTranslate() + return ( + + {t( + 'auto.components.new.workspace.RunTargetCombobox.orcaCloudAskOwnerOrAdmin', + 'Ask an owner or admin' + )} + + ) +} + function OrcaCloudMeta({ access, onAction @@ -187,6 +200,9 @@ function OrcaCloudMeta({ /> ) case 'no-plan': + if (!access.canManageBilling) { + return + } return ( - {t( - 'auto.components.new.workspace.RunTargetCombobox.orcaCloudAskOwnerOrAdmin', - 'Ask an owner or admin' - )} - - ) + return } return ( ({ + openPlansPage: (source: string) => openPlansPage(source) +})) +const requestOrcaCloudAfterCheckout = vi.fn() +vi.mock('@/lib/orca-cloud-after-checkout', () => ({ + requestOrcaCloudAfterCheckout: (intent: unknown) => requestOrcaCloudAfterCheckout(intent) +})) + +function denied( + reason: OrcaBillingEntitlement['reason'], + canManageBilling: boolean +): Parameters[0] { + return { + status: 'ok', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: access reads only these fields. + entitlement: { + canStartSandbox: false, + remainingIncludedSeconds: 0, + reason, + canManageBilling + } as OrcaBillingEntitlement + } +} + +describe('Orca Cloud run-target access by role', () => { + beforeEach(() => { + openPlansPage.mockReset() + requestOrcaCloudAfterCheckout.mockReset() + }) + + it('carries the role on no plan, so members are not sent to plans they cannot buy', () => { + const owner = toOrcaCloudRunTargetAccess(denied('no_subscription', true)) + const member = toOrcaCloudRunTargetAccess(denied('subscription_inactive', false)) + expect(owner).toEqual({ state: 'no-plan', lapsed: false, canManageBilling: true }) + expect(member).toEqual({ state: 'no-plan', lapsed: true, canManageBilling: false }) + expect(getOrcaCloudPick(member)).toEqual({ kind: 'blocked' }) + expect(openPlansPage).not.toHaveBeenCalled() + }) + + it('sends an owner or admin to plans and leaves a return-to-composer intent', () => { + const pick = getOrcaCloudPick(toOrcaCloudRunTargetAccess(denied('no_subscription', true))) + expect(pick.kind).toBe('act') + if (pick.kind === 'act') { + pick.run() + } + expect(requestOrcaCloudAfterCheckout).toHaveBeenCalledWith({ kind: 'composer' }) + expect(openPlansPage).toHaveBeenCalledWith('sandbox') + }) + + it('blocks members at the spend cap and lets owners act', () => { + const member = toOrcaCloudRunTargetAccess(denied('overage_cap_reached', false)) + expect(member).toEqual({ state: 'out-of-hours', cause: 'cap', canManageBilling: false }) + expect(getOrcaCloudPick(member)).toEqual({ kind: 'blocked' }) + const owner = toOrcaCloudRunTargetAccess(denied('overage_cap_reached', true)) + expect(getOrcaCloudPick(owner).kind).toBe('act') + }) +}) diff --git a/src/renderer/src/components/new-workspace/orca-cloud-run-target-access.ts b/src/renderer/src/components/new-workspace/orca-cloud-run-target-access.ts index 53d7af107cf..fb12e2f3e50 100644 --- a/src/renderer/src/components/new-workspace/orca-cloud-run-target-access.ts +++ b/src/renderer/src/components/new-workspace/orca-cloud-run-target-access.ts @@ -5,6 +5,7 @@ import { cancelOrcaCloudSelectionAfterSignIn, requestOrcaCloudSelectionAfterSignIn } from '@/lib/orca-cloud-select-after-sign-in' +import { requestOrcaCloudAfterCheckout } from '@/lib/orca-cloud-after-checkout' import { classifyEntitlementDenial } from '../../../../shared/orca-billing' /** Signing in from the row selects Orca Cloud once main offers it (the recipe list reloads). */ @@ -21,23 +22,30 @@ function signInThenSelectOrcaCloud(): void { .catch(() => cancelOrcaCloudSelectionAfterSignIn(token)) } +/** Paying from the row returns to this composer with Orca Cloud picked (see PlansDialogHost). */ +function choosePlanThenSelectOrcaCloud(): void { + requestOrcaCloudAfterCheckout({ kind: 'composer' }) + openPlansPage('sandbox') +} + /** * What the Run-on picker knows about the account's Orca Cloud access. * - `loading`: the first billing read is in flight; picking waits briefly for it. * - `unknown`: billing unreachable or not signed in to billing; picking proceeds and the build reports. * - `signed-out`: the build offers Orca Cloud but nobody is signed in; picking starts sign-in. * - `entitled`: can start a sandbox; `remainingSeconds` is the included time left this window. - * - `no-plan`: no active Team subscription (`lapsed` when one existed); routes to the plans page. + * - `no-plan`: no active Team subscription (`lapsed` when one existed); routes owners/admins to the + * plans page and, once paid, back to the composer with Orca Cloud picked. * - `out-of-hours`: included hours are spent (`cause: 'hours'`) or the overage cap is (`'cap'`). * - `payment-issue`: a failed payment blocks new starts. - * Out-of-hours and payment issues are actionable only by owners/admins (`canManageBilling`). + * No plan, out-of-hours and payment issues are actionable only by owners/admins (`canManageBilling`). */ export type OrcaCloudRunTargetAccess = | { state: 'loading' } | { state: 'unknown' } | { state: 'signed-out' } | { state: 'entitled'; remainingSeconds: number } - | { state: 'no-plan'; lapsed: boolean } + | { state: 'no-plan'; lapsed: boolean; canManageBilling: boolean } | { state: 'out-of-hours'; cause: 'hours' | 'cap'; canManageBilling: boolean } | { state: 'payment-issue'; canManageBilling: boolean } @@ -69,7 +77,11 @@ export function toOrcaCloudRunTargetAccess( case 'memberLimit': case 'machineSize': case 'other': - return { state: 'no-plan', lapsed: entitlement.reason === 'subscription_inactive' } + return { + state: 'no-plan', + lapsed: entitlement.reason === 'subscription_inactive', + canManageBilling + } } } @@ -89,7 +101,9 @@ export function getOrcaCloudPick(access: OrcaCloudRunTargetAccess): OrcaCloudPic case 'signed-out': return { kind: 'act', run: signInThenSelectOrcaCloud } case 'no-plan': - return { kind: 'act', run: () => openPlansPage('sandbox') } + return access.canManageBilling + ? { kind: 'act', run: choosePlanThenSelectOrcaCloud } + : { kind: 'blocked' } case 'out-of-hours': return access.canManageBilling ? { kind: 'act', run: openOverageSettings } diff --git a/src/renderer/src/components/new-workspace/use-run-target-orca-cloud.ts b/src/renderer/src/components/new-workspace/use-run-target-orca-cloud.ts index bacb45b0041..040b4594d03 100644 --- a/src/renderer/src/components/new-workspace/use-run-target-orca-cloud.ts +++ b/src/renderer/src/components/new-workspace/use-run-target-orca-cloud.ts @@ -28,7 +28,8 @@ export function getRunTargetOrcaCloudOffer({ case 'needs-sign-in': return override ?? { state: 'signed-out' } case 'needs-capability': - return override ?? { state: 'no-plan', lapsed: false } + // No billing read without the capability; unknown roles get the owner's way forward. + return override ?? { state: 'no-plan', lapsed: false, canManageBilling: true } case 'offered': case 'unavailable': return null @@ -65,8 +66,15 @@ export function useRunTargetOrcaCloud({ runBlockedAction: () => void select: (recipe: EphemeralVmRecipeOption | null) => void } { - const fetched = useOrcaCloudRunTargetAccess(!override && recipes.some(isOrcaCloudRecipe)) - const access = offer ?? override ?? fetched + const fetched = useOrcaCloudRunTargetAccess( + !override && (recipes.some(isOrcaCloudRecipe) || offer?.state === 'no-plan') + ) + // Without the capability the offer can't know the role; billing's answer can, so members are + // told to ask an owner or admin instead of being sent to plans they can't buy. + const access = + !override && offer?.state === 'no-plan' && fetched.state === 'no-plan' + ? fetched + : (offer ?? override ?? fetched) const [pending, setPending] = useState(false) const pickToken = useRef(0) const openRef = useRef(open) diff --git a/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx b/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx index 49ce22447c9..870c7d5c2ec 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx @@ -7,6 +7,7 @@ import { Loader2, LifeBuoy, LogIn, + Monitor, PanelLeft, Pencil, RotateCcw, @@ -31,6 +32,8 @@ export type VmBuildFailureActions = { onDismiss: () => void onSeePlans: () => void onOpenBilling: () => void + /** Billing refusals: reopen the composer with the same prompt, on this computer. */ + onCreateLocally: () => void onSignIn: () => void onShowWorkspaces: () => void onContactSupport: () => void @@ -46,6 +49,8 @@ type Action = { disabled?: boolean /** In progress: stays focusable (aria-disabled + aria-busy) so keyboard focus isn't dropped. */ pending?: boolean + /** Outline even when first: a way around the problem, not its fix. */ + secondary?: boolean } /** At most two visible actions; the rest sit in a More menu so the way forward stays obvious. */ @@ -78,18 +83,34 @@ function failureActions( icon: , onClick: actions.onEditRecipe } + // Every billing refusal can still run the same prompt here instead of waiting on billing. + const createLocally: Action = { + key: 'local', + label: t( + 'auto.components.worktree.creation.VmBuildFailureCard.createOnThisComputer', + 'Create on this computer instead' + ), + icon: , + onClick: actions.onCreateLocally, + secondary: true + } switch (failure.kind) { case 'plan': - return { - primary: [ - { - key: 'plans', - label: t('auto.lib.orcaCloudSession.seePlans', 'See plans'), - onClick: actions.onSeePlans + // Members can't choose a plan; their card tells them who can. + return canManageBilling + ? { + primary: [ + { + key: 'plans', + label: t('auto.lib.orcaCloudSession.seePlans', 'See plans'), + onClick: actions.onSeePlans + }, + // Paying elsewhere (another device, the portal) is picked up by trying again. + tryAgain + ], + more: [createLocally] } - ], - more: [] - } + : { primary: [createLocally], more: [] } case 'hoursUsed': case 'spendCap': case 'payment': @@ -116,9 +137,10 @@ function failureActions( ), icon: , onClick: actions.onOpenBilling - } + }, + createLocally ] - : [], + : [createLocally], more: [] } case 'signIn': @@ -288,7 +310,7 @@ export function FailureActionRow({ key={action.key} type="button" size="xs" - variant={index === 0 ? 'default' : 'outline'} + variant={index === 0 && !action.secondary ? 'default' : 'outline'} onClick={action.pending ? undefined : action.onClick} disabled={action.disabled} aria-disabled={action.pending || undefined} diff --git a/src/renderer/src/components/worktree-creation/VmBuildFailureCard.test.tsx b/src/renderer/src/components/worktree-creation/VmBuildFailureCard.test.tsx index 80c51125f67..c50f250e083 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildFailureCard.test.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildFailureCard.test.tsx @@ -15,6 +15,7 @@ const actions = (): VmBuildFailureActions => ({ onDismiss: vi.fn(), onSeePlans: vi.fn(), onOpenBilling: vi.fn(), + onCreateLocally: vi.fn(), onSignIn: vi.fn(), onShowWorkspaces: vi.fn(), onContactSupport: vi.fn(), @@ -25,10 +26,12 @@ const actions = (): VmBuildFailureActions => ({ function Sheet({ failure, connectingGitHub = false, + canManageBilling = true, handlers }: { failure: VmBuildFailure | null connectingGitHub?: boolean + canManageBilling?: boolean handlers: VmBuildFailureActions }) { return ( @@ -42,7 +45,7 @@ function Sheet({ vmKept={false} firstErrorLine={null} canFixWithAgent={false} - canManageBilling + canManageBilling={canManageBilling} signingIn={false} connectingGitHub={connectingGitHub} repoName="acme/web" @@ -80,3 +83,32 @@ it('keeps a pending Connect GitHub focusable and ignores repeat clicks', () => { expect(handlers.onConnectGitHub).not.toHaveBeenCalled() expect(document.activeElement).toBe(button) }) + +it('offers an owner See plans then Try again on no plan', () => { + const handlers = actions() + render() + const buttons = screen.getAllByRole('button').map((button) => button.textContent?.trim()) + expect(buttons.slice(0, 2)).toEqual(['See plans', 'Try again']) + fireEvent.click(screen.getByRole('button', { name: /Try again/ })) + expect(handlers.onRetry).toHaveBeenCalledOnce() +}) + +it.each(['plan', 'hoursUsed', 'spendCap', 'payment'] as const)( + 'gives a member running into %s a way to create on this computer, not a billing fix', + (kind) => { + const handlers = actions() + render() + expect( + screen.queryByRole('button', { name: /See plans|Raise spend cap|Turn on overage/ }) + ).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /Create on this computer instead/ })) + expect(handlers.onCreateLocally).toHaveBeenCalledOnce() + } +) + +it("keeps Create on this computer instead beside an owner's billing fix", () => { + const handlers = actions() + render() + const buttons = screen.getAllByRole('button').map((button) => button.textContent?.trim()) + expect(buttons.slice(0, 2)).toEqual(['Raise spend cap', 'Create on this computer instead']) +}) diff --git a/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx b/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx index 343674b9ebc..e2f3bf70f8c 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx @@ -28,6 +28,8 @@ import { import { deriveVmBuildSteps, isOrcaCloudBuild, type VmBuildStep } from './vm-build-steps' import { useMinimumRunningSteps } from './use-minimum-running-steps' import { useRetryAfterCloudSignIn } from './use-retry-after-cloud-sign-in' +import { typedComposerPrompt } from './vm-build-composer-return' +import { requestOrcaCloudAfterCheckout } from '@/lib/orca-cloud-after-checkout' import { connectOrcaCloudGitHub, orcaCloudRepoName, @@ -84,6 +86,7 @@ export function VmBuildSheet({ onCancel, onRetry, onDismiss, + onReturnToComposer, onEditRecipe, agentSignInRow, cloudAgentStep, @@ -98,6 +101,8 @@ export function VmBuildSheet({ onRetry: () => void /** Leaves the build; when a VM is still up the caller confirms before it is deleted. */ onDismiss: () => void + /** Leaves a refused build for the composer with its prompt; `local` runs it on this computer. */ + onReturnToComposer: (options: { local: boolean }) => void onEditRecipe: () => void /** Fixtures only: a fixed sign-in row in place of the live one. */ agentSignInRow?: React.ReactNode @@ -269,8 +274,17 @@ export function VmBuildSheet({ : onRetry, onEditRecipe, onOpenAnyway: () => resolveVmEnvironmentCheckDecision(creationId, 'open-anyway'), - onDismiss, - onSeePlans: () => openPlansPage('sandbox'), + // Nothing was created, so leaving a billing refusal hands the typed prompt back. + onDismiss: + isBillingFailure(failure) && typedComposerPrompt(entry).trim() + ? () => onReturnToComposer({ local: false }) + : onDismiss, + onSeePlans: () => { + // Once paid, the plans dialog closes and retries this create. + requestOrcaCloudAfterCheckout({ kind: 'retry', creationId }) + openPlansPage('sandbox') + }, + onCreateLocally: () => onReturnToComposer({ local: true }), onOpenBilling: () => { const store = useAppStore.getState() store.openSettingsTarget({ pane: 'orca-account', repoId: null }) diff --git a/src/renderer/src/components/worktree-creation/VmBuildSheetCloudFailures.fixture.tsx b/src/renderer/src/components/worktree-creation/VmBuildSheetCloudFailures.fixture.tsx index fec59dc74aa..fc3505df430 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildSheetCloudFailures.fixture.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildSheetCloudFailures.fixture.tsx @@ -13,12 +13,29 @@ import { // Orca Cloud refusals and failures, one card each. +function stubBillingRole(canManageBilling: boolean): void { + window.api.orcaProfiles.billingStatus = () => + Promise.resolve({ + status: 'ok', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the card reads only canManageBilling. + entitlement: { canManageBilling } as never + }) +} + +// An owner unless stated: the card's billing fix depends on the role billing reports. function cloudFailure(errorCode: string | undefined, error: string, log = CLOUD_LOG) { - return frame( + const fixture = frame( entry({ status: 'error', errorCode, provisioningLog: log, errorAt: T0 + 2_000 }, CLOUD_RECIPE), error, T0 + 2_000 ) + return defineFixture({ + ...fixture, + setup: () => { + seedFixtureRepo() + stubBillingRole(true) + } + }) } export const cloudPlanRequired = cloudFailure( @@ -63,12 +80,7 @@ function asMember(fixture: ReturnType) { ...fixture, setup: () => { seedFixtureRepo() - window.api.orcaProfiles.billingStatus = () => - Promise.resolve({ - status: 'ok', - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the card reads only canManageBilling. - entitlement: { canManageBilling: false } as never - }) + stubBillingRole(false) } }) } @@ -160,3 +172,15 @@ export const cloudPaymentDue = cloudFailure( ) export const cloudNetwork = cloudFailure(undefined, 'fetch failed', CLOUD_LOG) + +// An owner without a plan gets See plans and Try again; More holds "Create on this computer +// instead", which every billing card offers. +export const cloudPlanRequiredMore = defineFixture({ + ...cloudPlanRequired, + interact: async (frameElement) => { + Array.from(frameElement.querySelectorAll('button')) + .find((button) => button.textContent?.trim() === 'More') + ?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 })) + await new Promise((resolve) => setTimeout(resolve, 300)) + } +}) diff --git a/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx b/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx index 54dfb17f755..d7aca945fb2 100644 --- a/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx +++ b/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx @@ -16,6 +16,7 @@ import { } from './worktree-creation-clock' import { VmBuildSheet } from './VmBuildSheet' import { VmDeleteConfirmDialog } from './VmDeleteConfirmDialog' +import { composerDataFromRefusedCreate } from './vm-build-composer-return' /** Debounced in-frame creation placeholder: tab strip names the workspace while body shows status, enabling same-frame handoff without duplicating labels. */ export default function WorktreeCreationPanel({ @@ -91,6 +92,15 @@ export default function WorktreeCreationPanel({ onCancel={dismiss} onRetry={() => retryBackgroundWorktreeCreation(creationId)} onDismiss={leave} + onReturnToComposer={({ local }) => { + const data = composerDataFromRefusedCreate(entry, { local }) + dismiss() + const store = useAppStore.getState() + // Never replace a dialog the user opened meanwhile. + if (store.activeModal === 'none') { + store.openModal('new-workspace-composer', data) + } + }} onEditRecipe={() => { const state = useAppStore.getState() state.openSettingsTarget({ diff --git a/src/renderer/src/components/worktree-creation/vm-build-composer-return.test.ts b/src/renderer/src/components/worktree-creation/vm-build-composer-return.test.ts new file mode 100644 index 00000000000..bd377bf9d55 --- /dev/null +++ b/src/renderer/src/components/worktree-creation/vm-build-composer-return.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import type { WorktreeCreationRequest } from '@/lib/workspace/creation/pending-worktree-creation' +import { composerDataFromRefusedCreate, typedComposerPrompt } from './vm-build-composer-return' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the fields read here. +const request = { + repoId: 'repo-1', + name: 'otter', + displayName: 'Fix login', + displayNameKind: 'user', + baseBranch: 'main', + agent: 'claude', + note: 'Fix the login redirect', + telemetrySource: 'sidebar', + ephemeralVmRecipe: { + sourceRepoId: 'repo-1', + recipeId: 'cloud', + projectId: 'p', + provider: 'orca-cloud' + } +} as unknown as WorktreeCreationRequest + +describe('composer data for a create billing refused', () => { + it('restores the typed prompt and keeps Orca Cloud on Dismiss', () => { + expect(composerDataFromRefusedCreate({ request }, { local: false })).toEqual({ + initialRepoId: 'repo-1', + initialEphemeralVmRecipeId: 'cloud', + prefilledName: 'Fix login', + initialBaseBranch: 'main', + recovery: { note: 'Fix the login redirect', agent: 'claude' }, + telemetrySource: 'sidebar' + }) + }) + + it('drops the sandbox for Create on this computer instead', () => { + const data = composerDataFromRefusedCreate({ request }, { local: true }) + expect(data.initialEphemeralVmRecipeId).toBeUndefined() + expect(data.recovery?.note).toBe('Fix the login redirect') + }) + + it('prefers a prompt queued during the build and never restores a generated name', () => { + const entry = { + heldChatMessage: 'Queued instead', + request: { ...request, displayNameKind: 'generated' as const } + } + expect(typedComposerPrompt(entry)).toBe('Queued instead') + expect(composerDataFromRefusedCreate(entry, { local: true }).prefilledName).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/worktree-creation/vm-build-composer-return.ts b/src/renderer/src/components/worktree-creation/vm-build-composer-return.ts new file mode 100644 index 00000000000..1db89f80ec2 --- /dev/null +++ b/src/renderer/src/components/worktree-creation/vm-build-composer-return.ts @@ -0,0 +1,33 @@ +import type { ComposerModalData } from '@/components/new-workspace/composer-modal-data' +import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation' + +type ReturnSource = Pick + +/** What the user typed for the agent: a prompt queued during the build, else the composer's. */ +export function typedComposerPrompt(entry: ReturnSource): string { + return entry.heldChatMessage ?? entry.request.note +} + +/** + * The composer as it was submitted, for a create billing refused before anything started: same + * project, name, base, agent and prompt. `local` drops the sandbox so it runs on this computer; + * otherwise the same Run-on target is kept. + */ +export function composerDataFromRefusedCreate( + entry: ReturnSource, + { local }: { local: boolean } +): ComposerModalData { + const { request } = entry + const recipe = request.ephemeralVmRecipe + return { + initialRepoId: recipe?.sourceRepoId ?? request.repoId, + ...(!local && recipe ? { initialEphemeralVmRecipeId: recipe.recipeId } : {}), + // A generated name is regenerated; only a name the user typed comes back. + ...(request.displayNameKind === 'user' && request.displayName + ? { prefilledName: request.displayName } + : {}), + ...(request.baseBranch ? { initialBaseBranch: request.baseBranch } : {}), + recovery: { note: typedComposerPrompt(entry), agent: request.agent }, + telemetrySource: request.telemetrySource ?? 'unknown' + } +} diff --git a/src/renderer/src/components/worktree-creation/vm-build-sheet-fixture-support.tsx b/src/renderer/src/components/worktree-creation/vm-build-sheet-fixture-support.tsx index 26f66cb9b16..c9a136dd578 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-sheet-fixture-support.tsx +++ b/src/renderer/src/components/worktree-creation/vm-build-sheet-fixture-support.tsx @@ -41,8 +41,12 @@ export function entry( provisioningLog: log, provisioningLogStamps: stampsFor(log === '' ? 0 : log.split('\n').length), phaseLog: [{ phase: 'provisioning-vm', at: T0 }], - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sheet reads only name and ephemeralVmRecipe from the request. - request: { name: 'fix-login', ephemeralVmRecipe: recipe } as PendingWorktreeCreation['request'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sheet reads only name, note and ephemeralVmRecipe from the request. + request: { + name: 'fix-login', + note: 'Fix the login redirect', + ephemeralVmRecipe: recipe + } as PendingWorktreeCreation['request'], ...patch } } @@ -64,6 +68,7 @@ export function sheet( onCancel={() => {}} onRetry={() => {}} onDismiss={() => {}} + onReturnToComposer={() => {}} onEditRecipe={() => {}} agentSignInRow={agentSignInRow} stepTimings={FIXTURE_STEP_TIMINGS} diff --git a/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts b/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts index 9737ba987c3..4c12ae15ea6 100644 --- a/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts +++ b/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts @@ -6,6 +6,7 @@ import type { EphemeralVmFailure } from '../../../shared/ephemeral-vm-failure-co import { ephemeralVmErrorText } from '@/lib/ephemeral-vm-error-copy' import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision' import { takeOrcaCloudSelectionAfterSignIn } from '@/lib/orca-cloud-select-after-sign-in' +import { onOrcaCloudCheckoutReturn } from '@/lib/orca-cloud-after-checkout' type EphemeralVmRecipeOptionsArgs = { enabled: boolean @@ -143,6 +144,9 @@ export function useEphemeralVmRecipeOptions(args: EphemeralVmRecipeOptionsArgs): load(false) }, [authKey, load]) + // A paid Checkout from the Run-on row: reload so the left selection intent picks Orca Cloud. + useEffect(() => onOrcaCloudCheckoutReturn(() => load(false)), [load]) + useEffect(() => { if (!window.api.plugins?.onChanged) { return diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b9bcc9b9409..e40ed058260 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4217,7 +4217,8 @@ "connectGitHubFailed": "Couldn’t open GitHub. Try again in a moment.", "noGithubRemoteBody2": "Orca Cloud clones your code from GitHub, and this repository has no GitHub remote. Push it to GitHub, then try again. Nothing was created, and you weren’t charged.", "howToPushToGitHub": "How to push to GitHub", - "spendCapReachedBodyMember2": "Your team has reached this month’s spend cap. Ask an owner or admin to raise the spend cap. Nothing was created, and you weren’t charged." + "spendCapReachedBodyMember2": "Your team has reached this month’s spend cap. Ask an owner or admin to raise the spend cap. Nothing was created, and you weren’t charged.", + "createOnThisComputer": "Create on this computer instead" }, "VmBuildLog": { "jumpToError": "Jump to first error", @@ -16616,7 +16617,9 @@ "orcaCloudAskPayment": "Ask an owner or admin to update the payment method.", "orcaCloudIdleSleep_one": "Sleeps after {{count}} minute idle", "orcaCloudIdleSleep_other": "Sleeps after {{count}} minutes idle", - "orcaCloudAskAddHoursOnly": "Ask an owner or admin to add hours." + "orcaCloudAskAddHoursOnly": "Ask an owner or admin to add hours.", + "orcaCloudAskChoosePlan": "Ask an owner or admin to choose one.", + "orcaCloudAskRaiseSpendCap": "Ask an owner or admin to raise the spend cap." }, "projectCombobox": { "recentHeading": "Recent", diff --git a/src/renderer/src/lib/orca-cloud-after-checkout.test.ts b/src/renderer/src/lib/orca-cloud-after-checkout.test.ts new file mode 100644 index 00000000000..ec6e6d4d060 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud-after-checkout.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + cancelOrcaCloudAfterCheckout, + emitOrcaCloudCheckoutReturn, + onOrcaCloudCheckoutReturn, + requestOrcaCloudAfterCheckout, + resetOrcaCloudAfterCheckout, + takeOrcaCloudCheckoutReturn +} from './orca-cloud-after-checkout' + +const composerOpen = { composerOpen: true, hasCreation: () => true } +const composerClosed = { composerOpen: false, hasCreation: () => true } + +describe('select Orca Cloud after checkout', () => { + afterEach(() => resetOrcaCloudAfterCheckout()) + + it('lands on the Team page when no wall asked', () => { + expect(takeOrcaCloudCheckoutReturn(composerOpen)).toEqual({ kind: 'team-page' }) + }) + + it('returns to the open composer once, then the intent is consumed', () => { + requestOrcaCloudAfterCheckout({ kind: 'composer' }, 1_000) + expect(takeOrcaCloudCheckoutReturn(composerOpen, 2_000)).toEqual({ + kind: 'select-in-composer' + }) + expect(takeOrcaCloudCheckoutReturn(composerOpen, 3_000)).toEqual({ kind: 'team-page' }) + }) + + it('reopens the composer on Orca Cloud when it closed while paying', () => { + requestOrcaCloudAfterCheckout({ kind: 'composer' }, 1_000) + expect(takeOrcaCloudCheckoutReturn(composerClosed, 2_000)).toEqual({ kind: 'open-composer' }) + }) + + it('retries the refused creation, or falls back to the composer when it was dismissed', () => { + requestOrcaCloudAfterCheckout({ kind: 'retry', creationId: 'c1' }, 1_000) + expect(takeOrcaCloudCheckoutReturn(composerClosed, 2_000)).toEqual({ + kind: 'retry', + creationId: 'c1' + }) + requestOrcaCloudAfterCheckout({ kind: 'retry', creationId: 'gone' }, 1_000) + expect( + takeOrcaCloudCheckoutReturn({ composerOpen: false, hasCreation: () => false }, 2_000) + ).toEqual({ kind: 'open-composer' }) + }) + + it('drops a cancelled or abandoned intent', () => { + requestOrcaCloudAfterCheckout({ kind: 'composer' }, 1_000) + cancelOrcaCloudAfterCheckout() + expect(takeOrcaCloudCheckoutReturn(composerOpen, 2_000)).toEqual({ kind: 'team-page' }) + requestOrcaCloudAfterCheckout({ kind: 'composer' }, 0) + expect(takeOrcaCloudCheckoutReturn(composerOpen, 31 * 60_000)).toEqual({ kind: 'team-page' }) + }) + + it('notifies listening composers until they unsubscribe', () => { + const listener = vi.fn() + const off = onOrcaCloudCheckoutReturn(listener) + emitOrcaCloudCheckoutReturn() + off() + emitOrcaCloudCheckoutReturn() + expect(listener).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud-after-checkout.ts b/src/renderer/src/lib/orca-cloud-after-checkout.ts new file mode 100644 index 00000000000..3ac5247b793 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud-after-checkout.ts @@ -0,0 +1,82 @@ +// Choosing a plan from an Orca Cloud wall should land back on the work, not on the Team page: +// the Run-on picker returns to its open composer with Orca Cloud picked, and a build card that +// was refused for no plan retries that create. Like signInThenSelectOrcaCloud, the wall leaves +// an intent that the plans dialog consumes once Checkout reports paid. + +/** An intent older than this belongs to an abandoned Checkout and is ignored. */ +const INTENT_TTL_MS = 30 * 60_000 + +export type OrcaCloudCheckoutIntent = + /** From the Run-on picker: select Orca Cloud in the composer. */ + | { kind: 'composer' } + /** From a build card refused for no plan: retry that pending creation. */ + | { kind: 'retry'; creationId: string } + +/** Where a paid Checkout lands. */ +export type OrcaCloudCheckoutReturn = + /** No intent: the Team page's one-time "You're on Team" confirmation. */ + | { kind: 'team-page' } + /** The composer that asked is still open: pick Orca Cloud in it. */ + | { kind: 'select-in-composer' } + /** The composer closed meanwhile: reopen it on Orca Cloud. */ + | { kind: 'open-composer' } + | { kind: 'retry'; creationId: string } + +let pending: { intent: OrcaCloudCheckoutIntent; requestedAt: number } | null = null + +export function requestOrcaCloudAfterCheckout( + intent: OrcaCloudCheckoutIntent, + now = Date.now() +): void { + pending = { intent, requestedAt: now } +} + +/** Closing the plans page without paying drops the intent. */ +export function cancelOrcaCloudAfterCheckout(): void { + pending = null +} + +/** Consumes the intent and says where a paid Checkout should land. */ +export function takeOrcaCloudCheckoutReturn( + context: { + /** The new-workspace composer is the active modal. */ + composerOpen: boolean + /** The creation still exists (not dismissed while paying). */ + hasCreation: (creationId: string) => boolean + }, + now = Date.now() +): OrcaCloudCheckoutReturn { + const taken = pending + pending = null + if (!taken || now - taken.requestedAt > INTENT_TTL_MS) { + return { kind: 'team-page' } + } + const { intent } = taken + if (intent.kind === 'retry' && context.hasCreation(intent.creationId)) { + return { kind: 'retry', creationId: intent.creationId } + } + return context.composerOpen ? { kind: 'select-in-composer' } : { kind: 'open-composer' } +} + +type Listener = () => void +const listeners = new Set() + +/** The open composer's recipe list listens so a paid Checkout can pick Orca Cloud in it. */ +export function onOrcaCloudCheckoutReturn(listener: Listener): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function emitOrcaCloudCheckoutReturn(): void { + for (const listener of listeners) { + listener() + } +} + +/** Test seam. */ +export function resetOrcaCloudAfterCheckout(): void { + pending = null + listeners.clear() +} From 548e1549ae133deff42f6e8d593e11a7b03e7795 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 02:51:58 -0800 Subject: [PATCH 10/10] fix(billing): 'Use this computer instead' and glossary error order on member cards Design-squad sign-off tweaks for DS-15: the action reopens the composer rather than creating, so it says so; member billing bodies put 'Nothing was created, and you weren't charged.' before who to ask. --- .../worktree-creation/VmBuildFailureActions.tsx | 4 ++-- .../VmBuildFailureCard.test.tsx | 6 +++--- .../vm-build-composer-return.test.ts | 2 +- .../worktree-creation/vm-build-failure-copy.ts | 16 ++++++++-------- src/renderer/src/i18n/locales/en.json | 10 +++++----- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx b/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx index 870c7d5c2ec..63d8101f5ac 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx @@ -87,8 +87,8 @@ function failureActions( const createLocally: Action = { key: 'local', label: t( - 'auto.components.worktree.creation.VmBuildFailureCard.createOnThisComputer', - 'Create on this computer instead' + 'auto.components.worktree.creation.VmBuildFailureCard.useThisComputer', + 'Use this computer instead' ), icon: , onClick: actions.onCreateLocally, diff --git a/src/renderer/src/components/worktree-creation/VmBuildFailureCard.test.tsx b/src/renderer/src/components/worktree-creation/VmBuildFailureCard.test.tsx index c50f250e083..6ad77b291a4 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildFailureCard.test.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildFailureCard.test.tsx @@ -101,14 +101,14 @@ it.each(['plan', 'hoursUsed', 'spendCap', 'payment'] as const)( expect( screen.queryByRole('button', { name: /See plans|Raise spend cap|Turn on overage/ }) ).toBeNull() - fireEvent.click(screen.getByRole('button', { name: /Create on this computer instead/ })) + fireEvent.click(screen.getByRole('button', { name: /Use this computer instead/ })) expect(handlers.onCreateLocally).toHaveBeenCalledOnce() } ) -it("keeps Create on this computer instead beside an owner's billing fix", () => { +it("keeps Use this computer instead beside an owner's billing fix", () => { const handlers = actions() render() const buttons = screen.getAllByRole('button').map((button) => button.textContent?.trim()) - expect(buttons.slice(0, 2)).toEqual(['Raise spend cap', 'Create on this computer instead']) + expect(buttons.slice(0, 2)).toEqual(['Raise spend cap', 'Use this computer instead']) }) diff --git a/src/renderer/src/components/worktree-creation/vm-build-composer-return.test.ts b/src/renderer/src/components/worktree-creation/vm-build-composer-return.test.ts index bd377bf9d55..ab74de0f3ee 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-composer-return.test.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-composer-return.test.ts @@ -32,7 +32,7 @@ describe('composer data for a create billing refused', () => { }) }) - it('drops the sandbox for Create on this computer instead', () => { + it('drops the sandbox for Use this computer instead', () => { const data = composerDataFromRefusedCreate({ request }, { local: true }) expect(data.initialEphemeralVmRecipeId).toBeUndefined() expect(data.recovery?.note).toBe('Fix the login redirect') diff --git a/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts b/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts index dd9d63528d3..8a55cc84ea8 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts @@ -80,8 +80,8 @@ export function vmBuildFailureCopy( 'Cloud workspaces are part of the Team plan. Nothing was created, and you weren’t charged.' ) : t( - 'auto.components.worktree.creation.VmBuildFailureCard.planBodyMember2', - 'Ask an owner or admin to choose a Team plan. Nothing was created, and you weren’t charged.' + 'auto.components.worktree.creation.VmBuildFailureCard.planBodyMember3', + 'Nothing was created, and you weren’t charged. Ask an owner or admin to choose a Team plan.' ) } case 'hoursUsed': @@ -96,8 +96,8 @@ export function vmBuildFailureCopy( 'Your team has used this month’s sandbox hours. Turn on overage to keep starting sandboxes. Nothing was created, and you weren’t charged.' ) : t( - 'auto.components.worktree.creation.VmBuildFailureCard.outOfHoursBodyMember', - 'Your team has used this month’s sandbox hours. Ask an owner or admin to add hours. Nothing was created, and you weren’t charged.' + 'auto.components.worktree.creation.VmBuildFailureCard.outOfHoursBodyMember2', + 'Your team has used this month’s sandbox hours. Nothing was created, and you weren’t charged. Ask an owner or admin to add hours.' ) } case 'spendCap': @@ -112,8 +112,8 @@ export function vmBuildFailureCopy( 'Your team has reached this month’s spend cap. Raise it to keep starting sandboxes. Nothing was created, and you weren’t charged.' ) : t( - 'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedBodyMember2', - 'Your team has reached this month’s spend cap. Ask an owner or admin to raise the spend cap. Nothing was created, and you weren’t charged.' + 'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedBodyMember3', + 'Your team has reached this month’s spend cap. Nothing was created, and you weren’t charged. Ask an owner or admin to raise the spend cap.' ) } case 'payment': @@ -128,8 +128,8 @@ export function vmBuildFailureCopy( 'New sandboxes can’t start until the payment goes through. Nothing was created, and you weren’t charged.' ) : t( - 'auto.components.worktree.creation.VmBuildFailureCard.paymentFailedBodyMember', - 'New sandboxes can’t start until the payment goes through. Ask an owner or admin to update the payment method. Nothing was created, and you weren’t charged.' + 'auto.components.worktree.creation.VmBuildFailureCard.paymentFailedBodyMember2', + 'New sandboxes can’t start until the payment goes through. Nothing was created, and you weren’t charged. Ask an owner or admin to update the payment method.' ) } case 'concurrency': diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e40ed058260..752bc1899ae 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4193,15 +4193,12 @@ "openingGitHub": "Opening GitHub…", "connectGitHub": "Connect GitHub", "planBody2": "Cloud workspaces are part of the Team plan. Nothing was created, and you weren’t charged.", - "planBodyMember2": "Ask an owner or admin to choose a Team plan. Nothing was created, and you weren’t charged.", "outOfHoursTitle": "Out of sandbox hours", "outOfHoursBody": "Your team has used this month’s sandbox hours. Turn on overage to keep starting sandboxes. Nothing was created, and you weren’t charged.", - "outOfHoursBodyMember": "Your team has used this month’s sandbox hours. Ask an owner or admin to add hours. Nothing was created, and you weren’t charged.", "spendCapReachedTitle": "Spend cap reached", "spendCapReachedBody": "Your team has reached this month’s spend cap. Raise it to keep starting sandboxes. Nothing was created, and you weren’t charged.", "paymentFailedTitle": "Payment failed", "paymentFailedBody": "New sandboxes can’t start until the payment goes through. Nothing was created, and you weren’t charged.", - "paymentFailedBodyMember": "New sandboxes can’t start until the payment goes through. Ask an owner or admin to update the payment method. Nothing was created, and you weren’t charged.", "teamLimitTitle": "You’re at your team’s limit of running sandboxes", "limitBody": "Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.", "teamLimitBody": "Your team can run {{value0}} sandboxes at once ({{value1}} per seat). Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.", @@ -4217,8 +4214,11 @@ "connectGitHubFailed": "Couldn’t open GitHub. Try again in a moment.", "noGithubRemoteBody2": "Orca Cloud clones your code from GitHub, and this repository has no GitHub remote. Push it to GitHub, then try again. Nothing was created, and you weren’t charged.", "howToPushToGitHub": "How to push to GitHub", - "spendCapReachedBodyMember2": "Your team has reached this month’s spend cap. Ask an owner or admin to raise the spend cap. Nothing was created, and you weren’t charged.", - "createOnThisComputer": "Create on this computer instead" + "useThisComputer": "Use this computer instead", + "planBodyMember3": "Nothing was created, and you weren’t charged. Ask an owner or admin to choose a Team plan.", + "outOfHoursBodyMember2": "Your team has used this month’s sandbox hours. Nothing was created, and you weren’t charged. Ask an owner or admin to add hours.", + "spendCapReachedBodyMember3": "Your team has reached this month’s spend cap. Nothing was created, and you weren’t charged. Ask an owner or admin to raise the spend cap.", + "paymentFailedBodyMember2": "New sandboxes can’t start until the payment goes through. Nothing was created, and you weren’t charged. Ask an owner or admin to update the payment method." }, "VmBuildLog": { "jumpToError": "Jump to first error",