diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceOpenFeedback.fixture.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceOpenFeedback.fixture.tsx new file mode 100644 index 00000000000..f532e11a05f --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceOpenFeedback.fixture.tsx @@ -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 ( + <> +
+ + + ) +} + +async function toastShown(): Promise { + 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: () => , + interact: toastShown +}) + +/** The open timed out while the workspace is still starting (warning, Try again). */ +export const takingLonger = defineFixture({ + width: 1280, + render: () => , + interact: toastShown +}) + +/** The workspace is gone (error, Show cloud workspaces). */ +export const gone = defineFixture({ + width: 1280, + render: () => , + interact: toastShown +}) diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-links.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-links.ts index ce383f6ba66..529fdf24323 100644 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspace-links.ts +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-links.ts @@ -36,7 +36,9 @@ export async function handleCloudWorkspaceLink(link: string): Promise { 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) } diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-open-feedback.test.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-open-feedback.test.ts new file mode 100644 index 00000000000..e9d0046c1ac --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-open-feedback.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-open-feedback.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-open-feedback.ts new file mode 100644 index 00000000000..ca82aecd6ac --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-open-feedback.ts @@ -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 | null + shown: boolean +} + +const flights = new Map() +// Workspaces whose open the picker is showing (its row spinner), counted per mount. +const pickerShown = new Map() + +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, + actions: { retry: () => void; showWorkspaces: () => void } +): Promise { + 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() +} diff --git a/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts b/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts index 956c30f6e47..290a3728456 100644 --- a/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts +++ b/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts @@ -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 opening.delete(workspace.id)) + const run = (): Promise => 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 } diff --git a/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx b/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx index 7c4b3ead91d..9d08ab51220 100644 --- a/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx +++ b/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx @@ -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(null) const [confirm, setConfirm] = useState(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 diff --git a/src/renderer/src/components/settings/cloud-workspaces/TeamWorkspacesSettings.tsx b/src/renderer/src/components/settings/cloud-workspaces/TeamWorkspacesSettings.tsx index 0b330812538..55bc20629d9 100644 --- a/src/renderer/src/components/settings/cloud-workspaces/TeamWorkspacesSettings.tsx +++ b/src/renderer/src/components/settings/cloud-workspaces/TeamWorkspacesSettings.tsx @@ -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) } }) diff --git a/src/renderer/src/components/teammates/TeammateProfileDialog.tsx b/src/renderer/src/components/teammates/TeammateProfileDialog.tsx index ccd68a28629..648d2046d4f 100644 --- a/src/renderer/src/components/teammates/TeammateProfileDialog.tsx +++ b/src/renderer/src/components/teammates/TeammateProfileDialog.tsx @@ -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) } }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c2fd8a81981..f0fab10dc95 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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." } } diff --git a/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts index b71097c3f5c..b578ab68b8d 100644 --- a/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts +++ b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts @@ -208,6 +208,8 @@ async function runGitHubCloudCreate(creationId: string): Promise { 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