mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Merge remote-tracking branch 'private/cloud/integration' into cloud/run-actions-followup
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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<OrcaEntitlementDenialKind, Denial> = {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
)
|
||||
|
||||
@@ -336,7 +336,7 @@ function AgentSignInError({
|
||||
case 'port_busy':
|
||||
return (
|
||||
<ErrorLine
|
||||
title={t('agentSignIn.errors.portBusy', 'Port {{port}} is busy on this computer.', {
|
||||
title={t('agentSignIn.errors.portInUse', 'Port {{port}} is in use on this computer.', {
|
||||
port: String(view.port ?? '')
|
||||
})}
|
||||
detail={t(
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
OrcaBillingOverageArgs
|
||||
} from '../../../../shared/orca-billing'
|
||||
import { formatHours, includedUsagePercent } from './plan-billing-format'
|
||||
import { formatCents } from './plans/plans-pricing'
|
||||
import { formatCents, formatRateCents } from './plans/plans-pricing'
|
||||
import { ORCA_BILLING_OVERAGE_SECTION_ID } from '../new-workspace/orca-cloud-run-target-access'
|
||||
|
||||
/** "Resets and renews Dec 2" when both land the same day (monthly); separate parts otherwise. */
|
||||
@@ -162,17 +162,17 @@ export function OverageControls({
|
||||
const changed = capValid && capDollars.trim() !== savedDollars
|
||||
const paymentFailed = entitlement.payment.pastDueSince !== null
|
||||
const description = t(
|
||||
'auto.components.billing.overageDescription5',
|
||||
'Optional overage at {{value0}}/hour, up to a monthly spend cap you set ({{value1}} by default).',
|
||||
'auto.components.billing.overageDescription6',
|
||||
'Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set ({{cap}} by default).',
|
||||
{
|
||||
value0: formatCents(overage.centsPerHour, locale),
|
||||
value1: formatCents(entitlement.offers?.team.defaultSpendCapCents ?? 2500, locale)
|
||||
rate: formatRateCents(overage.centsPerHour, locale),
|
||||
cap: formatCents(entitlement.offers?.team.defaultSpendCapCents ?? 2500, locale)
|
||||
}
|
||||
)
|
||||
const descriptionOn = t(
|
||||
'auto.components.billing.overageDescriptionOn2',
|
||||
'Optional overage at {{value0}}/hour, up to a monthly spend cap you set.',
|
||||
{ value0: formatCents(overage.centsPerHour, locale) }
|
||||
'auto.components.billing.overageDescriptionOn3',
|
||||
'Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set.',
|
||||
{ rate: formatRateCents(overage.centsPerHour, locale) }
|
||||
)
|
||||
const capPercent =
|
||||
overage.spendCapCents > 0
|
||||
|
||||
@@ -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<CheckoutPhase, { kind: 'idle' }>
|
||||
@@ -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 (
|
||||
<dl className="space-y-2 rounded-lg border border-border bg-muted/40 p-4 text-sm">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
@@ -52,18 +52,27 @@ function OrderSummary({
|
||||
<dd className="text-muted-foreground tabular-nums">
|
||||
{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'
|
||||
}
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3 border-t border-border/60 pt-2">
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PlanBillingNotice | null>(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<void> => {
|
||||
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)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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 : (
|
||||
<p className="text-sm font-medium tabular-nums" aria-live="polite">
|
||||
{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)
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<typeof useTeamPageState>['arrival'] }) {
|
||||
const t = useTranslate()
|
||||
@@ -199,7 +200,11 @@ function TeamPageBody({ arrival }: { arrival: ReturnType<typeof useTeamPageState
|
||||
onManageBilling={openPortal}
|
||||
onCreateWorkspace={() => {
|
||||
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)}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) }
|
||||
)}
|
||||
</p>
|
||||
{entitlement.canManageBilling ? null : (
|
||||
|
||||
@@ -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<OrcaBillingStatusResult>((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 })
|
||||
})
|
||||
})
|
||||
@@ -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<OrcaBillingStatusResult> {
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ function sheet(
|
||||
onCancel={noop}
|
||||
onRetry={noop}
|
||||
onDismiss={noop}
|
||||
onReturnToComposer={noop}
|
||||
onEditRecipe={noop}
|
||||
cloudAgentStep={
|
||||
<VmBuildCloudAgentStepView model={model} actions={actions} initialMode={initialMode} />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -66,3 +66,25 @@ export const emptyTeam = defineFixture({
|
||||
render: () => <CloudWorkspacesHome />,
|
||||
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: () => <CloudWorkspacesHome />,
|
||||
interact: () =>
|
||||
waitFor(
|
||||
() =>
|
||||
document.querySelector('[data-cloud-workspace-row]') !== null &&
|
||||
[...document.querySelectorAll('button')].some((b) => b.textContent === 'Routines')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const [sectionFor, setSectionFor] = useState<CloudWorkspace | null>(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.
|
||||
|
||||
@@ -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 (
|
||||
<div className="mb-2 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -68,7 +78,13 @@ export function CloudWorkspacesToolbar({
|
||||
{meta}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto">
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{routinesAvailable ? (
|
||||
<Button size="xs" variant="ghost" onClick={openRoutines}>
|
||||
<CalendarClock />
|
||||
{t('auto.components.cloudWorkspaces.routines', 'Routines')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { translate } from '@/i18n/i18n'
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
import { getLastBillingStatus } from '@/components/billing/billing-summary-store'
|
||||
import { sandboxLimitFromBilling } from '@/components/worktree-creation/vm-build-failure-copy'
|
||||
import { classifyEntitlementDenial } from '../../../../shared/orca-billing'
|
||||
|
||||
/**
|
||||
* Whose seat a start counts against. The per-seat running cap counts the workspace owner's seat,
|
||||
@@ -23,7 +24,8 @@ export function describeError(
|
||||
)
|
||||
}
|
||||
if (error === 'entitlement_denied') {
|
||||
if (reason === 'concurrency_limit') {
|
||||
const denial = classifyEntitlementDenial(reason)
|
||||
if (denial === 'teamLimit') {
|
||||
const limit = sandboxLimitFromBilling(getLastBillingStatus())
|
||||
return limit
|
||||
? t(
|
||||
@@ -36,7 +38,7 @@ export function describeError(
|
||||
"You're at your team's limit of running sandboxes. Put one to sleep or delete it, then try again."
|
||||
)
|
||||
}
|
||||
if (reason === 'member_concurrency_limit') {
|
||||
if (denial === 'memberLimit') {
|
||||
return describeSeatLimit(owner, t)
|
||||
}
|
||||
return t(
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
CloudWorkspace,
|
||||
CloudWorkspaceListQuery
|
||||
} from '../../../../shared/cloud-workspaces/cloud-workspaces-wire'
|
||||
import { workspace } from './cloud-workspaces.test-fixture'
|
||||
import { useCloudWorkspaces } from './use-cloud-workspaces'
|
||||
|
||||
type ListResult = { ok: true; value: CloudWorkspace[] }
|
||||
let pending: ((result: ListResult) => void)[] = []
|
||||
const QUERY: CloudWorkspaceListQuery = { scope: 'team', archived: 'exclude' }
|
||||
|
||||
beforeEach(() => {
|
||||
pending = []
|
||||
vi.stubGlobal('api', {
|
||||
cloudWorkspaces: {
|
||||
available: () => Promise.resolve(true),
|
||||
list: vi.fn(
|
||||
() =>
|
||||
new Promise<ListResult>((resolve) => {
|
||||
pending.push(resolve)
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
const teamA = [workspace({ id: 'ws-a' })]
|
||||
const teamB = [workspace({ id: 'ws-b' })]
|
||||
|
||||
function render() {
|
||||
return renderHook(({ account }) => useCloudWorkspaces(QUERY, true, account), {
|
||||
initialProps: { account: 'user-1:team-a' }
|
||||
})
|
||||
}
|
||||
|
||||
describe('useCloudWorkspaces on a team switch', () => {
|
||||
it('hides the previous team list and reads the new team at once', async () => {
|
||||
const { result, rerender } = render()
|
||||
await act(async () => {})
|
||||
await act(async () => pending[0]({ ok: true, value: teamA }))
|
||||
expect(result.current.state).toMatchObject({ status: 'ready', workspaces: teamA })
|
||||
|
||||
rerender({ account: 'user-1:team-b' })
|
||||
// Not the old team's rows dimmed as "stale": nothing of team A is shown.
|
||||
expect(result.current.state).toEqual({ status: 'loading' })
|
||||
expect(result.current.stale).toBe(false)
|
||||
await act(async () => {})
|
||||
expect(pending).toHaveLength(2)
|
||||
|
||||
await act(async () => pending[1]({ ok: true, value: teamB }))
|
||||
expect(result.current.state).toMatchObject({ status: 'ready', workspaces: teamB })
|
||||
})
|
||||
|
||||
it('drops a list read for the previous team that lands after the switch', async () => {
|
||||
const { result, rerender } = render()
|
||||
await act(async () => {})
|
||||
expect(pending).toHaveLength(1)
|
||||
|
||||
rerender({ account: 'user-1:team-b' })
|
||||
await act(async () => {})
|
||||
await act(async () => pending[0]({ ok: true, value: teamA }))
|
||||
expect(result.current.state).toEqual({ status: 'loading' })
|
||||
|
||||
await act(async () => pending[1]({ ok: true, value: teamB }))
|
||||
expect(result.current.state).toMatchObject({ status: 'ready', workspaces: teamB })
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CloudWorkspace,
|
||||
CloudWorkspaceListQuery
|
||||
} from '../../../../shared/cloud-workspaces/cloud-workspaces-wire'
|
||||
import { availabilityRetryDelay } from '@/lib/availability-retry'
|
||||
import { installWindowVisibilityTimeoutPoller } from '@/lib/window-visibility-timeout-poller'
|
||||
|
||||
/** Presence is live; the list re-reads on this beat while visible (and on focus). */
|
||||
@@ -27,13 +28,32 @@ function setAvailable(value: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
let failedProbes = 0
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
export function cloudWorkspacesAvailable(): Promise<boolean> {
|
||||
availability ??= (
|
||||
window.api.cloudWorkspaces?.available().catch(() => false) ?? Promise.resolve(false)
|
||||
).then((value) => {
|
||||
setAvailable(value)
|
||||
return value
|
||||
})
|
||||
availability ??= (window.api.cloudWorkspaces?.available() ?? Promise.resolve(false)).then(
|
||||
(value) => {
|
||||
failedProbes = 0
|
||||
setAvailable(value)
|
||||
return value
|
||||
},
|
||||
() => {
|
||||
// A failed probe is not an answer: forget it and ask again with capped backoff, so one
|
||||
// transient error doesn't hide Orca Cloud until restart. Stop while nobody is listening;
|
||||
// the next subscriber asks again.
|
||||
availability = null
|
||||
if (!retryTimer) {
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null
|
||||
if (availabilityListeners.size > 0) {
|
||||
void cloudWorkspacesAvailable()
|
||||
}
|
||||
}, availabilityRetryDelay(failedProbes++))
|
||||
}
|
||||
return false
|
||||
}
|
||||
)
|
||||
return availability
|
||||
}
|
||||
|
||||
@@ -49,11 +69,24 @@ export function useCloudWorkspacesAvailable(): boolean | null {
|
||||
)
|
||||
}
|
||||
|
||||
export function useCloudWorkspaces(query: CloudWorkspaceListQuery, enabled: boolean) {
|
||||
/**
|
||||
* `account` is the signed-in `${userId}:${activeOrgId}`: it is part of the read's key, so a team
|
||||
* switch re-reads at once and a list read for the previous team is never shown or kept.
|
||||
*/
|
||||
function accountOf(key: string): string {
|
||||
const parsed: { account: string } = JSON.parse(key)
|
||||
return parsed.account
|
||||
}
|
||||
|
||||
export function useCloudWorkspaces(
|
||||
query: CloudWorkspaceListQuery,
|
||||
enabled: boolean,
|
||||
account: string
|
||||
) {
|
||||
const [state, setState] = useState<CloudWorkspacesState>({ status: 'loading' })
|
||||
const [reading, setReading] = useState(false)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const key = JSON.stringify(query)
|
||||
const key = JSON.stringify({ account, query })
|
||||
const keyRef = useRef(key)
|
||||
keyRef.current = key
|
||||
const refresh = useCallback(() => setReloadKey((k) => k + 1), [])
|
||||
@@ -77,7 +110,8 @@ export function useCloudWorkspaces(query: CloudWorkspaceListQuery, enabled: bool
|
||||
return
|
||||
}
|
||||
setReading(true)
|
||||
const listQuery: CloudWorkspaceListQuery = JSON.parse(key)
|
||||
const parsed: { query: CloudWorkspaceListQuery } = JSON.parse(key)
|
||||
const listQuery = parsed.query
|
||||
let result: Awaited<ReturnType<typeof window.api.cloudWorkspaces.list>>
|
||||
try {
|
||||
result = await window.api.cloudWorkspaces.list(listQuery)
|
||||
@@ -128,6 +162,10 @@ export function useCloudWorkspaces(query: CloudWorkspaceListQuery, enabled: bool
|
||||
}, [])
|
||||
|
||||
// A list or error read for an earlier filter is shown dimmed and busy until this one lands.
|
||||
const stale = (state.status === 'ready' || state.status === 'error') && state.key !== key
|
||||
return { state, stale, reading: reading && enabled, refresh, replace }
|
||||
// One read for another team is not shown at all: those are not this team's workspaces.
|
||||
const settled = state.status === 'ready' || state.status === 'error'
|
||||
const otherAccount = settled && accountOf(state.key) !== account
|
||||
const shown: CloudWorkspacesState = otherAccount ? { status: 'loading' } : state
|
||||
const stale = settled && !otherAccount && state.key !== key
|
||||
return { state: shown, stale, reading: reading && enabled, refresh, replace }
|
||||
}
|
||||
|
||||
+56
-3
@@ -142,7 +142,16 @@ function ProjectAndHostPreview({
|
||||
)
|
||||
}
|
||||
|
||||
export const closed = defineFixture({ width: 560, render: () => <ProjectAndHostPreview /> })
|
||||
export const closed = defineFixture({
|
||||
width: 560,
|
||||
// The browser preload answers agent keys and Cloud secrets with stubs the Orca Cloud agent hint
|
||||
// can't read; without them the hint shows nothing, as it does before they load.
|
||||
setup: () => {
|
||||
window.api.agentApiKeys = undefined
|
||||
window.api.cloudSecrets = undefined
|
||||
},
|
||||
render: () => <ProjectAndHostPreview />
|
||||
})
|
||||
export const narrow = defineFixture({ width: 360, render: () => <ProjectAndHostPreview /> })
|
||||
export const folderOnly = defineFixture({
|
||||
width: 560,
|
||||
@@ -186,7 +195,11 @@ export const hostMenuOrcaCloud = defineFixture({
|
||||
})
|
||||
export const hostMenuOrcaCloudLocked = defineFixture({
|
||||
...hostMenu,
|
||||
render: () => <ProjectAndHostPreview orcaCloud={{ state: 'no-plan', lapsed: false }} />
|
||||
render: () => (
|
||||
<ProjectAndHostPreview
|
||||
orcaCloud={{ state: 'no-plan', lapsed: false, canManageBilling: true }}
|
||||
/>
|
||||
)
|
||||
})
|
||||
export const orcaCloudSelected = defineFixture({
|
||||
...closed,
|
||||
@@ -196,7 +209,10 @@ export const orcaCloudSelected = defineFixture({
|
||||
export const orcaCloudSelectedNoPlan = defineFixture({
|
||||
...closed,
|
||||
render: () => (
|
||||
<ProjectAndHostPreview orcaCloud={{ state: 'no-plan', lapsed: false }} cloudSelected />
|
||||
<ProjectAndHostPreview
|
||||
orcaCloud={{ state: 'no-plan', lapsed: false, canManageBilling: true }}
|
||||
cloudSelected
|
||||
/>
|
||||
)
|
||||
})
|
||||
export const orcaCloudSelectedOutOfHoursMember = defineFixture({
|
||||
@@ -217,3 +233,40 @@ export const orcaCloudSelectedPaymentAdmin = defineFixture({
|
||||
/>
|
||||
)
|
||||
})
|
||||
// DS-15: a member can't choose a plan or raise the cap, so the hint says who can and offers no fix.
|
||||
export const orcaCloudSelectedNoPlanMember = defineFixture({
|
||||
...closed,
|
||||
render: () => (
|
||||
<ProjectAndHostPreview
|
||||
orcaCloud={{ state: 'no-plan', lapsed: false, canManageBilling: false }}
|
||||
cloudSelected
|
||||
/>
|
||||
)
|
||||
})
|
||||
export const orcaCloudSelectedCapMember = defineFixture({
|
||||
...closed,
|
||||
render: () => (
|
||||
<ProjectAndHostPreview
|
||||
orcaCloud={{ state: 'out-of-hours', cause: 'cap', canManageBilling: false }}
|
||||
cloudSelected
|
||||
/>
|
||||
)
|
||||
})
|
||||
export const hostMenuOrcaCloudNoPlanMember = defineFixture({
|
||||
...hostMenu,
|
||||
render: () => (
|
||||
<ProjectAndHostPreview
|
||||
orcaCloud={{ state: 'no-plan', lapsed: false, canManageBilling: false }}
|
||||
/>
|
||||
)
|
||||
})
|
||||
// DS-16: back from a paid Checkout, the composer is open again with Orca Cloud picked and its hours.
|
||||
export const orcaCloudAfterCheckout = defineFixture({
|
||||
...closed,
|
||||
render: () => (
|
||||
<ProjectAndHostPreview
|
||||
orcaCloud={{ state: 'entitled', remainingSeconds: 300 * 3600 }}
|
||||
cloudSelected
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -28,11 +28,20 @@ function useBlockCopy(access: OrcaCloudRunTargetAccess): BlockCopy | null {
|
||||
'auto.components.new.workspace.RunTargetCombobox.orcaCloudNeedsTeamPlan',
|
||||
'Orca Cloud needs a Team plan.'
|
||||
),
|
||||
detail: '',
|
||||
detail: access.canManageBilling
|
||||
? ''
|
||||
: t(
|
||||
'auto.components.new.workspace.RunTargetCombobox.orcaCloudAskChoosePlan',
|
||||
'Ask an owner or admin to choose one.'
|
||||
),
|
||||
tone: 'neutral',
|
||||
action: access.lapsed
|
||||
? t('auto.components.new.workspace.RunTargetCombobox.orcaCloudRenew', 'Renew')
|
||||
: t('auto.components.new.workspace.RunTargetCombobox.orcaCloudUpgrade', 'Upgrade')
|
||||
...(access.canManageBilling
|
||||
? {
|
||||
action: access.lapsed
|
||||
? t('auto.components.new.workspace.RunTargetCombobox.orcaCloudRenew', 'Renew')
|
||||
: t('auto.components.new.workspace.RunTargetCombobox.orcaCloudUpgrade', 'Upgrade')
|
||||
}
|
||||
: {})
|
||||
}
|
||||
case 'out-of-hours': {
|
||||
const cap = access.cause === 'cap'
|
||||
@@ -49,10 +58,15 @@ function useBlockCopy(access: OrcaCloudRunTargetAccess): BlockCopy | null {
|
||||
),
|
||||
detail: access.canManageBilling
|
||||
? ''
|
||||
: t(
|
||||
'auto.components.new.workspace.RunTargetCombobox.orcaCloudAskAddHoursOnly',
|
||||
'Ask an owner or admin to add hours.'
|
||||
),
|
||||
: cap
|
||||
? t(
|
||||
'auto.components.new.workspace.RunTargetCombobox.orcaCloudAskRaiseSpendCap',
|
||||
'Ask an owner or admin to raise the spend cap.'
|
||||
)
|
||||
: t(
|
||||
'auto.components.new.workspace.RunTargetCombobox.orcaCloudAskAddHoursOnly',
|
||||
'Ask an owner or admin to add hours.'
|
||||
),
|
||||
tone: 'neutral',
|
||||
...(access.canManageBilling
|
||||
? {
|
||||
|
||||
@@ -121,7 +121,12 @@ export const orcaCloudEntitled = defineFixture({
|
||||
})
|
||||
export const orcaCloudNotEntitled = defineFixture({
|
||||
width: 420,
|
||||
render: () => <Preview recipes={withCloud} access={{ state: 'no-plan', lapsed: false }} />,
|
||||
render: () => (
|
||||
<Preview
|
||||
recipes={withCloud}
|
||||
access={{ state: 'no-plan', lapsed: false, canManageBilling: true }}
|
||||
/>
|
||||
),
|
||||
interact: open
|
||||
})
|
||||
export const orcaCloudOutOfHours = defineFixture({
|
||||
@@ -153,7 +158,12 @@ export const orcaCloudPaymentIssue = defineFixture({
|
||||
})
|
||||
export const orcaCloudLapsed = defineFixture({
|
||||
width: 420,
|
||||
render: () => <Preview recipes={withCloud} access={{ state: 'no-plan', lapsed: true }} />,
|
||||
render: () => (
|
||||
<Preview
|
||||
recipes={withCloud}
|
||||
access={{ state: 'no-plan', lapsed: true, canManageBilling: true }}
|
||||
/>
|
||||
),
|
||||
interact: open
|
||||
})
|
||||
export const orcaCloudSelectedClosed = defineFixture({
|
||||
@@ -188,7 +198,12 @@ export const orcaCloudSignedOut = defineFixture({
|
||||
})
|
||||
export const orcaCloudNoCapability = defineFixture({
|
||||
width: 420,
|
||||
render: () => <Preview recipes={[devboxRecipe]} access={{ state: 'no-plan', lapsed: false }} />,
|
||||
render: () => (
|
||||
<Preview
|
||||
recipes={[devboxRecipe]}
|
||||
access={{ state: 'no-plan', lapsed: false, canManageBilling: true }}
|
||||
/>
|
||||
),
|
||||
interact: open
|
||||
})
|
||||
export const orcaCloudCapReached = defineFixture({
|
||||
@@ -233,3 +248,24 @@ export const needsSetupArmed = defineFixture({
|
||||
render: () => <Preview />,
|
||||
interact: pressKeys('ArrowDown', 'ArrowDown')
|
||||
})
|
||||
// DS-15: members see who can fix it, not Upgrade, and the row can't be picked.
|
||||
export const orcaCloudNoPlanMember = defineFixture({
|
||||
width: 420,
|
||||
render: () => (
|
||||
<Preview
|
||||
recipes={withCloud}
|
||||
access={{ state: 'no-plan', lapsed: false, canManageBilling: false }}
|
||||
/>
|
||||
),
|
||||
interact: open
|
||||
})
|
||||
export const orcaCloudCapReachedMember = defineFixture({
|
||||
width: 420,
|
||||
render: () => (
|
||||
<Preview
|
||||
recipes={withCloud}
|
||||
access={{ state: 'out-of-hours', cause: 'cap', canManageBilling: false }}
|
||||
/>
|
||||
),
|
||||
interact: open
|
||||
})
|
||||
|
||||
@@ -137,13 +137,24 @@ describe('RunTargetCombobox', () => {
|
||||
|
||||
it('opens the plans page instead of building when there is no plan', () => {
|
||||
const onRecipeChange = vi.fn()
|
||||
render({ state: 'no-plan', lapsed: false }, onRecipeChange)
|
||||
render({ state: 'no-plan', lapsed: false, canManageBilling: true }, onRecipeChange)
|
||||
press('ArrowDown')
|
||||
press('Enter')
|
||||
expect(openPlansPage).toHaveBeenCalledWith('sandbox')
|
||||
expect(onRecipeChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tells a member without a plan to ask an owner or admin instead of opening plans', () => {
|
||||
const onRecipeChange = vi.fn()
|
||||
render({ state: 'no-plan', lapsed: false, canManageBilling: false }, onRecipeChange)
|
||||
expect(container.textContent).toContain('Ask an owner or admin')
|
||||
expect(container.textContent).not.toContain('Upgrade')
|
||||
press('ArrowDown')
|
||||
press('Enter')
|
||||
expect(openPlansPage).not.toHaveBeenCalled()
|
||||
expect(onRecipeChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('moves keyboard focus into the recipe submenu and commits a recipe with Enter', () => {
|
||||
const onRecipeChange = vi.fn()
|
||||
render({ state: 'entitled', remainingSeconds: 3600 }, onRecipeChange)
|
||||
|
||||
@@ -148,6 +148,19 @@ function OrcaCloudDetail({ access }: { access: OrcaCloudRunTargetAccess }): Reac
|
||||
}
|
||||
}
|
||||
|
||||
/** Members can't fix billing; say who can rather than offer a pick that does nothing. */
|
||||
function AskOwnerOrAdmin(): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<span className="ml-2 shrink-0 self-center text-xs text-muted-foreground">
|
||||
{t(
|
||||
'auto.components.new.workspace.RunTargetCombobox.orcaCloudAskOwnerOrAdmin',
|
||||
'Ask an owner or admin'
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function OrcaCloudMeta({
|
||||
access,
|
||||
onAction
|
||||
@@ -187,6 +200,9 @@ function OrcaCloudMeta({
|
||||
/>
|
||||
)
|
||||
case 'no-plan':
|
||||
if (!access.canManageBilling) {
|
||||
return <AskOwnerOrAdmin />
|
||||
}
|
||||
return (
|
||||
<RowAction
|
||||
label={
|
||||
@@ -200,15 +216,7 @@ function OrcaCloudMeta({
|
||||
case 'out-of-hours':
|
||||
case 'payment-issue':
|
||||
if (!access.canManageBilling) {
|
||||
// Members can't fix it; say who can rather than offer a pick that does nothing.
|
||||
return (
|
||||
<span className="ml-2 shrink-0 self-center text-xs text-muted-foreground">
|
||||
{t(
|
||||
'auto.components.new.workspace.RunTargetCombobox.orcaCloudAskOwnerOrAdmin',
|
||||
'Ask an owner or admin'
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
return <AskOwnerOrAdmin />
|
||||
}
|
||||
return (
|
||||
<RowAction
|
||||
@@ -276,7 +284,9 @@ export function RunTargetOrcaCloudRow({
|
||||
armed={armed}
|
||||
current={current}
|
||||
disabled={
|
||||
(access.state === 'out-of-hours' || access.state === 'payment-issue') &&
|
||||
(access.state === 'no-plan' ||
|
||||
access.state === 'out-of-hours' ||
|
||||
access.state === 'payment-issue') &&
|
||||
!access.canManageBilling
|
||||
}
|
||||
optionId={optionId}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaBillingEntitlement } from '../../../../shared/orca-billing'
|
||||
import { getOrcaCloudPick, toOrcaCloudRunTargetAccess } from './orca-cloud-run-target-access'
|
||||
|
||||
const openPlansPage = vi.fn()
|
||||
vi.mock('@/components/billing/plans/plans-page-store', () => ({
|
||||
openPlansPage: (source: string) => openPlansPage(source)
|
||||
}))
|
||||
const requestOrcaCloudAfterCheckout = vi.fn()
|
||||
vi.mock('@/lib/orca-cloud-after-checkout', () => ({
|
||||
requestOrcaCloudAfterCheckout: (intent: unknown) => requestOrcaCloudAfterCheckout(intent)
|
||||
}))
|
||||
|
||||
function denied(
|
||||
reason: OrcaBillingEntitlement['reason'],
|
||||
canManageBilling: boolean
|
||||
): Parameters<typeof toOrcaCloudRunTargetAccess>[0] {
|
||||
return {
|
||||
status: 'ok',
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: access reads only these fields.
|
||||
entitlement: {
|
||||
canStartSandbox: false,
|
||||
remainingIncludedSeconds: 0,
|
||||
reason,
|
||||
canManageBilling
|
||||
} as OrcaBillingEntitlement
|
||||
}
|
||||
}
|
||||
|
||||
describe('Orca Cloud run-target access by role', () => {
|
||||
beforeEach(() => {
|
||||
openPlansPage.mockReset()
|
||||
requestOrcaCloudAfterCheckout.mockReset()
|
||||
})
|
||||
|
||||
it('carries the role on no plan, so members are not sent to plans they cannot buy', () => {
|
||||
const owner = toOrcaCloudRunTargetAccess(denied('no_subscription', true))
|
||||
const member = toOrcaCloudRunTargetAccess(denied('subscription_inactive', false))
|
||||
expect(owner).toEqual({ state: 'no-plan', lapsed: false, canManageBilling: true })
|
||||
expect(member).toEqual({ state: 'no-plan', lapsed: true, canManageBilling: false })
|
||||
expect(getOrcaCloudPick(member)).toEqual({ kind: 'blocked' })
|
||||
expect(openPlansPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends an owner or admin to plans and leaves a return-to-composer intent', () => {
|
||||
const pick = getOrcaCloudPick(toOrcaCloudRunTargetAccess(denied('no_subscription', true)))
|
||||
expect(pick.kind).toBe('act')
|
||||
if (pick.kind === 'act') {
|
||||
pick.run()
|
||||
}
|
||||
expect(requestOrcaCloudAfterCheckout).toHaveBeenCalledWith({ kind: 'composer' })
|
||||
expect(openPlansPage).toHaveBeenCalledWith('sandbox')
|
||||
})
|
||||
|
||||
it('blocks members at the spend cap and lets owners act', () => {
|
||||
const member = toOrcaCloudRunTargetAccess(denied('overage_cap_reached', false))
|
||||
expect(member).toEqual({ state: 'out-of-hours', cause: 'cap', canManageBilling: false })
|
||||
expect(getOrcaCloudPick(member)).toEqual({ kind: 'blocked' })
|
||||
const owner = toOrcaCloudRunTargetAccess(denied('overage_cap_reached', true))
|
||||
expect(getOrcaCloudPick(owner).kind).toBe('act')
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
cancelOrcaCloudSelectionAfterSignIn,
|
||||
requestOrcaCloudSelectionAfterSignIn
|
||||
} from '@/lib/orca-cloud-select-after-sign-in'
|
||||
import { requestOrcaCloudAfterCheckout } from '@/lib/orca-cloud-after-checkout'
|
||||
import { classifyEntitlementDenial } from '../../../../shared/orca-billing'
|
||||
|
||||
/** Signing in from the row selects Orca Cloud once main offers it (the recipe list reloads). */
|
||||
function signInThenSelectOrcaCloud(): void {
|
||||
@@ -20,23 +22,30 @@ function signInThenSelectOrcaCloud(): void {
|
||||
.catch(() => cancelOrcaCloudSelectionAfterSignIn(token))
|
||||
}
|
||||
|
||||
/** Paying from the row returns to this composer with Orca Cloud picked (see PlansDialogHost). */
|
||||
function choosePlanThenSelectOrcaCloud(): void {
|
||||
requestOrcaCloudAfterCheckout({ kind: 'composer' })
|
||||
openPlansPage('sandbox')
|
||||
}
|
||||
|
||||
/**
|
||||
* What the Run-on picker knows about the account's Orca Cloud access.
|
||||
* - `loading`: the first billing read is in flight; picking waits briefly for it.
|
||||
* - `unknown`: billing unreachable or not signed in to billing; picking proceeds and the build reports.
|
||||
* - `signed-out`: the build offers Orca Cloud but nobody is signed in; picking starts sign-in.
|
||||
* - `entitled`: can start a sandbox; `remainingSeconds` is the included time left this window.
|
||||
* - `no-plan`: no active Team subscription (`lapsed` when one existed); routes to the plans page.
|
||||
* - `no-plan`: no active Team subscription (`lapsed` when one existed); routes owners/admins to the
|
||||
* plans page and, once paid, back to the composer with Orca Cloud picked.
|
||||
* - `out-of-hours`: included hours are spent (`cause: 'hours'`) or the overage cap is (`'cap'`).
|
||||
* - `payment-issue`: a failed payment blocks new starts.
|
||||
* Out-of-hours and payment issues are actionable only by owners/admins (`canManageBilling`).
|
||||
* No plan, out-of-hours and payment issues are actionable only by owners/admins (`canManageBilling`).
|
||||
*/
|
||||
export type OrcaCloudRunTargetAccess =
|
||||
| { state: 'loading' }
|
||||
| { state: 'unknown' }
|
||||
| { state: 'signed-out' }
|
||||
| { state: 'entitled'; remainingSeconds: number }
|
||||
| { state: 'no-plan'; lapsed: boolean }
|
||||
| { state: 'no-plan'; lapsed: boolean; canManageBilling: boolean }
|
||||
| { state: 'out-of-hours'; cause: 'hours' | 'cap'; canManageBilling: boolean }
|
||||
| { state: 'payment-issue'; canManageBilling: boolean }
|
||||
|
||||
@@ -54,19 +63,25 @@ export function toOrcaCloudRunTargetAccess(
|
||||
}
|
||||
}
|
||||
const { canManageBilling } = entitlement
|
||||
switch (entitlement.reason) {
|
||||
case 'included_hours_exhausted':
|
||||
switch (classifyEntitlementDenial(entitlement.reason)) {
|
||||
case 'hours':
|
||||
return { state: 'out-of-hours', cause: 'hours', canManageBilling }
|
||||
case 'overage_cap_reached':
|
||||
case 'spendCap':
|
||||
return { state: 'out-of-hours', cause: 'cap', canManageBilling }
|
||||
case 'payment_past_due':
|
||||
case 'payment_grace_expired':
|
||||
case 'payment':
|
||||
return { state: 'payment-issue', canManageBilling }
|
||||
case 'subscription_inactive':
|
||||
return { state: 'no-plan', lapsed: true }
|
||||
case 'no_subscription':
|
||||
case null:
|
||||
return { state: 'no-plan', lapsed: false }
|
||||
// Billing's account answer never carries the start-gate reasons; anything else is no plan,
|
||||
// which the plan fixes.
|
||||
case 'plan':
|
||||
case 'teamLimit':
|
||||
case 'memberLimit':
|
||||
case 'machineSize':
|
||||
case 'other':
|
||||
return {
|
||||
state: 'no-plan',
|
||||
lapsed: entitlement.reason === 'subscription_inactive',
|
||||
canManageBilling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +101,9 @@ export function getOrcaCloudPick(access: OrcaCloudRunTargetAccess): OrcaCloudPic
|
||||
case 'signed-out':
|
||||
return { kind: 'act', run: signInThenSelectOrcaCloud }
|
||||
case 'no-plan':
|
||||
return { kind: 'act', run: () => openPlansPage('sandbox') }
|
||||
return access.canManageBilling
|
||||
? { kind: 'act', run: choosePlanThenSelectOrcaCloud }
|
||||
: { kind: 'blocked' }
|
||||
case 'out-of-hours':
|
||||
return access.canManageBilling
|
||||
? { kind: 'act', run: openOverageSettings }
|
||||
|
||||
@@ -28,7 +28,8 @@ export function getRunTargetOrcaCloudOffer({
|
||||
case 'needs-sign-in':
|
||||
return override ?? { state: 'signed-out' }
|
||||
case 'needs-capability':
|
||||
return override ?? { state: 'no-plan', lapsed: false }
|
||||
// No billing read without the capability; unknown roles get the owner's way forward.
|
||||
return override ?? { state: 'no-plan', lapsed: false, canManageBilling: true }
|
||||
case 'offered':
|
||||
case 'unavailable':
|
||||
return null
|
||||
@@ -65,8 +66,15 @@ export function useRunTargetOrcaCloud({
|
||||
runBlockedAction: () => void
|
||||
select: (recipe: EphemeralVmRecipeOption | null) => void
|
||||
} {
|
||||
const fetched = useOrcaCloudRunTargetAccess(!override && recipes.some(isOrcaCloudRecipe))
|
||||
const access = offer ?? override ?? fetched
|
||||
const fetched = useOrcaCloudRunTargetAccess(
|
||||
!override && (recipes.some(isOrcaCloudRecipe) || offer?.state === 'no-plan')
|
||||
)
|
||||
// Without the capability the offer can't know the role; billing's answer can, so members are
|
||||
// told to ask an owner or admin instead of being sent to plans they can't buy.
|
||||
const access =
|
||||
!override && offer?.state === 'no-plan' && fetched.state === 'no-plan'
|
||||
? fetched
|
||||
: (offer ?? override ?? fetched)
|
||||
const [pending, setPending] = useState(false)
|
||||
const pickToken = useRef(0)
|
||||
const openRef = useRef(open)
|
||||
|
||||
@@ -7,7 +7,11 @@ import { LocalWorkspacePortsPanel } from './local-workspace-ports-panel'
|
||||
import { SshPortsPanel } from './ssh-ports-panel'
|
||||
import { HostScopedPortsPanel } from './host-scoped-ports-panel'
|
||||
import { CloudSharedLinksSection } from './cloud-share/cloud-shared-links-section'
|
||||
import { SharePortMenuItem, useSharePortAction } from './cloud-share/use-share-port-action'
|
||||
import {
|
||||
SharePortButton,
|
||||
SharePortMenuItem,
|
||||
useSharePortAction
|
||||
} from './cloud-share/use-share-port-action'
|
||||
import type { WorkspacePort } from '../../../../shared/workspace-ports'
|
||||
import {
|
||||
getPortsPanelOwnerKey,
|
||||
@@ -38,6 +42,12 @@ export default function PortsPanel({ isVisible }: { isVisible: boolean }): React
|
||||
),
|
||||
[shareAvailable, openShareDialog]
|
||||
)
|
||||
const sharePortActions = useCallback(
|
||||
(port: WorkspacePort) => (
|
||||
<SharePortButton port={port.port} action={{ available: shareAvailable, openShareDialog }} />
|
||||
),
|
||||
[shareAvailable, openShareDialog]
|
||||
)
|
||||
const scopedHost = useMemo(
|
||||
() =>
|
||||
host.kind === 'host-scoped' && workspaceId
|
||||
@@ -62,6 +72,7 @@ export default function PortsPanel({ isVisible }: { isVisible: boolean }): React
|
||||
runtimeTarget={host.target}
|
||||
sshForwardTargetId={sshForwardTargetId}
|
||||
portMenuItems={shareAvailable ? sharePortMenuItems : undefined}
|
||||
portActions={shareAvailable ? sharePortActions : undefined}
|
||||
isSandbox={shareAvailable}
|
||||
footer={(activePorts) => (
|
||||
<CloudSharedLinksSection
|
||||
|
||||
@@ -218,12 +218,18 @@ export function ShareLinkRow({
|
||||
data-state={state}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn('flex min-w-0 flex-1 items-center gap-2', !active && 'opacity-60')}>
|
||||
<div className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{/* Dim the icon and port only: the Expired / Turned off status keeps full muted contrast. */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center text-muted-foreground',
|
||||
!active && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
<ShareAudienceIcon audience={share.audience} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<div className={cn('flex min-w-0 items-center gap-1.5', !active && 'opacity-60')}>
|
||||
{showPort && (
|
||||
<span className="text-xs font-medium text-foreground">:{share.port}</span>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { WorkspacePort } from '../../../../../shared/workspace-ports'
|
||||
import { LocalPortRow } from '../local-port-row'
|
||||
import { SharedLinksList, type SharedLinksListProps } from './shared-links-list'
|
||||
import { SharePortDialog } from './share-port-dialog'
|
||||
import { SharePortButton, SharePortMenuItem } from './use-share-port-action'
|
||||
|
||||
const NOW = Date.UTC(2026, 10, 9, 12, 0, 0)
|
||||
const HOUR = 3_600_000
|
||||
@@ -177,6 +178,58 @@ export const besidePorts = defineFixture({
|
||||
)
|
||||
})
|
||||
|
||||
const shareAction = { available: true, openShareDialog: noop }
|
||||
// Shows the focus-ring as keyboard focus would (Chromium honors focusVisible).
|
||||
const FOCUS_VISIBLE: FocusOptions & { focusVisible: boolean } = { focusVisible: true }
|
||||
|
||||
/** A cloud port row as keyboard users get it: Open in browser, Share…, Copy, Stop, More actions. */
|
||||
function CloudPortRow({ tall = false }: { tall?: boolean }) {
|
||||
return (
|
||||
<div className={`bg-background px-3 pt-2 pb-3 text-foreground ${tall ? 'h-[260px]' : ''}`}>
|
||||
<LocalPortRow
|
||||
port={devServer}
|
||||
onStop={noop}
|
||||
onShowDetails={noop}
|
||||
onOpenInBrowser={noop}
|
||||
shareAction={<SharePortButton port={devServer.port} action={shareAction} />}
|
||||
forwardMenuItems={<SharePortMenuItem port={devServer.port} action={shareAction} />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Tabbing onto the row: the focus-ring outline shows and the row buttons appear. */
|
||||
export const portRowFocused = defineFixture({
|
||||
width: 300,
|
||||
render: () => <CloudPortRow />,
|
||||
interact: (frame) => {
|
||||
frame.querySelector<HTMLElement>('[role="group"]')?.focus(FOCUS_VISIBLE)
|
||||
}
|
||||
})
|
||||
|
||||
/** Share… reached from the keyboard: the row's Share button focused. */
|
||||
export const portRowShareFocused = defineFixture({
|
||||
width: 300,
|
||||
render: () => <CloudPortRow />,
|
||||
interact: (frame) => {
|
||||
frame
|
||||
.querySelector<HTMLButtonElement>('button[aria-label="Share port 3000…"]')
|
||||
?.focus(FOCUS_VISIBLE)
|
||||
}
|
||||
})
|
||||
|
||||
/** "More actions for port 3000" opens the same menu as right-click, Share… included. */
|
||||
export const portRowMoreActions = defineFixture({
|
||||
width: 300,
|
||||
render: () => <CloudPortRow tall />,
|
||||
interact: async (frame) => {
|
||||
frame
|
||||
.querySelector<HTMLButtonElement>('button[aria-label="More actions for port 3000"]')
|
||||
?.click()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
})
|
||||
|
||||
function Dialog({
|
||||
port,
|
||||
created,
|
||||
|
||||
@@ -142,7 +142,7 @@ export function SharedLinksList({
|
||||
retryLabel={
|
||||
needsSignIn
|
||||
? undefined
|
||||
: t('auto.components.right.sidebar.CloudShare.retry', 'Retry')
|
||||
: t('auto.components.right.sidebar.CloudShare.tryAgain', 'Try again')
|
||||
}
|
||||
onRetry={needsSignIn ? undefined : onRetry}
|
||||
retrying={retrying}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { Share2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ContextMenuItem } from '@/components/ui/context-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import {
|
||||
@@ -144,3 +146,41 @@ export function SharePortMenuItem({
|
||||
</ContextMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
/** "Share…" as a port row hover/focus button, so sharing doesn't need a right-click. */
|
||||
export function SharePortButton({
|
||||
port,
|
||||
action
|
||||
}: {
|
||||
port: number
|
||||
action: Pick<SharePortAction, 'available' | 'openShareDialog'>
|
||||
}): React.JSX.Element | null {
|
||||
const t = useTranslate()
|
||||
if (!action.available || SHARE_RESERVED_PORTS.includes(port)) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => action.openShareDialog(port)}
|
||||
aria-label={t(
|
||||
'auto.components.right.sidebar.CloudShare.sharePortLabel',
|
||||
'Share port {{port}}…',
|
||||
{ port }
|
||||
)}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<Share2 size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{t('auto.components.right.sidebar.CloudShare.menuShare', 'Share…')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<LocalPortRow
|
||||
port={port}
|
||||
onStop={vi.fn()}
|
||||
onShowDetails={vi.fn()}
|
||||
onOpenInBrowser={vi.fn()}
|
||||
shareAction={<SharePortButton port={port.port} action={action} />}
|
||||
forwardMenuItems={<SharePortMenuItem port={port.port} action={action} />}
|
||||
/>
|
||||
)
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<HTMLDivElement>(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<HTMLButtonElement>) => {
|
||||
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({
|
||||
<div className="group flex items-center gap-2 py-1 px-1 -mx-1 rounded hover:bg-accent/50 transition-colors">
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-start gap-2 rounded focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring"
|
||||
ref={menuTriggerRef}
|
||||
className="flex min-w-0 flex-1 items-start gap-2 rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring"
|
||||
tabIndex={0}
|
||||
role="group"
|
||||
aria-label={t(
|
||||
@@ -168,6 +193,7 @@ export function LocalPortRow({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{shareAction}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -200,19 +226,34 @@ export function LocalPortRow({
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={handleStopButtonClick}
|
||||
aria-label={t(
|
||||
'auto.components.right.sidebar.PortsPanel.f9528da632',
|
||||
'Stop Process'
|
||||
)}
|
||||
aria-label={stopProcessLabel}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{t('auto.components.right.sidebar.PortsPanel.f9528da632', 'Stop Process')}
|
||||
{stopProcessLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={handleMoreActionsClick}
|
||||
aria-label={moreActionsLabel}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Ellipsis size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{moreActionsLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
@@ -231,7 +272,7 @@ export function LocalPortRow({
|
||||
)}
|
||||
<ContextMenuItem className={LOCAL_PORT_MENU_ITEM_CLASS} onSelect={handleCopy}>
|
||||
<Copy size={13} />
|
||||
{t('auto.components.right.sidebar.PortsPanel.792baeb7ed', 'Copy Address')}
|
||||
{t('auto.components.right.sidebar.PortsPanel.copyAddress', 'Copy address')}
|
||||
</ContextMenuItem>
|
||||
{forwardMenuItems}
|
||||
<ContextMenuItem
|
||||
@@ -241,14 +282,14 @@ export function LocalPortRow({
|
||||
}}
|
||||
>
|
||||
<Copy size={13} />
|
||||
{t('auto.components.right.sidebar.PortsPanel.bdac206faf', 'Copy Details')}
|
||||
{t('auto.components.right.sidebar.PortsPanel.copyDetails', 'Copy details')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className={LOCAL_PORT_MENU_ITEM_CLASS}
|
||||
onSelect={() => onShowDetails(port)}
|
||||
>
|
||||
<Info size={13} />
|
||||
{t('auto.components.right.sidebar.PortsPanel.a223459512', 'Show Details')}
|
||||
{t('auto.components.right.sidebar.PortsPanel.showDetails', 'Show details')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
@@ -258,7 +299,7 @@ export function LocalPortRow({
|
||||
onSelect={() => onStop(port)}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{t('auto.components.right.sidebar.PortsPanel.f9528da632', 'Stop Process')}
|
||||
{stopProcessLabel}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -58,7 +58,7 @@ const states: Record<number, RuntimePortForwardRowState> = {
|
||||
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' }
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export function RuntimePortForwardStatus({
|
||||
}
|
||||
: {
|
||||
icon: <RotateCw aria-hidden />,
|
||||
label: t('auto.components.right.sidebar.RuntimePortForward.retry', 'Retry')
|
||||
label: t('auto.components.right.sidebar.RuntimePortForward.tryAgain', 'Try again')
|
||||
}
|
||||
: {
|
||||
icon: <Plug aria-hidden />,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 : <OrcaSignInWaitNotice className="basis-full" />}
|
||||
</div>
|
||||
|
||||
{connected ? <PlanBillingSection /> : null}
|
||||
{connected ? <PlanBillingSection key={accountKey} /> : null}
|
||||
{connected && authStatus?.capabilities?.flags?.billing ? (
|
||||
<TeamSettingsLink
|
||||
orgId={authStatus.cloud?.activeOrgId ?? null}
|
||||
orgName={authStatus.cloud?.activeOrgName ?? null}
|
||||
/>
|
||||
) : null}
|
||||
{connected ? <CloudSecretsSection orgId={authStatus?.cloud?.activeOrgId ?? null} /> : null}
|
||||
{connected ? (
|
||||
<CloudSecretsSection key={accountKey} orgId={authStatus?.cloud?.activeOrgId ?? null} />
|
||||
) : null}
|
||||
{connected && authStatus?.capabilities?.flags?.sandbox ? (
|
||||
<CloudPredictiveWakeSetting />
|
||||
) : null}
|
||||
{connected ? (
|
||||
<CloudEnvironmentSection
|
||||
key={accountKey}
|
||||
orgId={authStatus?.cloud?.activeOrgId ?? null}
|
||||
viewerUserId={authStatus?.cloud?.userId ?? null}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<li data-settings-row="" data-key-id={apiKey.id} className="flex items-start gap-3 py-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<p className="truncate text-sm font-medium" title={apiKey.name}>
|
||||
{apiKey.name}
|
||||
</p>
|
||||
<code className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{formatKeyPrefix(apiKey.prefix)}
|
||||
</code>
|
||||
<ScopeSummary scopes={apiKey.scopes} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<time
|
||||
dateTime={new Date(apiKey.createdAt).toISOString()}
|
||||
title={formatAbsoluteTime(apiKey.createdAt, locale)}
|
||||
>
|
||||
{mine ? (
|
||||
t('auto.components.settings.cloudApi.keys.createdMine', 'Created {{time}}', {
|
||||
time: created
|
||||
})
|
||||
) : (
|
||||
<PersonSentence
|
||||
sentence={t(
|
||||
'auto.components.settings.cloudApi.keys.createdByPerson',
|
||||
'Created by <person></person> {{time}}',
|
||||
{ time: created }
|
||||
)}
|
||||
name={apiKey.createdByName}
|
||||
id={apiKey.createdBy}
|
||||
/>
|
||||
)}
|
||||
</time>
|
||||
<span aria-hidden> · </span>
|
||||
<span>
|
||||
{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')}
|
||||
</span>
|
||||
{apiKey.expiresAt !== null ? (
|
||||
<>
|
||||
<span aria-hidden> · </span>
|
||||
<span className={cn(expiresSoon && 'text-status-warning-text')}>
|
||||
{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)
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
data-revoke=""
|
||||
onClick={onRevoke}
|
||||
aria-label={t('auto.components.settings.cloudApi.keys.revokeLabel', 'Revoke {{keyName}}', {
|
||||
keyName: apiKey.name
|
||||
})}
|
||||
>
|
||||
<Trash2 />
|
||||
{t('auto.components.settings.cloudApi.keys.revoke', 'Revoke')}
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -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 <CloudApiKeysPaneBody key={useOrcaAccountKey()} />
|
||||
}
|
||||
|
||||
function CloudApiKeysPaneBody(): React.JSX.Element | null {
|
||||
const t = useTranslate()
|
||||
const availability = useOrcaCloudApiAvailability()
|
||||
const api = window.api.cloudApi
|
||||
|
||||
@@ -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<CloudApiSettings>
|
||||
onToggle: (enabled: boolean) => Promise<string | null>
|
||||
}): React.JSX.Element {
|
||||
@@ -158,7 +161,7 @@ export function OrgSettings({
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{settings.status === 'error' ? (
|
||||
{settings.status === 'error' && !blocked ? (
|
||||
<InlineError tone="neutral" title={settings.message} className="mt-2" />
|
||||
) : null}
|
||||
{error ? <InlineError title={error} className="mt-2" /> : null}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
</Button>
|
||||
)
|
||||
|
||||
// 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 (
|
||||
<div className="space-y-8">
|
||||
{pageError ? (
|
||||
<InlineError
|
||||
title={pageError}
|
||||
detail={t(
|
||||
'auto.components.settings.cloudApi.pageErrorDetail',
|
||||
'Keys, connected apps and team settings are unavailable until Orca Cloud answers.'
|
||||
)}
|
||||
onRetry={props.onRefresh}
|
||||
retryLabel={t('auto.components.settings.cloudApi.retry', 'Try again')}
|
||||
/>
|
||||
) : null}
|
||||
<SettingsGroup
|
||||
title={t('auto.components.settings.cloudApi.keys.listTitle', 'Keys')}
|
||||
description={
|
||||
@@ -169,9 +182,12 @@ export function CloudApiKeysView(props: CloudApiKeysViewProps): React.JSX.Elemen
|
||||
label={t('auto.components.settings.cloudApi.keys.loading', 'Loading Orca API keys')}
|
||||
/>
|
||||
) : keys.status === 'error' ? (
|
||||
<div data-settings-row="" className="py-4">
|
||||
<CloudApiErrorNotice message={keys.message} onRetry={props.onRefresh} />
|
||||
</div>
|
||||
<p data-settings-row="" className="py-4 text-xs text-muted-foreground">
|
||||
{t(
|
||||
'auto.components.settings.cloudApi.keys.unavailable',
|
||||
'Your keys appear here once Orca Cloud is reachable.'
|
||||
)}
|
||||
</p>
|
||||
) : keys.data.keys.length === 0 ? (
|
||||
<CloudApiEmptyState
|
||||
icon={KeyRound}
|
||||
@@ -206,27 +222,39 @@ export function CloudApiKeysView(props: CloudApiKeysViewProps): React.JSX.Elemen
|
||||
|
||||
<AccessLinks controlPlaneUrl={props.controlPlaneUrl} onApprove={() => setApproveOpen(true)} />
|
||||
|
||||
<CloudConnectedApps
|
||||
grants={props.grants}
|
||||
viewer={viewer}
|
||||
now={props.now}
|
||||
onApprove={() => setApproveOpen(true)}
|
||||
onRevoke={props.onRevokeGrant}
|
||||
onRetry={props.onRefreshGrants}
|
||||
initialRevokeId={initial?.revokeGrantId}
|
||||
/>
|
||||
|
||||
<OrgSettings settings={props.settings} onToggle={props.onToggleVmCli} />
|
||||
|
||||
{props.audit ? (
|
||||
<AuditLog
|
||||
audit={props.audit}
|
||||
now={props.now}
|
||||
{/* Disabled while the page can't reach Orca Cloud; the error above says why. */}
|
||||
<div
|
||||
className={cn('space-y-8', pageError && 'opacity-60')}
|
||||
inert={pageError ? true : undefined}
|
||||
aria-disabled={pageError ? true : undefined}
|
||||
>
|
||||
<CloudConnectedApps
|
||||
blocked={Boolean(pageError)}
|
||||
grants={props.grants}
|
||||
viewer={viewer}
|
||||
initiallyOpen={initial?.auditOpen ?? false}
|
||||
onLoad={props.onLoadAudit}
|
||||
now={props.now}
|
||||
onApprove={() => setApproveOpen(true)}
|
||||
onRevoke={props.onRevokeGrant}
|
||||
onRetry={props.onRefreshGrants}
|
||||
initialRevokeId={initial?.revokeGrantId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<OrgSettings
|
||||
blocked={Boolean(pageError)}
|
||||
settings={props.settings}
|
||||
onToggle={props.onToggleVmCli}
|
||||
/>
|
||||
|
||||
{props.audit ? (
|
||||
<AuditLog
|
||||
audit={props.audit}
|
||||
now={props.now}
|
||||
viewer={viewer}
|
||||
initiallyOpen={initial?.auditOpen ?? false}
|
||||
onLoad={props.onLoadAudit}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<CreateApiKeyDialog
|
||||
open={createOpen}
|
||||
@@ -321,91 +349,3 @@ export function CloudApiKeysView(props: CloudApiKeysViewProps): React.JSX.Elemen
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<li data-settings-row="" data-key-id={apiKey.id} className="flex items-start gap-3 py-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<p className="truncate text-sm font-medium" title={apiKey.name}>
|
||||
{apiKey.name}
|
||||
</p>
|
||||
<code className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{formatKeyPrefix(apiKey.prefix)}
|
||||
</code>
|
||||
<ScopeSummary scopes={apiKey.scopes} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<time
|
||||
dateTime={new Date(apiKey.createdAt).toISOString()}
|
||||
title={formatAbsoluteTime(apiKey.createdAt, locale)}
|
||||
>
|
||||
{mine ? (
|
||||
t('auto.components.settings.cloudApi.keys.createdMine', 'Created {{time}}', {
|
||||
time: created
|
||||
})
|
||||
) : (
|
||||
<PersonSentence
|
||||
sentence={t(
|
||||
'auto.components.settings.cloudApi.keys.createdByPerson',
|
||||
'Created by <person></person> {{time}}',
|
||||
{ time: created }
|
||||
)}
|
||||
name={apiKey.createdByName}
|
||||
id={apiKey.createdBy}
|
||||
/>
|
||||
)}
|
||||
</time>
|
||||
<span aria-hidden> · </span>
|
||||
<span>
|
||||
{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')}
|
||||
</span>
|
||||
{apiKey.expiresAt !== null ? (
|
||||
<>
|
||||
<span aria-hidden> · </span>
|
||||
<span className={cn(expiresSoon && 'text-status-warning-text')}>
|
||||
{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)
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
data-revoke=""
|
||||
onClick={onRevoke}
|
||||
aria-label={t('auto.components.settings.cloudApi.keys.revokeLabel', 'Revoke {{keyName}}', {
|
||||
keyName: apiKey.name
|
||||
})}
|
||||
>
|
||||
<Trash2 />
|
||||
{t('auto.components.settings.cloudApi.keys.revoke', 'Revoke')}
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ export function CloudConnectedApps({
|
||||
onApprove,
|
||||
onRevoke,
|
||||
onRetry,
|
||||
initialRevokeId
|
||||
initialRevokeId,
|
||||
blocked = false
|
||||
}: {
|
||||
grants: CloudLoadState<CloudApiGrant[]>
|
||||
viewer: CloudApiMe | null
|
||||
@@ -41,6 +42,8 @@ export function CloudConnectedApps({
|
||||
onRevoke: (grant: CloudApiGrant) => Promise<string | null>
|
||||
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' ? (
|
||||
<div data-settings-row="" className="py-4">
|
||||
<CloudApiErrorNotice message={grants.message} onRetry={onRetry} />
|
||||
</div>
|
||||
blocked ? (
|
||||
<p data-settings-row="" className="py-4 text-xs text-muted-foreground">
|
||||
{t(
|
||||
'auto.components.settings.cloudApi.apps.unavailable',
|
||||
'Connected apps appear here once Orca Cloud is reachable.'
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<div data-settings-row="" className="py-4">
|
||||
<CloudApiErrorNotice message={grants.message} onRetry={onRetry} />
|
||||
</div>
|
||||
)
|
||||
) : grants.data.length === 0 ? (
|
||||
<CloudApiEmptyState
|
||||
icon={Plug}
|
||||
|
||||
@@ -146,8 +146,7 @@ const RUNS: RoutineRunPage = {
|
||||
status: 'failed',
|
||||
startedAt: NOW - 45 * HOUR,
|
||||
durationMs: 38_000,
|
||||
error:
|
||||
'The agent could not clone acme/api: the Orca Cloud GitHub App is not installed on this repository.'
|
||||
error: 'start_failed:clone_failed'
|
||||
}),
|
||||
run({ id: 'r1', status: 'timed_out', startedAt: NOW - 69 * HOUR, durationMs: 30 * MIN }),
|
||||
run({
|
||||
@@ -161,6 +160,63 @@ const RUNS: RoutineRunPage = {
|
||||
}
|
||||
|
||||
const loadRuns: LoadRoutineRuns = async () => ({ 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: () => <CloudRoutinesView {...props} initial={{ deleteId: 'rt_fix' }} />
|
||||
})
|
||||
|
||||
export const runHistoryFailures = defineFixture({
|
||||
width: 820,
|
||||
render: () => (
|
||||
<CloudRoutinesView {...props} loadRuns={loadFailedRuns} initial={{ historyFor: 'rt_fix' }} />
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 ? (
|
||||
<p className="line-clamp-2 text-xs text-muted-foreground">{run.result.summary}</p>
|
||||
) : null}
|
||||
{run.error ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium text-destructive">
|
||||
{t('auto.components.settings.cloudRoutines.runs.why', 'Why:')}
|
||||
</span>{' '}
|
||||
{run.error}
|
||||
</p>
|
||||
) : null}
|
||||
{run.error ? <RunFailure error={run.error} /> : null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -341,3 +338,10 @@ function RunAction({
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 <InlineError layout="line" tone={tone} title={text} />
|
||||
}
|
||||
|
||||
@@ -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.'))
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,8 +123,8 @@ export function PortsPane({ settings }: { settings: GlobalSettings }): React.JSX
|
||||
<SettingsSwitchRow
|
||||
label={t('auto.components.settings.ports.exclusive', 'Same port number only')}
|
||||
description={t(
|
||||
'auto.components.settings.ports.exclusiveDescription',
|
||||
'If the port is taken here, skip it instead of remapping.'
|
||||
'auto.components.settings.ports.exclusiveDescriptionInUse',
|
||||
'If the port is in use on this computer, skip it instead of remapping.'
|
||||
)}
|
||||
// Off reads as off: a dependent switch that cannot act should not look on.
|
||||
checked={forwarding.exclusive && !off}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { translateSearchKeyword } from '../search/settings-search-keywords'
|
||||
|
||||
export const getPortsPaneSearchEntries = createLocalizedCatalog(() => [
|
||||
{
|
||||
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.'
|
||||
|
||||
+1
-3
@@ -71,9 +71,7 @@ function ServerRow({
|
||||
{row.name}
|
||||
</span>
|
||||
<span className="sr-only">{mcpSyncRowStateLabel(row, t)}</span>
|
||||
<span className="shrink-0 text-2xs text-muted-foreground/70">
|
||||
{AGENT_LABEL[row.agent]}
|
||||
</span>
|
||||
<span className="shrink-0 text-2xs text-muted-foreground">{AGENT_LABEL[row.agent]}</span>
|
||||
{row.target ? (
|
||||
<span className="min-w-0 truncate font-mono text-2xs text-muted-foreground">
|
||||
{row.target}
|
||||
|
||||
+1
-1
@@ -104,7 +104,7 @@ const CloudSshIncludeDialog = React.memo(function CloudSshIncludeDialog() {
|
||||
{t('auto.components.sidebar.CloudSshIncludeDialog.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>
|
||||
{t('auto.components.sidebar.CloudSshIncludeDialog.confirm', 'Add Line and Open')}
|
||||
{t('auto.components.sidebar.CloudSshIncludeDialog.addLineAndOpen', 'Add line and open')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
+2
-2
@@ -136,7 +136,7 @@ export function CloudSshMenuItems({
|
||||
aria-describedby={status.hasAccess ? undefined : hintId}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
{t('auto.components.sidebar.CloudSsh.copyCommand', 'Copy SSH Command')}
|
||||
{t('auto.components.sidebar.CloudSsh.copySshCommand', 'Copy SSH command')}
|
||||
</DropdownMenuItem>
|
||||
{status.hasAccess ? (
|
||||
<>
|
||||
@@ -147,7 +147,7 @@ export function CloudSshMenuItems({
|
||||
disabled={disabled}
|
||||
>
|
||||
<ShieldOff className="size-3.5" />
|
||||
{t('auto.components.sidebar.CloudSsh.revoke', 'Revoke SSH Access')}
|
||||
{t('auto.components.sidebar.CloudSsh.revokeSshAccess', 'Revoke SSH access')}
|
||||
</DropdownMenuItem>
|
||||
{status.includeInstalled ? (
|
||||
<DropdownMenuItem
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ function showFailure(
|
||||
...(retry && failure.code !== 'unsupported_image'
|
||||
? {
|
||||
action: {
|
||||
label: translate('auto.components.sidebar.CloudSsh.retry', 'Retry'),
|
||||
label: translate('auto.components.sidebar.CloudSsh.tryAgain', 'Try again'),
|
||||
onClick: retry
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ type InlineErrorProps = {
|
||||
/** 'compact' for narrow panels (sidebar sections): tighter padding, xs text. */
|
||||
density?: 'default' | 'compact'
|
||||
/** 'toast' leaves room for the toast's own dismiss button and puts the action under the text.
|
||||
* 'strip-top' / 'strip-bottom' run edge to edge at the top or bottom of a framed panel. */
|
||||
layout?: 'inline' | 'toast' | 'strip-top' | 'strip-bottom'
|
||||
* 'strip-top' / 'strip-bottom' run edge to edge at the top or bottom of a framed panel.
|
||||
* 'line' is one muted line with no box, for a reason under a row in a dense list; it is a note
|
||||
* (not announced) because it describes something that already happened. */
|
||||
layout?: 'inline' | 'toast' | 'strip-top' | 'strip-bottom' | 'line'
|
||||
/** A next step other than retry (e.g. "Sign in"), rendered inside the box after the text.
|
||||
* Pass an outline xs Button with a short verb-first label; never a primary button. */
|
||||
action?: React.ReactNode
|
||||
@@ -43,6 +45,27 @@ function InlineError({
|
||||
className
|
||||
}: InlineErrorProps): React.JSX.Element {
|
||||
const Icon = tone === 'error' ? CircleAlert : Info
|
||||
if (layout === 'line') {
|
||||
return (
|
||||
<p
|
||||
role="note"
|
||||
data-slot="inline-error"
|
||||
className={cn('flex items-start gap-1.5 text-xs text-muted-foreground', className)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'mt-0.5 size-3.5 shrink-0',
|
||||
tone === 'error' ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
{title}
|
||||
{detail ? <span> {detail}</span> : null}
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
role={tone === 'error' ? 'alert' : 'status'}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Loader2,
|
||||
LifeBuoy,
|
||||
LogIn,
|
||||
Monitor,
|
||||
PanelLeft,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
@@ -31,6 +32,8 @@ export type VmBuildFailureActions = {
|
||||
onDismiss: () => void
|
||||
onSeePlans: () => void
|
||||
onOpenBilling: () => void
|
||||
/** Billing refusals: reopen the composer with the same prompt, on this computer. */
|
||||
onCreateLocally: () => void
|
||||
onSignIn: () => void
|
||||
onShowWorkspaces: () => void
|
||||
onContactSupport: () => void
|
||||
@@ -46,6 +49,8 @@ type Action = {
|
||||
disabled?: boolean
|
||||
/** In progress: stays focusable (aria-disabled + aria-busy) so keyboard focus isn't dropped. */
|
||||
pending?: boolean
|
||||
/** Outline even when first: a way around the problem, not its fix. */
|
||||
secondary?: boolean
|
||||
}
|
||||
|
||||
/** At most two visible actions; the rest sit in a More menu so the way forward stays obvious. */
|
||||
@@ -78,18 +83,34 @@ function failureActions(
|
||||
icon: <Pencil />,
|
||||
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: <Monitor />,
|
||||
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: <CreditCard />,
|
||||
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}
|
||||
|
||||
@@ -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(<Sheet failure={{ kind: 'plan' }} handlers={handlers} />)
|
||||
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(<Sheet failure={{ kind }} canManageBilling={false} handlers={handlers} />)
|
||||
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(<Sheet failure={{ kind: 'spendCap' }} handlers={handlers} />)
|
||||
const buttons = screen.getAllByRole('button').map((button) => button.textContent?.trim())
|
||||
expect(buttons.slice(0, 2)).toEqual(['Raise spend cap', 'Use this computer instead'])
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
+31
-7
@@ -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<typeof cloudFailure>) {
|
||||
...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))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
import { classifyEntitlementDenial } from '../../../../shared/orca-billing'
|
||||
|
||||
// The hosted provider writes `[orca-cloud] <phase>` progress, `Orca Cloud: <deny reason>` 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<PendingWorktreeCreation, 'heldChatMessage' | 'request'>
|
||||
|
||||
/** 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'
|
||||
}
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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<Record<string, VmBuildAccountFailureKind>> = {
|
||||
|
||||
// The hosted provider logs the control plane's deny reason as `Orca Cloud: <reason>`.
|
||||
const DENY_REASON = /^Orca Cloud: ([a-z_]+)$/m
|
||||
const DENY_REASON_KINDS: Partial<Record<string, VmBuildAccountFailureKind>> = {
|
||||
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<Record<OrcaEntitlementDenialKind, VmBuildAccountFailureKind>> =
|
||||
{
|
||||
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 }
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof setTimeout> | 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 {
|
||||
|
||||
+6
@@ -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"
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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 <person></person> {{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 <person></person> {{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",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)]!
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<Listener>()
|
||||
|
||||
/** 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()
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user