mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
wip(cloud): composer-redo — Project | Run on row, setup moves to a full page after Start
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
-207
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -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
-1
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user