diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts index 5c98aa9cc47..caa40785b23 100644 --- a/src/renderer/src/store/slices/runtime-status.test.ts +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -78,6 +78,7 @@ beforeEach(() => { }) afterEach(() => { + vi.useRealTimers() vi.unstubAllGlobals() }) @@ -207,7 +208,16 @@ describe('runtime-status slice', () => { expect(toast.dismiss).toHaveBeenCalledWith('runtime-environment-disconnected:env-a') }) - it('retries from the toast and re-surfaces it when the server is still offline', async () => { + it('keeps the keyed action toast visible when an offline retry settles', async () => { + vi.useFakeTimers() + const toastId = 'runtime-environment-disconnected:env-a' + const visibleToastIds = new Set() + vi.mocked(toast.warning).mockImplementation((_title, options) => { + if (options?.id !== undefined) { + visibleToastIds.add(options.id) + } + return options?.id ?? '' + }) const getStatus = vi.fn().mockRejectedValue(new Error('closed')) stubRuntimeEnvironmentApi({ getStatus }) const store = createSliceStore() @@ -215,18 +225,39 @@ describe('runtime-status slice', () => { store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 }) store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 2 }) const options = vi.mocked(toast.warning).mock.calls[0]?.[1] as unknown as { - action: { onClick: () => void } + action: { onClick: (event: { preventDefault: () => void }) => void } + } + const clickAction = (): { defaultPrevented: boolean } => { + const event = { + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true + } + } + options.action.onClick(event) + if (!event.defaultPrevented) { + setTimeout(() => visibleToastIds.delete(toastId), 200) + } + return event } - options.action.onClick() + const firstClick = clickAction() + const secondClick = clickAction() + await vi.advanceTimersByTimeAsync(0) - await vi.waitFor(() => { - expect(getStatus).toHaveBeenCalledWith({ selector: 'env-a', timeoutMs: 10_000 }) - expect(toast.warning).toHaveBeenCalledTimes(2) - }) - expect(vi.mocked(toast.warning).mock.calls[1]?.[1]?.id).toBe( - 'runtime-environment-disconnected:env-a' - ) + expect(firstClick.defaultPrevented).toBe(true) + expect(secondClick.defaultPrevented).toBe(true) + expect(getStatus).toHaveBeenCalledWith({ selector: 'env-a', timeoutMs: 10_000 }) + await vi.advanceTimersByTimeAsync(200) + expect(visibleToastIds.has(toastId)).toBe(true) + expect(getStatus).toHaveBeenCalledTimes(1) + expect(toast.warning).toHaveBeenCalledTimes(3) + expect(vi.mocked(toast.warning).mock.calls.map((call) => call[1]?.duration)).toEqual([ + 4_000, + Number.POSITIVE_INFINITY, + 4_000 + ]) + expect(vi.mocked(toast.warning).mock.calls.every((call) => call[1]?.id === toastId)).toBe(true) }) it('does not report removal or explicit status clearing as a disconnect', () => { diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 281a8a6da18..1eda7652518 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -64,6 +64,7 @@ export type RuntimeStatusSlice = { const connectionGenerationByEnvironment = new Map() const activeRuntimeDisconnectedToasts = new Map() +const RUNTIME_DISCONNECTED_TOAST_DURATION_MS = 4_000 function getRuntimeDisconnectedToastId(environmentId: string): string { return `runtime-environment-disconnected:${environmentId}` @@ -89,30 +90,49 @@ function showRuntimeDisconnectedToast(environmentId: string, getState: () => App activeRuntimeDisconnectedToasts.delete(toastId) } } - toast.warning(title, { - id: toastId, - description: translate( - 'auto.store.slices.runtime.status.runtimeHostDisconnectedDescription', - 'Workspaces and terminals on this server are unavailable.' - ), - action: { - label: translate('browser.loadFailure.retry', 'Retry'), - onClick: () => { - void getState() - .refreshRuntimeEnvironmentStatus(environmentId) - .then((reachable) => { - const stillSaved = getState().runtimeEnvironments.some( - (entry) => entry.id === environmentId - ) - if (!reachable && stillSaved) { - showRuntimeDisconnectedToast(environmentId, getState) - } - }) - } - }, - onDismiss: clearActiveToast, - onAutoClose: clearActiveToast - }) + let retrying = false + const showToast = (duration = RUNTIME_DISCONNECTED_TOAST_DURATION_MS): void => { + toast.warning(title, { + id: toastId, + description: translate( + 'auto.store.slices.runtime.status.runtimeHostDisconnectedDescription', + 'Workspaces and terminals on this server are unavailable.' + ), + duration, + action: { + label: translate('browser.loadFailure.retry', 'Retry'), + onClick: (event) => { + // Why: Sonner otherwise deletes the keyed toast after the action callback. + event.preventDefault() + if (retrying) { + return + } + retrying = true + showToast(Number.POSITIVE_INFINITY) + void getState() + .refreshRuntimeEnvironmentStatus(environmentId) + .then((reachable) => { + const stillSaved = getState().runtimeEnvironments.some( + (entry) => entry.id === environmentId + ) + if ( + !reachable && + stillSaved && + activeRuntimeDisconnectedToasts.get(toastId) === activation + ) { + showToast() + } + }) + .finally(() => { + retrying = false + }) + } + }, + onDismiss: clearActiveToast, + onAutoClose: clearActiveToast + }) + } + showToast() } function dismissRuntimeDisconnectedToast(environmentId: string): void {