fix(cloud): opening a cloud workspace says so while it waits, and a timeout or a missing workspace says why (EP-6-FLAKE) (#710)

An open could wait up to 120 s (a grant or wake timing out, or a workspace
created, slept or deleted on another device mid-open) with nothing on
screen, then drop the person on Home. While an open runs, a loading toast
appears after 3 s ('Opening {{name}}...'); a timeout is a warning with Try
again; a workspace the server no longer has (not_found) fails at once with
'Couldn't find {{name}}' and Show cloud workspaces. The picker's own row
spinner holds the toast back until the picker closes; a GitHub-only
create's pending row keeps its own progress. design-squad copy.
This commit is contained in:
Neil
2026-11-13 07:20:16 -08:00
committed by GitHub
parent 881b82e401
commit e429e917cf
10 changed files with 417 additions and 10 deletions
@@ -0,0 +1,70 @@
// EP-6-FLAKE: the three states of a cloud workspace open that does not finish quickly: still opening
// (loading), taking longer than usual (warning, Try again), and gone (error, Show cloud workspaces).
import { useEffect } from 'react'
import { toast } from 'sonner'
import { defineFixture } from '../../component-fixtures/component-fixture'
import { Toaster } from '@/components/ui/sonner'
import {
beginCloudWorkspaceOpenFeedback,
endCloudWorkspaceOpenFeedback,
resetCloudWorkspaceOpenFeedbackForTests,
type OpenFeedbackFailure
} from './cloud-workspace-open-feedback'
const noActions = { retry: () => undefined, showWorkspaces: () => undefined }
function OpenToast({ failure }: { failure: OpenFeedbackFailure | null }): React.JSX.Element {
useEffect(() => {
// After the Toaster has mounted: a toast raised before it subscribes is not shown.
const timer = window.setTimeout(() => {
beginCloudWorkspaceOpenFeedback('fixture', 'web')
if (failure) {
endCloudWorkspaceOpenFeedback('fixture', { ok: false, failure }, noActions)
}
}, 100)
return () => {
window.clearTimeout(timer)
resetCloudWorkspaceOpenFeedbackForTests()
toast.dismiss()
}
}, [failure])
return (
<>
<div className="h-[900px]" />
<Toaster toastOptions={{ className: 'font-sans text-sm' }} />
</>
)
}
async function toastShown(): Promise<void> {
const deadline = performance.now() + 6000
while (document.querySelector('[data-sonner-toast][data-mounted="true"]') === null) {
if (performance.now() > deadline) {
throw new Error('Timed out waiting for the open toast')
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
// Let the toast finish its entrance before the shot.
await new Promise((resolve) => setTimeout(resolve, 500))
}
/** Still opening after the delay (loading). */
export const opening = defineFixture({
width: 1280,
render: () => <OpenToast failure={null} />,
interact: toastShown
})
/** The open timed out while the workspace is still starting (warning, Try again). */
export const takingLonger = defineFixture({
width: 1280,
render: () => <OpenToast failure="timeout" />,
interact: toastShown
})
/** The workspace is gone (error, Show cloud workspaces). */
export const gone = defineFixture({
width: 1280,
render: () => <OpenToast failure="gone" />,
interact: toastShown
})
@@ -36,7 +36,9 @@ export async function handleCloudWorkspaceLink(link: string): Promise<void> {
rememberCloudWorkspaces([workspace])
const outcome = await openCloudWorkspace(workspace)
if (!outcome.ok) {
toast.error(outcome.message)
if (!outcome.notified) {
toast.error(outcome.message)
}
} else if (resolved.value.chatId) {
await focusCloudChat(outcome.worktreeId, resolved.value.chatId)
}
@@ -0,0 +1,105 @@
// EP-6-FLAKE: a slow open says so after a few seconds; a timeout says it is still starting (Try
// again); a workspace the server no longer has fails at once (Couldn't find it).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const toast = vi.hoisted(() => ({
loading: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
dismiss: vi.fn()
}))
vi.mock('sonner', () => ({ toast }))
import {
OPEN_FEEDBACK_DELAY_MS,
beginCloudWorkspaceOpenFeedback,
endCloudWorkspaceOpenFeedback,
resetCloudWorkspaceOpenFeedbackForTests,
showCloudWorkspaceOpenInPicker
} from './cloud-workspace-open-feedback'
import { openFailureKind } from './open-cloud-workspace'
const actions = { retry: vi.fn(), showWorkspaces: vi.fn() }
beforeEach(() => {
vi.useFakeTimers()
for (const fn of Object.values(toast)) {
fn.mockReset()
}
})
afterEach(() => {
resetCloudWorkspaceOpenFeedbackForTests()
vi.useRealTimers()
})
describe('cloud workspace open feedback', () => {
it('a quick open shows nothing', () => {
beginCloudWorkspaceOpenFeedback('ws', 'web')
vi.advanceTimersByTime(OPEN_FEEDBACK_DELAY_MS - 1)
expect(endCloudWorkspaceOpenFeedback('ws', { ok: true }, actions)).toBe(false)
vi.advanceTimersByTime(OPEN_FEEDBACK_DELAY_MS)
expect(toast.loading).not.toHaveBeenCalled()
expect(toast.dismiss).not.toHaveBeenCalled()
})
it('a slow open says so after the delay, and the toast closes when it opens', () => {
beginCloudWorkspaceOpenFeedback('ws', 'web')
vi.advanceTimersByTime(OPEN_FEEDBACK_DELAY_MS)
expect(toast.loading).toHaveBeenCalledWith('Opening web…', expect.anything())
endCloudWorkspaceOpenFeedback('ws', { ok: true }, actions)
expect(toast.dismiss).toHaveBeenCalledWith('cloud-workspace-open:ws')
})
it('a timeout is a warning with Try again, and the caller shows nothing more', () => {
beginCloudWorkspaceOpenFeedback('ws', 'web')
vi.advanceTimersByTime(OPEN_FEEDBACK_DELAY_MS)
expect(endCloudWorkspaceOpenFeedback('ws', { ok: false, failure: 'timeout' }, actions)).toBe(
true
)
const [title, options] = toast.warning.mock.calls[0]!
expect(title).toBe('web is taking longer than usual')
expect(options.action.label).toBe('Try again')
options.action.onClick()
expect(actions.retry).toHaveBeenCalled()
})
it('a workspace that is gone fails at once with Show cloud workspaces', () => {
beginCloudWorkspaceOpenFeedback('ws', 'web')
expect(endCloudWorkspaceOpenFeedback('ws', { ok: false, failure: 'gone' }, actions)).toBe(true)
const [title, options] = toast.error.mock.calls[0]!
expect(title).toBe('Couldn’t find web')
expect(options.action.label).toBe('Show cloud workspaces')
})
it('any other failure leaves the message to the caller', () => {
beginCloudWorkspaceOpenFeedback('ws', 'web')
expect(endCloudWorkspaceOpenFeedback('ws', { ok: false }, actions)).toBe(false)
expect(toast.error).not.toHaveBeenCalled()
})
it('waits while the picker shows the open, and shows at once when the picker closes', () => {
beginCloudWorkspaceOpenFeedback('ws', 'web')
const release = showCloudWorkspaceOpenInPicker('ws')
vi.advanceTimersByTime(OPEN_FEEDBACK_DELAY_MS * 2)
expect(toast.loading).not.toHaveBeenCalled()
release()
expect(toast.loading).toHaveBeenCalledTimes(1)
})
it('the picker closing before the delay shows it at once too, without restarting the wait', () => {
beginCloudWorkspaceOpenFeedback('ws', 'web')
const release = showCloudWorkspaceOpenInPicker('ws')
vi.advanceTimersByTime(1_000)
release()
expect(toast.loading).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(OPEN_FEEDBACK_DELAY_MS)
expect(toast.loading).toHaveBeenCalledTimes(1)
})
it('classifies a grant or wake refusal: timeout is still starting, not_found is gone', () => {
expect(openFailureKind('timeout')).toBe('timeout')
expect(openFailureKind('not_found')).toBe('gone')
expect(openFailureKind('entitlement_denied')).toBeUndefined()
expect(openFailureKind(undefined)).toBeUndefined()
})
})
@@ -0,0 +1,179 @@
// EP-6-FLAKE: opening a cloud workspace can wait a long while (a wake, a just-created VM, a grant
// that times out) with nothing on screen, then drop the person on Home. While an open runs, a
// loading toast says so after a few seconds; a timeout says it is still starting (Try again); a
// workspace the server no longer has fails at once (Couldn't find it). The picker shows its own
// spinner on the row, so the toast waits while that row is on screen and shows at once if the
// picker closes mid-open.
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
export const OPEN_FEEDBACK_DELAY_MS = 3_000
export type OpenFeedbackFailure = 'timeout' | 'gone'
type Flight = {
name: string
timer: ReturnType<typeof setTimeout> | null
shown: boolean
}
const flights = new Map<string, Flight>()
// Workspaces whose open the picker is showing (its row spinner), counted per mount.
const pickerShown = new Map<string, number>()
function toastId(workspaceId: string): string {
return `cloud-workspace-open:${workspaceId}`
}
function showLoading(workspaceId: string, flight: Flight): void {
if (flight.shown) {
return
}
flight.shown = true
toast.loading(
translate('cloudWorkspaceOpen.opening', 'Opening {{name}}…', { name: flight.name }),
{
id: toastId(workspaceId),
duration: Infinity,
description: translate(
'cloudWorkspaceOpen.openingDescription',
'Workspaces that were asleep or just created can take a minute or two.'
)
}
)
}
/** An open began: after the delay, a loading toast unless the picker row shows the open. */
export function beginCloudWorkspaceOpenFeedback(workspaceId: string, name: string): void {
endFlight(workspaceId)
const flight: Flight = { name, timer: null, shown: false }
flight.timer = setTimeout(() => {
flight.timer = null
if (!pickerShown.get(workspaceId)) {
showLoading(workspaceId, flight)
}
}, OPEN_FEEDBACK_DELAY_MS)
flights.set(workspaceId, flight)
}
function endFlight(workspaceId: string): Flight | undefined {
const flight = flights.get(workspaceId)
if (flight?.timer) {
clearTimeout(flight.timer)
}
flights.delete(workspaceId)
return flight
}
/**
* The open settled. A timeout or a missing workspace gets its own toast (true: the caller shows
* nothing more); anything else only clears the loading toast and leaves the message to the caller.
*/
export function endCloudWorkspaceOpenFeedback(
workspaceId: string,
outcome: { ok: true } | { ok: false; failure?: OpenFeedbackFailure },
actions: { retry: () => void; showWorkspaces: () => void }
): boolean {
const flight = endFlight(workspaceId)
const name = flight?.name ?? ''
const id = toastId(workspaceId)
if (outcome.ok || !outcome.failure) {
if (flight?.shown) {
toast.dismiss(id)
}
return false
}
if (outcome.failure === 'timeout') {
// Still starting, not failed: the warning tone.
toast.warning(
translate('cloudWorkspaceOpen.slowTitle', '{{name}} is taking longer than usual', { name }),
{
id,
duration: Infinity,
description: translate(
'cloudWorkspaceOpen.slowDescription',
'It’s still starting. Try again in a moment.'
),
action: {
label: translate('cloudWorkspaceOpen.tryAgain', 'Try again'),
onClick: actions.retry
}
}
)
return true
}
toast.error(translate('cloudWorkspaceOpen.goneTitle', 'Couldn’t find {{name}}', { name }), {
id,
description: translate('cloudWorkspaceOpen.goneDescription', 'It may have been deleted.'),
action: {
label: translate(
'auto.components.cloudWorkspaces.showCloudWorkspaces',
'Show cloud workspaces'
),
onClick: actions.showWorkspaces
}
})
return true
}
/** Runs an open with its feedback; a failure a toast already explained comes back `notified`. */
export async function withCloudWorkspaceOpenFeedback<
T extends { ok: true } | { ok: false; failure?: OpenFeedbackFailure; notified?: true }
>(
workspaceId: string,
name: string,
run: () => Promise<T>,
actions: { retry: () => void; showWorkspaces: () => void }
): Promise<T> {
beginCloudWorkspaceOpenFeedback(workspaceId, name)
let outcome: T
try {
outcome = await run()
} catch (error) {
endCloudWorkspaceOpenFeedback(workspaceId, { ok: false }, actions)
throw error
}
const notified = endCloudWorkspaceOpenFeedback(workspaceId, outcome, actions)
return notified && !outcome.ok ? { ...outcome, notified: true } : outcome
}
/**
* The picker shows this workspace's open on its row while mounted. Returns the release: when the
* picker closes mid-open past the delay, the loading toast shows at once.
*/
export function showCloudWorkspaceOpenInPicker(workspaceId: string): () => void {
pickerShown.set(workspaceId, (pickerShown.get(workspaceId) ?? 0) + 1)
let released = false
return () => {
if (released) {
return
}
released = true
const left = (pickerShown.get(workspaceId) ?? 1) - 1
if (left > 0) {
pickerShown.set(workspaceId, left)
return
}
pickerShown.delete(workspaceId)
const flight = flights.get(workspaceId)
if (flight) {
// Closed mid-open: say so now, without restarting the delay.
if (flight.timer) {
clearTimeout(flight.timer)
flight.timer = null
}
showLoading(workspaceId, flight)
}
}
}
/** Tests only. */
export function resetCloudWorkspaceOpenFeedbackForTests(): void {
for (const flight of flights.values()) {
if (flight.timer) {
clearTimeout(flight.timer)
}
}
flights.clear()
pickerShown.clear()
}
@@ -18,12 +18,34 @@ import {
} from '../../../../shared/execution-host'
import { refreshCloudMessageSenders } from '@/lib/cloud-message-sender'
import { describeError, isUnknownServerFailure } from './cloud-workspace-errors'
import {
type OpenFeedbackFailure,
withCloudWorkspaceOpenFeedback
} from './cloud-workspace-open-feedback'
import { openCloudWorkspacePicker } from './cloud-workspace-picker-store'
/** On success, the worktree that was revealed (null when its projects are still syncing). */
export type OpenOutcome =
| { ok: true; worktreeId: string | null; hostId?: string }
/** `unknownServerError`: the wake failed on the server for no known reason (shared copy). */
| { ok: false; message: string; unknownServerError?: boolean }
/**
* `unknownServerError`: the wake failed on the server for no known reason (shared copy).
* `notified`: a toast already said what happened (EP-6-FLAKE); the caller shows nothing more.
*/
| {
ok: false
message: string
unknownServerError?: boolean
failure?: OpenFeedbackFailure
notified?: true
}
/** EP-6-FLAKE: a refusal that has its own toast: still starting (timeout) or gone (not found). */
export function openFailureKind(error: string | undefined): OpenFeedbackFailure | undefined {
if (error === 'timeout') {
return 'timeout'
}
return error === 'not_found' ? 'gone' : undefined
}
// Rejections that mean this device's pairing is no longer accepted (revoked grant, rebuilt VM).
const PAIRING_REJECTED_CODES = new Set(['unauthorized', 'device_revoked', 'forbidden'])
@@ -76,6 +98,8 @@ export type OpenCloudWorkspaceOptions = {
* hides that server's rows until it hands off to them, so its pending row never doubles up.
*/
onHost?: (hostId: string) => void
/** False: the caller shows the open's progress itself (a GitHub-only create's pending row). */
feedback?: false
}
async function connectAndReveal(
@@ -159,10 +183,12 @@ async function wakeIfAsleep(workspace: CloudWorkspace): Promise<OpenOutcome | nu
self: workspace.access === 'owner',
name: workspace.memberNames?.[workspace.ownerUserId] ?? null
}
const failure = openFailureKind(woke.error)
return {
ok: false,
message: describeError(woke.error, woke.reason, translate, owner),
unknownServerError: isUnknownServerFailure(woke.error, woke.status)
unknownServerError: isUnknownServerFailure(woke.error, woke.status),
...(failure ? { failure } : {})
}
}
@@ -206,10 +232,12 @@ async function pairAndReveal(
self: workspace.access === 'owner',
name: workspace.memberNames?.[workspace.ownerUserId] ?? null
}
const failure = openFailureKind(grant.error)
return {
ok: false,
message: describeError(grant.error, grant.reason, translate, owner),
unknownServerError: isUnknownServerFailure(grant.error, grant.status)
unknownServerError: isUnknownServerFailure(grant.error, grant.status),
...(failure ? { failure } : {})
}
}
const added = await window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
@@ -301,7 +329,15 @@ export function openCloudWorkspace(
if (existing) {
return existing
}
const attempt = openOnce(workspace, options).finally(() => opening.delete(workspace.id))
const run = (): Promise<OpenOutcome> => openOnce(workspace, options)
const attempt = (
options.feedback === false
? run()
: withCloudWorkspaceOpenFeedback(workspace.id, cloudWorkspaceTitle(workspace), run, {
retry: () => void openCloudWorkspace(workspace, options),
showWorkspaces: () => openCloudWorkspacePicker()
})
).finally(() => opening.delete(workspace.id))
opening.set(workspace.id, attempt)
return attempt
}
@@ -1,7 +1,7 @@
// What a cloud workspace row can do from "Open cloud workspace…": open (or read an Asleep one's
// saved copy), copy its link, share or hand it over, sleep, archive and delete. Opening goes through
// openCloudWorkspace, the same path as a workspace link and a teammate's profile.
import React, { useCallback, useMemo, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useTranslate } from '@/i18n/use-translate'
import { refreshCloudMessageSenders } from '@/lib/cloud-message-sender'
@@ -17,6 +17,7 @@ import { focusCloudChat } from './cloud-chat-focus'
import * as archive from './cloud-workspace-home-archive'
import { focusedKind, frontCloudChat } from './cloud-workspace-links'
import { copyCloudWorkspaceLink, openCloudWorkspace } from './open-cloud-workspace'
import { showCloudWorkspaceOpenInPicker } from './cloud-workspace-open-feedback'
import type { RowActions } from './CloudWorkspaceRowMenu'
import { CloudWorkspaceConfirmDialog, type PendingConfirm } from './CloudWorkspaceConfirmDialog'
import { CloudReadSnapshotSheet } from './CloudReadSnapshotSheet'
@@ -77,6 +78,9 @@ export function useCloudWorkspaceRowActions({
const [openingId, setOpeningId] = useState<string | null>(null)
const [confirm, setConfirm] = useState<PendingConfirm | null>(null)
const { readingId, readingRef, setReadingId, readWake, setReadWake } = useCloudReadPanel()
// EP-6-FLAKE: the row's spinner shows this open; the open's toast waits while the picker is up and
// shows at once if it closes mid-open.
useEffect(() => (openingId ? showCloudWorkspaceOpenInPicker(openingId) : undefined), [openingId])
/** Opens the workspace, then (going to a teammate's chat) the chat they have in front. */
const open = useCallback(
@@ -92,7 +96,7 @@ export function useCloudWorkspaceRowActions({
if (fromPanel && readingRef.current === w.id) {
// The panel shows the failure in place (with the reason) instead of a toast.
setReadWake({ ...outcome, phase: 'failed' })
} else {
} else if (!outcome.notified) {
toast.error(outcome.message)
}
return
@@ -84,7 +84,7 @@ export function TeamWorkspacesSettings({ orgId }: { orgId: string | null }): Rea
)
const open = (w: CloudWorkspace): void => {
void openCloudWorkspace(w).then((outcome) => {
if (!outcome.ok) {
if (!outcome.ok && !outcome.notified) {
toast.error(outcome.message)
}
})
@@ -100,7 +100,7 @@ function ProfileBody({ userId }: { userId: string }): React.JSX.Element {
onOpenWorkspace={(workspace) => {
closeTeammateProfile()
void openCloudWorkspace(workspace).then((outcome) => {
if (!outcome.ok) {
if (!outcome.ok && !outcome.notified) {
toast.error(outcome.message)
}
})
+9
View File
@@ -29807,5 +29807,14 @@
"title": "You can’t open this cloud workspace anymore",
"description": "Its owner or a team admin changed who can open it. Ask them for access, or remove it from this computer. This doesn’t delete the workspace.",
"remove": "Remove from this computer"
},
"cloudWorkspaceOpen": {
"opening": "Opening {{name}}…",
"openingDescription": "Workspaces that were asleep or just created can take a minute or two.",
"slowTitle": "{{name}} is taking longer than usual",
"slowDescription": "It’s still starting. Try again in a moment.",
"tryAgain": "Try again",
"goneTitle": "Couldn’t find {{name}}",
"goneDescription": "It may have been deleted."
}
}
@@ -208,6 +208,8 @@ async function runGitHubCloudCreate(creationId: string): Promise<boolean> {
const workspace = await window.api.cloudWorkspaces.get(sandboxId).catch(() => null)
const opened = workspace?.ok
? await openCloudWorkspace(workspace.value, {
// Its pending row shows the open's progress and failure (no open toasts).
feedback: false,
// GH-CREATE-NO-ROW: that server's rows wait behind the pending row until the hand-off.
onHost: (hostId) =>
useAppStore