mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
fix(cloud): desktop P2 review — unarchiving state, dismissible progress, late/thrown wake failures, one failure message
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -38,14 +38,14 @@ const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
#noVNC_connect_dlg, #noVNC_control_bar_anchor { display: none !important; }
|
||||
#orca_pause { --orca-scrim: rgb(10 10 10 / 0.88); --orca-card: #171717; --orca-text: #f0f0f0;
|
||||
--orca-border: rgb(255 255 255 / 0.1); --orca-control: rgb(255 255 255 / 0.15);
|
||||
--orca-border: rgb(255 255 255 / 0.1); --orca-control: rgb(255 255 255 / 0.25);
|
||||
--orca-control-hover: rgb(255 255 255 / 0.08); --orca-focus: color-mix(in oklab, #f0f0f0 65%, #0a0a0a);
|
||||
color-scheme: dark; position: fixed; inset: 0; display: none; align-items: center;
|
||||
justify-content: center; padding: 16px; background: var(--orca-scrim); z-index: 1000;
|
||||
font: 13px/1.5 system-ui, sans-serif; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
#orca_pause { --orca-scrim: rgb(255 255 255 / 0.88); --orca-card: #fff; --orca-text: #0a0a0a;
|
||||
--orca-border: #e5e5e5; --orca-control: #d4d4d4; --orca-control-hover: #f5f5f5;
|
||||
--orca-border: #e5e5e5; --orca-control: #a3a3a3; --orca-control-hover: #f5f5f5;
|
||||
--orca-focus: color-mix(in oklab, #0a0a0a 55%, #fff); color-scheme: light; }
|
||||
}
|
||||
#orca_pause.open { display: flex; }
|
||||
|
||||
@@ -57,7 +57,8 @@ export const notCloud = toastFixture({ kind: 'failed', code: 'not_cloud' })
|
||||
export const sandboxStarting = toastFixture({ kind: 'failed', code: 'starting' })
|
||||
export const archived = toastFixture({ kind: 'failed', code: 'archived' })
|
||||
/** After Wake sandbox or Unarchive: the desktop opens by itself once the sandbox is awake. */
|
||||
export const waking = toastFixture({ kind: 'waking' })
|
||||
export const waking = toastFixture({ kind: 'waking', via: 'wake' })
|
||||
export const unarchiving = toastFixture({ kind: 'waking', via: 'unarchive' })
|
||||
export const wakeFailed = toastFixture({ kind: 'failed', code: 'wake_failed' })
|
||||
|
||||
function keyboardHintFixture(kind: 'captured' | 'released', chord: string, width = 520) {
|
||||
|
||||
@@ -18,7 +18,7 @@ export type CloudRemoteDesktopToastFailure = CloudRemoteDesktopFailureCode | 'wa
|
||||
export type CloudRemoteDesktopToastState =
|
||||
| { kind: 'starting'; slow: boolean }
|
||||
/** Wake (or Unarchive) from this toast: the desktop opens by itself once the sandbox is awake. */
|
||||
| { kind: 'waking' }
|
||||
| { kind: 'waking'; via: 'wake' | 'unarchive' }
|
||||
| { kind: 'failed'; code: CloudRemoteDesktopToastFailure }
|
||||
|
||||
type FailureCopy = {
|
||||
@@ -50,7 +50,7 @@ function failureCopy(t: Translate, code: CloudRemoteDesktopToastFailure): Failur
|
||||
title: t('components.cloudRemoteDesktop.asleepTitle', 'This sandbox is asleep.'),
|
||||
detail: t(
|
||||
'components.cloudRemoteDesktop.asleepDetail',
|
||||
'Wake it and the remote desktop opens. Your files are safe.'
|
||||
'Wake it to open the remote desktop. Your files are safe.'
|
||||
),
|
||||
action: 'wake',
|
||||
tone: 'neutral'
|
||||
@@ -67,7 +67,7 @@ function failureCopy(t: Translate, code: CloudRemoteDesktopToastFailure): Failur
|
||||
title: t('components.cloudRemoteDesktop.archivedTitle', 'This workspace is archived.'),
|
||||
detail: t(
|
||||
'components.cloudRemoteDesktop.archivedDetail',
|
||||
'Unarchive it and the remote desktop opens. Your files are safe.'
|
||||
'Unarchive it to open the remote desktop. Your files are safe.'
|
||||
),
|
||||
action: 'unarchive',
|
||||
tone: 'neutral'
|
||||
@@ -162,21 +162,32 @@ export function CloudRemoteDesktopToast({
|
||||
onDismiss: () => void
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const dismissButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={t('components.cloudRemoteDesktop.dismiss', 'Dismiss')}
|
||||
className="absolute top-1.5 right-1.5"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
<X aria-hidden />
|
||||
</Button>
|
||||
)
|
||||
if (state.kind === 'starting' || state.kind === 'waking') {
|
||||
const slow = state.kind === 'starting' && state.slow
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex w-[356px] items-center gap-3 rounded-lg border border-border bg-popover px-3 py-2.5 text-sm text-popover-foreground shadow-lg"
|
||||
>
|
||||
<div className="relative flex w-[356px] items-center gap-3 rounded-lg border border-border bg-popover py-2.5 pr-9 pl-3 text-sm text-popover-foreground shadow-lg">
|
||||
<Loader2
|
||||
className="size-4 shrink-0 text-muted-foreground motion-safe:animate-spin"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div role="status" className="min-w-0 space-y-0.5">
|
||||
<p className="font-medium">
|
||||
{state.kind === 'waking'
|
||||
? t('components.cloudRemoteDesktop.waking', 'Waking sandbox…')
|
||||
? state.via === 'unarchive'
|
||||
? t('components.cloudRemoteDesktop.unarchiving', 'Unarchiving workspace…')
|
||||
: t('components.cloudRemoteDesktop.waking', 'Waking sandbox…')
|
||||
: slow
|
||||
? t('components.cloudRemoteDesktop.stillStarting', 'Still starting…')
|
||||
: t('components.cloudRemoteDesktop.starting', 'Starting remote desktop…')}
|
||||
@@ -197,6 +208,8 @@ export function CloudRemoteDesktopToast({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Hides the notice only: the desktop still opens when it's ready. */}
|
||||
{dismissButton}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -222,16 +235,7 @@ export function CloudRemoteDesktopToast({
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={t('components.cloudRemoteDesktop.dismiss', 'Dismiss')}
|
||||
className="absolute top-1.5 right-1.5"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
<X aria-hidden />
|
||||
</Button>
|
||||
{dismissButton}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -145,6 +145,6 @@ export async function runUnarchiveCloudWorkspace(workspaceId: string): Promise<b
|
||||
return result.ok
|
||||
}
|
||||
|
||||
export function wakeCloudWorkspace(workspaceId: string): Promise<void> {
|
||||
return activateWorktreeFromSidebar(workspaceId)
|
||||
export async function wakeCloudWorkspace(workspaceId: string): Promise<void> {
|
||||
await activateWorktreeFromSidebar(workspaceId)
|
||||
}
|
||||
|
||||
+5
-2
@@ -62,6 +62,7 @@ type WorktreeJumpPaletteQuickActionsInput = WorktreeJumpPaletteStoreState &
|
||||
Pick<WorktreeJumpPaletteOpenTabs, never>
|
||||
|
||||
export function useWorktreeJumpPaletteQuickActions({
|
||||
visible,
|
||||
openModal,
|
||||
openSettingsPage,
|
||||
openSettingsTarget,
|
||||
@@ -87,8 +88,10 @@ export function useWorktreeJumpPaletteQuickActions({
|
||||
// "Open remote desktop" shows only for Orca Cloud workspaces: read the list fresh on open.
|
||||
const cloudEnvironmentIds = useCloudEnvironmentIds()
|
||||
useEffect(() => {
|
||||
void refreshCloudEnvironmentIds()
|
||||
}, [])
|
||||
if (visible) {
|
||||
void refreshCloudEnvironmentIds()
|
||||
}
|
||||
}, [visible])
|
||||
const graphScope = useGitGraphScope(activeWorktreeId ?? '')
|
||||
const graphOwner = JSON.stringify([activeWorktreeId, graphScope?.route])
|
||||
const [graphProbe, setGraphProbe] = useState<{ owner: string; support: boolean | null }>()
|
||||
|
||||
@@ -22884,7 +22884,7 @@
|
||||
"notCloudTitle": "Remote desktop is only available in cloud workspaces.",
|
||||
"notCloudDetail": "Create a cloud workspace to run apps with a screen.",
|
||||
"asleepTitle": "This sandbox is asleep.",
|
||||
"asleepDetail": "Wake it and the remote desktop opens. Your files are safe.",
|
||||
"asleepDetail": "Wake it to open the remote desktop. Your files are safe.",
|
||||
"notInstalledTitle": "This sandbox doesn’t have a remote desktop yet.",
|
||||
"notInstalledDetail": "It was created before remote desktop was available. New cloud workspaces include it. Your files are safe.",
|
||||
"signedOutTitle": "You’re signed out of Orca Cloud.",
|
||||
@@ -22901,7 +22901,7 @@
|
||||
"openMenuItem": "Open remote desktop",
|
||||
"startingTitle": "The sandbox is still starting or waking.",
|
||||
"archivedTitle": "This workspace is archived.",
|
||||
"archivedDetail": "Unarchive it and the remote desktop opens. Your files are safe.",
|
||||
"archivedDetail": "Unarchive it to open the remote desktop. Your files are safe.",
|
||||
"startingDetail": "Try again in a moment. Your files are safe.",
|
||||
"keyboardCaptured": "Keys go to the remote desktop. Press <kbd>{{chord}}</kbd> to use Orca shortcuts.",
|
||||
"keyboardReleased": "Orca shortcuts are back. Click the desktop to type in it.",
|
||||
@@ -22916,6 +22916,7 @@
|
||||
"wakeFailedDetail": "Your files are safe.",
|
||||
"unarchive": "Unarchive",
|
||||
"waking": "Waking sandbox…",
|
||||
"unarchiving": "Unarchiving workspace…",
|
||||
"wakingDetail": "The remote desktop opens when it’s awake.",
|
||||
"paletteDescription": "Open the sandbox’s desktop in a browser tab.",
|
||||
"verbs": {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createDesktopToastGate } from './desktop-toast-gate'
|
||||
|
||||
type State = { kind: 'starting' } | { kind: 'waking' } | { kind: 'failed'; code: string }
|
||||
|
||||
describe('createDesktopToastGate', () => {
|
||||
it('keeps progress hidden after the user closes it, but still shows a failure', () => {
|
||||
const render = vi.fn()
|
||||
const hide = vi.fn()
|
||||
const gate = createDesktopToastGate<State>({ render, hide })
|
||||
gate.show({ kind: 'waking' })
|
||||
expect(render).toHaveBeenCalledTimes(1)
|
||||
|
||||
gate.closeByUser()
|
||||
expect(hide).toHaveBeenCalledOnce()
|
||||
gate.show({ kind: 'starting' })
|
||||
expect(render).toHaveBeenCalledTimes(1)
|
||||
|
||||
gate.show({ kind: 'failed', code: 'wake_failed' })
|
||||
expect(render).toHaveBeenLastCalledWith({ kind: 'failed', code: 'wake_failed' })
|
||||
})
|
||||
|
||||
it('shows every state while the user has not closed it', () => {
|
||||
const render = vi.fn()
|
||||
const gate = createDesktopToastGate<State>({ render, hide: () => {} })
|
||||
gate.show({ kind: 'waking' })
|
||||
gate.dismiss()
|
||||
gate.show({ kind: 'starting' })
|
||||
expect(render).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* The desktop toast's visibility. Closing a progress notice only hides it: the flow goes on and
|
||||
* the desktop still opens when ready, so later progress stays hidden. A failure still shows,
|
||||
* since it carries the one next step.
|
||||
*/
|
||||
export function createDesktopToastGate<State extends { kind: string }>(io: {
|
||||
render: (state: State) => void
|
||||
hide: () => void
|
||||
}): { show: (state: State) => void; closeByUser: () => void; dismiss: () => void } {
|
||||
let closedByUser = false
|
||||
return {
|
||||
show: (state) => {
|
||||
if (closedByUser && state.kind !== 'failed') {
|
||||
return
|
||||
}
|
||||
io.render(state)
|
||||
},
|
||||
closeByUser: () => {
|
||||
closedByUser = true
|
||||
io.hide()
|
||||
},
|
||||
dismiss: io.hide
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ function harness(answers: CloudRemoteDesktopResult[], overrides: Partial<OpenAft
|
||||
const open = vi.fn(async () => answers.shift() ?? opened)
|
||||
const onAwake = vi.fn()
|
||||
const deps: OpenAfterWakeDeps = {
|
||||
wake: vi.fn(async () => true),
|
||||
wake: vi.fn(async () => 'awake' as const),
|
||||
wakeFailedSince: () => false,
|
||||
environmentId: () => 'env-1',
|
||||
open,
|
||||
@@ -63,7 +63,7 @@ describe('openDesktopAfterWake', () => {
|
||||
})
|
||||
|
||||
it('stays quiet when Unarchive already showed why it failed', async () => {
|
||||
const { deps, open } = harness([], { wake: async () => false })
|
||||
const { deps, open } = harness([], { wake: async () => 'reported' as const })
|
||||
await expect(openDesktopAfterWake(deps)).resolves.toEqual({ kind: 'cancelled' })
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -109,6 +109,39 @@ describe('openDesktopAfterWake', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('notices a wake that fails after its call returned', async () => {
|
||||
let failed = false
|
||||
const { deps, open } = harness([{ ok: false, code: 'starting' }], {
|
||||
wakeFailedSince: () => failed,
|
||||
sleep: async () => {
|
||||
failed = true
|
||||
}
|
||||
})
|
||||
await expect(openDesktopAfterWake(deps)).resolves.toEqual({
|
||||
kind: 'failed',
|
||||
code: 'wake_failed'
|
||||
})
|
||||
expect(open).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports a wake the wake call said failed', async () => {
|
||||
const { deps, open } = harness([], { wake: async () => 'failed' as const })
|
||||
await expect(openDesktopAfterWake(deps)).resolves.toEqual({
|
||||
kind: 'failed',
|
||||
code: 'wake_failed'
|
||||
})
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a wake that throws as a failed wake', async () => {
|
||||
const { deps, open } = harness([], { wake: () => Promise.reject(new Error('ipc')) })
|
||||
await expect(openDesktopAfterWake(deps)).resolves.toEqual({
|
||||
kind: 'failed',
|
||||
code: 'wake_failed'
|
||||
})
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops when the workspace went away during the wake', async () => {
|
||||
const { deps, open } = harness([], { environmentId: () => null })
|
||||
await expect(openDesktopAfterWake(deps)).resolves.toEqual({ kind: 'cancelled' })
|
||||
|
||||
@@ -15,8 +15,11 @@ export type OpenAfterWakeOutcome =
|
||||
| { kind: 'cancelled' }
|
||||
|
||||
export type OpenAfterWakeDeps = {
|
||||
/** Wakes the sandbox (or unarchives, then wakes); false when that flow already said why not. */
|
||||
wake: () => Promise<boolean>
|
||||
/**
|
||||
* Wakes the sandbox (or unarchives, then wakes): 'reported' when that flow already showed why
|
||||
* it could not (Unarchive's own toast), 'failed' when the wake failed and this flow says so.
|
||||
*/
|
||||
wake: () => Promise<'awake' | 'failed' | 'reported'>
|
||||
/** Whether a wake that began at or after `startedAt` reported failure. */
|
||||
wakeFailedSince: (startedAt: number) => boolean
|
||||
/** Re-read after the wake: the workspace may have been deleted meanwhile. */
|
||||
@@ -37,16 +40,27 @@ export async function openDesktopAfterWake(deps: OpenAfterWakeDeps): Promise<Ope
|
||||
const now = deps.now ?? Date.now
|
||||
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
|
||||
const startedAt = now()
|
||||
if (!(await deps.wake())) {
|
||||
let woke: 'awake' | 'failed' | 'reported'
|
||||
try {
|
||||
woke = await deps.wake()
|
||||
} catch {
|
||||
// A wake that throws is still a failed wake: never leave "Waking…" up.
|
||||
woke = 'failed'
|
||||
}
|
||||
if (woke === 'reported') {
|
||||
return { kind: 'cancelled' }
|
||||
}
|
||||
if (deps.wakeFailedSince(startedAt)) {
|
||||
if (woke === 'failed' || deps.wakeFailedSince(startedAt)) {
|
||||
return { kind: 'failed', code: 'wake_failed' }
|
||||
}
|
||||
deps.onAwake()
|
||||
const deadline = now() + AFTER_WAKE_DEADLINE_MS
|
||||
let asleepAnswers = 0
|
||||
for (;;) {
|
||||
// The wake can also fail after its call returned (main reports it late): stop asking then.
|
||||
if (deps.wakeFailedSince(startedAt)) {
|
||||
return { kind: 'failed', code: 'wake_failed' }
|
||||
}
|
||||
const environmentId = deps.environmentId()
|
||||
if (!environmentId) {
|
||||
return { kind: 'cancelled' }
|
||||
|
||||
@@ -3,11 +3,12 @@ import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store/index'
|
||||
import { activateAndRevealWorkspace } from '@/lib/workspace/activation/worktree-activation'
|
||||
import { selectWorkspaceRuntimeEnvironmentId } from '@/lib/workspace/ownership/workspace-runtime-environment'
|
||||
import { getCloudWorkspaceSnapshot } from '@/lib/orca-cloud/orca-cloud-workspace-store'
|
||||
import {
|
||||
runUnarchiveCloudWorkspace,
|
||||
wakeCloudWorkspace
|
||||
} from '@/components/orca-cloud/cloud-workspace-actions'
|
||||
getCloudWorkspaceSnapshot,
|
||||
isCloudWorkspaceArchived
|
||||
} from '@/lib/orca-cloud/orca-cloud-workspace-store'
|
||||
import { runUnarchiveCloudWorkspace } from '@/components/orca-cloud/cloud-workspace-actions'
|
||||
import { activateWorktreeFromSidebar } from '@/lib/workspace/activation/sidebar-worktree-activation'
|
||||
import {
|
||||
CloudRemoteDesktopToast,
|
||||
type CloudRemoteDesktopNextAction,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from '@/components/cloud-remote-desktop/CloudRemoteDesktopToast'
|
||||
import type { CloudRemoteDesktopResult } from '../../../../shared/hosted-sandbox/cloud-remote-desktop'
|
||||
import { openDesktopAfterWake } from './open-after-wake'
|
||||
import { createDesktopToastGate } from './desktop-toast-gate'
|
||||
|
||||
const SLOW_START_MS = 5_000
|
||||
/** Worktree ids with an open (or a wake, then open) in flight. */
|
||||
@@ -61,25 +63,28 @@ type DesktopToast = {
|
||||
|
||||
function desktopToast(worktreeId: string): DesktopToast {
|
||||
const id = `cloud-remote-desktop:${worktreeId}`
|
||||
const dismiss = (): void => {
|
||||
toast.dismiss(id)
|
||||
}
|
||||
const show = (state: CloudRemoteDesktopToastState): void => {
|
||||
toast.custom(
|
||||
() => (
|
||||
<CloudRemoteDesktopToast
|
||||
state={state}
|
||||
onDismiss={dismiss}
|
||||
onAction={(action) => {
|
||||
dismiss()
|
||||
runNextAction(action, worktreeId)
|
||||
}}
|
||||
/>
|
||||
),
|
||||
// Errors stay until dismissed or acted on: each names the one next step.
|
||||
{ id, duration: Number.POSITIVE_INFINITY }
|
||||
)
|
||||
}
|
||||
const gate = createDesktopToastGate<CloudRemoteDesktopToastState>({
|
||||
render: (state) => {
|
||||
toast.custom(
|
||||
() => (
|
||||
<CloudRemoteDesktopToast
|
||||
state={state}
|
||||
onDismiss={gate.closeByUser}
|
||||
onAction={(action) => {
|
||||
gate.dismiss()
|
||||
runNextAction(action, worktreeId)
|
||||
}}
|
||||
/>
|
||||
),
|
||||
// Errors stay until dismissed or acted on: each names the one next step.
|
||||
{ id, duration: Number.POSITIVE_INFINITY }
|
||||
)
|
||||
},
|
||||
hide: () => {
|
||||
toast.dismiss(id)
|
||||
}
|
||||
})
|
||||
const { show, dismiss } = gate
|
||||
return {
|
||||
show,
|
||||
dismiss,
|
||||
@@ -157,14 +162,22 @@ export function openCloudRemoteDesktop(worktreeId: string, environmentId: string
|
||||
function wakeThenOpen(worktreeId: string, via: 'wake' | 'unarchive'): Promise<void> {
|
||||
return exclusive(worktreeId, async () => {
|
||||
const view = desktopToast(worktreeId)
|
||||
view.show({ kind: 'waking' })
|
||||
view.show({ kind: 'waking', via })
|
||||
let stopSlow = (): void => {}
|
||||
const outcome = await openDesktopAfterWake({
|
||||
wake:
|
||||
via === 'unarchive'
|
||||
? () => runUnarchiveCloudWorkspace(worktreeId)
|
||||
: () => wakeCloudWorkspace(worktreeId).then(() => true),
|
||||
? () => runUnarchiveCloudWorkspace(worktreeId).then((ok) => (ok ? 'awake' : 'reported'))
|
||||
: () =>
|
||||
// This toast reports a failed wake: the generic wake-failed toast stays quiet.
|
||||
activateWorktreeFromSidebar(worktreeId, undefined, {
|
||||
reportWakeFailure: false
|
||||
}).then((result) => (result.ok ? 'awake' : 'failed')),
|
||||
wakeFailedSince: (startedAt) => {
|
||||
// Archived meanwhile: the desktop answer says "archived" (with Unarchive), not a failed wake.
|
||||
if (isCloudWorkspaceArchived(worktreeId)) {
|
||||
return false
|
||||
}
|
||||
const wake = getCloudWorkspaceSnapshot().wake[worktreeId]
|
||||
return wake?.phase === 'failed' && wake.at >= startedAt
|
||||
},
|
||||
|
||||
@@ -37,16 +37,22 @@ async function settleLifecycleCall(
|
||||
export async function activateWorktreeFromSidebar(
|
||||
worktreeId: string,
|
||||
executionHostId?: ExecutionHostId,
|
||||
options?: { revealInSidebar?: boolean }
|
||||
): Promise<void> {
|
||||
options?: {
|
||||
revealInSidebar?: boolean
|
||||
/** False when the caller reports a failed wake itself (one failure message, not two). */
|
||||
reportWakeFailure?: boolean
|
||||
}
|
||||
): Promise<EphemeralVmWakeOutcome> {
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
activateAndRevealFolderWorkspace(workspaceScope.folderWorkspaceId, {
|
||||
navigationIntent: 'user-open',
|
||||
...options,
|
||||
...(options?.revealInSidebar !== undefined
|
||||
? { revealInSidebar: options.revealInSidebar }
|
||||
: {}),
|
||||
...(executionHostId ? { executionHostId } : {})
|
||||
})
|
||||
return
|
||||
return { ok: true }
|
||||
}
|
||||
// Keep navigation independent from an optional runtime wake IPC.
|
||||
activateAndRevealWorktree(worktreeId, {
|
||||
@@ -55,13 +61,21 @@ export async function activateWorktreeFromSidebar(
|
||||
...(executionHostId ? { executionHostId } : {})
|
||||
})
|
||||
|
||||
await resumeEphemeralVmWorkspace(worktreeId)
|
||||
return resumeEphemeralVmWorkspace(worktreeId, {
|
||||
reportFailure: options?.reportWakeFailure ?? true
|
||||
})
|
||||
}
|
||||
|
||||
/** How a wake went: ok also when there was nothing to wake (or it is archived: never woken). */
|
||||
export type EphemeralVmWakeOutcome = { ok: true } | { ok: false; reason: string }
|
||||
|
||||
/** Wakes the per-workspace VM behind a worktree, if it has one; host health reads Waking meanwhile. */
|
||||
export async function resumeEphemeralVmWorkspace(worktreeId: string): Promise<void> {
|
||||
export async function resumeEphemeralVmWorkspace(
|
||||
worktreeId: string,
|
||||
options: { reportFailure?: boolean } = {}
|
||||
): Promise<EphemeralVmWakeOutcome> {
|
||||
if (typeof window === 'undefined' || !window.api?.ephemeralVm?.resumeWorkspace) {
|
||||
return
|
||||
return { ok: true }
|
||||
}
|
||||
// An archived Orca Cloud workspace opens its read-only archive view; only Unarchive wakes it.
|
||||
// A cold start or a stale read must not wake it, so a cloud workspace re-reads its status first.
|
||||
@@ -70,7 +84,7 @@ export async function resumeEphemeralVmWorkspace(worktreeId: string): Promise<vo
|
||||
// Known archived: never wake. Otherwise don't wait on a status round trip before waking: main
|
||||
// and the control plane both refuse to resume an archive, and that refusal shows the archive.
|
||||
if (maybeCloud && isCloudWorkspaceArchived(worktreeId)) {
|
||||
return
|
||||
return { ok: true }
|
||||
}
|
||||
if (maybeCloud) {
|
||||
void refreshCloudWorkspaceStatusIfStale(worktreeId, 30_000)
|
||||
@@ -119,12 +133,15 @@ export async function resumeEphemeralVmWorkspace(worktreeId: string): Promise<vo
|
||||
if (JSON.stringify(result).includes(CLOUD_WORKSPACE_ARCHIVED_ERROR)) {
|
||||
// Archived elsewhere since this window last looked: show the archive instead of failing.
|
||||
void refreshCloudWorkspaceStatus(worktreeId)
|
||||
return
|
||||
return { ok: true }
|
||||
}
|
||||
if (getCloudWorkspaceSnapshot().ids.has(worktreeId)) {
|
||||
// The cloud waking overlay turns into its own failure card; no second toast.
|
||||
failCloudWake(worktreeId, reason)
|
||||
return
|
||||
return { ok: false, reason }
|
||||
}
|
||||
if (options.reportFailure === false) {
|
||||
return { ok: false, reason }
|
||||
}
|
||||
toast.error(
|
||||
translate(
|
||||
@@ -134,7 +151,7 @@ export async function resumeEphemeralVmWorkspace(worktreeId: string): Promise<vo
|
||||
// The row's Resume failed tooltip says how to retry; not every failed wake leaves one.
|
||||
{ description: reason }
|
||||
)
|
||||
return
|
||||
return { ok: false, reason }
|
||||
}
|
||||
const runtime = result.runtime
|
||||
if (runtime?.runtimeEnvironmentId) {
|
||||
@@ -153,6 +170,7 @@ export async function resumeEphemeralVmWorkspace(worktreeId: string): Promise<vo
|
||||
store.getState().setEphemeralVmWorkspaceWaking(worktreeId, false)
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/** Suspends a workspace's VM without closing panels (callers sleep panels first when open). */
|
||||
|
||||
Reference in New Issue
Block a user