From 795078f1aa2a2edf8f293fd7dcea4333076ad0c2 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 13 Nov 2026 02:02:26 -0800 Subject: [PATCH] fix(mobile-cloud): MOB-2 cheap phone fixes: failed prompt never blocks Open, Cloud shows with no desktop, W-number link asks to sign in, repo-access copy, archived is read-only - Open workspace opens even when a queued prompt can't start; the prompt is marked failed (Retry/Remove) instead of staying 'Waiting to connect' and being re-sent on every Open (runCloudDelivery, tested). - An enrolled phone with no paired desktop keeps Home's Cloud section (homeFeedLayout, tested). - A W-number link opened before connecting shows the share-link 'Sign in to Orca Cloud on this phone' state and returns to the link after connecting (cloudNumberLinkState, return-to allows /cloud//W-). - 409 github_not_connected names the repo and the fix; a clone failure names the repo. - Archived detail: no prompt box; hours line says archived, not asleep. --- mobile/app/cloud/[org]/[number].tsx | 37 +++++- mobile/src/cloud/CloudStatusCard.tsx | 40 +----- mobile/src/cloud/CloudWorkspaceScreen.tsx | 25 ++-- mobile/src/cloud/NewCloudWorkspaceScreen.tsx | 2 +- mobile/src/cloud/cloud-delivery-run.test.ts | 105 ++++++++++++++++ mobile/src/cloud/cloud-delivery-run.ts | 70 +++++++++++ mobile/src/cloud/cloud-return-to.ts | 4 +- mobile/src/cloud/cloud-screen-states.test.ts | 32 ++++- mobile/src/cloud/cloud-screen-states.ts | 21 ++++ mobile/src/cloud/cloud-workspace-copy.test.ts | 28 ++++- mobile/src/cloud/cloud-workspace-copy.ts | 35 +++++- .../src/cloud/cloud-workspace-display.test.ts | 21 +++- mobile/src/cloud/cloud-workspace-display.ts | 47 +++++++ mobile/src/cloud/use-cloud-workspace-run.ts | 117 ++++++++---------- mobile/src/home/home-feed-layout.test.ts | 32 +++++ mobile/src/home/home-feed-layout.ts | 27 ++++ mobile/src/home/tabs/MobileHomeFeedTab.tsx | 10 +- mobile/src/i18n/en-runtime-required.json | 2 + mobile/src/i18n/locales/en.json | 9 +- 19 files changed, 528 insertions(+), 136 deletions(-) create mode 100644 mobile/src/cloud/cloud-delivery-run.test.ts create mode 100644 mobile/src/cloud/cloud-delivery-run.ts create mode 100644 mobile/src/home/home-feed-layout.test.ts create mode 100644 mobile/src/home/home-feed-layout.ts diff --git a/mobile/app/cloud/[org]/[number].tsx b/mobile/app/cloud/[org]/[number].tsx index 49412b00873..4bb1296562e 100644 --- a/mobile/app/cloud/[org]/[number].tsx +++ b/mobile/app/cloud/[org]/[number].tsx @@ -6,11 +6,18 @@ import { MobileStateView } from '../../../src/components/MobileStateView' import { CloudWorkspaceScreen } from '../../../src/cloud/CloudWorkspaceScreen' import { resolveCloudWorkspaceLink } from '../../../src/cloud/cloud-workspace-prefs' import { loadCloudControlPlane } from '../../../src/cloud/cloud-session' +import { CloudStateGlyph } from '../../../src/cloud/CloudStateGlyph' +import { setCloudReturnTo } from '../../../src/cloud/cloud-return-to' +import { cloudNumberLinkState } from '../../../src/cloud/cloud-screen-states' import { useMobileTranslation } from '../../../src/i18n/use-mobile-translation' import { colors } from '../../../src/theme/mobile-theme' import { parseCloudWorkspaceLink } from '../../../../src/shared/cloud-workspaces/cloud-workspaces-wire' -type Resolved = { kind: 'loading' } | { kind: 'found'; sandboxId: string } | { kind: 'missing' } +type Resolved = + | { kind: 'loading' } + | { kind: 'found'; sandboxId: string } + | { kind: 'missing' } + | { kind: 'signin' } /** Team links with a number (orca://cloud//W-123, the desktop's "Copy link") open here. */ export default function CloudWorkspaceNumberLinkRoute() { @@ -26,8 +33,17 @@ export default function CloudWorkspaceNumberLinkRoute() { const plane = parsed ? await loadCloudControlPlane() : null const sandboxId = plane && parsed ? await resolveCloudWorkspaceLink(plane, parsed.target) : null + const state = cloudNumberLinkState({ + validLink: parsed !== null, + enrolled: plane !== null, + sandboxId + }) if (live) { - setResolved(sandboxId ? { kind: 'found', sandboxId } : { kind: 'missing' }) + setResolved( + state === 'found' && sandboxId + ? { kind: 'found', sandboxId } + : { kind: state === 'signin' ? 'signin' : 'missing' } + ) } })().catch(() => live && setResolved({ kind: 'missing' })) return () => { @@ -41,6 +57,23 @@ export default function CloudWorkspaceNumberLinkRoute() { {resolved.kind === 'loading' ? ( + ) : resolved.kind === 'signin' ? ( + // Same state as a share link opened before connecting: connect on Home, then come back here. + } + title={t('cloud.workspace.signIn', 'Sign in to Orca Cloud on this phone')} + message={t( + 'cloud.workspace.signInDetail', + 'This link opens a cloud workspace your team shared. Sign in to see it.' + )} + action={{ + label: t('cloud.workspace.signInAction', 'Sign in'), + onPress: () => { + setCloudReturnTo(`/cloud/${params.org ?? ''}/${params.number ?? ''}`) + router.replace('/') + } + }} + /> ) : ( diff --git a/mobile/src/cloud/CloudWorkspaceScreen.tsx b/mobile/src/cloud/CloudWorkspaceScreen.tsx index c5d802b7499..8a83e4018b2 100644 --- a/mobile/src/cloud/CloudWorkspaceScreen.tsx +++ b/mobile/src/cloud/CloudWorkspaceScreen.tsx @@ -333,17 +333,20 @@ export function CloudWorkspaceScreen({ /> ) : null} - - - {t('cloud.prompt.heading', 'Ask an agent')} - - - + {/* Archived is read-only here: nothing can wake it from the phone, so no prompt box. */} + {workspace.status === 'archived' ? null : ( + + + {t('cloud.prompt.heading', 'Ask an agent')} + + + + )} void start('send')} /> diff --git a/mobile/src/cloud/NewCloudWorkspaceScreen.tsx b/mobile/src/cloud/NewCloudWorkspaceScreen.tsx index bace0c8dfb9..d879c7a0655 100644 --- a/mobile/src/cloud/NewCloudWorkspaceScreen.tsx +++ b/mobile/src/cloud/NewCloudWorkspaceScreen.tsx @@ -137,7 +137,7 @@ export function NewCloudWorkspaceScreen() { return } setStartedAt(null) - setError(createErrorMessage(failure, t, role)) + setError(createErrorMessage(failure, t, role, repoUrl)) return } // The sandbox exists from here on: nothing below may report "couldn't create" or create again. diff --git a/mobile/src/cloud/cloud-delivery-run.test.ts b/mobile/src/cloud/cloud-delivery-run.test.ts new file mode 100644 index 00000000000..19e1a178974 --- /dev/null +++ b/mobile/src/cloud/cloud-delivery-run.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { runCloudDelivery, type CloudDeliveryDeps } from './cloud-delivery-run' +import type { QueuedCloudPrompt } from './cloud-prompt-queue' + +function prompt(id: string, status: QueuedCloudPrompt['status'] = 'queued'): QueuedCloudPrompt { + return { + id, + sandboxId: 'sbx_1', + text: `prompt ${id}`, + agent: 'claude', + operationId: `op-${id}`, + status, + createdAt: 1 + } +} + +/** An in-memory queue with the real delivery rules: failed prompts are skipped, not retried. */ +function fakeQueue(initial: QueuedCloudPrompt[], overrides: Partial = {}) { + let queue = [...initial] + const launched: string[] = [] + const deps: CloudDeliveryDeps = { + listQueued: async () => queue, + ensureWorktree: async () => 'wt-1', + launch: async (_worktreeId, entry) => { + launched.push(entry.id) + return { agent: entry.agent } + }, + deliverQueued: async (_sandboxId, deliver, errorCodeFor) => { + let delivered = 0 + const failed: QueuedCloudPrompt[] = [] + for (const entry of queue.filter((item) => item.status !== 'failed')) { + try { + await deliver(entry) + queue = queue.filter((item) => item.id !== entry.id) + delivered += 1 + } catch (error) { + const code = errorCodeFor(error) + queue = queue.map((item) => + item.id === entry.id ? { ...item, status: 'failed' as const, error: code } : item + ) + failed.push({ ...entry, status: 'failed', error: code }) + } + } + return { delivered, failed } + }, + markWaitingFailed: async (_sandboxId, code) => { + queue = queue.map((item) => + item.status === 'queued' ? { ...item, status: 'failed' as const, error: code } : item + ) + }, + errorCodeFor: (error) => (error instanceof Error ? error.message : 'launch_failed'), + ...overrides + } + return { deps, launched, queue: () => queue } +} + +describe('runCloudDelivery', () => { + it('opens when nothing is waiting, including when only failed prompts are left', async () => { + const empty = fakeQueue([]) + expect(await runCloudDelivery('sbx_1', 'open', empty.deps)).toEqual({ kind: 'open' }) + const onlyFailed = fakeQueue([prompt('a', 'failed')]) + expect(await runCloudDelivery('sbx_1', 'open', onlyFailed.deps)).toEqual({ kind: 'open' }) + expect(onlyFailed.launched).toEqual([]) + }) + + it('reports the last launched agent after a delivery', async () => { + const run = fakeQueue([prompt('a'), prompt('b')]) + expect(await runCloudDelivery('sbx_1', 'send', run.deps)).toEqual({ + kind: 'launched', + worktreeId: 'wt-1', + agent: { agent: 'claude' } + }) + expect(run.launched).toEqual(['a', 'b']) + }) + + it('a prompt that does not start still opens the workspace when the user asked to open it', async () => { + const run = fakeQueue([prompt('a')], { + launch: async () => { + throw new Error('launch_failed') + } + }) + expect(await runCloudDelivery('sbx_1', 'open', run.deps)).toEqual({ + kind: 'failed', + code: null, + open: true + }) + expect(run.queue()[0]).toMatchObject({ status: 'failed', error: 'launch_failed' }) + }) + + it('a failure before any launch marks every waiting prompt failed, so the next Open goes straight in', async () => { + const run = fakeQueue([prompt('a'), prompt('b')], { + ensureWorktree: async () => { + throw new Error('cloud_project_unavailable') + } + }) + expect(await runCloudDelivery('sbx_1', 'send', run.deps)).toEqual({ + kind: 'failed', + code: 'cloud_project_unavailable', + open: false + }) + expect(run.queue().map((entry) => entry.status)).toEqual(['failed', 'failed']) + // The stuck-prompt bug: Open used to re-run this delivery and fail again forever. + expect(await runCloudDelivery('sbx_1', 'open', run.deps)).toEqual({ kind: 'open' }) + }) +}) diff --git a/mobile/src/cloud/cloud-delivery-run.ts b/mobile/src/cloud/cloud-delivery-run.ts new file mode 100644 index 00000000000..06d4b36e8b5 --- /dev/null +++ b/mobile/src/cloud/cloud-delivery-run.ts @@ -0,0 +1,70 @@ +import type { QueuedCloudPrompt } from './cloud-prompt-queue' +import type { CloudLaunchedAgent } from './cloud-workspace-host-operations' + +/** What one delivery pass over a workspace's waiting prompts ended in. */ +export type CloudDeliveryOutcome = + | { kind: 'open' } + | { kind: 'launched'; worktreeId: string; agent: CloudLaunchedAgent } + /** A prompt didn't start. `open`: the user asked to open the workspace, so it opens anyway. */ + | { kind: 'failed'; code: string | null; open: boolean } + +export type CloudDeliveryDeps = { + listQueued: (sandboxId: string) => Promise + ensureWorktree: () => Promise + launch: (worktreeId: string, prompt: QueuedCloudPrompt) => Promise + deliverQueued: ( + sandboxId: string, + deliver: (prompt: QueuedCloudPrompt) => Promise, + errorCodeFor: (error: unknown) => string + ) => Promise<{ delivered: number; failed: QueuedCloudPrompt[] }> + markWaitingFailed: (sandboxId: string, code: string) => Promise + errorCodeFor: (error: unknown) => string +} + +const waiting = (queue: QueuedCloudPrompt[]) => queue.some((prompt) => prompt.status !== 'failed') + +/** + * Sends a workspace's waiting prompts once its host is connected. A prompt that can't start is + * marked failed (shown with Retry and Remove) rather than left waiting, so it never stands between + * the user and the workspace: "Open workspace" opens it whatever the prompts did. + */ +export async function runCloudDelivery( + sandboxId: string, + intent: 'open' | 'send', + deps: CloudDeliveryDeps +): Promise { + if (!waiting(await deps.listQueued(sandboxId))) { + return { kind: 'open' } + } + const launches: CloudLaunchedAgent[] = [] + let worktreeId: string + let failed = 0 + try { + worktreeId = await deps.ensureWorktree() + // Prompts typed while this ran are picked up too, so none is left "waiting" behind it. + for (let round = 0; round < 5; round++) { + const next = await deps.deliverQueued( + sandboxId, + async (prompt) => { + launches.push(await deps.launch(worktreeId, prompt)) + }, + deps.errorCodeFor + ) + failed += next.failed.length + if (next.delivered + next.failed.length === 0 || !waiting(await deps.listQueued(sandboxId))) { + break + } + } + } catch (error) { + // Nothing was launched (the project or connection failed first): every waiting prompt says why + // instead of "Waiting to connect", and Open no longer tries to send them again. + const code = deps.errorCodeFor(error) + await deps.markWaitingFailed(sandboxId, code).catch(() => undefined) + return { kind: 'failed', code, open: intent === 'open' } + } + if (failed > 0) { + return { kind: 'failed', code: null, open: intent === 'open' } + } + const last = launches[launches.length - 1] + return last ? { kind: 'launched', worktreeId, agent: last } : { kind: 'open' } +} diff --git a/mobile/src/cloud/cloud-return-to.ts b/mobile/src/cloud/cloud-return-to.ts index 5a61e05c9e7..94952f1ce68 100644 --- a/mobile/src/cloud/cloud-return-to.ts +++ b/mobile/src/cloud/cloud-return-to.ts @@ -6,8 +6,8 @@ const TTL_MS = 10 * 60_000 let returnTo: { path: string; at: number } | null = null export function setCloudReturnTo(path: string, now: number = Date.now()): void { - // Only an in-app cloud route, never an arbitrary destination. - returnTo = /^\/cloud\/[A-Za-z0-9_-]+$/.test(path) ? { path, at: now } : null + // Only an in-app cloud route (a workspace id, or a team slug and W-number), never anywhere else. + returnTo = /^\/cloud\/[A-Za-z0-9_-]+(?:\/W-[0-9]+)?$/.test(path) ? { path, at: now } : null } export function takeCloudReturnTo(now: number = Date.now()): string | null { diff --git a/mobile/src/cloud/cloud-screen-states.test.ts b/mobile/src/cloud/cloud-screen-states.test.ts index 34683b21133..5fe6a139e78 100644 --- a/mobile/src/cloud/cloud-screen-states.test.ts +++ b/mobile/src/cloud/cloud-screen-states.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { setCloudReturnTo, takeCloudReturnTo } from './cloud-return-to' -import { cloudHostGate, cloudMissingState } from './cloud-screen-states' +import { cloudHostGate, cloudMissingState, cloudNumberLinkState } from './cloud-screen-states' const base = { isPending: false, isFetching: false, isError: false } @@ -62,4 +62,34 @@ describe('return after connecting', () => { setCloudReturnTo('/cloud/sbx_abc', 0) expect(takeCloudReturnTo(11 * 60_000)).toBeNull() }) + + it('returns to a W-number link too, but not to other nested paths', () => { + setCloudReturnTo('/cloud/orca-eval-team/W-589') + expect(takeCloudReturnTo()).toBe('/cloud/orca-eval-team/W-589') + setCloudReturnTo('/cloud/orca-eval-team/settings') + expect(takeCloudReturnTo()).toBeNull() + }) +}) + +describe('W-number link', () => { + it('asks a phone not connected to Orca Cloud to sign in instead of calling it private or deleted', () => { + expect(cloudNumberLinkState({ validLink: true, enrolled: false, sandboxId: null })).toBe( + 'signin' + ) + }) + + it('opens the workspace it resolves to, and is missing when it resolves to none', () => { + expect(cloudNumberLinkState({ validLink: true, enrolled: true, sandboxId: 'sbx_1' })).toBe( + 'found' + ) + expect(cloudNumberLinkState({ validLink: true, enrolled: true, sandboxId: null })).toBe( + 'missing' + ) + }) + + it('is missing when the link is malformed, connected or not', () => { + expect(cloudNumberLinkState({ validLink: false, enrolled: false, sandboxId: null })).toBe( + 'missing' + ) + }) }) diff --git a/mobile/src/cloud/cloud-screen-states.ts b/mobile/src/cloud/cloud-screen-states.ts index a52294cbbbb..2b5e6d60b43 100644 --- a/mobile/src/cloud/cloud-screen-states.ts +++ b/mobile/src/cloud/cloud-screen-states.ts @@ -50,3 +50,24 @@ export function cloudHostGate(args: { } return args.status === 'sleeping' ? 'asleep' : 'pass' } + +export type CloudNumberLinkState = 'signin' | 'found' | 'missing' + +/** + * A W-number link (orca://cloud//W-123). Only the control plane can say which workspace it + * names, so a phone not connected to Orca Cloud asks to sign in, like a share link does, rather + * than calling the workspace private or deleted. A malformed link is missing either way. + */ +export function cloudNumberLinkState(args: { + validLink: boolean + enrolled: boolean + sandboxId: string | null +}): CloudNumberLinkState { + if (!args.validLink) { + return 'missing' + } + if (!args.enrolled) { + return 'signin' + } + return args.sandboxId ? 'found' : 'missing' +} diff --git a/mobile/src/cloud/cloud-workspace-copy.test.ts b/mobile/src/cloud/cloud-workspace-copy.test.ts index 382222ce560..e296d6eb49a 100644 --- a/mobile/src/cloud/cloud-workspace-copy.test.ts +++ b/mobile/src/cloud/cloud-workspace-copy.test.ts @@ -13,9 +13,14 @@ vi.mock('./cloud-workspace-attach', () => ({ import type { Translate } from '../i18n/mobile-i18n' import { CloudRequestError } from './cloud-control-plane-request' import { CloudAttachError } from './cloud-workspace-attach' -import { attachErrorMessage, readSnapshotRefusedMessage } from './cloud-workspace-copy' +import { + attachErrorMessage, + createErrorMessage, + readSnapshotRefusedMessage +} from './cloud-workspace-copy' -const t: Translate = (_key, fallback) => fallback +const t: Translate = (_key, fallback, options) => + fallback.replace(/{{(\w+)}}/g, (_match, name: string) => String(options?.[name] ?? '')) describe('cloud workspace copy', () => { it('says a failed wake plainly, and keeps the generic line for anything else', () => { @@ -39,4 +44,23 @@ describe('cloud workspace copy', () => { expect(readSnapshotRefusedMessage('unsigned', t)).toBe(unsigned) expect(readSnapshotRefusedMessage('bad_signature', t)).toBe(unsigned) }) + + it('names the repo and the fix when Orca can’t access it (github_not_connected)', () => { + const denied = new CloudRequestError('conflict', 409, 'github_not_connected') + expect(createErrorMessage(denied, t, null, 'https://github.com/stablyai/orca-cloud')).toBe( + 'Orca can’t access stablyai/orca-cloud. If it’s private, connect GitHub and give Orca access to it in Orca on your computer. Check the name, then try again. Nothing was created, and you weren’t charged.' + ) + expect(createErrorMessage(denied, t)).toContain('Orca can’t access this repository.') + // Never the generic line it used to fall back to. + expect(createErrorMessage(denied, t, null, 'https://github.com/a/b')).not.toContain( + 'Couldn’t start' + ) + }) + + it('names the repo when its clone fails', () => { + const clone = new CloudRequestError('provider_unavailable', 503, 'orcad_start_failed') + expect(createErrorMessage(clone, t, null, 'https://github.com/octocat/Hello-World')).toMatch( + /^Couldn’t clone octocat\/Hello-World\. / + ) + }) }) diff --git a/mobile/src/cloud/cloud-workspace-copy.ts b/mobile/src/cloud/cloud-workspace-copy.ts index a05eabf4e15..3791408c199 100644 --- a/mobile/src/cloud/cloud-workspace-copy.ts +++ b/mobile/src/cloud/cloud-workspace-copy.ts @@ -5,7 +5,7 @@ import { CloudRequestError } from './cloud-control-plane-request' import type { CloudReadSnapshotRefusedError } from './cloud-read-snapshot' import { CloudAttachError, type CloudAttachPhase } from './cloud-workspace-attach' import { entitlementDeniedMessage } from './cloud-entitlement-copy' -import { formatElapsed } from './cloud-workspace-display' +import { cloudRepoLine, formatElapsed } from './cloud-workspace-display' // What the cloud workspace screen says about each state and failure. @@ -173,8 +173,11 @@ export function cloudPromptErrorMessage(code: string | undefined, t: Translate): export function createErrorMessage( error: unknown, t: Translate, - role: CloudRole | null = null + role: CloudRole | null = null, + /** The repository URL asked for, so the message names what Orca couldn't reach. */ + repoUrl: string | null = null ): string { + const repo = repoUrl ? cloudRepoLine({ repo: { url: repoUrl, branch: null } }) : null if (error instanceof CloudRequestError) { if (error.status === 401) { return t( @@ -185,11 +188,31 @@ export function createErrorMessage( if (error.code === 'entitlement_denied') { return entitlementDeniedMessage(error, role, 'create', t) } + // Not public, and no GitHub connection covers it (private, or a mistyped name). A phone can't + // connect GitHub, so the fix points at the computer, like the desktop's "Give Orca access". + if (error.reason === 'github_not_connected') { + return repo + ? t( + 'cloud.error.repoAccess', + 'Orca can’t access {{repo}}. If it’s private, connect GitHub and give Orca access to it in Orca on your computer. Check the name, then try again. Nothing was created, and you weren’t charged.', + { repo } + ) + : t( + 'cloud.error.repoAccessUnnamed', + 'Orca can’t access this repository. If it’s private, connect GitHub and give Orca access to it in Orca on your computer. Check the name, then try again. Nothing was created, and you weren’t charged.' + ) + } if (error.code === 'provider_unavailable' && error.reason === 'orcad_start_failed') { - return t( - 'cloud.error.clone', - 'Couldn’t clone the repository. Check the name and that the Orca Cloud GitHub app can see it. Nothing was created, and you weren’t charged.' - ) + return repo + ? t( + 'cloud.error.cloneNamed', + 'Couldn’t clone {{repo}}. Check the name, and that the Orca GitHub App can see it (Give Orca access, in Orca on your computer). Nothing was created, and you weren’t charged.', + { repo } + ) + : t( + 'cloud.error.clone', + 'Couldn’t clone the repository. Check the name and that the Orca Cloud GitHub app can see it. Nothing was created, and you weren’t charged.' + ) } if (error.code === 'timeout' || error.code === 'network_error' || error.status >= 500) { return t( diff --git a/mobile/src/cloud/cloud-workspace-display.test.ts b/mobile/src/cloud/cloud-workspace-display.test.ts index 86276028d77..565c3c9c8a2 100644 --- a/mobile/src/cloud/cloud-workspace-display.test.ts +++ b/mobile/src/cloud/cloud-workspace-display.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { cloudSandboxHours } from './cloud-workspace-display' +import type { Translate } from '../i18n/mobile-i18n' +import { cloudSandboxHours, cloudSpendLine } from './cloud-workspace-display' const HOUR = 3_600_000 @@ -11,3 +12,21 @@ describe('cloudSandboxHours', () => { expect(cloudSandboxHours(0)).toEqual({ value: 0, label: '0' }) }) }) + +describe('cloudSpendLine', () => { + const t: Translate = (_key, fallback) => fallback + it('says an archived workspace uses no hours, not that it is asleep', () => { + const line = cloudSpendLine({ status: 'archived', sandboxMs: 3_600_000 }, null, false, t) + expect(line).toBe('{{hours}} sandbox hours used. Archived workspaces use no sandbox hours.') + }) + + it('keeps the asleep and running lines', () => { + expect(cloudSpendLine({ status: 'sleeping', sandboxMs: 1 }, null, false, t)).toContain( + 'while asleep' + ) + expect(cloudSpendLine({ status: 'running', sandboxMs: 1 }, null, true, t)).toContain( + 'while running' + ) + expect(cloudSpendLine({ status: 'running', sandboxMs: null }, null, true, t)).toBeNull() + }) +}) diff --git a/mobile/src/cloud/cloud-workspace-display.ts b/mobile/src/cloud/cloud-workspace-display.ts index f1829026a47..e2155338948 100644 --- a/mobile/src/cloud/cloud-workspace-display.ts +++ b/mobile/src/cloud/cloud-workspace-display.ts @@ -108,3 +108,50 @@ export function nextCloudWorkspaceTitle( } return `${base} ${n}` } + +/** Usage once, honestly: sandbox hours so far (what the plan meters), and whether it is using them + * now. No per-workspace dollars: included hours are not charged. */ +export function cloudSpendLine( + workspace: Pick, + usage: { totalMs: number } | null, + awake: boolean, + t: Translate +): string | null { + const ms = usage ? usage.totalMs : workspace.sandboxMs + if (ms === null) { + return null + } + const { value, label } = cloudSandboxHours(ms) + // Archived is not "asleep" to the user: it can't be woken from here at all. + if (workspace.status === 'archived') { + return t( + 'cloud.usage.archived', + '{{hours}} sandbox hours used. Archived workspaces use no sandbox hours.', + { + count: value, + hours: label, + defaultValue_one: '{{hours}} sandbox hour used. Archived workspaces use no sandbox hours.' + } + ) + } + if (!awake) { + return t( + 'cloud.usage.asleep', + '{{hours}} sandbox hours so far. Uses no sandbox hours while asleep.', + { + count: value, + hours: label, + defaultValue_one: '{{hours}} sandbox hour so far. Uses no sandbox hours while asleep.' + } + ) + } + return t( + 'cloud.usage.running', + '{{hours}} sandbox hours so far. Uses sandbox hours while running.', + { + count: value, + hours: label, + defaultValue_one: '{{hours}} sandbox hour so far. Uses sandbox hours while running.' + } + ) +} diff --git a/mobile/src/cloud/use-cloud-workspace-run.ts b/mobile/src/cloud/use-cloud-workspace-run.ts index 1343711a618..7b7ccf343cd 100644 --- a/mobile/src/cloud/use-cloud-workspace-run.ts +++ b/mobile/src/cloud/use-cloud-workspace-run.ts @@ -30,6 +30,7 @@ import { launchCloudPrompt, type CloudLaunchedAgent } from './cloud-workspace-host-operations' +import { runCloudDelivery } from './cloud-delivery-run' import { ownerForCloudError, roleForCloudError } from './cloud-entitlement-copy' import { cloudWorkspaceProjectRoot } from './cloud-workspace-links' import { removeHostAndCloseClient } from '../host-catalog/host-removal-lifecycle' @@ -113,76 +114,56 @@ export function useCloudWorkspaceRun(sandboxId: string, workspace: CloudWorkspac deliveryStarted.current = run const client = host.client const hostId = run.hostId + const intent = run.intent void (async () => { - try { - const waiting = await listQueuedCloudPrompts(sandboxId) - if (!waiting.some((prompt) => prompt.status !== 'failed')) { - // Nothing to send: the connection is proven, so open the sandbox's workspaces. - if (mountedRef.current) { - setRun({ kind: 'idle' }) - router.push(`/h/${hostId}`) - } - return - } - const projectRoot = (await cloudWorkspaceProjectRoot(sandboxId)) ?? undefined - const worktreeId = await ensureCloudProjectWorktree(client, projectRoot) - const launches: CloudLaunchedAgent[] = [] - let failed = 0 - // Prompts typed while this ran are picked up too, so none is left "waiting" behind it. - for (let round = 0; round < 5; round++) { - const next = await deliverQueuedCloudPrompts( - sandboxId, - async (prompt) => { - launches.push(await launchCloudPrompt(client, worktreeId, prompt)) - }, - cloudPromptErrorCode - ) - failed += next.failed.length - const pending = await listQueuedCloudPrompts(sandboxId) - if ( - next.delivered + next.failed.length === 0 || - !pending.some((prompt) => prompt.status !== 'failed') - ) { - break - } - } - invalidate() - if (!mountedRef.current) { - return - } - if (failed > 0) { - const stillQueued = (await listQueuedCloudPrompts(sandboxId)).some( - (prompt) => prompt.status !== 'failed' - ) - setRun({ - kind: 'error', - message: t( - 'cloud.error.deliver', - 'The workspace is running, but a prompt didn’t start. Your files are safe.' - ), - retry: stillQueued ? 'send' : null - }) - return - } - const last = launches[launches.length - 1] - if (last) { - // A cloud agent may still need its sign-in: say what happened before dropping into the - // session, and let the user pick the chat or the terminal. - setRun({ kind: 'launched', hostId, worktreeId, agent: last }) - return - } - setRun({ kind: 'idle' }) - router.push(`/h/${hostId}`) - } catch (error) { - if (mountedRef.current) { - // Delivery failures are about the prompt, not the sandbox: say which, never "something". - setRun({ - kind: 'error', - message: cloudPromptErrorMessage(cloudPromptErrorCode(error), t), - retry: 'send' - }) - } + const outcome = await runCloudDelivery(sandboxId, intent, { + listQueued: listQueuedCloudPrompts, + ensureWorktree: async () => + ensureCloudProjectWorktree( + client, + (await cloudWorkspaceProjectRoot(sandboxId)) ?? undefined + ), + launch: (worktreeId, prompt) => launchCloudPrompt(client, worktreeId, prompt), + deliverQueued: deliverQueuedCloudPrompts, + markWaitingFailed: markWaitingCloudPromptsFailed, + errorCodeFor: cloudPromptErrorCode + }).catch((error: unknown) => ({ + kind: 'failed' as const, + code: cloudPromptErrorCode(error), + open: intent === 'open' + })) + invalidate() + if (!mountedRef.current) { + return } + if (outcome.kind === 'launched') { + // A cloud agent may still need its sign-in: say what happened before dropping into the + // session, and let the user pick the chat or the terminal. + setRun({ kind: 'launched', hostId, worktreeId: outcome.worktreeId, agent: outcome.agent }) + return + } + if (outcome.kind === 'failed') { + // Delivery failures are about the prompt, not the sandbox: say which, never "something". + // The failed prompts wait in the list with Retry and Remove; nothing retries them here. + setRun({ + kind: 'error', + message: + outcome.code === null + ? t( + 'cloud.error.deliver', + 'The workspace is running, but a prompt didn’t start. Your files are safe.' + ) + : cloudPromptErrorMessage(outcome.code, t), + retry: null + }) + if (!outcome.open) { + return + } + } else { + setRun({ kind: 'idle' }) + } + // Nothing (more) to send, or the user asked to open: the connection is proven, so open it. + router.push(`/h/${hostId}`) })() }, [run, host.client, host.state, sandboxId, invalidate, router, t, title]) diff --git a/mobile/src/home/home-feed-layout.test.ts b/mobile/src/home/home-feed-layout.test.ts new file mode 100644 index 00000000000..80f1c0e9856 --- /dev/null +++ b/mobile/src/home/home-feed-layout.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { homeFeedLayout } from './home-feed-layout' + +const enrollment = { + controlPlaneUrl: 'https://cp.example', + deviceSessionId: 'ds_1', + label: 'iPhone', + enrolledAt: 1 +} + +describe('homeFeedLayout', () => { + it('waits for the host catalog', () => { + expect(homeFeedLayout({ hostCatalogLoaded: false, hostCount: 0, enrollment })).toBe('loading') + }) + + it('shows the feed whenever a host is paired', () => { + expect(homeFeedLayout({ hostCatalogLoaded: true, hostCount: 1, enrollment: null })).toBe('feed') + }) + + it('keeps the Cloud section for an enrolled phone with no desktop paired', () => { + expect(homeFeedLayout({ hostCatalogLoaded: true, hostCount: 0, enrollment })).toBe('feed') + }) + + it('shows the pairing empty state only to a phone with neither', () => { + expect(homeFeedLayout({ hostCatalogLoaded: true, hostCount: 0, enrollment: null })).toBe( + 'pair-desktop' + ) + expect(homeFeedLayout({ hostCatalogLoaded: true, hostCount: 0, enrollment: undefined })).toBe( + 'loading' + ) + }) +}) diff --git a/mobile/src/home/home-feed-layout.ts b/mobile/src/home/home-feed-layout.ts new file mode 100644 index 00000000000..7015bb3d447 --- /dev/null +++ b/mobile/src/home/home-feed-layout.ts @@ -0,0 +1,27 @@ +import type { CloudEnrollment } from '../cloud/cloud-device-identity' + +export type HomeFeedLayout = 'loading' | 'pair-desktop' | 'feed' + +/** + * The pairing empty state is only for a phone with nothing to show. A phone enrolled in Orca Cloud + * keeps its Cloud section with no desktop paired (unpaired later, or lost): the workspaces are + * reachable from the phone alone. + */ +export function homeFeedLayout(args: { + hostCatalogLoaded: boolean + hostCount: number + /** `undefined` while the keychain read is in flight. */ + enrollment: CloudEnrollment | null | undefined +}): HomeFeedLayout { + if (!args.hostCatalogLoaded) { + return 'loading' + } + if (args.hostCount > 0) { + return 'feed' + } + // Wait for the enrollment read rather than flash the pairing screen at an enrolled phone. + if (args.enrollment === undefined) { + return 'loading' + } + return args.enrollment ? 'feed' : 'pair-desktop' +} diff --git a/mobile/src/home/tabs/MobileHomeFeedTab.tsx b/mobile/src/home/tabs/MobileHomeFeedTab.tsx index 35bef14f2ac..2b24f959b23 100644 --- a/mobile/src/home/tabs/MobileHomeFeedTab.tsx +++ b/mobile/src/home/tabs/MobileHomeFeedTab.tsx @@ -12,6 +12,7 @@ import type { AgentListRowHandlers } from '../../agents/MobileAgentListRows' import type { HostCatalogEntry } from '../../host-catalog/host-profile' import { MobileHomeAppUpdateCard } from '../MobileHomeAppUpdateCard' import { MobileHomeCloudSection } from '../MobileHomeCloudSection' +import { homeFeedLayout } from '../home-feed-layout' import { MobileHomeEmptyState } from '../MobileHomeEmptyState' import { MobileHomeHostList } from '../MobileHomeHostList' import { MobileHomeActiveAgents } from '../MobileHomeActiveAgents' @@ -52,10 +53,15 @@ export function MobileHomeFeedTab(props: { }, [data.connectedHosts, data.allClients, cloudHostIds]) const title = // Keychain reads can outlast the splash; show the title instead of a blank frame. - if (!data.hostCatalogLoaded) { + const layout = homeFeedLayout({ + hostCatalogLoaded: data.hostCatalogLoaded, + hostCount: data.hostCatalog.length, + enrollment + }) + if (layout === 'loading') { return title } - if (data.hostCatalog.length === 0) { + if (layout === 'pair-desktop') { return (