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