mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user