diff --git a/src/main/cloud-api/cloud-api-client.test.ts b/src/main/cloud-api/cloud-api-client.test.ts index 3097e205518..6cae58ad6fb 100644 --- a/src/main/cloud-api/cloud-api-client.test.ts +++ b/src/main/cloud-api/cloud-api-client.test.ts @@ -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(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(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', () => { diff --git a/src/main/ephemeral-vm/runtime-service.ts b/src/main/ephemeral-vm/runtime-service.ts index cb87758dd92..573704383fd 100644 --- a/src/main/ephemeral-vm/runtime-service.ts +++ b/src/main/ephemeral-vm/runtime-service.ts @@ -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, diff --git a/src/main/ipc/ephemeral-vm-recipe-context.ts b/src/main/ipc/ephemeral-vm-recipe-context.ts index a1b8be2e052..1ede359801d 100644 --- a/src/main/ipc/ephemeral-vm-recipe-context.ts +++ b/src/main/ipc/ephemeral-vm-recipe-context.ts @@ -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 = { diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts index 15af546b10e..5f831e464e4 100644 --- a/src/main/ipc/ephemeral-vm.ts +++ b/src/main/ipc/ephemeral-vm.ts @@ -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 } : {}), diff --git a/src/preload/api/ephemeral-vm-api.ts b/src/preload/api/ephemeral-vm-api.ts index 12ae965adef..e212d012f1e 100644 --- a/src/preload/api/ephemeral-vm-api.ts +++ b/src/preload/api/ephemeral-vm-api.ts @@ -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 diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f640e779c0d..3ba4f821685 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { + diff --git a/src/renderer/src/app-shell/AppWorkspaceShell.tsx b/src/renderer/src/app-shell/AppWorkspaceShell.tsx index cddedd2a414..3b58c35221f 100644 --- a/src/renderer/src/app-shell/AppWorkspaceShell.tsx +++ b/src/renderer/src/app-shell/AppWorkspaceShell.tsx @@ -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: { + {floatingWorkspace.showToggleButton && !capturingPluginShot ? ( { switch (action.kind) { case 'renew': - openSettings('accounts', 'orca-cloud') + openOrcaCloudSignInSheet() return case 'signIn': gateActions.signIn() diff --git a/src/renderer/src/components/billing/team/TeamDialogHost.tsx b/src/renderer/src/components/billing/team/TeamDialogHost.tsx index 8a76f47e261..9ea1576480c 100644 --- a/src/renderer/src/components/billing/team/TeamDialogHost.tsx +++ b/src/renderer/src/components/billing/team/TeamDialogHost.tsx @@ -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['arrival'] }) { +export function TeamPageBody({ + arrival, + inline +}: { + arrival: ReturnType['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 void window.api.shell.openUrl(url)} + inline={inline} /> ) } diff --git a/src/renderer/src/components/billing/team/TeamPageView.tsx b/src/renderer/src/components/billing/team/TeamPageView.tsx index 233f9541c0e..f15032ae4fe 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.tsx @@ -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(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('[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 ? ( +

+ {t('auto.components.billing.team.seatsOnPlanBilling', 'Seats and invoices are in')} + +

+ ) : entitlement ? ( - - - ) -} diff --git a/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx b/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx index e3d3b0efcd9..5c0ed699d05 100644 --- a/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx +++ b/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx @@ -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( diff --git a/src/renderer/src/components/new-workspace/composer-modal-data.ts b/src/renderer/src/components/new-workspace/composer-modal-data.ts index 0a2a0f21224..fbf97f38b09 100644 --- a/src/renderer/src/components/new-workspace/composer-modal-data.ts +++ b/src/renderer/src/components/new-workspace/composer-modal-data.ts @@ -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 } diff --git a/src/renderer/src/components/new-workspace/run-on/cloud-start-model.test.ts b/src/renderer/src/components/new-workspace/run-on/cloud-start-model.test.ts index 7cb5e31ca4b..71d1f4f0a35 100644 --- a/src/renderer/src/components/new-workspace/run-on/cloud-start-model.test.ts +++ b/src/renderer/src/components/new-workspace/run-on/cloud-start-model.test.ts @@ -24,24 +24,25 @@ describe('Orca Cloud availability', () => { describe('setup rows and the setup page', () => { const page = (steps: Parameters[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') }) }) diff --git a/src/renderer/src/components/new-workspace/run-on/orca-cloud-hours-copy.ts b/src/renderer/src/components/new-workspace/run-on/orca-cloud-hours-copy.ts index 6362baa6e36..641e0cbb14b 100644 --- a/src/renderer/src/components/new-workspace/run-on/orca-cloud-hours-copy.ts +++ b/src/renderer/src/components/new-workspace/run-on/orca-cloud-hours-copy.ts @@ -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 diff --git a/src/renderer/src/components/new-workspace/run-on/use-cloud-only-repo.ts b/src/renderer/src/components/new-workspace/run-on/use-cloud-only-repo.ts index 7326ddc2015..1378325d091 100644 --- a/src/renderer/src/components/new-workspace/run-on/use-cloud-only-repo.ts +++ b/src/renderer/src/components/new-workspace/run-on/use-cloud-only-repo.ts @@ -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]) } /** diff --git a/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts b/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts index 28cc960bfd6..c37f84d5485 100644 --- a/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts +++ b/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts @@ -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( () => @@ -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 } } diff --git a/src/renderer/src/components/new-workspace/run-on/use-composer-cloud-start.ts b/src/renderer/src/components/new-workspace/run-on/use-composer-cloud-start.ts index fd9ec93edc9..6e6f291d5c1 100644 --- a/src/renderer/src/components/new-workspace/run-on/use-composer-cloud-start.ts +++ b/src/renderer/src/components/new-workspace/run-on/use-composer-cloud-start.ts @@ -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, diff --git a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx index d9dabad6168..6c0301cabbc 100644 --- a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx +++ b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx @@ -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 ( -
  • - {index > 0 ? : null} + const shownHere = viewing ? viewing === step.id : state === 'current' + const mark = ( + <> {label} @@ -79,6 +89,31 @@ function SetupStepper({ : ''} + + ) + // 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 ( +
  • + {index > 0 ? : null} + {clickable ? ( + + ) : ( + + {mark} + + )}
  • ) })} @@ -152,20 +187,6 @@ function StepActions({ flow }: { flow: CloudSetupFlow }): React.JSX.Element | nu ) case 'switch-team': return
    {flow.teamAction}
    - case 'agent': - return ( -
    - - -
    - ) case 'needs-repository': return flow.current?.action ? ( - - - {t('cloudSetupPage.eyebrow', 'Orca Cloud setup')} - - +
    + + + {context} + + +
    -
    - -
    + {/* The same optically-centred top offset on every step, so nothing jumps between them. */} +
    +

    {copy.title}

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

    {copy.lead}

    - ) : null} - {copy.note ?

    {copy.note}

    : null} +
    - {flow.error && !starting ? ( + {flow.error && !starting && !viewed ? ( ) : null} -
    - -
    - -
    -
    -
    - {t('cloudSetupPage.startsAfter', 'Starts when setup is done')} -
    -
    - {prompt.trim() || t('cloudSetupPage.untitled', 'New cloud workspace')} -
    -
    - {[projectName, agentLabel, t('cloudSetupPage.orcaCloud', 'Orca Cloud')] - .filter(Boolean) - .join(' · ')} -
    -
    +
    + {viewed ? ( + + ) : ( + + )}
    diff --git a/src/renderer/src/components/orca-cloud-setup/CloudSetupPageLayer.tsx b/src/renderer/src/components/orca-cloud-setup/CloudSetupPageLayer.tsx index 3672eddd32d..c57f661d8ad 100644 --- a/src/renderer/src/components/orca-cloud-setup/CloudSetupPageLayer.tsx +++ b/src/renderer/src/components/orca-cloud-setup/CloudSetupPageLayer.tsx @@ -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 ( ) @@ -104,6 +132,7 @@ export function CloudSetupPageLayer(): React.JSX.Element | null { return } if (openedOnRef.current !== activeWorktreeId) { + cancelCloudCreateIntent(request.intentId) closeCloudSetupPage() } }, [activeWorktreeId, request]) diff --git a/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-copy.ts b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-copy.ts index edd0e67a473..4ad8ab0224f 100644 --- a/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-copy.ts +++ b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-copy.ts @@ -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, - 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 + } + } +} diff --git a/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.test.ts b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.test.ts index 1fbeb8eca20..f1606a782b1 100644 --- a/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.test.ts +++ b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.test.ts @@ -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') - }) -}) diff --git a/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.ts b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.ts index ca7c8884e5f..584e1f86269 100644 --- a/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.ts +++ b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.ts @@ -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') } diff --git a/src/renderer/src/components/orca-cloud-setup/use-cloud-setup-flow.ts b/src/renderer/src/components/orca-cloud-setup/use-cloud-setup-flow.ts index 165dbd03b21..680274cf170 100644 --- a/src/renderer/src/components/orca-cloud-setup/use-cloud-setup-flow.ts +++ b/src/renderer/src/components/orca-cloud-setup/use-cloud-setup-flow.ts @@ -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(null) const [busy, setBusy] = useState(false) - const [agentSkipped, setAgentSkipped] = useState(() => isCloudSetupAgentSkipped(request.agent)) const githubOverride = useMemo( () => (request.cloudOnlyRepo ? { id: 'github', state: 'done' } : null), [request.cloudOnlyRepo] @@ -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) diff --git a/src/renderer/src/components/orca-cloud/CloudAgentRowSignInTarget.tsx b/src/renderer/src/components/orca-cloud/CloudAgentRowSignInTarget.tsx index daba491c714..03c6030286a 100644 --- a/src/renderer/src/components/orca-cloud/CloudAgentRowSignInTarget.tsx +++ b/src/renderer/src/components/orca-cloud/CloudAgentRowSignInTarget.tsx @@ -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() } /** diff --git a/src/renderer/src/components/orca-cloud/CloudSetupNotices.tsx b/src/renderer/src/components/orca-cloud/CloudSetupNotices.tsx index 4cf89f612cd..3868562512b 100644 --- a/src/renderer/src/components/orca-cloud/CloudSetupNotices.tsx +++ b/src/renderer/src/components/orca-cloud/CloudSetupNotices.tsx @@ -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 ( +
    + +
    + ) +} + 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" >

    {t('orcaCloud.setupNotices.label', 'Workspace setup')} diff --git a/src/renderer/src/components/orca-cloud/OrcaCloudWorkspaceSurface.tsx b/src/renderer/src/components/orca-cloud/OrcaCloudWorkspaceSurface.tsx index f2223d64523..6d86e12acc1 100644 --- a/src/renderer/src/components/orca-cloud/OrcaCloudWorkspaceSurface.tsx +++ b/src/renderer/src/components/orca-cloud/OrcaCloudWorkspaceSurface.tsx @@ -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() - } + // 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( ) : null} + {showSetupNotices ? : null} {notices.length > 0 ? (
    { @@ -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() }) diff --git a/src/renderer/src/components/orca-cloud/use-wake-attach-hold.ts b/src/renderer/src/components/orca-cloud/use-wake-attach-hold.ts index b2578c9ef83..aec9abdd74a 100644 --- a/src/renderer/src/components/orca-cloud/use-wake-attach-hold.ts +++ b/src/renderer/src/components/orca-cloud/use-wake-attach-hold.ts @@ -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) diff --git a/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.tsx b/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.tsx index 893a54ebcd9..66c70cab1ec 100644 --- a/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.tsx +++ b/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.tsx @@ -153,8 +153,8 @@ export function OrcaCloudAccountsView({ )} diff --git a/src/renderer/src/components/settings/agent-accounts/overview/AgentAccountsOverviewSection.tsx b/src/renderer/src/components/settings/agent-accounts/overview/AgentAccountsOverviewSection.tsx index 362ca159749..74951c2e387 100644 --- a/src/renderer/src/components/settings/agent-accounts/overview/AgentAccountsOverviewSection.tsx +++ b/src/renderer/src/components/settings/agent-accounts/overview/AgentAccountsOverviewSection.tsx @@ -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(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 ( open(row)} diff --git a/src/renderer/src/components/settings/agent-accounts/sign-in-sheet/OrcaCloudSignInSheet.tsx b/src/renderer/src/components/settings/agent-accounts/sign-in-sheet/OrcaCloudSignInSheet.tsx new file mode 100644 index 00000000000..ebec6ee976d --- /dev/null +++ b/src/renderer/src/components/settings/agent-accounts/sign-in-sheet/OrcaCloudSignInSheet.tsx @@ -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 ( + +
    + + + {t('orcaCloudSignInSheet.title', 'Agents in Orca Cloud')} + + + {t( + 'orcaCloudSignInSheet.description', + 'Set up Claude and Codex once. Every cloud workspace uses them, including new, cloned and restarted ones.' + )} + + +
    +
    {body}
    + +
    + ) +} + +/** Mounted once in App; opened by openOrcaCloudSignInSheet(). */ +export function OrcaCloudSignInSheet(): React.JSX.Element { + const open = useOrcaCloudSignInSheetOpen() + return ( + !next && closeOrcaCloudSignInSheet()}> + {open ? } /> : null} + + ) +} diff --git a/src/renderer/src/components/settings/agent-accounts/sign-in-sheet/orca-cloud-sign-in-sheet-store.ts b/src/renderer/src/components/settings/agent-accounts/sign-in-sheet/orca-cloud-sign-in-sheet-store.ts new file mode 100644 index 00000000000..4c5c9c5e1e0 --- /dev/null +++ b/src/renderer/src/components/settings/agent-accounts/sign-in-sheet/orca-cloud-sign-in-sheet-store.ts @@ -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 + ) +} diff --git a/src/renderer/src/components/settings/cloud-api/CloudRoutineRow.tsx b/src/renderer/src/components/settings/cloud-api/CloudRoutineRow.tsx index af301a4084e..aad30e0e498 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudRoutineRow.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudRoutineRow.tsx @@ -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 ? ( - - ) : ( - - )} - {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 ? : } + {t('auto.components.settings.cloudRoutines.runNow', 'Run now')} diff --git a/src/renderer/src/components/settings/cloud-api/CloudRoutinesView.fixture.tsx b/src/renderer/src/components/settings/cloud-api/CloudRoutinesView.fixture.tsx index 6bb25768433..44422f66bdc 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudRoutinesView.fixture.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudRoutinesView.fixture.tsx @@ -283,6 +283,41 @@ export const runHistory = defineFixture({ render: () => }) +/** Right after Run now: the pill and history say Queued; the button is back to Run now. */ +export const justQueued = defineFixture({ + width: 820, + render: () => ( + + 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: () => ( diff --git a/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts b/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts index 38137b52967..86ef124bba5 100644 --- a/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts +++ b/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts @@ -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') diff --git a/src/renderer/src/components/settings/cloud-api/cloud-api-format.ts b/src/renderer/src/components/settings/cloud-api/cloud-api-format.ts index daa7e17d258..15b98d6df4c 100644 --- a/src/renderer/src/components/settings/cloud-api/cloud-api-format.ts +++ b/src/renderer/src/components/settings/cloud-api/cloud-api-format.ts @@ -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) } diff --git a/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx b/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx index c6192ceeeee..f40cdaa3d23 100644 --- a/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx +++ b/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx @@ -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 { {(account) => account.billing ? ( - + // 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. + { + const store = useAppStore.getState() + store.openSettingsTarget({ pane: 'cloud-billing', repoId: null }) + store.openSettingsPage() + } + }} + /> ) : ( } diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx b/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx index 8361dd84216..3a05eca01c6 100644 --- a/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx +++ b/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx @@ -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) diff --git a/src/renderer/src/components/teammates/TeammateProfileView.test.tsx b/src/renderer/src/components/teammates/TeammateProfileView.test.tsx index 240b1a48207..440735453ba 100644 --- a/src/renderer/src/components/teammates/TeammateProfileView.test.tsx +++ b/src/renderer/src/components/teammates/TeammateProfileView.test.tsx @@ -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', () => { diff --git a/src/renderer/src/components/teammates/TeammateProfileView.tsx b/src/renderer/src/components/teammates/TeammateProfileView.tsx index c7142879d79..a6ad5b56b97 100644 --- a/src/renderer/src/components/teammates/TeammateProfileView.tsx +++ b/src/renderer/src/components/teammates/TeammateProfileView.tsx @@ -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 (
    @@ -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')} ) : ( - + + + {/* aria-disabled, not disabled: it stays focusable and hoverable so the tooltip can + say why it can't poke, with the muted disabled look. */} + + + {!footer.canPoke && footer.note ? ( + {footer.note} + ) : null} + )} ) : null} diff --git a/src/renderer/src/components/worktree-creation/CloudOpenCoverBody.tsx b/src/renderer/src/components/worktree-creation/CloudOpenCoverBody.tsx new file mode 100644 index 00000000000..a0ed4499be9 --- /dev/null +++ b/src/renderer/src/components/worktree-creation/CloudOpenCoverBody.tsx @@ -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 ( +
    + + {/* A picture of the last frame: nothing in it is interactive. */} +
    + +
    +
    + ) +} diff --git a/src/renderer/src/components/worktree-creation/CloudOpenCoverLayer.tsx b/src/renderer/src/components/worktree-creation/CloudOpenCoverLayer.tsx new file mode 100644 index 00000000000..5d8808e91d8 --- /dev/null +++ b/src/renderer/src/components/worktree-creation/CloudOpenCoverLayer.tsx @@ -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 | null = null +const bodyListeners = new Set<() => void>() + +export function preloadCloudOpenCover(): Promise { + 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 + }) +} diff --git a/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx b/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx index d77200a36ad..eb2489b4bf8 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx @@ -209,51 +209,54 @@ export function VmBuildSheet({ return (
    -
    -
    - - {building ? ( - - ) : state === 'ready' ? ( - - ) : gated ? ( - - ) : ( - + {/* JA-24: on Orca Cloud a failure card carries the one title; no page title above it. */} + {cloud && failure ? null : ( +
    +
    + + {building ? ( + + ) : state === 'ready' ? ( + + ) : gated ? ( + + ) : ( + + )} + + {/* Retry lands focus here once the failure card collapses. The card announces failures. */} +

    + {headerTitle(state, cloud, t, failure?.kind === 'create')} +

    + {/* Orca Cloud: each step carries its own timer, so the header has none. */} + {gated || cloud ? null : ( + {elapsedLabel} )} - - {/* Retry lands focus here once the failure card collapses. The card announces failures. */} -

    - {headerTitle(state, cloud, t, failure?.kind === 'create')} -

    - {/* Orca Cloud: each step carries its own timer, so the header has none. */} - {gated || cloud ? null : ( - {elapsedLabel} - )} +
    + {building ? ( + + ) : null}
    - {building ? ( - - ) : null} -
    + )}
    {/* An account refusal happens before anything starts, so there are no steps to show. */} diff --git a/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx b/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx index e5770626f92..056300b2d23 100644 --- a/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx +++ b/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx @@ -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 } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 671c08689c0..7208c28db92 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -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", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7c458d7a56b..39d7c87a880 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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 ", "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" } } diff --git a/src/renderer/src/lib/ephemeral-vm-workspace-target.ts b/src/renderer/src/lib/ephemeral-vm-workspace-target.ts index ad0f768f2a1..15b11617c65 100644 --- a/src/renderer/src/lib/ephemeral-vm-workspace-target.ts +++ b/src/renderer/src/lib/ephemeral-vm-workspace-target.ts @@ -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 @@ -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 { diff --git a/src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts b/src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts new file mode 100644 index 00000000000..0229515e9b3 --- /dev/null +++ b/src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts @@ -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> = {} + 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) => { + 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]) + }) +}) diff --git a/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts b/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts index 2b8adf0d2b2..28e2ddffefa 100644 --- a/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts +++ b/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts @@ -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 } : {} +} diff --git a/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.test.ts b/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.test.ts new file mode 100644 index 00000000000..d5d634403c9 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.test.ts @@ -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() + ) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts b/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts new file mode 100644 index 00000000000..cfa1e02bdb4 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts @@ -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 + } +} diff --git a/src/renderer/src/lib/orca-cloud/cloud-create-intent.test.ts b/src/renderer/src/lib/orca-cloud/cloud-create-intent.test.ts new file mode 100644 index 00000000000..8536db819c5 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-create-intent.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-create-intent.ts b/src/renderer/src/lib/orca-cloud/cloud-create-intent.ts new file mode 100644 index 00000000000..f7fb4b06be3 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-create-intent.ts @@ -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() + +/** `create:`, 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() + +/** 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) + } +} diff --git a/src/renderer/src/lib/orca-cloud/cloud-open-cover-store.test.ts b/src/renderer/src/lib/orca-cloud/cloud-open-cover-store.test.ts new file mode 100644 index 00000000000..1cc03247cf9 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-open-cover-store.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-open-cover-store.ts b/src/renderer/src/lib/orca-cloud/cloud-open-cover-store.ts new file mode 100644 index 00000000000..d170d38e3e1 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-open-cover-store.ts @@ -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> = {} +const listeners = new Set<() => void>() + +function emit(next: Readonly>): 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> + ptyIdsByTabId: Readonly> + }, + worktreeId: string +): boolean { + return (state.tabsByWorktree[worktreeId] ?? []).some( + (tab) => (state.ptyIdsByTabId[tab.id]?.length ?? 0) > 0 + ) +} + +/** Test seam. */ +export function resetCloudOpenCovers(): void { + emit({}) +} diff --git a/src/renderer/src/lib/orca-cloud/cloud-setup-page-store.ts b/src/renderer/src/lib/orca-cloud/cloud-setup-page-store.ts index 1a927a81feb..579777be94c 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-setup-page-store.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-setup-page-store.ts @@ -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): 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, { diff --git a/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts b/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts index e0857e3bf00..5bdd5cb0571 100644 --- a/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts +++ b/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts @@ -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. diff --git a/src/renderer/src/lib/workspace/creation/worktree-creation-completion.ts b/src/renderer/src/lib/workspace/creation/worktree-creation-completion.ts index 87579aeeca0..4fb4bf1f71f 100644 --- a/src/renderer/src/lib/workspace/creation/worktree-creation-completion.ts +++ b/src/renderer/src/lib/workspace/creation/worktree-creation-completion.ts @@ -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 { 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. diff --git a/src/shared/cloud-api/cloud-api-routine-types.ts b/src/shared/cloud-api/cloud-api-routine-types.ts index 39f8109580b..d77aafc2782 100644 --- a/src/shared/cloud-api/cloud-api-routine-types.ts +++ b/src/shared/cloud-api/cloud-api-routine-types.ts @@ -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 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