mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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/<org>/W-<n>). - 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.
This commit is contained in:
@@ -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/<org>/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() {
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.bgBase }}>
|
||||
{resolved.kind === 'loading' ? (
|
||||
<MobileStateView loading title={t('cloud.link.opening', 'Opening workspace…')} />
|
||||
) : resolved.kind === 'signin' ? (
|
||||
// Same state as a share link opened before connecting: connect on Home, then come back here.
|
||||
<MobileStateView
|
||||
iconElement={<CloudStateGlyph />}
|
||||
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('/')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MobileStateView
|
||||
icon={Cloud}
|
||||
|
||||
@@ -1,51 +1,15 @@
|
||||
import { Text, View } from 'react-native'
|
||||
import type { Translate } from '../i18n/mobile-i18n'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { CloudPhaseSteps } from './CloudPhaseSteps'
|
||||
import { CloudStatusDot } from './CloudStatusDot'
|
||||
import { CloudWakeSlowNote } from './CloudWakeSlowNote'
|
||||
import type { CloudWorkspace } from './cloud-control-plane-client'
|
||||
import { cloudSandboxHours, cloudStatusLabel } from './cloud-workspace-display'
|
||||
import { cloudSpendLine, cloudStatusLabel } from './cloud-workspace-display'
|
||||
import { statusSentence } from './cloud-workspace-copy'
|
||||
import { cloudWorkspaceScreenStyles as styles } from './cloud-workspace-screen-styles'
|
||||
import type { CloudWorkspaceRun } from './use-cloud-workspace-run'
|
||||
|
||||
/** 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. */
|
||||
function spendLine(
|
||||
workspace: CloudWorkspace,
|
||||
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)
|
||||
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.'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function CloudStatusCard(props: {
|
||||
workspace: CloudWorkspace
|
||||
run: CloudWorkspaceRun
|
||||
@@ -61,7 +25,7 @@ export function CloudStatusCard(props: {
|
||||
// Up but unreachable from this phone: a filled amber dot, as a desktop that can't connect.
|
||||
const notConnected = run.kind === 'error' && workspace.status === 'running'
|
||||
const awake = active || workspace.status === 'running' || workspace.status === 'creating'
|
||||
const spend = spendLine(workspace, props.usage, awake, t)
|
||||
const spend = cloudSpendLine(workspace, props.usage, awake, t)
|
||||
return (
|
||||
<View style={styles.statusCard}>
|
||||
<View style={styles.statusHeadline}>
|
||||
|
||||
@@ -333,17 +333,20 @@ export function CloudWorkspaceScreen({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionHeading} accessibilityRole="header">
|
||||
{t('cloud.prompt.heading', 'Ask an agent')}
|
||||
</Text>
|
||||
<CloudPromptComposer
|
||||
awake={awake}
|
||||
waking={active || workspace.status === 'waking'}
|
||||
disabled={unavailable}
|
||||
onSend={send}
|
||||
/>
|
||||
</View>
|
||||
{/* Archived is read-only here: nothing can wake it from the phone, so no prompt box. */}
|
||||
{workspace.status === 'archived' ? null : (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionHeading} accessibilityRole="header">
|
||||
{t('cloud.prompt.heading', 'Ask an agent')}
|
||||
</Text>
|
||||
<CloudPromptComposer
|
||||
awake={awake}
|
||||
waking={active || workspace.status === 'waking'}
|
||||
disabled={unavailable}
|
||||
onSend={send}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<CloudPromptQueueSection queue={queue} awake={awake} onRetry={() => void start('send')} />
|
||||
|
||||
<View style={styles.section}>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<CloudDeliveryDeps> = {}) {
|
||||
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' })
|
||||
})
|
||||
})
|
||||
@@ -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<QueuedCloudPrompt[]>
|
||||
ensureWorktree: () => Promise<string>
|
||||
launch: (worktreeId: string, prompt: QueuedCloudPrompt) => Promise<CloudLaunchedAgent>
|
||||
deliverQueued: (
|
||||
sandboxId: string,
|
||||
deliver: (prompt: QueuedCloudPrompt) => Promise<void>,
|
||||
errorCodeFor: (error: unknown) => string
|
||||
) => Promise<{ delivered: number; failed: QueuedCloudPrompt[] }>
|
||||
markWaitingFailed: (sandboxId: string, code: string) => Promise<void>
|
||||
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<CloudDeliveryOutcome> {
|
||||
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' }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/<org>/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'
|
||||
}
|
||||
|
||||
@@ -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\. /
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<CloudWorkspace, 'sandboxMs' | 'status'>,
|
||||
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.'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 = <MobileHomeTabHeader title={t('home.tabs.feed', 'Feed')} />
|
||||
// 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 (
|
||||
<MobileHomeEmptyState
|
||||
contentMaxWidth={contentMaxWidth}
|
||||
|
||||
@@ -154,6 +154,8 @@
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"archived_one": "{{hours}} sandbox hour used. Archived workspaces use no sandbox hours.",
|
||||
"archived_other": "{{hours}} sandbox hours used. Archived workspaces use no sandbox hours.",
|
||||
"asleep_one": "{{hours}} sandbox hour so far. Uses no sandbox hours while asleep.",
|
||||
"asleep_other": "{{hours}} sandbox hours so far. Uses no sandbox hours while asleep.",
|
||||
"running_one": "{{hours}} sandbox hour so far. Uses sandbox hours while running.",
|
||||
|
||||
@@ -5103,7 +5103,10 @@
|
||||
"rateLimited": "Too many new workspaces at once. Wait a minute, then try again.",
|
||||
"create": "Couldn’t start your cloud workspace. Nothing was created, and you weren’t charged.",
|
||||
"deliver": "The workspace is running, but a prompt didn’t start. Your files are safe.",
|
||||
"restartOwner": "Only the workspace’s owner can restart it. Your files are safe."
|
||||
"restartOwner": "Only the workspace’s owner can restart it. Your files are safe.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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."
|
||||
},
|
||||
"workspace": {
|
||||
"title": "Cloud workspace",
|
||||
@@ -5283,7 +5286,9 @@
|
||||
"asleep_one": "{{hours}} sandbox hour so far. Uses no sandbox hours while asleep.",
|
||||
"asleep_other": "{{hours}} sandbox hours so far. Uses no sandbox hours while asleep.",
|
||||
"running_one": "{{hours}} sandbox hour so far. Uses sandbox hours while running.",
|
||||
"running_other": "{{hours}} sandbox hours so far. Uses sandbox hours while running."
|
||||
"running_other": "{{hours}} sandbox hours so far. Uses sandbox hours while running.",
|
||||
"archived_one": "{{hours}} sandbox hour used. Archived workspaces use no sandbox hours.",
|
||||
"archived_other": "{{hours}} sandbox hours used. Archived workspaces use no sandbox hours."
|
||||
},
|
||||
"host": {
|
||||
"asleep": "{{name}} is asleep",
|
||||
|
||||
Reference in New Issue
Block a user