Merge remote-tracking branch 'private/cloud/integration' into cloud/multiplayer-gaps

This commit is contained in:
Neil
2026-11-10 21:09:20 -08:00
62 changed files with 1868 additions and 553 deletions
@@ -195,6 +195,49 @@ describe('response hardening', () => {
expect(parsed.result?.prUrl).toBeUndefined()
expect(parsed.status).toBe('succeeded')
})
it('accepts the run the control plane answers Run now with (object trigger detail)', async () => {
// Exactly what POST /v1/routines/:id/run returns (routines/store.ts runView, private view).
const queued = {
id: 'run_9',
routineId: 'rt_1',
trigger: 'manual',
triggerDetail: { requestedBy: 'usr_1' },
status: 'queued',
workspaceId: null,
result: null,
error: null,
createdAt: 5,
startedAt: null,
finishedAt: null,
durationMs: null
}
const fetchImpl = vi.fn<typeof fetch>(async () => respond(202, { run: queued }))
const { run } = await client(fetchImpl).runRoutine('rt_1')
expect(run).toMatchObject({ id: 'run_9', status: 'queued', triggerDetail: null })
const github = {
...queued,
trigger: 'github',
triggerDetail: {
event: 'pull_request.labeled',
delivery: 'd1',
target: { repo: 'acme/web', number: 482, kind: 'pull_request' },
context: { anything: true }
}
}
const page = vi.fn<typeof fetch>(async () =>
respond(200, {
runs: [github, { ...queued, triggerDetail: 'POST from ci' }],
nextBefore: null
})
)
const { runs } = await client(page).listRoutineRuns('rt_1')
expect(runs.map((r) => r.triggerDetail)).toEqual([
'acme/web#482 pull_request.labeled',
'POST from ci'
])
})
})
describe('classifyCloudApiResponse', () => {
+3
View File
@@ -33,6 +33,8 @@ export type ProvisionEphemeralVmRuntimeArgs = {
projectId?: string
workspaceId?: string
workspaceName?: string
/** One per Create press (Orca Cloud): the create's Idempotency-Key. */
idempotencyKey?: string
repoUrl?: string
branch?: string
ref?: string
@@ -102,6 +104,7 @@ export async function provisionEphemeralVmRuntime(
projectId: args.projectId,
workspaceId: args.workspaceId,
workspaceName: args.workspaceName,
...(args.idempotencyKey ? { idempotencyKey: args.idempotencyKey } : {}),
repoUrl: args.repoUrl,
branch: args.branch,
ref: args.ref,
@@ -36,6 +36,8 @@ export type EphemeralVmProvisionArgs = {
branch?: string
ref?: string
provisionId?: string
/** Orca Cloud: the Create press's key; a re-fired create gets the first one's sandbox. */
idempotencyKey?: string
}
export type EphemeralVmRecipeCatalogEntry = {
+1
View File
@@ -195,6 +195,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
projectId: args.projectId,
workspaceId: args.workspaceId,
workspaceName: args.workspaceName,
...(args.idempotencyKey ? { idempotencyKey: args.idempotencyKey } : {}),
...(repoUrl ? { repoUrl } : {}),
...(args.branch ? { branch: args.branch } : {}),
...(sourceRef ? { ref: sourceRef } : {}),
+2
View File
@@ -58,6 +58,8 @@ export type EphemeralVmApi = {
branch?: string
ref?: string
provisionId?: string
/** Orca Cloud: one per Create press, sent as the create's Idempotency-Key. */
idempotencyKey?: string
}) => Promise<
| {
ok: true
+2
View File
@@ -8,6 +8,7 @@ import { BrowserExtensionInstallDialog } from './components/browser-extension-in
import { PlansDialogHost } from './components/billing/plans/PlansDialogHost'
import { CloudMoveToComputerDialogMount } from './components/orca-cloud/CloudMoveToComputerDialogMount'
import { CloudSecretsAskDialogHost } from './components/cloud-secrets-ask/CloudSecretsMissingNotice'
import { OrcaCloudSignInSheet } from './components/settings/agent-accounts/sign-in-sheet/OrcaCloudSignInSheet'
import { AgentHostPanel } from './components/agent-host-panel/AgentHostPanel'
import { SecretRequestUnseenNotifier } from './components/agent-pane/SecretRequestUnseenNotifier'
import { TeamDialogHost } from './components/billing/team/TeamDialogHost'
@@ -129,6 +130,7 @@ function App(): React.JSX.Element {
<CloudMoveToComputerDialogMount />
<CloudSecretsAskDialogHost />
<AgentHostPanel />
<OrcaCloudSignInSheet />
<SecretRequestUnseenNotifier />
<TeamDialogHost />
<TeammateProfileDialog />
@@ -11,6 +11,7 @@ import { FloatingTerminalToggleButton } from '../components/floating-terminal/Fl
import { TerminalWorkbenchContainer } from '../components/TerminalWorkbenchContainer'
import { WorkspaceLowDiskBanner } from '../components/low-disk/WorkspaceLowDiskBanner'
import { CloudSetupPageLayer } from '../components/orca-cloud-setup/CloudSetupPageLayer'
import { CloudOpenCoverLayer } from '../components/worktree-creation/CloudOpenCoverLayer'
import type { VirtualizedScrollAnchor } from '../hooks/useVirtualizedScrollAnchor'
import { TitlebarLeftControls } from './TitlebarLeftControls'
import { RightSidebarToggle, TitlebarMainStrip } from './TitlebarMainStrip'
@@ -248,6 +249,9 @@ export function AppWorkspaceShell(props: {
</Suspense>
</div>
<CloudSetupPageLayer />
<CloudOpenCoverLayer
reserveCollapsedSidebarHeaderSpace={layout.leftTitlebarChromeLayout.isFloating}
/>
{floatingWorkspace.showToggleButton && !capturingPluginShot ? (
<MemoFloatingTerminalToggleButton
open={floatingWorkspace.open}
@@ -1,6 +1,7 @@
// The panel's footer links and row actions open Settings over it; the panel closes first so focus
// lands in Settings rather than behind the sheet.
import { useAppStore } from '@/store'
import { openOrcaCloudSignInSheet } from '@/components/settings/agent-accounts/sign-in-sheet/orca-cloud-sign-in-sheet-store'
import type { AgentHostFooterLink } from './agent-host-capabilities'
import { closeAgentHostPanel } from './agent-host-panel-store'
@@ -16,6 +17,10 @@ const TARGET = {
export function openAgentHostSettings(link: AgentHostFooterLink): void {
closeAgentHostPanel()
if (link === 'agentAccounts') {
openOrcaCloudSignInSheet()
return
}
const target: { pane: (typeof TARGET)[AgentHostFooterLink]['pane']; sectionId?: string } =
TARGET[link]
const store = useAppStore.getState()
@@ -2,6 +2,7 @@
// private launch hold waits, or #40228's per-workspace gate while the Orca-account path is paused or
// never offered. It overlays the top 56 px of the pane while the agent hasn't started, so the
// terminal's size never changes. SSH, WSL and paired hosts keep #40228's card.
import { openOrcaCloudSignInSheet } from '@/components/settings/agent-accounts/sign-in-sheet/orca-cloud-sign-in-sheet-store'
import React, { useEffect, useSyncExternalStore } from 'react'
import { createPortal } from 'react-dom'
import { useAppStore } from '@/store'
@@ -51,7 +52,7 @@ export function useCloudGatePaneKeys(controller: TerminalPaneController): Readon
return new Set(joined ? joined.split('\u0000') : [])
}
function openSettings(pane: 'accounts' | 'cloud-secrets', sectionId?: string): void {
function openSettings(pane: 'cloud-secrets', sectionId?: string): void {
const store = useAppStore.getState()
store.openSettingsTarget({ pane, repoId: null, ...(sectionId ? { sectionId } : {}) })
store.openSettingsPage()
@@ -119,7 +120,7 @@ function CloudStripPortal({
primary: (action) => {
switch (action.kind) {
case 'renew':
openSettings('accounts', 'orca-cloud')
openOrcaCloudSignInSheet()
return
case 'signIn':
gateActions.signIn()
@@ -17,7 +17,14 @@ import { useTeamActions } from './use-team-actions'
import { openTeammateProfile } from '../../teammates/teammate-profile-store'
import { ORCA_CLOUD_RECIPE_ID } from '../../../../../shared/hosted-sandbox/orca-cloud-recipe-id'
function TeamPageBody({ arrival }: { arrival: ReturnType<typeof useTeamPageState>['arrival'] }) {
export function TeamPageBody({
arrival,
inline
}: {
arrival: ReturnType<typeof useTeamPageState>['arrival']
/** Settings → Team: rendered on the page instead of in the dialog (JA-17). */
inline?: { onOpenPlanBilling: () => void }
}): React.JSX.Element {
const t = useTranslate()
const confirm = useConfirmationDialog()
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
@@ -217,6 +224,7 @@ function TeamPageBody({ arrival }: { arrival: ReturnType<typeof useTeamPageState
}}
onDismissArrival={clearTeamPageArrival}
onContactSales={(url) => void window.api.shell.openUrl(url)}
inline={inline}
/>
)
}
@@ -51,6 +51,8 @@ export type TeamPageViewProps = {
onDismissArrival: () => void
/** Opens the Enterprise contact page (SSO/SCIM are Enterprise; nothing here pretends otherwise). */
onContactSales: (url: string) => void
/** Settings → Team renders the page inline: seats and invoices stay on Plan & billing (linked). */
inline?: { onOpenPlanBilling: () => void }
}
function viewerRoleSentence(role: OrcaOrgRole, t: Translate): string {
@@ -112,15 +114,17 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
const inviteInput = useRef<HTMLInputElement>(null)
const ssoTitleId = useId()
const hasRoster = props.roster !== null
const inline = props.inline ?? null
useEffect(() => {
const active = document.activeElement
if (
!inline &&
hasRoster &&
(!active || active === document.body || active.getAttribute('role') === 'dialog')
) {
document.querySelector<HTMLElement>('[data-team-page-heading]')?.focus()
}
}, [hasRoster])
}, [hasRoster, inline])
const leaveTitleId = useId()
if (!props.org) {
@@ -192,7 +196,18 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
/>
) : null}
{entitlement ? (
{inline ? (
<p className="flex flex-wrap items-center gap-x-1 text-xs text-muted-foreground">
{t('auto.components.billing.team.seatsOnPlanBilling', 'Seats and invoices are in')}
<button
type="button"
className="rounded-sm text-foreground underline underline-offset-2 hover:text-foreground/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={inline.onOpenPlanBilling}
>
{t('auto.components.billing.title', 'Plan & billing')}
</button>
</p>
) : entitlement ? (
<TeamBillingBlock
entitlement={entitlement}
members={roster?.members.length ?? 0}
@@ -1,46 +0,0 @@
import { ChevronRight } from 'lucide-react'
import { useTranslate } from '@/i18n/use-translate'
import { TeamMark } from './TeamAvatar'
import { memberCountLabel } from './team-copy'
import { openTeamPage } from './team-page-store'
import { useOrgRoster } from './use-org-roster'
/** Settings › Orca Cloud › Team: one row that opens the Team page (members, invites, seats). */
export function TeamSettingsLink({
orgId,
orgName
}: {
orgId: string | null
orgName: string | null
}): React.JSX.Element {
const t = useTranslate()
const roster = useOrgRoster(orgId)
const members = roster.result?.status === 'ok' ? roster.result.roster.members.length : null
return (
<div>
<button
type="button"
onClick={() => openTeamPage()}
className="flex w-full items-center gap-3 rounded-lg border border-border bg-card px-3 py-2.5 text-left shadow-xs transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
{orgName ? <TeamMark name={orgName} className="size-8 text-xs" /> : null}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{orgName ?? t('auto.components.billing.team.createTitle', 'Create a team')}
</span>
<span className="block truncate text-xs text-muted-foreground">
{orgName
? members === null
? t('auto.components.billing.team.manage', 'Members, invites and seats')
: `${memberCountLabel(members, t)} · ${t('auto.components.billing.team.manage', 'Members, invites and seats')}`
: t(
'auto.components.billing.team.createHint',
'Share one Orca Cloud plan with your teammates.'
)}
</span>
</span>
<ChevronRight className="size-4 text-muted-foreground" />
</button>
</div>
)
}
@@ -2,8 +2,8 @@
// Later" hint: it says how the composer's agent will sign in, links to the one-time setup in
// Settings → Agent accounts, and never blocks Start or signs in per sandbox.
import { useEffect } from 'react'
import { openOrcaCloudSignInSheet } from '@/components/settings/agent-accounts/sign-in-sheet/orca-cloud-sign-in-sheet-store'
import { CircleCheck, CircleDashed, Info, TriangleAlert } from 'lucide-react'
import { useAppStore } from '@/store/index'
import { useTranslate, type Translate } from '@/i18n/use-translate'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
@@ -19,11 +19,9 @@ import { noteComposerAgentCoverage } from '@/lib/orca-cloud/cloud-first-start-ag
import type { OrcaCloudRunTargetAccess } from './orca-cloud-run-target-access'
import { agentName, keySourceLabel } from '../settings/agent-accounts/orca-cloud-accounts-copy'
/** Settings → Agent accounts, scrolled to the Orca Cloud section (S0). The composer stays open. */
/** The focused Orca Cloud sign-in sheet (JA-04), over the composer, which stays open. */
export function openOrcaCloudAgentAccounts(): void {
useAppStore
.getState()
.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'orca-cloud' })
openOrcaCloudSignInSheet()
}
export function orcaCloudAgentCoveredLabel(
@@ -40,4 +40,6 @@ export type ComposerModalData = {
setupGuideTourRequestId?: string
/** Orca Cloud setup finished on its page: submit without showing the dialog (falls back to it). */
cloudAutoStart?: boolean
/** The Create press's intent the auto-start may use, once (one press, one server create). */
cloudCreateIntent?: string
}
@@ -24,24 +24,25 @@ describe('Orca Cloud availability', () => {
describe('setup rows and the setup page', () => {
const page = (steps: Parameters<typeof cloudSetupPageModel>[0]) =>
cloudSetupPageModel(steps, { agentSkipped: true }).action
cloudSetupPageModel(steps).action
it('starts directly when the account is entitled', () => {
const steps = deriveCloudSetupSteps({
availability: 'offered',
access: { state: 'entitled', remainingSeconds: 3600 }
})
expect(cloudSetupRequired(steps, { agentSkipped: true })).toBe(false)
expect(cloudSetupRequired(steps)).toBe(false)
expect(page(steps)).toBe('done')
})
it('an agent with no account opens the page until it is skipped once', () => {
it('an agent with no account never opens the page (PC-1): the create starts at once', () => {
const steps = deriveCloudSetupSteps({
availability: 'offered',
access: { state: 'entitled', remainingSeconds: 3600 }
})
expect(cloudSetupRequired(steps)).toBe(true)
expect(cloudSetupPageModel(steps).action).toBe('agent')
expect(steps.find((s) => s.id === 'agent')?.state).toBe('optional')
expect(cloudSetupRequired(steps)).toBe(false)
expect(cloudSetupPageModel(steps).action).toBe('done')
})
it('signed out: sign-in first, plan checked after sign-in', () => {
@@ -148,7 +149,7 @@ describe('setup rows and the setup page', () => {
access: { state: 'entitled', remainingSeconds: 3600 },
github: { id: 'github', state: 'pending', need: 'github', blocksStart: true }
})
expect(cloudSetupRequired(steps, { agentSkipped: true })).toBe(true)
expect(cloudSetupRequired(steps)).toBe(true)
expect(page(steps)).toBe('github-waiting')
})
@@ -189,6 +190,7 @@ describe('setup rows and the setup page', () => {
{ id: 'agent', state: 'loading' }
]
expect(cloudSetupRequired(steps)).toBe(false)
expect(cloudSetupPageModel(steps).action).toBe('done')
})
})
@@ -37,37 +37,6 @@ export function getOrcaCloudHoursChip(
})
}
/** The start toast: "Using sandbox hours · 2 sandbox hours left in your trial". */
export function getOrcaCloudStartToast(
access: OrcaCloudRunTargetAccess,
t: Translate,
locale: string
): string | null {
if (access.state !== 'entitled') {
return null
}
const args = hoursArgs(access.remainingSeconds, locale)
return access.trial
? t(
'components.newWorkspace.cloudHours.trialToast',
'Using sandbox hours · {{hours}} sandbox hours left in your trial',
{
count: args.count,
hours: args.hours,
defaultValue_one: 'Using sandbox hours · {{hours}} sandbox hour left in your trial'
}
)
: t(
'components.newWorkspace.cloudHours.planToast',
'Using sandbox hours · {{hours}} sandbox hours left',
{
count: args.count,
hours: args.hours,
defaultValue_one: 'Using sandbox hours · {{hours}} sandbox hour left'
}
)
}
/** T1: under half an hour of trial left, the hint turns to a warning with the fix. */
export const ORCA_CLOUD_TRIAL_LOW_SECONDS = 30 * 60
@@ -2,7 +2,6 @@
// repositories with no clone here. Picking one keeps Orca Cloud and starts it from GitHub.
import { useCallback, useMemo, useState } from 'react'
import { useAppStore } from '@/store'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { clearCloudStartDraft } from '@/lib/orca-cloud/cloud-start-draft'
import {
@@ -102,7 +101,6 @@ export function useCloudOnlyCreate({
prompt,
agent,
profileId,
hoursToast,
onClose
}: {
cloudOnly: CloudOnlyRepo
@@ -110,8 +108,6 @@ export function useCloudOnlyCreate({
prompt: string
agent: string | null
profileId: string | null
/** "Using sandbox hours · …", or null before billing answers. */
hoursToast: string | null
onClose?: () => void
}): () => void {
const { picked, start } = cloudOnly
@@ -121,12 +117,9 @@ export function useCloudOnlyCreate({
return
}
clearCloudStartDraft(profileId)
if (hoursToast) {
toast.message(hoursToast)
}
void start({ prompt, agent })
onClose?.()
}, [agent, createWorkspace, hoursToast, onClose, picked, profileId, prompt, start])
}, [agent, createWorkspace, onClose, picked, profileId, prompt, start])
}
/**
@@ -2,15 +2,18 @@
// 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 { useCallback, useEffect, useMemo } from 'react'
import { useAppStore } from '@/store/index'
import { translate } from '@/i18n/i18n'
import { clearCloudStartDraft, saveCloudStartDraft } from '@/lib/orca-cloud/cloud-start-draft'
import {
isCloudSetupAgentSkipped,
getCloudSetupPageRequest,
openCloudSetupPage
} from '@/lib/orca-cloud/cloud-setup-page-store'
import {
armCloudCreateIdempotency,
consumeCloudCreateIntent,
startCloudCreate
} from '@/lib/orca-cloud/cloud-create-intent'
import { recordSuccessfulRunOn } from '@/lib/orca-cloud/run-on-memory'
import { ORCA_CLOUD_RECIPE_ID } from '../../../../../shared/hosted-sandbox/orca-cloud-recipe-id'
import type { OrcaCloudAvailability } from '../../../../../shared/orca-yaml-hook-types'
@@ -18,48 +21,22 @@ import { useOrcaCloudRunTargetAccess } from '../orca-cloud-billing-cache'
import type { OrcaCloudRunTargetAccess } from '../orca-cloud-run-target-access'
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 { 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 { showCloudAgentNotStartedToast } from '@/lib/orca-cloud/cloud-agent-not-started-toast'
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
}
}
)
/** The workspace started without its agent: the one prompt to set it up (design-squad journey). */
function showAgentNotStartedToast(agent: string | null, workspaceName: string): void {
if (isOrcaCloudAccountAgent(agent)) {
showCloudAgentNotStartedToast(agent, workspaceName)
}
}
export function useCloudStartComposer({
@@ -71,7 +48,7 @@ export function useCloudStartComposer({
prompt,
agent,
initialTargetFixed,
autoStart = false,
autoStartIntent = null,
createReady = true,
composerSnapshot,
onCreate: createWorkspace,
@@ -88,8 +65,11 @@ export function useCloudStartComposer({
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
/**
* Reopened by the setup page once everything is done: the Create press's intent, submitted once
* the form is ready (and only if nothing has used it yet).
*/
autoStartIntent?: string | null
/** 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. */
@@ -129,10 +109,7 @@ export function useCloudStartComposer({
githubOverride: cloudOnly.githubStep
})
const { steps, held: switchTeam } = useSwitchTeamSteps(rowSteps, cloudSelected)
const setupNeeded = cloudSetupRequired(steps, {
teamSwitch: switchTeam !== null,
agentSkipped: isCloudSetupAgentSkipped(agent)
})
const setupNeeded = cloudSetupRequired(steps, { teamSwitch: switchTeam !== null })
// 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 =
@@ -143,7 +120,6 @@ export function useCloudStartComposer({
prompt,
agent: agentUnset ? null : agent,
profileId,
hoursToast: getOrcaCloudStartToast(access, translate, navigator.language),
onClose
})
const onCreate = useCallback(() => {
@@ -157,8 +133,8 @@ export function useCloudStartComposer({
} else {
onCreateWithoutAgent()
}
showAgentNotStartedToast(agent)
}, [agent, agentUnset, cloudOnly.picked, onCreateRaw, onCreateWithoutAgent])
showAgentNotStartedToast(agent, prompt)
}, [agent, agentUnset, cloudOnly.picked, onCreateRaw, onCreateWithoutAgent, prompt])
const { markTouched } = useRunOnDefault({
active,
@@ -176,40 +152,54 @@ export function useCloudStartComposer({
}, [markTouched, setRecipeId])
// 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',
setupSessionId: null,
createdAt: Date.now()
})
openCloudSetupPage({
prompt,
repoId,
agent,
availability,
cloudOnlyRepo: cloudOnly.picked?.fullName ?? null,
composer: composerSnapshot()
})
onClose?.()
}, [agent, availability, cloudOnly.picked, composerSnapshot, onClose, profileId, prompt, repoId])
const openSetup = useCallback(
(intentId: string) => {
saveCloudStartDraft(profileId, {
prompt,
repoId,
agent,
target: 'orca-cloud',
setupSessionId: null,
createdAt: Date.now()
})
openCloudSetupPage({
prompt,
repoId,
agent,
availability,
cloudOnlyRepo: cloudOnly.picked?.fullName ?? null,
composer: composerSnapshot(),
intentId
})
onClose?.()
},
[agent, availability, cloudOnly.picked, composerSnapshot, onClose, profileId, prompt, repoId]
)
// 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)
// One Create press is one server create (P0): the intent is consumed here or nowhere.
const stepsSettled = !steps.some((step) => step.state === 'loading' && step.id !== 'agent')
useEffect(() => {
if (!autoStart || autoStartedRef.current || !cloudSelected || !createReady || !stepsSettled) {
if (!autoStartIntent || !cloudSelected || !createReady || !stepsSettled || setupNeeded) {
return
}
if (setupNeeded) {
return
if (consumeCloudCreateIntent(autoStartIntent)) {
armCloudCreateIdempotency(autoStartIntent)
onCreate()
}
autoStartedRef.current = true
onCreate()
}, [autoStart, cloudSelected, createReady, onCreate, setupNeeded, stepsSettled])
}, [autoStartIntent, cloudSelected, createReady, onCreate, setupNeeded, stepsSettled])
const onCloudPrimary = useCallback(
() =>
startCloudCreate({
setupOpen: getCloudSetupPageRequest() !== null,
setupNeeded,
openSetup,
create: () => onCreate()
}),
[onCreate, openSetup, setupNeeded]
)
const recordCreated = useCallback(() => {
if (!repoId) {
@@ -220,12 +210,8 @@ export function useCloudStartComposer({
return
}
clearCloudStartDraft(profileId)
// O4: the start always says which hours it spends.
const message = getOrcaCloudStartToast(access, translate, navigator.language)
if (message) {
toast.message(message)
}
}, [access, cloudSelected, profileId, repoId])
// PC-5: no "Using sandbox hours" toast on start; hours live in billing (Neil: noise).
}, [cloudSelected, profileId, repoId])
const view = useMemo<CloudStartView | null>(
() =>
@@ -242,5 +228,5 @@ export function useCloudStartComposer({
if (!view || !cloudSelected) {
return { view, onPrimary: null, recordCreated }
}
return { view, onPrimary: setupNeeded ? openSetup : onCreate, recordCreated }
return { view, onPrimary: onCloudPrimary, recordCreated }
}
@@ -66,7 +66,7 @@ export function useComposerCloudStart({
initialTargetFixed: Boolean(
modalData.initialEphemeralVmRecipeId || modalData.initialWorkspaceTarget || modalData.recovery
),
autoStart: modalData.cloudAutoStart === true,
autoStartIntent: modalData.cloudAutoStart ? (modalData.cloudCreateIntent ?? null) : null,
createReady: !createDisabled,
composerSnapshot,
onCreate: runCreate,
@@ -3,24 +3,28 @@
// 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 { ArrowLeft, Check, Copy, ExternalLink, LoaderCircle, X } 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 type {
CloudSetupStep,
CloudSetupStepId
} 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 { currentStepCopy, doneStepCopy, stepLabel, type StepCopy } 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
/** Back from the first step: Create workspace again, with the prompt, project and agent kept. */
onBackToComposer: () => void
/** The × in the corner: leave setup, keep the draft (nothing starts). */
onExit: () => void
/** "All set": shown for a moment before the workspace starts. */
starting: boolean
}
@@ -30,11 +34,17 @@ type StepState = 'done' | 'current' | 'todo'
function SetupStepper({
steps,
current,
allDone
viewing,
allDone,
onView
}: {
steps: readonly CloudSetupStep[]
current: CloudSetupStep | null
/** A finished step being revisited. */
viewing: CloudSetupStepId | null
allDone: boolean
/** Revisit a finished step; null returns to the current one. */
onView: (id: CloudSetupStepId | null) => void
}): React.JSX.Element {
const t = useTranslate()
const currentIndex = current ? steps.findIndex((step) => step.id === current.id) : steps.length
@@ -47,9 +57,9 @@ function SetupStepper({
const state: StepState =
allDone || index < currentIndex ? 'done' : index === currentIndex ? 'current' : 'todo'
const label = stepLabel(step, t)
return (
<li key={step.id} className="flex items-center gap-2" data-setup-page-step={step.id}>
{index > 0 ? <span aria-hidden className="h-px w-6 bg-border" /> : null}
const shownHere = viewing ? viewing === step.id : state === 'current'
const mark = (
<>
<span
aria-hidden
className={cn(
@@ -67,7 +77,7 @@ function SetupStepper({
<span
className={cn(
'text-xs',
state === 'current' ? 'font-medium text-foreground' : 'text-muted-foreground'
shownHere ? 'font-medium text-foreground' : 'text-muted-foreground'
)}
>
{label}
@@ -79,6 +89,31 @@ function SetupStepper({
: ''}
</span>
</span>
</>
)
// Finished steps (and the current one, to come back to it) open; future ones don't.
const clickable =
!allDone && (state === 'done' || (state === 'current' && viewing !== null))
return (
<li key={step.id} className="flex items-center gap-2" data-setup-page-step={step.id}>
{index > 0 ? <span aria-hidden className="h-px w-6 bg-border" /> : null}
{clickable ? (
<button
type="button"
aria-current={shownHere ? 'step' : undefined}
onClick={() => onView(state === 'current' ? null : step.id)}
className="flex items-center gap-2 rounded-sm outline-none focus-visible:ring-1 focus-visible:ring-focus-ring"
>
{mark}
</button>
) : (
<span
className="flex items-center gap-2"
aria-current={shownHere ? 'step' : undefined}
>
{mark}
</span>
)}
</li>
)
})}
@@ -152,20 +187,6 @@ function StepActions({ flow }: { flow: CloudSetupFlow }): React.JSX.Element | nu
)
case 'switch-team':
return <div>{flow.teamAction}</div>
case 'agent':
return (
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
onClick={flow.current?.action?.onClick ?? openOrcaCloudAgentAccounts}
>
{flow.current?.action?.label ?? t('cloudSetupPage.agent.setUp', 'Set up once')}
</Button>
<Button type="button" variant="ghost" onClick={flow.skipAgent}>
{t('cloudSetupPage.agent.skip', 'Skip for now')}
</Button>
</div>
)
case 'needs-repository':
return flow.current?.action ? (
<Button type="button" variant="outline" onClick={flow.current.action.onClick}>
@@ -198,50 +219,113 @@ function StepActions({ flow }: { flow: CloudSetupFlow }): React.JSX.Element | nu
}
}
function StepBody({ copy, checking }: { copy: StepCopy; checking: boolean }): React.JSX.Element {
return (
<>
{checking ? (
<Skeleton className="h-4 w-72" />
) : copy.lead ? (
<p className="text-sm leading-relaxed text-muted-foreground">{copy.lead}</p>
) : null}
{copy.aside ? <p className="text-sm italic text-muted-foreground">{copy.aside}</p> : null}
{copy.note ? <p className="text-xs text-muted-foreground">{copy.note}</p> : null}
</>
)
}
export function CloudSetupPage({
flow,
projectName,
prompt,
agentLabel,
onBack,
onBackToComposer,
onExit,
starting
}: CloudSetupPageProps): React.JSX.Element {
const t = useTranslate()
const titleId = React.useId()
const headingRef = React.useRef<HTMLHeadingElement>(null)
// A finished step opened from the stepper or Back; null shows the current step.
const [viewing, setViewing] = React.useState<CloudSetupStepId | null>(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)
const viewed = viewing && !starting ? flow.steps.find((step) => step.id === viewing) : undefined
const copy: StepCopy = viewed ? doneStepCopy(viewed, t, agentLabel) : currentStepCopy(shown, t)
// Back walks to the previous step; from the first it returns to Create workspace.
const back = (): void => {
const steps = flow.steps
const from = viewing
? steps.findIndex((step) => step.id === viewing)
: flow.current
? steps.findIndex((step) => step.id === flow.current?.id)
: steps.length
const previous = from > 0 ? steps[from - 1] : undefined
if (previous && !starting) {
setViewing(previous.id)
return
}
onBackToComposer()
}
// Each new step moves focus to its heading so screen readers hear the change once.
const stepKey = `${shown.current?.id ?? 'done'}:${shown.action}`
const stepKey = `${viewing ?? shown.current?.id ?? 'done'}:${shown.action}`
React.useEffect(() => {
headingRef.current?.focus({ preventScroll: true })
}, [stepKey])
const context = [
prompt.trim()
? t('cloudSetupPage.headerFor', 'Setting up Orca Cloud for {{name}}', { name: prompt.trim() })
: t('cloudSetupPage.header', 'Setting up Orca Cloud'),
projectName,
agentLabel
]
.filter(Boolean)
.join(' · ')
return (
<section
aria-labelledby={titleId}
data-cloud-setup-page="true"
className="flex h-full min-h-0 w-full flex-col overflow-y-auto bg-background scrollbar-sleek"
>
<div className="mx-auto flex w-full max-w-xl flex-1 flex-col px-8 pb-12 pt-10">
<div className="flex items-center justify-between gap-3">
<Button type="button" variant="ghost" size="sm" className="-ms-2" onClick={onBack}>
<ArrowLeft aria-hidden />
{t('cloudSetupPage.back', 'Back')}
</Button>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Cloud className="size-3.5" aria-hidden />
{t('cloudSetupPage.eyebrow', 'Orca Cloud setup')}
</span>
</div>
<div className="flex shrink-0 items-center gap-3 px-6 pt-5">
<Button type="button" variant="ghost" size="sm" className="-ms-2" onClick={back}>
<ArrowLeft aria-hidden />
{t('cloudSetupPage.back', 'Back')}
</Button>
<span
className="min-w-0 flex-1 truncate text-center text-xs text-muted-foreground"
data-cloud-setup-context="true"
>
{context}
</span>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t('cloudSetupPage.exit', 'Exit setup')}
title={t('cloudSetupPage.exit', 'Exit setup')}
onClick={onExit}
>
<X aria-hidden />
</Button>
</div>
<div className="mt-8">
<SetupStepper steps={flow.steps} current={flow.current} allDone={starting} />
</div>
{/* The same optically-centred top offset on every step, so nothing jumps between them. */}
<div className="mx-auto w-full max-w-xl px-8 pb-12 pt-[max(2rem,calc(22vh-3.5rem))]">
<SetupStepper
steps={flow.steps}
current={flow.current}
viewing={viewing}
allDone={starting}
onView={setViewing}
/>
<div className="mt-8 space-y-3">
<h1
@@ -252,15 +336,10 @@ export function CloudSetupPage({
>
{copy.title}
</h1>
{shown.action === 'checking' ? (
<Skeleton className="h-4 w-72" />
) : copy.lead ? (
<p className="text-sm leading-relaxed text-muted-foreground">{copy.lead}</p>
) : null}
{copy.note ? <p className="text-xs text-muted-foreground">{copy.note}</p> : null}
<StepBody copy={copy} checking={!viewed && shown.action === 'checking'} />
</div>
{flow.error && !starting ? (
{flow.error && !starting && !viewed ? (
<CloudSetupErrorNotice
error={flow.error}
canManageBilling={flow.canManageBilling}
@@ -271,24 +350,14 @@ export function CloudSetupPage({
/>
) : null}
<div className="mt-6">
<StepActions flow={shown} />
</div>
<div className="mt-12">
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
<div className="text-xs font-medium text-muted-foreground">
{t('cloudSetupPage.startsAfter', 'Starts when setup is done')}
</div>
<div className="mt-1 truncate text-sm text-foreground">
{prompt.trim() || t('cloudSetupPage.untitled', 'New cloud workspace')}
</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{[projectName, agentLabel, t('cloudSetupPage.orcaCloud', 'Orca Cloud')]
.filter(Boolean)
.join(' · ')}
</div>
</div>
<div className="mt-6 flex justify-end">
{viewed ? (
<Button type="button" onClick={() => setViewing(null)}>
{t('cloudSetupPage.continue', 'Continue')}
</Button>
) : (
<StepActions flow={shown} />
)}
</div>
</div>
</section>
@@ -4,6 +4,10 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { useTranslate } from '@/i18n/use-translate'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { clearCloudStartDraft } from '@/lib/orca-cloud/cloud-start-draft'
import { cancelCloudCreateIntent } from '@/lib/orca-cloud/cloud-create-intent'
import {
closeCloudSetupPage,
useCloudSetupPageRequest,
@@ -21,11 +25,33 @@ 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
/**
* The × leaves setup. Nothing starts later (JA-01): the page and its auto-start go away, and the
* on-disk draft is dropped. The draft stays one click away in a toast, as a filled composer.
*/
function exitSetup(request: CloudSetupPageRequest): void {
cancelCloudCreateIntent(request.intentId)
closeCloudSetupPage()
clearCloudStartDraft(useAppStore.getState().activeOrcaProfileId ?? null)
toast.message(
translate('cloudSetupPage.exited', 'Orca Cloud setup closed. Your draft is kept.'),
{
action: {
label: translate('cloudSetupPage.resumeDraft', 'Open draft'),
onClick: () => reopenComposer(request, false)
}
}
)
}
function reopenComposer(request: CloudSetupPageRequest, autoStart: boolean): void {
// A GitHub-only repository can't be preselected; the composer opens for one more Start.
const hiddenStart = autoStart && !request.cloudOnlyRepo
if (!hiddenStart) {
// Back, or a start that needs one more press: this setup's intent can never create.
cancelCloudCreateIntent(request.intentId)
closeCloudSetupPage()
clearCloudStartDraft(useAppStore.getState().activeOrcaProfileId ?? null)
} else {
// Warm the start screen's chunk so it is ready the frame the create takes over.
void import('@/components/worktree-creation/WorktreeCreationPanel').catch(() => undefined)
@@ -33,7 +59,7 @@ function reopenComposer(request: CloudSetupPageRequest, autoStart: boolean): voi
useAppStore.getState().openModal('new-workspace-composer', {
...request.composer,
initialEphemeralVmRecipeId: ORCA_CLOUD_RECIPE_ID,
...(hiddenStart ? { cloudAutoStart: true } : {})
...(hiddenStart ? { cloudAutoStart: true, cloudCreateIntent: request.intentId } : {})
})
if (hiddenStart) {
// The page stays up until the start screen has painted: nothing under it may flash.
@@ -76,14 +102,16 @@ function ActiveCloudSetupPage({ request }: { request: CloudSetupPageRequest }):
)
return () => window.clearTimeout(timer)
}, [request, starting])
const onBack = useCallback(() => reopenComposer(request, false), [request])
const onBackToComposer = useCallback(() => reopenComposer(request, false), [request])
const onExit = useCallback(() => exitSetup(request), [request])
return (
<CloudSetupPage
flow={flow}
projectName={projectName}
prompt={request.prompt}
agentLabel={agentLabel}
onBack={onBack}
onBackToComposer={onBackToComposer}
onExit={onExit}
starting={starting}
/>
)
@@ -104,6 +132,7 @@ export function CloudSetupPageLayer(): React.JSX.Element | null {
return
}
if (openedOnRef.current !== activeWorktreeId) {
cancelCloudCreateIntent(request.intentId)
closeCloudSetupPage()
}
}, [activeWorktreeId, request])
@@ -22,7 +22,13 @@ export function stepLabel(step: CloudSetupStep, t: Translate): string {
}
}
type StepCopy = { title: string; lead: string | null; note?: string | null }
export type StepCopy = {
title: string
lead: string | null
note?: string | null
/** A gentle option under the lead, set apart in muted italics (the agent step's skip hint). */
aside?: string | null
}
function trialHours(step: CloudSetupStep): string | null {
return step.trialSeconds ? String(Math.round((step.trialSeconds / 3600) * 10) / 10) : null
@@ -30,8 +36,7 @@ function trialHours(step: CloudSetupStep): string | null {
export function currentStepCopy(
flow: Pick<CloudSetupPageModel, 'current' | 'action'>,
t: Translate,
agentLabel: string | null
t: Translate
): StepCopy {
const step = flow.current
const detail = step
@@ -146,16 +151,6 @@ export function currentStepCopy(
'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…'),
@@ -168,3 +163,45 @@ export function currentStepCopy(
}
}
}
/** A finished step revisited from the stepper or Back: what it settled, in one calm line. */
export function doneStepCopy(
step: CloudSetupStep,
t: Translate,
agentLabel: string | null
): StepCopy {
switch (step.id) {
case 'account':
return {
title: t('cloudSetupPage.done.accountTitle', 'You’re signed in'),
lead: t('cloudSetupPage.done.accountLead', 'Your Orca account is ready for Orca Cloud.')
}
case 'team':
return {
title: t('cloudSetupPage.done.teamTitle', 'Team selected'),
lead: step.detail ?? null
}
case 'plan':
return {
title:
step.detail === 'trial' || step.need === 'trial'
? t('cloudSetupPage.done.trialTitle', 'Your trial is on')
: t('cloudSetupPage.done.planTitle', 'Your team has Orca Cloud'),
lead: null
}
case 'github':
return {
title: t('cloudSetupPage.done.githubTitle', 'Orca Cloud can reach this repository'),
lead: null
}
case 'agent':
return {
title: agentLabel
? t('cloudSetupPage.done.agentTitle', '{{agent}} is set up for Orca Cloud', {
agent: agentLabel
})
: t('cloudSetupPage.done.agentTitleGeneric', 'Your agent is set up for Orca Cloud'),
lead: null
}
}
}
@@ -16,17 +16,15 @@ describe('cloudSetupPageModel', () => {
expect(sessionNeedsFrom(steps)).toEqual(['plan', 'github'])
})
it('a skipped agent finishes setup', () => {
it('the agent is never a page step and never holds the start (PC-1)', () => {
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'
})
const model = cloudSetupPageModel(steps)
expect(model.steps.map((step) => step.id)).toEqual(['account', 'plan'])
expect(model).toMatchObject({ current: null, action: 'done' })
})
it('maps each blocker to its one action', () => {
@@ -41,14 +39,3 @@ describe('cloudSetupPageModel', () => {
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')
})
})
@@ -22,8 +22,6 @@ export type CloudSetupPageAction =
| '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. */
@@ -35,17 +33,6 @@ export type CloudSetupPageModel = {
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 } = {}
@@ -90,23 +77,23 @@ export function actionForStep(
}
return 'github-install'
case 'agent':
return 'agent'
// Never a page step (PC-1): agent sign-in is offered after create.
return 'done'
}
}
/**
* 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.
* The page's state from the setup rows. Steps come in walk order (account, team, plan, GitHub); the
* first unfinished one is current. The agent row is never a step (PC-1): it never gates a start, and
* its sign-in is offered after create. 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 } = {}
rows: readonly CloudSetupStep[],
{ teamSwitch = false }: { teamSwitch?: boolean } = {}
): CloudSetupPageModel {
const current = steps.find((step) => !isFinished(step, agentSkipped)) ?? null
const steps = rows.filter((step) => step.id !== 'agent')
const current = steps.find((step) => step.state !== 'done') ?? null
if (!current) {
return { steps, current: null, action: 'done' }
}
@@ -121,23 +108,16 @@ export function sessionNeedsFrom(steps: readonly CloudSetupStep[]): CloudSetupSt
}
/**
* 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.
* Pressing Start opens the setup page only when the account, team, plan or GitHub is missing. The
* agent never holds a start (PC-1): without an Orca Cloud account it starts as a plain terminal and
* the post-create prompt offers the sign-in.
*/
export function cloudSetupRequired(
steps: readonly CloudSetupStep[],
{
teamSwitch = false,
agentSkipped = false
}: { teamSwitch?: boolean; agentSkipped?: boolean } = {}
{ teamSwitch = false }: { teamSwitch?: boolean } = {}
): boolean {
if (teamSwitch) {
return true
}
return steps.some((step) => {
if (step.id === 'agent') {
return step.state === 'optional' && !agentSkipped
}
return step.state !== 'done'
})
return steps.some((step) => step.id !== 'agent' && step.state !== 'done')
}
@@ -20,11 +20,7 @@ import {
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 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'
@@ -89,7 +85,6 @@ export type CloudSetupFlow = CloudSetupPageModel & {
startTrial: () => void
fixBilling: () => void
installGitHub: () => void
skipAgent: () => void
checkAgain: () => void
copyLink: (() => void) | null
reopenBrowser: () => void
@@ -108,7 +103,6 @@ export function useCloudSetupFlow(
// opened for, so it waits on all of them; a single hop (GitHub install, billing) only on its own.
const [browserStep, setBrowserStep] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [agentSkipped, setAgentSkipped] = useState(() => isCloudSetupAgentSkipped(request.agent))
const githubOverride = useMemo<CloudSetupStep | null>(
() => (request.cloudOnlyRepo ? { id: 'github', state: 'done' } : null),
[request.cloudOnlyRepo]
@@ -127,14 +121,14 @@ export function useCloudSetupFlow(
githubOverride
})
const { steps, held: switchTeam } = useSwitchTeamSteps(rowSteps, !session)
const model = cloudSetupPageModel(steps, { agentSkipped, teamSwitch: switchTeam !== null })
const model = cloudSetupPageModel(steps, { 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'
const waiting = model.action !== 'done'
useEffect(() => {
if (!waiting || accessOverride) {
return
@@ -217,11 +211,6 @@ export function useCloudSetupFlow(
void openInstall()
}, [markInBrowser, openInstall])
const skipAgent = useCallback(() => {
rememberCloudSetupAgentSkipped(request.agent)
setAgentSkipped(true)
}, [request.agent])
const checkAgain = useCallback(() => void prefetchOrcaCloudBilling(true), [])
return {
@@ -235,7 +224,6 @@ export function useCloudSetupFlow(
startTrial,
fixBilling,
installGitHub,
skipAgent,
checkAgain,
copyLink:
sessionId && canCopyCloudSetupLink(sessionId)
@@ -1,7 +1,6 @@
// The status (dot and text) of a cloud agent row that needs sign-in: clicking it opens
// Settings → Agent accounts, where the fix lives. The rest of the row still opens the pane.
import React, { useCallback } from 'react'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { AgentStateDot } from '@/components/AgentStateDot'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -11,10 +10,9 @@ import { openOrcaCloudAgentAccounts } from '@/components/new-workspace/OrcaCloud
/** What the status shows: its label, the agent's catalog name, and the tooltip naming the fix. */
export type CloudAgentRowStatusText = { label: string; agentName: string; tooltip: string | null }
/** The composer's "Set up once" target, plus opening Settings, which the sidebar has closed. */
/** The sidebar row's "Set up once": the focused Orca Cloud sign-in sheet (JA-04). */
export function openCloudAgentAccountsSettings(): void {
openOrcaCloudAgentAccounts()
useAppStore.getState().openSettingsPage()
}
/**
@@ -185,6 +185,18 @@ export function CloudSetupNotices({
)
}
/** Bottom right, above the floating-terminal button: top right would cover the prompt's first row. */
export function CloudSetupNoticesDock({ workspaceId }: { workspaceId: string }): React.JSX.Element {
return (
<div
data-cloud-surface=""
className="pointer-events-none absolute right-3 bottom-14 z-30 flex flex-col items-end gap-2"
>
<CloudSetupNotices workspaceId={workspaceId} />
</div>
)
}
export function CloudSetupNoticesView({
record,
notices,
@@ -207,7 +219,7 @@ export function CloudSetupNoticesView({
role="status"
aria-labelledby={labelId}
data-cloud-notice="setup"
className="pointer-events-auto w-[26rem] max-w-[calc(100%-1.5rem)] rounded-lg border border-border bg-card text-card-foreground shadow-[0_8px_24px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_24px_rgba(0,0,0,0.4)] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1"
className="pointer-events-auto w-[26rem] max-w-[calc(100%-1.5rem)] rounded-lg border border-border bg-card text-card-foreground shadow-[0_8px_24px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_24px_rgba(0,0,0,0.4)] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1"
>
<h2 id={labelId} className="sr-only">
{t('orcaCloud.setupNotices.label', 'Workspace setup')}
@@ -22,7 +22,7 @@ import type { CloudWakeProgressEvent } from '../../../../shared/hosted-sandbox/c
import { CloudArchivedWorkspaceView, type ArchiveLoad } from './CloudArchivedWorkspaceView'
import { CloudWakingOverlay } from './CloudWakingOverlay'
import { CloudWorkspaceNotice } from './CloudWorkspaceNotice'
import { CloudSetupNotices } from './CloudSetupNotices'
import { CloudSetupNoticesDock } from './CloudSetupNotices'
import { useConfirmedRestart } from './use-cloud-panel-actions'
import { useCloudVersionSkewNotice } from './use-cloud-version-skew-notice'
import { CloudWorkspaceOfflineView } from './offline/CloudWorkspaceOfflineView'
@@ -339,10 +339,8 @@ function CloudSurface({ workspaceId }: { workspaceId: string }): React.JSX.Eleme
if (layer === null && versionSkewNotice) {
notices.push(versionSkewNotice)
}
if (layer === null && view.state !== 'sleeping' && view.state !== 'archived') {
// What the environment check found that didn't need to stop the create (env-check-ux).
notices.push(<CloudSetupNotices key="setup" workspaceId={workspaceId} />)
}
// What the environment check found that didn't need to stop the create (env-check-ux).
const showSetupNotices = layer === null && view.state !== 'sleeping' && view.state !== 'archived'
if (layer === null && view.oom) {
notices.push(
<CloudWorkspaceNotice
@@ -439,6 +437,7 @@ function CloudSurface({ workspaceId }: { workspaceId: string }): React.JSX.Eleme
) : null}
</div>
) : null}
{showSetupNotices ? <CloudSetupNoticesDock workspaceId={workspaceId} /> : null}
{notices.length > 0 ? (
<div
data-cloud-surface=""
@@ -23,6 +23,7 @@ const waking: CloudWakeProgressEvent = {
startedAt: T0 - 5_000,
at: T0
}
const PAST_CAP = WAKE_ATTACH_HOLD_MAX_MS + WAKE_ATTACH_POLL_MS
const start: { wake: CloudWakeProgressEvent | undefined } = { wake: waking }
beforeEach(() => {
@@ -99,18 +100,31 @@ describe('useWakeAttachHold', () => {
expect(result.current.wake).toBeUndefined()
})
it('a pane that never takes input becomes "Couldn’t reconnect the terminal"; Try again reconnects it', () => {
it('at the cap reconnects the pane once on its own, and only then shows the card', () => {
const retry = vi.fn(() => true)
const { result, rerender } = hook(() => false, retry)
rerender({ wake: { ...waking, phase: 'ready' } })
expect(result.current.wake?.phase).toBe('connecting')
act(() => vi.advanceTimersByTime(PAST_CAP))
// The quiet retry: still Connecting…, no card yet.
expect(retry).toHaveBeenCalledTimes(1)
expect(result.current.terminalStalled).toBe(false)
expect(result.current.wake?.phase).toBe('connecting')
act(() => vi.advanceTimersByTime(PAST_CAP))
expect(retry).toHaveBeenCalledTimes(1)
expect(result.current.terminalStalled).toBe(true)
expect(result.current.wake?.phase).toBe('failed')
})
it('Try again re-runs the attach (not a wake) and waits for the pane once more', () => {
let attached: boolean | null = false
const retry = vi.fn(() => true)
const { result, rerender } = hook(() => attached, retry)
rerender({ wake: { ...waking, phase: 'ready' } })
expect(result.current.wake?.phase).toBe('connecting')
act(() => vi.advanceTimersByTime(WAKE_ATTACH_HOLD_MAX_MS + WAKE_ATTACH_POLL_MS))
act(() => vi.advanceTimersByTime(PAST_CAP * 2))
expect(result.current.terminalStalled).toBe(true)
expect(result.current.wake?.phase).toBe('failed')
// Try again: the pane reconnects and the pill waits for it once more.
act(() => result.current.retry())
expect(retry).toHaveBeenCalledWith('ws')
expect(retry).toHaveBeenLastCalledWith('ws')
expect(result.current.terminalStalled).toBe(false)
expect(result.current.wake?.phase).toBe('connecting')
attached = true
@@ -118,6 +132,48 @@ describe('useWakeAttachHold', () => {
expect(result.current.wake).toBeUndefined()
})
it('the card clears by itself once the pane attaches through any path', () => {
let attached: boolean | null = false
const retry = vi.fn(() => true)
const { result, rerender } = hook(() => attached, retry)
rerender({ wake: { ...waking, phase: 'ready' } })
act(() => vi.advanceTimersByTime(PAST_CAP * 2))
expect(result.current.terminalStalled).toBe(true)
// A restore or account restart brings the pane back; nobody pressed Try again.
attached = true
act(() => vi.advanceTimersByTime(WAKE_ATTACH_POLL_MS))
expect(result.current.terminalStalled).toBe(false)
expect(result.current.wake).toBeUndefined()
expect(retry).toHaveBeenCalledTimes(1)
})
it('a restart with no wake in flight never shows the card', () => {
const retry = vi.fn(() => true)
const { result } = renderHook(() => useWakeAttachHold('ws', undefined, () => false, retry))
// The restored pane takes its time to attach; no wake, so nothing holds or fails over it.
act(() => vi.advanceTimersByTime(PAST_CAP * 3))
expect(result.current.wake).toBeUndefined()
expect(result.current.terminalStalled).toBe(false)
expect(retry).not.toHaveBeenCalled()
})
it('an account restart (no wake ready from main) never shows the card', () => {
const retry = vi.fn(() => true)
const { result, rerender } = hook(() => false, retry)
// A wake entry that never settles through main goes away (stale, quiet failure, Dismiss)
// while the pane respawns for its new account: no hold, no card.
rerender({ wake: undefined })
act(() => vi.advanceTimersByTime(PAST_CAP * 3))
expect(result.current.wake).toBeUndefined()
expect(result.current.terminalStalled).toBe(false)
// Nor a `ready` from a different wake than the one seen in flight.
rerender({ wake: { ...waking, startedAt: T0 } })
rerender({ wake: { ...waking, startedAt: T0 + 1, phase: 'ready' } })
act(() => vi.advanceTimersByTime(PAST_CAP * 3))
expect(result.current.wake).toBeUndefined()
expect(retry).not.toHaveBeenCalled()
})
it('counts only visible time toward the reconnect cap', () => {
let visibility: DocumentVisibilityState = 'visible'
const descriptor = Object.getOwnPropertyDescriptor(document, 'visibilityState')
@@ -142,6 +198,9 @@ describe('useWakeAttachHold', () => {
act(() => vi.advanceTimersByTime(WAKE_ATTACH_HOLD_MAX_MS / 2 - WAKE_ATTACH_POLL_MS * 2))
expect(result.current.terminalStalled).toBe(false)
act(() => vi.advanceTimersByTime(WAKE_ATTACH_POLL_MS * 3))
// The cap's quiet reconnect, counted in visible time too; the card waits one more cap.
expect(result.current.terminalStalled).toBe(false)
act(() => vi.advanceTimersByTime(PAST_CAP))
expect(result.current.terminalStalled).toBe(true)
} finally {
if (descriptor) {
@@ -155,7 +214,8 @@ describe('useWakeAttachHold', () => {
it('Dismiss clears the reconnect card', () => {
const { result, rerender } = hook(() => false)
rerender({ wake: { ...waking, phase: 'ready' } })
act(() => vi.advanceTimersByTime(WAKE_ATTACH_HOLD_MAX_MS + WAKE_ATTACH_POLL_MS))
act(() => vi.advanceTimersByTime(PAST_CAP * 2))
expect(result.current.terminalStalled).toBe(true)
act(() => result.current.dismiss())
expect(result.current.wake).toBeUndefined()
})
@@ -8,7 +8,7 @@ import type { CloudWakeProgressEvent } from '../../../../shared/hosted-sandbox/c
/** How often the hold checks the pane in front while it waits. */
export const WAKE_ATTACH_POLL_MS = 100
/** A pane that hasn't re-attached by now gets the "Couldn't reconnect the terminal" card. */
/** A pane that hasn't re-attached by now is reconnected once; after a second cap it gets the card. */
export const WAKE_ATTACH_HOLD_MAX_MS = 8_000
type Track = {
@@ -38,8 +38,11 @@ export type WakeAttachHold = {
* A wake reports ready once the server answers; the pane in front re-attaches to its terminal a
* moment later. Until it takes input (attached, with nothing it was given still unsent), this
* returns the wake as still `connecting` (same start time and source, so the pill keeps counting
* from the wake's start). A pane that hasn't by the cap becomes a failed card with Try again,
* never a silent dead pane. Then `undefined` hands back to the store's wake.
* from the wake's start). A pane that hasn't by the cap is reconnected once quietly; one that
* still hasn't by the next cap becomes a failed card with Try again (re-runs the attach), never a
* silent dead pane, and the card clears itself if the pane attaches after all. Only main's `ready`
* for a wake seen in flight arms this: a restart or an account restart with no wake never does.
* Then `undefined` hands back to the store's wake.
*/
export function useWakeAttachHold(
workspaceId: string,
@@ -65,43 +68,61 @@ export function useWakeAttachHold(
if (track.last || track.held || track.stalled) {
setTrack({ ...IDLE, attempt: track.attempt })
}
} else if (track.last && (!wake || wake.startedAt === track.last.startedAt)) {
// Ready, or the settled entry already dropped: hold only a wake that was in flight here.
} else if (track.last) {
// Only main's own `ready` for the wake seen in flight here holds the pane. An entry that just
// goes away (a stale or quietly failed wake dropped by the store, a Dismiss) never does: no
// wake brought the pane back, so there is nothing to wait for.
const settled = wake?.phase === 'ready' && wake.startedAt === track.last.startedAt
const held =
isAttached(workspaceId) === false
settled && isAttached(workspaceId) === false
? { ...track.last, phase: 'connecting' as const }
: undefined
setTrack({ ...IDLE, attempt: track.attempt + 1, held })
}
// Release once the pane takes input; at the cap, fail visibly. The store dropping the settled
// wake doesn't cancel this: it is keyed on the hold only.
const holding = track.held !== undefined
// Release once the pane takes input; at the cap, reconnect it once quietly, then fail visibly.
// The card keeps watching too: a pane that attaches later (restore, account restart, a slow
// wake) clears it on its own. The store dropping the settled wake doesn't cancel this.
const waiting = track.held !== undefined || track.stalled !== undefined
const attempt = track.attempt
useEffect(() => {
if (!holding) {
if (!waiting) {
return
}
// Only visible time counts toward the cap: a hidden window pauses this poll (and the pane's
// own re-attach), so time spent hidden must not turn a healthy wake into a stalled card.
let visibleMs = 0
let lastAt = Date.now()
let quietRetryDone = false
let shownStalled = false
const check = (): void => {
const attached = isAttached(workspaceId)
if (attached === false && visibleMs < WAKE_ATTACH_HOLD_MAX_MS) {
if (attached === false && (shownStalled || visibleMs < WAKE_ATTACH_HOLD_MAX_MS)) {
return
}
if (attached === false && !quietRetryDone) {
// One reconnect on its own before the card: a pane whose attach got lost usually comes
// back from this, and the person never sees a failure for it.
quietRetryDone = true
visibleMs = 0
retryPane(workspaceId)
return
}
shownStalled = attached === false
setTrack((current) => {
if (current.attempt !== attempt || !current.held) {
if (current.attempt !== attempt || (!current.held && !current.stalled)) {
return current
}
return attached === false
if (attached !== false) {
return { ...current, held: undefined, stalled: undefined }
}
return current.held
? {
...current,
held: undefined,
stalled: { ...current.held, phase: 'failed', at: Date.now() }
}
: { ...current, held: undefined }
: current
})
}
return installWindowVisibilityInterval({
@@ -118,7 +139,7 @@ export function useWakeAttachHold(
},
intervalMs: WAKE_ATTACH_POLL_MS
})
}, [holding, attempt, workspaceId, isAttached])
}, [waiting, attempt, workspaceId, isAttached, retryPane])
const retry = useCallback(() => {
retryPane(workspaceId)
@@ -153,8 +153,8 @@ export function OrcaCloudAccountsView({
<SettingsGroupHeader
title={t('orcaCloudAccounts.title', 'Orca Cloud')}
description={t(
'orcaCloudAccounts.help',
'Set up once on this computer. Every cloud workspace uses it, including new, cloned and restarted ones.'
'orcaCloudAccounts.helpAccountWide',
'Set up once. Every cloud workspace uses it, including new, cloned and restarted ones.'
)}
/>
)}
@@ -1,11 +1,9 @@
// Wires S3 to the renderer's host list, the per-host sign-in probes and the Orca account. Rows open
// the A16 panel; the Orca Cloud row scrolls to S0 below (one row stands for every cloud workspace).
import React, { useEffect, useMemo, useState } from 'react'
// Wires S3 to the renderer's host list and the per-host sign-in probes. Rows open the A16 panel.
// No Orca Cloud row (JA-04): the page's own Orca Cloud part right below is the one place for it.
import React, { useMemo } from 'react'
import { useAppStore } from '@/store'
import { useTranslate } from '@/i18n/use-translate'
import { useOrcaCloudAgentAccounts } from '@/lib/orca-cloud/orca-cloud-agent-accounts-store'
import { openAgentHostPanel } from '../../../agent-host-panel/agent-host-panel-store'
import { ORCA_CLOUD_ACCOUNTS_SECTION_ID } from '../OrcaCloudAccounts'
import {
overviewRows,
type OverviewAction,
@@ -22,28 +20,6 @@ function openSettings(pane: 'accounts', sectionId?: string): void {
store.openSettingsPage()
}
/** A member can't change team keys: their key cells say "set by your team". */
function useIsCloudMember(enabled: boolean): boolean {
const [member, setMember] = useState(false)
useEffect(() => {
if (!enabled) {
return
}
let cancelled = false
void (window.api.cloudSecrets?.effective({}) ?? Promise.resolve(null))
.catch(() => null)
.then((result) => {
if (!cancelled && result?.ok) {
setMember(!result.value.caller.canWrite.org)
}
})
return () => {
cancelled = true
}
}, [enabled])
return member
}
export function AgentAccountsOverviewSection({
hideHeader = false
}: {
@@ -52,28 +28,11 @@ export function AgentAccountsOverviewSection({
const t = useTranslate()
const hosts = useOverviewHosts()
const { probes, checkAgain, firstPassDone } = useOverviewProbes(hosts)
const accounts = useOrcaCloudAgentAccounts()
const ready = accounts.load.kind === 'ready'
const member = useIsCloudMember(ready)
const signedOutOfOrca = accounts.load.kind === 'failed' && accounts.load.error === 'signed_out'
const rows = useMemo(
() =>
overviewRows({
hosts,
probes,
cloud:
accounts.load.kind === 'ready'
? { status: accounts.load.status, keyKinds: accounts.keyKinds, member }
: null
}),
[hosts, probes, accounts.load, accounts.keyKinds, member]
)
const rows = useMemo(() => overviewRows({ hosts, probes, cloud: null }), [hosts, probes])
const hostById = new Map<string, OverviewHost>(hosts.map((host) => [host.hostId, host]))
const open = (row: OverviewRow, focus?: 'claude' | 'codex'): void => {
const host = row.hostId ? hostById.get(row.hostId) : undefined
if (!host) {
// Orca Cloud: S0 below owns setup and the Source for every cloud workspace.
document.getElementById(ORCA_CLOUD_ACCOUNTS_SECTION_ID)?.scrollIntoView({ block: 'start' })
return
}
openAgentHostPanel({
@@ -111,7 +70,7 @@ export function AgentAccountsOverviewSection({
return (
<AgentAccountsOverview
rows={rows}
signedOutOfOrca={signedOutOfOrca}
signedOutOfOrca={false}
hideHeader={hideHeader}
summary={summary}
onOpenRow={(row) => open(row)}
@@ -0,0 +1,75 @@
// JA-04: the focused Orca Cloud sign-in sheet. Only Claude and Codex, each with its one action and
// status (S0's rows), so "Set up once" never lands on this computer's provider accounts.
import React from 'react'
import { useAppStore } from '@/store'
import { useTranslate } from '@/i18n/use-translate'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet'
import { OrcaCloudAccountsSection } from '../OrcaCloudAccountsSection'
import {
closeOrcaCloudSignInSheet,
useOrcaCloudSignInSheetOpen
} from './orca-cloud-sign-in-sheet-store'
function openAllAgentAccounts(): void {
closeOrcaCloudSignInSheet()
const store = useAppStore.getState()
store.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'orca-cloud' })
store.openSettingsPage()
}
/** The sheet's contents; the host owns the Sheet so fixtures can render it open. */
export function OrcaCloudSignInSheetView({
body
}: {
/** S0's rows; fixtures pass a fixed view. */
body: React.ReactNode
}): React.JSX.Element {
const t = useTranslate()
const titleId = React.useId()
return (
<SheetContent side="right" className="w-[600px] sm:max-w-[600px]" aria-labelledby={titleId}>
<div className="border-b border-border pe-8">
<SheetHeader>
<SheetTitle id={titleId}>
{t('orcaCloudSignInSheet.title', 'Agents in Orca Cloud')}
</SheetTitle>
<SheetDescription>
{t(
'orcaCloudSignInSheet.description',
'Set up Claude and Codex once. Every cloud workspace uses them, including new, cloned and restarted ones.'
)}
</SheetDescription>
</SheetHeader>
</div>
<div className="scrollbar-sleek min-h-0 flex-1 overflow-y-auto px-5 py-5">{body}</div>
<nav
aria-label={t('orcaCloudSignInSheet.footerLabel', 'Related settings')}
className="border-t border-border px-5 py-3 text-xs"
>
<button
type="button"
className="text-muted-foreground underline underline-offset-2 hover:text-foreground focus-visible:outline-[var(--focus-ring)]"
onClick={openAllAgentAccounts}
>
{t('orcaCloudSignInSheet.allAccounts', 'All agent accounts')}
</button>
</nav>
</SheetContent>
)
}
/** Mounted once in App; opened by openOrcaCloudSignInSheet(). */
export function OrcaCloudSignInSheet(): React.JSX.Element {
const open = useOrcaCloudSignInSheetOpen()
return (
<Sheet open={open} onOpenChange={(next) => !next && closeOrcaCloudSignInSheet()}>
{open ? <OrcaCloudSignInSheetView body={<OrcaCloudAccountsSection hideHeader />} /> : null}
</Sheet>
)
}
@@ -0,0 +1,37 @@
// JA-04: every "Set up once" / "Renew" for Orca Cloud opens one focused sheet (Claude and Codex
// only) instead of Settings, so nobody scrolls past this computer's provider accounts to reach it.
import { useSyncExternalStore } from 'react'
let open = false
const listeners = new Set<() => void>()
function set(next: boolean): void {
if (open === next) {
return
}
open = next
for (const listener of listeners) {
listener()
}
}
export function openOrcaCloudSignInSheet(): void {
set(true)
}
export function closeOrcaCloudSignInSheet(): void {
set(false)
}
function subscribe(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function useOrcaCloudSignInSheetOpen(): boolean {
return useSyncExternalStore(
subscribe,
() => open,
() => false
)
}
@@ -127,17 +127,14 @@ export function RoutineRow({
setNotice(null)
const failure = await onRunNow()
setRunning(false)
setNotice(
failure
? { tone: 'error', text: failure }
: {
tone: 'ok',
text: t(
'auto.components.settings.cloudRoutines.queued',
'Run queued. It shows up in the history below.'
)
}
)
if (failure) {
setNotice({ tone: 'error', text: failure })
return
}
// The new run's status lives in the pill and the history; open the history so it is in view.
if (!expanded) {
onToggleHistory()
}
}
return (
@@ -218,7 +215,7 @@ export function RoutineRow({
variant="ghost"
size="xs"
aria-disabled={running && !active}
aria-busy={running || active}
aria-busy={running}
aria-expanded={active ? expanded : undefined}
aria-controls={active ? historyId : undefined}
onClick={() => void runNow()}
@@ -246,14 +243,9 @@ export function RoutineRow({
: undefined
}
>
{running || active ? (
<Loader2 className="animate-spin motion-reduce:animate-none" />
) : (
<Play />
)}
{active
? t('auto.components.settings.cloudRoutines.runningNow', 'Running…')
: t('auto.components.settings.cloudRoutines.runNow', 'Run now')}
{/* Spins only while the request is in flight; the run's own status is the pill's job. */}
{running ? <Loader2 className="animate-spin motion-reduce:animate-none" /> : <Play />}
{t('auto.components.settings.cloudRoutines.runNow', 'Run now')}
</Button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
@@ -283,6 +283,41 @@ export const runHistory = defineFixture({
render: () => <CloudRoutinesView {...props} initial={{ historyFor: 'rt_review' }} />
})
/** Right after Run now: the pill and history say Queued; the button is back to Run now. */
export const justQueued = defineFixture({
width: 820,
render: () => (
<CloudRoutinesView
{...props}
routines={{
status: 'ok',
data: ROUTINES.map((r) =>
r.id === 'rt_triage'
? { ...r, lastRun: { id: 'run_q', status: 'queued', finishedAt: null } }
: r
)
}}
loadRuns={async () => ({
ok: true,
data: {
runs: [
run({
id: 'run_q',
status: 'queued',
trigger: 'manual',
createdAt: NOW - 3_000,
startedAt: null
}),
...RUNS.runs.slice(1)
],
nextBefore: null
}
})}
initial={{ historyFor: 'rt_triage' }}
/>
)
})
export const runHistoryEmpty = defineFixture({
width: 820,
render: () => (
@@ -10,6 +10,7 @@ import {
describeRoutineTrigger,
formatCountdown,
formatKeyPrefix,
formatRelativeFromNow,
formatRunDuration,
presetForScopes,
scopesForPreset,
@@ -65,6 +66,15 @@ describe('formatting', () => {
).toMatch(/^Weekdays at 9:00.* E[SD]T$/)
})
it('says "just now" for the last minute, not "this minute"', () => {
const now = 1_000_000_000
expect(formatRelativeFromNow(now - 3_000, now, 'en')).toBe('just now')
expect(formatRelativeFromNow(now - 59_000, now, 'en')).toBe('just now')
expect(formatRelativeFromNow(now - 5 * 60_000, now, 'en')).toBe('5 minutes ago')
// Future times (next scheduled run) keep Intl wording.
expect(formatRelativeFromNow(now + 10 * 60_000, now, 'en')).toBe('in 10 minutes')
})
it('offers the local zone first', () => {
const zones = timeZoneOptions('Asia/Tokyo')
expect(zones[0]).toBe('Asia/Tokyo')
@@ -51,6 +51,10 @@ export function formatKeyPrefix(prefix: string): string {
}
export function formatRelativeFromNow(timestamp: number, now: number, intlLocale?: string): string {
// Under a minute ago reads "just now", like the other cloud screens (not Intl's "this minute").
if (timestamp <= now && now - timestamp < 60_000) {
return translate('auto.components.cloudWorkspaces.justNow', 'just now')
}
return formatUiRelativeTime(timestamp - now, intlLocale)
}
@@ -7,7 +7,7 @@ import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-s
import { useTranslate } from '@/i18n/use-translate'
import { useAppStore } from '@/store'
import { PlanBillingSection } from '../../billing/PlanBillingSection'
import { TeamSettingsLink } from '../../billing/team/TeamSettingsLink'
import { TeamPageBody } from '../../billing/team/TeamDialogHost'
import {
CloudEnvironmentSection,
useOrcaCloudEnvironmentsAvailability
@@ -113,7 +113,19 @@ export function OrcaCloudTeamPane(): React.JSX.Element {
<OrcaCloudAccountGate>
{(account) =>
account.billing ? (
<TeamSettingsLink orgId={account.orgId} orgName={account.orgName} />
// JA-17: members inline (invite field, then the table); seats and invoices stay on
// Plan & billing, linked. Remount per team so row state never carries over.
<TeamPageBody
key={account.orgId ?? 'none'}
arrival={null}
inline={{
onOpenPlanBilling: () => {
const store = useAppStore.getState()
store.openSettingsTarget({ pane: 'cloud-billing', repoId: null })
store.openSettingsPage()
}
}}
/>
) : (
<InlineError
tone="neutral"
@@ -30,8 +30,8 @@ export function orcaCloudAgentAccountsParts(
id: 'accounts-orca-cloud',
title: t('orcaCloudAccounts.title', 'Orca Cloud'),
description: t(
'orcaCloudAccounts.help',
'Set up once on this computer. Every cloud workspace uses it, including new, cloned and restarted ones.'
'orcaCloudAccounts.helpAccountWide',
'Set up once. Every cloud workspace uses it, including new, cloned and restarted ones.'
),
content: <OrcaCloudAccountsSection hideHeader />
}
@@ -1,5 +1,8 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCloudRecoveryContext } from '@/lib/orca-cloud/use-cloud-recovery-context'
import { useIsCloudWorkspace } from '@/lib/orca-cloud/orca-cloud-workspace-store'
import { useCloudSetupPageRequest } from '@/lib/orca-cloud/cloud-setup-page-store'
import { selectWorktreeCreationSurfaceShown } from '@/lib/workspace/creation/worktree-creation-surface'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { track } from '@/lib/telemetry'
@@ -71,9 +74,18 @@ function SetupScriptPromptCard(): React.JSX.Element | null {
// An asleep Orca Cloud workspace can't be inspected: wait until it's awake instead of showing
// "Couldn't verify this repo's setup script" with Retry (RA-12).
const asleepCloud = useCloudRecoveryContext(activeWorktreeId)?.asleep === true
// Orca Cloud never shows this nudge: a cloud workspace reports a setup script that hasn't run in
// its own notice, and while a create is setting up, starting or stopped on a check the card would
// flip between states under the start screen (JA-12, PC-7).
const cloudWorkspace = useIsCloudWorkspace(activeWorktreeId)
const creationShown = useAppStore(selectWorktreeCreationSurfaceShown)
const cloudSetupOpen = useCloudSetupPageRequest() !== null
const isDismissed =
provisionedByEnvironmentBuild ||
asleepCloud ||
cloudWorkspace ||
creationShown ||
cloudSetupOpen ||
(activeRepoHostIdentity
? isSetupScriptPromptDismissed(activeRepoHostIdentity, dismissedRepoIds)
: false)
@@ -58,9 +58,17 @@ it('sends you to Settings when your Pokes are off, and disables Poke when theirs
fireEvent.click(screen.getByRole('button', { name: 'Open settings' }))
expect(onTurnOnPokes).toHaveBeenCalledOnce()
cleanup()
view({ profile: profile({ pokes: { enabled: true, teammateEnabled: false } }) })
const { onPoke } = view({
profile: profile({ pokes: { enabled: true, teammateEnabled: false } })
})
expect(screen.getByText('Grace Hopper hasn’t turned on Pokes yet.')).toBeTruthy()
expect(screen.getByRole('button', { name: /Poke/ }).hasAttribute('disabled')).toBe(true)
const poke = screen.getByRole('button', { name: /Poke/ })
// Muted and explained, but still focusable; a click does nothing.
expect(poke.getAttribute('aria-disabled')).toBe('true')
expect(poke.hasAttribute('disabled')).toBe(false)
expect(poke.getAttribute('aria-describedby')).toBeTruthy()
fireEvent.click(poke)
expect(onPoke).not.toHaveBeenCalled()
})
it('says when a teammate has not shared their activity', () => {
@@ -1,8 +1,10 @@
import { useId } from 'react'
import { ArrowUpRight, Hand } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { InlineError } from '@/components/ui/inline-error'
import { Skeleton } from '@/components/ui/skeleton'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useIntlLocale, useTranslate, type Translate } from '@/i18n/use-translate'
import { cn } from '@/lib/utils'
import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire'
@@ -135,6 +137,7 @@ export function TeammateProfileView(props: TeammateProfileViewProps): React.JSX.
const { profile } = props
const self = profile?.self ?? false
const footer = pokeFooterCopy(props, t)
const pokeNoteId = useId()
const loading = profile === null && props.error === null
return (
<div className="flex flex-col gap-6" aria-busy={loading || undefined}>
@@ -275,6 +278,7 @@ export function TeammateProfileView(props: TeammateProfileViewProps): React.JSX.
props.poke.kind === 'refused' ? 'text-foreground' : 'text-muted-foreground'
)}
role="status"
id={pokeNoteId}
>
{footer.note ??
t(
@@ -288,20 +292,30 @@ export function TeammateProfileView(props: TeammateProfileViewProps): React.JSX.
{t('auto.components.teammates.openExperimental', 'Open settings')}
</Button>
) : (
<Button
size="sm"
variant="outline"
disabled={!footer.canPoke}
aria-disabled={props.poke.kind === 'sending' || undefined}
onClick={() => {
if (props.poke.kind !== 'sending') {
props.onPoke()
}
}}
>
<Hand aria-hidden />
{t('auto.components.teammates.poke', 'Poke')}
</Button>
<Tooltip>
<TooltipTrigger asChild>
{/* aria-disabled, not disabled: it stays focusable and hoverable so the tooltip can
say why it can't poke, with the muted disabled look. */}
<Button
size="sm"
variant="outline"
aria-disabled={!footer.canPoke || undefined}
aria-busy={props.poke.kind === 'sending' || undefined}
aria-describedby={footer.canPoke ? undefined : pokeNoteId}
onClick={() => {
if (footer.canPoke && props.poke.kind !== 'sending') {
props.onPoke()
}
}}
>
<Hand aria-hidden />
{t('auto.components.teammates.poke', 'Poke')}
</Button>
</TooltipTrigger>
{!footer.canPoke && footer.note ? (
<TooltipContent side="top">{footer.note}</TooltipContent>
) : null}
</Tooltip>
)}
</footer>
) : null}
@@ -0,0 +1,44 @@
import React from 'react'
import type { CloudOpenCover } from '@/lib/orca-cloud/cloud-open-cover-store'
import WorktreeCreationTabStrip from './WorktreeCreationTabStrip'
import { VmBuildSheet } from './VmBuildSheet'
const noop = (): void => {}
/** The creation panel's last frame, laid out exactly like WorktreeCreationPanel. */
export default function CloudOpenCoverBody({
cover,
now,
reserveCollapsedSidebarHeaderSpace
}: {
cover: CloudOpenCover
now: number
reserveCollapsedSidebarHeaderSpace: boolean
}): React.JSX.Element {
const { entry } = cover
return (
<div data-cloud-open-cover="" className="absolute inset-0 z-20 flex flex-col bg-background">
<WorktreeCreationTabStrip
title={entry.request.displayName || entry.request.name}
isError={false}
reserveCollapsedSidebarHeaderSpace={reserveCollapsedSidebarHeaderSpace}
onCancel={noop}
/>
{/* A picture of the last frame: nothing in it is interactive. */}
<div className="min-h-0 flex-1 p-3" inert>
<VmBuildSheet
creationId={entry.creationId}
entry={entry}
now={now}
elapsedLabel=""
error={null}
onCancel={noop}
onRetry={noop}
onDismiss={noop}
onReturnToComposer={noop}
onEditRecipe={noop}
/>
</div>
</div>
)
}
@@ -0,0 +1,80 @@
import React, { useEffect, useSyncExternalStore } from 'react'
import { useAppStore } from '@/store'
import { useNow } from '@/hooks/use-now'
import {
CLOUD_OPEN_COVER_MAX_MS,
releaseCloudOpenCover,
useCloudOpenCover,
workspaceHasAttachedPty
} from '@/lib/orca-cloud/cloud-open-cover-store'
import type CloudOpenCoverBodyComponent from './CloudOpenCoverBody'
// The body pulls in the build sheet, which the shell must not load at boot. The creation panel
// preloads it, so it is ready (no Suspense frame) by the time a create hands over.
let Body: typeof CloudOpenCoverBodyComponent | null = null
let loading: Promise<void> | null = null
const bodyListeners = new Set<() => void>()
export function preloadCloudOpenCover(): Promise<void> {
loading ??= import('./CloudOpenCoverBody')
.then((module) => {
Body = module.default
for (const listener of bodyListeners) {
listener()
}
})
.catch(() => {
loading = null
})
return loading
}
function useBody(): typeof CloudOpenCoverBodyComponent | null {
return useSyncExternalStore(
(listener) => {
bodyListeners.add(listener)
return () => bodyListeners.delete(listener)
},
() => Body
)
}
/**
* Keeps the Orca Cloud start screen over a just-created workspace until its terminal's PTY is
* attached (JA-12), so "Open workspace" goes straight to a live prompt with no blank pane between.
*/
export function CloudOpenCoverLayer({
reserveCollapsedSidebarHeaderSpace
}: {
reserveCollapsedSidebarHeaderSpace: boolean
}): React.JSX.Element | null {
const worktreeId = useAppStore((s) => (s.activeView === 'terminal' ? s.activeWorktreeId : null))
const cover = useCloudOpenCover(worktreeId)
const attached = useAppStore((s) =>
cover ? workspaceHasAttachedPty(s, cover.worktreeId) : false
)
const BodyComponent = useBody()
const now = useNow(1_000, cover !== null)
useEffect(() => {
if (!cover) {
return
}
if (attached) {
releaseCloudOpenCover(cover.worktreeId)
return
}
void preloadCloudOpenCover()
const left = Math.max(0, cover.heldAt + CLOUD_OPEN_COVER_MAX_MS - Date.now())
const timer = window.setTimeout(() => releaseCloudOpenCover(cover.worktreeId), left)
return () => window.clearTimeout(timer)
}, [attached, cover])
if (!cover || attached || !BodyComponent) {
return null
}
// The body is a module-level component loaded once, not one made during render.
return React.createElement(BodyComponent, {
cover,
now: Math.max(now, cover.heldAt),
reserveCollapsedSidebarHeaderSpace
})
}
@@ -209,51 +209,54 @@ export function VmBuildSheet({
return (
<div data-vm-build-sheet className="flex min-h-full justify-center pt-12">
<div className="flex w-full max-w-2xl flex-col gap-4">
<div className="flex min-h-7 items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="grid size-4 shrink-0 place-items-center">
{building ? (
<Loader2 aria-hidden className="size-4 text-muted-foreground animate-spin" />
) : state === 'ready' ? (
<CircleCheck aria-hidden className="size-4 text-foreground" />
) : gated ? (
<CircleAlert aria-hidden className="size-4 text-muted-foreground" />
) : (
<AlertTriangle
aria-hidden
// Failed checks leave a running sandbox that needs a decision, not a broken start.
className={cn(
'size-4 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-90 motion-safe:duration-200',
failure?.kind === 'checks' && !cloud
? 'text-status-warning-text'
: 'text-destructive'
)}
/>
{/* JA-24: on Orca Cloud a failure card carries the one title; no page title above it. */}
{cloud && failure ? null : (
<div className="flex min-h-7 items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="grid size-4 shrink-0 place-items-center">
{building ? (
<Loader2 aria-hidden className="size-4 text-muted-foreground animate-spin" />
) : state === 'ready' ? (
<CircleCheck aria-hidden className="size-4 text-foreground" />
) : gated ? (
<CircleAlert aria-hidden className="size-4 text-muted-foreground" />
) : (
<AlertTriangle
aria-hidden
// Failed checks leave a running sandbox that needs a decision, not a broken start.
className={cn(
'size-4 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-90 motion-safe:duration-200',
failure?.kind === 'checks' && !cloud
? 'text-status-warning-text'
: 'text-destructive'
)}
/>
)}
</span>
{/* Retry lands focus here once the failure card collapses. The card announces failures. */}
<h2
data-vm-build-heading
tabIndex={-1}
aria-live={failure ? 'off' : 'polite'}
className="text-sm font-medium text-foreground outline-none"
>
{headerTitle(state, cloud, t, failure?.kind === 'create')}
</h2>
{/* Orca Cloud: each step carries its own timer, so the header has none. */}
{gated || cloud ? null : (
<span className="text-xs tabular-nums text-muted-foreground">{elapsedLabel}</span>
)}
</span>
{/* Retry lands focus here once the failure card collapses. The card announces failures. */}
<h2
data-vm-build-heading
tabIndex={-1}
aria-live={failure ? 'off' : 'polite'}
className="text-sm font-medium text-foreground outline-none"
>
{headerTitle(state, cloud, t, failure?.kind === 'create')}
</h2>
{/* Orca Cloud: each step carries its own timer, so the header has none. */}
{gated || cloud ? null : (
<span className="text-xs tabular-nums text-muted-foreground">{elapsedLabel}</span>
)}
</div>
{building ? (
<Button type="button" variant="ghost" size="xs" onClick={onCancel}>
{t(
'auto.components.worktree.creation.WorktreeCreationPanel.cancelProvisioning',
'Cancel'
)}
</Button>
) : null}
</div>
{building ? (
<Button type="button" variant="ghost" size="xs" onClick={onCancel}>
{t(
'auto.components.worktree.creation.WorktreeCreationPanel.cancelProvisioning',
'Cancel'
)}
</Button>
) : null}
</div>
)}
<div>
{/* An account refusal happens before anything starts, so there are no steps to show. */}
@@ -16,6 +16,7 @@ import {
} from './worktree-creation-clock'
import { VmBuildSheet } from './VmBuildSheet'
import { VmDeleteConfirmDialog } from './VmDeleteConfirmDialog'
import { preloadCloudOpenCover } from './CloudOpenCoverLayer'
import { composerDataFromRefusedCreate } from './vm-build-composer-return'
/** Debounced in-frame creation placeholder: tab strip names the workspace while body shows status, enabling same-frame handoff without duplicating labels. */
@@ -35,6 +36,13 @@ export default function WorktreeCreationPanel({
const now = useWorktreeCreationClock(
entry?.status === 'creating' && entry.waitingSince === undefined
)
const cloudCreate = entry?.request.ephemeralVmRecipe?.provider === 'orca-cloud'
React.useEffect(() => {
// The open cover takes over from this panel when the create finishes (JA-12).
if (cloudCreate) {
void preloadCloudOpenCover()
}
}, [cloudCreate])
if (!entry) {
return null
}
+1 -5
View File
@@ -2131,14 +2131,10 @@
"cloudHours": {
"planChip_one": "{{hours}} sandbox hour left",
"planChip_other": "{{hours}} sandbox hours left",
"planToast_one": "Using sandbox hours · {{hours}} sandbox hour left",
"planToast_other": "Using sandbox hours · {{hours}} sandbox hours left",
"trialChip_one": "Trial · {{hours}} sandbox hour left",
"trialChip_other": "Trial · {{hours}} sandbox hours left",
"trialOffered_one": "Trial · {{hours}} sandbox hour left · No card needed",
"trialOffered_other": "Trial · {{hours}} sandbox hours left · No card needed",
"trialToast_one": "Using sandbox hours · {{hours}} sandbox hour left in your trial",
"trialToast_other": "Using sandbox hours · {{hours}} sandbox hours left in your trial"
"trialOffered_other": "Trial · {{hours}} sandbox hours left · No card needed"
},
"cloudSetup": {
"trialOffer_one": "{{hours}} sandbox hour to try Orca Cloud",
+37 -27
View File
@@ -2291,7 +2291,9 @@
"preparingCloudEnvironment": "Preparing environment…",
"startingSandbox": "Starting sandbox…",
"cloningRepository": "Cloning the repository…",
"updatingRepository": "Updating the repository…"
"updatingRepository": "Updating the repository…",
"cloudBlockedAsleep": "Asleep",
"cloudCouldNotOpen": "Couldn’t open"
},
"duplicatePairedServers": {
"ambiguousSameServer": "{{value0}} and {{value1}} are the same server paired twice. Remove one in Settings > Remote Orca Servers."
@@ -14600,7 +14602,6 @@
"sectionDescription": "For the Orca Cloud API, the orca CLI and MCP clients. Claude or Codex keys go in Cloud secrets."
},
"cloudRoutines": {
"queued": "Run queued. It shows up in the history below.",
"pausedSr": "(paused)",
"createdByPerson": "by <person></person>",
"lastRun": "Last run:",
@@ -14609,7 +14610,6 @@
"runningShowHistory": "{{routineName}} is running. Show run history",
"runNowLabel": "Run {{routineName}} now",
"alreadyRunning": "A run is already in progress.",
"runningNow": "Running…",
"runNow": "Run now",
"more": "More actions for {{routineName}}",
"hideHistory": "Hide run history",
@@ -20651,7 +20651,6 @@
"makeOwnerBody": "Owners control billing and can manage other owners. To hand the team over, make {{value0}} an owner, then ask them to change your role.",
"makeOwnerConfirm": "Make owner",
"makeOwnerTitle": "Make {{value0}} an owner?",
"manage": "Members, invites and seats",
"members": "Members",
"membersList": "Team members and invites",
"nextInvoice": "Next invoice (est.)",
@@ -20719,7 +20718,8 @@
"totalPerMonth": "{{total}} per month",
"totalPerYear": "{{total}} per year",
"openProfile": "Open {{value0}}’s profile",
"viewProfile": "View profile"
"viewProfile": "View profile",
"seatsOnPlanBilling": "Seats and invoices are in"
},
"manageBilling": "Manage billing",
"comparePlans": "Compare plans",
@@ -23231,10 +23231,6 @@
"trialChip_other": "Trial · {{hours}} sandbox hours left",
"planChip_one": "{{hours}} sandbox hour left",
"planChip_other": "{{hours}} sandbox hours left",
"trialToast_one": "Using sandbox hours · {{hours}} sandbox hour left in your trial",
"trialToast_other": "Using sandbox hours · {{hours}} sandbox hours left in your trial",
"planToast_one": "Using sandbox hours · {{hours}} sandbox hour left",
"planToast_other": "Using sandbox hours · {{hours}} sandbox hours left",
"trialOffered_one": "Trial · {{hours}} sandbox hour left · No card needed",
"trialOffered_other": "Trial · {{hours}} sandbox hours left · No card needed"
},
@@ -23257,10 +23253,15 @@
"folderDetail": "Orca Cloud starts from a GitHub repository. This project is a folder on this computer."
},
"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"
"agentNotStartedShort": "{{agent}} isn’t set up for Orca Cloud, so it didn’t start.",
"agentNotStartedIn": "{{agent}} isn’t set up for Orca Cloud, so it didn’t start in {{workspace}}.",
"copyCode": "Copy code",
"openSettings": "Open Settings",
"signInBrowser": "Finish signing in to {{agent}} in your browser…",
"signInDevice": "Enter {{code}} at {{host}} to sign in to {{agent}}.",
"signInPaste": "Paste the {{agent}} setup token in Settings to finish.",
"signInSaving": "Saving…"
}
},
"JiraIssueWorkspace": {
@@ -27936,7 +27937,6 @@
"signIn": "Sign in",
"loadFailed": "Couldn’t load your agent accounts.",
"title": "Orca Cloud",
"help": "Set up once on this computer. Every cloud workspace uses it, including new, cloned and restarted ones.",
"policy": "Your Claude and ChatGPT accounts are used only in your own workspaces. Teammates never use them.",
"codeCopied": "Code copied",
"agent": {
@@ -27965,7 +27965,8 @@
"unavailableDetail": "Signing in once with your {{product}} account isn’t available in Orca Cloud yet. Each new cloud workspace asks you to sign in.",
"allUnavailable": "Claude and Codex sign in for each cloud workspace for now.",
"pasteTokenInstead": "Paste a token instead",
"rowMenu": "{{agent}} account options"
"rowMenu": "{{agent}} account options",
"helpAccountWide": "Set up once. Every cloud workspace uses it, including new, cloned and restarted ones."
},
"vmBuildAgents": {
"menu": {
@@ -28092,13 +28093,6 @@
"expiredCompact": "{{agent}} sign-in expired"
},
"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.",
@@ -28117,16 +28111,22 @@
"copyLink": "Copy link",
"done": {
"lead": "Starting your cloud workspace…",
"title": "All set"
"title": "All set",
"accountLead": "Your Orca account is ready for Orca Cloud.",
"accountTitle": "You’re signed in",
"agentTitle": "{{agent}} is set up for Orca Cloud",
"agentTitleGeneric": "Your agent is set up for Orca Cloud",
"githubTitle": "Orca Cloud can reach this repository",
"planTitle": "Your team has Orca Cloud",
"teamTitle": "Team selected",
"trialTitle": "Your trial is on"
},
"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"
@@ -28149,7 +28149,6 @@
"title": "Sign in to Orca"
},
"starting": "Starting…",
"startsAfter": "Starts when setup is done",
"step": {
"account": "Account",
"agent": "Agent",
@@ -28173,7 +28172,18 @@
"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…"
"waitingBrowser": "Waiting for your browser…",
"continue": "Continue",
"exit": "Exit setup",
"exited": "Orca Cloud setup closed. Your draft is kept.",
"header": "Setting up Orca Cloud",
"headerFor": "Setting up Orca Cloud for {{name}}",
"resumeDraft": "Open draft"
},
"orcaCloudSignInSheet": {
"title": "Agents in Orca Cloud",
"description": "Set up Claude and Codex once. Every cloud workspace uses them, including new, cloned and restarted ones.",
"footerLabel": "Related settings",
"allAccounts": "All agent accounts"
}
}
@@ -28,6 +28,8 @@ export type PrepareEphemeralVmWorkspaceTargetArgs = {
branch?: string
ref?: string
provisionId?: string
/** Orca Cloud: the Create press's key (one press, one sandbox). */
idempotencyKey?: string
setupExistingFolder: (
args: ProjectHostSetupExistingFolderArgs
) => Promise<ProjectHostSetupResult | null>
@@ -62,7 +64,8 @@ export async function prepareEphemeralVmWorkspaceTarget(
workspaceName: args.workspaceName,
...(args.branch ? { branch: args.branch } : {}),
...(args.ref ? { ref: args.ref } : {}),
...(args.provisionId ? { provisionId: args.provisionId } : {})
...(args.provisionId ? { provisionId: args.provisionId } : {}),
...(args.idempotencyKey ? { idempotencyKey: args.idempotencyKey } : {})
})
if (!provisioned.ok) {
return {
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// JA-01 tail: Delete sandbox on a create stopped by its environment check must delete THAT sandbox
// (its runtime, hence its control-plane record), and the restart path must delete it before retrying.
const { state, gate, cleanup, retry } = vi.hoisted(() => {
const entries: Record<string, Record<string, unknown>> = {}
return {
state: {
entries,
repos: [
{
id: 'repo-src',
displayName: 'Hello-World',
gitRemoteIdentity: { remoteUrl: 'https://github.com/octocat/Hello-World.git' }
}
]
},
gate: vi.fn(),
cleanup: vi.fn(async (_request: unknown, _actions?: unknown) => true),
retry: vi.fn()
}
})
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({
pendingWorktreeCreations: state.entries,
repos: state.repos,
projects: [],
activePendingCreationId: 'c1',
updatePendingWorktreeCreation: (id: string, patch: Record<string, unknown>) => {
if (state.entries[id]) {
Object.assign(state.entries[id], patch)
}
},
setRuntimeEnvironments: vi.fn(),
setupProjectExistingFolder: vi.fn(),
deleteProjectHostSetup: vi.fn()
})
}
}))
vi.mock('sonner', () => ({ toast: { error: vi.fn(), warning: vi.fn() } }))
vi.mock('@/lib/ephemeral-vm-environment-check', () => ({ gateVmCreateOnEnvironmentCheck: gate }))
vi.mock('@/lib/ephemeral-vm-failed-create-cleanup', () => ({
cleanupFailedEphemeralVmWorkspace: cleanup
}))
vi.mock('@/lib/vm-create-hidden-checkouts', () => ({
hideVmCreateCheckout: vi.fn(),
settleVmCreateCheckoutAfterCleanup: vi.fn()
}))
vi.mock('@/lib/workspace/creation/worktree-creation-flow', () => ({
retryBackgroundWorktreeCreation: retry
}))
vi.mock('@/lib/ephemeral-vm-workspace-target', () => ({
prepareEphemeralVmWorkspaceTarget: vi.fn(async () => ({
ok: true,
setup: {
repo: { id: 'repo-vm', path: '/vercel/orca/workspaces/Hello-World/project' },
setup: { id: 'setup-1', projectId: 'p1', hostId: 'runtime:env-1' }
},
runtimeId: 'rt-blocked',
checkoutMode: 'provisioned-root',
environmentId: 'env-1',
stderr: '',
warnings: []
}))
}))
import { prepareRequestForCreate } from './ephemeral-vm-worktree-creation'
import type { WorktreeCreationRequest } from '@/lib/workspace/creation/pending-worktree-creation'
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create path reads only these fields.
const request = {
name: 'elephantfish',
setupDecision: 'inherit',
ephemeralVmRecipe: {
sourceRepoId: 'repo-src',
recipeId: 'cloud',
projectId: 'p1',
checkoutMode: 'provisioned-root',
provider: 'orca-cloud'
}
} as unknown as WorktreeCreationRequest
describe('prepareRequestForCreate on a create stopped by its environment check', () => {
beforeEach(() => {
state.entries.c1 = { request }
vi.stubGlobal('window', {
api: { ephemeralVm: {}, runtimeEnvironments: { list: async () => [] } }
})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('Delete sandbox removes that very sandbox and creates nothing', async () => {
gate.mockResolvedValue('stop')
await expect(prepareRequestForCreate('c1', request)).resolves.toBeNull()
expect(cleanup).toHaveBeenCalledOnce()
expect(cleanup.mock.calls[0][0]).toMatchObject({ ephemeralVmRuntimeId: 'rt-blocked' })
expect(retry).not.toHaveBeenCalled()
})
it('Start again deletes the blocked sandbox before starting a new one', async () => {
gate.mockResolvedValue('restart')
await expect(prepareRequestForCreate('c1', request)).resolves.toBeNull()
expect(cleanup.mock.calls[0][0]).toMatchObject({ ephemeralVmRuntimeId: 'rt-blocked' })
expect(retry).toHaveBeenCalledWith('c1')
expect(cleanup.mock.invocationCallOrder[0]).toBeLessThan(retry.mock.invocationCallOrder[0])
})
})
@@ -19,6 +19,8 @@ import {
appendProvisioningLog,
appendProvisioningWarnings
} from '@/lib/pending-creation-provisioning-log'
import { cloudCreateIdempotencyKey } from '@/lib/orca-cloud/cloud-create-intent'
import { ORCA_CLOUD_RECIPE_ID } from '../../../shared/hosted-sandbox/orca-cloud-recipe-id'
export async function prepareRequestForCreate(
creationId: string,
@@ -71,6 +73,7 @@ async function prepareEphemeralVmRecipeRequest(
}
: {}),
provisionId: creationId,
...cloudCreateIdempotency(recipe.recipeId, creationId),
setupExistingFolder: async (args) => {
// The VM's checkout folder is named "project"; register it under the real project's name.
const displayName =
@@ -267,3 +270,12 @@ export function getProvisionedRootSparseCheckoutError(): string {
'Provisioned-root recipes do not support sparse checkout.'
)
}
/** Orca Cloud creates carry the Create press's Idempotency-Key; other recipes don't. */
function cloudCreateIdempotency(recipeId: string, creationId: string): { idempotencyKey?: string } {
if (recipeId !== ORCA_CLOUD_RECIPE_ID) {
return {}
}
const key = cloudCreateIdempotencyKey(creationId)
return key ? { idempotencyKey: key } : {}
}
@@ -0,0 +1,116 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
const toastMock = vi.hoisted(() => ({ message: vi.fn(), dismiss: vi.fn() }))
vi.mock('sonner', () => ({ toast: toastMock }))
vi.mock('@/components/new-workspace/OrcaCloudAgentSetupRow', () => ({
openOrcaCloudAgentAccounts: vi.fn()
}))
import { useAppStore } from '@/store/index'
import type { OrcaCloudAgentsStatus } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire'
import {
setOrcaCloudAgentAccountsStateForTest,
type OrcaCloudAgentAccountsState
} from './orca-cloud-agent-accounts-store'
import {
cloudAgentNotStartedMessage,
showCloudAgentNotStartedToast
} from './cloud-agent-not-started-toast'
function accounts(
claude: OrcaCloudAgentsStatus['credentials']['claude']
): OrcaCloudAgentAccountsState {
return {
load: {
kind: 'ready',
status: {
credentials: {
claude,
codex: { state: 'none' },
providerEnabled: { claude: true, codex: true }
},
sources: { defaults: {}, coveredByKey: { claude: false, codex: false } }
}
},
setups: { claude: { phase: 'idle' }, codex: { phase: 'idle' } },
keyKinds: { claude: null, codex: null },
queue: { agents: [], index: 0 }
}
}
describe('cloud agent not-started toast', () => {
beforeEach(() => {
toastMock.message.mockClear()
toastMock.dismiss.mockClear()
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'none' }))
useAppStore.setState({ activeWorktreeId: null })
})
it('says it once, with no extra sentence, and names the workspace only after a switch', () => {
expect(cloudAgentNotStartedMessage('Claude', null)).toBe(
'Claude isn’t set up for Orca Cloud, so it didn’t start.'
)
expect(cloudAgentNotStartedMessage('Claude', 'fix-readme-typo')).toBe(
'Claude isn’t set up for Orca Cloud, so it didn’t start in fix-readme-typo.'
)
showCloudAgentNotStartedToast('claude', 'fix-readme-typo')
expect(toastMock.message).toHaveBeenLastCalledWith(
'Claude isn’t set up for Orca Cloud, so it didn’t start.',
expect.objectContaining({
id: 'orca-cloud-agent-not-started',
action: expect.objectContaining({ label: 'Set up Claude once' })
})
)
// The create activates the new workspace, then the user moves elsewhere.
useAppStore.setState({ activeWorktreeId: 'repo::new' })
expect(toastMock.message).toHaveBeenCalledTimes(1)
useAppStore.setState({ activeWorktreeId: 'repo::other' })
expect(toastMock.message).toHaveBeenLastCalledWith(
'Claude isn’t set up for Orca Cloud, so it didn’t start in fix-readme-typo.',
expect.anything()
)
})
it('clears itself once the agent account turns active', () => {
showCloudAgentNotStartedToast('claude', 'w')
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'expired' }))
expect(toastMock.dismiss).not.toHaveBeenCalled()
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' }))
expect(toastMock.dismiss).toHaveBeenCalledWith('orca-cloud-agent-not-started')
})
it('"Set up Claude once" starts the sign-in right from the toast, which stays up', () => {
const start = vi.fn(async () => undefined)
Object.assign(window, {
api: {
orcaCloudAgents: {
start,
setups: vi.fn(async () => ({ claude: { phase: 'idle' }, codex: { phase: 'idle' } })),
onSetupChanged: vi.fn(() => () => undefined)
}
}
})
showCloudAgentNotStartedToast('claude', 'w')
const options: {
duration: number
action: { onClick: (event: { preventDefault: () => void }) => void }
} = toastMock.message.mock.calls.at(-1)?.[1]
expect(options.duration).toBe(Number.POSITIVE_INFINITY)
const preventDefault = vi.fn()
options.action.onClick({ preventDefault })
expect(preventDefault).toHaveBeenCalled()
expect(start).toHaveBeenCalledWith({ agent: 'claude' })
})
it('in the workspace itself it stays hidden (its pane strip asks); away, it names it', () => {
showCloudAgentNotStartedToast('claude', 'fix-readme-typo', { onlyWhenAway: true })
expect(toastMock.message).not.toHaveBeenCalled()
useAppStore.setState({ activeWorktreeId: 'repo::new' })
useAppStore.setState({ activeWorktreeId: 'repo::other' })
expect(toastMock.message).toHaveBeenLastCalledWith(
'Claude isn’t set up for Orca Cloud, so it didn’t start in fix-readme-typo.',
expect.anything()
)
})
})
@@ -0,0 +1,246 @@
// After a cloud create whose agent has no Orca Cloud account, the workspace starts with a terminal
// (never the agent's /login). This one toast is the only sign-in prompt (design-squad journey rule;
// PC-1, PC-3).
// - It stays until the user acts on it or dismisses it.
// - It names the workspace once the user has moved to another one.
// - "Set up Claude once" runs the S0 sign-in right from the toast: Claude via setup-token in the
// browser, Codex via a device code. The toast shows progress and errors, with Try again.
// - It clears itself as soon as the account turns active.
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store/index'
import { openOrcaCloudAgentAccounts } from '@/components/new-workspace/OrcaCloudAgentSetupRow'
import {
agentName,
setupFailureCopy
} from '@/components/settings/agent-accounts/orca-cloud-accounts-copy'
import type {
OrcaCloudAccountAgent,
OrcaCloudAgentSetup
} from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire'
import { orcaCloudAgentCoverageFromAccounts } from './orca-cloud-agent-accounts-model'
import {
readOrcaCloudAgentAccounts,
subscribeOrcaCloudAgentAccounts
} from './orca-cloud-agent-accounts-store'
const TOAST_ID = 'orca-cloud-agent-not-started'
let teardown: (() => void) | null = null
export function cloudAgentNotStartedMessage(agent: string, workspaceName: string | null): string {
return workspaceName
? translate(
'components.newWorkspace.cloudStart.agentNotStartedIn',
'{{agent}} isn’t set up for Orca Cloud, so it didn’t start in {{workspace}}.',
{ agent, workspace: workspaceName }
)
: translate(
'components.newWorkspace.cloudStart.agentNotStartedShort',
'{{agent}} isn’t set up for Orca Cloud, so it didn’t start.',
{ agent }
)
}
/** True once the agent signs in from the Orca account or a key covers it. */
export function isCloudAgentCovered(agent: OrcaCloudAccountAgent): boolean {
const coverage = orcaCloudAgentCoverageFromAccounts(agent, readOrcaCloudAgentAccounts())
return coverage?.kind === 'account' || coverage?.kind === 'key'
}
type ToastView = {
message: string
description?: string
action?: { label: string; run: () => void }
}
function deviceHost(url: string): string {
try {
return new URL(url).hostname
} catch {
return url
}
}
/** What the toast says while the S0 sign-in runs; null while it is idle (the plain prompt). */
export function cloudAgentSignInView(
agent: OrcaCloudAccountAgent,
setup: OrcaCloudAgentSetup
): ToastView | null {
const api = globalThis.window?.api?.orcaCloudAgents
const name = agentName(agent, translate)
switch (setup.phase) {
case 'idle':
case 'saved':
return null
case 'browser':
return {
message: translate(
'components.newWorkspace.cloudStart.signInBrowser',
'Finish signing in to {{agent}} in your browser…',
{ agent: name }
),
// Every phase has a button: an updated toast keeps the last one otherwise.
action: setup.canReopen
? {
label: translate('orcaCloudAccounts.openAgain', 'Open again'),
run: () => void api?.reopen({ agent })
}
: {
label: translate('orcaCloudAccounts.cancel', 'Cancel'),
run: () => void api?.cancel({ agent })
}
}
case 'preparing':
return {
message: translate('orcaCloudAccounts.preparing', 'Getting ChatGPT sign-in ready'),
action: {
label: translate('orcaCloudAccounts.cancel', 'Cancel'),
run: () => void api?.cancel({ agent })
}
}
case 'device':
return {
message: translate(
'components.newWorkspace.cloudStart.signInDevice',
'Enter {{code}} at {{host}} to sign in to {{agent}}.',
{ code: setup.userCode, host: deviceHost(setup.verificationUrl), agent: name }
),
action: {
label: translate('components.newWorkspace.cloudStart.copyCode', 'Copy code'),
run: () => void window.api.ui.writeClipboardText(setup.userCode).catch(() => {})
}
}
case 'paste':
// The token goes in Settings' masked field, never in a toast.
return {
message: translate(
'components.newWorkspace.cloudStart.signInPaste',
'Paste the {{agent}} setup token in Settings to finish.',
{ agent: name }
),
action: {
label: translate('components.newWorkspace.cloudStart.openSettings', 'Open Settings'),
run: openOrcaCloudAgentAccounts
}
}
case 'saving':
return {
message: translate('components.newWorkspace.cloudStart.signInSaving', 'Saving…')
}
case 'failed': {
const copy = setupFailureCopy(agent, setup.error, translate)
return {
message: copy.title,
...(copy.detail ? { description: copy.detail } : {}),
action: {
label: translate('orcaCloudAccounts.tryAgain', 'Try again'),
run: () => void api?.start({ agent })
}
}
}
}
}
export function showCloudAgentNotStartedToast(
agent: OrcaCloudAccountAgent,
workspaceName: string,
{
onlyWhenAway = false
}: {
/**
* The workspace's agent pane shows its own held strip (PC-3), so the toast appears only while
* the user is in another workspace, naming this one.
*/
onlyWhenAway?: boolean
} = {}
): void {
teardown?.()
const name = agentName(agent, translate)
let switchedAway = false
const render = (): void => {
const signIn = cloudAgentSignInView(agent, readOrcaCloudAgentAccounts().setups[agent])
const view: ToastView = signIn ?? {
message: cloudAgentNotStartedMessage(
name,
switchedAway && workspaceName.trim() ? workspaceName : null
),
action: {
label: translate(
'components.newWorkspace.cloudStart.setUpAgentOnce',
'Set up {{agent}} once',
{ agent: name }
),
run: () => void globalThis.window?.api?.orcaCloudAgents?.start({ agent })
}
}
toast.message(view.message, {
id: TOAST_ID,
...(view.description ? { description: view.description } : {}),
// Persistent (PC-3): it stays until the user acts on it, closes it, or the account is active.
duration: Number.POSITIVE_INFINITY,
closeButton: true,
...(view.action
? {
action: {
label: view.action.label,
onClick: (event: { preventDefault: () => void }) => {
// Keep the toast: it turns into the sign-in's progress.
event.preventDefault()
view.action?.run()
}
}
}
: {}),
// A close by the user ends it; a hide on returning to the workspace (onlyWhenAway) doesn't.
onDismiss: () => {
if (!onlyWhenAway || switchedAway) {
teardown?.()
}
}
})
}
const update = (): void => {
if (onlyWhenAway && !switchedAway) {
toast.dismiss(TOAST_ID)
return
}
render()
}
update()
// The create activates the new workspace (first change); any later change means the user moved.
let createdOn: string | null | undefined
const stopWorkspace = useAppStore.subscribe((state, previous) => {
if (state.activeWorktreeId === previous.activeWorktreeId) {
return
}
if (createdOn === undefined) {
createdOn = state.activeWorktreeId ?? null
return
}
const away = state.activeWorktreeId !== createdOn
if (away !== switchedAway) {
switchedAway = away
update()
}
})
let lastPhase = readOrcaCloudAgentAccounts().setups[agent].phase
const stopAccounts = subscribeOrcaCloudAgentAccounts(() => {
if (isCloudAgentCovered(agent)) {
toast.dismiss(TOAST_ID)
teardown?.()
return
}
const phase = readOrcaCloudAgentAccounts().setups[agent].phase
if (phase !== lastPhase) {
lastPhase = phase
update()
}
})
teardown = () => {
stopWorkspace()
stopAccounts()
teardown = null
}
}
@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
cancelCloudCreateIntent,
cloudCreateIdempotencyKey,
consumeCloudCreateIntent,
resetCloudCreateIntentsForTest,
startCloudCreate
} from './cloud-create-intent'
import {
closeCloudSetupPage,
getCloudSetupPageRequest,
openCloudSetupPage
} from './cloud-setup-page-store'
// One Create press makes exactly one server create, across sign-in, the setup page, its auto-start,
// a reveal, Exit, Back and a re-open (P0, JA-01/PC-8). `create` stands in for the server create.
function press(create: (id: string) => void, setupNeeded: boolean): void {
startCloudCreate({
setupOpen: getCloudSetupPageRequest() !== null,
setupNeeded,
openSetup: (intentId) =>
openCloudSetupPage({
prompt: 'p',
repoId: 'r',
agent: 'claude',
availability: 'needs-sign-in',
cloudOnlyRepo: null,
composer: {},
intentId
}),
create
})
}
describe('one Create, one server create', () => {
beforeEach(() => {
resetCloudCreateIntentsForTest()
closeCloudSetupPage()
})
it('a ready account creates once, and a double press makes two separate intents', () => {
const create = vi.fn()
press(create, false)
expect(create).toHaveBeenCalledTimes(1)
const [intent] = create.mock.calls[0] ?? []
expect(consumeCloudCreateIntent(intent)).toBe(false)
})
it('setup: nothing creates while the page is open; the auto-start creates once', () => {
const create = vi.fn()
press(create, true)
const request = getCloudSetupPageRequest()
expect(request).not.toBeNull()
expect(create).not.toHaveBeenCalled()
// Another press while the page is open (a stale composer, a keyboard repeat) does nothing.
press(create, false)
expect(create).not.toHaveBeenCalled()
// Sign-in lands and the page reopens the hidden composer: the auto-start consumes the intent.
closeCloudSetupPage()
if (consumeCloudCreateIntent(request?.intentId)) {
create(request?.intentId)
}
// The 8 s reveal remounts the composer, and its effect runs again: no second create.
if (consumeCloudCreateIntent(request?.intentId)) {
create(request?.intentId)
}
expect(create).toHaveBeenCalledTimes(1)
})
it('Exit or Back cancels the start for real, even after a later sign-in', () => {
const create = vi.fn()
press(create, true)
const request = getCloudSetupPageRequest()
cancelCloudCreateIntent(request?.intentId)
closeCloudSetupPage()
// A later sign-in elsewhere, or a stale auto-start, finds nothing to run.
expect(consumeCloudCreateIntent(request?.intentId)).toBe(false)
expect(create).not.toHaveBeenCalled()
})
it('a new Start replaces the old setup, and the old press can never create', () => {
const create = vi.fn()
press(create, true)
const first = getCloudSetupPageRequest()
closeCloudSetupPage()
press(create, true)
const second = getCloudSetupPageRequest()
expect(second?.intentId).not.toBe(first?.intentId)
expect(consumeCloudCreateIntent(second?.intentId)).toBe(true)
expect(create).not.toHaveBeenCalled()
})
})
describe('Idempotency-Key per Create press', () => {
beforeEach(() => {
resetCloudCreateIntentsForTest()
closeCloudSetupPage()
})
it('every creation fired by one press sends the same key; an explicit retry gets its own', () => {
const create = vi.fn()
press(create, false)
const [intent] = create.mock.calls[0] ?? []
expect(intent).toMatch(/^create:[A-Za-z0-9_.:-]{8,120}$/)
expect(cloudCreateIdempotencyKey('creation-1')).toBe(intent)
// A re-fired start (another creation for the same press) dedupes on the server.
expect(cloudCreateIdempotencyKey('creation-2')).toBe(intent)
// Retry of creation-1 after a failure is a new attempt.
expect(cloudCreateIdempotencyKey('creation-1')).toBe(`${intent}:retry-1`)
})
it('no armed press, no key', () => {
expect(cloudCreateIdempotencyKey('creation-x')).toBeNull()
})
})
@@ -0,0 +1,110 @@
// One Create press on Orca Cloud is one intent, and one intent makes at most one server create, ever
// (P0, JA-01/PC-8). The intent travels from the composer through the setup page to the hidden
// auto-start; whichever path creates first consumes it, and every later attempt is dropped. Exit,
// Back and Cancel consume it without creating. The id doubles as the create's idempotency key.
import { createNonSecureContextUuid } from '../../../../shared/non-secure-context-uuid'
type IntentState = 'pending' | 'consumed'
const intents = new Map<string, IntentState>()
/** `create:<uuid>`, within the server's Idempotency-Key format (8–128 of [A-Za-z0-9_.:-]). */
function newIntentId(): string {
return `create:${createNonSecureContextUuid()}`
}
/** A fresh intent for one Create press. */
export function beginCloudCreateIntent(): string {
const id = newIntentId()
intents.set(id, 'pending')
return id
}
/** True exactly once per intent: the caller may create now. Unknown or used intents are refused. */
export function consumeCloudCreateIntent(id: string | null | undefined): boolean {
if (!id || intents.get(id) !== 'pending') {
return false
}
intents.set(id, 'consumed')
return true
}
/** Exit, Back or Cancel: the intent can never create. */
export function cancelCloudCreateIntent(id: string | null | undefined): void {
if (id) {
intents.set(id, 'consumed')
}
}
export function isCloudCreateIntentPending(id: string | null | undefined): boolean {
return id ? intents.get(id) === 'pending' : false
}
/**
* The server backstop (create-speed's Idempotency-Key): the intent a create was started with is
* armed for a while, and every creation launched under it sends the same key, so a re-fired start
* gets back the first sandbox instead of a second one. An explicit Retry of the same creation is a
* new attempt and gets its own suffix.
*/
const ARMED_FOR_MS = 10 * 60_000
let armed: { key: string; at: number } | null = null
const keyByCreation = new Map<string, { key: string; attempts: number }>()
/** The composer calls this right before it creates with an intent. */
export function armCloudCreateIdempotency(intentId: string, now: number = Date.now()): void {
armed = { key: intentId, at: now }
}
/** The key to send for a creation's provision; null with no armed intent. */
export function cloudCreateIdempotencyKey(
creationId: string,
now: number = Date.now()
): string | null {
const bound = keyByCreation.get(creationId)
if (bound) {
bound.attempts += 1
return `${bound.key}:retry-${bound.attempts}`
}
if (!armed || now - armed.at > ARMED_FOR_MS) {
return null
}
keyByCreation.set(creationId, { key: armed.key, attempts: 0 })
return armed.key
}
/** Tests only. */
export function resetCloudCreateIntentsForTest(): void {
intents.clear()
armed = null
keyByCreation.clear()
}
/**
* The composer's primary on Orca Cloud. Never creates while a setup page is open; otherwise starts
* one intent and either hands it to the setup page or creates with it, once.
*/
export function startCloudCreate({
setupOpen,
setupNeeded,
openSetup,
create
}: {
setupOpen: boolean
setupNeeded: boolean
openSetup: (intentId: string) => void
create: (intentId: string) => void
}): void {
if (setupOpen) {
return
}
const id = beginCloudCreateIntent()
if (setupNeeded) {
openSetup(id)
return
}
if (consumeCloudCreateIntent(id)) {
armCloudCreateIdempotency(id)
create(id)
}
}
@@ -0,0 +1,37 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation'
import {
getCloudOpenCover,
holdCloudOpenCover,
releaseCloudOpenCover,
resetCloudOpenCovers,
workspaceHasAttachedPty
} from './cloud-open-cover-store'
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the store only keeps the entry.
const entry = { creationId: 'c1' } as PendingWorktreeCreation
afterEach(resetCloudOpenCovers)
describe('cloud open cover', () => {
it('holds the start screen for one workspace until released', () => {
holdCloudOpenCover('w1', entry, 1_000)
expect(getCloudOpenCover('w1')).toMatchObject({ worktreeId: 'w1', heldAt: 1_000 })
expect(getCloudOpenCover('w2')).toBeNull()
releaseCloudOpenCover('w1')
expect(getCloudOpenCover('w1')).toBeNull()
})
it('counts the workspace as open only once one of its terminals has a PTY', () => {
const noPtys: string[] = []
const state = {
tabsByWorktree: { w1: [{ id: 't1' }, { id: 't2' }] },
ptyIdsByTabId: { t1: noPtys }
}
expect(workspaceHasAttachedPty(state, 'w1')).toBe(false)
expect(
workspaceHasAttachedPty({ ...state, ptyIdsByTabId: { t1: [], t2: ['pty-1'] } }, 'w1')
).toBe(true)
expect(workspaceHasAttachedPty(state, 'missing')).toBe(false)
})
})
@@ -0,0 +1,73 @@
// JA-12: a just-created Orca Cloud workspace showed a blank pane for ~1 s between the start screen
// and its terminal (the create finishes, then the terminal's PTY attaches over the network). The
// start screen's last frame stays up over the new workspace until its PTY is attached, or a cap.
import { useSyncExternalStore } from 'react'
import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation'
/** Never cover longer than this: a terminal that is slow to attach shows its own state. */
export const CLOUD_OPEN_COVER_MAX_MS = 6_000
export type CloudOpenCover = {
worktreeId: string
/** The start screen as it was when the create finished ("Open workspace" running). */
entry: PendingWorktreeCreation
heldAt: number
}
let covers: Readonly<Record<string, CloudOpenCover>> = {}
const listeners = new Set<() => void>()
function emit(next: Readonly<Record<string, CloudOpenCover>>): void {
covers = next
for (const listener of listeners) {
listener()
}
}
export function holdCloudOpenCover(
worktreeId: string,
entry: PendingWorktreeCreation,
now = Date.now()
): void {
emit({ ...covers, [worktreeId]: { worktreeId, entry, heldAt: now } })
}
export function releaseCloudOpenCover(worktreeId: string): void {
if (!covers[worktreeId]) {
return
}
const { [worktreeId]: _released, ...rest } = covers
emit(rest)
}
export function getCloudOpenCover(worktreeId: string | null): CloudOpenCover | null {
return worktreeId ? (covers[worktreeId] ?? null) : null
}
export function useCloudOpenCover(worktreeId: string | null): CloudOpenCover | null {
return useSyncExternalStore(
(listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
() => getCloudOpenCover(worktreeId)
)
}
/** True once any terminal of the workspace has a PTY attached. */
export function workspaceHasAttachedPty(
state: {
tabsByWorktree: Readonly<Record<string, readonly { id: string }[] | undefined>>
ptyIdsByTabId: Readonly<Record<string, readonly string[] | undefined>>
},
worktreeId: string
): boolean {
return (state.tabsByWorktree[worktreeId] ?? []).some(
(tab) => (state.ptyIdsByTabId[tab.id]?.length ?? 0) > 0
)
}
/** Test seam. */
export function resetCloudOpenCovers(): void {
emit({})
}
@@ -6,6 +6,7 @@ import type { OrcaCloudAvailability } from '../../../../shared/orca-yaml-hook-ty
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'
import { cancelCloudCreateIntent } from './cloud-create-intent'
export type CloudSetupPageRequest = {
prompt: string
@@ -17,6 +18,8 @@ export type CloudSetupPageRequest = {
cloudOnlyRepo: string | null
/** The composer as it was: Back and the auto-start reopen it with everything kept. */
composer: ComposerModalData
/** The Create press this setup belongs to: its start uses it once; Exit and Back cancel it. */
intentId: string
/**
* 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.
@@ -35,6 +38,10 @@ function emit(): void {
}
export function openCloudSetupPage(request: Omit<CloudSetupPageRequest, 'openedAt'>): void {
// A newer Start replaces an older setup: the older press can never create.
if (current && current.intentId !== request.intentId) {
cancelCloudCreateIntent(current.intentId)
}
current = { ...request, openedAt: Date.now() }
emit()
}
@@ -61,31 +68,6 @@ export function useCloudSetupPageRequest(): CloudSetupPageRequest | null {
)
}
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, {
@@ -323,6 +323,12 @@ export function getCreationProgressLabel(
}
if (entry.phase === 'provisioning-vm') {
if (entry.environmentCheck?.status === 'failed') {
// Orca Cloud stops only on an unusable sandbox; the row says what the card says.
if (entry.request.ephemeralVmRecipe?.provider === 'orca-cloud') {
return entry.environmentCheck.sandbox === 'asleep'
? t('auto.lib.pendingWorktreeCreation.cloudBlockedAsleep', 'Asleep')
: t('auto.lib.pendingWorktreeCreation.cloudCouldNotOpen', 'Couldn’t open')
}
return t('auto.lib.pendingWorktreeCreation.vmChecksFailed', 'Environment checks failed')
}
// Glossary: an Orca Cloud machine is a sandbox; "VM" stays only for bring-your-own recipes.
@@ -2,6 +2,7 @@ import type { TuiAgent } from '../../../../../shared/agent-launch/catalog/tui-ag
import { parseExecutionHostId } from '../../../../../shared/execution-host'
import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/workspace/ownership/worktree-runtime-owner'
import { useAppStore } from '@/store/index'
import { holdCloudOpenCover } from '@/lib/orca-cloud/cloud-open-cover-store'
import { ensureAgentStartupInTerminal } from '@/lib/new-workspace'
import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace/activation/workspace-activation-terminal-focus'
import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/workspace/creation/worktree-creation-agent-seeds'
@@ -23,6 +24,16 @@ export async function completeWorktreeCreation(args: {
focusOnCompletion: boolean
}): Promise<void> {
const { request } = args
// Orca Cloud: the start screen stays over the new workspace until its PTY attaches (JA-12).
const pending = useAppStore.getState().pendingWorktreeCreations[args.creationId]
if (
pending &&
args.focusOnCompletion &&
!args.structuredLaunchAccepted &&
request.ephemeralVmRecipe?.provider === 'orca-cloud'
) {
holdCloudOpenCover(args.worktreeId, pending)
}
// Why: clearing synchronously after activation lets React commit the panel-to-terminal swap in one frame.
useAppStore.getState().removePendingWorktreeCreation(args.creationId, { cleanupVm: false })
// Why: an agent the host planned but did not start has no tab to seed and is not the window's to start.
@@ -1,7 +1,7 @@
// Orca Cloud routines: /v1/routines wire shapes and the editor's request bodies.
import { z } from 'zod'
import { ORCA_CLOUD_IMAGE_AGENTS } from '../hosted-sandbox/orca-cloud-image-agents'
import { HttpsUrl, Id, Millis, PersonName, ShortText } from './cloud-api-primitives'
import { HttpsUrl, Id, Millis, PersonName } from './cloud-api-primitives'
// ── Routines ────────────────────────────────────────────────────────────────
@@ -115,11 +115,33 @@ export const RoutineCreatedSchema = z.object({
export type RoutineCreated = z.infer<typeof RoutineCreatedSchema>
export const RoutineWebhookSchema = z.object({ url: HttpsUrl })
const TriggerTarget = z.object({ repo: z.string().max(200), number: z.number().int() })
const TriggerDetailObject = z.object({
event: z.string().max(100).optional(),
target: TriggerTarget.optional().catch(undefined)
})
/** "acme/web#482 pull_request.labeled" for GitHub runs; null when there is nothing worth showing. */
export function triggerLabel(detail: unknown): string | null {
if (typeof detail === 'string') {
return detail.slice(0, 500) || null
}
const parsed = TriggerDetailObject.safeParse(detail)
if (!parsed.success) {
return null
}
const { event, target } = parsed.data
const where = target ? `${target.repo}#${target.number}` : null
return [where, event].filter(Boolean).join(' ') || null
}
export const RoutineRunSchema = z.object({
id: Id,
routineId: Id,
trigger: z.enum(ROUTINE_RUN_TRIGGERS),
triggerDetail: ShortText.nullable(),
// The control plane sends what fired the run as an object ({requestedBy} for manual runs,
// {event, target} for GitHub, {scheduledFor} for schedules); the UI shows one short label.
triggerDetail: z.unknown().transform(triggerLabel),
status: z.enum(ROUTINE_RUN_STATUSES),
workspaceId: Id.nullable(),
result: z