From 03093cc6f64fa764a9015357542e9202bf2036a7 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 19:43:03 -0800 Subject: [PATCH 01/15] feat(cloud): JA-04 focused Orca Cloud sign-in sheet; no duplicate Orca Cloud host row; account-wide copy Every Orca Cloud 'Set up once' / 'Renew' (composer row, sign-in strip, VM build row, A16 panel) opens one sheet with only Claude and Codex (S0's rows) instead of scrolling Settings past this computer's provider accounts. The Hosts table drops its Orca Cloud row: the page's Orca Cloud part below is the one place for it. 'Set up once on this computer' becomes 'Set up once' (setup is account-wide). --- src/renderer/src/App.tsx | 2 + .../open-agent-host-settings.ts | 5 ++ .../CloudAgentSignInStripPortals.tsx | 5 +- .../new-workspace/OrcaCloudAgentSetupRow.tsx | 8 +- .../agent-accounts/OrcaCloudAccounts.tsx | 4 +- .../overview/AgentAccountsOverviewSection.tsx | 51 ++----------- .../sign-in-sheet/OrcaCloudSignInSheet.tsx | 75 +++++++++++++++++++ .../orca-cloud-sign-in-sheet-store.ts | 37 +++++++++ ...ngs-orca-cloud-agent-accounts-sections.tsx | 4 +- .../VmBuildCloudAgentsRow.tsx | 7 +- src/renderer/src/i18n/locales/en.json | 10 ++- 11 files changed, 146 insertions(+), 62 deletions(-) create mode 100644 src/renderer/src/components/settings/agent-accounts/sign-in-sheet/OrcaCloudSignInSheet.tsx create mode 100644 src/renderer/src/components/settings/agent-accounts/sign-in-sheet/orca-cloud-sign-in-sheet-store.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 65d9149536e..873546f3b2b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -8,6 +8,7 @@ import { BrowserExtensionInstallDialog } from './components/browser-extension-in import { PlansDialogHost } from './components/billing/plans/PlansDialogHost' import { CloudMoveToComputerDialogMount } from './components/orca-cloud/CloudMoveToComputerDialogMount' import { CloudSecretsAskDialogHost } from './components/cloud-secrets-ask/CloudSecretsMissingNotice' +import { OrcaCloudSignInSheet } from './components/settings/agent-accounts/sign-in-sheet/OrcaCloudSignInSheet' import { AgentHostPanel } from './components/agent-host-panel/AgentHostPanel' import { SecretRequestUnseenNotifier } from './components/agent-pane/SecretRequestUnseenNotifier' import { TeamDialogHost } from './components/billing/team/TeamDialogHost' @@ -127,6 +128,7 @@ function App(): React.JSX.Element { + diff --git a/src/renderer/src/components/agent-host-panel/open-agent-host-settings.ts b/src/renderer/src/components/agent-host-panel/open-agent-host-settings.ts index 3585214c722..b5634b5bdc2 100644 --- a/src/renderer/src/components/agent-host-panel/open-agent-host-settings.ts +++ b/src/renderer/src/components/agent-host-panel/open-agent-host-settings.ts @@ -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() diff --git a/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStripPortals.tsx b/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStripPortals.tsx index 7999809739f..24cf1ee152c 100644 --- a/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStripPortals.tsx +++ b/src/renderer/src/components/agent-sign-in-strip/CloudAgentSignInStripPortals.tsx @@ -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() diff --git a/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx b/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx index e3d3b0efcd9..5c0ed699d05 100644 --- a/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx +++ b/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx @@ -2,8 +2,8 @@ // Later" hint: it says how the composer's agent will sign in, links to the one-time setup in // Settings → Agent accounts, and never blocks Start or signs in per sandbox. import { useEffect } from 'react' +import { openOrcaCloudSignInSheet } from '@/components/settings/agent-accounts/sign-in-sheet/orca-cloud-sign-in-sheet-store' import { CircleCheck, CircleDashed, Info, TriangleAlert } from 'lucide-react' -import { useAppStore } from '@/store/index' import { useTranslate, type Translate } from '@/i18n/use-translate' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' @@ -19,11 +19,9 @@ import { noteComposerAgentCoverage } from '@/lib/orca-cloud/cloud-first-start-ag import type { OrcaCloudRunTargetAccess } from './orca-cloud-run-target-access' import { agentName, keySourceLabel } from '../settings/agent-accounts/orca-cloud-accounts-copy' -/** Settings → Agent accounts, scrolled to the Orca Cloud section (S0). The composer stays open. */ +/** The focused Orca Cloud sign-in sheet (JA-04), over the composer, which stays open. */ export function openOrcaCloudAgentAccounts(): void { - useAppStore - .getState() - .openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'orca-cloud' }) + openOrcaCloudSignInSheet() } export function orcaCloudAgentCoveredLabel( diff --git a/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.tsx b/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.tsx index 893a54ebcd9..66c70cab1ec 100644 --- a/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.tsx +++ b/src/renderer/src/components/settings/agent-accounts/OrcaCloudAccounts.tsx @@ -153,8 +153,8 @@ export function OrcaCloudAccountsView({ )} diff --git a/src/renderer/src/components/settings/agent-accounts/overview/AgentAccountsOverviewSection.tsx b/src/renderer/src/components/settings/agent-accounts/overview/AgentAccountsOverviewSection.tsx index 362ca159749..74951c2e387 100644 --- a/src/renderer/src/components/settings/agent-accounts/overview/AgentAccountsOverviewSection.tsx +++ b/src/renderer/src/components/settings/agent-accounts/overview/AgentAccountsOverviewSection.tsx @@ -1,11 +1,9 @@ -// Wires S3 to the renderer's host list, the per-host sign-in probes and the Orca account. Rows open -// the A16 panel; the Orca Cloud row scrolls to S0 below (one row stands for every cloud workspace). -import React, { useEffect, useMemo, useState } from 'react' +// Wires S3 to the renderer's host list and the per-host sign-in probes. Rows open the A16 panel. +// No Orca Cloud row (JA-04): the page's own Orca Cloud part right below is the one place for it. +import React, { useMemo } from 'react' import { useAppStore } from '@/store' import { useTranslate } from '@/i18n/use-translate' -import { useOrcaCloudAgentAccounts } from '@/lib/orca-cloud/orca-cloud-agent-accounts-store' import { openAgentHostPanel } from '../../../agent-host-panel/agent-host-panel-store' -import { ORCA_CLOUD_ACCOUNTS_SECTION_ID } from '../OrcaCloudAccounts' import { overviewRows, type OverviewAction, @@ -22,28 +20,6 @@ function openSettings(pane: 'accounts', sectionId?: string): void { store.openSettingsPage() } -/** A member can't change team keys: their key cells say "set by your team". */ -function useIsCloudMember(enabled: boolean): boolean { - const [member, setMember] = useState(false) - useEffect(() => { - if (!enabled) { - return - } - let cancelled = false - void (window.api.cloudSecrets?.effective({}) ?? Promise.resolve(null)) - .catch(() => null) - .then((result) => { - if (!cancelled && result?.ok) { - setMember(!result.value.caller.canWrite.org) - } - }) - return () => { - cancelled = true - } - }, [enabled]) - return member -} - export function AgentAccountsOverviewSection({ hideHeader = false }: { @@ -52,28 +28,11 @@ export function AgentAccountsOverviewSection({ const t = useTranslate() const hosts = useOverviewHosts() const { probes, checkAgain, firstPassDone } = useOverviewProbes(hosts) - const accounts = useOrcaCloudAgentAccounts() - const ready = accounts.load.kind === 'ready' - const member = useIsCloudMember(ready) - const signedOutOfOrca = accounts.load.kind === 'failed' && accounts.load.error === 'signed_out' - const rows = useMemo( - () => - overviewRows({ - hosts, - probes, - cloud: - accounts.load.kind === 'ready' - ? { status: accounts.load.status, keyKinds: accounts.keyKinds, member } - : null - }), - [hosts, probes, accounts.load, accounts.keyKinds, member] - ) + const rows = useMemo(() => overviewRows({ hosts, probes, cloud: null }), [hosts, probes]) const hostById = new Map(hosts.map((host) => [host.hostId, host])) const open = (row: OverviewRow, focus?: 'claude' | 'codex'): void => { const host = row.hostId ? hostById.get(row.hostId) : undefined if (!host) { - // Orca Cloud: S0 below owns setup and the Source for every cloud workspace. - document.getElementById(ORCA_CLOUD_ACCOUNTS_SECTION_ID)?.scrollIntoView({ block: 'start' }) return } openAgentHostPanel({ @@ -111,7 +70,7 @@ export function AgentAccountsOverviewSection({ return ( open(row)} diff --git a/src/renderer/src/components/settings/agent-accounts/sign-in-sheet/OrcaCloudSignInSheet.tsx b/src/renderer/src/components/settings/agent-accounts/sign-in-sheet/OrcaCloudSignInSheet.tsx new file mode 100644 index 00000000000..67ba6b64dfd --- /dev/null +++ b/src/renderer/src/components/settings/agent-accounts/sign-in-sheet/OrcaCloudSignInSheet.tsx @@ -0,0 +1,75 @@ +// JA-04: the focused Orca Cloud sign-in sheet. Only Claude and Codex, each with its one action and +// status (S0's rows), so "Set up once" never lands on this computer's provider accounts. +import React from 'react' +import { useAppStore } from '@/store' +import { useTranslate } from '@/i18n/use-translate' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle +} from '@/components/ui/sheet' +import { OrcaCloudAccountsSection } from '../OrcaCloudAccountsSection' +import { + closeOrcaCloudSignInSheet, + useOrcaCloudSignInSheetOpen +} from './orca-cloud-sign-in-sheet-store' + +function openAllAgentAccounts(): void { + closeOrcaCloudSignInSheet() + const store = useAppStore.getState() + store.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'orca-cloud' }) + store.openSettingsPage() +} + +/** The sheet's contents; the host owns the Sheet so fixtures can render it open. */ +export function OrcaCloudSignInSheetView({ + body +}: { + /** S0's rows; fixtures pass a fixed view. */ + body: React.ReactNode +}): React.JSX.Element { + const t = useTranslate() + const titleId = React.useId() + return ( + +
+ + + {t('orcaCloudSignInSheet.title', 'Agents in Orca Cloud')} + + + {t( + 'orcaCloudSignInSheet.description', + 'Set up Claude and Codex once. Every cloud workspace uses them, including new, cloned and restarted ones.' + )} + + +
+
{body}
+ +
+ ) +} + +/** Mounted once in App; opened by openOrcaCloudSignInSheet(). */ +export function OrcaCloudSignInSheet(): React.JSX.Element { + const open = useOrcaCloudSignInSheetOpen() + return ( + !next && closeOrcaCloudSignInSheet()}> + {open ? } /> : null} + + ) +} diff --git a/src/renderer/src/components/settings/agent-accounts/sign-in-sheet/orca-cloud-sign-in-sheet-store.ts b/src/renderer/src/components/settings/agent-accounts/sign-in-sheet/orca-cloud-sign-in-sheet-store.ts new file mode 100644 index 00000000000..4c5c9c5e1e0 --- /dev/null +++ b/src/renderer/src/components/settings/agent-accounts/sign-in-sheet/orca-cloud-sign-in-sheet-store.ts @@ -0,0 +1,37 @@ +// JA-04: every "Set up once" / "Renew" for Orca Cloud opens one focused sheet (Claude and Codex +// only) instead of Settings, so nobody scrolls past this computer's provider accounts to reach it. +import { useSyncExternalStore } from 'react' + +let open = false +const listeners = new Set<() => void>() + +function set(next: boolean): void { + if (open === next) { + return + } + open = next + for (const listener of listeners) { + listener() + } +} + +export function openOrcaCloudSignInSheet(): void { + set(true) +} + +export function closeOrcaCloudSignInSheet(): void { + set(false) +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function useOrcaCloudSignInSheetOpen(): boolean { + return useSyncExternalStore( + subscribe, + () => open, + () => false + ) +} diff --git a/src/renderer/src/components/settings/page/settings-orca-cloud-agent-accounts-sections.tsx b/src/renderer/src/components/settings/page/settings-orca-cloud-agent-accounts-sections.tsx index 81d7aa181ec..71222c6103b 100644 --- a/src/renderer/src/components/settings/page/settings-orca-cloud-agent-accounts-sections.tsx +++ b/src/renderer/src/components/settings/page/settings-orca-cloud-agent-accounts-sections.tsx @@ -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: } diff --git a/src/renderer/src/components/worktree-creation/cloud-agents-row/VmBuildCloudAgentsRow.tsx b/src/renderer/src/components/worktree-creation/cloud-agents-row/VmBuildCloudAgentsRow.tsx index b61d73246f0..f3694cdc101 100644 --- a/src/renderer/src/components/worktree-creation/cloud-agents-row/VmBuildCloudAgentsRow.tsx +++ b/src/renderer/src/components/worktree-creation/cloud-agents-row/VmBuildCloudAgentsRow.tsx @@ -1,6 +1,7 @@ // Live Agents row for an Orca Cloud build. Reads the Orca account once (shared store with S0 and the // composer), never the sandbox; runs S0's setup inline when nothing is set up (R2), and writes the // per-workspace Source from the ⋯ menu (S1-b). +import { openOrcaCloudSignInSheet } from '@/components/settings/agent-accounts/sign-in-sheet/orca-cloud-sign-in-sheet-store' import React, { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { useAppStore } from '@/store' @@ -32,7 +33,7 @@ import { VmBuildAgentSignInRow } from '../VmBuildAgentSignInRow' import { VmBuildCloudAgentsMenu, type WorkspaceSourcesLoad } from './VmBuildCloudAgentsMenu' import { VmBuildCloudAgentsRowView, type DesktopKeySave } from './VmBuildCloudAgentsRowView' -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() @@ -156,7 +157,7 @@ export function VmBuildCloudAgentsRow({ sources={sources} onOpen={readSources} onChoose={choose} - onManage={() => openSettings('accounts', 'orca-cloud')} + onManage={() => openOrcaCloudSignInSheet()} onUseKey={() => openSettings('cloud-secrets')} /> ) @@ -184,7 +185,7 @@ export function VmBuildCloudAgentsRow({ void api?.acknowledge({ agent }) }, retryLoad: () => void reloadOrcaCloudAgentAccounts(), - manage: () => openSettings('accounts', 'orca-cloud'), + manage: () => openOrcaCloudSignInSheet(), saveDesktopKey: saveKey, signInHere: (agent) => { nextRequest.current += 1 diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ec448c98c22..1ff4d702bcb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -27814,7 +27814,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": { @@ -27843,7 +27842,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": { @@ -28086,5 +28086,11 @@ }, "untitled": "New cloud workspace", "waitingBrowser": "Waiting for your browser…" + }, + "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" } } From 042fb4ba0b033fddfc80d86619ef8983f25a9d10 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 19:49:39 -0800 Subject: [PATCH 02/15] fix(cloud): the reconnect card only follows a real wake, retries the attach once quietly, and clears when the pane attaches The wake attach hold armed whenever a wake entry it had seen in flight left the store, not only on main's ready, so a stale, quietly failed or dismissed wake (as around an app restart or a pane's account restart) held the restored pane as Connecting and failed it at 8 s. The card then stayed until Try again even after the pane attached. - Arm only on main's ready for the same wake seen in flight. - At the cap, reconnect the pane once on its own; show the card only after a second cap. - Keep watching while the card shows and clear it once the pane attaches (restore, account restart, wake). - Try again still re-runs the pane's attach, not a wake. --- .../orca-cloud/use-wake-attach-hold.test.tsx | 74 +++++++++++++++++-- .../orca-cloud/use-wake-attach-hold.ts | 51 +++++++++---- 2 files changed, 103 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/components/orca-cloud/use-wake-attach-hold.test.tsx b/src/renderer/src/components/orca-cloud/use-wake-attach-hold.test.tsx index b806794454b..6acb5bc94e0 100644 --- a/src/renderer/src/components/orca-cloud/use-wake-attach-hold.test.tsx +++ b/src/renderer/src/components/orca-cloud/use-wake-attach-hold.test.tsx @@ -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() }) diff --git a/src/renderer/src/components/orca-cloud/use-wake-attach-hold.ts b/src/renderer/src/components/orca-cloud/use-wake-attach-hold.ts index b2578c9ef83..aec9abdd74a 100644 --- a/src/renderer/src/components/orca-cloud/use-wake-attach-hold.ts +++ b/src/renderer/src/components/orca-cloud/use-wake-attach-hold.ts @@ -8,7 +8,7 @@ import type { CloudWakeProgressEvent } from '../../../../shared/hosted-sandbox/c /** How often the hold checks the pane in front while it waits. */ export const WAKE_ATTACH_POLL_MS = 100 -/** A pane that hasn't re-attached by now gets the "Couldn't reconnect the terminal" card. */ +/** A pane that hasn't re-attached by now is reconnected once; after a second cap it gets the card. */ export const WAKE_ATTACH_HOLD_MAX_MS = 8_000 type Track = { @@ -38,8 +38,11 @@ export type WakeAttachHold = { * A wake reports ready once the server answers; the pane in front re-attaches to its terminal a * moment later. Until it takes input (attached, with nothing it was given still unsent), this * returns the wake as still `connecting` (same start time and source, so the pill keeps counting - * from the wake's start). A pane that hasn't by the cap becomes a failed card with Try again, - * never a silent dead pane. Then `undefined` hands back to the store's wake. + * from the wake's start). A pane that hasn't by the cap is reconnected once quietly; one that + * still hasn't by the next cap becomes a failed card with Try again (re-runs the attach), never a + * silent dead pane, and the card clears itself if the pane attaches after all. Only main's `ready` + * for a wake seen in flight arms this: a restart or an account restart with no wake never does. + * Then `undefined` hands back to the store's wake. */ export function useWakeAttachHold( workspaceId: string, @@ -65,43 +68,61 @@ export function useWakeAttachHold( if (track.last || track.held || track.stalled) { setTrack({ ...IDLE, attempt: track.attempt }) } - } else if (track.last && (!wake || wake.startedAt === track.last.startedAt)) { - // Ready, or the settled entry already dropped: hold only a wake that was in flight here. + } else if (track.last) { + // Only main's own `ready` for the wake seen in flight here holds the pane. An entry that just + // goes away (a stale or quietly failed wake dropped by the store, a Dismiss) never does: no + // wake brought the pane back, so there is nothing to wait for. + const settled = wake?.phase === 'ready' && wake.startedAt === track.last.startedAt const held = - isAttached(workspaceId) === false + settled && isAttached(workspaceId) === false ? { ...track.last, phase: 'connecting' as const } : undefined setTrack({ ...IDLE, attempt: track.attempt + 1, held }) } - // Release once the pane takes input; at the cap, fail visibly. The store dropping the settled - // wake doesn't cancel this: it is keyed on the hold only. - const holding = track.held !== undefined + // Release once the pane takes input; at the cap, reconnect it once quietly, then fail visibly. + // The card keeps watching too: a pane that attaches later (restore, account restart, a slow + // wake) clears it on its own. The store dropping the settled wake doesn't cancel this. + const waiting = track.held !== undefined || track.stalled !== undefined const attempt = track.attempt useEffect(() => { - if (!holding) { + if (!waiting) { return } // Only visible time counts toward the cap: a hidden window pauses this poll (and the pane's // own re-attach), so time spent hidden must not turn a healthy wake into a stalled card. let visibleMs = 0 let lastAt = Date.now() + let quietRetryDone = false + let shownStalled = false const check = (): void => { const attached = isAttached(workspaceId) - if (attached === false && visibleMs < WAKE_ATTACH_HOLD_MAX_MS) { + if (attached === false && (shownStalled || visibleMs < WAKE_ATTACH_HOLD_MAX_MS)) { return } + if (attached === false && !quietRetryDone) { + // One reconnect on its own before the card: a pane whose attach got lost usually comes + // back from this, and the person never sees a failure for it. + quietRetryDone = true + visibleMs = 0 + retryPane(workspaceId) + return + } + shownStalled = attached === false setTrack((current) => { - if (current.attempt !== attempt || !current.held) { + if (current.attempt !== attempt || (!current.held && !current.stalled)) { return current } - return attached === false + if (attached !== false) { + return { ...current, held: undefined, stalled: undefined } + } + return current.held ? { ...current, held: undefined, stalled: { ...current.held, phase: 'failed', at: Date.now() } } - : { ...current, held: undefined } + : current }) } return installWindowVisibilityInterval({ @@ -118,7 +139,7 @@ export function useWakeAttachHold( }, intervalMs: WAKE_ATTACH_POLL_MS }) - }, [holding, attempt, workspaceId, isAttached]) + }, [waiting, attempt, workspaceId, isAttached, retryPane]) const retry = useCallback(() => { retryPane(workspaceId) From bd6cd6e7f82915c0a8bdeb2d2a864c3e1a712e8d Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 20:11:12 -0800 Subject: [PATCH 03/15] feat(cloud): JA-04 sheet 600px wide; sidebar row 'Set up once' opens the sheet too --- .../src/components/orca-cloud/CloudAgentRowSignInTarget.tsx | 4 +--- .../agent-accounts/sign-in-sheet/OrcaCloudSignInSheet.tsx | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/orca-cloud/CloudAgentRowSignInTarget.tsx b/src/renderer/src/components/orca-cloud/CloudAgentRowSignInTarget.tsx index daba491c714..03c6030286a 100644 --- a/src/renderer/src/components/orca-cloud/CloudAgentRowSignInTarget.tsx +++ b/src/renderer/src/components/orca-cloud/CloudAgentRowSignInTarget.tsx @@ -1,7 +1,6 @@ // The status (dot and text) of a cloud agent row that needs sign-in: clicking it opens // Settings → Agent accounts, where the fix lives. The rest of the row still opens the pane. import React, { useCallback } from 'react' -import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import { AgentStateDot } from '@/components/AgentStateDot' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -11,10 +10,9 @@ import { openOrcaCloudAgentAccounts } from '@/components/new-workspace/OrcaCloud /** What the status shows: its label, the agent's catalog name, and the tooltip naming the fix. */ export type CloudAgentRowStatusText = { label: string; agentName: string; tooltip: string | null } -/** The composer's "Set up once" target, plus opening Settings, which the sidebar has closed. */ +/** The sidebar row's "Set up once": the focused Orca Cloud sign-in sheet (JA-04). */ export function openCloudAgentAccountsSettings(): void { openOrcaCloudAgentAccounts() - useAppStore.getState().openSettingsPage() } /** diff --git a/src/renderer/src/components/settings/agent-accounts/sign-in-sheet/OrcaCloudSignInSheet.tsx b/src/renderer/src/components/settings/agent-accounts/sign-in-sheet/OrcaCloudSignInSheet.tsx index 67ba6b64dfd..ebec6ee976d 100644 --- a/src/renderer/src/components/settings/agent-accounts/sign-in-sheet/OrcaCloudSignInSheet.tsx +++ b/src/renderer/src/components/settings/agent-accounts/sign-in-sheet/OrcaCloudSignInSheet.tsx @@ -33,7 +33,7 @@ export function OrcaCloudSignInSheetView({ const t = useTranslate() const titleId = React.useId() return ( - +
From 7f66337e475498a09f7357eed8cd1a6233431383 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 20:20:01 -0800 Subject: [PATCH 04/15] fix(teammates): Poke reads as unavailable when the teammate hasn't opted in design-squad P3 on #336: aria-disabled with the muted look (still focusable), a tooltip and aria-describedby with the footer sentence, and clicks do nothing. --- .../teammates/TeammateProfileView.test.tsx | 12 +++++- .../teammates/TeammateProfileView.tsx | 42 ++++++++++++------- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/teammates/TeammateProfileView.test.tsx b/src/renderer/src/components/teammates/TeammateProfileView.test.tsx index 240b1a48207..440735453ba 100644 --- a/src/renderer/src/components/teammates/TeammateProfileView.test.tsx +++ b/src/renderer/src/components/teammates/TeammateProfileView.test.tsx @@ -58,9 +58,17 @@ it('sends you to Settings when your Pokes are off, and disables Poke when theirs fireEvent.click(screen.getByRole('button', { name: 'Open settings' })) expect(onTurnOnPokes).toHaveBeenCalledOnce() cleanup() - view({ profile: profile({ pokes: { enabled: true, teammateEnabled: false } }) }) + const { onPoke } = view({ + profile: profile({ pokes: { enabled: true, teammateEnabled: false } }) + }) expect(screen.getByText('Grace Hopper hasn’t turned on Pokes yet.')).toBeTruthy() - expect(screen.getByRole('button', { name: /Poke/ }).hasAttribute('disabled')).toBe(true) + const poke = screen.getByRole('button', { name: /Poke/ }) + // Muted and explained, but still focusable; a click does nothing. + expect(poke.getAttribute('aria-disabled')).toBe('true') + expect(poke.hasAttribute('disabled')).toBe(false) + expect(poke.getAttribute('aria-describedby')).toBeTruthy() + fireEvent.click(poke) + expect(onPoke).not.toHaveBeenCalled() }) it('says when a teammate has not shared their activity', () => { diff --git a/src/renderer/src/components/teammates/TeammateProfileView.tsx b/src/renderer/src/components/teammates/TeammateProfileView.tsx index c7142879d79..a6ad5b56b97 100644 --- a/src/renderer/src/components/teammates/TeammateProfileView.tsx +++ b/src/renderer/src/components/teammates/TeammateProfileView.tsx @@ -1,8 +1,10 @@ +import { useId } from 'react' import { ArrowUpRight, Hand } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { InlineError } from '@/components/ui/inline-error' import { Skeleton } from '@/components/ui/skeleton' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useIntlLocale, useTranslate, type Translate } from '@/i18n/use-translate' import { cn } from '@/lib/utils' import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' @@ -135,6 +137,7 @@ export function TeammateProfileView(props: TeammateProfileViewProps): React.JSX. const { profile } = props const self = profile?.self ?? false const footer = pokeFooterCopy(props, t) + const pokeNoteId = useId() const loading = profile === null && props.error === null return (
@@ -275,6 +278,7 @@ export function TeammateProfileView(props: TeammateProfileViewProps): React.JSX. props.poke.kind === 'refused' ? 'text-foreground' : 'text-muted-foreground' )} role="status" + id={pokeNoteId} > {footer.note ?? t( @@ -288,20 +292,30 @@ export function TeammateProfileView(props: TeammateProfileViewProps): React.JSX. {t('auto.components.teammates.openExperimental', 'Open settings')} ) : ( - + + + {/* aria-disabled, not disabled: it stays focusable and hoverable so the tooltip can + say why it can't poke, with the muted disabled look. */} + + + {!footer.canPoke && footer.note ? ( + {footer.note} + ) : null} + )} ) : null} From 3e5fa71c071c2ad196d9e18342f92e5f86b6cef5 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 19:16:39 -0800 Subject: [PATCH 05/15] =?UTF-8?q?fix(cloud):=20one=20post-create=20agent?= =?UTF-8?q?=20prompt=20=E2=80=94=20exact=20copy,=20names=20the=20workspace?= =?UTF-8?q?=20after=20a=20switch,=20clears=20when=20the=20account=20turns?= =?UTF-8?q?=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../run-on/use-cloud-start-composer.ts | 41 ++----- src/renderer/src/i18n/locales/en.json | 5 +- .../cloud-agent-not-started-toast.test.ts | 81 ++++++++++++++ .../cloud-agent-not-started-toast.ts | 101 ++++++++++++++++++ 4 files changed, 192 insertions(+), 36 deletions(-) create mode 100644 src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.test.ts create mode 100644 src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts diff --git a/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts b/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts index 28cc960bfd6..2f4a600a969 100644 --- a/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts +++ b/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts @@ -25,41 +25,16 @@ import { cloudSetupRequired } from '../../orca-cloud-setup/cloud-setup-page-mode 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({ @@ -157,8 +132,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, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3193a98dc26..4bf4c0cd4f1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -23237,10 +23237,9 @@ "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}}." } }, "JiraIssueWorkspace": { diff --git a/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.test.ts b/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.test.ts new file mode 100644 index 00000000000..c39b9cb0317 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.test.ts @@ -0,0 +1,81 @@ +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') + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts b/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts new file mode 100644 index 00000000000..897b5d01690 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts @@ -0,0 +1,101 @@ +// After a cloud create whose agent has no Orca Cloud account: the workspace starts with a terminal +// (never the agent's /login) and this one toast is the only sign-in prompt (design-squad journey +// rule). It names the workspace once the user has moved to another one, and clears itself as soon +// as the agent's 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 } from '@/components/settings/agent-accounts/orca-cloud-accounts-copy' +import type { OrcaCloudAccountAgent } 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' +} + +export function showCloudAgentNotStartedToast( + agent: OrcaCloudAccountAgent, + workspaceName: string +): void { + teardown?.() + const name = agentName(agent, translate) + const render = (switchedAway: boolean): void => { + toast.message( + cloudAgentNotStartedMessage( + name, + switchedAway && workspaceName.trim() ? workspaceName : null + ), + { + id: TOAST_ID, + // Stays until the user acts on it or the account turns active. + duration: Number.POSITIVE_INFINITY, + action: { + label: translate( + 'components.newWorkspace.cloudStart.setUpAgentOnce', + 'Set up {{agent}} once', + { + agent: name + } + ), + onClick: openOrcaCloudAgentAccounts + }, + onDismiss: () => teardown?.() + } + ) + } + render(false) + + // The create activates the new workspace (first change); any later change means the user moved. + let createdOn: string | null | undefined + let switchedAway = false + 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 + render(away) + } + }) + const stopAccounts = subscribeOrcaCloudAgentAccounts(() => { + if (isCloudAgentCovered(agent)) { + toast.dismiss(TOAST_ID) + teardown?.() + } + }) + teardown = () => { + stopWorkspace() + stopAccounts() + teardown = null + } +} From 1742f7b6678fc09c2c7dfc01ed90bb41a0877c5d Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 20:18:32 -0800 Subject: [PATCH 06/15] =?UTF-8?q?fix(cloud):=20Neil's=20build=20gate=20?= =?UTF-8?q?=E2=80=94=20Set=20up=20once=20works,=20no=20setup=20page=20when?= =?UTF-8?q?=20only=20the=20agent=20is=20missing,=20one=20Create=20=3D=20on?= =?UTF-8?q?e=20sandbox,=20no=20hours=20toast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set up once: Settings actually opens (the target alone did nothing); the post-create prompt now runs the agent sign-in inline (Claude setup-token, Codex device code) with progress and Try again, and stays until acted on or dismissed (PC-3 interim) - PC-1: the Agent step is never a setup-page step and never gates a start - PC-8: one Create press is one intent, single-flight across the setup page, its auto-start, reveal, Exit and Back; nothing creates while the setup page is open; the intent is the create's Idempotency-Key (preload → IPC → recipe context, create-speed's #345) - PC-5: no 'Using sandbox hours' start toast - setup page: fixed top offset, header line instead of the prompt card, right-aligned actions, Back walks the steps, × Exit keeps the draft without ever auto-running, done steps clickable --- src/main/ephemeral-vm/runtime-service.ts | 3 + src/main/ipc/ephemeral-vm-recipe-context.ts | 2 + src/main/ipc/ephemeral-vm.ts | 1 + src/preload/api/ephemeral-vm-api.ts | 2 + .../new-workspace/OrcaCloudAgentSetupRow.tsx | 7 +- .../new-workspace/composer-modal-data.ts | 2 + .../run-on/cloud-start-model.test.ts | 14 +- .../run-on/orca-cloud-hours-copy.ts | 31 --- .../run-on/use-cloud-only-repo.ts | 9 +- .../run-on/use-cloud-start-composer.ts | 103 +++++---- .../run-on/use-composer-cloud-start.ts | 2 +- .../orca-cloud-setup/CloudSetupPage.tsx | 203 ++++++++++++------ .../orca-cloud-setup/CloudSetupPageLayer.tsx | 35 ++- .../orca-cloud-setup/cloud-setup-page-copy.ts | 63 ++++-- .../cloud-setup-page-model.test.ts | 21 +- .../cloud-setup-page-model.ts | 50 ++--- .../orca-cloud-setup/use-cloud-setup-flow.ts | 18 +- .../src/i18n/en-runtime-required.json | 6 +- src/renderer/src/i18n/locales/en.json | 41 ++-- .../src/lib/ephemeral-vm-workspace-target.ts | 5 +- .../src/lib/ephemeral-vm-worktree-creation.ts | 12 ++ .../cloud-agent-not-started-toast.test.ts | 29 +++ .../cloud-agent-not-started-toast.ts | 197 ++++++++++++++--- .../orca-cloud/cloud-create-intent.test.ts | 115 ++++++++++ .../src/lib/orca-cloud/cloud-create-intent.ts | 110 ++++++++++ .../lib/orca-cloud/cloud-setup-page-store.ts | 32 +-- 26 files changed, 790 insertions(+), 323 deletions(-) create mode 100644 src/renderer/src/lib/orca-cloud/cloud-create-intent.test.ts create mode 100644 src/renderer/src/lib/orca-cloud/cloud-create-intent.ts diff --git a/src/main/ephemeral-vm/runtime-service.ts b/src/main/ephemeral-vm/runtime-service.ts index cb87758dd92..573704383fd 100644 --- a/src/main/ephemeral-vm/runtime-service.ts +++ b/src/main/ephemeral-vm/runtime-service.ts @@ -33,6 +33,8 @@ export type ProvisionEphemeralVmRuntimeArgs = { projectId?: string workspaceId?: string workspaceName?: string + /** One per Create press (Orca Cloud): the create's Idempotency-Key. */ + idempotencyKey?: string repoUrl?: string branch?: string ref?: string @@ -102,6 +104,7 @@ export async function provisionEphemeralVmRuntime( projectId: args.projectId, workspaceId: args.workspaceId, workspaceName: args.workspaceName, + ...(args.idempotencyKey ? { idempotencyKey: args.idempotencyKey } : {}), repoUrl: args.repoUrl, branch: args.branch, ref: args.ref, diff --git a/src/main/ipc/ephemeral-vm-recipe-context.ts b/src/main/ipc/ephemeral-vm-recipe-context.ts index a1b8be2e052..1ede359801d 100644 --- a/src/main/ipc/ephemeral-vm-recipe-context.ts +++ b/src/main/ipc/ephemeral-vm-recipe-context.ts @@ -36,6 +36,8 @@ export type EphemeralVmProvisionArgs = { branch?: string ref?: string provisionId?: string + /** Orca Cloud: the Create press's key; a re-fired create gets the first one's sandbox. */ + idempotencyKey?: string } export type EphemeralVmRecipeCatalogEntry = { diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts index 15af546b10e..5f831e464e4 100644 --- a/src/main/ipc/ephemeral-vm.ts +++ b/src/main/ipc/ephemeral-vm.ts @@ -195,6 +195,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin projectId: args.projectId, workspaceId: args.workspaceId, workspaceName: args.workspaceName, + ...(args.idempotencyKey ? { idempotencyKey: args.idempotencyKey } : {}), ...(repoUrl ? { repoUrl } : {}), ...(args.branch ? { branch: args.branch } : {}), ...(sourceRef ? { ref: sourceRef } : {}), diff --git a/src/preload/api/ephemeral-vm-api.ts b/src/preload/api/ephemeral-vm-api.ts index 12ae965adef..e212d012f1e 100644 --- a/src/preload/api/ephemeral-vm-api.ts +++ b/src/preload/api/ephemeral-vm-api.ts @@ -58,6 +58,8 @@ export type EphemeralVmApi = { branch?: string ref?: string provisionId?: string + /** Orca Cloud: one per Create press, sent as the create's Idempotency-Key. */ + idempotencyKey?: string }) => Promise< | { ok: true diff --git a/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx b/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx index e3d3b0efcd9..4c4de67f66f 100644 --- a/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx +++ b/src/renderer/src/components/new-workspace/OrcaCloudAgentSetupRow.tsx @@ -21,9 +21,10 @@ import { agentName, keySourceLabel } from '../settings/agent-accounts/orca-cloud /** Settings → Agent accounts, scrolled to the Orca Cloud section (S0). The composer stays open. */ export function openOrcaCloudAgentAccounts(): void { - useAppStore - .getState() - .openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'orca-cloud' }) + const state = useAppStore.getState() + state.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'orca-cloud' }) + // The target alone only pre-selects the pane; the page has to open too (Neil, P0: "does nothing"). + state.openSettingsPage() } export function orcaCloudAgentCoveredLabel( diff --git a/src/renderer/src/components/new-workspace/composer-modal-data.ts b/src/renderer/src/components/new-workspace/composer-modal-data.ts index 0a2a0f21224..fbf97f38b09 100644 --- a/src/renderer/src/components/new-workspace/composer-modal-data.ts +++ b/src/renderer/src/components/new-workspace/composer-modal-data.ts @@ -40,4 +40,6 @@ export type ComposerModalData = { setupGuideTourRequestId?: string /** Orca Cloud setup finished on its page: submit without showing the dialog (falls back to it). */ cloudAutoStart?: boolean + /** The Create press's intent the auto-start may use, once (one press, one server create). */ + cloudCreateIntent?: string } diff --git a/src/renderer/src/components/new-workspace/run-on/cloud-start-model.test.ts b/src/renderer/src/components/new-workspace/run-on/cloud-start-model.test.ts index 7cb5e31ca4b..71d1f4f0a35 100644 --- a/src/renderer/src/components/new-workspace/run-on/cloud-start-model.test.ts +++ b/src/renderer/src/components/new-workspace/run-on/cloud-start-model.test.ts @@ -24,24 +24,25 @@ describe('Orca Cloud availability', () => { describe('setup rows and the setup page', () => { const page = (steps: Parameters[0]) => - cloudSetupPageModel(steps, { agentSkipped: true }).action + cloudSetupPageModel(steps).action it('starts directly when the account is entitled', () => { const steps = deriveCloudSetupSteps({ availability: 'offered', access: { state: 'entitled', remainingSeconds: 3600 } }) - expect(cloudSetupRequired(steps, { agentSkipped: true })).toBe(false) + expect(cloudSetupRequired(steps)).toBe(false) expect(page(steps)).toBe('done') }) - it('an agent with no account opens the page until it is skipped once', () => { + it('an agent with no account never opens the page (PC-1): the create starts at once', () => { const steps = deriveCloudSetupSteps({ availability: 'offered', access: { state: 'entitled', remainingSeconds: 3600 } }) - expect(cloudSetupRequired(steps)).toBe(true) - expect(cloudSetupPageModel(steps).action).toBe('agent') + expect(steps.find((s) => s.id === 'agent')?.state).toBe('optional') + expect(cloudSetupRequired(steps)).toBe(false) + expect(cloudSetupPageModel(steps).action).toBe('done') }) it('signed out: sign-in first, plan checked after sign-in', () => { @@ -148,7 +149,7 @@ describe('setup rows and the setup page', () => { access: { state: 'entitled', remainingSeconds: 3600 }, github: { id: 'github', state: 'pending', need: 'github', blocksStart: true } }) - expect(cloudSetupRequired(steps, { agentSkipped: true })).toBe(true) + expect(cloudSetupRequired(steps)).toBe(true) expect(page(steps)).toBe('github-waiting') }) @@ -189,6 +190,7 @@ describe('setup rows and the setup page', () => { { id: 'agent', state: 'loading' } ] expect(cloudSetupRequired(steps)).toBe(false) + expect(cloudSetupPageModel(steps).action).toBe('done') }) }) diff --git a/src/renderer/src/components/new-workspace/run-on/orca-cloud-hours-copy.ts b/src/renderer/src/components/new-workspace/run-on/orca-cloud-hours-copy.ts index 6362baa6e36..641e0cbb14b 100644 --- a/src/renderer/src/components/new-workspace/run-on/orca-cloud-hours-copy.ts +++ b/src/renderer/src/components/new-workspace/run-on/orca-cloud-hours-copy.ts @@ -37,37 +37,6 @@ export function getOrcaCloudHoursChip( }) } -/** The start toast: "Using sandbox hours · 2 sandbox hours left in your trial". */ -export function getOrcaCloudStartToast( - access: OrcaCloudRunTargetAccess, - t: Translate, - locale: string -): string | null { - if (access.state !== 'entitled') { - return null - } - const args = hoursArgs(access.remainingSeconds, locale) - return access.trial - ? t( - 'components.newWorkspace.cloudHours.trialToast', - 'Using sandbox hours · {{hours}} sandbox hours left in your trial', - { - count: args.count, - hours: args.hours, - defaultValue_one: 'Using sandbox hours · {{hours}} sandbox hour left in your trial' - } - ) - : t( - 'components.newWorkspace.cloudHours.planToast', - 'Using sandbox hours · {{hours}} sandbox hours left', - { - count: args.count, - hours: args.hours, - defaultValue_one: 'Using sandbox hours · {{hours}} sandbox hour left' - } - ) -} - /** T1: under half an hour of trial left, the hint turns to a warning with the fix. */ export const ORCA_CLOUD_TRIAL_LOW_SECONDS = 30 * 60 diff --git a/src/renderer/src/components/new-workspace/run-on/use-cloud-only-repo.ts b/src/renderer/src/components/new-workspace/run-on/use-cloud-only-repo.ts index 7326ddc2015..1378325d091 100644 --- a/src/renderer/src/components/new-workspace/run-on/use-cloud-only-repo.ts +++ b/src/renderer/src/components/new-workspace/run-on/use-cloud-only-repo.ts @@ -2,7 +2,6 @@ // repositories with no clone here. Picking one keeps Orca Cloud and starts it from GitHub. import { useCallback, useMemo, useState } from 'react' import { useAppStore } from '@/store' -import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { clearCloudStartDraft } from '@/lib/orca-cloud/cloud-start-draft' import { @@ -102,7 +101,6 @@ export function useCloudOnlyCreate({ prompt, agent, profileId, - hoursToast, onClose }: { cloudOnly: CloudOnlyRepo @@ -110,8 +108,6 @@ export function useCloudOnlyCreate({ prompt: string agent: string | null profileId: string | null - /** "Using sandbox hours · …", or null before billing answers. */ - hoursToast: string | null onClose?: () => void }): () => void { const { picked, start } = cloudOnly @@ -121,12 +117,9 @@ export function useCloudOnlyCreate({ return } clearCloudStartDraft(profileId) - if (hoursToast) { - toast.message(hoursToast) - } void start({ prompt, agent }) onClose?.() - }, [agent, createWorkspace, hoursToast, onClose, picked, profileId, prompt, start]) + }, [agent, createWorkspace, onClose, picked, profileId, prompt, start]) } /** diff --git a/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts b/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts index 2f4a600a969..c37f84d5485 100644 --- a/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts +++ b/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts @@ -2,15 +2,18 @@ // dialog: Start either creates the workspace (everything is set up) or saves the draft, closes the // dialog and opens the full-page setup, which starts the prompt by itself when done. Also: where // each project last started (O5), the remembered default (D7) and GitHub-only repositories (D4). -import { useCallback, useEffect, useMemo, useRef } from 'react' -import { toast } from 'sonner' +import { useCallback, useEffect, useMemo } from 'react' import { useAppStore } from '@/store/index' -import { translate } from '@/i18n/i18n' import { clearCloudStartDraft, saveCloudStartDraft } from '@/lib/orca-cloud/cloud-start-draft' import { - isCloudSetupAgentSkipped, + getCloudSetupPageRequest, openCloudSetupPage } from '@/lib/orca-cloud/cloud-setup-page-store' +import { + armCloudCreateIdempotency, + consumeCloudCreateIntent, + startCloudCreate +} from '@/lib/orca-cloud/cloud-create-intent' import { recordSuccessfulRunOn } from '@/lib/orca-cloud/run-on-memory' import { ORCA_CLOUD_RECIPE_ID } from '../../../../../shared/hosted-sandbox/orca-cloud-recipe-id' import type { OrcaCloudAvailability } from '../../../../../shared/orca-yaml-hook-types' @@ -18,7 +21,6 @@ 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' @@ -46,7 +48,7 @@ export function useCloudStartComposer({ prompt, agent, initialTargetFixed, - autoStart = false, + autoStartIntent = null, createReady = true, composerSnapshot, onCreate: createWorkspace, @@ -63,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. */ @@ -104,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 = @@ -118,7 +120,6 @@ export function useCloudStartComposer({ prompt, agent: agentUnset ? null : agent, profileId, - hoursToast: getOrcaCloudStartToast(access, translate, navigator.language), onClose }) const onCreate = useCallback(() => { @@ -151,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) { @@ -195,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( () => @@ -217,5 +228,5 @@ export function useCloudStartComposer({ if (!view || !cloudSelected) { return { view, onPrimary: null, recordCreated } } - return { view, onPrimary: setupNeeded ? openSetup : onCreate, recordCreated } + return { view, onPrimary: onCloudPrimary, recordCreated } } diff --git a/src/renderer/src/components/new-workspace/run-on/use-composer-cloud-start.ts b/src/renderer/src/components/new-workspace/run-on/use-composer-cloud-start.ts index fd9ec93edc9..6e6f291d5c1 100644 --- a/src/renderer/src/components/new-workspace/run-on/use-composer-cloud-start.ts +++ b/src/renderer/src/components/new-workspace/run-on/use-composer-cloud-start.ts @@ -66,7 +66,7 @@ export function useComposerCloudStart({ initialTargetFixed: Boolean( modalData.initialEphemeralVmRecipeId || modalData.initialWorkspaceTarget || modalData.recovery ), - autoStart: modalData.cloudAutoStart === true, + autoStartIntent: modalData.cloudAutoStart ? (modalData.cloudCreateIntent ?? null) : null, createReady: !createDisabled, composerSnapshot, onCreate: runCreate, diff --git a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx index d9dabad6168..6c0301cabbc 100644 --- a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx +++ b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx @@ -3,24 +3,28 @@ // steps are ticks in the stepper. Browser hops only for sign-in, Stripe and GitHub. When every // step is done, the saved prompt starts by itself. import React from 'react' -import { ArrowLeft, Check, Cloud, Copy, ExternalLink, LoaderCircle } from 'lucide-react' +import { ArrowLeft, Check, Copy, ExternalLink, LoaderCircle, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { cn } from '@/lib/utils' import { useTranslate } from '@/i18n/use-translate' -import type { CloudSetupStep } from '@/components/new-workspace/run-on/cloud-start-model' +import type { + CloudSetupStep, + CloudSetupStepId +} from '@/components/new-workspace/run-on/cloud-start-model' import { CloudSetupErrorNotice } from '@/components/new-workspace/cloud-setup/CloudSetupErrorNotice' -import { currentStepCopy, stepLabel } from './cloud-setup-page-copy' +import { currentStepCopy, doneStepCopy, stepLabel, type StepCopy } from './cloud-setup-page-copy' import type { CloudSetupFlow } from './use-cloud-setup-flow' -import { openOrcaCloudAgentAccounts } from '@/components/new-workspace/OrcaCloudAgentSetupRow' export type CloudSetupPageProps = { flow: CloudSetupFlow projectName: string | null prompt: string agentLabel: string | null - /** Back to the composer with the prompt, project and agent as they were. */ - onBack: () => void + /** Back from the first step: Create workspace again, with the prompt, project and agent kept. */ + onBackToComposer: () => void + /** The × in the corner: leave setup, keep the draft (nothing starts). */ + onExit: () => void /** "All set": shown for a moment before the workspace starts. */ starting: boolean } @@ -30,11 +34,17 @@ type StepState = 'done' | 'current' | 'todo' function SetupStepper({ steps, current, - allDone + viewing, + allDone, + onView }: { steps: readonly CloudSetupStep[] current: CloudSetupStep | null + /** A finished step being revisited. */ + viewing: CloudSetupStepId | null allDone: boolean + /** Revisit a finished step; null returns to the current one. */ + onView: (id: CloudSetupStepId | null) => void }): React.JSX.Element { const t = useTranslate() const currentIndex = current ? steps.findIndex((step) => step.id === current.id) : steps.length @@ -47,9 +57,9 @@ function SetupStepper({ const state: StepState = allDone || index < currentIndex ? 'done' : index === currentIndex ? 'current' : 'todo' const label = stepLabel(step, t) - return ( -
  • - {index > 0 ? : null} + const shownHere = viewing ? viewing === step.id : state === 'current' + const mark = ( + <> {label} @@ -79,6 +89,31 @@ function SetupStepper({ : ''} + + ) + // Finished steps (and the current one, to come back to it) open; future ones don't. + const clickable = + !allDone && (state === 'done' || (state === 'current' && viewing !== null)) + return ( +
  • + {index > 0 ? : null} + {clickable ? ( + + ) : ( + + {mark} + + )}
  • ) })} @@ -152,20 +187,6 @@ function StepActions({ flow }: { flow: CloudSetupFlow }): React.JSX.Element | nu ) case 'switch-team': return
    {flow.teamAction}
    - case 'agent': - return ( -
    - - -
    - ) case 'needs-repository': return flow.current?.action ? ( - - - {t('cloudSetupPage.eyebrow', 'Orca Cloud setup')} - -
    +
    + + + {context} + + +
    -
    - -
    + {/* The same optically-centred top offset on every step, so nothing jumps between them. */} +
    +

    {copy.title}

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

    {copy.lead}

    - ) : null} - {copy.note ?

    {copy.note}

    : null} +
    - {flow.error && !starting ? ( + {flow.error && !starting && !viewed ? ( ) : null} -
    - -
    - -
    -
    -
    - {t('cloudSetupPage.startsAfter', 'Starts when setup is done')} -
    -
    - {prompt.trim() || t('cloudSetupPage.untitled', 'New cloud workspace')} -
    -
    - {[projectName, agentLabel, t('cloudSetupPage.orcaCloud', 'Orca Cloud')] - .filter(Boolean) - .join(' · ')} -
    -
    +
    + {viewed ? ( + + ) : ( + + )}
    diff --git a/src/renderer/src/components/orca-cloud-setup/CloudSetupPageLayer.tsx b/src/renderer/src/components/orca-cloud-setup/CloudSetupPageLayer.tsx index 3672eddd32d..c57f661d8ad 100644 --- a/src/renderer/src/components/orca-cloud-setup/CloudSetupPageLayer.tsx +++ b/src/renderer/src/components/orca-cloud-setup/CloudSetupPageLayer.tsx @@ -4,6 +4,10 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { useAppStore } from '@/store' import { useTranslate } from '@/i18n/use-translate' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { clearCloudStartDraft } from '@/lib/orca-cloud/cloud-start-draft' +import { cancelCloudCreateIntent } from '@/lib/orca-cloud/cloud-create-intent' import { closeCloudSetupPage, useCloudSetupPageRequest, @@ -21,11 +25,33 @@ import { useCloudSetupFlow } from './use-cloud-setup-flow' /** How long every row must read done before the page starts the workspace. */ const SETTLE_MS = 1_500 +/** + * The × leaves setup. Nothing starts later (JA-01): the page and its auto-start go away, and the + * on-disk draft is dropped. The draft stays one click away in a toast, as a filled composer. + */ +function exitSetup(request: CloudSetupPageRequest): void { + cancelCloudCreateIntent(request.intentId) + closeCloudSetupPage() + clearCloudStartDraft(useAppStore.getState().activeOrcaProfileId ?? null) + toast.message( + translate('cloudSetupPage.exited', 'Orca Cloud setup closed. Your draft is kept.'), + { + action: { + label: translate('cloudSetupPage.resumeDraft', 'Open draft'), + onClick: () => reopenComposer(request, false) + } + } + ) +} + function reopenComposer(request: CloudSetupPageRequest, autoStart: boolean): void { // A GitHub-only repository can't be preselected; the composer opens for one more Start. const hiddenStart = autoStart && !request.cloudOnlyRepo if (!hiddenStart) { + // Back, or a start that needs one more press: this setup's intent can never create. + cancelCloudCreateIntent(request.intentId) closeCloudSetupPage() + clearCloudStartDraft(useAppStore.getState().activeOrcaProfileId ?? null) } else { // Warm the start screen's chunk so it is ready the frame the create takes over. void import('@/components/worktree-creation/WorktreeCreationPanel').catch(() => undefined) @@ -33,7 +59,7 @@ function reopenComposer(request: CloudSetupPageRequest, autoStart: boolean): voi useAppStore.getState().openModal('new-workspace-composer', { ...request.composer, initialEphemeralVmRecipeId: ORCA_CLOUD_RECIPE_ID, - ...(hiddenStart ? { cloudAutoStart: true } : {}) + ...(hiddenStart ? { cloudAutoStart: true, cloudCreateIntent: request.intentId } : {}) }) if (hiddenStart) { // The page stays up until the start screen has painted: nothing under it may flash. @@ -76,14 +102,16 @@ function ActiveCloudSetupPage({ request }: { request: CloudSetupPageRequest }): ) return () => window.clearTimeout(timer) }, [request, starting]) - const onBack = useCallback(() => reopenComposer(request, false), [request]) + const onBackToComposer = useCallback(() => reopenComposer(request, false), [request]) + const onExit = useCallback(() => exitSetup(request), [request]) return ( ) @@ -104,6 +132,7 @@ export function CloudSetupPageLayer(): React.JSX.Element | null { return } if (openedOnRef.current !== activeWorktreeId) { + cancelCloudCreateIntent(request.intentId) closeCloudSetupPage() } }, [activeWorktreeId, request]) diff --git a/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-copy.ts b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-copy.ts index edd0e67a473..4ad8ab0224f 100644 --- a/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-copy.ts +++ b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-copy.ts @@ -22,7 +22,13 @@ export function stepLabel(step: CloudSetupStep, t: Translate): string { } } -type StepCopy = { title: string; lead: string | null; note?: string | null } +export type StepCopy = { + title: string + lead: string | null + note?: string | null + /** A gentle option under the lead, set apart in muted italics (the agent step's skip hint). */ + aside?: string | null +} function trialHours(step: CloudSetupStep): string | null { return step.trialSeconds ? String(Math.round((step.trialSeconds / 3600) * 10) / 10) : null @@ -30,8 +36,7 @@ function trialHours(step: CloudSetupStep): string | null { export function currentStepCopy( flow: Pick, - t: Translate, - agentLabel: string | null + t: Translate ): StepCopy { const step = flow.current const detail = step @@ -146,16 +151,6 @@ export function currentStepCopy( 'Orca Cloud needs a Team plan. Only owners and admins can choose one.' ) } - case 'agent': - return { - title: agentLabel - ? t('cloudSetupPage.agent.title', 'Sign in to {{agent}} once', { agent: agentLabel }) - : t('cloudSetupPage.agent.titleGeneric', 'Sign in to your agent once'), - lead: t( - 'cloudSetupPage.agent.lead', - 'Save your agent sign-in to your Orca account and every cloud workspace uses it. You can also skip this and sign in inside the workspace.' - ) - } case 'checking': return { title: t('cloudSetupPage.checking.title', 'Checking your Orca Cloud account…'), @@ -168,3 +163,45 @@ export function currentStepCopy( } } } + +/** A finished step revisited from the stepper or Back: what it settled, in one calm line. */ +export function doneStepCopy( + step: CloudSetupStep, + t: Translate, + agentLabel: string | null +): StepCopy { + switch (step.id) { + case 'account': + return { + title: t('cloudSetupPage.done.accountTitle', 'You’re signed in'), + lead: t('cloudSetupPage.done.accountLead', 'Your Orca account is ready for Orca Cloud.') + } + case 'team': + return { + title: t('cloudSetupPage.done.teamTitle', 'Team selected'), + lead: step.detail ?? null + } + case 'plan': + return { + title: + step.detail === 'trial' || step.need === 'trial' + ? t('cloudSetupPage.done.trialTitle', 'Your trial is on') + : t('cloudSetupPage.done.planTitle', 'Your team has Orca Cloud'), + lead: null + } + case 'github': + return { + title: t('cloudSetupPage.done.githubTitle', 'Orca Cloud can reach this repository'), + lead: null + } + case 'agent': + return { + title: agentLabel + ? t('cloudSetupPage.done.agentTitle', '{{agent}} is set up for Orca Cloud', { + agent: agentLabel + }) + : t('cloudSetupPage.done.agentTitleGeneric', 'Your agent is set up for Orca Cloud'), + lead: null + } + } +} diff --git a/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.test.ts b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.test.ts index 1fbeb8eca20..f1606a782b1 100644 --- a/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.test.ts +++ b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.test.ts @@ -16,17 +16,15 @@ describe('cloudSetupPageModel', () => { expect(sessionNeedsFrom(steps)).toEqual(['plan', 'github']) }) - it('a skipped agent finishes setup', () => { + it('the agent is never a page step and never holds the start (PC-1)', () => { const steps: CloudSetupStep[] = [ { id: 'account', state: 'done' }, { id: 'plan', state: 'done' }, { id: 'agent', state: 'optional' } ] - expect(cloudSetupPageModel(steps).action).toBe('agent') - expect(cloudSetupPageModel(steps, { agentSkipped: true })).toMatchObject({ - current: null, - action: 'done' - }) + const model = cloudSetupPageModel(steps) + expect(model.steps.map((step) => step.id)).toEqual(['account', 'plan']) + expect(model).toMatchObject({ current: null, action: 'done' }) }) it('maps each blocker to its one action', () => { @@ -41,14 +39,3 @@ describe('cloudSetupPageModel', () => { expect(actionForStep({ id: 'github', state: 'loading' })).toBe('checking') }) }) - -describe('page settles before it starts', () => { - it('a still-loading agent row reads Checking, not All set', () => { - const steps: CloudSetupStep[] = [ - { id: 'account', state: 'done' }, - { id: 'plan', state: 'done' }, - { id: 'agent', state: 'loading' } - ] - expect(cloudSetupPageModel(steps).action).toBe('checking') - }) -}) diff --git a/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.ts b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.ts index ca7c8884e5f..584e1f86269 100644 --- a/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.ts +++ b/src/renderer/src/components/orca-cloud-setup/cloud-setup-page-model.ts @@ -22,8 +22,6 @@ export type CloudSetupPageAction = | 'needs-repository' /** A member can't fix billing; an owner or admin has to. */ | 'ask-owner' - /** Optional agent account: set it up once, or skip. */ - | 'agent' /** Still reading account, plan or repository state. */ | 'checking' /** Every step is done: the prompt starts. */ @@ -35,17 +33,6 @@ export type CloudSetupPageModel = { action: CloudSetupPageAction } -function isFinished(step: CloudSetupStep, agentSkipped: boolean): boolean { - if (step.state === 'done') { - return true - } - if (step.id === 'agent') { - // Still loading reads "Checking": the page only says All set once every row has answered. - return agentSkipped && step.state !== 'loading' - } - return false -} - export function actionForStep( step: CloudSetupStep, { teamSwitch = false }: { teamSwitch?: boolean } = {} @@ -90,23 +77,23 @@ export function actionForStep( } return 'github-install' case 'agent': - return 'agent' + // Never a page step (PC-1): agent sign-in is offered after create. + return 'done' } } /** - * The page's state from the setup rows. Steps come in walk order (account, team, plan, GitHub, - * agent); the first unfinished one is current. A signed-out plan row is "pending" until sign-in, so - * account stays current until then. + * The page's state from the setup rows. Steps come in walk order (account, team, plan, GitHub); the + * first unfinished one is current. The agent row is never a step (PC-1): it never gates a start, and + * its sign-in is offered after create. A signed-out plan row is "pending" until sign-in, so account + * stays current until then. */ export function cloudSetupPageModel( - steps: readonly CloudSetupStep[], - { - agentSkipped = false, - teamSwitch = false - }: { agentSkipped?: boolean; teamSwitch?: boolean } = {} + rows: readonly CloudSetupStep[], + { teamSwitch = false }: { teamSwitch?: boolean } = {} ): CloudSetupPageModel { - const current = steps.find((step) => !isFinished(step, agentSkipped)) ?? null + const steps = rows.filter((step) => step.id !== 'agent') + const current = steps.find((step) => step.state !== 'done') ?? null if (!current) { return { steps, current: null, action: 'done' } } @@ -121,23 +108,16 @@ export function sessionNeedsFrom(steps: readonly CloudSetupStep[]): CloudSetupSt } /** - * Pressing Start goes straight to create only when nothing is open. An agent without an account - * counts until the user skips it once; a still-loading agent row never holds a start. + * Pressing Start opens the setup page only when the account, team, plan or GitHub is missing. The + * agent never holds a start (PC-1): without an Orca Cloud account it starts as a plain terminal and + * the post-create prompt offers the sign-in. */ export function cloudSetupRequired( steps: readonly CloudSetupStep[], - { - teamSwitch = false, - agentSkipped = false - }: { teamSwitch?: boolean; agentSkipped?: boolean } = {} + { teamSwitch = false }: { teamSwitch?: boolean } = {} ): boolean { if (teamSwitch) { return true } - return steps.some((step) => { - if (step.id === 'agent') { - return step.state === 'optional' && !agentSkipped - } - return step.state !== 'done' - }) + return steps.some((step) => step.id !== 'agent' && step.state !== 'done') } diff --git a/src/renderer/src/components/orca-cloud-setup/use-cloud-setup-flow.ts b/src/renderer/src/components/orca-cloud-setup/use-cloud-setup-flow.ts index 165dbd03b21..680274cf170 100644 --- a/src/renderer/src/components/orca-cloud-setup/use-cloud-setup-flow.ts +++ b/src/renderer/src/components/orca-cloud-setup/use-cloud-setup-flow.ts @@ -20,11 +20,7 @@ import { reopenCloudSetup } from '@/components/new-workspace/cloud-setup/cloud-setup-tracker-actions' import { getCloudSetupStarter } from '@/lib/orca-cloud/cloud-setup-session-seam' -import { - isCloudSetupAgentSkipped, - rememberCloudSetupAgentSkipped, - type CloudSetupPageRequest -} from '@/lib/orca-cloud/cloud-setup-page-store' +import type { CloudSetupPageRequest } from '@/lib/orca-cloud/cloud-setup-page-store' import { reportCloudSetupProgress } from '@/lib/orca-cloud/cloud-setup-telemetry' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import type { OrcaCloudAvailability } from '../../../../shared/orca-yaml-hook-types' @@ -89,7 +85,6 @@ export type CloudSetupFlow = CloudSetupPageModel & { startTrial: () => void fixBilling: () => void installGitHub: () => void - skipAgent: () => void checkAgain: () => void copyLink: (() => void) | null reopenBrowser: () => void @@ -108,7 +103,6 @@ export function useCloudSetupFlow( // opened for, so it waits on all of them; a single hop (GitHub install, billing) only on its own. const [browserStep, setBrowserStep] = useState(null) const [busy, setBusy] = useState(false) - const [agentSkipped, setAgentSkipped] = useState(() => isCloudSetupAgentSkipped(request.agent)) const githubOverride = useMemo( () => (request.cloudOnlyRepo ? { id: 'github', state: 'done' } : null), [request.cloudOnlyRepo] @@ -127,14 +121,14 @@ export function useCloudSetupFlow( githubOverride }) const { steps, held: switchTeam } = useSwitchTeamSteps(rowSteps, !session) - const model = cloudSetupPageModel(steps, { agentSkipped, teamSwitch: switchTeam !== null }) + const model = cloudSetupPageModel(steps, { teamSwitch: switchTeam !== null }) const error = useMemo(() => { const parsed = parseCloudSetupError(session?.error) return parsed && session?.teamName ? { ...parsed, teamName: session.teamName } : parsed }, [session?.error, session?.teamName]) // Rows derived from billing tick on their own: re-read while a step is waiting elsewhere. - const waiting = model.action !== 'done' && model.action !== 'agent' + const waiting = model.action !== 'done' useEffect(() => { if (!waiting || accessOverride) { return @@ -217,11 +211,6 @@ export function useCloudSetupFlow( void openInstall() }, [markInBrowser, openInstall]) - const skipAgent = useCallback(() => { - rememberCloudSetupAgentSkipped(request.agent) - setAgentSkipped(true) - }, [request.agent]) - const checkAgain = useCallback(() => void prefetchOrcaCloudBilling(true), []) return { @@ -235,7 +224,6 @@ export function useCloudSetupFlow( startTrial, fixBilling, installGitHub, - skipAgent, checkAgain, copyLink: sessionId && canCopyCloudSetupLink(sessionId) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 671c08689c0..7208c28db92 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2131,14 +2131,10 @@ "cloudHours": { "planChip_one": "{{hours}} sandbox hour left", "planChip_other": "{{hours}} sandbox hours left", - "planToast_one": "Using sandbox hours · {{hours}} sandbox hour left", - "planToast_other": "Using sandbox hours · {{hours}} sandbox hours left", "trialChip_one": "Trial · {{hours}} sandbox hour left", "trialChip_other": "Trial · {{hours}} sandbox hours left", "trialOffered_one": "Trial · {{hours}} sandbox hour left · No card needed", - "trialOffered_other": "Trial · {{hours}} sandbox hours left · No card needed", - "trialToast_one": "Using sandbox hours · {{hours}} sandbox hour left in your trial", - "trialToast_other": "Using sandbox hours · {{hours}} sandbox hours left in your trial" + "trialOffered_other": "Trial · {{hours}} sandbox hours left · No card needed" }, "cloudSetup": { "trialOffer_one": "{{hours}} sandbox hour to try Orca Cloud", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4bf4c0cd4f1..69dd241e41b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -23211,10 +23211,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" }, @@ -23239,7 +23235,13 @@ "cloudStart": { "setUpAgentOnce": "Set up {{agent}} 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}}." + "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": { @@ -28071,13 +28073,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.", @@ -28096,16 +28091,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" @@ -28128,7 +28129,6 @@ "title": "Sign in to Orca" }, "starting": "Starting…", - "startsAfter": "Starts when setup is done", "step": { "account": "Account", "agent": "Agent", @@ -28152,7 +28152,12 @@ "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" } } diff --git a/src/renderer/src/lib/ephemeral-vm-workspace-target.ts b/src/renderer/src/lib/ephemeral-vm-workspace-target.ts index ad0f768f2a1..15b11617c65 100644 --- a/src/renderer/src/lib/ephemeral-vm-workspace-target.ts +++ b/src/renderer/src/lib/ephemeral-vm-workspace-target.ts @@ -28,6 +28,8 @@ export type PrepareEphemeralVmWorkspaceTargetArgs = { branch?: string ref?: string provisionId?: string + /** Orca Cloud: the Create press's key (one press, one sandbox). */ + idempotencyKey?: string setupExistingFolder: ( args: ProjectHostSetupExistingFolderArgs ) => Promise @@ -62,7 +64,8 @@ export async function prepareEphemeralVmWorkspaceTarget( workspaceName: args.workspaceName, ...(args.branch ? { branch: args.branch } : {}), ...(args.ref ? { ref: args.ref } : {}), - ...(args.provisionId ? { provisionId: args.provisionId } : {}) + ...(args.provisionId ? { provisionId: args.provisionId } : {}), + ...(args.idempotencyKey ? { idempotencyKey: args.idempotencyKey } : {}) }) if (!provisioned.ok) { return { diff --git a/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts b/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts index 084094691a3..b03c9e01085 100644 --- a/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts +++ b/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts @@ -19,6 +19,8 @@ import { appendProvisioningLog, appendProvisioningWarnings } from '@/lib/pending-creation-provisioning-log' +import { cloudCreateIdempotencyKey } from '@/lib/orca-cloud/cloud-create-intent' +import { ORCA_CLOUD_RECIPE_ID } from '../../../shared/hosted-sandbox/orca-cloud-recipe-id' export async function prepareRequestForCreate( creationId: string, @@ -71,6 +73,7 @@ async function prepareEphemeralVmRecipeRequest( } : {}), provisionId: creationId, + ...cloudCreateIdempotency(recipe.recipeId, creationId), setupExistingFolder: async (args) => { // The VM's checkout folder is named "project"; register it under the real project's name. const displayName = @@ -268,3 +271,12 @@ export function getProvisionedRootSparseCheckoutError(): string { 'Provisioned-root recipes do not support sparse checkout.' ) } + +/** Orca Cloud creates carry the Create press's Idempotency-Key; other recipes don't. */ +function cloudCreateIdempotency(recipeId: string, creationId: string): { idempotencyKey?: string } { + if (recipeId !== ORCA_CLOUD_RECIPE_ID) { + return {} + } + const key = cloudCreateIdempotencyKey(creationId) + return key ? { idempotencyKey: key } : {} +} diff --git a/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.test.ts b/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.test.ts index c39b9cb0317..8352e50e713 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.test.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' const toastMock = vi.hoisted(() => ({ message: vi.fn(), dismiss: vi.fn() })) @@ -78,4 +79,32 @@ describe('cloud agent not-started toast', () => { 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: { ...(window as { api?: object }).api, orcaCloudAgents: { start } } + }) + showCloudAgentNotStartedToast('claude', 'w') + const options: { + duration: number + action: { onClick: (event: { preventDefault: () => void }) => void } + } = toastMock.message.mock.calls.at(-1)?.[1] + expect(options.duration).toBe(Number.POSITIVE_INFINITY) + const preventDefault = vi.fn() + options.action.onClick({ preventDefault }) + expect(preventDefault).toHaveBeenCalled() + expect(start).toHaveBeenCalledWith({ agent: 'claude' }) + }) + + it('in the workspace itself it stays hidden (its pane strip asks); away, it names it', () => { + showCloudAgentNotStartedToast('claude', 'fix-readme-typo', { onlyWhenAway: true }) + expect(toastMock.message).not.toHaveBeenCalled() + useAppStore.setState({ activeWorktreeId: 'repo::new' }) + useAppStore.setState({ activeWorktreeId: 'repo::other' }) + expect(toastMock.message).toHaveBeenLastCalledWith( + 'Claude isn’t set up for Orca Cloud, so it didn’t start in fix-readme-typo.', + expect.anything() + ) + }) }) diff --git a/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts b/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts index 897b5d01690..30024fddd20 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts @@ -1,13 +1,23 @@ -// After a cloud create whose agent has no Orca Cloud account: the workspace starts with a terminal -// (never the agent's /login) and this one toast is the only sign-in prompt (design-squad journey -// rule). It names the workspace once the user has moved to another one, and clears itself as soon -// as the agent's account turns active. +// 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 } from '@/components/settings/agent-accounts/orca-cloud-accounts-copy' -import type { OrcaCloudAccountAgent } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +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, @@ -38,41 +48,163 @@ export function isCloudAgentCovered(agent: OrcaCloudAccountAgent): boolean { 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 } + ), + ...(setup.canReopen + ? { + action: { + label: translate('orcaCloudAccounts.openAgain', 'Open again'), + run: () => void api?.reopen({ agent }) + } + } + : {}) + } + case 'preparing': + return { + message: translate('orcaCloudAccounts.preparing', 'Getting ChatGPT sign-in ready') + } + 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 + 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) - const render = (switchedAway: boolean): void => { - toast.message( - cloudAgentNotStartedMessage( + 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 ), - { - id: TOAST_ID, - // Stays until the user acts on it or the account turns active. - duration: Number.POSITIVE_INFINITY, - action: { - label: translate( - 'components.newWorkspace.cloudStart.setUpAgentOnce', - 'Set up {{agent}} once', - { - agent: name - } - ), - onClick: openOrcaCloudAgentAccounts - }, - onDismiss: () => teardown?.() + 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?.() + } + } + }) } - render(false) + 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 - let switchedAway = false const stopWorkspace = useAppStore.subscribe((state, previous) => { if (state.activeWorktreeId === previous.activeWorktreeId) { return @@ -84,13 +216,20 @@ export function showCloudAgentNotStartedToast( const away = state.activeWorktreeId !== createdOn if (away !== switchedAway) { switchedAway = away - render(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 = () => { diff --git a/src/renderer/src/lib/orca-cloud/cloud-create-intent.test.ts b/src/renderer/src/lib/orca-cloud/cloud-create-intent.test.ts new file mode 100644 index 00000000000..8536db819c5 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-create-intent.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + cancelCloudCreateIntent, + cloudCreateIdempotencyKey, + consumeCloudCreateIntent, + resetCloudCreateIntentsForTest, + startCloudCreate +} from './cloud-create-intent' +import { + closeCloudSetupPage, + getCloudSetupPageRequest, + openCloudSetupPage +} from './cloud-setup-page-store' + +// One Create press makes exactly one server create, across sign-in, the setup page, its auto-start, +// a reveal, Exit, Back and a re-open (P0, JA-01/PC-8). `create` stands in for the server create. +function press(create: (id: string) => void, setupNeeded: boolean): void { + startCloudCreate({ + setupOpen: getCloudSetupPageRequest() !== null, + setupNeeded, + openSetup: (intentId) => + openCloudSetupPage({ + prompt: 'p', + repoId: 'r', + agent: 'claude', + availability: 'needs-sign-in', + cloudOnlyRepo: null, + composer: {}, + intentId + }), + create + }) +} + +describe('one Create, one server create', () => { + beforeEach(() => { + resetCloudCreateIntentsForTest() + closeCloudSetupPage() + }) + + it('a ready account creates once, and a double press makes two separate intents', () => { + const create = vi.fn() + press(create, false) + expect(create).toHaveBeenCalledTimes(1) + const [intent] = create.mock.calls[0] ?? [] + expect(consumeCloudCreateIntent(intent)).toBe(false) + }) + + it('setup: nothing creates while the page is open; the auto-start creates once', () => { + const create = vi.fn() + press(create, true) + const request = getCloudSetupPageRequest() + expect(request).not.toBeNull() + expect(create).not.toHaveBeenCalled() + // Another press while the page is open (a stale composer, a keyboard repeat) does nothing. + press(create, false) + expect(create).not.toHaveBeenCalled() + // Sign-in lands and the page reopens the hidden composer: the auto-start consumes the intent. + closeCloudSetupPage() + if (consumeCloudCreateIntent(request?.intentId)) { + create(request?.intentId) + } + // The 8 s reveal remounts the composer, and its effect runs again: no second create. + if (consumeCloudCreateIntent(request?.intentId)) { + create(request?.intentId) + } + expect(create).toHaveBeenCalledTimes(1) + }) + + it('Exit or Back cancels the start for real, even after a later sign-in', () => { + const create = vi.fn() + press(create, true) + const request = getCloudSetupPageRequest() + cancelCloudCreateIntent(request?.intentId) + closeCloudSetupPage() + // A later sign-in elsewhere, or a stale auto-start, finds nothing to run. + expect(consumeCloudCreateIntent(request?.intentId)).toBe(false) + expect(create).not.toHaveBeenCalled() + }) + + it('a new Start replaces the old setup, and the old press can never create', () => { + const create = vi.fn() + press(create, true) + const first = getCloudSetupPageRequest() + closeCloudSetupPage() + press(create, true) + const second = getCloudSetupPageRequest() + expect(second?.intentId).not.toBe(first?.intentId) + expect(consumeCloudCreateIntent(second?.intentId)).toBe(true) + expect(create).not.toHaveBeenCalled() + }) +}) + +describe('Idempotency-Key per Create press', () => { + beforeEach(() => { + resetCloudCreateIntentsForTest() + closeCloudSetupPage() + }) + + it('every creation fired by one press sends the same key; an explicit retry gets its own', () => { + const create = vi.fn() + press(create, false) + const [intent] = create.mock.calls[0] ?? [] + expect(intent).toMatch(/^create:[A-Za-z0-9_.:-]{8,120}$/) + expect(cloudCreateIdempotencyKey('creation-1')).toBe(intent) + // A re-fired start (another creation for the same press) dedupes on the server. + expect(cloudCreateIdempotencyKey('creation-2')).toBe(intent) + // Retry of creation-1 after a failure is a new attempt. + expect(cloudCreateIdempotencyKey('creation-1')).toBe(`${intent}:retry-1`) + }) + + it('no armed press, no key', () => { + expect(cloudCreateIdempotencyKey('creation-x')).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-create-intent.ts b/src/renderer/src/lib/orca-cloud/cloud-create-intent.ts new file mode 100644 index 00000000000..f7fb4b06be3 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-create-intent.ts @@ -0,0 +1,110 @@ +// One Create press on Orca Cloud is one intent, and one intent makes at most one server create, ever +// (P0, JA-01/PC-8). The intent travels from the composer through the setup page to the hidden +// auto-start; whichever path creates first consumes it, and every later attempt is dropped. Exit, +// Back and Cancel consume it without creating. The id doubles as the create's idempotency key. + +import { createNonSecureContextUuid } from '../../../../shared/non-secure-context-uuid' + +type IntentState = 'pending' | 'consumed' + +const intents = new Map() + +/** `create:`, within the server's Idempotency-Key format (8–128 of [A-Za-z0-9_.:-]). */ +function newIntentId(): string { + return `create:${createNonSecureContextUuid()}` +} + +/** A fresh intent for one Create press. */ +export function beginCloudCreateIntent(): string { + const id = newIntentId() + intents.set(id, 'pending') + return id +} + +/** True exactly once per intent: the caller may create now. Unknown or used intents are refused. */ +export function consumeCloudCreateIntent(id: string | null | undefined): boolean { + if (!id || intents.get(id) !== 'pending') { + return false + } + intents.set(id, 'consumed') + return true +} + +/** Exit, Back or Cancel: the intent can never create. */ +export function cancelCloudCreateIntent(id: string | null | undefined): void { + if (id) { + intents.set(id, 'consumed') + } +} + +export function isCloudCreateIntentPending(id: string | null | undefined): boolean { + return id ? intents.get(id) === 'pending' : false +} + +/** + * The server backstop (create-speed's Idempotency-Key): the intent a create was started with is + * armed for a while, and every creation launched under it sends the same key, so a re-fired start + * gets back the first sandbox instead of a second one. An explicit Retry of the same creation is a + * new attempt and gets its own suffix. + */ +const ARMED_FOR_MS = 10 * 60_000 +let armed: { key: string; at: number } | null = null +const keyByCreation = new Map() + +/** The composer calls this right before it creates with an intent. */ +export function armCloudCreateIdempotency(intentId: string, now: number = Date.now()): void { + armed = { key: intentId, at: now } +} + +/** The key to send for a creation's provision; null with no armed intent. */ +export function cloudCreateIdempotencyKey( + creationId: string, + now: number = Date.now() +): string | null { + const bound = keyByCreation.get(creationId) + if (bound) { + bound.attempts += 1 + return `${bound.key}:retry-${bound.attempts}` + } + if (!armed || now - armed.at > ARMED_FOR_MS) { + return null + } + keyByCreation.set(creationId, { key: armed.key, attempts: 0 }) + return armed.key +} + +/** Tests only. */ +export function resetCloudCreateIntentsForTest(): void { + intents.clear() + armed = null + keyByCreation.clear() +} + +/** + * The composer's primary on Orca Cloud. Never creates while a setup page is open; otherwise starts + * one intent and either hands it to the setup page or creates with it, once. + */ +export function startCloudCreate({ + setupOpen, + setupNeeded, + openSetup, + create +}: { + setupOpen: boolean + setupNeeded: boolean + openSetup: (intentId: string) => void + create: (intentId: string) => void +}): void { + if (setupOpen) { + return + } + const id = beginCloudCreateIntent() + if (setupNeeded) { + openSetup(id) + return + } + if (consumeCloudCreateIntent(id)) { + armCloudCreateIdempotency(id) + create(id) + } +} diff --git a/src/renderer/src/lib/orca-cloud/cloud-setup-page-store.ts b/src/renderer/src/lib/orca-cloud/cloud-setup-page-store.ts index 1a927a81feb..579777be94c 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-setup-page-store.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-setup-page-store.ts @@ -6,6 +6,7 @@ import type { OrcaCloudAvailability } from '../../../../shared/orca-yaml-hook-ty import type { ComposerModalData } from '@/components/new-workspace/composer-modal-data' import type { OrcaCloudRunTargetAccess } from '@/components/new-workspace/orca-cloud-run-target-access' import { e2eConfig } from '@/lib/e2e-config' +import { cancelCloudCreateIntent } from './cloud-create-intent' export type CloudSetupPageRequest = { prompt: string @@ -17,6 +18,8 @@ export type CloudSetupPageRequest = { cloudOnlyRepo: string | null /** The composer as it was: Back and the auto-start reopen it with everything kept. */ composer: ComposerModalData + /** The Create press this setup belongs to: its start uses it once; Exit and Back cancel it. */ + intentId: string /** * Dev and e2e only: pins the account's billing answer so every step can be walked against the * real page (dev sign-in has no billing). Never set by the app. @@ -35,6 +38,10 @@ function emit(): void { } export function openCloudSetupPage(request: Omit): void { + // A newer Start replaces an older setup: the older press can never create. + if (current && current.intentId !== request.intentId) { + cancelCloudCreateIntent(current.intentId) + } current = { ...request, openedAt: Date.now() } emit() } @@ -61,31 +68,6 @@ export function useCloudSetupPageRequest(): CloudSetupPageRequest | null { ) } -const AGENT_SKIP_KEY = 'orca.cloudSetup.agentSkipped.v1' - -/** "Skip for now" on the agent step is remembered, so a ready account isn't asked on every Start. */ -export function isCloudSetupAgentSkipped(agent: string | null): boolean { - if (!agent || typeof localStorage === 'undefined') { - return false - } - try { - return localStorage.getItem(`${AGENT_SKIP_KEY}:${agent}`) === '1' - } catch { - return false - } -} - -export function rememberCloudSetupAgentSkipped(agent: string | null): void { - if (!agent || typeof localStorage === 'undefined') { - return - } - try { - localStorage.setItem(`${AGENT_SKIP_KEY}:${agent}`, '1') - } catch { - // Private mode: the step asks again next time. - } -} - // Dev and e2e: drive the page from the console or CDP (as `__store` is), e.g. to walk the trial step. if ((import.meta.env.DEV || e2eConfig.exposeStore) && typeof window !== 'undefined') { Object.assign(window, { From 7857c54b05c47d29a5e77fd4cce37aad17c0798a Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 20:26:02 -0800 Subject: [PATCH 07/15] test(cloud): toast test without casts --- .../lib/orca-cloud/cloud-agent-not-started-toast.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.test.ts b/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.test.ts index 8352e50e713..d5d634403c9 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.test.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.test.ts @@ -83,7 +83,13 @@ describe('cloud agent not-started toast', () => { 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: { ...(window as { api?: object }).api, orcaCloudAgents: { start } } + api: { + orcaCloudAgents: { + start, + setups: vi.fn(async () => ({ claude: { phase: 'idle' }, codex: { phase: 'idle' } })), + onSetupChanged: vi.fn(() => () => undefined) + } + } }) showCloudAgentNotStartedToast('claude', 'w') const options: { From 48b51db33511c7d7bd27b0a3b7a9c3a217a6a4d4 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 20:27:04 -0800 Subject: [PATCH 08/15] fix(cloud-routines): Run now no longer reports invalid-response for a started run The control plane sends a run's triggerDetail as an object ({requestedBy}, {event, target}, {scheduledFor}); the desktop schema wanted a string, so POST /run and run history failed to parse although the run had started, inviting a duplicate run. Accept the object and show one short label. --- src/main/cloud-api/cloud-api-client.test.ts | 43 +++++++++++++++++++ .../cloud-api/cloud-api-routine-types.ts | 26 ++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/main/cloud-api/cloud-api-client.test.ts b/src/main/cloud-api/cloud-api-client.test.ts index 3097e205518..6cae58ad6fb 100644 --- a/src/main/cloud-api/cloud-api-client.test.ts +++ b/src/main/cloud-api/cloud-api-client.test.ts @@ -195,6 +195,49 @@ describe('response hardening', () => { expect(parsed.result?.prUrl).toBeUndefined() expect(parsed.status).toBe('succeeded') }) + + it('accepts the run the control plane answers Run now with (object trigger detail)', async () => { + // Exactly what POST /v1/routines/:id/run returns (routines/store.ts runView, private view). + const queued = { + id: 'run_9', + routineId: 'rt_1', + trigger: 'manual', + triggerDetail: { requestedBy: 'usr_1' }, + status: 'queued', + workspaceId: null, + result: null, + error: null, + createdAt: 5, + startedAt: null, + finishedAt: null, + durationMs: null + } + const fetchImpl = vi.fn(async () => respond(202, { run: queued })) + const { run } = await client(fetchImpl).runRoutine('rt_1') + expect(run).toMatchObject({ id: 'run_9', status: 'queued', triggerDetail: null }) + + const github = { + ...queued, + trigger: 'github', + triggerDetail: { + event: 'pull_request.labeled', + delivery: 'd1', + target: { repo: 'acme/web', number: 482, kind: 'pull_request' }, + context: { anything: true } + } + } + const page = vi.fn(async () => + respond(200, { + runs: [github, { ...queued, triggerDetail: 'POST from ci' }], + nextBefore: null + }) + ) + const { runs } = await client(page).listRoutineRuns('rt_1') + expect(runs.map((r) => r.triggerDetail)).toEqual([ + 'acme/web#482 pull_request.labeled', + 'POST from ci' + ]) + }) }) describe('classifyCloudApiResponse', () => { diff --git a/src/shared/cloud-api/cloud-api-routine-types.ts b/src/shared/cloud-api/cloud-api-routine-types.ts index 39f8109580b..d77aafc2782 100644 --- a/src/shared/cloud-api/cloud-api-routine-types.ts +++ b/src/shared/cloud-api/cloud-api-routine-types.ts @@ -1,7 +1,7 @@ // Orca Cloud routines: /v1/routines wire shapes and the editor's request bodies. import { z } from 'zod' import { ORCA_CLOUD_IMAGE_AGENTS } from '../hosted-sandbox/orca-cloud-image-agents' -import { HttpsUrl, Id, Millis, PersonName, ShortText } from './cloud-api-primitives' +import { HttpsUrl, Id, Millis, PersonName } from './cloud-api-primitives' // ── Routines ──────────────────────────────────────────────────────────────── @@ -115,11 +115,33 @@ export const RoutineCreatedSchema = z.object({ export type RoutineCreated = z.infer export const RoutineWebhookSchema = z.object({ url: HttpsUrl }) +const TriggerTarget = z.object({ repo: z.string().max(200), number: z.number().int() }) +const TriggerDetailObject = z.object({ + event: z.string().max(100).optional(), + target: TriggerTarget.optional().catch(undefined) +}) + +/** "acme/web#482 pull_request.labeled" for GitHub runs; null when there is nothing worth showing. */ +export function triggerLabel(detail: unknown): string | null { + if (typeof detail === 'string') { + return detail.slice(0, 500) || null + } + const parsed = TriggerDetailObject.safeParse(detail) + if (!parsed.success) { + return null + } + const { event, target } = parsed.data + const where = target ? `${target.repo}#${target.number}` : null + return [where, event].filter(Boolean).join(' ') || null +} + export const RoutineRunSchema = z.object({ id: Id, routineId: Id, trigger: z.enum(ROUTINE_RUN_TRIGGERS), - triggerDetail: ShortText.nullable(), + // The control plane sends what fired the run as an object ({requestedBy} for manual runs, + // {event, target} for GitHub, {scheduledFor} for schedules); the UI shows one short label. + triggerDetail: z.unknown().transform(triggerLabel), status: z.enum(ROUTINE_RUN_STATUSES), workspaceId: Id.nullable(), result: z From 27c2dbcbec6eee3d915f9d1201d892cee8ad68c8 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 20:28:47 -0800 Subject: [PATCH 09/15] feat(cloud): env-check follow-up: no blank before the terminal, no setup nudge in cloud, notices bottom-right, one title on failure cards, Couldn't open row --- .../src/app-shell/AppWorkspaceShell.tsx | 4 + .../orca-cloud/CloudSetupNotices.tsx | 14 ++- .../orca-cloud/OrcaCloudWorkspaceSurface.tsx | 9 +- .../sidebar/SetupScriptPromptCard.tsx | 12 ++ .../worktree-creation/CloudOpenCoverBody.tsx | 44 +++++++ .../worktree-creation/CloudOpenCoverLayer.tsx | 80 +++++++++++++ .../worktree-creation/VmBuildSheet.tsx | 89 +++++++------- .../WorktreeCreationPanel.tsx | 8 ++ src/renderer/src/i18n/locales/en.json | 4 +- .../ephemeral-vm-worktree-creation.test.ts | 113 ++++++++++++++++++ .../orca-cloud/cloud-open-cover-store.test.ts | 36 ++++++ .../lib/orca-cloud/cloud-open-cover-store.ts | 73 +++++++++++ .../creation/pending-worktree-creation.ts | 6 + .../creation/worktree-creation-completion.ts | 11 ++ 14 files changed, 453 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/components/worktree-creation/CloudOpenCoverBody.tsx create mode 100644 src/renderer/src/components/worktree-creation/CloudOpenCoverLayer.tsx create mode 100644 src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts create mode 100644 src/renderer/src/lib/orca-cloud/cloud-open-cover-store.test.ts create mode 100644 src/renderer/src/lib/orca-cloud/cloud-open-cover-store.ts diff --git a/src/renderer/src/app-shell/AppWorkspaceShell.tsx b/src/renderer/src/app-shell/AppWorkspaceShell.tsx index cddedd2a414..3b58c35221f 100644 --- a/src/renderer/src/app-shell/AppWorkspaceShell.tsx +++ b/src/renderer/src/app-shell/AppWorkspaceShell.tsx @@ -11,6 +11,7 @@ import { FloatingTerminalToggleButton } from '../components/floating-terminal/Fl import { TerminalWorkbenchContainer } from '../components/TerminalWorkbenchContainer' import { WorkspaceLowDiskBanner } from '../components/low-disk/WorkspaceLowDiskBanner' import { CloudSetupPageLayer } from '../components/orca-cloud-setup/CloudSetupPageLayer' +import { CloudOpenCoverLayer } from '../components/worktree-creation/CloudOpenCoverLayer' import type { VirtualizedScrollAnchor } from '../hooks/useVirtualizedScrollAnchor' import { TitlebarLeftControls } from './TitlebarLeftControls' import { RightSidebarToggle, TitlebarMainStrip } from './TitlebarMainStrip' @@ -248,6 +249,9 @@ export function AppWorkspaceShell(props: {
    + {floatingWorkspace.showToggleButton && !capturingPluginShot ? ( + +
    + ) +} + export function CloudSetupNoticesView({ record, notices, @@ -207,7 +219,7 @@ export function CloudSetupNoticesView({ role="status" aria-labelledby={labelId} data-cloud-notice="setup" - className="pointer-events-auto w-[26rem] max-w-[calc(100%-1.5rem)] rounded-lg border border-border bg-card text-card-foreground shadow-[0_8px_24px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_24px_rgba(0,0,0,0.4)] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1" + className="pointer-events-auto w-[26rem] max-w-[calc(100%-1.5rem)] rounded-lg border border-border bg-card text-card-foreground shadow-[0_8px_24px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_24px_rgba(0,0,0,0.4)] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1" >

    {t('orcaCloud.setupNotices.label', 'Workspace setup')} diff --git a/src/renderer/src/components/orca-cloud/OrcaCloudWorkspaceSurface.tsx b/src/renderer/src/components/orca-cloud/OrcaCloudWorkspaceSurface.tsx index f2223d64523..6d86e12acc1 100644 --- a/src/renderer/src/components/orca-cloud/OrcaCloudWorkspaceSurface.tsx +++ b/src/renderer/src/components/orca-cloud/OrcaCloudWorkspaceSurface.tsx @@ -22,7 +22,7 @@ import type { CloudWakeProgressEvent } from '../../../../shared/hosted-sandbox/c import { CloudArchivedWorkspaceView, type ArchiveLoad } from './CloudArchivedWorkspaceView' import { CloudWakingOverlay } from './CloudWakingOverlay' import { CloudWorkspaceNotice } from './CloudWorkspaceNotice' -import { CloudSetupNotices } from './CloudSetupNotices' +import { CloudSetupNoticesDock } from './CloudSetupNotices' import { useConfirmedRestart } from './use-cloud-panel-actions' import { useCloudVersionSkewNotice } from './use-cloud-version-skew-notice' import { CloudWorkspaceOfflineView } from './offline/CloudWorkspaceOfflineView' @@ -339,10 +339,8 @@ function CloudSurface({ workspaceId }: { workspaceId: string }): React.JSX.Eleme if (layer === null && versionSkewNotice) { notices.push(versionSkewNotice) } - if (layer === null && view.state !== 'sleeping' && view.state !== 'archived') { - // What the environment check found that didn't need to stop the create (env-check-ux). - notices.push() - } + // What the environment check found that didn't need to stop the create (env-check-ux). + const showSetupNotices = layer === null && view.state !== 'sleeping' && view.state !== 'archived' if (layer === null && view.oom) { notices.push( ) : null} + {showSetupNotices ? : null} {notices.length > 0 ? (
    {} + +/** The creation panel's last frame, laid out exactly like WorktreeCreationPanel. */ +export default function CloudOpenCoverBody({ + cover, + now, + reserveCollapsedSidebarHeaderSpace +}: { + cover: CloudOpenCover + now: number + reserveCollapsedSidebarHeaderSpace: boolean +}): React.JSX.Element { + const { entry } = cover + return ( +
    + + {/* A picture of the last frame: nothing in it is interactive. */} +
    + +
    +
    + ) +} diff --git a/src/renderer/src/components/worktree-creation/CloudOpenCoverLayer.tsx b/src/renderer/src/components/worktree-creation/CloudOpenCoverLayer.tsx new file mode 100644 index 00000000000..5d8808e91d8 --- /dev/null +++ b/src/renderer/src/components/worktree-creation/CloudOpenCoverLayer.tsx @@ -0,0 +1,80 @@ +import React, { useEffect, useSyncExternalStore } from 'react' +import { useAppStore } from '@/store' +import { useNow } from '@/hooks/use-now' +import { + CLOUD_OPEN_COVER_MAX_MS, + releaseCloudOpenCover, + useCloudOpenCover, + workspaceHasAttachedPty +} from '@/lib/orca-cloud/cloud-open-cover-store' +import type CloudOpenCoverBodyComponent from './CloudOpenCoverBody' + +// The body pulls in the build sheet, which the shell must not load at boot. The creation panel +// preloads it, so it is ready (no Suspense frame) by the time a create hands over. +let Body: typeof CloudOpenCoverBodyComponent | null = null +let loading: Promise | null = null +const bodyListeners = new Set<() => void>() + +export function preloadCloudOpenCover(): Promise { + loading ??= import('./CloudOpenCoverBody') + .then((module) => { + Body = module.default + for (const listener of bodyListeners) { + listener() + } + }) + .catch(() => { + loading = null + }) + return loading +} + +function useBody(): typeof CloudOpenCoverBodyComponent | null { + return useSyncExternalStore( + (listener) => { + bodyListeners.add(listener) + return () => bodyListeners.delete(listener) + }, + () => Body + ) +} + +/** + * Keeps the Orca Cloud start screen over a just-created workspace until its terminal's PTY is + * attached (JA-12), so "Open workspace" goes straight to a live prompt with no blank pane between. + */ +export function CloudOpenCoverLayer({ + reserveCollapsedSidebarHeaderSpace +}: { + reserveCollapsedSidebarHeaderSpace: boolean +}): React.JSX.Element | null { + const worktreeId = useAppStore((s) => (s.activeView === 'terminal' ? s.activeWorktreeId : null)) + const cover = useCloudOpenCover(worktreeId) + const attached = useAppStore((s) => + cover ? workspaceHasAttachedPty(s, cover.worktreeId) : false + ) + const BodyComponent = useBody() + const now = useNow(1_000, cover !== null) + useEffect(() => { + if (!cover) { + return + } + if (attached) { + releaseCloudOpenCover(cover.worktreeId) + return + } + void preloadCloudOpenCover() + const left = Math.max(0, cover.heldAt + CLOUD_OPEN_COVER_MAX_MS - Date.now()) + const timer = window.setTimeout(() => releaseCloudOpenCover(cover.worktreeId), left) + return () => window.clearTimeout(timer) + }, [attached, cover]) + if (!cover || attached || !BodyComponent) { + return null + } + // The body is a module-level component loaded once, not one made during render. + return React.createElement(BodyComponent, { + cover, + now: Math.max(now, cover.heldAt), + reserveCollapsedSidebarHeaderSpace + }) +} diff --git a/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx b/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx index e30da2a0085..4efc743720e 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx @@ -187,51 +187,54 @@ export function VmBuildSheet({ return (
    -
    -
    - - {building ? ( - - ) : state === 'ready' ? ( - - ) : gated ? ( - - ) : ( - + {/* JA-24: on Orca Cloud a failure card carries the one title; no page title above it. */} + {cloud && failure ? null : ( +
    +
    + + {building ? ( + + ) : state === 'ready' ? ( + + ) : gated ? ( + + ) : ( + + )} + + {/* Retry lands focus here once the failure card collapses. The card announces failures. */} +

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

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

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

    - {/* Orca Cloud: each step carries its own timer, so the header has none. */} - {gated || cloud ? null : ( - {elapsedLabel} - )} +
    + {building ? ( + + ) : null}
    - {building ? ( - - ) : null} -
    + )}
    {/* An account refusal happens before anything starts, so there are no steps to show. */} diff --git a/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx b/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx index e5770626f92..056300b2d23 100644 --- a/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx +++ b/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx @@ -16,6 +16,7 @@ import { } from './worktree-creation-clock' import { VmBuildSheet } from './VmBuildSheet' import { VmDeleteConfirmDialog } from './VmDeleteConfirmDialog' +import { preloadCloudOpenCover } from './CloudOpenCoverLayer' import { composerDataFromRefusedCreate } from './vm-build-composer-return' /** Debounced in-frame creation placeholder: tab strip names the workspace while body shows status, enabling same-frame handoff without duplicating labels. */ @@ -35,6 +36,13 @@ export default function WorktreeCreationPanel({ const now = useWorktreeCreationClock( entry?.status === 'creating' && entry.waitingSince === undefined ) + const cloudCreate = entry?.request.ephemeralVmRecipe?.provider === 'orca-cloud' + React.useEffect(() => { + // The open cover takes over from this panel when the create finishes (JA-12). + if (cloudCreate) { + void preloadCloudOpenCover() + } + }, [cloudCreate]) if (!entry) { return null } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0d1e058e6ce..c7b3c5dba1a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2290,7 +2290,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." diff --git a/src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts b/src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts new file mode 100644 index 00000000000..f94f1ffff1e --- /dev/null +++ b/src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// JA-01 tail: Delete sandbox on a create stopped by its environment check must delete THAT sandbox +// (its runtime, hence its control-plane record), and the restart path must delete it before retrying. + +const { state, gate, cleanup, retry } = vi.hoisted(() => { + const entries: Record> = {} + return { + state: { + entries, + repos: [ + { + id: 'repo-src', + displayName: 'Hello-World', + gitRemoteIdentity: { remoteUrl: 'https://github.com/octocat/Hello-World.git' } + } + ] + }, + gate: vi.fn(), + cleanup: vi.fn(async () => true), + retry: vi.fn() + } +}) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + pendingWorktreeCreations: state.entries, + repos: state.repos, + projects: [], + activePendingCreationId: 'c1', + updatePendingWorktreeCreation: (id: string, patch: Record) => { + if (state.entries[id]) { + Object.assign(state.entries[id], patch) + } + }, + setRuntimeEnvironments: vi.fn(), + setupProjectExistingFolder: vi.fn(), + deleteProjectHostSetup: vi.fn() + }) + } +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), warning: vi.fn() } })) +vi.mock('@/lib/ephemeral-vm-environment-check', () => ({ gateVmCreateOnEnvironmentCheck: gate })) +vi.mock('@/lib/ephemeral-vm-failed-create-cleanup', () => ({ + cleanupFailedEphemeralVmWorkspace: cleanup +})) +vi.mock('@/lib/vm-create-hidden-checkouts', () => ({ + hideVmCreateCheckout: vi.fn(), + settleVmCreateCheckoutAfterCleanup: vi.fn() +})) +vi.mock('@/lib/workspace/creation/worktree-creation-flow', () => ({ + retryBackgroundWorktreeCreation: retry +})) +vi.mock('@/lib/ephemeral-vm-workspace-target', () => ({ + prepareEphemeralVmWorkspaceTarget: vi.fn(async () => ({ + ok: true, + setup: { + repo: { id: 'repo-vm', path: '/vercel/orca/workspaces/Hello-World/project' }, + setup: { id: 'setup-1', projectId: 'p1', hostId: 'runtime:env-1' } + }, + runtimeId: 'rt-blocked', + checkoutMode: 'provisioned-root', + environmentId: 'env-1', + stderr: '', + warnings: [] + })) +})) + +import { prepareRequestForCreate } from './ephemeral-vm-worktree-creation' +import type { WorktreeCreationRequest } from '@/lib/workspace/creation/pending-worktree-creation' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create path reads only these fields. +const request = { + name: 'elephantfish', + setupDecision: 'inherit', + ephemeralVmRecipe: { + sourceRepoId: 'repo-src', + recipeId: 'cloud', + projectId: 'p1', + checkoutMode: 'provisioned-root', + provider: 'orca-cloud' + } +} as unknown as WorktreeCreationRequest + +describe('prepareRequestForCreate on a create stopped by its environment check', () => { + beforeEach(() => { + state.entries.c1 = { request } + vi.stubGlobal('window', { + api: { ephemeralVm: {}, runtimeEnvironments: { list: async () => [] } } + }) + }) + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('Delete sandbox removes that very sandbox and creates nothing', async () => { + gate.mockResolvedValue('stop') + await expect(prepareRequestForCreate('c1', request)).resolves.toBeNull() + expect(cleanup).toHaveBeenCalledOnce() + expect(cleanup.mock.calls[0][0]).toMatchObject({ ephemeralVmRuntimeId: 'rt-blocked' }) + expect(retry).not.toHaveBeenCalled() + }) + + it('Start again deletes the blocked sandbox before starting a new one', async () => { + gate.mockResolvedValue('restart') + await expect(prepareRequestForCreate('c1', request)).resolves.toBeNull() + expect(cleanup.mock.calls[0][0]).toMatchObject({ ephemeralVmRuntimeId: 'rt-blocked' }) + expect(retry).toHaveBeenCalledWith('c1') + expect(cleanup.mock.invocationCallOrder[0]).toBeLessThan(retry.mock.invocationCallOrder[0]) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-open-cover-store.test.ts b/src/renderer/src/lib/orca-cloud/cloud-open-cover-store.test.ts new file mode 100644 index 00000000000..83b6cac5ee9 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-open-cover-store.test.ts @@ -0,0 +1,36 @@ +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 state = { + tabsByWorktree: { w1: [{ id: 't1' }, { id: 't2' }] }, + ptyIdsByTabId: { t1: [] as string[] } + } + expect(workspaceHasAttachedPty(state, 'w1')).toBe(false) + expect( + workspaceHasAttachedPty({ ...state, ptyIdsByTabId: { t1: [], t2: ['pty-1'] } }, 'w1') + ).toBe(true) + expect(workspaceHasAttachedPty(state, 'missing')).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-open-cover-store.ts b/src/renderer/src/lib/orca-cloud/cloud-open-cover-store.ts new file mode 100644 index 00000000000..d170d38e3e1 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-open-cover-store.ts @@ -0,0 +1,73 @@ +// JA-12: a just-created Orca Cloud workspace showed a blank pane for ~1 s between the start screen +// and its terminal (the create finishes, then the terminal's PTY attaches over the network). The +// start screen's last frame stays up over the new workspace until its PTY is attached, or a cap. +import { useSyncExternalStore } from 'react' +import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation' + +/** Never cover longer than this: a terminal that is slow to attach shows its own state. */ +export const CLOUD_OPEN_COVER_MAX_MS = 6_000 + +export type CloudOpenCover = { + worktreeId: string + /** The start screen as it was when the create finished ("Open workspace" running). */ + entry: PendingWorktreeCreation + heldAt: number +} + +let covers: Readonly> = {} +const listeners = new Set<() => void>() + +function emit(next: Readonly>): void { + covers = next + for (const listener of listeners) { + listener() + } +} + +export function holdCloudOpenCover( + worktreeId: string, + entry: PendingWorktreeCreation, + now = Date.now() +): void { + emit({ ...covers, [worktreeId]: { worktreeId, entry, heldAt: now } }) +} + +export function releaseCloudOpenCover(worktreeId: string): void { + if (!covers[worktreeId]) { + return + } + const { [worktreeId]: _released, ...rest } = covers + emit(rest) +} + +export function getCloudOpenCover(worktreeId: string | null): CloudOpenCover | null { + return worktreeId ? (covers[worktreeId] ?? null) : null +} + +export function useCloudOpenCover(worktreeId: string | null): CloudOpenCover | null { + return useSyncExternalStore( + (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + () => getCloudOpenCover(worktreeId) + ) +} + +/** True once any terminal of the workspace has a PTY attached. */ +export function workspaceHasAttachedPty( + state: { + tabsByWorktree: Readonly> + ptyIdsByTabId: Readonly> + }, + worktreeId: string +): boolean { + return (state.tabsByWorktree[worktreeId] ?? []).some( + (tab) => (state.ptyIdsByTabId[tab.id]?.length ?? 0) > 0 + ) +} + +/** Test seam. */ +export function resetCloudOpenCovers(): void { + emit({}) +} diff --git a/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts b/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts index e0857e3bf00..5bdd5cb0571 100644 --- a/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts +++ b/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts @@ -323,6 +323,12 @@ export function getCreationProgressLabel( } if (entry.phase === 'provisioning-vm') { if (entry.environmentCheck?.status === 'failed') { + // Orca Cloud stops only on an unusable sandbox; the row says what the card says. + if (entry.request.ephemeralVmRecipe?.provider === 'orca-cloud') { + return entry.environmentCheck.sandbox === 'asleep' + ? t('auto.lib.pendingWorktreeCreation.cloudBlockedAsleep', 'Asleep') + : t('auto.lib.pendingWorktreeCreation.cloudCouldNotOpen', 'Couldn’t open') + } return t('auto.lib.pendingWorktreeCreation.vmChecksFailed', 'Environment checks failed') } // Glossary: an Orca Cloud machine is a sandbox; "VM" stays only for bring-your-own recipes. diff --git a/src/renderer/src/lib/workspace/creation/worktree-creation-completion.ts b/src/renderer/src/lib/workspace/creation/worktree-creation-completion.ts index 87579aeeca0..4fb4bf1f71f 100644 --- a/src/renderer/src/lib/workspace/creation/worktree-creation-completion.ts +++ b/src/renderer/src/lib/workspace/creation/worktree-creation-completion.ts @@ -2,6 +2,7 @@ import type { TuiAgent } from '../../../../../shared/agent-launch/catalog/tui-ag import { parseExecutionHostId } from '../../../../../shared/execution-host' import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/workspace/ownership/worktree-runtime-owner' import { useAppStore } from '@/store/index' +import { holdCloudOpenCover } from '@/lib/orca-cloud/cloud-open-cover-store' import { ensureAgentStartupInTerminal } from '@/lib/new-workspace' import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace/activation/workspace-activation-terminal-focus' import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/workspace/creation/worktree-creation-agent-seeds' @@ -23,6 +24,16 @@ export async function completeWorktreeCreation(args: { focusOnCompletion: boolean }): Promise { const { request } = args + // Orca Cloud: the start screen stays over the new workspace until its PTY attaches (JA-12). + const pending = useAppStore.getState().pendingWorktreeCreations[args.creationId] + if ( + pending && + args.focusOnCompletion && + !args.structuredLaunchAccepted && + request.ephemeralVmRecipe?.provider === 'orca-cloud' + ) { + holdCloudOpenCover(args.worktreeId, pending) + } // Why: clearing synchronously after activation lets React commit the panel-to-terminal swap in one frame. useAppStore.getState().removePendingWorktreeCreation(args.creationId, { cleanupVm: false }) // Why: an agent the host planned but did not start has no tab to seed and is not the window's to start. From 6bb8b95dc41c7dcb5f334d9556b9ce916e31dc2a Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 20:36:46 -0800 Subject: [PATCH 10/15] fix(cloud): the sign-in toast drops a stale button when a phase has none --- .../src/lib/orca-cloud/cloud-agent-not-started-toast.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts b/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts index 30024fddd20..1cd41968be2 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts @@ -168,6 +168,10 @@ export function showCloudAgentNotStartedToast( run: () => void globalThis.window?.api?.orcaCloudAgents?.start({ agent }) } } + if (!view.action) { + // Sonner keeps an updated toast's old button: drop it so a stale action can't linger. + toast.dismiss(TOAST_ID) + } toast.message(view.message, { id: TOAST_ID, ...(view.description ? { description: view.description } : {}), From df3d8e92084845c8587fc062c2b9137ccd045c03 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 20:40:04 -0800 Subject: [PATCH 11/15] fix(cloud): the sign-in toast always shows the current phase's button (Cancel / Open again) --- .../cloud-agent-not-started-toast.ts | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts b/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts index 1cd41968be2..cfa1e02bdb4 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-agent-not-started-toast.ts @@ -80,18 +80,24 @@ export function cloudAgentSignInView( 'Finish signing in to {{agent}} in your browser…', { agent: name } ), - ...(setup.canReopen + // Every phase has a button: an updated toast keeps the last one otherwise. + action: setup.canReopen ? { - action: { - label: translate('orcaCloudAccounts.openAgain', 'Open again'), - run: () => void api?.reopen({ agent }) - } + 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') + message: translate('orcaCloudAccounts.preparing', 'Getting ChatGPT sign-in ready'), + action: { + label: translate('orcaCloudAccounts.cancel', 'Cancel'), + run: () => void api?.cancel({ agent }) + } } case 'device': return { @@ -168,10 +174,6 @@ export function showCloudAgentNotStartedToast( run: () => void globalThis.window?.api?.orcaCloudAgents?.start({ agent }) } } - if (!view.action) { - // Sonner keeps an updated toast's old button: drop it so a stale action can't linger. - toast.dismiss(TOAST_ID) - } toast.message(view.message, { id: TOAST_ID, ...(view.description ? { description: view.description } : {}), From af0fab19f84227fb3f0a70f6f4b981f71e4ec332 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 20:47:18 -0800 Subject: [PATCH 12/15] fix(cloud-routines): Run now button matches the run's status; drop redundant queued line; 'just now' --- .../settings/cloud-api/CloudRoutineRow.tsx | 32 +++++++---------- .../cloud-api/CloudRoutinesView.fixture.tsx | 35 +++++++++++++++++++ .../cloud-api/cloud-api-format.test.ts | 10 ++++++ .../settings/cloud-api/cloud-api-format.ts | 4 +++ src/renderer/src/i18n/locales/en.json | 2 -- 5 files changed, 61 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/components/settings/cloud-api/CloudRoutineRow.tsx b/src/renderer/src/components/settings/cloud-api/CloudRoutineRow.tsx index af301a4084e..aad30e0e498 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudRoutineRow.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudRoutineRow.tsx @@ -127,17 +127,14 @@ export function RoutineRow({ setNotice(null) const failure = await onRunNow() setRunning(false) - setNotice( - failure - ? { tone: 'error', text: failure } - : { - tone: 'ok', - text: t( - 'auto.components.settings.cloudRoutines.queued', - 'Run queued. It shows up in the history below.' - ) - } - ) + if (failure) { + setNotice({ tone: 'error', text: failure }) + return + } + // The new run's status lives in the pill and the history; open the history so it is in view. + if (!expanded) { + onToggleHistory() + } } return ( @@ -218,7 +215,7 @@ export function RoutineRow({ variant="ghost" size="xs" aria-disabled={running && !active} - aria-busy={running || active} + aria-busy={running} aria-expanded={active ? expanded : undefined} aria-controls={active ? historyId : undefined} onClick={() => void runNow()} @@ -246,14 +243,9 @@ export function RoutineRow({ : undefined } > - {running || active ? ( - - ) : ( - - )} - {active - ? t('auto.components.settings.cloudRoutines.runningNow', 'Running…') - : t('auto.components.settings.cloudRoutines.runNow', 'Run now')} + {/* Spins only while the request is in flight; the run's own status is the pill's job. */} + {running ? : } + {t('auto.components.settings.cloudRoutines.runNow', 'Run now')} diff --git a/src/renderer/src/components/settings/cloud-api/CloudRoutinesView.fixture.tsx b/src/renderer/src/components/settings/cloud-api/CloudRoutinesView.fixture.tsx index 6bb25768433..44422f66bdc 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudRoutinesView.fixture.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudRoutinesView.fixture.tsx @@ -283,6 +283,41 @@ export const runHistory = defineFixture({ render: () => }) +/** Right after Run now: the pill and history say Queued; the button is back to Run now. */ +export const justQueued = defineFixture({ + width: 820, + render: () => ( + + r.id === 'rt_triage' + ? { ...r, lastRun: { id: 'run_q', status: 'queued', finishedAt: null } } + : r + ) + }} + loadRuns={async () => ({ + ok: true, + data: { + runs: [ + run({ + id: 'run_q', + status: 'queued', + trigger: 'manual', + createdAt: NOW - 3_000, + startedAt: null + }), + ...RUNS.runs.slice(1) + ], + nextBefore: null + } + })} + initial={{ historyFor: 'rt_triage' }} + /> + ) +}) + export const runHistoryEmpty = defineFixture({ width: 820, render: () => ( diff --git a/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts b/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts index 38137b52967..86ef124bba5 100644 --- a/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts +++ b/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts @@ -10,6 +10,7 @@ import { describeRoutineTrigger, formatCountdown, formatKeyPrefix, + formatRelativeFromNow, formatRunDuration, presetForScopes, scopesForPreset, @@ -65,6 +66,15 @@ describe('formatting', () => { ).toMatch(/^Weekdays at 9:00.* E[SD]T$/) }) + it('says "just now" for the last minute, not "this minute"', () => { + const now = 1_000_000_000 + expect(formatRelativeFromNow(now - 3_000, now, 'en')).toBe('just now') + expect(formatRelativeFromNow(now - 59_000, now, 'en')).toBe('just now') + expect(formatRelativeFromNow(now - 5 * 60_000, now, 'en')).toBe('5 minutes ago') + // Future times (next scheduled run) keep Intl wording. + expect(formatRelativeFromNow(now + 10 * 60_000, now, 'en')).toBe('in 10 minutes') + }) + it('offers the local zone first', () => { const zones = timeZoneOptions('Asia/Tokyo') expect(zones[0]).toBe('Asia/Tokyo') diff --git a/src/renderer/src/components/settings/cloud-api/cloud-api-format.ts b/src/renderer/src/components/settings/cloud-api/cloud-api-format.ts index daa7e17d258..15b98d6df4c 100644 --- a/src/renderer/src/components/settings/cloud-api/cloud-api-format.ts +++ b/src/renderer/src/components/settings/cloud-api/cloud-api-format.ts @@ -51,6 +51,10 @@ export function formatKeyPrefix(prefix: string): string { } export function formatRelativeFromNow(timestamp: number, now: number, intlLocale?: string): string { + // Under a minute ago reads "just now", like the other cloud screens (not Intl's "this minute"). + if (timestamp <= now && now - timestamp < 60_000) { + return translate('auto.components.cloudWorkspaces.justNow', 'just now') + } return formatUiRelativeTime(timestamp - now, intlLocale) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3193a98dc26..48c095bcc1b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14599,7 +14599,6 @@ "sectionDescription": "For the Orca Cloud API, the orca CLI and MCP clients. Claude or Codex keys go in Cloud secrets." }, "cloudRoutines": { - "queued": "Run queued. It shows up in the history below.", "pausedSr": "(paused)", "createdByPerson": "by ", "lastRun": "Last run:", @@ -14608,7 +14607,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", From 6d3919f4d25e697525f8889680fb800ca5eba062 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 20:50:52 -0800 Subject: [PATCH 13/15] test(cloud): type the cleanup mock --- src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts b/src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts index f94f1ffff1e..0229515e9b3 100644 --- a/src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts +++ b/src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts @@ -17,7 +17,7 @@ const { state, gate, cleanup, retry } = vi.hoisted(() => { ] }, gate: vi.fn(), - cleanup: vi.fn(async () => true), + cleanup: vi.fn(async (_request: unknown, _actions?: unknown) => true), retry: vi.fn() } }) From 35b198929156283c2038c436f5c58831ab807c3b Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 20:52:53 -0800 Subject: [PATCH 14/15] test(cloud): no cast in the cover store test --- src/renderer/src/lib/orca-cloud/cloud-open-cover-store.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/lib/orca-cloud/cloud-open-cover-store.test.ts b/src/renderer/src/lib/orca-cloud/cloud-open-cover-store.test.ts index 83b6cac5ee9..1cc03247cf9 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-open-cover-store.test.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-open-cover-store.test.ts @@ -23,9 +23,10 @@ describe('cloud open cover', () => { }) 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: [] as string[] } + ptyIdsByTabId: { t1: noPtys } } expect(workspaceHasAttachedPty(state, 'w1')).toBe(false) expect( From 48c0f573fe6943ec9acd1f005119d4e9376da17c Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 20:47:45 -0800 Subject: [PATCH 15/15] =?UTF-8?q?feat(cloud):=20JA-17=20Settings=20?= =?UTF-8?q?=E2=86=92=20Team=20shows=20members=20inline=20(invite=20field,?= =?UTF-8?q?=20then=20the=20table)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Orca Cloud → Team renders the Team page body in place instead of one row that opens a dialog. Inline, the plan block is replaced by a link to Plan & billing (seats and invoices stay there, JA-18). The Team dialog stays for its other callers (checkout landing, plans). --- .../billing/team/TeamDialogHost.tsx | 10 +++- .../components/billing/team/TeamPageView.tsx | 19 +++++++- .../billing/team/TeamSettingsLink.tsx | 46 ------------------- .../orca-cloud/OrcaCloudSettingsPanes.tsx | 16 ++++++- src/renderer/src/i18n/locales/en.json | 4 +- 5 files changed, 42 insertions(+), 53 deletions(-) delete mode 100644 src/renderer/src/components/billing/team/TeamSettingsLink.tsx diff --git a/src/renderer/src/components/billing/team/TeamDialogHost.tsx b/src/renderer/src/components/billing/team/TeamDialogHost.tsx index 8a76f47e261..9ea1576480c 100644 --- a/src/renderer/src/components/billing/team/TeamDialogHost.tsx +++ b/src/renderer/src/components/billing/team/TeamDialogHost.tsx @@ -17,7 +17,14 @@ import { useTeamActions } from './use-team-actions' import { openTeammateProfile } from '../../teammates/teammate-profile-store' import { ORCA_CLOUD_RECIPE_ID } from '../../../../../shared/hosted-sandbox/orca-cloud-recipe-id' -function TeamPageBody({ arrival }: { arrival: ReturnType['arrival'] }) { +export function TeamPageBody({ + arrival, + inline +}: { + arrival: ReturnType['arrival'] + /** Settings → Team: rendered on the page instead of in the dialog (JA-17). */ + inline?: { onOpenPlanBilling: () => void } +}): React.JSX.Element { const t = useTranslate() const confirm = useConfirmationDialog() const authStatus = useAppStore((state) => state.orcaProfileAuthStatus) @@ -217,6 +224,7 @@ function TeamPageBody({ arrival }: { arrival: ReturnType void window.api.shell.openUrl(url)} + inline={inline} /> ) } diff --git a/src/renderer/src/components/billing/team/TeamPageView.tsx b/src/renderer/src/components/billing/team/TeamPageView.tsx index 233f9541c0e..f15032ae4fe 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.tsx @@ -51,6 +51,8 @@ export type TeamPageViewProps = { onDismissArrival: () => void /** Opens the Enterprise contact page (SSO/SCIM are Enterprise; nothing here pretends otherwise). */ onContactSales: (url: string) => void + /** Settings → Team renders the page inline: seats and invoices stay on Plan & billing (linked). */ + inline?: { onOpenPlanBilling: () => void } } function viewerRoleSentence(role: OrcaOrgRole, t: Translate): string { @@ -112,15 +114,17 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { const inviteInput = useRef(null) const ssoTitleId = useId() const hasRoster = props.roster !== null + const inline = props.inline ?? null useEffect(() => { const active = document.activeElement if ( + !inline && hasRoster && (!active || active === document.body || active.getAttribute('role') === 'dialog') ) { document.querySelector('[data-team-page-heading]')?.focus() } - }, [hasRoster]) + }, [hasRoster, inline]) const leaveTitleId = useId() if (!props.org) { @@ -192,7 +196,18 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { /> ) : null} - {entitlement ? ( + {inline ? ( +

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

    + ) : entitlement ? ( - -
    - ) -} diff --git a/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx b/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx index c6192ceeeee..f40cdaa3d23 100644 --- a/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx +++ b/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx @@ -7,7 +7,7 @@ import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-s import { useTranslate } from '@/i18n/use-translate' import { useAppStore } from '@/store' import { PlanBillingSection } from '../../billing/PlanBillingSection' -import { TeamSettingsLink } from '../../billing/team/TeamSettingsLink' +import { TeamPageBody } from '../../billing/team/TeamDialogHost' import { CloudEnvironmentSection, useOrcaCloudEnvironmentsAvailability @@ -113,7 +113,19 @@ export function OrcaCloudTeamPane(): React.JSX.Element { {(account) => account.billing ? ( - + // JA-17: members inline (invite field, then the table); seats and invoices stay on + // Plan & billing, linked. Remount per team so row state never carries over. + { + const store = useAppStore.getState() + store.openSettingsTarget({ pane: 'cloud-billing', repoId: null }) + store.openSettingsPage() + } + }} + /> ) : (