wip(cloud): composer-redo — Project | Run on row, setup moves to a full page after Start

This commit is contained in:
Neil
2026-11-10 18:53:31 -08:00
parent a868970883
commit f892a6d3f6
39 changed files with 1366 additions and 2767 deletions
@@ -10,6 +10,7 @@ import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/R
import { FloatingTerminalToggleButton } from '../components/floating-terminal/FloatingTerminalToggleButton'
import { TerminalWorkbenchContainer } from '../components/TerminalWorkbenchContainer'
import { WorkspaceLowDiskBanner } from '../components/low-disk/WorkspaceLowDiskBanner'
import { CloudSetupPageLayer } from '../components/orca-cloud-setup/CloudSetupPageLayer'
import type { VirtualizedScrollAnchor } from '../hooks/useVirtualizedScrollAnchor'
import { TitlebarLeftControls } from './TitlebarLeftControls'
import { RightSidebarToggle, TitlebarMainStrip } from './TitlebarMainStrip'
@@ -246,6 +247,7 @@ export function AppWorkspaceShell(props: {
</RecoverableRenderErrorBoundary>
</Suspense>
</div>
<CloudSetupPageLayer />
{floatingWorkspace.showToggleButton && !capturingPluginShot ? (
<MemoFloatingTerminalToggleButton
open={floatingWorkspace.open}
@@ -40,6 +40,9 @@ import { useAgentMenuObservation } from '@/components/tab-bar/use-new-tab-agent-
import { addAppKeyListener, removeAppKeyListener } from '@/lib/keyboard/app-keyboard-router'
import { useComposerCloudStart } from './new-workspace/run-on/use-composer-cloud-start'
/** How long a hidden Orca Cloud auto-start may take before the dialog shows instead. */
const CLOUD_AUTO_START_REVEAL_MS = 8_000
// Keep Add Project’s clone/SSH machinery outside the composer entry render, matching App loading.
const HostedAddRepoDialog = lazyWithRetry(
() => import('@/components/sidebar/project-admission/dialog/AddRepoDialog'),
@@ -80,6 +83,34 @@ function ComposerModalBody({
onClose()
}, [onClose])
const isSubmissionCancelled = useCallback(() => submitCancelledRef.current, [])
// Orca Cloud setup finished on its page: submit with the dialog unseen. If the start can't run
// by itself (a form error, a revoked grant), the dialog appears with everything kept.
const [autoStartRevealed, setAutoStartRevealed] = useState(false)
useEffect(() => {
if (!modalData.cloudAutoStart) {
return
}
const timer = window.setTimeout(() => setAutoStartRevealed(true), CLOUD_AUTO_START_REVEAL_MS)
return () => window.clearTimeout(timer)
}, [modalData.cloudAutoStart])
if (modalData.cloudAutoStart && !autoStartRevealed) {
return (
// A closed Root still provides the title's context; nothing is portaled or shown.
<Dialog open={false}>
<div className="hidden" aria-hidden>
<QuickTabBody
modalData={modalData}
onClose={onClose}
isSubmissionCancelled={isSubmissionCancelled}
active
/>
</div>
</Dialog>
)
}
const visibleModalData = modalData.cloudAutoStart
? { ...modalData, cloudAutoStart: false }
: modalData
return (
<Dialog open onOpenChange={(open) => !open && handleDismiss()}>
@@ -99,7 +130,7 @@ function ComposerModalBody({
}}
>
<QuickTabBody
modalData={modalData}
modalData={visibleModalData}
onClose={onClose}
isSubmissionCancelled={isSubmissionCancelled}
active
@@ -259,6 +290,18 @@ function QuickTabBody({
modalData,
cardProps
])
// The composer as it is now, for Orca Cloud's setup page (Back, and the start when it's done).
const composerSnapshot = useCallback(
(): ComposerModalData => ({
...modalData,
prefilledName: cardProps.name,
initialRepoId: cardProps.repoId,
initialBaseBranch: cardProps.baseBranch,
linkedWorkItem: cardProps.linkedWorkItem,
recovery: { ...quickAgentPick, agent: quickAgent, note: cardProps.note }
}),
[cardProps, modalData, quickAgent, quickAgentPick]
)
const { cloudStart, primaryCreateDisabled, onPrimary } = useComposerCloudStart({
active,
modalData,
@@ -266,6 +309,7 @@ function QuickTabBody({
quickAgent,
handleCreate,
createDisabled,
composerSnapshot,
recordCreatedRef,
onClose
})
@@ -304,14 +348,12 @@ function QuickTabBody({
const isFolderWorkspaceTarget =
cardProps.projectOptions.find((option) => option.id === cardProps.selectedProjectId)?.kind ===
'project-group'
// An Orca Cloud sandbox is a whole workspace, not a local worktree.
const primaryActionLabel =
cloudStart.primaryActionLabel ??
(isFolderWorkspaceTarget
? getFolderWorkspacePrimaryActionLabel(t)
: cardProps.selectedRepoIsGit && !orcaCloud
? t('auto.components.NewWorkspaceComposerModal.createWorktree', 'Create worktree')
: t('auto.components.NewWorkspaceComposerModal.createWorkspace', 'Create workspace'))
// Run on never changes the button: Orca Cloud reads exactly like this computer.
const primaryActionLabel = isFolderWorkspaceTarget
? getFolderWorkspacePrimaryActionLabel(t)
: cardProps.selectedRepoIsGit
? t('auto.components.NewWorkspaceComposerModal.createWorktree', 'Create worktree')
: t('auto.components.NewWorkspaceComposerModal.createWorkspace', 'Create workspace')
// Let Radix own Escape for the topmost layer. This dialog auto-focuses input, so page-style blur-first handling swallowed its first Escape.
const nestedDialogOpen = agentSettingsOpen || addProjectOpen || setLocationOpen
@@ -16,7 +16,6 @@ type NewWorkspaceComposerFooterProps = Pick<
| 'createDisabled'
| 'creating'
| 'primaryActionLabel'
| 'cloudStart'
> & {
submitShortcutModifierLabel: string
}
@@ -30,13 +29,9 @@ export function NewWorkspaceComposerFooter({
createDisabled,
creating,
primaryActionLabel,
submitShortcutModifierLabel,
cloudStart
submitShortcutModifierLabel
}: NewWorkspaceComposerFooterProps): React.JSX.Element {
const t = useTranslate()
const heldReasonId = React.useId()
// B2: Start stays focusable but aria-disabled, saying why ("Switch to acme first").
const heldReason = cloudStart?.primaryHeldReason
return (
<>
{createError ? (
@@ -56,16 +51,16 @@ export function NewWorkspaceComposerFooter({
</div>
) : null}
{/* Wraps instead of overflowing: "Use this computer instead" plus a long primary label
("Give access and start") don't fit beside "Create more" in the modal's width. */}
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
{/* One row: "Create more" on the left, the one primary button on the right. Run on never
adds buttons here; Orca Cloud setup happens on its own page after the button. */}
<div className="flex items-center justify-between gap-3">
{showCreateMultiple ? (
<button
type="button"
role="switch"
aria-checked={createMultiple}
onClick={() => onCreateMultipleChange?.(!createMultiple)}
className="group flex w-fit cursor-pointer items-center gap-2 rounded-md text-xs outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-focus-ring"
className="group flex w-fit shrink-0 cursor-pointer items-center gap-2 rounded-md text-xs outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-focus-ring"
>
<SwitchIndicator checked={createMultiple} />
<span className="whitespace-nowrap text-muted-foreground transition-colors group-hover:text-foreground">
@@ -73,32 +68,16 @@ export function NewWorkspaceComposerFooter({
</span>
</button>
) : null}
<div className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-x-3 gap-y-2">
{cloudStart?.onUseThisComputer ? (
<Button type="button" variant="ghost" size="sm" onClick={cloudStart.onUseThisComputer}>
{t('components.newWorkspace.cloudStart.useThisComputer', 'Use this computer instead')}
</Button>
) : null}
<Button
onClick={heldReason ? undefined : () => void onCreate()}
disabled={createDisabled}
aria-disabled={heldReason ? true : undefined}
aria-describedby={heldReason ? heldReasonId : undefined}
title={heldReason}
variant={heldReason ? 'default-held' : 'default'}
size="sm"
className="text-xs"
>
{creating ? <LoaderCircle className="size-4 animate-spin" /> : null}
{primaryActionLabel}
<ButtonKeyHint modifierLabel={submitShortcutModifierLabel} />
</Button>
</div>
{heldReason ? (
<span id={heldReasonId} className="sr-only">
{heldReason}
</span>
) : null}
<Button
onClick={() => void onCreate()}
disabled={createDisabled}
size="sm"
className="ml-auto shrink-0 text-xs"
>
{creating ? <LoaderCircle className="size-4 animate-spin" /> : null}
{primaryActionLabel}
<ButtonKeyHint modifierLabel={submitShortcutModifierLabel} />
</Button>
</div>
</>
)
@@ -1,11 +1,10 @@
import React, { useState } from 'react'
import React from 'react'
import { FolderPlus, LoaderCircle, PlugZap } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import ProjectCombobox from '@/components/new-workspace/ProjectCombobox'
import RunTargetCombobox from '@/components/new-workspace/RunTargetCombobox'
import { ORCA_CLOUD_RECIPE_ID } from '../../../../shared/hosted-sandbox/orca-cloud-recipe-id'
import { OrcaCloudAgentSetupRow } from './OrcaCloudAgentSetupRow'
import { useTranslate } from '@/i18n/use-translate'
import { cn } from '@/lib/utils'
import { getSelectedNewWorkspaceProjectOptionId } from '@/lib/new-workspace-project-options'
@@ -15,15 +14,7 @@ import type {
NewWorkspaceComposerCardProps
} from './new-workspace-composer-card-props'
import { EMPTY_PROJECT_OPTIONS } from './new-workspace-composer-card-props'
import { OrcaCloudSelectionHint } from './OrcaCloudSelectionHint'
import RunOnSegmentedControl from './run-on/RunOnSegmentedControl'
import { RunOnHintLine } from './run-on/RunOnHintLine'
import { OrcaCloudSetupPanel } from './run-on/OrcaCloudSetupPanel'
import { CloudSetupBrowserTracker } from './cloud-setup/CloudSetupBrowserTracker'
import { isOrcaCloudRecipe, isOrcaCloudSelection } from './run-target-options'
import { useOrcaCloudRunTargetAccess } from './orca-cloud-billing-cache'
import type { OrcaCloudRunTargetAccess } from './orca-cloud-run-target-access'
import { FolderGitHubActions } from './run-on/FolderGitHubActions'
type NewWorkspaceComposerProjectSectionProps = Pick<
NewWorkspaceComposerCardProps,
@@ -46,8 +37,7 @@ type NewWorkspaceComposerProjectSectionProps = Pick<
| 'ephemeralVmRecipeError'
| 'orcaCloudAvailability'
| 'cloudStart'
> &
Partial<Pick<NewWorkspaceComposerCardProps, 'quickAgent'>> & {
> & {
disabled?: boolean
projectDescriptionId: string
onAddProject: () => void
@@ -102,7 +92,6 @@ export function NewWorkspaceComposerProjectSection({
connectButtonLabel,
selectedProjectName,
orcaCloudAccess,
quickAgent = null,
cloudStart = null
}: NewWorkspaceComposerProjectSectionProps): React.JSX.Element {
// D4 rows: while Orca Cloud is picked, and for a folder, which can only start from GitHub.
@@ -110,15 +99,12 @@ export function NewWorkspaceComposerProjectSection({
selectedEphemeralVmRecipeId === ORCA_CLOUD_RECIPE_ID || cloudStart?.folderProject
? cloudStart?.cloudOnly
: undefined
const [pickerSignal, setPickerSignal] = useState(0)
const t = useTranslate()
const orcaCloudOffered = shouldShowRunTargetPicker && ephemeralVmRecipes.some(isOrcaCloudRecipe)
// Shared with the picker; mounting the composer prefetches it so the row rarely shows a skeleton.
const fetchedOrcaCloudAccess = useOrcaCloudRunTargetAccess(orcaCloudOffered && !orcaCloudAccess)
return (
<fieldset disabled={disabled} className="space-y-1">
{/* Run on as segments needs the full width, so it sits under the project (spec E1). */}
<div className={cn('grid gap-3', shouldShowRunTargetPicker && !cloudStart && 'grid-cols-2')}>
{/* One row, two equal columns: Project | Run on. Picking a target never changes the rows
below it, so the dialog keeps its height (composer-redo). */}
<div className={cn('grid gap-3', shouldShowRunTargetPicker && 'grid-cols-2')}>
<div className="min-w-0 space-y-1">
<div className="flex h-5 items-center justify-between gap-2">
<label className="text-xs font-medium text-muted-foreground">
@@ -169,7 +155,6 @@ export function NewWorkspaceComposerProjectSection({
cloudOnly?.pick(fullName)
cloudStart?.onSelectOrcaCloud()
}}
openSignal={pickerSignal}
onMoreCloudAccess={cloudOnly?.moreAccess}
onShowMoreCloudRepos={cloudOnly?.showMore ?? null}
placeholder={
@@ -200,96 +185,37 @@ export function NewWorkspaceComposerProjectSection({
<label className="flex h-5 min-w-0 items-center truncate text-xs font-medium text-muted-foreground">
{t('auto.components.NewWorkspaceComposerCard.runOn', 'Run on')}
</label>
{cloudStart ? (
<RunOnSegmentedControl
cloudOnlyRepo={Boolean(cloudOnly?.picked)}
hostOptions={projectHostSetupOptions ?? []}
hostValue={selectedProjectHostSetupId ?? null}
hostExecutionHostId={selectedRepoExecutionHostId}
onHostChange={handleProjectHostSetupChange}
recipes={ephemeralVmRecipes}
recipeValue={selectedEphemeralVmRecipeId}
onRecipeChange={onEphemeralVmRecipeChange}
onAddSshHost={handleAddSshHost}
onAddRemoteServer={handleAddRemoteServer}
onConnectHost={handleConnectRunTargetHost}
onSetLocation={handleSetLocation}
orcaCloudAvailability={orcaCloudAvailability}
{...(orcaCloudAccess ? { orcaCloudAccess } : {})}
onSelectOrcaCloud={cloudStart.onSelectOrcaCloud}
onUserPick={cloudStart.onUserPick}
/>
) : (
<RunTargetCombobox
hostOptions={projectHostSetupOptions ?? []}
hostValue={selectedProjectHostSetupId ?? null}
hostExecutionHostId={selectedRepoExecutionHostId}
onHostChange={handleProjectHostSetupChange}
recipes={ephemeralVmRecipes}
recipeValue={selectedEphemeralVmRecipeId}
onRecipeChange={onEphemeralVmRecipeChange}
onAddSshHost={handleAddSshHost}
onAddRemoteServer={handleAddRemoteServer}
onConnectHost={handleConnectRunTargetHost}
onSetLocation={handleSetLocation}
orcaCloudAvailability={orcaCloudAvailability}
{...(orcaCloudAccess ? { orcaCloudAccess } : {})}
/>
)}
<RunTargetCombobox
hostOptions={projectHostSetupOptions ?? []}
hostValue={selectedProjectHostSetupId ?? null}
hostExecutionHostId={selectedRepoExecutionHostId}
onHostChange={(setupId) => {
cloudStart?.onUserPick()
handleProjectHostSetupChange(setupId)
}}
recipes={ephemeralVmRecipes}
recipeValue={selectedEphemeralVmRecipeId}
onRecipeChange={(recipeId) => {
cloudStart?.onUserPick()
onEphemeralVmRecipeChange?.(recipeId)
}}
onAddSshHost={handleAddSshHost}
onAddRemoteServer={handleAddRemoteServer}
onConnectHost={handleConnectRunTargetHost}
onSetLocation={handleSetLocation}
orcaCloudAvailability={orcaCloudAvailability}
{...(orcaCloudAccess ? { orcaCloudAccess } : {})}
// Setup happens after Start, on its own page: picking Orca Cloud just picks it.
{...(cloudStart ? { onSelectOrcaCloud: cloudStart.onSelectOrcaCloud } : {})}
/>
{ephemeralVmRecipeError ? (
<p className="whitespace-pre-line text-[11px] text-destructive">
{ephemeralVmRecipeError}
</p>
) : cloudStart && selectedEphemeralVmRecipeId !== ORCA_CLOUD_RECIPE_ID ? (
<RunOnHintLine
hostOptions={projectHostSetupOptions ?? []}
selectedHostId={selectedProjectHostSetupId ?? null}
recipes={ephemeralVmRecipes}
selectedRecipeId={selectedEphemeralVmRecipeId}
orcaCloudAvailability={orcaCloudAvailability}
/>
) : orcaCloudOffered && !cloudStart?.panelVisible ? (
<OrcaCloudSelectionHint
access={orcaCloudAccess ?? fetchedOrcaCloudAccess}
visible={isOrcaCloudSelection({ ephemeralVmRecipes, selectedEphemeralVmRecipeId })}
/>
) : null}
</div>
) : null}
</div>
{cloudStart?.tracker && selectedEphemeralVmRecipeId === ORCA_CLOUD_RECIPE_ID ? (
// A6: the browser trip is open; the same rows tick in place, with X1 errors.
<CloudSetupBrowserTracker
{...cloudStart.tracker}
steps={cloudStart.steps}
rowActions={cloudStart.rowActions}
/>
) : cloudStart?.panelVisible && selectedEphemeralVmRecipeId === ORCA_CLOUD_RECIPE_ID ? (
// The panel's own Agent row (G) comes from the same model as the standalone one below.
<OrcaCloudSetupPanel
steps={cloudStart.steps}
allSet={cloudStart.allSet}
rowActions={
cloudStart.folderProject && !cloudOnly?.picked
? {
github: (
<FolderGitHubActions
canChoose={Boolean(cloudOnly?.repos?.length)}
onChooseRepository={() => setPickerSignal((n) => n + 1)}
onAddProject={onAddProject}
/>
)
}
: cloudStart.rowActions
}
/>
) : // Agent row (cloud-start G) without the panel: links to the one-time setup; never blocks Start.
selectedEphemeralVmRecipeId === ORCA_CLOUD_RECIPE_ID ? (
<OrcaCloudAgentSetupRow
agent={quickAgent}
access={orcaCloudAccess ?? fetchedOrcaCloudAccess}
/>
) : null}
{!shouldShowRunTargetPicker && ephemeralVmRecipeError ? (
<p className="whitespace-pre-line text-2xs text-destructive">{ephemeralVmRecipeError}</p>
) : null}
@@ -1,283 +0,0 @@
import React from 'react'
import { cn } from '@/lib/utils'
import { useTranslate } from '@/i18n/use-translate'
import { getOrcaCloudPick, type OrcaCloudRunTargetAccess } from './orca-cloud-run-target-access'
import { invalidateOrcaCloudBilling } from './orca-cloud-billing-cache'
import { openPlansPage } from '@/components/billing/plans/plans-page-store'
import { ORCA_CLOUD_IDLE_SLEEP_MINUTES } from '../../../../shared/hosted-sandbox/hosted-sandbox-wire'
import { OrcaCloudSpecs, useOrcaCloudHoursLeft } from './RunTargetOrcaCloudRow'
import { isOrcaCloudTrialLow } from './run-on/orca-cloud-hours-copy'
/** T1: "Trial · 0.3 sandbox hours left · Choose plan", or who can choose one for members. */
function TrialLowLine({
access,
hoursLeft
}: {
access: OrcaCloudRunTargetAccess
hoursLeft: string | null
}): React.JSX.Element {
const t = useTranslate()
const canChoose = access.state === 'entitled' && access.canManageBilling === true
return (
<>
<p className="flex min-w-0 items-center gap-1.5 font-medium text-status-warning-text">
<span className="truncate">{hoursLeft}</span>
{canChoose ? (
<>
<span aria-hidden>·</span>
<button
type="button"
className="shrink-0 font-medium text-foreground underline underline-offset-2 hover:no-underline"
onClick={() => {
invalidateOrcaCloudBilling({ refetchNow: false })
openPlansPage('sandbox')
}}
>
{t('components.newWorkspace.cloudSetup.choosePlan', 'Choose plan')}
</button>
</>
) : null}
</p>
<p className="truncate">
{canChoose
? t(
'auto.components.new.workspace.RunTargetCombobox.orcaCloudIdleSleep',
'Sleeps after {{count}} minutes idle',
{
count: ORCA_CLOUD_IDLE_SLEEP_MINUTES,
defaultValue_one: 'Sleeps after {{count}} minute idle'
}
)
: t(
'components.newWorkspace.cloudSetup.askChoosePlan',
'Ask an owner or admin to choose a plan.'
)}
</p>
</>
)
}
type BlockCopy = {
title: string
detail: string
tone: 'error' | 'neutral'
/** Owners/admins only; members read who can fix it in `detail`. */
action?: string
}
function useBlockCopy(access: OrcaCloudRunTargetAccess): BlockCopy | null {
const t = useTranslate()
switch (access.state) {
case 'no-plan':
if (access.trialOfferSeconds) {
// B1: the trial is included; Start claims it, so nothing blocks.
return null
}
if (access.trialUsedUp) {
// T2: neutral, nothing failed.
return {
title: t('components.newWorkspace.cloudSetup.trialUsedUpTitle', 'Your trial is used up'),
detail: access.canManageBilling
? t(
'components.newWorkspace.cloudSetup.trialUsedUpChoose',
'Choose a plan to keep using Orca Cloud.'
)
: t(
'components.newWorkspace.cloudSetup.askChoosePlan',
'Ask an owner or admin to choose a plan.'
),
tone: 'neutral',
...(access.canManageBilling
? { action: t('components.newWorkspace.cloudSetup.choosePlan', 'Choose plan') }
: {})
}
}
return {
title: access.lapsed
? t(
'auto.components.new.workspace.RunTargetCombobox.orcaCloudPlanEnded',
'Your Team plan ended'
)
: t(
'auto.components.new.workspace.RunTargetCombobox.orcaCloudNeedsTeamPlan',
'Orca Cloud needs a Team plan.'
),
detail: access.canManageBilling
? ''
: t(
'auto.components.new.workspace.RunTargetCombobox.orcaCloudAskChoosePlan',
'Ask an owner or admin to choose one.'
),
tone: 'neutral',
...(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'
// Create time: no workspace exists yet, so "Your files are safe." does not apply here.
return {
title: cap
? t(
'auto.components.new.workspace.RunTargetCombobox.orcaCloudCapReached',
'Spend cap reached'
)
: t(
'auto.components.new.workspace.RunTargetCombobox.orcaCloudOutOfHours',
'Out of sandbox hours'
),
detail: access.canManageBilling
? ''
: 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
? {
action:
access.cause === 'cap'
? t(
'auto.components.new.workspace.RunTargetCombobox.orcaCloudRaiseSpendCap',
'Raise spend cap'
)
: t(
'auto.components.new.workspace.RunTargetCombobox.orcaCloudTurnOnOverage',
'Turn on overage'
)
}
: {})
}
}
case 'payment-issue':
return {
title: t(
'auto.components.new.workspace.RunTargetCombobox.orcaCloudPaymentFailed',
'Payment failed'
),
detail: access.canManageBilling
? t(
'auto.components.new.workspace.RunTargetCombobox.orcaCloudPaymentEffect',
"New sandboxes can't start."
)
: t(
'auto.components.new.workspace.RunTargetCombobox.orcaCloudAskPayment',
'Ask an owner or admin to update the payment method.'
),
tone: 'error',
...(access.canManageBilling
? {
action: t(
'auto.components.new.workspace.RunTargetCombobox.orcaCloudUpdatePaymentMethod',
'Update payment method'
)
}
: {})
}
case 'loading':
case 'unknown':
case 'signed-out':
case 'entitled':
return null
}
}
/**
* Context under Run on once Orca Cloud is the committed target, in exactly two reserved lines so
* neither picking nor a late billing answer moves the form: specs and sandbox hours over idle
* sleep, or what blocks it over who can fix it, with the fix as an inline link. The wrapper is an
* always-mounted polite live region, so a block that arrives after the pick is announced.
*/
export function OrcaCloudSelectionHint({
access,
visible
}: {
access: OrcaCloudRunTargetAccess
/** Orca Cloud is the committed target; otherwise the two lines stay empty. */
visible: boolean
}): React.JSX.Element {
const t = useTranslate()
const block = useBlockCopy(access)
const hoursLeft = useOrcaCloudHoursLeft(access)
// T1: under half an hour of trial left, announced once as it crosses (the live region below).
const low = isOrcaCloudTrialLow(access)
const pick = getOrcaCloudPick(access)
const action =
block?.action && pick.kind === 'act' ? (
<button
type="button"
className="shrink-0 font-medium text-foreground underline underline-offset-2 hover:no-underline"
onClick={() => {
// The fix finishes elsewhere; reread billing when the user comes back.
invalidateOrcaCloudBilling({ refetchNow: false })
pick.run()
}}
>
{block.action}
</button>
) : null
return (
// @container: the specs drop out whole when the column is narrow, instead of ellipsizing.
<div className="@container h-8 text-2xs leading-4 tabular-nums text-muted-foreground">
{visible && !block && !low ? (
// Outside the live region: hour ticks on refresh are not re-announced.
<>
<p className="flex min-w-0">
<span className="truncate">
<OrcaCloudSpecs t={t} />
</span>
{hoursLeft ? (
// pl-1: a flex item trims its leading space, so the gap before "·" comes from padding.
<span className="shrink-0 whitespace-nowrap pl-1">
{'· '}
{hoursLeft}
</span>
) : null}
</p>
<p className="truncate">
{/* One source for n: the control plane, billing and every surface read this constant. */}
{t(
'auto.components.new.workspace.RunTargetCombobox.orcaCloudIdleSleep',
'Sleeps after {{count}} minutes idle',
{
count: ORCA_CLOUD_IDLE_SLEEP_MINUTES,
defaultValue_one: 'Sleeps after {{count}} minute idle'
}
)}
</p>
</>
) : null}
{/* Always mounted, so a block that arrives after the pick (late billing, restored draft) is announced. */}
<div aria-live="polite">
{visible && !block && low ? <TrialLowLine access={access} hoursLeft={hoursLeft} /> : null}
{visible && block ? (
<>
<p
className={cn(
'flex min-w-0 items-center gap-1.5 font-medium',
block.tone === 'error' ? 'text-foreground' : 'text-status-warning-text'
)}
>
{block.tone === 'error' ? (
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-destructive" />
) : null}
<span className="truncate">{block.title}</span>
{action ? <span aria-hidden>·</span> : null}
{action}
</p>
{block.detail ? <p className="truncate">{block.detail}</p> : null}
</>
) : null}
</div>
</div>
)
}
@@ -9,6 +9,7 @@ import {
type ReadyProjectHostSetupOption
} from '@/lib/project-host-setup-options'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { ORCA_CLOUD_RECIPE_ID } from '../../../../shared/hosted-sandbox/orca-cloud-recipe-id'
import { isWithinComboboxRoot, useTypeAheadCombobox } from './use-type-ahead-combobox'
import { COMBOBOX_POPOVER_SURFACE } from './type-ahead-combobox-styles'
import {
@@ -17,6 +18,9 @@ import {
getRecipeSubmenuItemKey,
getRunTargetSubmenuItemKeys,
getRunTargetCurrentRowKey,
getOrcaCloudDetail,
ORCA_CLOUD_LABEL,
RUN_TARGET_ORCA_CLOUD_KEY,
getRunTargetOptionId,
RUN_TARGET_ADD_HOST_KEY,
RUN_TARGET_ADD_REMOTE_SERVER_ITEM,
@@ -50,6 +54,11 @@ type RunTargetComboboxProps = {
orcaCloudAccess?: OrcaCloudRunTargetAccess
/** From main: drives the sign-in/upsell row when there is no Orca Cloud recipe. */
orcaCloudAvailability?: OrcaCloudAvailability
/**
* The composer's pick: Orca Cloud is selected even while the account needs setup, which then
* happens on a page after Start. Without it, a blocked account's pick runs the fix instead.
*/
onSelectOrcaCloud?: () => void
}
/** Add-host actions open dialogs, so the picker closes before they run. */
@@ -81,7 +90,8 @@ export default function RunTargetCombobox({
onConnectHost,
onSetLocation,
orcaCloudAccess: orcaCloudAccessOverride,
orcaCloudAvailability = 'unavailable'
orcaCloudAvailability = 'unavailable',
onSelectOrcaCloud
}: RunTargetComboboxProps): React.JSX.Element {
const t = useTranslate()
// Track in-flight connects per host so one stalling connect never blocks the others.
@@ -100,8 +110,13 @@ export default function RunTargetCombobox({
// An unlisted target (e.g. a per-workspace VM) shows "Choose target", never another host's name (#38669).
null
const selectedRecipe = recipes.find((recipe) => recipe.id === recipeValue) ?? null
// Signed out, main lists no Orca Cloud recipe, yet the composer can still pick it.
const orcaCloudPicked =
selectedRecipe === null && recipeValue === ORCA_CLOUD_RECIPE_ID && Boolean(onSelectOrcaCloud)
// Opening highlights the committed row, so only one row reads as chosen.
const currentRowKey = getRunTargetCurrentRowKey(selectedRecipe, selectedHost)
const currentRowKey = orcaCloudPicked
? RUN_TARGET_ORCA_CLOUD_KEY
: getRunTargetCurrentRowKey(selectedRecipe, selectedHost)
const hasAddHost = Boolean(onAddSshHost || onAddRemoteServer)
const orcaCloudOffer = getRunTargetOrcaCloudOffer({
@@ -137,7 +152,8 @@ export default function RunTargetCombobox({
const { submenu, armedItemKey, openSubmenu, closeSubmenu, armItem, moveItemArm, armItemEdge } =
useRunTargetSubmenu(submenuItemKeys)
const armedRow = rows.find((row) => row.key === armedKey) ?? rows[0] ?? null
const committed = query.length === 0 && (selectedRecipe !== null || selectedHost !== null)
const committed =
query.length === 0 && (orcaCloudPicked || selectedRecipe !== null || selectedHost !== null)
// Closing also drops any open submenu, which the shared hook doesn't know about.
const close = useCallback((): void => {
@@ -173,7 +189,8 @@ export default function RunTargetCombobox({
override: orcaCloudAccessOverride,
open,
close,
selectRecipe
selectRecipe,
...(onSelectOrcaCloud ? { onSelectOrcaCloud } : {})
})
const addSshHost = useMemo(() => closingFirst(close, onAddSshHost), [close, onAddSshHost])
@@ -307,7 +324,9 @@ export default function RunTargetCombobox({
const activeDescendantId = activeDescendantKey ? optionIdFor(activeDescendantKey) : undefined
const addHostOptionId = optionIdFor(RUN_TARGET_ADD_HOST_KEY)
const field = getRunTargetFieldText(selectedRecipe, selectedHost, t)
const field = orcaCloudPicked
? { label: ORCA_CLOUD_LABEL, detail: getOrcaCloudDetail(t), recipeKind: 'orca-cloud' as const }
: getRunTargetFieldText(selectedRecipe, selectedHost, t)
return (
<Popover open={open} onOpenChange={handleOpenChange}>
@@ -323,7 +342,7 @@ export default function RunTargetCombobox({
onToggle={() => setOpen(!open)}
committed={committed}
recipeKind={field.recipeKind}
hostId={selectedHost?.hostId ?? null}
hostId={orcaCloudPicked ? null : (selectedHost?.hostId ?? null)}
label={field.label}
detail={field.detail}
controlsId={submenu ? `${listId} ${listId}-${submenu}` : listId}
@@ -377,8 +396,8 @@ export default function RunTargetCombobox({
<RunTargetRowList
rows={rows}
armedKey={armedRow?.key ?? null}
selectedHost={selectedRecipe === null ? selectedHost : null}
selectedRecipeId={selectedRecipe?.id ?? null}
selectedHost={selectedRecipe === null && !orcaCloudPicked ? selectedHost : null}
selectedRecipeId={orcaCloudPicked ? ORCA_CLOUD_RECIPE_ID : (selectedRecipe?.id ?? null)}
matchedRecipes={matchedRecipes}
recipesOpen={submenu === 'recipes'}
inSubmenu={inSubmenu}
@@ -404,6 +423,7 @@ export default function RunTargetCombobox({
onSelectRecipe={selectRecipe}
onSelectOrcaCloud={selectOrcaCloud}
onOrcaCloudBlockedAction={runOrcaCloudBlockedAction}
orcaCloudSetupAfterStart={Boolean(onSelectOrcaCloud)}
onConnectHost={(option) => void connectHost(option)}
onSetLocation={setLocation}
/>
@@ -270,6 +270,22 @@ function OrcaCloudMeta({
}
}
/** Hours left, the loading skeleton, and "Ask an owner or admin" still say something useful. */
function showsMetaAfterStart(access: OrcaCloudRunTargetAccess): boolean {
switch (access.state) {
case 'loading':
case 'entitled':
return true
case 'no-plan':
case 'out-of-hours':
case 'payment-issue':
return !access.canManageBilling
case 'signed-out':
case 'unknown':
return false
}
}
/** Orca Cloud as a first-class Run-on target, with its hours or the fix for what blocks it. */
export function RunTargetOrcaCloudRow({
recipe,
@@ -280,7 +296,8 @@ export function RunTargetOrcaCloudRow({
optionId,
onArm,
onSelect,
onBlockedAction
onBlockedAction,
setupAfterStart = false
}: {
recipe: EphemeralVmRecipeOption | null
access: OrcaCloudRunTargetAccess
@@ -292,6 +309,8 @@ export function RunTargetOrcaCloudRow({
onArm: () => void
onSelect: () => void
onBlockedAction: () => void
/** The composer walks setup on a page after Start: no inline Sign in / Upgrade here. */
setupAfterStart?: boolean
}): React.JSX.Element {
const t = useTranslate()
return (
@@ -320,7 +339,11 @@ export function RunTargetOrcaCloudRow({
onArm={onArm}
onCommit={onSelect}
description={orcaCloudRowHint(access, t)}
trailing={<OrcaCloudMeta access={access} onAction={onBlockedAction} />}
trailing={
setupAfterStart && !showsMetaAfterStart(access) ? undefined : (
<OrcaCloudMeta access={access} onAction={onBlockedAction} />
)
}
/>
)
}
@@ -10,6 +10,7 @@ import { RunTargetOrcaCloudRow } from './RunTargetOrcaCloudRow'
import { needsSetupRowHint } from './run-target-row-hints'
import { useTranslate } from '@/i18n/use-translate'
import type { OrcaCloudRunTargetAccess } from './orca-cloud-run-target-access'
import { ORCA_CLOUD_RECIPE_ID } from '../../../../shared/hosted-sandbox/orca-cloud-recipe-id'
import type { EphemeralVmRecipeOption, RunTargetRowModel } from './run-target-options'
/** The scrolling rows of the run-target picker; "Add host" is pinned outside this list. */
@@ -34,6 +35,7 @@ export default function RunTargetRowList({
onSelectRecipe,
onSelectOrcaCloud,
onOrcaCloudBlockedAction,
orcaCloudSetupAfterStart = false,
onConnectHost,
onSetLocation
}: {
@@ -61,6 +63,8 @@ export default function RunTargetRowList({
onSelectOrcaCloud: (recipe: EphemeralVmRecipeOption | null) => void
/** Plans or billing, for an account that cannot start a sandbox. */
onOrcaCloudBlockedAction: () => void
/** Picking Orca Cloud just picks it; setup comes after Start, so the row offers no fixes. */
orcaCloudSetupAfterStart?: boolean
onConnectHost: (option: NeedsSetupProjectHostOption) => void
onSetLocation: (option: NeedsSetupProjectHostOption) => void
}): React.JSX.Element {
@@ -96,11 +100,16 @@ export default function RunTargetRowList({
access={orcaCloudAccess}
pending={orcaCloudPending}
armed={isArmed}
current={row.recipe !== null && row.recipe.id === selectedRecipeId}
current={
row.recipe !== null
? row.recipe.id === selectedRecipeId
: selectedRecipeId === ORCA_CLOUD_RECIPE_ID
}
optionId={optionId}
onArm={() => onArm(row.key, null)}
onSelect={() => onSelectOrcaCloud(row.recipe)}
onBlockedAction={onOrcaCloudBlockedAction}
setupAfterStart={orcaCloudSetupAfterStart}
/>
)
}
@@ -1,207 +0,0 @@
import { defineFixture } from '../../../component-fixtures/component-fixture'
import { OrcaCloudSetupPanel } from '@/components/new-workspace/run-on/OrcaCloudSetupPanel'
import type { CloudSetupStep } from '@/components/new-workspace/run-on/cloud-start-model'
import { CloudSetupBrowserTracker } from './CloudSetupBrowserTracker'
import { CloudSetupErrorNotice } from './CloudSetupErrorNotice'
import { useCloudSetupSwitchTeamFor } from './CloudSetupSwitchTeam'
import type { CloudSetupError } from './cloud-setup-errors'
import type { PlannedTeam } from './use-switch-to-planned-team'
const noop = (): void => {}
// Agent row in agent-accounts v4 wording (glossary "Agent source").
const NOT_SET_UP =
'Claude isn’t set up for Orca Cloud yet. Set it up once in Settings → Agent accounts.'
const FROM_ACCOUNT = 'Claude · from your Orca account'
const SET_UP_ONCE = { label: 'Set up once', onClick: noop }
// A6: account and team done in the browser, GitHub in progress.
const waitingGithub: CloudSetupStep[] = [
{ id: 'account', state: 'done', detail: 'neil@acme.dev' },
{ id: 'team', state: 'done', detail: 'acme · 2 sandbox hours to try Orca Cloud' },
{ id: 'github', state: 'active', detail: 'Waiting for GitHub…' },
{ id: 'agent', state: 'optional', detail: NOT_SET_UP, action: SET_UP_ONCE }
]
// Rows derived from billing before the setup session exists: the plan row is the browser's.
const derived: CloudSetupStep[] = [
{ id: 'account', state: 'done' },
{ id: 'plan', state: 'needed', need: 'plan', canFix: true },
{ id: 'agent', state: 'optional', detail: NOT_SET_UP, action: SET_UP_ONCE }
]
// Signed in, nothing finished yet: the account step is in the browser.
const signingIn: CloudSetupStep[] = [
{ id: 'account', state: 'active' },
{ id: 'team', state: 'pending' },
{ id: 'github', state: 'pending' },
{ id: 'agent', state: 'optional', detail: NOT_SET_UP, action: SET_UP_ONCE }
]
function Tracker({
steps = waitingGithub,
error = null,
canManageBilling = true
}: {
steps?: CloudSetupStep[]
error?: CloudSetupError | null
canManageBilling?: boolean
}): React.JSX.Element {
return (
<div className="p-4">
<CloudSetupBrowserTracker
steps={steps}
error={error}
canManageBilling={canManageBilling}
onCancel={noop}
onCopyLink={noop}
onReopen={noop}
onErrorAction={noop}
/>
</div>
)
}
export const trackerWaitingGithub = defineFixture({ width: 560, render: () => <Tracker /> })
export const trackerSigningIn = defineFixture({
width: 560,
render: () => <Tracker steps={signingIn} />
})
export const trackerDerivedRows = defineFixture({
width: 560,
render: () => <Tracker steps={derived} />
})
const stopped: CloudSetupStep[] = [
{ id: 'account', state: 'done', detail: 'neil@acme.dev' },
{ id: 'team', state: 'done', detail: 'acme' },
{ id: 'plan', state: 'needed', need: 'plan', canFix: true },
{ id: 'agent', state: 'optional', detail: NOT_SET_UP, action: SET_UP_ONCE }
]
export const trackerTimedOut = defineFixture({
width: 560,
render: () => <Tracker steps={stopped} error={{ kind: 'timed_out' }} />
})
export const trackerCheckoutCanceled = defineFixture({
width: 560,
render: () => <Tracker steps={stopped} error={{ kind: 'checkout_canceled' }} />
})
export const trackerBankStep = defineFixture({
width: 560,
render: () => <Tracker steps={stopped} error={{ kind: 'bank_step' }} />
})
export const trackerTrialUnavailable = defineFixture({
width: 560,
render: () => <Tracker steps={stopped} error={{ kind: 'trial_unavailable' }} />
})
export const trackerTrialUnavailableMember = defineFixture({
width: 560,
render: () => (
<Tracker steps={stopped} error={{ kind: 'trial_unavailable' }} canManageBilling={false} />
)
})
export const trackerTeamCreateFailed = defineFixture({
width: 560,
render: () => (
<Tracker
steps={[
{ id: 'account', state: 'done', detail: 'neil@acme.dev' },
{ id: 'team', state: 'failed' },
{ id: 'github', state: 'pending' }
]}
error={{ kind: 'team_create_failed' }}
/>
)
})
export const trackerWrongAccount = defineFixture({
width: 560,
render: () => (
<Tracker
steps={[
{ id: 'account', state: 'failed', detail: 'sam@other.dev' },
{ id: 'team', state: 'pending' },
{ id: 'github', state: 'pending' }
]}
error={{ kind: 'wrong_account', detail: 'sam@other.dev', teamName: 'acme' }}
/>
)
})
// X1: every setup error notice, as in the spec's sheet.
export const x1SetupErrors = defineFixture({
width: 560,
render: () => (
<div className="flex flex-col gap-2 p-4">
{(
[
{ kind: 'timed_out' },
{ kind: 'checkout_canceled' },
{ kind: 'bank_step' },
{ kind: 'trial_unavailable' },
{ kind: 'team_create_failed' },
{ kind: 'wrong_account', detail: 'sam@other.dev', teamName: 'acme' }
] satisfies CloudSetupError[]
).map((error) => (
<CloudSetupErrorNotice key={error.kind} error={error} onAction={noop} />
))}
<CloudSetupErrorNotice
error={{ kind: 'trial_unavailable' }}
canManageBilling={false}
onAction={noop}
/>
</div>
)
})
// B1: an owner whose team never had a trial: the trial is the first plan, no trip.
export const b1OwnerTrialAvailable = defineFixture({
width: 560,
render: () => (
<div className="p-4">
<OrcaCloudSetupPanel
steps={[
{
id: 'plan',
state: 'done',
detail: '2 sandbox hours to try Orca Cloud · no card needed'
},
{ id: 'github', state: 'done', detail: 'acme/web' },
{ id: 'agent', state: 'done', detail: FROM_ACCOUNT }
]}
/>
</div>
)
})
function SwitchTeamPanel({ teams }: { teams: PlannedTeam[] }): React.JSX.Element {
const b2 = useCloudSetupSwitchTeamFor(teams, noop)
return (
<div className="h-[260px] p-4">
<OrcaCloudSetupPanel
steps={[
{ id: 'account', state: 'done', detail: 'neil@acme.dev · Personal account' },
...(b2 ? [b2.step] : []),
{ id: 'github', state: 'done', detail: 'acme/web' }
]}
rowActions={b2 ? { team: b2.action } : undefined}
/>
</div>
)
}
// B2: personal account, but a team the user is on has a plan: switch instead of buying.
export const b2SwitchTeam = defineFixture({
width: 560,
render: () => <SwitchTeamPanel teams={[{ orgId: 'org_acme', name: 'acme', planLabel: 'Team' }]} />
})
export const b2SwitchTeamMenu = defineFixture({
width: 560,
render: () => (
<SwitchTeamPanel
teams={[
{ orgId: 'org_acme', name: 'acme', planLabel: 'Team' },
{ orgId: 'org_side', name: 'side-project', planLabel: 'Team' }
]}
/>
)
})
@@ -1,120 +0,0 @@
// A6 "Finish in your browser": while the one setup tab is open, the panel ticks each step in place
// (from the setup session, or rows derived from billing until it exists), says why it stopped (X1)
// and keeps the way back to the browser one click away. The prompt stays editable above it.
import React from 'react'
import { Copy, ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useTranslate } from '@/i18n/use-translate'
import { OrcaCloudSetupPanel } from '@/components/new-workspace/run-on/OrcaCloudSetupPanel'
import type {
CloudSetupStep,
CloudSetupStepId
} from '@/components/new-workspace/run-on/cloud-start-model'
import { reportCloudSetupProgress } from '@/lib/orca-cloud/cloud-setup-telemetry'
import { CloudSetupErrorNotice } from './CloudSetupErrorNotice'
import type { CloudSetupError, CloudSetupErrorAction } from './cloud-setup-errors'
export type CloudSetupBrowserTrackerProps = {
steps: readonly CloudSetupStep[]
/** Why setup stopped (X1), from the session's `error`; null while it's going. */
error: CloudSetupError | null
/** Members get "Ask an owner or admin…" instead of Choose plan on trial_unavailable. */
canManageBilling?: boolean
/** Setup session id, for telemetry only (never sent). Omit for derived rows. */
sessionId?: string | null
/** Ends the trip; finished steps stay done. */
onCancel: () => void
/** For a default browser that's the wrong one; hidden when there's no session link to copy. */
onCopyLink?: () => void
onReopen: () => void
onErrorAction: (action: CloudSetupErrorAction) => void
/** Per-row actions passed through to the panel (e.g. the Agent row's "Set up once"). */
rowActions?: Partial<Record<CloudSetupStepId, React.ReactNode>>
/** Trial → plan conversion context for telemetry. */
afterTrial?: boolean
className?: string
}
/**
* Rows derived from billing (before the setup session reports its own) never say 'active'. While
* the browser is open, the first unfinished required row is the one it's working on, so it reads
* "In browser" rather than "Needed". Session rows that already mark a step active pass through.
*/
export function trackerSteps(
steps: readonly CloudSetupStep[],
stopped: boolean
): readonly CloudSetupStep[] {
if (stopped || steps.some((step) => step.state === 'active')) {
return steps
}
const index = steps.findIndex((step) => step.state === 'needed')
if (index === -1) {
return steps
}
return steps.map((step, i) => (i === index ? { ...step, state: 'active' } : step))
}
export function CloudSetupBrowserTracker({
steps,
error,
canManageBilling = true,
sessionId = null,
onCancel,
onCopyLink,
onReopen,
onErrorAction,
rowActions,
afterTrial = false,
className
}: CloudSetupBrowserTrackerProps): React.JSX.Element {
const t = useTranslate()
const errorKind = error?.kind ?? null
const rows = React.useMemo(() => trackerSteps(steps, error !== null), [steps, error])
React.useEffect(() => {
if (sessionId) {
reportCloudSetupProgress(sessionId, rows, errorKind, { afterTrial })
}
}, [sessionId, rows, errorKind, afterTrial])
// The error's own button already offers the browser for these; don't show it twice.
const reopenInError = error !== null && (error.kind === 'timed_out' || error.kind === 'bank_step')
return (
<OrcaCloudSetupPanel
steps={rows}
title={t('components.newWorkspace.cloudSetup.trackerTitle', 'Finish in your browser')}
subtitle={t(
'components.newWorkspace.cloudSetup.trackerSubtitle',
'Orca picks up each step the moment it’s done, then starts this workspace.'
)}
rowActions={rowActions}
className={className}
>
{error ? (
<CloudSetupErrorNotice
error={error}
canManageBilling={canManageBilling}
onAction={onErrorAction}
className="mt-2.5"
/>
) : null}
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<Button type="button" variant="quiet" size="xs" onClick={onCancel}>
{t('components.newWorkspace.cloudSetup.cancelSetup', 'Cancel setup')}
</Button>
<span className="ms-auto flex flex-wrap items-center gap-2">
{onCopyLink ? (
<Button type="button" variant="outline" size="xs" onClick={onCopyLink}>
<Copy aria-hidden />
{t('components.newWorkspace.cloudSetup.copySetupLink', 'Copy setup link')}
</Button>
) : null}
{reopenInError ? null : (
<Button type="button" variant="outline" size="xs" onClick={onReopen}>
{t('components.newWorkspace.cloudSetup.reopenBrowser', 'Reopen browser')}
<ExternalLink aria-hidden />
</Button>
)}
</span>
</div>
</OrcaCloudSetupPanel>
)
}
@@ -21,7 +21,7 @@ function linkUnavailable(): void {
toast.error(
translate(
'components.newWorkspace.cloudSetup.linkUnavailable',
'Couldn’t get a setup link. Try Set up and start again.'
'Couldn’t get a setup link. Try Continue in browser again.'
)
)
}
@@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest'
import type { CloudSetupStep } from '@/components/new-workspace/run-on/cloud-start-model'
import { trackerSteps } from './CloudSetupBrowserTracker'
import { applySwitchTeamStep } from './CloudSetupSwitchTeam'
const derived: CloudSetupStep[] = [
@@ -9,25 +8,6 @@ const derived: CloudSetupStep[] = [
{ id: 'agent', state: 'optional' }
]
describe('trackerSteps', () => {
it('marks the first open row as the browser’s for rows derived from billing', () => {
expect(trackerSteps(derived, false).map((step) => step.state)).toEqual([
'done',
'active',
'optional'
])
})
it('passes session rows that already say active, and stopped trips, through', () => {
const session: CloudSetupStep[] = [
{ id: 'account', state: 'active' },
{ id: 'team', state: 'needed' }
]
expect(trackerSteps(session, false)).toBe(session)
expect(trackerSteps(derived, true)).toBe(derived)
})
})
describe('applySwitchTeamStep', () => {
const team: CloudSetupStep = { id: 'team', state: 'needed', detail: 'acme has a Team plan.' }
@@ -1,117 +1,11 @@
// The composer's side of the setup tracker (A6/X1) and the B2 switch row: what the tracker shows
// while the browser trip is open, and the setup rows with B2's Team row swapped in.
import { useCallback, useMemo, type ReactNode } from 'react'
import { readCloudStartDraft, saveCloudStartDraft } from '@/lib/orca-cloud/cloud-start-draft'
import type { CloudSetupSessionSnapshot } from '@/lib/orca-cloud/cloud-setup-session-seam'
import type {
CloudSetupStep,
CloudSetupStepId
} from '@/components/new-workspace/run-on/cloud-start-model'
import { setupActionFor } from '@/components/new-workspace/run-on/cloud-start-actions'
import type { OrcaCloudRunTargetAccess } from '../orca-cloud-run-target-access'
import type { OrcaCloudAvailability } from '../../../../../shared/orca-yaml-hook-types'
import type { CloudSetupBrowserTrackerProps } from './CloudSetupBrowserTracker'
// B2 for Orca Cloud setup: the setup rows with the "Switch to <team>" Team row swapped in.
import { useMemo } from 'react'
import type { CloudSetupStep } from '@/components/new-workspace/run-on/cloud-start-model'
import {
applySwitchTeamStep,
useCloudSetupSwitchTeam,
type CloudSetupSwitchTeam
} from './CloudSetupSwitchTeam'
import { parseCloudSetupError } from './cloud-setup-errors'
import {
canCopyCloudSetupLink,
copyCloudSetupLink,
reopenCloudSetup
} from './cloud-setup-tracker-actions'
export type CloudSetupTrackerView = Omit<
CloudSetupBrowserTrackerProps,
'steps' | 'rowActions' | 'className'
>
/** What the tracker and B2 add to the composer's CloudStartView. */
export type CloudSetupViewExtras = {
/** Per-row actions in the setup panel (B2's "Switch to <team>"). */
rowActions?: Partial<Record<CloudSetupStepId, ReactNode>>
/** While the setup trip is open in the browser: the tracker replaces the panel (A6, X1). */
tracker?: CloudSetupTrackerView
/** Start is held (aria-disabled) with this reason, e.g. "Switch to acme first" (B2). */
primaryHeldReason?: string
}
/** "Cancel setup": the draft keeps its prompt but forgets the trip (finished steps stay done). */
export function clearCloudSetupSessionFromDraft(profileId: string | null): void {
const draft = readCloudStartDraft(profileId)
if (draft) {
saveCloudStartDraft(profileId, { ...draft, setupSessionId: null })
}
}
/**
* The tracker's props while a trip from this composer is still waiting in the browser, or stopped
* on an error; undefined otherwise. Rows derived from billing (no session yet) work too: Reopen then
* re-runs today's setup action and there is no link to copy.
*/
export function useComposerSetupTracker({
cloudSelected,
setupStarted,
allSet,
readyToStart,
session,
sessionId,
steps,
access,
availability,
profileId,
runSetup,
onCancel
}: {
cloudSelected: boolean
setupStarted: boolean
allSet: boolean
readyToStart: boolean
session: CloudSetupSessionSnapshot | null
sessionId: string | null
steps: readonly CloudSetupStep[]
access: OrcaCloudRunTargetAccess
availability: OrcaCloudAvailability
profileId: string | null
/** A fresh setup trip for whatever is still open. */
runSetup: () => void
/** Ends the trip in the composer; the draft forgets the session here. */
onCancel: () => unknown
}): CloudSetupTrackerView | undefined {
const error = useMemo(() => {
const parsed = parseCloudSetupError(session?.error)
return parsed && session?.teamName ? { ...parsed, teamName: session.teamName } : parsed
}, [session?.error, session?.teamName])
const open = cloudSelected && setupStarted && !allSet && (!readyToStart || error !== null)
const rerun = useCallback(
() => setupActionFor(steps, access, availability)?.(),
[access, availability, steps]
)
const canManageBilling = access.state === 'no-plan' ? access.canManageBilling : true
return useMemo(() => {
if (!open) {
return undefined
}
const reopen = (): void => void reopenCloudSetup(sessionId, rerun)
return {
error,
canManageBilling,
sessionId,
onCancel: () => {
onCancel()
clearCloudSetupSessionFromDraft(profileId)
},
...(sessionId && canCopyCloudSetupLink(sessionId)
? { onCopyLink: () => void copyCloudSetupLink(sessionId) }
: {}),
onReopen: reopen,
// A canceled Checkout, a used trial or a failed team: a fresh trip covers what's still open.
onErrorAction: (action) => (action === 'reopen-browser' ? reopen() : runSetup())
}
}, [canManageBilling, error, onCancel, open, profileId, rerun, runSetup, sessionId])
}
/** B2 applied to the setup rows: the rows to show, and the switch when it holds Start. */
export function useSwitchTeamSteps(
@@ -126,16 +20,3 @@ export function useSwitchTeamSteps(
)
return { steps: switched ?? baseSteps, held: switched ? switchTeam : null }
}
/** The view fields the tracker and B2 add. */
export function setupViewExtras(
switchTeam: CloudSetupSwitchTeam | null,
tracker: CloudSetupTrackerView | undefined
): CloudSetupViewExtras {
return {
...(switchTeam
? { rowActions: { team: switchTeam.action }, primaryHeldReason: switchTeam.startHeldReason }
: {}),
...(tracker ? { tracker } : {})
}
}
@@ -38,4 +38,6 @@ export type ComposerModalData = {
telemetrySource?: WorkspaceCreateTelemetrySource
contextualTourSource?: string
setupGuideTourRequestId?: string
/** Orca Cloud setup finished on its page: submit without showing the dialog (falls back to it). */
cloudAutoStart?: boolean
}
@@ -1,328 +0,0 @@
// "Start in Orca Cloud" composer frames from cloud-start-onboarding spec v2: E1, A1, A6, A7, T1,
// T2, E, X2, the panel's GitHub rows D1–D3 and D4's GitHub-only projects. Entry points and T4 are in CloudStartSurfaces.
import { defineFixture } from '../../../component-fixtures/component-fixture'
import type { OrcaCloudRunTargetAccess } from '../orca-cloud-run-target-access'
import type { CloudSetupStep } from './cloud-start-model'
import { ComposerPreview } from './cloud-start-preview-fixture'
const setup = (): void => {
window.api.agentApiKeys = undefined
window.api.cloudSecrets = undefined
// No agent-accounts bridge (as in builds without it): the Agent row reads "unavailable" instead
// of awaiting the fixture harness's catch-all mock, which resolves to undefined.
window.api.orcaCloudAgents = undefined
window.api.cloudWorkspaces = { ...window.api.cloudWorkspaces, available: async () => true }
}
const entitled: OrcaCloudRunTargetAccess = { state: 'entitled', remainingSeconds: 41 * 3600 }
const trial = (hours: number, canManageBilling = true): OrcaCloudRunTargetAccess => ({
state: 'entitled',
remainingSeconds: hours * 3600,
trial: true,
canManageBilling
})
const githubDone: CloudSetupStep = {
id: 'github',
state: 'done',
label: 'GitHub access',
status: 'Done',
detail: 'acme/web'
}
const githubNeeded: CloudSetupStep = {
id: 'github',
state: 'needed',
need: 'github',
canFix: true,
label: 'GitHub access',
status: 'Needed',
detail: 'Orca Cloud can’t reach acme/billing yet.',
note: 'It clones your code from GitHub. Give access to this repository once.'
}
/** E1: Run on as segments, this computer picked; the machine moves to the hint line. */
export const e1RunOnDefault = defineFixture({
width: 560,
setup,
render: () => <ComposerPreview availability="offered" access={entitled} cloudSelected={false} />
})
/** E1 with "⋯" open: today's host picker without the Orca Cloud row. */
export const e1MorePlaces = defineFixture({
...e1RunOnDefault,
interact: (frame) => {
frame.querySelector<HTMLButtonElement>('[aria-label="More places to run"]')?.click()
}
})
/** A1: Orca Cloud picked while signed out; trial included, one setup trip. */
export const a1SignedOut = defineFixture({
width: 560,
setup,
render: () => (
<ComposerPreview
availability="needs-sign-in"
access={{ state: 'signed-out' }}
cloudSelected
github={{
...githubNeeded,
detail: 'acme/web is private. Orca Cloud needs access to clone it.',
note: undefined
}}
/>
)
})
/** B1: signed in, the team can still get its trial. */
export const b1TrialAvailable = defineFixture({
width: 560,
setup,
render: () => (
<ComposerPreview
availability="offered"
access={{ state: 'no-plan', lapsed: false, canManageBilling: true, trialOfferSeconds: 7200 }}
cloudSelected
github={githubDone}
/>
)
})
/** A6: Finish in your browser; each step ticks in place. */
export const a6FinishInBrowser = defineFixture({
width: 560,
setup,
render: () => (
<ComposerPreview
availability="offered"
access={entitled}
cloudSelected
steps={[
{ id: 'account', state: 'done', detail: 'neil@acme.dev' },
{ id: 'team', state: 'done', detail: 'acme · 2 sandbox hours to try Orca Cloud' },
{ id: 'github', state: 'active', status: 'In browser', detail: 'Waiting for GitHub…' },
{ id: 'agent', state: 'optional' }
]}
/>
)
})
/** A7: everything done; "All set", then the composer starts by itself. */
export const a7AllSet = defineFixture({
width: 560,
setup,
render: () => (
<ComposerPreview
availability="offered"
access={trial(2)}
cloudSelected
allSet
steps={[
{ id: 'account', state: 'done', detail: 'neil@acme.dev' },
{ id: 'plan', state: 'done', label: 'Trial', detail: 'acme · 2 sandbox hours left' },
{ ...githubDone }
]}
/>
)
})
/** T1: trial hours in the composer hint. */
export const t1TrialHint = defineFixture({
width: 560,
setup,
render: () => <ComposerPreview availability="offered" access={trial(1.4)} cloudSelected />
})
/** T1 under half an hour: warning, with Choose plan for owners. */
export const t1TrialLow = defineFixture({
width: 560,
setup,
render: () => <ComposerPreview availability="offered" access={trial(0.3)} cloudSelected />
})
/** T1 low, member: who can choose a plan. */
export const t1TrialLowMember = defineFixture({
width: 560,
setup,
render: () => <ComposerPreview availability="offered" access={trial(0.3, false)} cloudSelected />
})
/** T2: the trial is used up at New workspace; the prompt is kept. */
export const t2TrialUsedUp = defineFixture({
width: 560,
setup,
render: () => (
<ComposerPreview
availability="offered"
access={{ state: 'no-plan', lapsed: false, canManageBilling: true, trialUsedUp: true }}
cloudSelected
prompt="Upgrade Next.js to 16 and fix the type errors."
/>
)
})
/** C1 with the trial used up: a member can't buy, so ⌘↵ runs on this computer. */
export const t2TrialUsedUpMember = defineFixture({
width: 560,
setup,
render: () => (
<ComposerPreview
availability="offered"
access={{ state: 'no-plan', lapsed: false, canManageBilling: false, trialUsedUp: true }}
cloudSelected
prompt="Add rate limiting to the public API routes."
/>
)
})
/** D1: GitHub checked up front; "Give access and start". */
export const d1GitHubNeeded = defineFixture({
width: 560,
setup,
render: () => (
<ComposerPreview
availability="offered"
access={entitled}
cloudSelected
github={githubNeeded}
prompt="Write integration tests for the billing webhooks."
/>
)
})
/** D2: waiting for an owner of the GitHub org; ⌘↵ runs on this computer. */
export const d2GitHubRequested = defineFixture({
width: 560,
setup,
render: () => (
<ComposerPreview
availability="offered"
access={entitled}
cloudSelected
github={{
id: 'github',
state: 'pending',
need: 'github',
blocksStart: true,
label: 'GitHub access',
status: 'Requested',
detail: 'Waiting for an owner of acme on GitHub to approve Orca Cloud.',
note: 'GitHub sent them the request. This workspace starts once they approve, if Orca is open.'
}}
/>
)
})
/** D3: the project isn't on GitHub; outranks account and plan rows. */
export const d3NotOnGitHub = defineFixture({
width: 560,
setup,
render: () => (
<ComposerPreview
availability="offered"
access={entitled}
cloudSelected
github={{
id: 'github',
state: 'failed',
need: 'github',
blocksStart: true,
label: 'GitHub repository',
status: '',
detail: 'Orca Cloud starts from a GitHub repository.',
note: 'Push this project to GitHub, or pick a repository Orca Cloud can reach from Project.',
action: { label: 'How to push', onClick: () => {} }
}}
/>
)
})
const cloudRepos = [
{ fullName: 'acme/billing', url: 'https://github.com/acme/billing', private: true },
{ fullName: 'acme/infra', url: 'https://github.com/acme/infra', private: true },
{ fullName: 'acme/docs', url: 'https://github.com/acme/docs', private: false }
]
/** D4: Orca Cloud picked; the Project picker adds GitHub repositories with no clone here. */
export const d4GitHubProjects = defineFixture({
width: 560,
setup,
render: () => (
<ComposerPreview
availability="offered"
access={entitled}
cloudSelected
cloudRepos={cloudRepos}
/>
),
interact: async (frame) => {
frame.querySelector<HTMLButtonElement>('[aria-label="Browse projects"]')?.click()
await new Promise((resolve) => setTimeout(resolve, 200))
}
})
/** D4 picked: the repository stands in for the project; This computer says why it can't run it. */
export const d4GitHubProjectPicked = defineFixture({
width: 560,
setup,
render: () => (
<ComposerPreview
availability="offered"
access={entitled}
cloudSelected
cloudRepos={cloudRepos}
pickedCloudRepo="acme/billing"
prompt="Write integration tests for the billing webhooks."
/>
)
})
/** D3 for a folder: Orca Cloud stays selectable; the panel offers a GitHub repository instead. */
export const d3FolderProject = defineFixture({
width: 560,
setup,
render: () => (
<ComposerPreview
availability="offered"
access={entitled}
cloudSelected
folderProject
cloudRepos={cloudRepos}
github={{
id: 'github',
state: 'failed',
need: 'github',
blocksStart: true,
label: 'GitHub repository',
status: 'Needed',
detail:
'Orca Cloud starts from a GitHub repository. This project is a folder on this computer.'
}}
prompt="Summarize the notes in this folder."
/>
)
})
/** D3 for a folder, after "Choose a GitHub repository": the picker opens at the D4 section. */
export const d3FolderChooseRepository = defineFixture({
...d3FolderProject,
interact: async (frame) => {
const link = [...frame.querySelectorAll('button')].find((b) =>
b.textContent?.includes('Choose a GitHub repository')
)
link?.click()
await new Promise((resolve) => setTimeout(resolve, 300))
}
})
/** E: returning, entitled; remembered Orca Cloud, no panel, "Start in Orca Cloud". */
export const eReturning = defineFixture({
width: 560,
setup,
render: () => (
<ComposerPreview
availability="offered"
access={entitled}
cloudSelected
github={githubDone}
steps={[{ id: 'account', state: 'done' }, { id: 'plan', state: 'done' }, githubDone]}
prompt="Bump eslint to v10 and fix the new warnings."
/>
)
})
/** X2: Orca Cloud can't be reached; the segment stays, disabled, with the reason. */
export const x2Unreachable = defineFixture({
width: 560,
setup,
render: () => (
<ComposerPreview
availability="unreachable"
access={{ state: 'unknown' }}
cloudSelected={false}
/>
),
interact: async (frame) => {
frame
.querySelector('[role="radio"][aria-disabled="true"]')
?.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' }))
await new Promise((resolve) => setTimeout(resolve, 500))
}
})
@@ -1,33 +0,0 @@
// Cloud-start G inside the "Set up Orca Cloud" panel: the Agent row from the Orca account's setup.
import { defineFixture } from '../../../component-fixtures/component-fixture'
import { translate } from '@/i18n/i18n'
import type { OrcaCloudAgentSetupRowModel } from '@/lib/orca-cloud/orca-cloud-agent-setup-row'
import { OrcaCloudSetupPanel } from './OrcaCloudSetupPanel'
import { cloudStartAgentStep } from './cloud-start-agent-step'
import type { CloudSetupStep } from './cloud-start-model'
const BASE: CloudSetupStep[] = [
{ id: 'account', state: 'done', detail: 'neil@acme.dev' },
{ id: 'github', state: 'needed', need: 'github', canFix: true }
]
function panel(row: OrcaCloudAgentSetupRowModel) {
return defineFixture({
width: 560,
render: () => {
const agent = cloudStartAgentStep({ id: 'agent', state: 'optional' }, row, translate)
return (
<div className="bg-background p-4">
<OrcaCloudSetupPanel steps={agent ? [...BASE, agent] : BASE} />
</div>
)
}
})
}
export const gFromAccount = panel({ kind: 'covered', agent: 'claude', via: 'account' })
export const gTeamKey = panel({ kind: 'covered', agent: 'codex', via: 'team' })
export const gNotSetUp = panel({ kind: 'notSetUp', agent: 'claude' })
export const gKillSwitch = panel({ kind: 'killSwitch', agent: 'claude' })
export const gSignedOut = panel({ kind: 'signedOut', agent: 'claude' })
export const gLoading = panel({ kind: 'loading', agent: 'claude' })
@@ -1,37 +0,0 @@
// Orca Cloud picked on the segmented Run on, blocked by billing: the hint under Run on names the fix
// for an owner or admin, and says who can for a member (DS-15). These replace the pre-#273 dropdown
// fixtures NewWorkspaceComposerProjectSection:orcaCloudSelected* (DS-33).
import { defineFixture } from '../../../component-fixtures/component-fixture'
import type { OrcaCloudRunTargetAccess } from '../orca-cloud-run-target-access'
import { ComposerPreview } from './cloud-start-preview-fixture'
const setup = (): void => {
window.api.agentApiKeys = undefined
window.api.cloudSecrets = undefined
// No agent-accounts bridge: the Agent row reads "unavailable" instead of awaiting the harness's
// catch-all mock, which resolves to undefined (the page error DS-33 found).
window.api.orcaCloudAgents = undefined
window.api.cloudWorkspaces = { ...window.api.cloudWorkspaces, available: async () => true }
}
function blocked(access: OrcaCloudRunTargetAccess) {
return defineFixture({
width: 560,
setup,
render: () => <ComposerPreview availability="offered" access={access} cloudSelected />
})
}
export const noPlanOwner = blocked({ state: 'no-plan', lapsed: false, canManageBilling: true })
export const noPlanMember = blocked({ state: 'no-plan', lapsed: false, canManageBilling: false })
export const outOfHoursMember = blocked({
state: 'out-of-hours',
cause: 'hours',
canManageBilling: false
})
export const spendCapMember = blocked({
state: 'out-of-hours',
cause: 'cap',
canManageBilling: false
})
export const paymentIssueAdmin = blocked({ state: 'payment-issue', canManageBilling: true })
@@ -1,120 +0,0 @@
// "Start in Orca Cloud" at the real modal's width (sm:max-w-lg, ~448px of content) with its
// "Create more" switch, where np4-e2e saw the primary button overflow the dialog's right edge.
// Also the setup session's plan row (no raw "subscription") and the plan + GitHub subtitle.
import { defineFixture } from '../../../component-fixtures/component-fixture'
import type { OrcaCloudRunTargetAccess } from '../orca-cloud-run-target-access'
import type { CloudSetupStep } from './cloud-start-model'
import { ComposerPreview } from './cloud-start-preview-fixture'
const setup = (): void => {
window.api.agentApiKeys = undefined
window.api.cloudSecrets = undefined
window.api.orcaCloudAgents = undefined
window.api.cloudWorkspaces = { ...window.api.cloudWorkspaces, available: async () => true }
}
const CARD_WIDTH = 480
const noPlan: OrcaCloudRunTargetAccess = {
state: 'no-plan',
lapsed: false,
canManageBilling: true
}
const githubNeeded: CloudSetupStep = {
id: 'github',
state: 'needed',
need: 'github',
canFix: true,
label: 'GitHub access',
status: 'Needed',
detail: 'Orca Cloud can’t reach nwparker/orca-sandbox-push-test yet.',
note: 'It clones your code from GitHub. Give access to this repository once.'
}
/** Signed out at the modal's width: "Create more", "Use this computer instead", "Set up and start". */
export const modalSignedOut = defineFixture({
width: CARD_WIDTH + 40,
setup,
render: () => (
<ComposerPreview
availability="needs-sign-in"
access={{ state: 'signed-out' }}
cloudSelected
showCreateMultiple
width={CARD_WIDTH}
/>
)
})
/** Only GitHub is missing: the longer "Give access and start" at the modal's width. */
export const modalGiveAccess = defineFixture({
width: CARD_WIDTH + 40,
setup,
render: () => (
<ComposerPreview
availability="offered"
access={{ state: 'entitled', remainingSeconds: 41 * 3600 }}
cloudSelected
github={githubNeeded}
showCreateMultiple
width={CARD_WIDTH}
/>
)
})
/** No plan and no GitHub access: the subtitle names both parts of the one browser visit. */
export const modalPlanAndGitHub = defineFixture({
width: CARD_WIDTH + 40,
setup,
render: () => (
<ComposerPreview
availability="offered"
access={noPlan}
cloudSelected
github={githubNeeded}
showCreateMultiple
width={CARD_WIDTH}
/>
)
})
/** A setup session after Checkout: Team plan done with no raw "subscription" under it. */
export const sessionPlanDone = defineFixture({
width: CARD_WIDTH + 40,
setup,
render: () => (
<ComposerPreview
availability="offered"
access={noPlan}
cloudSelected
showCreateMultiple
width={CARD_WIDTH}
steps={[
{ id: 'account', state: 'done' },
{ id: 'team', state: 'done' },
{ id: 'plan', state: 'done', detail: 'subscription' },
{ id: 'github', state: 'active' },
{ id: 'agent', state: 'optional' }
]}
/>
)
})
/** A setup session mid-Checkout, with an owner's GitHub approval pending: codes read as words. */
export const sessionCheckoutAndRequested = defineFixture({
width: CARD_WIDTH + 40,
setup,
render: () => (
<ComposerPreview
availability="offered"
access={noPlan}
cloudSelected
width={CARD_WIDTH}
steps={[
{ id: 'account', state: 'done' },
{ id: 'team', state: 'done' },
{ id: 'plan', state: 'active', detail: 'checkout' },
{ id: 'github', state: 'pending', detail: 'requested' },
{ id: 'agent', state: 'optional' }
]}
/>
)
})
@@ -1,30 +0,0 @@
// The setup panel's way forward for a plain folder on Orca Cloud (design-squad: D3 for folders):
// choose a GitHub repository from the Project picker (D4), or add a GitHub project.
import React from 'react'
import { Button } from '@/components/ui/button'
import { useTranslate } from '@/i18n/use-translate'
export function FolderGitHubActions({
canChoose,
onChooseRepository,
onAddProject
}: {
/** Signed in with GitHub access: the Project picker lists repositories to choose from. */
canChoose: boolean
onChooseRepository: () => void
onAddProject: () => void
}): React.JSX.Element {
const t = useTranslate()
return (
<span className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
{canChoose ? (
<Button type="button" variant="outline" size="xs" onClick={onChooseRepository}>
{t('components.newWorkspace.cloudOnly.chooseRepository', 'Choose a GitHub repository')}
</Button>
) : null}
<Button type="button" variant="link" size="statusAction" onClick={onAddProject}>
{t('components.newWorkspace.cloudOnly.addGitHubProject', 'Add a GitHub project…')}
</Button>
</span>
)
}
@@ -1,351 +0,0 @@
// "Set up Orca Cloud": what still stands between this prompt and a running sandbox (spec A1/B1/C1).
// Shown when Orca Cloud is picked and anything required is missing; hidden once everything is done.
// The rows come from billing today and from the one-trip setup session once it exists.
import React from 'react'
import { Check, CircleDashed, CircleDot, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import { useTranslate, type Translate } from '@/i18n/use-translate'
import {
cloudSetupNeedsGitHub,
cloudSetupNeedsPlan,
type CloudSetupStep,
type CloudSetupStepId
} from './cloud-start-model'
type RowCopy = { title: string; detail: string | null; note?: string; status?: string }
function rowCopy(step: CloudSetupStep, t: Translate): RowCopy {
switch (step.id) {
case 'account':
return {
title: t('components.newWorkspace.cloudSetup.account', 'Orca account'),
detail:
step.state === 'done'
? null
: t(
'components.newWorkspace.cloudSetup.accountNeededTeam',
'Sign in or create one. We’ll make a team for you.'
)
}
case 'team':
return { title: t('components.newWorkspace.cloudSetup.team', 'Team'), detail: null }
case 'plan':
return step.need === 'trial' ? trialCopy(step, t) : planCopy(step, t)
case 'github':
return {
title: t('components.newWorkspace.cloudSetup.github', 'GitHub access'),
detail:
step.state === 'done'
? null
: t(
'components.newWorkspace.cloudSetup.githubNeeded',
'Orca Cloud clones your code from GitHub. Give access to this repository once.'
)
}
case 'agent':
// The composer names the agent in `detail`; it never asks for a per-sandbox sign-in (G).
return {
title: t('components.newWorkspace.cloudSetup.agent', 'Agent'),
detail: null,
status: t('components.newWorkspace.cloudSetup.stateOptional', 'Optional')
}
}
}
/** A1/B1: the one-time trial, counted from billing's offer, never a literal. */
function trialCopy(step: CloudSetupStep, t: Translate): RowCopy {
const hours = step.trialSeconds ? Math.round((step.trialSeconds / 3600) * 10) / 10 : null
return {
title: t('components.newWorkspace.cloudSetup.trial', 'Trial'),
detail:
hours === null
? null
: t(
'components.newWorkspace.cloudSetup.trialOffer',
'{{hours}} sandbox hours to try Orca Cloud',
{
count: hours,
hours: String(hours),
defaultValue_one: '{{hours}} sandbox hour to try Orca Cloud'
}
),
note: t(
'components.newWorkspace.cloudSetup.trialNoCard',
'No card needed. Nothing is charged until you choose a plan.'
),
status: t('components.newWorkspace.cloudSetup.stateIncluded', 'Included')
}
}
function planCopy(step: CloudSetupStep, t: Translate): RowCopy {
if (step.need === 'trial-used-up' && step.state === 'needed') {
// T2 / C1: neutral, nothing failed. Members read who can fix it.
return {
title: t('components.newWorkspace.cloudSetup.plan', 'Team plan'),
detail: t('components.newWorkspace.cloudSetup.trialUsedUp', 'Your trial is used up.'),
note:
step.canFix === false
? t(
'components.newWorkspace.cloudSetup.trialUsedUpMember',
'Only owners and admins can choose a plan.'
)
: t(
'components.newWorkspace.cloudSetup.trialUsedUpOwner',
'Choose a plan to keep using Orca Cloud. Your prompt is still here.'
)
}
}
return {
title: t('components.newWorkspace.cloudSetup.plan', 'Team plan'),
detail: planDetail(step, t)
}
}
function planDetail(step: CloudSetupStep, t: Translate): string | null {
if (step.state === 'pending') {
return t('components.newWorkspace.cloudSetup.planAfterSignIn', 'Checked after you sign in')
}
if (step.state !== 'needed') {
return null
}
const memberSuffix =
step.canFix === false
? ` ${t('components.newWorkspace.cloudSetup.askOwner', 'Ask an owner or admin to fix it.')}`
: ''
switch (step.need) {
case 'renew-plan':
return (
t('components.newWorkspace.cloudSetup.planEnded', 'Your Team plan ended.') + memberSuffix
)
// Members read who fixes it and how, per cause (glossary), not a generic "fix it".
case 'hours':
return step.canFix === false
? t(
'components.newWorkspace.cloudSetup.outOfHoursMember',
'Out of sandbox hours. Ask an owner or admin to add hours.'
)
: t('components.newWorkspace.cloudSetup.outOfHours', 'Out of sandbox hours.')
case 'spend-cap':
return step.canFix === false
? t(
'components.newWorkspace.cloudSetup.spendCapMember',
'Spend cap reached. Ask an owner or admin to raise the spend cap.'
)
: t('components.newWorkspace.cloudSetup.spendCap', 'Spend cap reached.')
case 'payment':
return step.canFix === false
? t(
'components.newWorkspace.cloudSetup.paymentFailedMember',
'Payment failed. Ask an owner or admin to update the payment method.'
)
: t('components.newWorkspace.cloudSetup.paymentFailed', 'Payment failed.')
case 'github':
return t(
'components.newWorkspace.cloudSetup.githubNeeded',
'Orca Cloud clones your code from GitHub. Give access to this repository once.'
)
case 'sign-in':
case 'plan':
case 'trial':
case 'trial-used-up':
case undefined:
return step.canFix === false
? t(
'components.newWorkspace.cloudSetup.planMember',
'Orca Cloud needs a Team plan. Only owners and admins can choose one.'
)
: t('components.newWorkspace.cloudSetup.planNeeded', 'Orca Cloud needs a Team plan.')
}
}
/**
* A setup session's rows name how a step is covered with a code ('subscription', 'trial',
* 'checkout', 'requested'); say it in words, or nothing. Undefined: not a session code.
*/
export function sessionDetailCopy(step: CloudSetupStep, t: Translate): string | null | undefined {
if (step.id === 'plan' && step.detail === 'subscription') {
// "Team plan · Done" already says it.
return null
}
if (step.id === 'plan' && step.detail === 'trial') {
return t('components.newWorkspace.cloudSetup.planCoveredByTrial', 'Your trial covers it.')
}
if (step.id === 'plan' && step.detail === 'checkout') {
return t(
'components.newWorkspace.cloudSetup.planCheckoutOpen',
'Finish checkout in your browser.'
)
}
if (step.id === 'github' && step.detail === 'requested') {
return t(
'components.newWorkspace.cloudSetup.githubRequestedSession',
'Waiting for an owner on GitHub to approve Orca Cloud.'
)
}
return undefined
}
function stateLabel(step: CloudSetupStep, t: Translate): string {
switch (step.state) {
case 'done':
return t('components.newWorkspace.cloudSetup.stateDone', 'Done')
case 'optional':
return t('components.newWorkspace.cloudSetup.stateOptional', 'Optional')
case 'pending':
return t('components.newWorkspace.cloudSetup.stateNext', 'Next')
case 'active':
return t('components.newWorkspace.cloudSetup.stateInBrowser', 'In browser')
case 'failed':
return t('components.newWorkspace.cloudSetup.stateFailed', "Didn't finish")
case 'loading':
return t('components.newWorkspace.cloudSetup.stateChecking', 'Checking')
case 'needed':
return t('components.newWorkspace.cloudSetup.stateNeeded', 'Needed')
}
}
function StateIcon({ step }: { step: CloudSetupStep }): React.JSX.Element {
// Neutral checks: done and an included trial are facts, not alerts.
if (step.state === 'done' || step.need === 'trial') {
return <Check className="size-3.5 text-muted-foreground" aria-hidden />
}
if (step.state === 'active') {
// The browser is working on this step; static under reduced motion, the label carries the state.
return (
<Loader2 className="size-3.5 text-muted-foreground motion-safe:animate-spin" aria-hidden />
)
}
if (step.state === 'needed' || step.state === 'failed') {
return <CircleDot className="size-3.5 text-status-warning-text" aria-hidden />
}
return <CircleDashed className="size-3.5 text-muted-foreground" aria-hidden />
}
export function OrcaCloudSetupPanel({
steps,
allSet = false,
title,
subtitle,
children,
rowActions,
className
}: {
steps: readonly CloudSetupStep[]
/** Setup just finished: "All set", then the composer starts the workspace by itself. */
allSet?: boolean
/** Overrides for variants such as the "Finish in your browser" tracker. */
title?: string
subtitle?: string
/** Rendered under the rows (tracker errors and actions). */
children?: React.ReactNode
/** Per-row slot under a row's detail, e.g. the member "Ask for a Team plan" action on 'plan'. */
rowActions?: Partial<Record<CloudSetupStepId, React.ReactNode>>
className?: string
}): React.JSX.Element {
const t = useTranslate()
const titleId = React.useId()
return (
<section
role="region"
aria-labelledby={titleId}
data-orca-cloud-setup-panel="true"
className={cn('rounded-md border border-border bg-muted/30 px-3 py-2.5', className)}
>
<h3 id={titleId} className="text-xs font-medium text-foreground">
{allSet
? t('components.newWorkspace.cloudSetup.allSet', 'All set')
: (title ?? t('components.newWorkspace.cloudSetup.title', 'Set up Orca Cloud'))}
</h3>
<p className="mt-0.5 text-[11px] text-muted-foreground">
{allSet
? t('components.newWorkspace.cloudSetup.starting', 'Starting your cloud workspace…')
: (subtitle ??
// No browser trip fixes a project without a GitHub repository (D3): say what holds.
(steps.some((step) => step.blocksStart && step.state === 'failed')
? t(
'components.newWorkspace.cloudSetup.subtitleNeedsRepository',
'Orca Cloud needs a GitHub repository. Your prompt stays here.'
)
: // A member can't fix billing, and no browser trip will.
steps.some((step) => step.state === 'needed' && step.canFix === false)
? t(
'components.newWorkspace.cloudSetup.subtitleOwnerFixes',
'An owner or admin needs to fix this. Your prompt stays here.'
)
: cloudSetupNeedsPlan(steps) && cloudSetupNeedsGitHub(steps)
? t(
'components.newWorkspace.cloudSetup.subtitleChoosePlanAndGitHub',
'One visit to your browser to choose a plan and give Orca Cloud access on GitHub. Your prompt stays here and starts when you’re done.'
)
: cloudSetupNeedsPlan(steps)
? t(
'components.newWorkspace.cloudSetup.subtitleChoosePlan',
'One visit to your browser to choose a plan. Your prompt stays here and starts when you’re done.'
)
: t(
'components.newWorkspace.cloudSetup.subtitleNoCard',
'One visit to your browser, no card. Your prompt stays here and starts when you’re done.'
)))}
</p>
{/* One polite announcement per row change ("Team plan, done"). */}
<ul aria-live="polite" className="mt-2 space-y-1.5">
{steps.map((step) => {
const copy = rowCopy(step, t)
const title = step.label ?? copy.title
const state = step.status ?? copy.status ?? stateLabel(step, t)
const note = step.note ?? copy.note
const sessionDetail = sessionDetailCopy(step, t)
const detail = sessionDetail === undefined ? (step.detail ?? copy.detail) : sessionDetail
return (
<li
key={step.id}
data-setup-step={step.id}
data-setup-state={step.state}
className="flex items-start gap-2"
>
<span className="mt-0.5 flex size-4 shrink-0 items-center justify-center">
<StateIcon step={step} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-xs text-foreground">{title}</span>
{step.state === 'loading' ? (
<Skeleton shape="small" className="mt-1 h-2.5 w-40" />
) : detail ? (
<span className="block text-[11px] text-muted-foreground">{detail}</span>
) : null}
{note ? (
<span className="block text-[11px] text-muted-foreground">{note}</span>
) : null}
{step.action ? (
// One shared in-row action ("Set up once", "How to push"): it must read as a button.
<Button
type="button"
variant="outline"
size="xs"
className="mt-1"
onClick={step.action.onClick}
>
{step.action.label}
</Button>
) : null}
{rowActions?.[step.id] ?? null}
</span>
<span
className={cn(
'shrink-0 text-[11px]',
step.state === 'needed' ? 'text-foreground' : 'text-muted-foreground'
)}
>
<span className="sr-only">{`${title}, `}</span>
{state}
</span>
</li>
)
})}
</ul>
{children}
</section>
)
}
@@ -1,39 +0,0 @@
// The hint under Run on for this computer or a "⋯" target: where it runs (spec E1). When Orca
// Cloud can't be reached, the line says so (X2) so the disabled segment never looks like a bug.
import React from 'react'
import { useTranslate } from '@/i18n/use-translate'
import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options'
import type { OrcaCloudAvailability } from '../../../../../shared/orca-yaml-hook-types'
import { getRecipeDetail, type EphemeralVmRecipeOption } from '../run-target-options'
export function RunOnHintLine({
hostOptions,
selectedHostId,
recipes,
selectedRecipeId,
orcaCloudAvailability
}: {
hostOptions: readonly ProjectHostSetupOption[]
selectedHostId: string | null
recipes: readonly EphemeralVmRecipeOption[]
selectedRecipeId: string | null
orcaCloudAvailability: OrcaCloudAvailability
}): React.JSX.Element | null {
const t = useTranslate()
const recipe = recipes.find((candidate) => candidate.id === selectedRecipeId) ?? null
const host = hostOptions.find((option) => option.id === selectedHostId) ?? null
const where = recipe ? getRecipeDetail(recipe, t) : host ? `${host.label} · ${host.detail}` : null
return (
<div className="space-y-0.5">
{where ? <p className="truncate text-[11px] text-muted-foreground">{where}</p> : null}
{orcaCloudAvailability === 'unreachable' ? (
<p role="status" className="text-[11px] text-muted-foreground">
{t(
'components.newWorkspace.runOn.cloudUnreachableReason',
"Orca Cloud: can't reach it right now. Check your connection."
)}
</p>
) : null}
</div>
)
}
@@ -1,212 +0,0 @@
// Run on as one click: "This computer · Orca Cloud · ⋯" (spec D1). ⋯ opens today's host picker
// without its Orca Cloud row, and a target picked there replaces ⋯ so the choice stays visible.
import React, { useMemo, useState } from 'react'
import { Cloud, Ellipsis, Monitor } from 'lucide-react'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { SettingsSegmentedControl } from '@/components/settings/form-controls/SettingsSegmentedControl'
import { useTranslate } from '@/i18n/use-translate'
import type { ReadyProjectHostSetupOption } from '@/lib/project-host-setup-options'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../../shared/execution-host'
import { ORCA_CLOUD_RECIPE_ID } from '../../../../../shared/hosted-sandbox/orca-cloud-recipe-id'
import type { OrcaCloudAvailability } from '../../../../../shared/orca-yaml-hook-types'
import RunTargetCombobox from '../RunTargetCombobox'
import { isOrcaCloudRecipe } from '../run-target-options'
import { getOrcaCloudSegmentState, getRunOnSegment, type RunOnSegment } from './cloud-start-model'
type RunTargetComboboxProps = React.ComponentProps<typeof RunTargetCombobox>
export type RunOnSegmentedControlProps = RunTargetComboboxProps & {
orcaCloudAvailability: OrcaCloudAvailability
/** Selects Orca Cloud even when the account still needs setup; the panel below says what's missing. */
onSelectOrcaCloud: () => void
/** Marks the control as touched so a remembered default never overrides a user pick. */
onUserPick?: () => void
/** D4: the picked project is on GitHub only, so this computer has nothing to run. */
cloudOnlyRepo?: boolean
}
export default function RunOnSegmentedControl(
props: RunOnSegmentedControlProps
): React.JSX.Element {
const {
hostOptions,
hostValue,
hostExecutionHostId,
onHostChange,
recipes,
recipeValue,
onRecipeChange,
orcaCloudAvailability,
onSelectOrcaCloud,
onUserPick,
cloudOnlyRepo = false
} = props
const t = useTranslate()
const [moreOpen, setMoreOpen] = useState(false)
const localOption = useMemo(
() =>
hostOptions.find(
(option): option is ReadyProjectHostSetupOption =>
option.kind === 'ready' && option.hostId === LOCAL_EXECUTION_HOST_ID
) ?? null,
[hostOptions]
)
const selectedRecipe = recipes.find((recipe) => recipe.id === recipeValue) ?? null
const orcaCloudSelected = recipeValue === ORCA_CLOUD_RECIPE_ID
const selectedHost =
hostOptions.find(
(option) =>
option.kind === 'ready' &&
option.id === hostValue &&
(!hostExecutionHostId || option.hostId === hostExecutionHostId)
) ?? null
const segment = getRunOnSegment({
orcaCloudSelected,
otherRecipeSelected: selectedRecipe !== null && !orcaCloudSelected,
selectedHostIsLocal: selectedHost ? selectedHost.hostId === LOCAL_EXECUTION_HOST_ID : null
})
const cloudState = getOrcaCloudSegmentState(orcaCloudAvailability)
const localReason = cloudOnlyRepo
? t(
'components.newWorkspace.cloudOnly.notOnThisComputer',
'Not on this computer · Orca Cloud only'
)
: t('components.newWorkspace.runOn.locationNotSet', 'Project location not set')
const pickLocal = (): void => {
if (!localOption || cloudOnlyRepo) {
return
}
onUserPick?.()
onRecipeChange?.(null)
onHostChange?.(localOption.id)
}
const pickCloud = (): void => {
if (cloudState !== 'enabled') {
return
}
onUserPick?.()
onSelectOrcaCloud()
}
const otherLabel =
segment === 'other'
? ((selectedRecipe && !isOrcaCloudRecipe(selectedRecipe)
? selectedRecipe.name
: selectedHost?.label) ?? null)
: null
const options = [
{
value: 'local' as const,
label: (
<span className="flex items-center gap-1.5">
<Monitor className="size-3.5" aria-hidden />
{t('components.newWorkspace.runOn.thisComputer', 'This computer')}
</span>
),
disabled: !localOption || cloudOnlyRepo,
// A disabled option says why in its accessible name too (spec E1).
ariaLabel:
localOption && !cloudOnlyRepo
? undefined
: t('components.newWorkspace.runOn.thisComputerUnusable', 'This computer, {{reason}}', {
reason: localReason
}),
tooltip: localOption && !cloudOnlyRepo ? localOption.label : localReason
},
...(cloudState === 'hidden'
? []
: [
{
value: 'orca-cloud' as const,
label: (
<span className="flex items-center gap-1.5">
<Cloud className="size-3.5" aria-hidden />
{t('components.newWorkspace.runOn.orcaCloud', 'Orca Cloud')}
</span>
),
disabled: cloudState === 'unreachable',
ariaLabel:
cloudState === 'unreachable'
? t(
'components.newWorkspace.runOn.cloudUnreachableName',
"Orca Cloud, can't reach it right now"
)
: undefined,
tooltip:
cloudState === 'unreachable'
? t(
'components.newWorkspace.runOn.cloudUnreachableReason',
"Orca Cloud: can't reach it right now. Check your connection."
)
: t('components.newWorkspace.runOn.cloudHosted', 'Hosted sandbox')
}
]),
{
value: 'other' as const,
ariaLabel: otherLabel
? t('components.newWorkspace.runOn.moreWithTarget', 'More places to run: {{name}}', {
name: otherLabel
})
: t('components.newWorkspace.runOn.more', 'More places to run'),
label: otherLabel ? (
<span className="block max-w-28 truncate">{otherLabel}</span>
) : (
<Ellipsis className="size-3.5" aria-hidden />
),
tooltip: t('components.newWorkspace.runOn.more', 'More places to run')
}
]
// The host picker keeps everything except Orca Cloud, which now has its own segment.
const otherRecipes = useMemo(
() => recipes.filter((recipe) => !isOrcaCloudRecipe(recipe)),
[recipes]
)
return (
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
<PopoverAnchor asChild>
<div className="w-fit max-w-full" data-run-on-segmented="true">
<SettingsSegmentedControl<RunOnSegment>
value={segment ?? 'local'}
activation="manual"
size="sm"
ariaLabel={t('auto.components.NewWorkspaceComposerCard.runOn', 'Run on')}
options={options}
onChange={(value) => {
if (value === 'local') {
pickLocal()
} else if (value === 'orca-cloud') {
pickCloud()
} else {
setMoreOpen(true)
}
}}
/>
</div>
</PopoverAnchor>
<PopoverContent align="end" className="w-80" data-run-on-more="true">
<RunTargetCombobox
{...props}
recipes={otherRecipes}
recipeValue={selectedRecipe && !orcaCloudSelected ? recipeValue : null}
orcaCloudAvailability="unavailable"
onHostChange={(setupId) => {
onUserPick?.()
onRecipeChange?.(null)
onHostChange?.(setupId)
setMoreOpen(false)
}}
onRecipeChange={(recipeId) => {
onUserPick?.()
onRecipeChange?.(recipeId)
setMoreOpen(false)
}}
/>
</PopoverContent>
</Popover>
)
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { translate } from '@/i18n/i18n'
import { sessionDetailCopy } from './OrcaCloudSetupPanel'
import { sessionDetailCopy } from './cloud-setup-session-copy'
describe('sessionDetailCopy', () => {
// np4-e2e: the plan row read the session's raw "subscription".
@@ -0,0 +1,30 @@
// Setup-session codes in words, for the Orca Cloud setup page.
import type { Translate } from '@/i18n/use-translate'
import type { CloudSetupStep } from './cloud-start-model'
/**
* A setup session's rows name how a step is covered with a code ('subscription', 'trial',
* 'checkout', 'requested'); say it in words, or nothing. Undefined: not a session code.
*/
export function sessionDetailCopy(step: CloudSetupStep, t: Translate): string | null | undefined {
if (step.id === 'plan' && step.detail === 'subscription') {
// "Team plan · Done" already says it.
return null
}
if (step.id === 'plan' && step.detail === 'trial') {
return t('components.newWorkspace.cloudSetup.planCoveredByTrial', 'Your trial covers it.')
}
if (step.id === 'plan' && step.detail === 'checkout') {
return t(
'components.newWorkspace.cloudSetup.planCheckoutOpen',
'Finish checkout in your browser.'
)
}
if (step.id === 'github' && step.detail === 'requested') {
return t(
'components.newWorkspace.cloudSetup.githubRequestedSession',
'Waiting for an owner on GitHub to approve Orca Cloud.'
)
}
return undefined
}
@@ -1,39 +1,8 @@
// What the "Start in Orca Cloud" primary button says, and the fix one click runs before a session
// exists (sign in first, else the plan/billing action).
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
// The fix one click runs before a setup session exists (sign in first, else the plan/billing
// action); the setup page's fallback when the session can't open.
import type { OrcaCloudAvailability } from '../../../../../shared/orca-yaml-hook-types'
import { getOrcaCloudPick, type OrcaCloudRunTargetAccess } from '../orca-cloud-run-target-access'
import type { CloudPrimaryAction, CloudSetupStep } from './cloud-start-model'
export function getCloudPrimaryLabel(action: CloudPrimaryAction): string {
switch (action) {
case 'start':
case 'startTrial':
return translate('components.newWorkspace.cloudStart.start', 'Start in Orca Cloud')
case 'setUpAndStart':
return translate('components.newWorkspace.cloudStart.setUpAndStart', 'Set up and start')
case 'choosePlanAndStart':
return translate(
'components.newWorkspace.cloudStart.choosePlanAndStart',
'Choose plan and start'
)
case 'renewAndStart':
return translate('components.newWorkspace.cloudStart.renewAndStart', 'Renew and start')
case 'giveAccessAndStart':
return translate(
'components.newWorkspace.cloudStart.giveAccessAndStart',
'Give access and start'
)
case 'useThisComputer':
return translate(
'components.newWorkspace.cloudStart.useThisComputer',
'Use this computer instead'
)
case 'checking':
return translate('components.newWorkspace.cloudStart.checking', 'Checking Orca Cloud…')
}
}
import type { CloudSetupStep } from './cloud-start-model'
/** The fix one click on the primary button runs: sign in first, else the plan/billing action. */
export function setupActionFor(
@@ -52,17 +21,3 @@ export function setupActionFor(
const pick = getOrcaCloudPick(effective)
return pick.kind === 'act' ? pick.run : null
}
/** D2: an owner approved on GitHub while this composer waits; offer the start (never auto). */
export function showGitHubGrantedToast(repoName: string, onStart: () => void): void {
toast.message(
translate(
'components.newWorkspace.cloudStart.githubGranted',
'GitHub access granted for {{repo}}',
{
repo: repoName
}
),
{ action: { label: getCloudPrimaryLabel('start'), onClick: onStart } }
)
}
@@ -1,54 +1,18 @@
import { describe, expect, it } from 'vitest'
import {
cloudSetupNeedsGitHub,
deriveCloudSetupSteps,
getCloudPrimaryAction,
getOrcaCloudSegmentState,
getRunOnSegment,
isCloudSetupPanelVisible,
resolveDefaultRunOn,
type CloudSetupStep,
withReachableGitHub
} from './cloud-start-model'
import {
cloudSetupPageModel,
cloudSetupRequired,
sessionNeedsFrom
} from '../../orca-cloud-setup/cloud-setup-page-model'
describe('Run on segment', () => {
it('maps the committed target to a segment', () => {
expect(
getRunOnSegment({
orcaCloudSelected: true,
otherRecipeSelected: false,
selectedHostIsLocal: true
})
).toBe('orca-cloud')
expect(
getRunOnSegment({
orcaCloudSelected: false,
otherRecipeSelected: true,
selectedHostIsLocal: true
})
).toBe('other')
expect(
getRunOnSegment({
orcaCloudSelected: false,
otherRecipeSelected: false,
selectedHostIsLocal: false
})
).toBe('other')
expect(
getRunOnSegment({
orcaCloudSelected: false,
otherRecipeSelected: false,
selectedHostIsLocal: true
})
).toBe('local')
expect(
getRunOnSegment({
orcaCloudSelected: false,
otherRecipeSelected: false,
selectedHostIsLocal: null
})
).toBeNull()
})
describe('Orca Cloud availability', () => {
it('keeps Orca Cloud visible but disabled when it cannot be reached (X2)', () => {
expect(getOrcaCloudSegmentState('unreachable')).toBe('unreachable')
expect(getOrcaCloudSegmentState('unavailable')).toBe('hidden')
@@ -58,17 +22,29 @@ describe('Run on segment', () => {
})
})
describe('setup rows and the primary label', () => {
describe('setup rows and the setup page', () => {
const page = (steps: Parameters<typeof cloudSetupPageModel>[0]) =>
cloudSetupPageModel(steps, { agentSkipped: true }).action
it('starts directly when the account is entitled', () => {
const steps = deriveCloudSetupSteps({
availability: 'offered',
access: { state: 'entitled', remainingSeconds: 3600 }
})
expect(isCloudSetupPanelVisible(steps)).toBe(false)
expect(getCloudPrimaryAction(steps)).toBe('start')
expect(cloudSetupRequired(steps, { agentSkipped: true })).toBe(false)
expect(page(steps)).toBe('done')
})
it('signed out: one setup trip ("Set up and start"), plan checked after sign-in', () => {
it('an agent with no account opens the page until it is skipped once', () => {
const steps = deriveCloudSetupSteps({
availability: 'offered',
access: { state: 'entitled', remainingSeconds: 3600 }
})
expect(cloudSetupRequired(steps)).toBe(true)
expect(cloudSetupPageModel(steps).action).toBe('agent')
})
it('signed out: sign-in first, plan checked after sign-in', () => {
const steps = deriveCloudSetupSteps({
availability: 'needs-sign-in',
access: { state: 'loading' }
@@ -78,49 +54,57 @@ describe('setup rows and the primary label', () => {
['plan', 'pending'],
['agent', 'optional']
])
expect(isCloudSetupPanelVisible(steps)).toBe(true)
expect(getCloudPrimaryAction(steps)).toBe('setUpAndStart')
expect(page(steps)).toBe('sign-in')
})
it('owner without a plan: "Choose plan and start"; lapsed: "Renew and start"', () => {
it('owner without a plan chooses one in the browser; lapsed renews there', () => {
const noPlan = deriveCloudSetupSteps({
availability: 'needs-capability',
access: { state: 'no-plan', lapsed: false, canManageBilling: true }
})
expect(getCloudPrimaryAction(noPlan)).toBe('choosePlanAndStart')
expect(page(noPlan)).toBe('choose-plan')
const lapsed = deriveCloudSetupSteps({
availability: 'needs-capability',
availability: 'offered',
access: { state: 'no-plan', lapsed: true, canManageBilling: true }
})
expect(getCloudPrimaryAction(lapsed)).toBe('renewAndStart')
expect(page(lapsed)).toBe('choose-plan')
expect(lapsed.find((s) => s.id === 'plan')?.need).toBe('renew-plan')
})
it('a member who cannot fix it gets "Use this computer instead" and no agent row', () => {
it('a member who cannot fix billing is told to ask an owner, with no agent row', () => {
const steps = deriveCloudSetupSteps({
availability: 'needs-capability',
access: { state: 'no-plan', lapsed: false, canManageBilling: false }
})
expect(steps.some((s) => s.id === 'agent')).toBe(false)
expect(getCloudPrimaryAction(steps)).toBe('useThisComputer')
expect(page(steps)).toBe('ask-owner')
const outOfHours = deriveCloudSetupSteps({
availability: 'offered',
access: { state: 'out-of-hours', cause: 'hours', canManageBilling: false }
})
expect(getCloudPrimaryAction(outOfHours)).toBe('useThisComputer')
expect(page(outOfHours)).toBe('ask-owner')
})
it('a missing GitHub grant alone reads "Give access and start"', () => {
it('an owner out of hours fixes it in billing', () => {
const steps = deriveCloudSetupSteps({
availability: 'offered',
access: { state: 'out-of-hours', cause: 'hours', canManageBilling: true }
})
expect(page(steps)).toBe('fix-billing')
})
it('a missing GitHub grant alone is the GitHub step', () => {
expect(
getCloudPrimaryAction([
page([
{ id: 'account', state: 'done' },
{ id: 'plan', state: 'done' },
{ id: 'github', state: 'needed', need: 'github', canFix: true },
{ id: 'agent', state: 'optional' }
])
).toBe('giveAccessAndStart')
).toBe('github-install')
})
it('a team that can still get its trial just starts (B1); setup grants it with no card', () => {
it('a team that can still get its trial claims it in the app (B1)', () => {
const steps = deriveCloudSetupSteps({
availability: 'offered',
access: { state: 'no-plan', lapsed: false, canManageBilling: false, trialOfferSeconds: 7200 }
@@ -129,23 +113,21 @@ describe('setup rows and the primary label', () => {
expect(plan).toMatchObject({ need: 'trial', trialSeconds: 7200, canFix: true })
// Members aren't blocked from the trial: the agent row stays.
expect(steps.some((s) => s.id === 'agent')).toBe(true)
expect(getCloudPrimaryAction(steps)).toBe('startTrial')
// An included trial isn't a task: no panel, the hint line carries it.
expect(isCloudSetupPanelVisible(steps)).toBe(false)
expect(page(steps)).toBe('start-trial')
})
it('trial used up: owner "Choose plan and start"; member runs on this computer (T2, C1)', () => {
it('trial used up: owner chooses a plan; member asks an owner (T2, C1)', () => {
const owner = deriveCloudSetupSteps({
availability: 'offered',
access: { state: 'no-plan', lapsed: false, canManageBilling: true, trialUsedUp: true }
})
expect(owner.find((s) => s.id === 'plan')?.need).toBe('trial-used-up')
expect(getCloudPrimaryAction(owner)).toBe('choosePlanAndStart')
expect(page(owner)).toBe('choose-plan')
const member = deriveCloudSetupSteps({
availability: 'offered',
access: { state: 'no-plan', lapsed: false, canManageBilling: false, trialUsedUp: true }
})
expect(getCloudPrimaryAction(member)).toBe('useThisComputer')
expect(page(member)).toBe('ask-owner')
})
it('signed out: the trial row is included and GitHub is checked up front (A1)', () => {
@@ -156,17 +138,18 @@ describe('setup rows and the primary label', () => {
})
expect(steps.map((s) => s.id)).toEqual(['account', 'plan', 'github', 'agent'])
expect(steps.find((s) => s.id === 'plan')?.need).toBe('trial')
expect(getCloudPrimaryAction(steps)).toBe('setUpAndStart')
expect(sessionNeedsFrom(steps)).toEqual(['account', 'plan', 'github'])
expect(page(steps)).toBe('sign-in')
})
it('GitHub waiting on an owner blocks the start, so ⌘↵ runs locally (D2)', () => {
it('GitHub waiting on an owner: the page waits and starts on its own (D2)', () => {
const steps = deriveCloudSetupSteps({
availability: 'offered',
access: { state: 'entitled', remainingSeconds: 3600 },
github: { id: 'github', state: 'pending', need: 'github', blocksStart: true }
})
expect(isCloudSetupPanelVisible(steps)).toBe(true)
expect(getCloudPrimaryAction(steps)).toBe('useThisComputer')
expect(cloudSetupRequired(steps, { agentSkipped: true })).toBe(true)
expect(page(steps)).toBe('github-waiting')
})
it('no GitHub remote outranks account and plan rows (D3)', () => {
@@ -176,21 +159,37 @@ describe('setup rows and the primary label', () => {
github: { id: 'github', state: 'failed', need: 'github', blocksStart: true }
})
expect(steps.map((s) => s.id)).toEqual(['github'])
expect(getCloudPrimaryAction(steps)).toBe('useThisComputer')
expect(page(steps)).toBe('needs-repository')
})
it('a GitHub check still running holds the start (D1 cache miss)', () => {
const steps = deriveCloudSetupSteps({
it('checks while GitHub or billing are still being read', () => {
const github = deriveCloudSetupSteps({
availability: 'offered',
access: { state: 'entitled', remainingSeconds: 3600 },
github: { id: 'github', state: 'loading' }
})
expect(getCloudPrimaryAction(steps)).toBe('checking')
expect(page(github)).toBe('checking')
const billing = deriveCloudSetupSteps({ availability: 'offered', access: { state: 'loading' } })
expect(page(billing)).toBe('checking')
})
it('waits while billing is still being read', () => {
const steps = deriveCloudSetupSteps({ availability: 'offered', access: { state: 'loading' } })
expect(getCloudPrimaryAction(steps)).toBe('checking')
it('B2: a team switch is its own step and always opens the page', () => {
const steps: CloudSetupStep[] = [
{ id: 'account', state: 'done' },
{ id: 'team', state: 'needed' }
]
expect(cloudSetupRequired(steps, { teamSwitch: true })).toBe(true)
expect(cloudSetupPageModel(steps, { teamSwitch: true }).action).toBe('switch-team')
})
it('a still-loading agent row never holds a ready start', () => {
const steps: CloudSetupStep[] = [
{ id: 'account', state: 'done' },
{ id: 'plan', state: 'done' },
{ id: 'agent', state: 'loading' }
]
expect(cloudSetupRequired(steps)).toBe(false)
expect(cloudSetupPageModel(steps).action).toBe('done')
})
})
@@ -266,7 +265,7 @@ describe('withReachableGitHub', () => {
{ id: 'plan', state: 'done', detail: 'subscription' },
{ id: 'github', state: 'done' }
])
expect(getCloudPrimaryAction([...withReachableGitHub([...session], true)])).toBe('start')
expect(cloudSetupPageModel([...withReachableGitHub([...session], true)]).action).toBe('done')
})
it('leaves the rows alone while the repository is still unreachable', () => {
@@ -274,15 +273,3 @@ describe('withReachableGitHub', () => {
expect(withReachableGitHub(steps, false)).toBe(steps)
})
})
describe('cloudSetupNeedsGitHub', () => {
it('counts GitHub access the browser visit gives, not an approval, a missing remote or a check', () => {
expect(cloudSetupNeedsGitHub([{ id: 'github', state: 'needed', need: 'github' }])).toBe(true)
expect(cloudSetupNeedsGitHub([{ id: 'github', state: 'active' }])).toBe(true)
expect(cloudSetupNeedsGitHub([{ id: 'github', state: 'done' }])).toBe(false)
expect(cloudSetupNeedsGitHub([{ id: 'github', state: 'loading' }])).toBe(false)
expect(
cloudSetupNeedsGitHub([{ id: 'github', state: 'pending', need: 'github', blocksStart: true }])
).toBe(false)
})
})
@@ -1,30 +1,8 @@
// "Start in Orca Cloud": what the Run on control shows, which setup rows are missing, and what the
// composer's primary button says and does. Pure, so every state the spec draws is unit-tested here.
// Orca Cloud from the composer: whether its Run on option is offered, which setup rows are missing,
// and the remembered default. Pure, so every state the spec draws is unit-tested here.
import type { OrcaCloudAvailability } from '../../../../../shared/orca-yaml-hook-types'
import type { OrcaCloudRunTargetAccess } from '../orca-cloud-run-target-access'
/** The three segments of Run on: this computer, Orca Cloud, and everything else behind "⋯". */
export type RunOnSegment = 'local' | 'orca-cloud' | 'other'
export function getRunOnSegment({
orcaCloudSelected,
otherRecipeSelected,
selectedHostIsLocal
}: {
orcaCloudSelected: boolean
otherRecipeSelected: boolean
/** Null when no host is committed (the picker's "Choose where to run" state). */
selectedHostIsLocal: boolean | null
}): RunOnSegment | null {
if (orcaCloudSelected) {
return 'orca-cloud'
}
if (otherRecipeSelected || selectedHostIsLocal === false) {
return 'other'
}
return selectedHostIsLocal === null ? null : 'local'
}
/**
* How the Orca Cloud segment behaves. `hidden`: this build doesn't ship Orca Cloud.
* `unreachable`: it does, but the account check failed (offline); the segment stays visible and
@@ -47,7 +25,7 @@ export function getOrcaCloudSegmentState(
}
}
/** One row of the "Set up Orca Cloud" panel. Shape shared with the setup session (team-billing-ux). */
/** One step of the Orca Cloud setup page. Shape shared with the setup session (team-billing-ux). */
export type CloudSetupStepId = 'account' | 'team' | 'plan' | 'github' | 'agent'
export type CloudSetupStepState =
| 'done'
@@ -173,79 +151,6 @@ function derivePlanStep(
}
}
/** Required rows still open; `optional` (agent) never counts. */
export function getOpenSetupSteps(steps: readonly CloudSetupStep[]): CloudSetupStep[] {
return steps.filter((step) => step.state !== 'done' && step.state !== 'optional')
}
/** The panel shows while anything required is open (or still loading). */
export function isCloudSetupPanelVisible(steps: readonly CloudSetupStep[]): boolean {
// An included trial isn't a task: with nothing else open, the hint line carries it (B1).
return getOpenSetupSteps(steps).some((step) => step.need !== 'trial')
}
/** The browser trip is for choosing a plan (T2, lapsed, no plan), not a free trial. */
export function cloudSetupNeedsPlan(steps: readonly CloudSetupStep[]): boolean {
return getOpenSetupSteps(steps).some(
(step) => step.need === 'plan' || step.need === 'renew-plan' || step.need === 'trial-used-up'
)
}
/**
* The same browser trip also gives Orca Cloud access to the repository on GitHub. An owner's
* approval (D2) or a project not on GitHub (D3) isn't part of the trip; still checking isn't either.
*/
export function cloudSetupNeedsGitHub(steps: readonly CloudSetupStep[]): boolean {
return getOpenSetupSteps(steps).some(
(step) => step.id === 'github' && !step.blocksStart && step.state !== 'loading'
)
}
export type CloudPrimaryAction =
| 'start'
| 'setUpAndStart'
| 'choosePlanAndStart'
| 'renewAndStart'
| 'giveAccessAndStart'
/** Only the trial is missing: setup grants it, so the button just says Start (B1). */
| 'startTrial'
| 'useThisComputer'
| 'checking'
/**
* The composer's primary action while Orca Cloud is selected. The label names where the work runs
* (spec D10); a member who can't fix a block gets "Use this computer instead" so ⌘↵ always works.
*/
export function getCloudPrimaryAction(steps: readonly CloudSetupStep[]): CloudPrimaryAction {
const open = getOpenSetupSteps(steps)
if (open.length === 0) {
return 'start'
}
if (open.some((step) => (step.state === 'needed' && step.canFix === false) || step.blocksStart)) {
return 'useThisComputer'
}
if (open.every((step) => step.state === 'loading')) {
return 'checking'
}
const needed = open.filter((step) => step.state !== 'pending' && step.state !== 'loading')
if (needed.length === 1) {
const need = needed[0]?.need
if (need === 'plan' || need === 'trial-used-up') {
return 'choosePlanAndStart'
}
if (need === 'trial') {
return 'startTrial'
}
if (need === 'renew-plan') {
return 'renewAndStart'
}
if (need === 'github') {
return 'giveAccessAndStart'
}
}
return 'setUpAndStart'
}
export type RunOnPreference = 'last' | 'local' | 'orca-cloud'
/**
@@ -1,194 +0,0 @@
// Shared preview for the cloud-start fixtures: the composer's project section and footer wired to
// the pure setup model, so every spec frame renders without a store or network.
import React, { useState } from 'react'
import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
import type { OrcaCloudAvailability } from '../../../../../shared/orca-yaml-hook-types'
import { NewWorkspaceComposerProjectSection } from '../NewWorkspaceComposerProjectSection'
import { NewWorkspaceComposerFooter } from '../NewWorkspaceComposerFooter'
import type { EphemeralVmRecipeOption } from '../run-target-options'
import type { OrcaCloudRunTargetAccess } from '../orca-cloud-run-target-access'
import {
deriveCloudSetupSteps,
getCloudPrimaryAction,
isCloudSetupPanelVisible,
type CloudSetupStep
} from './cloud-start-model'
import type { CloudStartView } from './use-cloud-start-composer'
import { getCloudPrimaryLabel } from './cloud-start-actions'
import { decorateCloudSetupSteps } from './use-cloud-start-steps'
import type { OrcaCloudGitHubRepository } from '@/lib/orca-cloud/use-orca-cloud-github-repositories'
const projects: NewWorkspaceProjectOption[] = [
{
kind: 'project',
id: 'web',
projectId: 'web',
displayName: 'acme/web',
badgeColor: 'var(--muted-foreground)',
detail: 'acme/web'
}
]
/** A plain folder (no git): the D3-for-folders frames. */
const folderProjects: NewWorkspaceProjectOption[] = [
{
kind: 'project',
id: 'web',
projectId: 'web',
displayName: 'meeting-notes',
badgeColor: 'var(--muted-foreground)',
detail: '~/Documents/meeting-notes'
}
]
const hosts: ProjectHostSetupOption[] = [
{
kind: 'ready',
id: 'local',
projectId: 'web',
repoId: 'web',
hostId: 'local',
label: 'MacBook Pro',
detail: '~/code/acme-web',
path: '/Users/neil/code/acme-web'
},
{
kind: 'ready',
id: 'devbox',
projectId: 'web',
repoId: 'web-devbox',
hostId: 'ssh:devbox',
label: 'devbox',
detail: 'SSH',
path: '/home/neil/acme-web'
}
]
const orcaCloudRecipe: EphemeralVmRecipeOption = {
id: 'cloud',
name: 'Orca Cloud',
description: 'Hosted by Orca. Sleeps when idle and keeps your files.',
provider: 'orca-cloud',
create: 'orca-cloud create'
}
export function ComposerPreview({
availability,
access,
cloudSelected,
steps: pinnedSteps,
github = null,
allSet = false,
prompt = 'Fix the flaky login test in e2e/auth.spec.ts and open a PR.',
cloudRepos,
pickedCloudRepo = null,
folderProject = false,
showCreateMultiple = false,
width
}: {
availability: OrcaCloudAvailability
access: OrcaCloudRunTargetAccess
cloudSelected: boolean
steps?: CloudSetupStep[]
/** The GitHub row (D1–D3), as the repository check would report it. */
github?: CloudSetupStep | null
allSet?: boolean
prompt?: string
/** D4: GitHub repositories with no clone here. */
cloudRepos?: OrcaCloudGitHubRepository[]
pickedCloudRepo?: string | null
/** The project is a plain folder (D3 for folders). */
folderProject?: boolean
/** The modal's "Create more" switch, which shares the footer row with the actions. */
showCreateMultiple?: boolean
/** Pins the card's width, e.g. the modal's content width. */
width?: number
}): React.JSX.Element {
const [picked, setPicked] = useState<string | null>(pickedCloudRepo)
const [host, setHost] = useState('local')
const [recipe, setRecipe] = useState<string | null>(cloudSelected ? 'cloud' : null)
const signedOut = availability === 'needs-sign-in' || access.state === 'signed-out'
// G: Claude not set up yet, so the Agent row offers the one-time setup (or waits for sign-in).
const steps = decorateCloudSetupSteps(
pinnedSteps ?? deriveCloudSetupSteps({ availability, access, github }),
signedOut ? { kind: 'signedOut', agent: 'claude' } : { kind: 'notSetUp', agent: 'claude' }
)
const selected = recipe === 'cloud'
const panelVisible = selected && (isCloudSetupPanelVisible(steps) || allSet)
const action = getCloudPrimaryAction(steps)
const cloudStart: CloudStartView = {
steps,
panelVisible,
allSet,
onSelectOrcaCloud: () => setRecipe('cloud'),
onUserPick: () => {},
folderProject,
...(cloudRepos
? {
cloudOnly: {
repos: cloudRepos,
showMore: () => {},
picked: cloudRepos.find((repo) => repo.fullName === picked) ?? null,
githubStep: null,
folderProject,
pick: setPicked,
clear: () => setPicked(null),
moreAccess: () => {},
start: async () => true
}
}
: {}),
...(panelVisible && !allSet && action !== 'useThisComputer'
? { onUseThisComputer: () => setRecipe(null) }
: {})
}
return (
<div
className="space-y-4 rounded-xl border border-border bg-background p-4"
style={width ? { width } : undefined}
>
<h1 className="text-base font-semibold">Create workspace</h1>
<p className="text-sm text-muted-foreground">{prompt}</p>
<NewWorkspaceComposerProjectSection
projectError={null}
selectedRepoConnectionId={null}
selectedRepoRequiresConnection={false}
selectedRepoConnectInProgress={false}
projectOptions={folderProject ? folderProjects : projects}
selectedProjectId="web"
onProjectChange={() => {}}
projectDescriptionId="fixture-project-description"
onAddProject={() => {}}
focusNameInput={() => {}}
shouldShowRunTargetPicker
projectHostSetupOptions={hosts}
selectedProjectHostSetupId={host}
handleProjectHostSetupChange={(id) => setHost(id)}
ephemeralVmRecipes={availability === 'offered' ? [orcaCloudRecipe] : []}
selectedEphemeralVmRecipeId={recipe}
onEphemeralVmRecipeChange={setRecipe}
orcaCloudAvailability={availability}
orcaCloudAccess={access}
cloudStart={cloudStart}
handleAddSshHost={() => {}}
handleAddRemoteServer={() => {}}
handleConnectRunTargetHost={async () => {}}
handleSetLocation={() => {}}
onConnectSelectedRepo={async () => {}}
sshStatusLabel="Connected"
connectButtonLabel="Connect"
selectedProjectName={folderProject ? 'meeting-notes' : 'acme/web'}
/>
<NewWorkspaceComposerFooter
createError={null}
onCreate={() => {}}
createDisabled={allSet || action === 'checking'}
creating={allSet}
primaryActionLabel={
selected ? getCloudPrimaryLabel(allSet ? 'start' : action) : 'Create worktree'
}
submitShortcutModifierLabel="⌘"
cloudStart={cloudStart}
showCreateMultiple={showCreateMultiple}
/>
</div>
)
}
@@ -1,17 +1,11 @@
// What the composer card renders for "Start in Orca Cloud", shared by the hook and the card.
import type { CloudSetupStep } from './cloud-start-model'
// What the composer card needs for Orca Cloud, shared by the hook and the card. Picking Orca Cloud
// changes nothing else in the dialog (composer-redo): setup lives on its own page after Start.
import type { CloudOnlyRepo } from './use-cloud-only-repo'
import type { CloudSetupViewExtras } from '../cloud-setup/use-cloud-setup-tracker'
/** What the card renders: Run on's Orca Cloud segment, the setup panel and the fallback button. */
export type CloudStartView = CloudSetupViewExtras & {
steps: CloudSetupStep[]
panelVisible: boolean
allSet: boolean
export type CloudStartView = {
/** Picks Orca Cloud even while the account still needs setup; Start walks it on a page. */
onSelectOrcaCloud: () => void
onUserPick: () => void
/** Present while setup blocks a start: creates the same prompt on this computer. */
onUseThisComputer?: () => void
/** D4: GitHub repositories with no clone here, and the one picked. */
cloudOnly?: CloudOnlyRepo
/** The project is a plain folder: Orca Cloud needs a GitHub repository instead (D3/D4). */
@@ -1,42 +1,27 @@
// Composer glue for "Start in Orca Cloud": the setup rows, the primary button's label and action,
// setup instead of a build that billing or GitHub would refuse, the start on its own once setup
// finishes (O4), the draft on disk (N7), and where each project last started (O5).
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
// Composer glue for Orca Cloud (composer-redo). Picking Orca Cloud changes nothing else in the
// dialog: Start either creates the workspace (everything is set up) or saves the draft, closes the
// dialog and opens the full-page setup, which starts the prompt by itself when done. Also: where
// each project last started (O5), the remembered default (D7) and GitHub-only repositories (D4).
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { toast } from 'sonner'
import { useAppStore } from '@/store/index'
import { claimOrcaCloudTrial } from '@/components/billing/claim-trial'
import { translate } from '@/i18n/i18n'
import type {
ProjectHostSetupOption,
ReadyProjectHostSetupOption
} from '@/lib/project-host-setup-options'
import { clearCloudStartDraft, saveCloudStartDraft } from '@/lib/orca-cloud/cloud-start-draft'
import {
clearCloudStartDraft,
readCloudStartDraft,
saveCloudStartDraft
} from '@/lib/orca-cloud/cloud-start-draft'
import { getCloudSetupStarter } from '@/lib/orca-cloud/cloud-setup-session-seam'
isCloudSetupAgentSkipped,
openCloudSetupPage
} from '@/lib/orca-cloud/cloud-setup-page-store'
import { recordSuccessfulRunOn } from '@/lib/orca-cloud/run-on-memory'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../../shared/execution-host'
import { ORCA_CLOUD_RECIPE_ID } from '../../../../../shared/hosted-sandbox/orca-cloud-recipe-id'
import type { OrcaCloudAvailability } from '../../../../../shared/orca-yaml-hook-types'
import { useOrcaCloudRunTargetAccess } from '../orca-cloud-billing-cache'
import type { OrcaCloudRunTargetAccess } from '../orca-cloud-run-target-access'
import { getCloudPrimaryLabel, setupActionFor, showGitHubGrantedToast } from './cloud-start-actions'
import {
getCloudPrimaryAction,
getOpenSetupSteps,
getOrcaCloudSegmentState,
isCloudSetupPanelVisible
} from './cloud-start-model'
import type { ComposerModalData } from '../composer-modal-data'
import { getOrcaCloudSegmentState } from './cloud-start-model'
import { getOrcaCloudStartToast } from './orca-cloud-hours-copy'
import { useCloudStartSteps } from './use-cloud-start-steps'
import { useSetupAutoStart } from './use-setup-auto-start'
import {
setupViewExtras,
useComposerSetupTracker,
useSwitchTeamSteps
} from '../cloud-setup/use-cloud-setup-tracker'
import { useSwitchTeamSteps } from '../cloud-setup/use-cloud-setup-tracker'
import { cloudSetupRequired } from '../../orca-cloud-setup/cloud-setup-page-model'
import { useRunOnDefault } from './use-run-on-default'
import { useCloudOnlyCreate, useCloudOnlyRepo } from './use-cloud-only-repo'
import type { CloudStartView } from './cloud-start-view'
@@ -48,12 +33,13 @@ export function useCloudStartComposer({
availability,
selectedRecipeId,
setRecipeId,
hostOptions,
onHostChange,
repoId,
prompt,
agent,
initialTargetFixed,
autoStart = false,
createReady = true,
composerSnapshot,
onCreate: createWorkspace,
onClose,
accessOverride
@@ -62,26 +48,26 @@ export function useCloudStartComposer({
availability: OrcaCloudAvailability
selectedRecipeId: string | null
setRecipeId: ((recipeId: string | null) => void) | undefined
hostOptions: readonly ProjectHostSetupOption[]
onHostChange: ((setupId: string) => void) | undefined
repoId: string
prompt: string
agent: string | null
/** The composer was opened on an explicit target (Landing card, a recovery). */
initialTargetFixed: boolean
/** Reopened by the setup page once everything is done: submit as soon as the form is ready. */
autoStart?: boolean
/** The composer's own create would run now (project picked, agent resolved). */
createReady?: boolean
/** The composer as it is now, for the setup page's Back and its auto-start. */
composerSnapshot: () => ComposerModalData
onCreate: () => void
/** Closes the composer once a cloud-only repository has started (D4). */
/** Closes the composer (setup moves to its page; a cloud-only repository started, D4). */
onClose?: () => void
/** Fixtures pin the account's access. */
accessOverride?: OrcaCloudRunTargetAccess
}): {
view: CloudStartView | null
primaryActionLabel: string | null
/** Replaces the plain create while Orca Cloud is selected. */
onPrimary: (() => void) | null
primaryDisabled: boolean
/** False while the primary runs setup or the local fallback instead of a plain create. */
primaryIsCreate: boolean
/** Call from the composer's onCreated: only a successful start is remembered. */
recordCreated: () => void
} {
@@ -105,57 +91,20 @@ export function useCloudStartComposer({
hoursToast: getOrcaCloudStartToast(access, translate, navigator.language),
onClose
})
const [startedSessionId, setSetupSessionId] = useState<string | null>(null)
// A composer reopened after setup (e.g. the Checkout return) resumes its saved draft: derived,
// not copied into state, so it is right on the first render.
const restoredDraft = useMemo(
() => (active && cloudSelected ? readCloudStartDraft(profileId) : null),
[active, cloudSelected, profileId]
)
const [restoreDone, setRestoreDone] = useState(false)
const restored = restoredDraft !== null && restoredDraft.repoId === repoId && !restoreDone
const setupSessionId = startedSessionId ?? (restored ? restoredDraft.setupSessionId : null)
const [startedHere, setSetupStarted] = useState(false)
const setupStarted = startedHere || restored
const setupStartedRef = useRef(setupStarted)
const onCreateRef = useRef(onCreate)
useEffect(() => {
setupStartedRef.current = setupStarted
onCreateRef.current = onCreate
}, [onCreate, setupStarted])
const {
steps: rowSteps,
session,
openInstall
} = useCloudStartSteps({
const { steps: rowSteps } = useCloudStartSteps({
availability,
access,
repoId,
agent,
cloudSelected,
setupSessionId,
githubOverride: cloudOnly.githubStep,
onGranted: (repoName) => {
// D2: an owner approved on GitHub. Setup already running starts by itself; otherwise ask.
if (setupStartedRef.current) {
return
}
showGitHubGrantedToast(repoName, () => onCreateRef.current())
}
setupSessionId: null,
githubOverride: cloudOnly.githubStep
})
const { steps, held: switchTeam } = useSwitchTeamSteps(rowSteps, cloudSelected)
const setupNeeded = cloudSetupRequired(steps, {
teamSwitch: switchTeam !== null,
agentSkipped: isCloudSetupAgentSkipped(agent)
})
// B2: on the personal account while a team the user is on has a plan, switch instead of buying.
const { steps, held: switchTeam } = useSwitchTeamSteps(rowSteps, cloudSelected && !session)
const action = getCloudPrimaryAction(steps)
const panelVisible = cloudSelected && isCloudSetupPanelVisible(steps)
const localOption = useMemo(
() =>
hostOptions.find(
(option): option is ReadyProjectHostSetupOption =>
option.kind === 'ready' && option.hostId === LOCAL_EXECUTION_HOST_ID
) ?? null,
[hostOptions]
)
const { markTouched } = useRunOnDefault({
active,
@@ -172,95 +121,41 @@ export function useCloudStartComposer({
setRecipeId?.(ORCA_CLOUD_RECIPE_ID)
}, [markTouched, setRecipeId])
// "Use this computer instead": switch, then submit once the switch has rendered.
const localSubmitPendingRef = useRef(false)
const useThisComputer = useCallback(() => {
if (!localOption) {
return
}
markTouched()
localSubmitPendingRef.current = true
setRecipeId?.(null)
onHostChange?.(localOption.id)
}, [localOption, markTouched, onHostChange, setRecipeId])
useEffect(() => {
if (localSubmitPendingRef.current && !cloudSelected) {
localSubmitPendingRef.current = false
createWorkspace()
}
}, [cloudSelected, createWorkspace])
// O4: setup started here (or its restored draft) starts by itself once every row is done.
const startAfterSetup = useCallback(() => {
setSetupStarted(false)
setRestoreDone(true)
onCreate()
}, [onCreate])
const allSet = useSetupAutoStart({
armed: setupStarted && cloudSelected,
done: action === 'start' || session?.finished === true,
setupSessionId,
onStart: startAfterSetup
})
// The draft survives the browser trip and a quit; it is cleared once a workspace starts.
const runSetup = useCallback(() => {
const draft = {
// Start with setup still open: the dialog closes and the page takes over, prompt kept (N6).
const openSetup = useCallback(() => {
saveCloudStartDraft(profileId, {
prompt,
repoId,
agent,
target: 'orca-cloud' as const,
setupSessionId,
target: 'orca-cloud',
setupSessionId: null,
createdAt: Date.now()
}
saveCloudStartDraft(profileId, draft)
setSetupStarted(true)
const open = getOpenSetupSteps(steps)
// D1: GitHub is the only thing missing, so its install page is the whole trip.
if (open.length > 0 && open.every((step) => step.id === 'github' || step.id === 'agent')) {
void openInstall()
return
}
const startSession = getCloudSetupStarter()
const fallback = setupActionFor(steps, access, availability)
if (!startSession) {
fallback?.()
return
}
// One browser trip for every missing step; the session id rides on the draft across a quit.
void startSession({ repoId, needs: open.map((step) => step.id) })
.then((opened) => {
if (!opened) {
fallback?.()
return
}
setSetupSessionId(opened.sessionId)
saveCloudStartDraft(profileId, { ...draft, setupSessionId: opened.sessionId })
})
.catch(() => fallback?.())
}, [access, agent, availability, openInstall, profileId, prompt, repoId, setupSessionId, steps])
// B1: only the trial is missing. Claiming it is the whole setup; the billing cache then reads
// entitled and the auto-start above submits. A personal account needs a team first: setup trip.
const startTrial = useCallback(() => {
setSetupStarted(true)
void claimOrcaCloudTrial().then((result) => {
if (result.status !== 'ok') {
runSetup()
}
})
}, [runSetup])
openCloudSetupPage({
prompt,
repoId,
agent,
availability,
cloudOnlyRepo: cloudOnly.picked?.fullName ?? null,
composer: composerSnapshot()
})
onClose?.()
}, [agent, availability, cloudOnly.picked, composerSnapshot, onClose, profileId, prompt, repoId])
const trip = { cloudSelected, setupStarted, allSet, session, steps, access, availability }
const tracker = useComposerSetupTracker({
...trip,
profileId,
readyToStart: action === 'start',
sessionId: setupSessionId,
runSetup,
// Ends the trip here; the tracker hook also clears the draft's session id.
onCancel: () => [setSetupStarted(false), setRestoreDone(true), setSetupSessionId(null)]
})
// Reopened by the setup page: submit once the account and form are ready. Anything still open
// (a race, a revoked grant) leaves the composer for the user, never a second trip on its own.
const autoStartedRef = useRef(false)
const stepsSettled = !steps.some((step) => step.state === 'loading' && step.id !== 'agent')
useEffect(() => {
if (!autoStart || autoStartedRef.current || !cloudSelected || !createReady || !stepsSettled) {
return
}
if (setupNeeded) {
return
}
autoStartedRef.current = true
onCreate()
}, [autoStart, cloudSelected, createReady, onCreate, setupNeeded, stepsSettled])
const recordCreated = useCallback(() => {
if (!repoId) {
@@ -278,51 +173,20 @@ export function useCloudStartComposer({
}
}, [access, cloudSelected, profileId, repoId])
// Orca Cloud isn't the target (or this build has none): the composer's own create runs.
const plainCreate = {
primaryActionLabel: null,
onPrimary: null,
primaryDisabled: false,
primaryIsCreate: true,
recordCreated
}
if (segment === 'hidden') {
return { view: null, ...plainCreate }
}
const view: CloudStartView = {
steps,
panelVisible: panelVisible || allSet,
allSet,
onSelectOrcaCloud,
onUserPick: markTouched,
cloudOnly,
folderProject: cloudOnly.folderProject,
...setupViewExtras(switchTeam, tracker),
...(panelVisible && localOption && action !== 'useThisComputer' && !cloudOnly.picked
? { onUseThisComputer: useThisComputer }
: {})
}
if (!cloudSelected) {
return { view, ...plainCreate }
}
const onPrimary = switchTeam
? () => undefined
: action === 'start'
? onCreate
: action === 'useThisComputer'
? useThisComputer
: action === 'checking'
? () => undefined
: action === 'startTrial'
? startTrial
: runSetup
return {
view,
primaryActionLabel: getCloudPrimaryLabel(allSet || switchTeam ? 'start' : action),
onPrimary,
primaryDisabled:
action === 'checking' || allSet || (action === 'useThisComputer' && !localOption),
primaryIsCreate: action === 'start',
recordCreated
const view = useMemo<CloudStartView | null>(
() =>
segment === 'hidden'
? null
: {
onSelectOrcaCloud,
onUserPick: markTouched,
cloudOnly,
folderProject: cloudOnly.folderProject
},
[cloudOnly, markTouched, onSelectOrcaCloud, segment]
)
if (!view || !cloudSelected) {
return { view, onPrimary: null, recordCreated }
}
return { view, onPrimary: setupNeeded ? openSetup : onCreate, recordCreated }
}
@@ -1,5 +1,5 @@
// The composer modal's side of "Start in Orca Cloud": feeds the composer state into
// useCloudStartComposer and returns what the primary button and ⌘↵ do.
// The composer modal's side of Orca Cloud: feeds the composer state into useCloudStartComposer and
// returns what the primary button and ⌘↵ do.
import { useCallback, useEffect, type MutableRefObject } from 'react'
import type { ComposerModalData } from '../composer-modal-data'
import type { NewWorkspaceComposerCardProps } from '../new-workspace-composer-card-props'
@@ -10,8 +10,6 @@ type ComposerCardState = Pick<
| 'orcaCloudAvailability'
| 'selectedEphemeralVmRecipeId'
| 'onEphemeralVmRecipeChange'
| 'projectHostSetupOptions'
| 'onProjectHostSetupChange'
| 'repoId'
| 'name'
>
@@ -23,6 +21,7 @@ export function useComposerCloudStart({
quickAgent,
handleCreate,
createDisabled,
composerSnapshot,
recordCreatedRef,
onClose
}: {
@@ -32,6 +31,7 @@ export function useComposerCloudStart({
quickAgent: string | null
handleCreate: () => Promise<void>
createDisabled: boolean
composerSnapshot: () => ComposerModalData
/** The composer's onCreated calls this: only a start that happened is remembered. */
recordCreatedRef: MutableRefObject<(() => void) | null>
onClose?: () => void
@@ -44,26 +44,25 @@ export function useComposerCloudStart({
availability: cardProps.orcaCloudAvailability ?? 'unavailable',
selectedRecipeId: cardProps.selectedEphemeralVmRecipeId ?? null,
setRecipeId: cardProps.onEphemeralVmRecipeChange,
hostOptions: cardProps.projectHostSetupOptions ?? [],
onHostChange: cardProps.onProjectHostSetupChange,
repoId: cardProps.repoId,
prompt: cardProps.name,
agent: quickAgent,
initialTargetFixed: Boolean(
modalData.initialEphemeralVmRecipeId || modalData.initialWorkspaceTarget || modalData.recovery
),
autoStart: modalData.cloudAutoStart === true,
createReady: !createDisabled,
composerSnapshot,
onCreate: runCreate,
onClose
})
useEffect(() => {
recordCreatedRef.current = cloudStart.recordCreated
}, [cloudStart.recordCreated, recordCreatedRef])
const primaryCreateDisabled = cloudStart.primaryIsCreate
? createDisabled || cloudStart.primaryDisabled
: cloudStart.primaryDisabled
return {
cloudStart,
primaryCreateDisabled,
// Local and Orca Cloud share one button: same label, same rules for when it's enabled.
primaryCreateDisabled: createDisabled,
onPrimary: cloudStart.onPrimary ?? runCreate
}
}
@@ -1,42 +0,0 @@
// O4: setup started from this composer (or its restored draft) starts the workspace by itself.
// When every required row is done, hold "All set", then submit as if ⌘↵ had been pressed.
import { useEffect, useState } from 'react'
import { CLOUD_SETUP_ALL_SET_HOLD_MS } from '@/lib/orca-cloud/cloud-setup-session-seam'
import { recordCloudSetupFirstSandbox } from '@/lib/orca-cloud/cloud-setup-telemetry'
import { prefersReducedMotion } from './cloud-start-view'
/** Returns whether "All set" is showing. `onStart` ends the trip state, then creates. */
export function useSetupAutoStart({
armed,
done,
setupSessionId,
onStart
}: {
/** Setup was started here and Orca Cloud is still the target. */
armed: boolean
/** Every required row is done, or the session says it finished. */
done: boolean
setupSessionId: string | null
onStart: () => void
}): boolean {
const [allSet, setAllSet] = useState(false)
useEffect(() => {
if (!armed || !done) {
return
}
setAllSet(true)
const timer = window.setTimeout(
() => {
setAllSet(false)
// N10: time to first sandbox, from when the tracker first saw this trip.
if (setupSessionId) {
recordCloudSetupFirstSandbox({ sessionId: setupSessionId, hoursSource: 'unknown' })
}
onStart()
},
prefersReducedMotion() ? 0 : CLOUD_SETUP_ALL_SET_HOLD_MS
)
return () => window.clearTimeout(timer)
}, [armed, done, onStart, setupSessionId])
return allSet
}
@@ -51,7 +51,8 @@ export function useRunTargetOrcaCloud({
override,
open,
close,
selectRecipe
selectRecipe,
onSelectOrcaCloud
}: {
recipes: readonly EphemeralVmRecipeOption[]
offer: OrcaCloudRunTargetAccess | null
@@ -60,6 +61,8 @@ export function useRunTargetOrcaCloud({
open: boolean
close: () => void
selectRecipe: (recipeId: string) => void
/** Picks Orca Cloud whatever setup it still needs (the composer walks it after Start). */
onSelectOrcaCloud?: () => void
}): {
access: OrcaCloudRunTargetAccess
/** A pick is waiting on billing; the row shows busy. */
@@ -97,6 +100,11 @@ export function useRunTargetOrcaCloud({
const pickFor = useCallback(
(resolved: OrcaCloudRunTargetAccess, recipe: EphemeralVmRecipeOption | null): void => {
const pick = getOrcaCloudPick(resolved)
if (onSelectOrcaCloud && pick.kind !== 'blocked') {
close()
onSelectOrcaCloud()
return
}
if (pick.kind === 'act') {
close()
// The fix finishes elsewhere (plans, portal, settings): reread billing on return.
@@ -106,7 +114,7 @@ export function useRunTargetOrcaCloud({
selectRecipe(recipe.id)
}
},
[close, selectRecipe]
[close, onSelectOrcaCloud, selectRecipe]
)
const runBlockedAction = useCallback((): void => pickFor(access, null), [access, pickFor])
const select = useCallback(
@@ -0,0 +1,421 @@
// "Set up Orca Cloud" as a page (composer-redo). Start in the composer closes the dialog and opens
// this when the account isn't ready. One step at a time: the current step is the page; finished
// steps are ticks in the stepper. Browser hops only for sign-in, Stripe and GitHub. When every
// step is done, the saved prompt starts by itself.
import React from 'react'
import { ArrowLeft, Check, Cloud, Copy, ExternalLink, LoaderCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import { useTranslate, type Translate } from '@/i18n/use-translate'
import type { CloudSetupStep } from '@/components/new-workspace/run-on/cloud-start-model'
import { CloudSetupErrorNotice } from '@/components/new-workspace/cloud-setup/CloudSetupErrorNotice'
import { sessionDetailCopy } from '@/components/new-workspace/run-on/cloud-setup-session-copy'
import type { CloudSetupFlow } from './use-cloud-setup-flow'
export type CloudSetupPageProps = {
flow: CloudSetupFlow
projectName: string | null
prompt: string
agentLabel: string | null
/** Back to the composer with the prompt, project and agent as they were. */
onBack: () => void
/** "All set": shown for a moment before the workspace starts. */
starting: boolean
}
function stepLabel(step: CloudSetupStep, t: Translate): string {
switch (step.id) {
case 'account':
return t('cloudSetupPage.step.account', 'Account')
case 'team':
return t('cloudSetupPage.step.team', 'Team')
case 'plan':
return step.need === 'trial'
? t('cloudSetupPage.step.trial', 'Trial')
: t('cloudSetupPage.step.plan', 'Plan')
case 'github':
return t('cloudSetupPage.step.github', 'GitHub')
case 'agent':
return t('cloudSetupPage.step.agent', 'Agent')
}
}
type StepState = 'done' | 'current' | 'todo'
function SetupStepper({
steps,
current,
allDone
}: {
steps: readonly CloudSetupStep[]
current: CloudSetupStep | null
allDone: boolean
}): React.JSX.Element {
const t = useTranslate()
const currentIndex = current ? steps.findIndex((step) => step.id === current.id) : steps.length
return (
<ol
aria-label={t('cloudSetupPage.stepsLabel', 'Setup steps')}
className="flex flex-wrap items-center gap-x-2 gap-y-2"
>
{steps.map((step, index) => {
const state: StepState =
allDone || index < currentIndex ? 'done' : index === currentIndex ? 'current' : 'todo'
const label = stepLabel(step, t)
return (
<li key={step.id} className="flex items-center gap-2" data-setup-page-step={step.id}>
{index > 0 ? <span aria-hidden className="h-px w-6 bg-border" /> : null}
<span
aria-hidden
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-full',
state === 'done' && 'bg-foreground text-background',
state === 'current' && 'border-2 border-focus-ring',
state === 'todo' && 'border border-border'
)}
>
{state === 'done' ? <Check className="size-2.5" strokeWidth={3} /> : null}
{state === 'current' ? <span className="size-1.5 rounded-full bg-focus-ring" /> : null}
</span>
<span
className={cn(
'text-xs',
state === 'current' ? 'font-medium text-foreground' : 'text-muted-foreground'
)}
>
{label}
<span className="sr-only">
{state === 'done'
? `, ${t('cloudSetupPage.stepDone', 'done')}`
: state === 'current'
? `, ${t('cloudSetupPage.stepCurrent', 'current step')}`
: ''}
</span>
</span>
</li>
)
})}
</ol>
)
}
type StepCopy = { title: string; lead: string | null; note?: string | null }
function trialHours(step: CloudSetupStep): string | null {
return step.trialSeconds ? String(Math.round((step.trialSeconds / 3600) * 10) / 10) : null
}
function currentStepCopy(flow: CloudSetupFlow, t: Translate, agentLabel: string | null): StepCopy {
const step = flow.current
const detail = step
? (sessionDetailCopy(step, t) === undefined ? step.detail : sessionDetailCopy(step, t)) ?? null
: null
switch (flow.action) {
case 'sign-in':
return {
title: t('cloudSetupPage.signIn.title', 'Sign in to Orca'),
lead: t(
'cloudSetupPage.signIn.lead',
'Orca Cloud runs your agents in a cloud workspace, so they keep going with your laptop closed. Sign in or create an account in your browser. We’ll make a team for you.'
),
note: t(
'cloudSetupPage.signIn.note',
'New teams get a trial with no card needed. This page picks up each step on its own.'
)
}
case 'start-trial': {
const hours = step ? trialHours(step) : null
return {
title: t('cloudSetupPage.trial.title', 'Start your free trial'),
lead:
hours === null
? t('cloudSetupPage.trial.leadNoHours', 'Try Orca Cloud on your team. No card needed.')
: t('cloudSetupPage.trial.lead', '{{hours}} sandbox hours to try Orca Cloud. No card needed.', {
hours
}),
note: t('cloudSetupPage.trial.note', 'Nothing is charged until you choose a plan.')
}
}
case 'choose-plan':
return step?.need === 'trial-used-up'
? {
title: t('cloudSetupPage.plan.trialUsedUpTitle', 'Your trial is used up'),
lead: t(
'cloudSetupPage.plan.trialUsedUpLead',
'Choose a Team plan to keep using Orca Cloud. Checkout opens in your browser.'
)
}
: step?.need === 'renew-plan'
? {
title: t('cloudSetupPage.plan.renewTitle', 'Renew your Team plan'),
lead: t(
'cloudSetupPage.plan.renewLead',
'Your Team plan ended. Renew it in your browser to start this workspace.'
)
}
: {
title: t('cloudSetupPage.plan.title', 'Choose a Team plan'),
lead: t(
'cloudSetupPage.plan.lead',
'Orca Cloud needs a Team plan. Checkout opens in your browser.'
)
}
case 'fix-billing':
return {
title:
step?.need === 'payment'
? t('cloudSetupPage.billing.paymentTitle', 'Update your payment method')
: step?.need === 'spend-cap'
? t('cloudSetupPage.billing.capTitle', 'Spend cap reached')
: t('cloudSetupPage.billing.hoursTitle', 'Out of sandbox hours'),
lead:
step?.need === 'payment'
? t('cloudSetupPage.billing.paymentLead', 'The last payment failed. Update the card to keep going.')
: step?.need === 'spend-cap'
? t('cloudSetupPage.billing.capLead', 'Raise the spend cap to start this workspace.')
: t('cloudSetupPage.billing.hoursLead', 'Turn on overage to start this workspace.')
}
case 'switch-team':
return { title: t('cloudSetupPage.team.title', 'Switch to your team'), lead: detail }
case 'github-install':
return {
title: t('cloudSetupPage.github.title', 'Give Orca Cloud access on GitHub'),
lead: t(
'cloudSetupPage.github.lead',
'Orca Cloud clones your code from GitHub. Install the Orca Cloud app on this repository once.'
),
note: step?.note ?? null
}
case 'github-waiting':
return {
title: t('cloudSetupPage.github.waitingTitle', 'Waiting for approval on GitHub'),
lead: detail,
note: step?.note ?? null
}
case 'needs-repository':
return {
title: t('cloudSetupPage.repository.title', 'Orca Cloud needs a GitHub repository'),
lead: detail,
note: step?.note ?? null
}
case 'ask-owner':
return {
title: t('cloudSetupPage.owner.title', 'An owner or admin needs to fix this'),
lead: detail ?? t('cloudSetupPage.owner.lead', 'Orca Cloud needs a Team plan. Only owners and admins can choose one.')
}
case 'agent':
return {
title: agentLabel
? t('cloudSetupPage.agent.title', 'Sign in to {{agent}} once', { agent: agentLabel })
: t('cloudSetupPage.agent.titleGeneric', 'Sign in to your agent once'),
lead: t(
'cloudSetupPage.agent.lead',
'Save your agent sign-in to your Orca account and every cloud workspace uses it. You can also skip this and sign in inside the workspace.'
)
}
case 'checking':
return { title: t('cloudSetupPage.checking.title', 'Checking your Orca Cloud account…'), lead: null }
case 'done':
return {
title: t('cloudSetupPage.done.title', 'All set'),
lead: t('cloudSetupPage.done.lead', 'Starting your cloud workspace…')
}
}
}
function BrowserWaiting({ flow }: { flow: CloudSetupFlow }): React.JSX.Element {
const t = useTranslate()
return (
<div className="flex flex-wrap items-center gap-2" role="status">
<span className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderCircle className="size-4 motion-safe:animate-spin" aria-hidden />
{t('cloudSetupPage.waitingBrowser', 'Waiting for your browser…')}
</span>
<span className="ms-auto flex items-center gap-2">
{flow.copyLink ? (
<Button type="button" variant="ghost" size="sm" onClick={flow.copyLink}>
<Copy aria-hidden />
{t('cloudSetupPage.copyLink', 'Copy link')}
</Button>
) : null}
<Button type="button" variant="outline" size="sm" onClick={flow.reopenBrowser}>
{t('cloudSetupPage.reopenBrowser', 'Reopen browser')}
<ExternalLink aria-hidden />
</Button>
</span>
</div>
)
}
function StepActions({ flow }: { flow: CloudSetupFlow }): React.JSX.Element | null {
const t = useTranslate()
const browser = (label: string, onClick: () => void): React.JSX.Element => (
<Button type="button" onClick={onClick} disabled={flow.busy}>
{flow.busy ? <LoaderCircle className="size-4 animate-spin" aria-hidden /> : null}
{label}
<ExternalLink aria-hidden />
</Button>
)
const inBrowserAction =
flow.action === 'sign-in' ||
flow.action === 'choose-plan' ||
flow.action === 'fix-billing' ||
flow.action === 'github-install'
if (flow.inBrowser && inBrowserAction && !flow.error) {
return <BrowserWaiting flow={flow} />
}
const continueLabel = t('cloudSetupPage.continueInBrowser', 'Continue in browser')
switch (flow.action) {
case 'sign-in':
case 'choose-plan':
return browser(continueLabel, flow.continueInBrowser)
case 'fix-billing':
return browser(continueLabel, flow.fixBilling)
case 'github-install':
return browser(continueLabel, flow.installGitHub)
case 'start-trial':
return (
<Button type="button" onClick={flow.startTrial} disabled={flow.busy}>
{flow.busy ? <LoaderCircle className="size-4 animate-spin" aria-hidden /> : null}
{t('cloudSetupPage.trial.start', 'Start free trial')}
</Button>
)
case 'switch-team':
return <div>{flow.teamAction}</div>
case 'agent':
return (
<div className="flex flex-wrap items-center gap-2">
{flow.current?.action ? (
<Button type="button" onClick={flow.current.action.onClick}>
{flow.current.action.label}
</Button>
) : null}
<Button type="button" variant="ghost" onClick={flow.skipAgent}>
{t('cloudSetupPage.agent.skip', 'Skip for now')}
</Button>
</div>
)
case 'needs-repository':
return flow.current?.action ? (
<Button type="button" variant="outline" onClick={flow.current.action.onClick}>
{flow.current.action.label}
<ExternalLink aria-hidden />
</Button>
) : null
case 'ask-owner':
return (
<Button type="button" variant="outline" onClick={flow.checkAgain}>
{t('cloudSetupPage.checkAgain', 'Check again')}
</Button>
)
case 'github-waiting':
return (
<span className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
<LoaderCircle className="size-4 motion-safe:animate-spin" aria-hidden />
{t('cloudSetupPage.github.waiting', 'This page starts the workspace once they approve.')}
</span>
)
case 'checking':
return <Skeleton className="h-9 w-40" />
case 'done':
return (
<span className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
<LoaderCircle className="size-4 motion-safe:animate-spin" aria-hidden />
{t('cloudSetupPage.starting', 'Starting…')}
</span>
)
}
}
export function CloudSetupPage({
flow,
projectName,
prompt,
agentLabel,
onBack,
starting
}: CloudSetupPageProps): React.JSX.Element {
const t = useTranslate()
const titleId = React.useId()
const headingRef = React.useRef<HTMLHeadingElement>(null)
const copy = currentStepCopy(starting ? { ...flow, action: 'done' } : flow, t, agentLabel)
// Each new step moves focus to its heading so screen readers hear the change once.
const stepKey = starting ? 'done' : `${flow.current?.id ?? 'done'}:${flow.action}`
React.useEffect(() => {
headingRef.current?.focus({ preventScroll: true })
}, [stepKey])
return (
<section
aria-labelledby={titleId}
data-cloud-setup-page="true"
className="flex h-full min-h-0 w-full flex-col overflow-y-auto bg-background scrollbar-sleek"
>
<div className="mx-auto flex w-full max-w-xl flex-1 flex-col px-8 pb-12 pt-10">
<div className="flex items-center justify-between gap-3">
<Button type="button" variant="ghost" size="sm" className="-ms-2" onClick={onBack}>
<ArrowLeft aria-hidden />
{t('cloudSetupPage.back', 'Back')}
</Button>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Cloud className="size-3.5" aria-hidden />
{t('cloudSetupPage.eyebrow', 'Orca Cloud setup')}
</span>
</div>
<div className="mt-8">
<SetupStepper steps={flow.steps} current={flow.current} allDone={starting || flow.action === 'done'} />
</div>
<div className="mt-8 space-y-3">
<h1
ref={headingRef}
id={titleId}
tabIndex={-1}
className="text-2xl font-semibold tracking-tight text-foreground outline-none"
>
{copy.title}
</h1>
{flow.action === 'checking' && !starting ? (
<Skeleton className="h-4 w-72" />
) : copy.lead ? (
<p className="text-sm leading-relaxed text-muted-foreground">{copy.lead}</p>
) : null}
{copy.note ? <p className="text-xs text-muted-foreground">{copy.note}</p> : null}
</div>
{flow.error && !starting ? (
<CloudSetupErrorNotice
error={flow.error}
canManageBilling={flow.canManageBilling}
onAction={(action) =>
action === 'reopen-browser' ? flow.reopenBrowser() : flow.continueInBrowser()
}
className="mt-6"
/>
) : null}
<div className="mt-6">
<StepActions flow={starting ? { ...flow, action: 'done' } : flow} />
</div>
<div className="mt-auto pt-12">
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
<div className="text-xs font-medium text-muted-foreground">
{t('cloudSetupPage.startsAfter', 'Starts when setup is done')}
</div>
<div className="mt-1 truncate text-sm text-foreground">
{prompt.trim() ||
t('cloudSetupPage.untitled', 'New cloud workspace')}
</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{[projectName, agentLabel, t('cloudSetupPage.orcaCloud', 'Orca Cloud')]
.filter(Boolean)
.join(' · ')}
</div>
</div>
</div>
</div>
</section>
)
}
@@ -0,0 +1,94 @@
// Mounts the Orca Cloud setup page over the workspace area while a Start is waiting on setup. Other
// pages (Settings for billing or agent accounts) show over it; coming back shows it again. Opening
// another workspace from the sidebar puts setup away (Back in the composer still has the prompt).
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { useTranslate } from '@/i18n/use-translate'
import {
closeCloudSetupPage,
useCloudSetupPageRequest,
type CloudSetupPageRequest
} from '@/lib/orca-cloud/cloud-setup-page-store'
import { CLOUD_SETUP_ALL_SET_HOLD_MS } from '@/lib/orca-cloud/cloud-setup-session-seam'
import { isOrcaCloudAccountAgent } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire'
import { ORCA_CLOUD_RECIPE_ID } from '../../../../shared/hosted-sandbox/orca-cloud-recipe-id'
import { agentName } from '@/components/settings/agent-accounts/orca-cloud-accounts-copy'
import { prefersReducedMotion } from '@/components/new-workspace/run-on/cloud-start-view'
import { CloudSetupPage } from './CloudSetupPage'
import { useCloudSetupFlow } from './use-cloud-setup-flow'
function reopenComposer(request: CloudSetupPageRequest, autoStart: boolean): void {
closeCloudSetupPage()
useAppStore.getState().openModal('new-workspace-composer', {
...request.composer,
initialEphemeralVmRecipeId: ORCA_CLOUD_RECIPE_ID,
// A GitHub-only repository can't be preselected; the composer opens for one more Start.
...(autoStart && !request.cloudOnlyRepo ? { cloudAutoStart: true } : {})
})
}
function ActiveCloudSetupPage({ request }: { request: CloudSetupPageRequest }): React.JSX.Element {
const t = useTranslate()
const flow = useCloudSetupFlow(request)
const projectName = useAppStore(
(s) => s.repos.find((repo) => repo.id === request.repoId)?.displayName ?? null
)
const agentLabel = isOrcaCloudAccountAgent(request.agent) ? agentName(request.agent, t) : null
const [starting, setStarting] = useState(false)
const startedRef = useRef(false)
useEffect(() => {
if (flow.action !== 'done' || startedRef.current) {
return
}
startedRef.current = true
setStarting(true)
const timer = window.setTimeout(
() => reopenComposer(request, true),
prefersReducedMotion() ? 0 : CLOUD_SETUP_ALL_SET_HOLD_MS
)
return () => window.clearTimeout(timer)
}, [flow.action, request])
const onBack = useCallback(() => reopenComposer(request, false), [request])
return (
<CloudSetupPage
flow={flow}
projectName={projectName}
prompt={request.prompt}
agentLabel={agentLabel}
onBack={onBack}
starting={starting}
/>
)
}
export function CloudSetupPageLayer(): React.JSX.Element | null {
const request = useCloudSetupPageRequest()
const onWorkspaceView = useAppStore((s) => s.activeView === 'terminal')
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId ?? null)
const openedOnRef = useRef<string | null | undefined>(undefined)
useEffect(() => {
if (!request) {
openedOnRef.current = undefined
return
}
if (openedOnRef.current === undefined) {
openedOnRef.current = activeWorktreeId
return
}
if (openedOnRef.current !== activeWorktreeId) {
closeCloudSetupPage()
}
}, [activeWorktreeId, request])
if (!request) {
return null
}
// Stays mounted under other pages so an open browser trip keeps its session and waiting state.
return (
<div
className={onWorkspaceView ? 'absolute inset-0 z-20 flex bg-background' : 'hidden'}
aria-hidden={!onWorkspaceView}
>
<ActiveCloudSetupPage key={request.openedAt} request={request} />
</div>
)
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import type { CloudSetupStep } from '@/components/new-workspace/run-on/cloud-start-model'
import { actionForStep, cloudSetupPageModel, sessionNeedsFrom } from './cloud-setup-page-model'
import { effectiveAvailability } from './use-cloud-setup-flow'
describe('cloudSetupPageModel', () => {
it('walks one step at a time: the first unfinished step is current', () => {
const steps: CloudSetupStep[] = [
{ id: 'account', state: 'done' },
{ id: 'plan', state: 'needed', need: 'trial', canFix: true, trialSeconds: 7200 },
{ id: 'github', state: 'needed', need: 'github', canFix: true },
{ id: 'agent', state: 'optional' }
]
const model = cloudSetupPageModel(steps)
expect(model.current?.id).toBe('plan')
expect(model.action).toBe('start-trial')
expect(sessionNeedsFrom(steps)).toEqual(['plan', 'github'])
})
it('a skipped agent finishes setup', () => {
const steps: CloudSetupStep[] = [
{ id: 'account', state: 'done' },
{ id: 'plan', state: 'done' },
{ id: 'agent', state: 'optional' }
]
expect(cloudSetupPageModel(steps).action).toBe('agent')
expect(cloudSetupPageModel(steps, { agentSkipped: true })).toMatchObject({
current: null,
action: 'done'
})
})
it('maps each blocker to its one action', () => {
expect(actionForStep({ id: 'plan', state: 'pending', need: 'trial' })).toBe('sign-in')
expect(actionForStep({ id: 'plan', state: 'needed', need: 'payment', canFix: true })).toBe(
'fix-billing'
)
expect(actionForStep({ id: 'plan', state: 'needed', need: 'plan', canFix: false })).toBe(
'ask-owner'
)
expect(actionForStep({ id: 'team', state: 'needed' }, { teamSwitch: true })).toBe(
'switch-team'
)
expect(actionForStep({ id: 'github', state: 'loading' })).toBe('checking')
})
})
describe('effectiveAvailability', () => {
it('a sign-in that lands upgrades the snapshot from Start', () => {
expect(effectiveAvailability('needs-sign-in', { state: 'signed-out' })).toBe('needs-sign-in')
expect(effectiveAvailability('needs-sign-in', { state: 'loading' })).toBe('needs-sign-in')
expect(
effectiveAvailability('needs-sign-in', { state: 'entitled', remainingSeconds: 60 })
).toBe('offered')
expect(effectiveAvailability('offered', { state: 'signed-out' })).toBe('offered')
})
})
@@ -0,0 +1,137 @@
// The full-page Orca Cloud setup (composer-redo): which step is current, what its one action is,
// and when the saved prompt can start. Pure, so every state is unit-tested here.
import type { CloudSetupStep } from '@/components/new-workspace/run-on/cloud-start-model'
/** What the current step's primary button does. */
export type CloudSetupPageAction =
/** Sign in (and whatever the browser trip covers next): one browser hop. */
| 'sign-in'
/** The team's trial, claimed in the app: no browser, no card. */
| 'start-trial'
/** Stripe Checkout for a plan: one browser hop. */
| 'choose-plan'
/** Hours, spend cap or payment, fixed in billing settings or the portal. */
| 'fix-billing'
/** Personal account while a team has the plan: switch in place (B2). */
| 'switch-team'
/** Install the GitHub App on this repository: one browser hop. */
| 'github-install'
/** An owner must approve on GitHub; the page waits and starts on its own. */
| 'github-waiting'
/** The project isn't on GitHub; nothing here fixes it. */
| 'needs-repository'
/** A member can't fix billing; an owner or admin has to. */
| 'ask-owner'
/** Optional agent account: set it up once, or skip. */
| 'agent'
/** Still reading account, plan or repository state. */
| 'checking'
/** Every step is done: the prompt starts. */
| 'done'
export type CloudSetupPageModel = {
steps: readonly CloudSetupStep[]
current: CloudSetupStep | null
action: CloudSetupPageAction
}
function isFinished(step: CloudSetupStep, agentSkipped: boolean): boolean {
if (step.state === 'done') {
return true
}
if (step.id === 'agent') {
// Never wait on a slow agent-accounts read: it doesn't block a start.
return agentSkipped || step.state === 'loading'
}
return false
}
export function actionForStep(
step: CloudSetupStep,
{ teamSwitch = false }: { teamSwitch?: boolean } = {}
): CloudSetupPageAction {
if (step.state === 'loading') {
return 'checking'
}
switch (step.id) {
case 'account':
return 'sign-in'
case 'team':
return teamSwitch ? 'switch-team' : 'sign-in'
case 'plan':
if (step.state === 'pending') {
return 'sign-in'
}
if (step.canFix === false) {
return 'ask-owner'
}
switch (step.need) {
case 'trial':
return 'start-trial'
case 'hours':
case 'spend-cap':
case 'payment':
return 'fix-billing'
case 'plan':
case 'renew-plan':
case 'trial-used-up':
case 'sign-in':
case 'github':
case undefined:
return 'choose-plan'
}
return 'choose-plan'
case 'github':
if (step.state === 'failed' && step.blocksStart) {
return 'needs-repository'
}
if (step.state === 'pending' && step.blocksStart) {
return 'github-waiting'
}
return 'github-install'
case 'agent':
return 'agent'
}
}
/**
* The page's state from the setup rows. Steps come in walk order (account, team, plan, GitHub,
* agent); the first unfinished one is current. A signed-out plan row is "pending" until sign-in, so
* account stays current until then.
*/
export function cloudSetupPageModel(
steps: readonly CloudSetupStep[],
{ agentSkipped = false, teamSwitch = false }: { agentSkipped?: boolean; teamSwitch?: boolean } = {}
): CloudSetupPageModel {
const current = steps.find((step) => !isFinished(step, agentSkipped)) ?? null
if (!current) {
return { steps, current: null, action: 'done' }
}
return { steps, current, action: actionForStep(current, { teamSwitch }) }
}
/** Needs that a browser trip (setup session) can cover, from the current step on. */
export function sessionNeedsFrom(steps: readonly CloudSetupStep[]): CloudSetupStep['id'][] {
return steps
.filter((step) => step.state !== 'done' && step.state !== 'optional' && step.id !== 'agent')
.map((step) => step.id)
}
/**
* Pressing Start goes straight to create only when nothing is open. An agent without an account
* counts until the user skips it once; a still-loading agent row never holds a start.
*/
export function cloudSetupRequired(
steps: readonly CloudSetupStep[],
{ teamSwitch = false, agentSkipped = false }: { teamSwitch?: boolean; agentSkipped?: boolean } = {}
): boolean {
if (teamSwitch) {
return true
}
return steps.some((step) => {
if (step.id === 'agent') {
return step.state === 'optional' && !agentSkipped
}
return step.state !== 'done'
})
}
@@ -0,0 +1,200 @@
// The setup page's state and actions (composer-redo). It reuses the composer's setup plumbing: the
// rows from billing, the GitHub repository check and the agent accounts (useCloudStartSteps), the
// one-trip setup session (#255) with its deep-link return (#257), B2's team switch, and the
// tracker's error parsing (#260). Only the UI moved: from a card in the dialog to this page.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { claimOrcaCloudTrial } from '@/components/billing/claim-trial'
import {
invalidateOrcaCloudBilling,
prefetchOrcaCloudBilling,
useOrcaCloudRunTargetAccess
} from '@/components/new-workspace/orca-cloud-billing-cache'
import { setupActionFor } from '@/components/new-workspace/run-on/cloud-start-actions'
import type { CloudSetupStep } from '@/components/new-workspace/run-on/cloud-start-model'
import { useCloudStartSteps } from '@/components/new-workspace/run-on/use-cloud-start-steps'
import { useSwitchTeamSteps } from '@/components/new-workspace/cloud-setup/use-cloud-setup-tracker'
import { parseCloudSetupError } from '@/components/new-workspace/cloud-setup/cloud-setup-errors'
import {
canCopyCloudSetupLink,
copyCloudSetupLink,
reopenCloudSetup
} from '@/components/new-workspace/cloud-setup/cloud-setup-tracker-actions'
import { getCloudSetupStarter } from '@/lib/orca-cloud/cloud-setup-session-seam'
import {
isCloudSetupAgentSkipped,
rememberCloudSetupAgentSkipped,
type CloudSetupPageRequest
} from '@/lib/orca-cloud/cloud-setup-page-store'
import { reportCloudSetupProgress } from '@/lib/orca-cloud/cloud-setup-telemetry'
import type { OrcaCloudAvailability } from '../../../../shared/orca-yaml-hook-types'
import type { OrcaCloudRunTargetAccess } from '@/components/new-workspace/orca-cloud-run-target-access'
import { cloudSetupPageModel, sessionNeedsFrom, type CloudSetupPageModel } from './cloud-setup-page-model'
/** Billing rows re-read this often while the page waits on a browser step (focus also re-reads). */
const BILLING_POLL_MS = 4_000
/** Main's availability is a snapshot from Start; once billing answers for a signed-in account, it wins. */
export function effectiveAvailability(
requested: OrcaCloudAvailability,
access: OrcaCloudRunTargetAccess
): OrcaCloudAvailability {
if (requested === 'needs-sign-in' && access.state !== 'signed-out' && access.state !== 'loading') {
return 'offered'
}
return requested
}
export type CloudSetupFlow = CloudSetupPageModel & {
/** True from the click until the browser opens or the trial answers. */
busy: boolean
/** The browser trip is open: the current step reads "Waiting for your browser". */
inBrowser: boolean
error: ReturnType<typeof parseCloudSetupError>
canManageBilling: boolean
teamAction: React.ReactNode | null
continueInBrowser: () => void
startTrial: () => void
fixBilling: () => void
installGitHub: () => void
skipAgent: () => void
checkAgain: () => void
copyLink: (() => void) | null
reopenBrowser: () => void
}
export function useCloudSetupFlow(
request: CloudSetupPageRequest,
/** Fixtures pin the account; the app reads billing. */
accessOverride?: OrcaCloudRunTargetAccess
): CloudSetupFlow {
const fetched = useOrcaCloudRunTargetAccess(!accessOverride)
const access = accessOverride ?? fetched
const availability = effectiveAvailability(request.availability, access)
const [sessionId, setSessionId] = useState<string | null>(null)
const [inBrowser, setInBrowser] = useState(false)
const [busy, setBusy] = useState(false)
const [agentSkipped, setAgentSkipped] = useState(() => isCloudSetupAgentSkipped(request.agent))
const githubOverride = useMemo<CloudSetupStep | null>(
() => (request.cloudOnlyRepo ? { id: 'github', state: 'done' } : null),
[request.cloudOnlyRepo]
)
const { steps: rowSteps, session, openInstall } = useCloudStartSteps({
availability,
access,
repoId: request.repoId,
agent: request.agent,
cloudSelected: true,
setupSessionId: sessionId,
githubOverride
})
const { steps, held: switchTeam } = useSwitchTeamSteps(rowSteps, !session)
const model = cloudSetupPageModel(steps, { agentSkipped, teamSwitch: switchTeam !== null })
const error = useMemo(() => {
const parsed = parseCloudSetupError(session?.error)
return parsed && session?.teamName ? { ...parsed, teamName: session.teamName } : parsed
}, [session?.error, session?.teamName])
// Rows derived from billing tick on their own: re-read while a step is waiting elsewhere.
const waiting = model.action !== 'done' && model.action !== 'agent'
useEffect(() => {
if (!waiting || accessOverride) {
return
}
const timer = window.setInterval(() => void prefetchOrcaCloudBilling(true), BILLING_POLL_MS)
return () => window.clearInterval(timer)
}, [accessOverride, waiting])
useEffect(() => {
if (sessionId) {
reportCloudSetupProgress(sessionId, steps, error?.kind ?? null, { afterTrial: false })
}
}, [error?.kind, sessionId, steps])
const stepsRef = useRef(steps)
useEffect(() => {
stepsRef.current = steps
}, [steps])
const fallback = useCallback(
() => setupActionFor(stepsRef.current, access, availability)?.(),
[access, availability]
)
// One browser trip for everything still open from here (sign-in, team, trial or Checkout, GitHub).
const continueInBrowser = useCallback(() => {
const needs = sessionNeedsFrom(stepsRef.current)
if (needs.length > 0 && needs.every((need) => need === 'github')) {
setInBrowser(true)
void openInstall()
return
}
const start = getCloudSetupStarter()
if (!start) {
setInBrowser(true)
fallback()
return
}
setBusy(true)
void start({ repoId: request.repoId, needs })
.then((opened) => {
setInBrowser(true)
if (opened) {
setSessionId(opened.sessionId)
} else {
fallback()
}
})
.catch(() => fallback())
.finally(() => setBusy(false))
}, [fallback, openInstall, request.repoId])
const startTrial = useCallback(() => {
setBusy(true)
void claimOrcaCloudTrial()
.then((result) => {
if (result.status !== 'ok') {
// A personal account needs a team first, or the trial is gone: the browser covers it.
continueInBrowser()
}
})
.finally(() => setBusy(false))
}, [continueInBrowser])
const fixBilling = useCallback(() => {
setInBrowser(true)
invalidateOrcaCloudBilling({ refetchNow: false })
fallback()
}, [fallback])
const installGitHub = useCallback(() => {
setInBrowser(true)
void openInstall()
}, [openInstall])
const skipAgent = useCallback(() => {
rememberCloudSetupAgentSkipped(request.agent)
setAgentSkipped(true)
}, [request.agent])
const checkAgain = useCallback(() => void prefetchOrcaCloudBilling(true), [])
return {
...model,
busy,
inBrowser,
error,
canManageBilling: access.state === 'no-plan' ? access.canManageBilling : true,
teamAction: switchTeam?.action ?? null,
continueInBrowser,
startTrial,
fixBilling,
installGitHub,
skipAgent,
checkAgain,
copyLink:
sessionId && canCopyCloudSetupLink(sessionId)
? () => void copyCloudSetupLink(sessionId)
: null,
reopenBrowser: () => void reopenCloudSetup(sessionId, continueInBrowser)
}
}
@@ -0,0 +1,80 @@
// The full-page "Set up Orca Cloud" view (composer-redo): Start in the composer closes the dialog
// and opens this page when the account isn't ready yet. The page walks the missing steps, then
// starts the saved prompt. One request at a time; a new Start replaces an older one.
import { useSyncExternalStore } from 'react'
import type { OrcaCloudAvailability } from '../../../../shared/orca-yaml-hook-types'
import type { ComposerModalData } from '@/components/new-workspace/composer-modal-data'
export type CloudSetupPageRequest = {
prompt: string
repoId: string
agent: string | null
/** What main reported when Start was pressed; the page upgrades it once sign-in lands. */
availability: OrcaCloudAvailability
/** D4: a GitHub repository with no clone here; the start goes through the composer visibly. */
cloudOnlyRepo: string | null
/** The composer as it was: Back and the auto-start reopen it with everything kept. */
composer: ComposerModalData
openedAt: number
}
let current: CloudSetupPageRequest | null = null
const listeners = new Set<() => void>()
function emit(): void {
for (const listener of listeners) {
listener()
}
}
export function openCloudSetupPage(request: Omit<CloudSetupPageRequest, 'openedAt'>): void {
current = { ...request, openedAt: Date.now() }
emit()
}
export function closeCloudSetupPage(): void {
if (current === null) {
return
}
current = null
emit()
}
export function getCloudSetupPageRequest(): CloudSetupPageRequest | null {
return current
}
export function useCloudSetupPageRequest(): CloudSetupPageRequest | null {
return useSyncExternalStore(
(listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
() => current
)
}
const AGENT_SKIP_KEY = 'orca.cloudSetup.agentSkipped.v1'
/** "Skip for now" on the agent step is remembered, so a ready account isn't asked on every Start. */
export function isCloudSetupAgentSkipped(agent: string | null): boolean {
if (!agent || typeof localStorage === 'undefined') {
return false
}
try {
return localStorage.getItem(`${AGENT_SKIP_KEY}:${agent}`) === '1'
} catch {
return false
}
}
export function rememberCloudSetupAgentSkipped(agent: string | null): void {
if (!agent || typeof localStorage === 'undefined') {
return
}
try {
localStorage.setItem(`${AGENT_SKIP_KEY}:${agent}`, '1')
} catch {
// Private mode: the step asks again next time.
}
}