diff --git a/src/renderer/src/app-shell/AppWorkspaceShell.tsx b/src/renderer/src/app-shell/AppWorkspaceShell.tsx index df5297cb06d..cddedd2a414 100644 --- a/src/renderer/src/app-shell/AppWorkspaceShell.tsx +++ b/src/renderer/src/app-shell/AppWorkspaceShell.tsx @@ -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: { + {floatingWorkspace.showToggleButton && !capturingPluginShot ? ( expect(create.hasAttribute('disabled')).toBe(false)) fireEvent.click(create) await waitFor(() => expect(evidence.submit).toHaveBeenCalledOnce()) diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 18d17cd45f9..5e5f9c61cba 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -39,6 +39,7 @@ import { getFolderWorkspacePrimaryActionLabel } from '@/components/sidebar/works import { useAgentMenuObservation } from '@/components/tab-bar/use-new-tab-agent-menu-rows' import { addAppKeyListener, removeAppKeyListener } from '@/lib/keyboard/app-keyboard-router' import { useComposerCloudStart } from './new-workspace/run-on/use-composer-cloud-start' +import { HiddenComposer, useCloudAutoStartReveal } from './new-workspace/composer-cloud-auto-start' // Keep Add Project’s clone/SSH machinery outside the composer entry render, matching App loading. const HostedAddRepoDialog = lazyWithRetry( @@ -80,6 +81,18 @@ function ComposerModalBody({ onClose() }, [onClose]) const isSubmissionCancelled = useCallback(() => submitCancelledRef.current, []) + const visibleModalData = useCloudAutoStartReveal(modalData) + const body = ( + + ) + if (!visibleModalData) { + return {body} + } return ( !open && handleDismiss()}> @@ -98,12 +111,7 @@ function ComposerModalBody({ getWorkspaceComposerInitialFocusTarget(content)?.focus({ preventScroll: true }) }} > - + {body} ) @@ -203,14 +211,18 @@ function QuickTabBody({ [observation.contextKey, orcaCloud] ) + // Orca Cloud with an agent that has no account there starts a plain terminal, never the agent's + // /login: the cloud hook sets this around one create (read before the first await). + const noAgentRef = useRef(false) const handleCreate = useCallback(async (): Promise => { + const agent = noAgentRef.current ? null : quickAgent if (createDisabled) { return } - await submitQuick(quickAgent, { + await submitQuick(agent, { // Blank Terminal is the setup-only create: no agent, and no extra shell beside setup. - setupOnly: quickAgent === null, - launchChoice: launchSelection.choice, + setupOnly: agent === null, + ...(agent === null ? {} : { launchChoice: launchSelection.choice }), onLegacyPreparationFailure: (error) => { if (isSubmissionCancelled()) { return @@ -266,6 +278,8 @@ function QuickTabBody({ quickAgent, handleCreate, createDisabled, + quickAgentPick, + noAgentRef, recordCreatedRef, onClose }) @@ -304,14 +318,10 @@ 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, and it matches the title for every target (design-squad). + const primaryActionLabel = isFolderWorkspaceTarget + ? getFolderWorkspacePrimaryActionLabel(t) + : 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 diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerFooter.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerFooter.tsx index 1bc6b24bab0..c041625c6e2 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerFooter.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerFooter.tsx @@ -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({ ) : 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. */} -
+ {/* 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. */} +
{showCreateMultiple ? ( ) : null} -
- {cloudStart?.onUseThisComputer ? ( - - ) : null} - -
- {heldReason ? ( - - {heldReason} - ) : null} +
) diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx index 549cdbf3909..d7f1e67462b 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx @@ -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,26 +37,25 @@ type NewWorkspaceComposerProjectSectionProps = Pick< | 'ephemeralVmRecipeError' | 'orcaCloudAvailability' | 'cloudStart' -> & - Partial> & { - disabled?: boolean - projectDescriptionId: string - onAddProject: () => void - focusNameInput: () => void - shouldShowRunTargetPicker: boolean - projectHostSetupOptions: NewWorkspaceComposerCardProps['projectHostSetupOptions'] - ephemeralVmRecipes: EphemeralVmRecipeOption[] - handleProjectHostSetupChange: (setupId: string) => void - handleAddSshHost: () => void - handleAddRemoteServer: () => void - handleConnectRunTargetHost: (option: NeedsProjectHostOption) => Promise - handleSetLocation: (option: NeedsProjectHostOption) => void - sshStatusLabel: string - connectButtonLabel: string - selectedProjectName: string - /** Fixtures pin Orca Cloud entitlement; the app reads it from billing. */ - orcaCloudAccess?: OrcaCloudRunTargetAccess - } +> & { + disabled?: boolean + projectDescriptionId: string + onAddProject: () => void + focusNameInput: () => void + shouldShowRunTargetPicker: boolean + projectHostSetupOptions: NewWorkspaceComposerCardProps['projectHostSetupOptions'] + ephemeralVmRecipes: EphemeralVmRecipeOption[] + handleProjectHostSetupChange: (setupId: string) => void + handleAddSshHost: () => void + handleAddRemoteServer: () => void + handleConnectRunTargetHost: (option: NeedsProjectHostOption) => Promise + handleSetLocation: (option: NeedsProjectHostOption) => void + sshStatusLabel: string + connectButtonLabel: string + selectedProjectName: string + /** Fixtures pin Orca Cloud entitlement; the app reads it from billing. */ + orcaCloudAccess?: OrcaCloudRunTargetAccess +} export function NewWorkspaceComposerProjectSection({ disabled = false, @@ -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 (
- {/* Run on as segments needs the full width, so it sits under the project (spec E1). */} -
+ {/* 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). */} +
) : null}
- {cloudStart?.tracker && selectedEphemeralVmRecipeId === ORCA_CLOUD_RECIPE_ID ? ( - // A6: the browser trip is open; the same rows tick in place, with X1 errors. - - ) : cloudStart?.panelVisible && selectedEphemeralVmRecipeId === ORCA_CLOUD_RECIPE_ID ? ( - // The panel's own Agent row (G) comes from the same model as the standalone one below. - 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 ? ( - - ) : null} {!shouldShowRunTargetPicker && ephemeralVmRecipeError ? (

{ephemeralVmRecipeError}

) : null} diff --git a/src/renderer/src/components/new-workspace/OrcaCloudSelectionHint.tsx b/src/renderer/src/components/new-workspace/OrcaCloudSelectionHint.tsx deleted file mode 100644 index e8e371d777f..00000000000 --- a/src/renderer/src/components/new-workspace/OrcaCloudSelectionHint.tsx +++ /dev/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 ( - <> -

- {hoursLeft} - {canChoose ? ( - <> - · - - - ) : null} -

-

- {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.' - )} -

- - ) -} - -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' ? ( - - ) : null - return ( - // @container: the specs drop out whole when the column is narrow, instead of ellipsizing. -
- {visible && !block && !low ? ( - // Outside the live region: hour ticks on refresh are not re-announced. - <> -

- - - - {hoursLeft ? ( - // pl-1: a flex item trims its leading space, so the gap before "·" comes from padding. - - {'· '} - {hoursLeft} - - ) : null} -

-

- {/* 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' - } - )} -

- - ) : null} - {/* Always mounted, so a block that arrives after the pick (late billing, restored draft) is announced. */} -
- {visible && !block && low ? : null} - {visible && block ? ( - <> -

- {block.tone === 'error' ? ( - - ) : null} - {block.title} - {action ? · : null} - {action} -

- {block.detail ?

{block.detail}

: null} - - ) : null} -
-
- ) -} diff --git a/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx index b07abf4670e..a80779b6e57 100644 --- a/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx @@ -16,6 +16,7 @@ import { getRunTargetFieldText, getRecipeSubmenuItemKey, getRunTargetSubmenuItemKeys, + closingFirst, getRunTargetCurrentRowKey, getRunTargetOptionId, RUN_TARGET_ADD_HOST_KEY, @@ -50,19 +51,11 @@ type RunTargetComboboxProps = { orcaCloudAccess?: OrcaCloudRunTargetAccess /** From main: drives the sign-in/upsell row when there is no Orca Cloud recipe. */ orcaCloudAvailability?: OrcaCloudAvailability -} - -/** Add-host actions open dialogs, so the picker closes before they run. */ -function closingFirst( - close: () => void, - action: (() => void) | undefined -): (() => void) | undefined { - return action - ? () => { - close() - action() - } - : undefined + /** + * 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 } const ROOT_ATTRIBUTE = 'data-run-target-combobox-root' @@ -81,7 +74,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. @@ -173,7 +167,8 @@ export default function RunTargetCombobox({ override: orcaCloudAccessOverride, open, close, - selectRecipe + selectRecipe, + ...(onSelectOrcaCloud ? { onSelectOrcaCloud } : {}) }) const addSshHost = useMemo(() => closingFirst(close, onAddSshHost), [close, onAddSshHost]) @@ -332,7 +327,9 @@ export default function RunTargetCombobox({ onKeyDown={handleKeyDown} /> void connectHost(option)} onSetLocation={setLocation} /> diff --git a/src/renderer/src/components/new-workspace/RunTargetOrcaCloudRow.tsx b/src/renderer/src/components/new-workspace/RunTargetOrcaCloudRow.tsx index c3f574b3f7e..b23cd91a6c9 100644 --- a/src/renderer/src/components/new-workspace/RunTargetOrcaCloudRow.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetOrcaCloudRow.tsx @@ -270,6 +270,24 @@ function OrcaCloudMeta({ } } +/** + * In the composer, hours and plan details wait until after create (Neil): the row only keeps what + * explains why it can't be picked, "Ask an owner or admin". + */ +function showsMetaAfterStart(access: OrcaCloudRunTargetAccess): boolean { + switch (access.state) { + case 'no-plan': + case 'out-of-hours': + case 'payment-issue': + return !access.canManageBilling + case 'loading': + case 'entitled': + 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 +298,8 @@ export function RunTargetOrcaCloudRow({ optionId, onArm, onSelect, - onBlockedAction + onBlockedAction, + setupAfterStart = false }: { recipe: EphemeralVmRecipeOption | null access: OrcaCloudRunTargetAccess @@ -292,6 +311,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 +341,11 @@ export function RunTargetOrcaCloudRow({ onArm={onArm} onCommit={onSelect} description={orcaCloudRowHint(access, t)} - trailing={} + trailing={ + setupAfterStart && !showsMetaAfterStart(access) ? undefined : ( + + ) + } /> ) } diff --git a/src/renderer/src/components/new-workspace/RunTargetRowList.tsx b/src/renderer/src/components/new-workspace/RunTargetRowList.tsx index 89fe146ff91..b8c283ab812 100644 --- a/src/renderer/src/components/new-workspace/RunTargetRowList.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetRowList.tsx @@ -34,6 +34,7 @@ export default function RunTargetRowList({ onSelectRecipe, onSelectOrcaCloud, onOrcaCloudBlockedAction, + orcaCloudSetupAfterStart = false, onConnectHost, onSetLocation }: { @@ -61,6 +62,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 { @@ -101,6 +104,7 @@ export default function RunTargetRowList({ onArm={() => onArm(row.key, null)} onSelect={() => onSelectOrcaCloud(row.recipe)} onBlockedAction={onOrcaCloudBlockedAction} + setupAfterStart={orcaCloudSetupAfterStart} /> ) } diff --git a/src/renderer/src/components/new-workspace/cloud-setup/CloudSetupBrowserTracker.fixture.tsx b/src/renderer/src/components/new-workspace/cloud-setup/CloudSetupBrowserTracker.fixture.tsx deleted file mode 100644 index b62a4e322e6..00000000000 --- a/src/renderer/src/components/new-workspace/cloud-setup/CloudSetupBrowserTracker.fixture.tsx +++ /dev/null @@ -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 ( -
- -
- ) -} - -export const trackerWaitingGithub = defineFixture({ width: 560, render: () => }) -export const trackerSigningIn = defineFixture({ - width: 560, - render: () => -}) -export const trackerDerivedRows = defineFixture({ - width: 560, - render: () => -}) - -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: () => -}) -export const trackerCheckoutCanceled = defineFixture({ - width: 560, - render: () => -}) -export const trackerBankStep = defineFixture({ - width: 560, - render: () => -}) -export const trackerTrialUnavailable = defineFixture({ - width: 560, - render: () => -}) -export const trackerTrialUnavailableMember = defineFixture({ - width: 560, - render: () => ( - - ) -}) -export const trackerTeamCreateFailed = defineFixture({ - width: 560, - render: () => ( - - ) -}) -export const trackerWrongAccount = defineFixture({ - width: 560, - render: () => ( - - ) -}) - -// X1: every setup error notice, as in the spec's sheet. -export const x1SetupErrors = defineFixture({ - width: 560, - render: () => ( -
- {( - [ - { 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) => ( - - ))} - -
- ) -}) - -// B1: an owner whose team never had a trial: the trial is the first plan, no trip. -export const b1OwnerTrialAvailable = defineFixture({ - width: 560, - render: () => ( -
- -
- ) -}) - -function SwitchTeamPanel({ teams }: { teams: PlannedTeam[] }): React.JSX.Element { - const b2 = useCloudSetupSwitchTeamFor(teams, noop) - return ( -
- -
- ) -} - -// B2: personal account, but a team the user is on has a plan: switch instead of buying. -export const b2SwitchTeam = defineFixture({ - width: 560, - render: () => -}) -export const b2SwitchTeamMenu = defineFixture({ - width: 560, - render: () => ( - - ) -}) diff --git a/src/renderer/src/components/new-workspace/cloud-setup/CloudSetupBrowserTracker.tsx b/src/renderer/src/components/new-workspace/cloud-setup/CloudSetupBrowserTracker.tsx deleted file mode 100644 index 8b053dca6c1..00000000000 --- a/src/renderer/src/components/new-workspace/cloud-setup/CloudSetupBrowserTracker.tsx +++ /dev/null @@ -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> - /** 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 ( - - {error ? ( - - ) : null} -
- - - {onCopyLink ? ( - - ) : null} - {reopenInError ? null : ( - - )} - -
-
- ) -} diff --git a/src/renderer/src/components/new-workspace/cloud-setup/cloud-setup-tracker-actions.ts b/src/renderer/src/components/new-workspace/cloud-setup/cloud-setup-tracker-actions.ts index b8ef4759f3e..07bbbdeb2a9 100644 --- a/src/renderer/src/components/new-workspace/cloud-setup/cloud-setup-tracker-actions.ts +++ b/src/renderer/src/components/new-workspace/cloud-setup/cloud-setup-tracker-actions.ts @@ -20,8 +20,8 @@ async function freshLink(sessionId: string): Promise { function linkUnavailable(): void { toast.error( translate( - 'components.newWorkspace.cloudSetup.linkUnavailable', - 'Couldn’t get a setup link. Try Set up and start again.' + 'components.newWorkspace.cloudSetup.linkUnavailableRetry', + 'Couldn’t get a setup link. Try Continue in browser again.' ) ) } diff --git a/src/renderer/src/components/new-workspace/cloud-setup/cloud-setup-tracker.test.ts b/src/renderer/src/components/new-workspace/cloud-setup/cloud-setup-tracker.test.ts index a169c440a3e..913f163430c 100644 --- a/src/renderer/src/components/new-workspace/cloud-setup/cloud-setup-tracker.test.ts +++ b/src/renderer/src/components/new-workspace/cloud-setup/cloud-setup-tracker.test.ts @@ -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.' } diff --git a/src/renderer/src/components/new-workspace/cloud-setup/use-cloud-setup-tracker.ts b/src/renderer/src/components/new-workspace/cloud-setup/use-cloud-setup-tracker.ts index a4b64b924a5..7db939a3c06 100644 --- a/src/renderer/src/components/new-workspace/cloud-setup/use-cloud-setup-tracker.ts +++ b/src/renderer/src/components/new-workspace/cloud-setup/use-cloud-setup-tracker.ts @@ -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 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 "). */ - rowActions?: Partial> - /** 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 } : {}) - } -} diff --git a/src/renderer/src/components/new-workspace/composer-cloud-auto-start.tsx b/src/renderer/src/components/new-workspace/composer-cloud-auto-start.tsx new file mode 100644 index 00000000000..c327e43e5c7 --- /dev/null +++ b/src/renderer/src/components/new-workspace/composer-cloud-auto-start.tsx @@ -0,0 +1,61 @@ +// The composer's side of the Orca Cloud setup page (composer-redo): the snapshot the page keeps for +// Back and for its auto-start, and the hidden composer that submits once setup is done. +import React, { useEffect, useState } from 'react' +import { Dialog } from '@/components/ui/dialog' +import type { ComposerModalData } from './composer-modal-data' + +/** How long a hidden Orca Cloud auto-start may take before the dialog shows instead. */ +const CLOUD_AUTO_START_REVEAL_MS = 8_000 + +/** The composer as it is now: prompt, project, base branch, linked item and agent pick. */ +export function buildComposerSnapshot( + modalData: ComposerModalData, + card: { + name: string + repoId: string + baseBranch?: string + linkedWorkItem?: ComposerModalData['linkedWorkItem'] + note: string + }, + agentPick: Omit, 'note'> +): ComposerModalData { + return { + ...modalData, + prefilledName: card.name, + initialRepoId: card.repoId, + ...(card.baseBranch ? { initialBaseBranch: card.baseBranch } : {}), + linkedWorkItem: card.linkedWorkItem ?? null, + recovery: { ...agentPick, note: card.note } + } +} + +/** + * Setup finished on its page: the composer submits with the dialog unseen. If the start can't run + * by itself in time (a form error, a revoked grant), the dialog appears with everything kept. + * Returns the modal data to render once visible (no second auto-start), or null while hidden. + */ +export function useCloudAutoStartReveal(modalData: ComposerModalData): ComposerModalData | null { + const [revealed, setRevealed] = useState(false) + useEffect(() => { + if (!modalData.cloudAutoStart) { + return + } + const timer = window.setTimeout(() => setRevealed(true), CLOUD_AUTO_START_REVEAL_MS) + return () => window.clearTimeout(timer) + }, [modalData.cloudAutoStart]) + if (!modalData.cloudAutoStart) { + return modalData + } + return revealed ? { ...modalData, cloudAutoStart: false } : null +} + +/** A closed Root still gives the title its context; nothing is portaled or shown. */ +export function HiddenComposer({ children }: { children: React.ReactNode }): React.JSX.Element { + return ( + +
+ {children} +
+
+ ) +} diff --git a/src/renderer/src/components/new-workspace/composer-modal-data.ts b/src/renderer/src/components/new-workspace/composer-modal-data.ts index 482ac94c762..0a2a0f21224 100644 --- a/src/renderer/src/components/new-workspace/composer-modal-data.ts +++ b/src/renderer/src/components/new-workspace/composer-modal-data.ts @@ -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 } diff --git a/src/renderer/src/components/new-workspace/run-on/CloudStart.fixture.tsx b/src/renderer/src/components/new-workspace/run-on/CloudStart.fixture.tsx deleted file mode 100644 index 177660c20a0..00000000000 --- a/src/renderer/src/components/new-workspace/run-on/CloudStart.fixture.tsx +++ /dev/null @@ -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: () => -}) -/** E1 with "⋯" open: today's host picker without the Orca Cloud row. */ -export const e1MorePlaces = defineFixture({ - ...e1RunOnDefault, - interact: (frame) => { - frame.querySelector('[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: () => ( - - ) -}) -/** B1: signed in, the team can still get its trial. */ -export const b1TrialAvailable = defineFixture({ - width: 560, - setup, - render: () => ( - - ) -}) -/** A6: Finish in your browser; each step ticks in place. */ -export const a6FinishInBrowser = defineFixture({ - width: 560, - setup, - render: () => ( - - ) -}) -/** A7: everything done; "All set", then the composer starts by itself. */ -export const a7AllSet = defineFixture({ - width: 560, - setup, - render: () => ( - - ) -}) -/** T1: trial hours in the composer hint. */ -export const t1TrialHint = defineFixture({ - width: 560, - setup, - render: () => -}) -/** T1 under half an hour: warning, with Choose plan for owners. */ -export const t1TrialLow = defineFixture({ - width: 560, - setup, - render: () => -}) -/** T1 low, member: who can choose a plan. */ -export const t1TrialLowMember = defineFixture({ - width: 560, - setup, - render: () => -}) -/** T2: the trial is used up at New workspace; the prompt is kept. */ -export const t2TrialUsedUp = defineFixture({ - width: 560, - setup, - render: () => ( - - ) -}) -/** C1 with the trial used up: a member can't buy, so ⌘↵ runs on this computer. */ -export const t2TrialUsedUpMember = defineFixture({ - width: 560, - setup, - render: () => ( - - ) -}) -/** D1: GitHub checked up front; "Give access and start". */ -export const d1GitHubNeeded = defineFixture({ - width: 560, - setup, - render: () => ( - - ) -}) -/** D2: waiting for an owner of the GitHub org; ⌘↵ runs on this computer. */ -export const d2GitHubRequested = defineFixture({ - width: 560, - setup, - render: () => ( - - ) -}) -/** D3: the project isn't on GitHub; outranks account and plan rows. */ -export const d3NotOnGitHub = defineFixture({ - width: 560, - setup, - render: () => ( - {} } - }} - /> - ) -}) -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: () => ( - - ), - interact: async (frame) => { - frame.querySelector('[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: () => ( - - ) -}) -/** D3 for a folder: Orca Cloud stays selectable; the panel offers a GitHub repository instead. */ -export const d3FolderProject = defineFixture({ - width: 560, - setup, - render: () => ( - - ) -}) -/** 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: () => ( - - ) -}) -/** X2: Orca Cloud can't be reached; the segment stays, disabled, with the reason. */ -export const x2Unreachable = defineFixture({ - width: 560, - setup, - render: () => ( - - ), - 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)) - } -}) diff --git a/src/renderer/src/components/new-workspace/run-on/CloudStartAgentRow.fixture.tsx b/src/renderer/src/components/new-workspace/run-on/CloudStartAgentRow.fixture.tsx deleted file mode 100644 index 35823b4b3f3..00000000000 --- a/src/renderer/src/components/new-workspace/run-on/CloudStartAgentRow.fixture.tsx +++ /dev/null @@ -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 ( -
- -
- ) - } - }) -} - -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' }) diff --git a/src/renderer/src/components/new-workspace/run-on/CloudStartBilling.fixture.tsx b/src/renderer/src/components/new-workspace/run-on/CloudStartBilling.fixture.tsx deleted file mode 100644 index b56940016c5..00000000000 --- a/src/renderer/src/components/new-workspace/run-on/CloudStartBilling.fixture.tsx +++ /dev/null @@ -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: () => - }) -} - -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 }) diff --git a/src/renderer/src/components/new-workspace/run-on/CloudStartModalWidth.fixture.tsx b/src/renderer/src/components/new-workspace/run-on/CloudStartModalWidth.fixture.tsx deleted file mode 100644 index c0f0b899ede..00000000000 --- a/src/renderer/src/components/new-workspace/run-on/CloudStartModalWidth.fixture.tsx +++ /dev/null @@ -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: () => ( - - ) -}) - -/** 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: () => ( - - ) -}) - -/** 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: () => ( - - ) -}) - -/** A setup session after Checkout: Team plan done with no raw "subscription" under it. */ -export const sessionPlanDone = defineFixture({ - width: CARD_WIDTH + 40, - setup, - render: () => ( - - ) -}) - -/** 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: () => ( - - ) -}) diff --git a/src/renderer/src/components/new-workspace/run-on/FolderGitHubActions.tsx b/src/renderer/src/components/new-workspace/run-on/FolderGitHubActions.tsx deleted file mode 100644 index d87118dd03c..00000000000 --- a/src/renderer/src/components/new-workspace/run-on/FolderGitHubActions.tsx +++ /dev/null @@ -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 ( - - {canChoose ? ( - - ) : null} - - - ) -} diff --git a/src/renderer/src/components/new-workspace/run-on/OrcaCloudSetupPanel.tsx b/src/renderer/src/components/new-workspace/run-on/OrcaCloudSetupPanel.tsx deleted file mode 100644 index c57a8a6e4eb..00000000000 --- a/src/renderer/src/components/new-workspace/run-on/OrcaCloudSetupPanel.tsx +++ /dev/null @@ -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 - } - if (step.state === 'active') { - // The browser is working on this step; static under reduced motion, the label carries the state. - return ( - - ) - } - if (step.state === 'needed' || step.state === 'failed') { - return - } - return -} - -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> - className?: string -}): React.JSX.Element { - const t = useTranslate() - const titleId = React.useId() - return ( -
-

- {allSet - ? t('components.newWorkspace.cloudSetup.allSet', 'All set') - : (title ?? t('components.newWorkspace.cloudSetup.title', 'Set up Orca Cloud'))} -

-

- {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.' - )))} -

- {/* One polite announcement per row change ("Team plan, done"). */} -
    - {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 ( -
  • - - - - - {title} - {step.state === 'loading' ? ( - - ) : detail ? ( - {detail} - ) : null} - {note ? ( - {note} - ) : null} - {step.action ? ( - // One shared in-row action ("Set up once", "How to push"): it must read as a button. - - ) : null} - {rowActions?.[step.id] ?? null} - - - {`${title}, `} - {state} - -
  • - ) - })} -
- {children} -
- ) -} diff --git a/src/renderer/src/components/new-workspace/run-on/RunOnHintLine.tsx b/src/renderer/src/components/new-workspace/run-on/RunOnHintLine.tsx deleted file mode 100644 index 9c3b64cc2ab..00000000000 --- a/src/renderer/src/components/new-workspace/run-on/RunOnHintLine.tsx +++ /dev/null @@ -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 ( -
- {where ?

{where}

: null} - {orcaCloudAvailability === 'unreachable' ? ( -

- {t( - 'components.newWorkspace.runOn.cloudUnreachableReason', - "Orca Cloud: can't reach it right now. Check your connection." - )} -

- ) : null} -
- ) -} diff --git a/src/renderer/src/components/new-workspace/run-on/RunOnSegmentedControl.tsx b/src/renderer/src/components/new-workspace/run-on/RunOnSegmentedControl.tsx deleted file mode 100644 index d4bf68717da..00000000000 --- a/src/renderer/src/components/new-workspace/run-on/RunOnSegmentedControl.tsx +++ /dev/null @@ -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 - -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: ( - - - {t('components.newWorkspace.runOn.thisComputer', 'This computer')} - - ), - 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: ( - - - {t('components.newWorkspace.runOn.orcaCloud', 'Orca Cloud')} - - ), - 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 ? ( - {otherLabel} - ) : ( - - ), - 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 ( - - -
- - 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) - } - }} - /> -
-
- - { - onUserPick?.() - onRecipeChange?.(null) - onHostChange?.(setupId) - setMoreOpen(false) - }} - onRecipeChange={(recipeId) => { - onUserPick?.() - onRecipeChange?.(recipeId) - setMoreOpen(false) - }} - /> - -
- ) -} diff --git a/src/renderer/src/components/new-workspace/run-on/OrcaCloudSetupPanel.test.tsx b/src/renderer/src/components/new-workspace/run-on/cloud-setup-session-copy.test.ts similarity index 94% rename from src/renderer/src/components/new-workspace/run-on/OrcaCloudSetupPanel.test.tsx rename to src/renderer/src/components/new-workspace/run-on/cloud-setup-session-copy.test.ts index c36929e963a..7b424cd31b5 100644 --- a/src/renderer/src/components/new-workspace/run-on/OrcaCloudSetupPanel.test.tsx +++ b/src/renderer/src/components/new-workspace/run-on/cloud-setup-session-copy.test.ts @@ -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". diff --git a/src/renderer/src/components/new-workspace/run-on/cloud-setup-session-copy.ts b/src/renderer/src/components/new-workspace/run-on/cloud-setup-session-copy.ts new file mode 100644 index 00000000000..2ad45b29562 --- /dev/null +++ b/src/renderer/src/components/new-workspace/run-on/cloud-setup-session-copy.ts @@ -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 +} diff --git a/src/renderer/src/components/new-workspace/run-on/cloud-start-actions.ts b/src/renderer/src/components/new-workspace/run-on/cloud-start-actions.ts index c2781995838..054aaaa6ced 100644 --- a/src/renderer/src/components/new-workspace/run-on/cloud-start-actions.ts +++ b/src/renderer/src/components/new-workspace/run-on/cloud-start-actions.ts @@ -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 } } - ) -} diff --git a/src/renderer/src/components/new-workspace/run-on/cloud-start-model.test.ts b/src/renderer/src/components/new-workspace/run-on/cloud-start-model.test.ts index d5d4fe9bada..7cb5e31ca4b 100644 --- a/src/renderer/src/components/new-workspace/run-on/cloud-start-model.test.ts +++ b/src/renderer/src/components/new-workspace/run-on/cloud-start-model.test.ts @@ -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[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,36 @@ 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) }) }) @@ -266,7 +264,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 +272,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) - }) -}) diff --git a/src/renderer/src/components/new-workspace/run-on/cloud-start-model.ts b/src/renderer/src/components/new-workspace/run-on/cloud-start-model.ts index 4ea27621248..22137588ce5 100644 --- a/src/renderer/src/components/new-workspace/run-on/cloud-start-model.ts +++ b/src/renderer/src/components/new-workspace/run-on/cloud-start-model.ts @@ -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' /** diff --git a/src/renderer/src/components/new-workspace/run-on/cloud-start-preview-fixture.tsx b/src/renderer/src/components/new-workspace/run-on/cloud-start-preview-fixture.tsx deleted file mode 100644 index 15445595c82..00000000000 --- a/src/renderer/src/components/new-workspace/run-on/cloud-start-preview-fixture.tsx +++ /dev/null @@ -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(pickedCloudRepo) - const [host, setHost] = useState('local') - const [recipe, setRecipe] = useState(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 ( -
-

Create workspace

-

{prompt}

- {}} - 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'} - /> - {}} - createDisabled={allSet || action === 'checking'} - creating={allSet} - primaryActionLabel={ - selected ? getCloudPrimaryLabel(allSet ? 'start' : action) : 'Create worktree' - } - submitShortcutModifierLabel="⌘" - cloudStart={cloudStart} - showCreateMultiple={showCreateMultiple} - /> -
- ) -} diff --git a/src/renderer/src/components/new-workspace/run-on/cloud-start-view.ts b/src/renderer/src/components/new-workspace/run-on/cloud-start-view.ts index d2f656c4f3a..36193ccbb95 100644 --- a/src/renderer/src/components/new-workspace/run-on/cloud-start-view.ts +++ b/src/renderer/src/components/new-workspace/run-on/cloud-start-view.ts @@ -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). */ diff --git a/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts b/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts index 8f5ae3564e7..28cc960bfd6 100644 --- a/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts +++ b/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts @@ -1,60 +1,81 @@ -// 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' +import { openOrcaCloudAgentAccounts } from '../OrcaCloudAgentSetupRow' +import { agentName } from '../../settings/agent-accounts/orca-cloud-accounts-copy' +import { isOrcaCloudAccountAgent } from '../../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' export type { CloudStartView } from './cloud-start-view' +/** The workspace started without its agent: one clear way to set the agent up for next time. */ +function showAgentNotStartedToast(agent: string | null): void { + const name = isOrcaCloudAccountAgent(agent) ? agentName(agent, translate) : null + toast.message( + name + ? translate( + 'components.newWorkspace.cloudStart.agentNotStarted', + '{{agent}} isn’t set up for Orca Cloud, so it didn’t start. The workspace has a terminal.', + { agent: name } + ) + : translate( + 'components.newWorkspace.cloudStart.agentNotStartedGeneric', + 'Your agent isn’t set up for Orca Cloud, so it didn’t start. The workspace has a terminal.' + ), + { + action: { + label: name + ? translate( + 'components.newWorkspace.cloudStart.setUpAgentOnce', + 'Set up {{agent}} once', + { + agent: name + } + ) + : translate('components.newWorkspace.cloudStart.setUpAgentOnceGeneric', 'Set up once'), + onClick: openOrcaCloudAgentAccounts + } + } + ) +} + export function useCloudStartComposer({ active, availability, selectedRecipeId, setRecipeId, - hostOptions, - onHostChange, repoId, prompt, agent, initialTargetFixed, + autoStart = false, + createReady = true, + composerSnapshot, onCreate: createWorkspace, + onCreateWithoutAgent, onClose, accessOverride }: { @@ -62,26 +83,28 @@ 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). */ + /** The same create with no agent launched (the agent has no Orca Cloud account yet). */ + onCreateWithoutAgent?: () => void + /** 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 } { @@ -96,66 +119,46 @@ export function useCloudStartComposer({ cloudOffered: segment === 'enabled', signedIn: availability !== 'needs-sign-in' && access.state !== 'signed-out' }) - const onCreate = useCloudOnlyCreate({ - cloudOnly, - createWorkspace, - prompt, - agent, - profileId, - hoursToast: getOrcaCloudStartToast(access, translate, navigator.language), - onClose - }) - const [startedSessionId, setSetupSessionId] = useState(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 }) - // 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 { steps, held: switchTeam } = useSwitchTeamSteps(rowSteps, cloudSelected) + const setupNeeded = cloudSetupRequired(steps, { + teamSwitch: switchTeam !== null, + agentSkipped: isCloudSetupAgentSkipped(agent) + }) + // The agent has no Orca Cloud account (the user skipped setting it up): never launch it into its + // /login. Start a plain terminal and say how to set the agent up once. + const agentUnset = + cloudSelected && steps.some((step) => step.id === 'agent' && step.state === 'optional') + const onCreateRaw = useCloudOnlyCreate({ + cloudOnly, + createWorkspace, + prompt, + agent: agentUnset ? null : agent, + profileId, + hoursToast: getOrcaCloudStartToast(access, translate, navigator.language), + onClose + }) + const onCreate = useCallback(() => { + if (!agentUnset) { + onCreateRaw() + return + } + // A GitHub-only start already leaves the agent out (above); a local project's create skips it. + if (cloudOnly.picked || !onCreateWithoutAgent) { + onCreateRaw() + } else { + onCreateWithoutAgent() + } + showAgentNotStartedToast(agent) + }, [agent, agentUnset, cloudOnly.picked, onCreateRaw, onCreateWithoutAgent]) const { markTouched } = useRunOnDefault({ active, @@ -172,95 +175,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 +227,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( + () => + 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 } } diff --git a/src/renderer/src/components/new-workspace/run-on/use-composer-cloud-start.ts b/src/renderer/src/components/new-workspace/run-on/use-composer-cloud-start.ts index b4d3b564034..fd9ec93edc9 100644 --- a/src/renderer/src/components/new-workspace/run-on/use-composer-cloud-start.ts +++ b/src/renderer/src/components/new-workspace/run-on/use-composer-cloud-start.ts @@ -1,20 +1,22 @@ -// 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' import { useCloudStartComposer } from './use-cloud-start-composer' +import { buildComposerSnapshot } from '../composer-cloud-auto-start' +import type { TuiAgent } from '../../../../../shared/agent-launch/catalog/tui-agent' type ComposerCardState = Pick< NewWorkspaceComposerCardProps, | 'orcaCloudAvailability' | 'selectedEphemeralVmRecipeId' | 'onEphemeralVmRecipeChange' - | 'projectHostSetupOptions' - | 'onProjectHostSetupChange' | 'repoId' | 'name' -> + | 'baseBranch' + | 'note' +> & { linkedWorkItem?: ComposerModalData['linkedWorkItem'] } export function useComposerCloudStart({ active, @@ -23,15 +25,21 @@ export function useComposerCloudStart({ quickAgent, handleCreate, createDisabled, + quickAgentPick, + noAgentRef, recordCreatedRef, onClose }: { active: boolean modalData: ComposerModalData cardProps: ComposerCardState - quickAgent: string | null + quickAgent: TuiAgent | null handleCreate: () => Promise + /** Set right before a create to start it with no agent. */ + noAgentRef: MutableRefObject createDisabled: boolean + /** The agent pick (surface, draft) as the composer keeps it, for the setup page's snapshot. */ + quickAgentPick: Omit, 'note'> /** The composer's onCreated calls this: only a start that happened is remembered. */ recordCreatedRef: MutableRefObject<(() => void) | null> onClose?: () => void @@ -39,31 +47,39 @@ export function useComposerCloudStart({ // The latest handleCreate, never a ref copied after render: a submit right after an edit must // see that edit. const runCreate = useCallback(() => void handleCreate(), [handleCreate]) + const runCreateWithoutAgent = useCallback(() => { + noAgentRef.current = true + void handleCreate() + }, [handleCreate, noAgentRef]) + const composerSnapshot = useCallback( + () => buildComposerSnapshot(modalData, cardProps, { ...quickAgentPick, agent: quickAgent }), + [cardProps, modalData, quickAgent, quickAgentPick] + ) const cloudStart = useCloudStartComposer({ active, 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, + onCreateWithoutAgent: runCreateWithoutAgent, 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 } } diff --git a/src/renderer/src/components/new-workspace/run-on/use-setup-auto-start.ts b/src/renderer/src/components/new-workspace/run-on/use-setup-auto-start.ts deleted file mode 100644 index bd3793ddef4..00000000000 --- a/src/renderer/src/components/new-workspace/run-on/use-setup-auto-start.ts +++ /dev/null @@ -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 -} diff --git a/src/renderer/src/components/new-workspace/run-target-options.ts b/src/renderer/src/components/new-workspace/run-target-options.ts index 9ed7fe869a5..c484b9d4b5c 100644 --- a/src/renderer/src/components/new-workspace/run-target-options.ts +++ b/src/renderer/src/components/new-workspace/run-target-options.ts @@ -308,3 +308,16 @@ export function buildRunTargetRows( ] return { rows, matchedRecipes, hiddenHostCount: hostRows.hiddenHostCount } } + +/** Add-host actions open dialogs, so the picker closes before they run. */ +export function closingFirst( + close: () => void, + action: (() => void) | undefined +): (() => void) | undefined { + return action + ? () => { + close() + action() + } + : undefined +} diff --git a/src/renderer/src/components/new-workspace/use-composer-quick-agent.ts b/src/renderer/src/components/new-workspace/use-composer-quick-agent.ts index 2178b08b831..e8d235303eb 100644 --- a/src/renderer/src/components/new-workspace/use-composer-quick-agent.ts +++ b/src/renderer/src/components/new-workspace/use-composer-quick-agent.ts @@ -1,7 +1,5 @@ import { useMemo } from 'react' import { useAppStore } from '@/store/index' -import { useTranslate } from '@/i18n/use-translate' -import { formatAgentTypeLabel } from '../../../../shared/agent-status/presentation/agent-type-label' import { agentChoicePending } from '@/lib/host-chat-program' import { useAgentsOfferedForLaunch } from '@/lib/host-chat-program-acquisition' import { @@ -11,7 +9,6 @@ import { } from '@/lib/quick-workspace-agent-selection' import { ORCA_CLOUD_AGENT_IDS, - orcaCloudAgentFallback, orcaCloudAgentMenuModel, pickOrcaCloudAgent } from '@/lib/orca-cloud/orca-cloud-agent-picker' @@ -38,7 +35,6 @@ export function useComposerQuickAgent( quickAgentOverride: TuiAgent | null | undefined, setQuickAgentOverride: (agent: TuiAgent | null | undefined) => void ) { - const t = useTranslate() // The picker rows' workspace when known, so the offered list and the rows read one host. const launchWorkspace = cardProps.agentRows?.target.workspace ?? composerLaunchWorkspace(cardProps) @@ -83,23 +79,8 @@ export function useComposerQuickAgent( const chipAgentIds = offeredAgentIds && new Set([...offeredAgentIds, ...(quickAgent && choicePending(quickAgent) ? [quickAgent] : [])]) - // Said, not silent: the agent the user wanted isn't on the image, so another one is selected. - const orcaCloudFallback = orcaCloud - ? orcaCloudAgentFallback({ - override: quickAgentOverride, - defaultAgent: settings?.defaultTuiAgent, - selected: quickAgent - }) - : null - const agentNotice = orcaCloudFallback - ? t( - 'components.newWorkspace.orcaCloudAgentFallback', - '{{agent}} isn’t available on Orca Cloud yet, so {{fallback}} is selected.', - { - agent: formatAgentTypeLabel(orcaCloudFallback.wanted), - fallback: formatAgentTypeLabel(orcaCloudFallback.selected) - } - ) - : undefined + // Orca Cloud adds nothing to the dialog (Neil): an agent it can't run falls back silently; the + // picker shows what will start. + const agentNotice: string | undefined = undefined return { orcaCloud, orcaCloudModel, quickAgent, chipAgentIds, agentNotice } } diff --git a/src/renderer/src/components/new-workspace/use-run-target-orca-cloud.ts b/src/renderer/src/components/new-workspace/use-run-target-orca-cloud.ts index 0de3f1d8ec9..291b1352c47 100644 --- a/src/renderer/src/components/new-workspace/use-run-target-orca-cloud.ts +++ b/src/renderer/src/components/new-workspace/use-run-target-orca-cloud.ts @@ -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( diff --git a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx new file mode 100644 index 00000000000..d9dabad6168 --- /dev/null +++ b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx @@ -0,0 +1,296 @@ +// "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 } 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 { currentStepCopy, stepLabel } from './cloud-setup-page-copy' +import type { CloudSetupFlow } from './use-cloud-setup-flow' +import { openOrcaCloudAgentAccounts } from '@/components/new-workspace/OrcaCloudAgentSetupRow' + +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 +} + +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 ( +
    + {steps.map((step, index) => { + const state: StepState = + allDone || index < currentIndex ? 'done' : index === currentIndex ? 'current' : 'todo' + const label = stepLabel(step, t) + return ( +
  1. + {index > 0 ? : null} + + {state === 'done' ? : null} + {state === 'current' ? ( + + ) : null} + + + {label} + + {state === 'done' + ? `, ${t('cloudSetupPage.stepDone', 'done')}` + : state === 'current' + ? `, ${t('cloudSetupPage.stepCurrent', 'current step')}` + : ''} + + +
  2. + ) + })} +
+ ) +} + +function BrowserWaiting({ flow }: { flow: CloudSetupFlow }): React.JSX.Element { + const t = useTranslate() + return ( +
+ + + {t('cloudSetupPage.waitingBrowser', 'Waiting for your browser…')} + + + {flow.copyLink ? ( + + ) : null} + + +
+ ) +} + +function StepActions({ flow }: { flow: CloudSetupFlow }): React.JSX.Element | null { + const t = useTranslate() + const browser = (label: string, onClick: () => void): React.JSX.Element => ( + + ) + 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 + } + 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': + // A failed payment is fixed in the billing portal (browser); hours and the cap in Settings. + return flow.current?.need === 'payment' ? ( + browser(continueLabel, flow.fixBilling) + ) : ( + + ) + case 'github-install': + return browser(continueLabel, flow.installGitHub) + case 'start-trial': + return ( + + ) + case 'switch-team': + return
{flow.teamAction}
+ case 'agent': + return ( +
+ + +
+ ) + case 'needs-repository': + return flow.current?.action ? ( + + ) : null + case 'ask-owner': + return ( + + ) + case 'github-waiting': + return ( + + + {t('cloudSetupPage.github.waiting', 'This page starts the workspace once they approve.')} + + ) + case 'checking': + return + case 'done': + return ( + + + {t('cloudSetupPage.starting', 'Starting…')} + + ) + } +} + +export function CloudSetupPage({ + flow, + projectName, + prompt, + agentLabel, + onBack, + starting +}: CloudSetupPageProps): React.JSX.Element { + const t = useTranslate() + const titleId = React.useId() + const headingRef = React.useRef(null) + // Every row reads done but hasn't held yet: still "Checking" until the layer says it's starting. + const shown: CloudSetupFlow = starting + ? { ...flow, action: 'done' } + : flow.action === 'done' + ? { ...flow, action: 'checking' } + : flow + const copy = currentStepCopy(shown, t, agentLabel) + // Each new step moves focus to its heading so screen readers hear the change once. + const stepKey = `${shown.current?.id ?? 'done'}:${shown.action}` + React.useEffect(() => { + headingRef.current?.focus({ preventScroll: true }) + }, [stepKey]) + return ( +
+
+
+ + + + {t('cloudSetupPage.eyebrow', 'Orca Cloud setup')} + +
+ +
+ +
+ +
+

+ {copy.title} +

+ {shown.action === 'checking' ? ( + + ) : copy.lead ? ( +

{copy.lead}

+ ) : null} + {copy.note ?

{copy.note}

: null} +
+ + {flow.error && !starting ? ( + + action === 'reopen-browser' ? flow.reopenBrowser() : flow.continueInBrowser() + } + className="mt-6" + /> + ) : null} + +
+ +
+ +
+
+
+ {t('cloudSetupPage.startsAfter', 'Starts when setup is done')} +
+
+ {prompt.trim() || t('cloudSetupPage.untitled', 'New cloud workspace')} +
+
+ {[projectName, agentLabel, t('cloudSetupPage.orcaCloud', 'Orca Cloud')] + .filter(Boolean) + .join(' · ')} +
+
+
+
+
+ ) +} diff --git a/src/renderer/src/components/orca-cloud-setup/CloudSetupPageLayer.tsx b/src/renderer/src/components/orca-cloud-setup/CloudSetupPageLayer.tsx new file mode 100644 index 00000000000..13f653faa59 --- /dev/null +++ b/src/renderer/src/components/orca-cloud-setup/CloudSetupPageLayer.tsx @@ -0,0 +1,122 @@ +// 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' + +/** How long every row must read done before the page starts the workspace. */ +const SETTLE_MS = 1_500 + +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, request.debugAccess) + 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) + // Rows can all read done for a moment right after a sign-in, before billing, GitHub and agent + // accounts answer for the new account. Done must hold for SETTLE_MS before "All set". + const done = flow.action === 'done' + useEffect(() => { + if (!done) { + return + } + const timer = window.setTimeout(() => setStarting(true), SETTLE_MS) + return () => window.clearTimeout(timer) + }, [done]) + useEffect(() => { + if (!starting) { + return + } + const timer = window.setTimeout( + () => reopenComposer(request, true), + prefersReducedMotion() ? 0 : CLOUD_SETUP_ALL_SET_HOLD_MS + ) + return () => window.clearTimeout(timer) + }, [request, starting]) + const onBack = useCallback(() => reopenComposer(request, false), [request]) + return ( + + ) +} + +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(undefined) + useEffect(() => { + if (!request) { + openedOnRef.current = undefined + return + } + if (openedOnRef.current === undefined) { + openedOnRef.current = activeWorktreeId + return + } + if (openedOnRef.current !== activeWorktreeId) { + closeCloudSetupPage() + } + }, [activeWorktreeId, request]) + // The right sidebar (files, source control) is noise during setup: hide it while the page shows, + // and put it back as it was when the page closes or another page takes over. + const showing = request !== null && onWorkspaceView + useEffect(() => { + if (!showing) { + return + } + const store = useAppStore.getState() + const wasOpen = store.rightSidebarOpen + if (wasOpen) { + store.setRightSidebarOpen(false) + } + return () => { + if (wasOpen && !useAppStore.getState().rightSidebarOpen) { + useAppStore.getState().setRightSidebarOpen(true) + } + } + }, [showing]) + if (!request) { + return null + } + // Stays mounted under other pages so an open browser trip keeps its session and waiting state. + return ( +
+ +
+ ) +} diff --git a/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-copy.ts b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-copy.ts new file mode 100644 index 00000000000..edd0e67a473 --- /dev/null +++ b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-copy.ts @@ -0,0 +1,170 @@ +// Words for the Orca Cloud setup page: each step's stepper label, and the current step's title, +// lead and note. Copy follows the product glossary (Orca Cloud, sandbox hours, Team plan). +import type { Translate } from '@/i18n/use-translate' +import type { CloudSetupStep } from '@/components/new-workspace/run-on/cloud-start-model' +import { sessionDetailCopy } from '@/components/new-workspace/run-on/cloud-setup-session-copy' +import type { CloudSetupPageModel } from './cloud-setup-page-model' + +export 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 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 +} + +export function currentStepCopy( + flow: Pick, + 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, + count: Number(hours), + defaultValue_one: '{{hours}} sandbox hour to try Orca Cloud. No card needed.' + } + ), + 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'), + // The row names the repository ("Orca Cloud can't reach acme/web yet"). + lead: detail, + note: t( + 'cloudSetupPage.github.lead', + 'Orca Cloud clones your code from GitHub. Install the Orca Cloud app on this repository once.' + ) + } + 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…') + } + } +} diff --git a/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.test.ts b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.test.ts new file mode 100644 index 00000000000..1fbeb8eca20 --- /dev/null +++ b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.test.ts @@ -0,0 +1,54 @@ +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' + +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('page settles before it starts', () => { + it('a still-loading agent row reads Checking, not All set', () => { + const steps: CloudSetupStep[] = [ + { id: 'account', state: 'done' }, + { id: 'plan', state: 'done' }, + { id: 'agent', state: 'loading' } + ] + expect(cloudSetupPageModel(steps).action).toBe('checking') + }) +}) diff --git a/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.ts b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.ts new file mode 100644 index 00000000000..ca7c8884e5f --- /dev/null +++ b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.ts @@ -0,0 +1,143 @@ +// 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') { + // Still loading reads "Checking": the page only says All set once every row has answered. + 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' + }) +} diff --git a/src/renderer/src/components/orca-cloud-setup/use-cloud-setup-flow.ts b/src/renderer/src/components/orca-cloud-setup/use-cloud-setup-flow.ts new file mode 100644 index 00000000000..165dbd03b21 --- /dev/null +++ b/src/renderer/src/components/orca-cloud-setup/use-cloud-setup-flow.ts @@ -0,0 +1,246 @@ +// 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 { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' +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 Orca Cloud availability for the project (needs-sign-in, needs-capability, offered), read + * the way the composer reads it and re-read while the page waits, so a sign-in in the browser (or + * the dev sign-in) moves the page on. Starts from the composer's answer at Start. + */ +function useLiveAvailability( + repoId: string, + initial: OrcaCloudAvailability, + enabled: boolean +): OrcaCloudAvailability { + const [availability, setAvailability] = useState(initial) + useEffect(() => { + if (!enabled) { + return + } + let cancelled = false + const read = (): void => { + void Promise.resolve() + .then(() => window.api.ephemeralVm.listRecipes({ repoId })) + .then((result) => { + if (!cancelled && result.orcaCloud) { + setAvailability(result.orcaCloud) + } + }) + .catch(() => undefined) + } + read() + // Paused while the window is hidden; returning from the browser re-reads at once. + const stop = installWindowVisibilityInterval({ run: read, intervalMs: BILLING_POLL_MS }) + window.addEventListener('focus', read) + return () => { + cancelled = true + stop() + window.removeEventListener('focus', read) + } + }, [enabled, repoId]) + return availability +} + +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 + 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 = useLiveAvailability(request.repoId, request.availability, !accessOverride) + const [sessionId, setSessionId] = useState(null) + // The step that was current when the browser opened. A setup session covers every step it was + // opened for, so it waits on all of them; a single hop (GitHub install, billing) only on its own. + const [browserStep, setBrowserStep] = useState(null) + const [busy, setBusy] = useState(false) + const [agentSkipped, setAgentSkipped] = useState(() => isCloudSetupAgentSkipped(request.agent)) + const githubOverride = useMemo( + () => (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 + } + return installWindowVisibilityInterval({ + run: () => void prefetchOrcaCloudBilling(true), + intervalMs: BILLING_POLL_MS + }) + }, [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 markInBrowser = useCallback( + () => setBrowserStep(cloudSetupPageModel(stepsRef.current).current?.id ?? null), + [] + ) + + 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')) { + markInBrowser() + void openInstall() + return + } + const start = getCloudSetupStarter() + if (!start) { + markInBrowser() + fallback() + return + } + setBusy(true) + void start({ repoId: request.repoId, needs }) + .then((opened) => { + markInBrowser() + if (opened) { + setSessionId(opened.sessionId) + } else { + fallback() + } + }) + .catch(() => fallback()) + .finally(() => setBusy(false)) + }, [fallback, markInBrowser, 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(() => { + markInBrowser() + invalidateOrcaCloudBilling({ refetchNow: false }) + fallback() + }, [fallback, markInBrowser]) + + const installGitHub = useCallback(() => { + markInBrowser() + void openInstall() + }, [markInBrowser, openInstall]) + + const skipAgent = useCallback(() => { + rememberCloudSetupAgentSkipped(request.agent) + setAgentSkipped(true) + }, [request.agent]) + + const checkAgain = useCallback(() => void prefetchOrcaCloudBilling(true), []) + + return { + ...model, + busy, + inBrowser: browserStep !== null && (sessionId !== null || model.current?.id === browserStep), + 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) + } +} diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 57070f86d45..5a71a0907a4 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -415,8 +415,6 @@ "new": { "workspace": { "RunTargetCombobox": { - "orcaCloudIdleSleep_one": "Sleeps after {{count}} minute idle", - "orcaCloudIdleSleep_other": "Sleeps after {{count}} minutes idle", "orcaCloudSandboxHoursLeft_one": "{{hours}} sandbox hour left", "orcaCloudSandboxHoursLeft_other": "{{hours}} sandbox hours left" } @@ -1562,6 +1560,13 @@ "save_one": "Save secret", "save_other": "Save {{count}} secrets" }, + "cloudSetupPage": { + "trial": { + "lead": "{{hours}} sandbox hours to try Orca Cloud. No card needed.", + "lead_one": "{{hours}} sandbox hour to try Orca Cloud. No card needed.", + "lead_other": "{{hours}} sandbox hours to try Orca Cloud. No card needed." + } + }, "codeHost": { "bitbucketConnect": { "accessTokenRequired": "Enter an access token.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index cd109e0d6fe..13ddd1561e6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16715,20 +16715,12 @@ "currentSuffix": "(current)", "orcaCloudSignInDetail": "Sign in to start a sandbox", "orcaCloudChecking": "Checking your plan…", - "orcaCloudNeedsTeamPlan": "Orca Cloud needs a Team plan.", "orcaCloudOutOfHours": "Out of sandbox hours", "orcaCloudRaiseSpendCap": "Raise spend cap", - "orcaCloudPaymentEffect": "New sandboxes can't start.", "orcaCloudUpdatePaymentMethod": "Update payment method", "orcaCloudSandboxHoursLeft_one": "{{hours}} sandbox hour left", "orcaCloudSandboxHoursLeft_other": "{{hours}} sandbox hours left", "orcaCloudAskOwnerOrAdmin": "Ask an owner or admin", - "orcaCloudAskPayment": "Ask an owner or admin to update the payment method.", - "orcaCloudIdleSleep_one": "Sleeps after {{count}} minute idle", - "orcaCloudIdleSleep_other": "Sleeps after {{count}} minutes idle", - "orcaCloudAskAddHoursOnly": "Ask an owner or admin to add hours.", - "orcaCloudAskChoosePlan": "Ask an owner or admin to choose one.", - "orcaCloudAskRaiseSpendCap": "Ask an owner or admin to raise the spend cap.", "hintConnect": "{{host}}, not connected. Press Enter to connect.", "hintSetLocation": "{{host}}, project location not set. Press Enter to set it.", "hintSignIn": "Orca Cloud, signed out. Press Enter to sign in.", @@ -23099,68 +23091,15 @@ "bothSources": "orca.yaml + local", "localSource": "local settings" }, - "cloudStart": { - "useThisComputer": "Use this computer instead", - "start": "Start in Orca Cloud", - "setUpAndStart": "Set up and start", - "choosePlanAndStart": "Choose plan and start", - "renewAndStart": "Renew and start", - "giveAccessAndStart": "Give access and start", - "checking": "Checking Orca Cloud…", - "githubGranted": "GitHub access granted for {{repo}}" - }, "cloudSetup": { - "account": "Orca account", - "team": "Team", - "plan": "Team plan", - "github": "GitHub access", - "githubNeeded": "Orca Cloud clones your code from GitHub. Give access to this repository once.", - "agent": "Agent", - "planAfterSignIn": "Checked after you sign in", - "askOwner": "Ask an owner or admin to fix it.", - "planEnded": "Your Team plan ended.", - "outOfHours": "Out of sandbox hours.", - "spendCap": "Spend cap reached.", - "paymentFailed": "Payment failed.", - "planMember": "Orca Cloud needs a Team plan. Only owners and admins can choose one.", - "planNeeded": "Orca Cloud needs a Team plan.", - "stateDone": "Done", - "stateOptional": "Optional", - "stateNext": "Next", - "stateInBrowser": "In browser", - "stateFailed": "Didn't finish", - "stateChecking": "Checking", - "stateNeeded": "Needed", - "allSet": "All set", - "title": "Set up Orca Cloud", - "starting": "Starting your cloud workspace…", - "accountNeededTeam": "Sign in or create one. We’ll make a team for you.", - "trial": "Trial", - "trialNoCard": "No card needed. Nothing is charged until you choose a plan.", - "stateIncluded": "Included", - "trialUsedUp": "Your trial is used up.", - "trialUsedUpMember": "Only owners and admins can choose a plan.", - "trialUsedUpOwner": "Choose a plan to keep using Orca Cloud. Your prompt is still here.", - "subtitleNoCard": "One visit to your browser, no card. Your prompt stays here and starts when you’re done.", "trialUsedUpTitle": "Your trial is used up", "choosePlan": "Choose plan", "askChoosePlan": "Ask an owner or admin to choose a plan.", - "trialUsedUpChoose": "Choose a plan to keep using Orca Cloud.", "trialOffer_one": "{{hours}} sandbox hour to try Orca Cloud", "trialOffer_other": "{{hours}} sandbox hours to try Orca Cloud", - "subtitleChoosePlan": "One visit to your browser to choose a plan. Your prompt stays here and starts when you’re done.", - "subtitleNeedsRepository": "Orca Cloud needs a GitHub repository. Your prompt stays here.", - "outOfHoursMember": "Out of sandbox hours. Ask an owner or admin to add hours.", - "spendCapMember": "Spend cap reached. Ask an owner or admin to raise the spend cap.", - "paymentFailedMember": "Payment failed. Ask an owner or admin to update the payment method.", - "subtitleOwnerFixes": "An owner or admin needs to fix this. Your prompt stays here.", - "cancelSetup": "Cancel setup", - "copySetupLink": "Copy setup link", "reopenBrowser": "Reopen browser", "tryAgain": "Try again", "switchAccount": "Switch account", - "trackerTitle": "Finish in your browser", - "trackerSubtitle": "Orca picks up each step the moment it’s done, then starts this workspace.", "teamRequired": "Cloud workspaces belong to a team.", "teamHasPlan": "{{team}} has a Team plan. Switch to start this workspace there.", "teamsHavePlans": "{{teams}} have a Team plan. Switch to one to start this workspace there.", @@ -23168,7 +23107,6 @@ "switchTeam": "Switch team", "switchFirst": "Switch to {{team}} first", "switchTeamFirst": "Switch to a team first", - "linkUnavailable": "Couldn’t get a setup link. Try Set up and start again.", "linkCopied": "Setup link copied. Open it in the browser you use for Orca.", "error": { "timedOut": "Setup didn’t finish in your browser. Your prompt is still here.", @@ -23183,18 +23121,11 @@ "planCoveredByTrial": "Your trial covers it.", "planCheckoutOpen": "Finish checkout in your browser.", "githubRequestedSession": "Waiting for an owner on GitHub to approve Orca Cloud.", - "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." + "linkUnavailableRetry": "Couldn’t get a setup link. Try Continue in browser again." }, "runOn": { "thisComputer": "This computer", - "orcaCloud": "Orca Cloud", - "moreWithTarget": "More places to run: {{name}}", - "more": "More places to run", - "cloudUnreachableReason": "Orca Cloud: can't reach it right now. Check your connection.", - "thisComputerUnusable": "This computer, {{reason}}", - "locationNotSet": "Project location not set", - "cloudUnreachableName": "Orca Cloud, can't reach it right now", - "cloudHosted": "Hosted sandbox" + "orcaCloud": "Orca Cloud" }, "cloudHours": { "trialChip_one": "Trial · {{hours}} sandbox hour left", @@ -23218,18 +23149,20 @@ "heading": "On GitHub · Orca Cloud only", "detail": "Orca Cloud clones it. No copy on this computer needed.", "moreAccess": "Give Orca Cloud access to more repositories…", - "notOnThisComputer": "Not on this computer · Orca Cloud only", "startFailed": "Couldn’t start the cloud workspace. Nothing was created, and you weren’t charged.", "starting": "Starting {{repo}} in Orca Cloud…", "openLater": "Your cloud workspace started, but this computer couldn’t open it yet. Open it from Cloud workspaces.", "private": "{{owner}} · Private", "public": "{{owner}} · Public", "showMore": "Show more repositories…", - "chooseRepository": "Choose a GitHub repository", - "addGitHubProject": "Add a GitHub project…", "folderDetail": "Orca Cloud starts from a GitHub repository. This project is a folder on this computer." }, - "orcaCloudAgentFallback": "{{agent}} isn’t available on Orca Cloud yet, so {{fallback}} is selected." + "cloudStart": { + "agentNotStarted": "{{agent}} isn’t set up for Orca Cloud, so it didn’t start. The workspace has a terminal.", + "agentNotStartedGeneric": "Your agent isn’t set up for Orca Cloud, so it didn’t start. The workspace has a terminal.", + "setUpAgentOnce": "Set up {{agent}} once", + "setUpAgentOnceGeneric": "Set up once" + } }, "JiraIssueWorkspace": { "pendingChange": "Another issue change is in progress." @@ -28064,5 +27997,90 @@ "signInHere": "{{agent}} isn’t signed in for this workspace.", "pausedFix": "Orca account sign-in is paused. Sign in for this workspace; the sign-in stays with this workspace.", "notOfferedFix": "Signs in for each cloud workspace for now. Sign in for this workspace; the sign-in stays with this workspace." + }, + "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.", + "setUp": "Set up once", + "skip": "Skip for now", + "title": "Sign in to {{agent}} once", + "titleGeneric": "Sign in to your agent once" + }, + "back": "Back", + "billing": { + "capLead": "Raise the spend cap to start this workspace.", + "capTitle": "Spend cap reached", + "hoursLead": "Turn on overage to start this workspace.", + "hoursTitle": "Out of sandbox hours", + "paymentLead": "The last payment failed. Update the card to keep going.", + "paymentTitle": "Update your payment method", + "openSettings": "Open billing settings" + }, + "checkAgain": "Check again", + "checking": { + "title": "Checking your Orca Cloud account…" + }, + "continueInBrowser": "Continue in browser", + "copyLink": "Copy link", + "done": { + "lead": "Starting your cloud workspace…", + "title": "All set" + }, + "eyebrow": "Orca Cloud setup", + "github": { + "lead": "Orca Cloud clones your code from GitHub. Install the Orca Cloud app on this repository once.", + "title": "Give Orca Cloud access on GitHub", + "waiting": "This page starts the workspace once they approve.", + "waitingTitle": "Waiting for approval on GitHub" + }, + "orcaCloud": "Orca Cloud", + "owner": { + "lead": "Orca Cloud needs a Team plan. Only owners and admins can choose one.", + "title": "An owner or admin needs to fix this" + }, + "plan": { + "lead": "Orca Cloud needs a Team plan. Checkout opens in your browser.", + "renewLead": "Your Team plan ended. Renew it in your browser to start this workspace.", + "renewTitle": "Renew your Team plan", + "title": "Choose a Team plan", + "trialUsedUpLead": "Choose a Team plan to keep using Orca Cloud. Checkout opens in your browser.", + "trialUsedUpTitle": "Your trial is used up" + }, + "reopenBrowser": "Reopen browser", + "repository": { + "title": "Orca Cloud needs a GitHub repository" + }, + "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": "New teams get a trial with no card needed. This page picks up each step on its own.", + "title": "Sign in to Orca" + }, + "starting": "Starting…", + "startsAfter": "Starts when setup is done", + "step": { + "account": "Account", + "agent": "Agent", + "github": "GitHub", + "plan": "Plan", + "team": "Team", + "trial": "Trial" + }, + "stepCurrent": "current step", + "stepDone": "done", + "stepsLabel": "Setup steps", + "team": { + "title": "Switch to your team" + }, + "trial": { + "lead": "{{hours}} sandbox hours to try Orca Cloud. No card needed.", + "leadNoHours": "Try Orca Cloud on your team. No card needed.", + "note": "Nothing is charged until you choose a plan.", + "start": "Start free trial", + "title": "Start your free trial", + "lead_one": "{{hours}} sandbox hour to try Orca Cloud. No card needed.", + "lead_other": "{{hours}} sandbox hours to try Orca Cloud. No card needed." + }, + "untitled": "New cloud workspace", + "waitingBrowser": "Waiting for your browser…" } } diff --git a/src/renderer/src/lib/orca-cloud/cloud-setup-page-store.ts b/src/renderer/src/lib/orca-cloud/cloud-setup-page-store.ts new file mode 100644 index 00000000000..1a927a81feb --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-setup-page-store.ts @@ -0,0 +1,98 @@ +// 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' +import type { OrcaCloudRunTargetAccess } from '@/components/new-workspace/orca-cloud-run-target-access' +import { e2eConfig } from '@/lib/e2e-config' + +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 + /** + * Dev and e2e only: pins the account's billing answer so every step can be walked against the + * real page (dev sign-in has no billing). Never set by the app. + */ + debugAccess?: OrcaCloudRunTargetAccess + 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): 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. + } +} + +// Dev and e2e: drive the page from the console or CDP (as `__store` is), e.g. to walk the trial step. +if ((import.meta.env.DEV || e2eConfig.exposeStore) && typeof window !== 'undefined') { + Object.assign(window, { + __cloudSetupPage: { + open: openCloudSetupPage, + close: closeCloudSetupPage, + get: getCloudSetupPageRequest + } + }) +}