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/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/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/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 ( 0 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/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/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/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/plans/TeamPlanCard.tsx b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx index 5d91a56424c..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) } ) ] @@ -130,11 +135,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/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/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/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/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-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/cloud-remote-desktop/CloudRemoteDesktopToast.tsx b/src/renderer/src/components/cloud-remote-desktop/CloudRemoteDesktopToast.tsx index 90d57e271d7..80d1f85f836 100644 --- a/src/renderer/src/components/cloud-remote-desktop/CloudRemoteDesktopToast.tsx +++ b/src/renderer/src/components/cloud-remote-desktop/CloudRemoteDesktopToast.tsx @@ -27,7 +27,7 @@ type FailureCopy = { action: CloudRemoteDesktopNextAction | null /** Overrides the action's usual label (a failed wake retries the wake: "Try again"). */ actionLabel?: string - /** Asleep and starting are states, not errors. */ + /** Asleep, starting, signed out and not-available-here are states, not errors. */ tone?: 'neutral' } @@ -43,7 +43,8 @@ function failureCopy(t: Translate, code: CloudRemoteDesktopToastFailure): Failur 'components.cloudRemoteDesktop.notCloudDetail', 'Create a cloud workspace to run apps with a screen.' ), - action: 'create_workspace' + action: 'create_workspace', + tone: 'neutral' } case 'not_running': return { @@ -95,7 +96,8 @@ function failureCopy(t: Translate, code: CloudRemoteDesktopToastFailure): Failur 'components.cloudRemoteDesktop.notInstalledDetail', 'It was created before remote desktop was available. New cloud workspaces include it. Your files are safe.' ), - action: 'create_workspace' + action: 'create_workspace', + tone: 'neutral' } case 'signed_out': return { @@ -107,7 +109,8 @@ function failureCopy(t: Translate, code: CloudRemoteDesktopToastFailure): Failur 'components.cloudRemoteDesktop.signedOutDetail', 'Sign in, then open the remote desktop again.' ), - action: 'sign_in' + action: 'sign_in', + tone: 'neutral' } case 'forward_failed': return { 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/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/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} + + + {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/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/ApiKeyRow.tsx b/src/renderer/src/components/settings/cloud-api/ApiKeyRow.tsx new file mode 100644 index 00000000000..0c7fabf3c52 --- /dev/null +++ b/src/renderer/src/components/settings/cloud-api/ApiKeyRow.tsx @@ -0,0 +1,99 @@ +import { Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import { cn } from '@/lib/utils' +import type { CloudApiKey, CloudApiMe } from '../../../../../shared/cloud-api/cloud-api-types' +import { formatAbsoluteTime, formatKeyPrefix, formatRelativeFromNow } from './cloud-api-format' +import { PersonSentence } from './PersonName' +import { ScopeSummary } from './ScopeSummary' + +const EXPIRY_WARNING_MS = 7 * 86_400_000 + +/** One Orca API key: name, masked prefix, permission badge, who made it and when it was used. */ +export 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/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/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.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/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' }) @@ -206,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.' }} /> ) @@ -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..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, 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,14 +313,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} ) } @@ -341,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 9cf5ce3d993..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( @@ -170,3 +170,73 @@ 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 { + 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 failure( + t( + 'auto.components.settings.cloudRoutines.runError.startFailed', + 'The workspace couldn’t start, so nothing was charged.' + ) + ) + } + if (code === 'workspace_lost') { + 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 failure( + t( + 'auto.components.settings.cloudRoutines.runError.postFailed', + 'The run finished, but Orca couldn’t post the results.' + ) + ) + } + if (code === 'queue_expired') { + return neutral( + t( + 'auto.components.settings.cloudRoutines.runError.queueExpired', + 'The run waited too long to start and was skipped.' + ) + ) + } + if (code.endsWith('_interrupted')) { + return failure( + t( + 'auto.components.settings.cloudRoutines.runError.interrupted', + 'Orca Cloud restarted during the run.' + ) + ) + } + if (code === 'routine_changed') { + return neutral( + t( + 'auto.components.settings.cloudRoutines.runError.routineChanged', + 'The routine changed while this run was queued.' + ) + ) + } + 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 1312784f4ab..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, routineRunTone } from './cloud-api-copy' +import { + cloudApiErrorCopy, + describeRoutineRunError, + routineRunErrorCopy, + routineRunTone +} from './cloud-api-copy' import { describeRoutineTrigger, formatCountdown, @@ -83,3 +88,42 @@ 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, so 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.') + }) +}) + +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/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/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/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 ? ( + + + {title} + {detail ? {detail} : null} + +

    + ) + } return (
    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.useThisComputer', + 'Use 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..6ad77b291a4 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: /Use this computer instead/ })) + expect(handlers.onCreateLocally).toHaveBeenCalledOnce() + } +) + +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', 'Use 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/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-composer-return.test.ts b/src/renderer/src/components/worktree-creation/vm-build-composer-return.test.ts new file mode 100644 index 00000000000..ab74de0f3ee --- /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 Use 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-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/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/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/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' + }) +} 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/hooks/useOrcaCloudApiAvailability.ts b/src/renderer/src/hooks/useOrcaCloudApiAvailability.ts index 6428cb32313..4d5ee9986fc 100644 --- a/src/renderer/src/hooks/useOrcaCloudApiAvailability.ts +++ b/src/renderer/src/hooks/useOrcaCloudApiAvailability.ts @@ -1,14 +1,17 @@ import { useSyncExternalStore } from 'react' import type { CloudApiAvailability } from '../../../shared/cloud-api/cloud-api-types' +import { availabilityRetryDelay } from '@/lib/availability-retry' // One probe per renderer: whether this build has an Orca Cloud control plane (private/hosted builds). // Settings navigation and the panes share it, so the sections never flicker in and out. let availability: CloudApiAvailability | null = null let probing = false +let failedAttempts = 0 +let retryTimer: ReturnType | 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 @@ -17,16 +20,28 @@ function probe(): void { return } probing = true - void bridge - .availability() - .catch((): CloudApiAvailability => ({ status: 'unconfigured' })) - .then((next) => { + void bridge.availability().then( + (next) => { availability = next probing = false + failedAttempts = 0 for (const listener of listeners) { listener() } - }) + }, + () => { + // 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 + const delay = availabilityRetryDelay(failedAttempts++) + retryTimer = setTimeout(() => { + retryTimer = null + if (listeners.size > 0) { + probe() + } + }, delay) + } + ) } function subscribe(listener: () => void): () => void { 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/de.json b/src/renderer/src/i18n/locales/de.json index 7861da6b080..d44ad3ef561 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -13578,7 +13578,6 @@ "e740075063": "Entfernen", "b3548e59f4": "Bearbeiten", "fe2730d050": "{{value0}} kopieren", - "b22b128b2a": "Im Browser öffnen", "75aeea592f": "{{value0}} im Browser öffnen", "de349d4560": "Öffnet {{value0}}", "907eb53ed2": "Port weiterleiten", @@ -13599,10 +13598,6 @@ "729be0b4e5": "Art", "0f1d8cd324": "Bindung", "1c1c18cefc": "Adresse", - "f9528da632": "Prozess beenden", - "a223459512": "Details anzeigen", - "bdac206faf": "Details kopieren", - "792baeb7ed": "Adresse kopieren", "d41a8241ec": "Port", "a2a9fc6899": "Keine lokalen Ports erkannt", "f59c783b7a": "Port-Scan auf {{value0}} nicht verfügbar: {{value1}}", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a5e002c1791..db12e076459 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2291,7 +2291,7 @@ "hostNotReachable": "Host not reachable", "failedAnnouncement": "Couldn't forward :{{value0}}. {{value1}}", "busyInUseHere": "localhost:{{value0}} in use on this computer", - "listenFailedRetry": "Couldn't listen on this computer. Retry or pick another port" + "listenFailedTryAgain": "Couldn't listen on this computer. Try again or pick another port." }, "orcaCloud": { "workspaceActions": { @@ -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,7 +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." + "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", @@ -7934,13 +7935,11 @@ "title": "Let {{editor}} find your cloud workspaces?", "body": "{{editor}} connects through ~/.ssh/config. Orca will add this line at the top. Nothing else in the file changes.", "cancel": "Cancel", - "confirm": "Add Line and Open" + "addLineAndOpen": "Add line and open" }, "CloudSsh": { "noEditors": "Install VS Code or Cursor to open over SSH.", "menu": "SSH", - "copyCommand": "Copy SSH Command", - "revoke": "Revoke SSH Access", "firstUseHint": "Your first connection adds this computer’s key.", "unsupportedImage": "SSH isn’t available here", "unsupportedImageDetail": "This workspace was created before Orca Cloud supported SSH. New workspaces have it.", @@ -7953,7 +7952,6 @@ "failed": "Couldn’t set up SSH", "forwardFailedDetail": "Orca couldn’t reach the workspace. Check your connection and try again.", "failedDetail": "Orca Cloud didn’t answer. Try again in a moment.", - "retry": "Retry", "settingUp": "Setting up SSH…", "waking": "Waking the workspace…", "addingKey": "Adding this computer’s key…", @@ -7969,7 +7967,10 @@ "includeRemoveFailed": "Couldn’t edit ~/.ssh/config", "includeRemoved": "Removed from ~/.ssh/config", "includeRemovedDetail": "Editors no longer see Orca Cloud hosts. Opening one in an editor asks again.", - "revokedUnreachableDetail": "Deleted here. Orca couldn’t reach the workspace, so its copy is removed the next time this computer connects." + "revokedUnreachableDetail": "Deleted here. Orca couldn’t reach the workspace, so its copy is removed the next time this computer connects.", + "copySshCommand": "Copy SSH command", + "revokeSshAccess": "Revoke SSH access", + "tryAgain": "Try again" }, "teamSwitcher": { "create": "Create a team…", @@ -14240,26 +14241,57 @@ "ports": { "autoForward": "Auto-forward ports", "exclusive": "Same port number only", - "exclusiveDescription": "If the port is taken here, skip it instead of remapping.", "range": "Auto-forward port range", "rangeDescription": "Ports outside this range aren't auto-forwarded.", "rangeStart": "Lowest port", "rangeEnd": "Highest port", "notify": "Notify when a port is forwarded", "search": { - "title": "Port Forwarding", "ports": "ports", "forward": "forward", "localhost": "localhost", "tunnel": "tunnel", "devServer": "dev server", - "descriptionSandboxes": "Forward dev servers in sandboxes and on remote hosts to this computer." + "descriptionSandboxes": "Forward dev servers in sandboxes and on remote hosts to this computer.", + "titleSentenceCase": "Port forwarding" }, "rangeInvalid": "\"{{value0}}\" isn't a usable port. Use a port from 1 to 65535, lowest first.", "needsAutoForward": "Turn on Auto-forward ports to change this.", - "autoForwardDescriptionSandboxes": "Forward dev servers in sandboxes and on remote hosts to this computer." + "autoForwardDescriptionSandboxes": "Forward dev servers in sandboxes and on remote hosts to this computer.", + "exclusiveDescriptionInUse": "If the port is in use on this computer, skip it instead of remapping." + }, + "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 +14365,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 +14381,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", @@ -14424,7 +14435,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." }, @@ -14478,8 +14489,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 +14614,15 @@ "webhook": "Webhook", "manual": "Manual" }, + "runError": { + "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.", + "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 +14640,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": { @@ -14804,7 +14816,6 @@ "e740075063": "Remove", "b3548e59f4": "Edit", "fe2730d050": "Copy {{value0}}", - "b22b128b2a": "Open in Browser", "75aeea592f": "Open {{value0}} in Browser", "de349d4560": "opens {{value0}}", "907eb53ed2": "Forward a Port", @@ -14825,10 +14836,6 @@ "729be0b4e5": "Kind", "0f1d8cd324": "Bind", "1c1c18cefc": "Address", - "f9528da632": "Stop Process", - "a223459512": "Show Details", - "bdac206faf": "Copy Details", - "792baeb7ed": "Copy Address", "d41a8241ec": "Port", "a2a9fc6899": "No local ports detected", "f59c783b7a": "Port scan unavailable on {{value0}}: {{value1}}", @@ -14863,7 +14870,13 @@ "localPortRequiresPrivileges": "Port {{port}} requires elevated privileges. Use a local port ≥ 1024.", "remoteEmpty": "No ports yet. Dev servers started here are forwarded to localhost.", "remoteEmptyOff": "No ports yet. Auto-forward is off.", - "turnOnInSettings": "Turn on in Ports settings" + "turnOnInSettings": "Turn on in Ports settings", + "openInBrowser": "Open in browser", + "moreActionsForPort": "More actions for port {{port}}", + "stopProcess": "Stop process", + "copyAddress": "Copy address", + "copyDetails": "Copy details", + "showDetails": "Show details" }, "Search": { "1abfb25a66": "Type to search in files", @@ -16197,7 +16210,6 @@ "RuntimePortForward": { "pending": "Forwarding…", "useAnother": "Use another port", - "retry": "Retry", "forward": "Forward to localhost", "copied": "Copied {{value0}}", "localAddress": "localhost:{{value0}}", @@ -16211,7 +16223,8 @@ "stopAndSkip": "Stop and don't auto-forward :{{value0}}", "sandboxAsleep": "Sandbox is asleep", "remappedInUseOnComputer": "({{value0}} in use on this computer)", - "wake": "Wake" + "wake": "Wake", + "tryAgain": "Try again" }, "CloudShare": { "alreadyShared": "Already shared", @@ -16263,7 +16276,6 @@ "portInvalid": "Enter a port from 1 to 65535. Ports 2222, 6768 and 6769 are used by Orca.", "portLabel": "Port", "publicNote": "Anyone with this link can open port {{value0}} without signing in. Turn it off anytime in Ports → Shared links.", - "retry": "Retry", "revokeFailed": "Couldn’t turn it off. Try again.", "revoked": "Turned off", "revokedAnnouncement": "Link turned off", @@ -16279,7 +16291,9 @@ "turnOff": "Turn off", "turnOffLink": "Turn off link", "wakeHint": "Otherwise the link works only while the workspace is awake. Wake time counts toward your usage.", - "wakeLabel": "Let visitors wake the workspace" + "wakeLabel": "Let visitors wake the workspace", + "tryAgain": "Try again", + "sharePortLabel": "Share port {{port}}…" } } }, @@ -16603,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", @@ -20508,8 +20524,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.", @@ -20568,7 +20582,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", @@ -20589,8 +20605,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.", @@ -20613,7 +20627,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.", @@ -20624,7 +20638,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", @@ -20642,13 +20658,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.", @@ -20659,7 +20672,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", @@ -20670,11 +20687,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.", @@ -20685,7 +20702,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", @@ -20705,7 +20722,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.", @@ -20855,7 +20872,8 @@ "workspaceLinkCopied": "Workspace link copied", "you": "You", "youAreTyping": "You are typing", - "youAreViewing": "You are viewing" + "youAreViewing": "You are viewing", + "routines": "Routines" }, "orcaCloud": { "QueuedPrompt": { @@ -26203,7 +26221,6 @@ "tryAgain": "Try again", "startAnyway": "Start without signing in", "errors": { - "portBusy": "Port {{port}} is busy on this computer.", "portBusyDetail": "Another {{agent}} sign-in is probably open. Close it and try again.", "timeout": "Sign-in timed out.", "timeoutDetail": "Nothing came back from the browser in time.", @@ -26211,7 +26228,8 @@ "declinedDetail": "{{site}} reported that the sign-in was declined.", "offline": "Can't reach {{host}}.", "offlineDetail": "Your prompt stays here until it reconnects.", - "failed": "Sign-in failed." + "failed": "Sign-in failed.", + "portInUse": "Port {{port}} is in use on this computer." }, "cancelledToast": "Sign-in cancelled. The agent did not start.", "copyPrompt": "Copy prompt", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 650c3114e52..ea40d7898b0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13237,7 +13237,6 @@ "e740075063": "Eliminar", "b3548e59f4": "Editar", "fe2730d050": "Copiar {{value0}}", - "b22b128b2a": "Abrir en el navegador", "75aeea592f": "Abrir {{value0}} en el navegador", "de349d4560": "abre {{value0}}", "907eb53ed2": "Reenviar un puerto", @@ -13258,10 +13257,6 @@ "729be0b4e5": "Tipo", "0f1d8cd324": "Bind", "1c1c18cefc": "Dirección", - "f9528da632": "Detener proceso", - "a223459512": "Mostrar detalles", - "bdac206faf": "Copiar detalles", - "792baeb7ed": "Copiar dirección", "d41a8241ec": "Puerto", "a2a9fc6899": "No se detectaron puertos locales", "f59c783b7a": "Escaneo de puertos no disponible en {{value0}}: {{value1}}", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index a107ce60742..d3bc9765917 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -13480,7 +13480,6 @@ "e740075063": "Supprimer", "b3548e59f4": "Édition", "fe2730d050": "Copier {{value0}}", - "b22b128b2a": "Ouvrir dans le navigateur", "75aeea592f": "Ouvrir {{value0}} dans le navigateur", "de349d4560": "ouvre {{value0}}", "907eb53ed2": "Transférer un port", @@ -13501,10 +13500,6 @@ "729be0b4e5": "Type", "0f1d8cd324": "Bind", "1c1c18cefc": "Adresse", - "f9528da632": "Arrêter le processus", - "a223459512": "Afficher les détails", - "bdac206faf": "Copier les détails", - "792baeb7ed": "Copier l'adresse", "d41a8241ec": "Port", "a2a9fc6899": "Aucun port local détecté", "f59c783b7a": "Scan des ports indisponible sur {{value0}} : {{value1}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4a8fe2ff9f7..f54f01172a3 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -12931,7 +12931,6 @@ "e740075063": "削除", "b3548e59f4": "編集", "fe2730d050": "{{value0}}をコピー", - "b22b128b2a": "ブラウザで開く", "75aeea592f": "ブラウザで{{value0}}を開く", "de349d4560": "{{value0}} を開く", "907eb53ed2": "ポートを転送する", @@ -12952,10 +12951,6 @@ "729be0b4e5": "種類", "0f1d8cd324": "バインド", "1c1c18cefc": "アドレス", - "f9528da632": "プロセスの停止", - "a223459512": "詳細を表示", - "bdac206faf": "詳細をコピー", - "792baeb7ed": "アドレスをコピー", "d41a8241ec": "ポート", "a2a9fc6899": "ローカルポートが検出されませんでした", "f59c783b7a": "{{value0}} ではポートスキャンを利用できません: {{value1}}", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3f9c6c5a583..11ab1bea587 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12931,7 +12931,6 @@ "e740075063": "제거", "b3548e59f4": "편집", "fe2730d050": "{{value0}} 복사", - "b22b128b2a": "브라우저에서 열기", "75aeea592f": "브라우저에서 {{value0}} 열기", "de349d4560": "{{value0}} 열기", "907eb53ed2": "포트 전달", @@ -12952,10 +12951,6 @@ "729be0b4e5": "종류", "0f1d8cd324": "바인딩", "1c1c18cefc": "주소", - "f9528da632": "프로세스 중지", - "a223459512": "세부정보 표시", - "bdac206faf": "세부정보 복사", - "792baeb7ed": "주소 복사", "d41a8241ec": "포트", "a2a9fc6899": "로컬 포트가 감지되지 않았습니다.", "f59c783b7a": "{{value0}}에서 포트 스캔을 사용할 수 없습니다: {{value1}}", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 645d0e6cdaa..461b4f20b0c 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -10507,7 +10507,6 @@ "e740075063": "Remover", "b3548e59f4": "Editar", "fe2730d050": "Copiar {{value0}}", - "b22b128b2a": "Abrir no Navegador", "75aeea592f": "Abrir {{value0}} no Navegador", "de349d4560": "abre {{value0}}", "907eb53ed2": "Encaminhar uma Porta", @@ -10528,10 +10527,6 @@ "729be0b4e5": "Tipo", "0f1d8cd324": "Bind", "1c1c18cefc": "Endereço", - "f9528da632": "Parar Processo", - "a223459512": "Mostrar Detalhes", - "bdac206faf": "Copiar Detalhes", - "792baeb7ed": "Copiar Endereço", "d41a8241ec": "Porta", "a2a9fc6899": "Nenhuma porta local detectada", "f59c783b7a": "Varredura de portas indisponível em {{value0}}: {{value1}}", diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index 7d10cef0f86..602152d89bc 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -13942,7 +13942,6 @@ "e740075063": "Удалить", "b3548e59f4": "Редактировать", "fe2730d050": "Копировать {{value0}}", - "b22b128b2a": "Открыть в браузере", "75aeea592f": "Открыть {{value0}} в браузере", "de349d4560": "открывает {{value0}}", "907eb53ed2": "Пробросить порт", @@ -13963,10 +13962,6 @@ "729be0b4e5": "Тип", "0f1d8cd324": "Адрес привязки", "1c1c18cefc": "Адрес", - "f9528da632": "Остановить процесс", - "a223459512": "Показать подробности", - "bdac206faf": "Скопировать сведения", - "792baeb7ed": "Скопировать адрес", "d41a8241ec": "Порт", "a2a9fc6899": "Локальные порты не обнаружены", "f59c783b7a": "Сканирование портов на {{value0}} недоступно: {{value1}}", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index f1072b0c61a..47b65c71412 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -13562,7 +13562,6 @@ "e740075063": "Kaldır", "b3548e59f4": "Düzenle", "fe2730d050": "{{value0}} Kopyala", - "b22b128b2a": "Tarayıcıda Aç", "75aeea592f": "{{value0}} öğesini Tarayıcıda Aç", "de349d4560": "{{value0}} açar", "907eb53ed2": "Bir Port Yönlendir", @@ -13583,10 +13582,6 @@ "729be0b4e5": "Tür", "0f1d8cd324": "Bağlama", "1c1c18cefc": "Adres", - "f9528da632": "İşlemi Durdur", - "a223459512": "Ayrıntıları Göster", - "bdac206faf": "Ayrıntıları Kopyala", - "792baeb7ed": "Adresi Kopyala", "d41a8241ec": "Port", "a2a9fc6899": "Yerel port algılanmadı", "f59c783b7a": "{{value0}} üzerinde port taraması kullanılamıyor: {{value1}}", diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 6b8340e848a..d7ffd980735 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -13398,7 +13398,6 @@ "e740075063": "移除", "b3548e59f4": "編輯", "fe2730d050": "複製 {{value0}}", - "b22b128b2a": "在瀏覽器中開啟", "75aeea592f": "在瀏覽器中開啟 {{value0}}", "de349d4560": "開啟 {{value0}}", "907eb53ed2": "轉送連接埠", @@ -13419,10 +13418,6 @@ "729be0b4e5": "種類", "0f1d8cd324": "繫結", "1c1c18cefc": "位址", - "f9528da632": "停止行程", - "a223459512": "顯示詳情", - "bdac206faf": "複製詳情", - "792baeb7ed": "複製位址", "d41a8241ec": "連接埠", "a2a9fc6899": "未偵測到本機連接埠", "f59c783b7a": "{{value0}} 上無法進行連接埠掃描:{{value1}}", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7117b2b4262..6e666420865 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12931,7 +12931,6 @@ "e740075063": "移除", "b3548e59f4": "编辑", "fe2730d050": "复制 {{value0}}", - "b22b128b2a": "在浏览器中打开", "75aeea592f": "在浏览器中打开 {{value0}}", "de349d4560": "打开 {{value0}}", "907eb53ed2": "转发端口", @@ -12952,10 +12951,6 @@ "729be0b4e5": "种类", "0f1d8cd324": "绑定", "1c1c18cefc": "地址", - "f9528da632": "停止进程", - "a223459512": "显示详情", - "bdac206faf": "复制详情", - "792baeb7ed": "复制地址", "d41a8241ec": "端口", "a2a9fc6899": "未检测到本地端口", "f59c783b7a": "{{value0}} 上无法进行端口扫描:{{value1}}", 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)]! +} 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() +} diff --git a/src/renderer/src/lib/runtime-port-forwards/runtime-port-forwards-store.ts b/src/renderer/src/lib/runtime-port-forwards/runtime-port-forwards-store.ts index 3ef8ad9166f..507d88c49c4 100644 --- a/src/renderer/src/lib/runtime-port-forwards/runtime-port-forwards-store.ts +++ b/src/renderer/src/lib/runtime-port-forwards/runtime-port-forwards-store.ts @@ -245,8 +245,8 @@ export function runtimePortForwardFailureText(failure: RuntimePortForwardFailure case 'invalid_request': case 'listen_failed': return translate( - 'auto.lib.runtimePortForwards.listenFailedRetry', - "Couldn't listen on this computer. Retry or pick another port" + 'auto.lib.runtimePortForwards.listenFailedTryAgain', + "Couldn't listen on this computer. Try again or pick another port." ) case 'tunnel_failed': return translate('auto.lib.runtimePortForwards.hostNotReachable', 'Host not reachable') 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..23252573f91 100644 --- a/src/shared/orca-billing.ts +++ b/src/shared/orca-billing.ts @@ -16,10 +16,55 @@ 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' + case null: + case undefined: + return 'other' + default: + return 'other' + } +} + 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. */ diff --git a/src/shared/plugins/languages/protected-copy-keys.json b/src/shared/plugins/languages/protected-copy-keys.json index e250a717c34..2c939681d4a 100644 --- a/src/shared/plugins/languages/protected-copy-keys.json +++ b/src/shared/plugins/languages/protected-copy-keys.json @@ -440,21 +440,21 @@ "auto.components.sidebar.CloudSsh.opening", "auto.components.sidebar.CloudSsh.openingDetail", "auto.components.sidebar.CloudSsh.preparing", - "auto.components.sidebar.CloudSsh.retry", "auto.components.sidebar.CloudSsh.revoked", "auto.components.sidebar.CloudSsh.revokedDetail", "auto.components.sidebar.CloudSsh.revokedUnreachableDetail", "auto.components.sidebar.CloudSsh.revoking", "auto.components.sidebar.CloudSsh.settingUp", + "auto.components.sidebar.CloudSsh.tryAgain", "auto.components.sidebar.CloudSsh.unsupportedImage", "auto.components.sidebar.CloudSsh.unsupportedImageDetail", "auto.components.sidebar.CloudSsh.wakeFailed", "auto.components.sidebar.CloudSsh.wakeFailedDetail", "auto.components.sidebar.CloudSsh.waking", "auto.components.sidebar.CloudSsh.wakingTitle", + "auto.components.sidebar.CloudSshIncludeDialog.addLineAndOpen", "auto.components.sidebar.CloudSshIncludeDialog.body", "auto.components.sidebar.CloudSshIncludeDialog.cancel", - "auto.components.sidebar.CloudSshIncludeDialog.confirm", "auto.components.sidebar.CloudSshIncludeDialog.title", "auto.components.sidebar.DeleteNestedWorktreesDialog.unsupportedHost", "auto.components.sidebar.DeleteWorktreeDialog.2b56b35f53",