From 8e9b505073717de99a269359a55b238e5114c177 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:15:46 -0700 Subject: [PATCH] restore single update toast and cover updater transitions (#299) * fix: restore update toast flow and cover updater transitions * fix: resolve oxlint errors in test file and FileExplorer - Replace smart quote (U+2019) with ASCII apostrophe in update toast test - Use .at(-1) instead of array[length - 1] per prefer-at rule - Extract background context menu into FileExplorerBgMenu to fix max-lines * fix: restore dismissed update version check in toast controller The old UpdateReminder component checked dismissedUpdateVersion before showing the update prompt. This logic was missing from the new createUpdateToastController, causing the toast to reappear even after the user dismissed it. Now the controller skips the available toast when the version matches the dismissed one and persists the dismissal when the user closes the toast without clicking Update. --- src/renderer/src/App.tsx | 2 - .../src/components/UpdateReminder.tsx | 71 ------- .../src/hooks/update-toast-controller.test.ts | 200 ++++++++++++++++++ .../src/hooks/update-toast-controller.ts | 177 ++++++++++++++++ src/renderer/src/hooks/useIpcEvents.ts | 83 +------- 5 files changed, 381 insertions(+), 152 deletions(-) delete mode 100644 src/renderer/src/components/UpdateReminder.tsx create mode 100644 src/renderer/src/hooks/update-toast-controller.test.ts create mode 100644 src/renderer/src/hooks/update-toast-controller.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7b1ba0538d7..d4479019ce5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -14,7 +14,6 @@ import Landing from './components/Landing' import Settings from './components/settings/Settings' import RightSidebar from './components/right-sidebar' import QuickOpen from './components/QuickOpen' -import UpdateReminder from './components/UpdateReminder' import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling' import { setRuntimeGraphStoreStateGetter, @@ -436,7 +435,6 @@ function App(): React.JSX.Element { {showSidebar && rightSidebarOpen ? : null} - ) diff --git a/src/renderer/src/components/UpdateReminder.tsx b/src/renderer/src/components/UpdateReminder.tsx deleted file mode 100644 index 9640e89b582..00000000000 --- a/src/renderer/src/components/UpdateReminder.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { X } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { useAppStore } from '@/store' -import type { UpdateStatus } from '../../../shared/types' - -function getReleaseUrl( - status: Extract -): string { - return status.releaseUrl ?? `https://github.com/stablyai/orca/releases/tag/v${status.version}` -} - -export default function UpdateReminder(): React.JSX.Element | null { - const updateStatus = useAppStore((s) => s.updateStatus) - const dismissedUpdateVersion = useAppStore((s) => s.dismissedUpdateVersion) - const dismissUpdate = useAppStore((s) => s.dismissUpdate) - - if (updateStatus.state !== 'available' && updateStatus.state !== 'downloaded') { - return null - } - - if (updateStatus.state === 'available' && updateStatus.version === dismissedUpdateVersion) { - return null - } - - const isDownloaded = updateStatus.state === 'downloaded' - const label = isDownloaded ? 'Restart to update' : 'Update available' - - return ( - // Persistent bottom-right toast, positioned to sit above the Sonner toaster. -
-
-

- {label} v{updateStatus.version} -

-
-
- - Notes - - - {!isDownloaded ? ( - - ) : null} -
-
- ) -} diff --git a/src/renderer/src/hooks/update-toast-controller.test.ts b/src/renderer/src/hooks/update-toast-controller.test.ts new file mode 100644 index 00000000000..5644f699299 --- /dev/null +++ b/src/renderer/src/hooks/update-toast-controller.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { createUpdateToastController } from './update-toast-controller' + +function createToastApi() { + return { + loading: vi.fn(), + info: vi.fn(), + success: vi.fn(), + error: vi.fn(), + dismiss: vi.fn() + } +} + +function createUpdaterApi() { + return { + download: vi.fn().mockResolvedValue(undefined), + quitAndInstall: vi.fn().mockResolvedValue(undefined) + } +} + +function createStoreApi(dismissedVersion: string | null = null) { + return { + getDismissedVersion: vi.fn().mockReturnValue(dismissedVersion), + dismissUpdate: vi.fn() + } +} + +function getInfoOptions(toastApi: ReturnType) { + const lastCall = toastApi.info.mock.calls.at(-1) as [string, Record] + const [, options] = lastCall + return options +} + +describe('createUpdateToastController', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows a single persistent available toast with release notes and update action', () => { + const toastApi = createToastApi() + toastApi.loading.mockReturnValue('checking-toast') + toastApi.info.mockReturnValue('available-toast') + const updaterApi = createUpdaterApi() + const storeApi = createStoreApi() + const controller = createUpdateToastController({ toastApi, updaterApi, storeApi }) + + controller.handleStatus({ state: 'checking', userInitiated: true }) + controller.handleStatus({ + state: 'available', + version: '1.2.3', + releaseUrl: 'https://example.com/release/1.2.3' + }) + + expect(toastApi.loading).toHaveBeenCalledWith('Checking for updates...') + expect(toastApi.dismiss).toHaveBeenCalledWith('checking-toast') + expect(toastApi.info).toHaveBeenCalledTimes(1) + expect(toastApi.success).not.toHaveBeenCalled() + + const options = getInfoOptions(toastApi) + expect(options.duration).toBe(Infinity) + expect((options.description as { props: { href: string } }).props.href).toBe( + 'https://example.com/release/1.2.3' + ) + expect((options.action as { label: string }).label).toBe('Update') + }) + + it('dismisses the available toast when download progress starts', () => { + const toastApi = createToastApi() + toastApi.info.mockReturnValue('available-toast') + const updaterApi = createUpdaterApi() + const storeApi = createStoreApi() + const controller = createUpdateToastController({ toastApi, updaterApi, storeApi }) + + controller.handleStatus({ state: 'available', version: '1.2.3' }) + controller.handleStatus({ state: 'downloading', version: '1.2.3', percent: 42 }) + + expect(toastApi.dismiss).toHaveBeenCalledWith('available-toast') + expect(toastApi.loading).toHaveBeenLastCalledWith('Downloading v1.2.3… 42%', { + id: 'update-download-progress', + duration: Infinity + }) + }) + + it('auto-restarts after download only when the user clicked the one-click update action', async () => { + const toastApi = createToastApi() + toastApi.info.mockReturnValue('available-toast') + const updaterApi = createUpdaterApi() + const storeApi = createStoreApi() + const controller = createUpdateToastController({ toastApi, updaterApi, storeApi }) + + controller.handleStatus({ state: 'available', version: '1.2.3' }) + const infoOptions = getInfoOptions(toastApi) + ;(infoOptions.action as { onClick: () => void }).onClick() + + expect(updaterApi.download).toHaveBeenCalledTimes(1) + + controller.handleStatus({ state: 'downloaded', version: '1.2.3' }) + + expect(updaterApi.quitAndInstall).toHaveBeenCalledTimes(1) + expect(toastApi.success).not.toHaveBeenCalled() + }) + + it('shows a restart toast after manual-download updates because they still need confirmation', () => { + const toastApi = createToastApi() + toastApi.info.mockReturnValue('available-toast') + const updaterApi = createUpdaterApi() + const storeApi = createStoreApi() + const controller = createUpdateToastController({ toastApi, updaterApi, storeApi }) + + controller.handleStatus({ + state: 'available', + version: '1.2.3', + manualDownloadUrl: 'https://example.com/download/1.2.3' + }) + const infoOptions = getInfoOptions(toastApi) + ;(infoOptions.action as { onClick: () => void }).onClick() + controller.handleStatus({ state: 'downloaded', version: '1.2.3' }) + + expect(updaterApi.download).toHaveBeenCalledTimes(1) + expect(updaterApi.quitAndInstall).not.toHaveBeenCalled() + expect(toastApi.success).toHaveBeenCalledWith('Version 1.2.3 is ready to install.', { + description: expect.any(Object), + duration: Infinity, + action: expect.objectContaining({ label: 'Restart Now' }) + }) + }) + + it('clears stale one-click restart intent after a later check error', () => { + const toastApi = createToastApi() + toastApi.info.mockReturnValue('available-toast') + const updaterApi = createUpdaterApi() + const storeApi = createStoreApi() + const controller = createUpdateToastController({ toastApi, updaterApi, storeApi }) + + controller.handleStatus({ state: 'available', version: '1.2.3' }) + const infoOptions = getInfoOptions(toastApi) + ;(infoOptions.action as { onClick: () => void }).onClick() + + controller.handleStatus({ state: 'error', message: 'network timeout' }) + controller.handleStatus({ state: 'downloaded', version: '1.2.4' }) + + expect(updaterApi.quitAndInstall).not.toHaveBeenCalled() + expect(toastApi.success).toHaveBeenCalledWith('Version 1.2.4 is ready to install.', { + description: expect.any(Object), + duration: Infinity, + action: expect.objectContaining({ label: 'Restart Now' }) + }) + }) + + it('replaces the checking toast with a latest-version success toast for user-initiated checks', () => { + const toastApi = createToastApi() + toastApi.loading.mockReturnValue('checking-toast') + const updaterApi = createUpdaterApi() + const storeApi = createStoreApi() + const controller = createUpdateToastController({ toastApi, updaterApi, storeApi }) + + controller.handleStatus({ state: 'checking', userInitiated: true }) + controller.handleStatus({ state: 'not-available', userInitiated: true }) + + expect(toastApi.success).toHaveBeenCalledWith("You're on the latest version.", { + id: 'checking-toast' + }) + }) + + it('suppresses the available toast when the version matches the dismissed version', () => { + const toastApi = createToastApi() + const updaterApi = createUpdaterApi() + const storeApi = createStoreApi('1.2.3') + const controller = createUpdateToastController({ toastApi, updaterApi, storeApi }) + + controller.handleStatus({ state: 'available', version: '1.2.3' }) + + expect(toastApi.info).not.toHaveBeenCalled() + }) + + it('shows the available toast when a newer version supersedes the dismissed one', () => { + const toastApi = createToastApi() + const updaterApi = createUpdaterApi() + const storeApi = createStoreApi('1.2.3') + const controller = createUpdateToastController({ toastApi, updaterApi, storeApi }) + + controller.handleStatus({ state: 'available', version: '1.3.0' }) + + expect(toastApi.info).toHaveBeenCalledTimes(1) + }) + + it('calls dismissUpdate when the user closes the available toast without updating', () => { + const toastApi = createToastApi() + toastApi.info.mockReturnValue('available-toast') + const updaterApi = createUpdaterApi() + const storeApi = createStoreApi() + const controller = createUpdateToastController({ toastApi, updaterApi, storeApi }) + + controller.handleStatus({ state: 'available', version: '1.2.3' }) + const options = getInfoOptions(toastApi) + ;(options.onDismiss as () => void)() + + expect(storeApi.dismissUpdate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/hooks/update-toast-controller.ts b/src/renderer/src/hooks/update-toast-controller.ts new file mode 100644 index 00000000000..b6355d4df3e --- /dev/null +++ b/src/renderer/src/hooks/update-toast-controller.ts @@ -0,0 +1,177 @@ +import { createElement } from 'react' +import { toast } from 'sonner' +import type { UpdateStatus } from '../../../shared/types' +import { useAppStore } from '../store' + +type ReleaseToastStatus = Extract + +type ToastApi = Pick + +type UpdaterApi = { + download: () => Promise + quitAndInstall: () => Promise +} + +type StoreApi = { + getDismissedVersion: () => string | null + dismissUpdate: () => void +} + +function getReleaseUrl(status: ReleaseToastStatus): string { + return status.releaseUrl ?? `https://github.com/stablyai/orca/releases/tag/v${status.version}` +} + +export function createUpdateToastController(deps?: { + toastApi?: ToastApi + updaterApi?: UpdaterApi + storeApi?: StoreApi +}): { + handleStatus: (status: UpdateStatus) => void +} { + const toastApi = deps?.toastApi ?? toast + const updaterApi = deps?.updaterApi ?? window.api.updater + const storeApi: StoreApi = deps?.storeApi ?? { + getDismissedVersion: () => useAppStore.getState().dismissedUpdateVersion, + dismissUpdate: () => useAppStore.getState().dismissUpdate() + } + + let checkingToastId: string | number | undefined + let availableToastId: string | number | undefined + const downloadToastId = 'update-download-progress' + // Why: the old updater UX was a single toast flow. Remember whether the + // user clicked the toast's update action so auto-download installs can + // finish in one step instead of showing a second bottom-right prompt. + let autoRestartAfterDownload = false + + return { + handleStatus(status) { + // Why: update checks are a new lifecycle. Clearing the one-click + // install intent here prevents a stale flag from a previous release + // from auto-restarting on an unrelated later download. + if (status.state === 'checking' || status.state === 'error') { + autoRestartAfterDownload = false + } + + if (status.state === 'checking' && 'userInitiated' in status && status.userInitiated) { + checkingToastId = toastApi.loading('Checking for updates...') + } else if (status.state === 'idle') { + if (checkingToastId) { + toastApi.dismiss(checkingToastId) + checkingToastId = undefined + } + } else if (status.state === 'not-available') { + if ('userInitiated' in status && status.userInitiated) { + toastApi.success("You're on the latest version.", { id: checkingToastId }) + checkingToastId = undefined + } + } else if (status.state === 'available') { + if (checkingToastId) { + toastApi.dismiss(checkingToastId) + } + checkingToastId = undefined + // Why: if the user previously dismissed this exact version, don't + // re-show the toast. This preserves the old UpdateReminder behavior + // where dismissedUpdateVersion was checked before rendering. + if (storeApi.getDismissedVersion() === status.version) { + return + } + const releaseUrl = getReleaseUrl(status) + availableToastId = toastApi.info(`Version ${status.version} is available.`, { + description: createElement( + 'a', + { + href: releaseUrl, + target: '_blank', + rel: 'noopener noreferrer', + style: { textDecoration: 'underline' } + }, + 'Release notes' + ), + duration: Infinity, + // Why: when the user closes the toast without clicking Update, + // persist the dismissed version so the same release doesn't + // re-appear on the next check or app restart. + onDismiss: () => storeApi.dismissUpdate(), + action: { + label: 'Update', + onClick: () => { + // Why: manual-download builds still need the follow-up install + // step, but auto-download builds should preserve the previous + // one-click toast behavior and restart as soon as the payload + // is ready. + if (!status.manualDownloadUrl) { + autoRestartAfterDownload = true + } + void updaterApi.download() + } + } + }) + } else if (status.state === 'downloading') { + if (availableToastId) { + toastApi.dismiss(availableToastId) + availableToastId = undefined + } + toastApi.loading(`Downloading v${status.version}… ${status.percent}%`, { + id: downloadToastId, + duration: Infinity + }) + } else if (status.state === 'downloaded') { + if (availableToastId) { + toastApi.dismiss(availableToastId) + availableToastId = undefined + } + toastApi.dismiss(downloadToastId) + if (autoRestartAfterDownload) { + autoRestartAfterDownload = false + void updaterApi.quitAndInstall() + return + } + const releaseUrl = getReleaseUrl(status) + toastApi.success(`Version ${status.version} is ready to install.`, { + description: createElement( + 'a', + { + href: releaseUrl, + target: '_blank', + rel: 'noopener noreferrer', + style: { textDecoration: 'underline' } + }, + 'Release notes' + ), + duration: Infinity, + action: { + label: 'Restart Now', + onClick: () => { + void updaterApi.quitAndInstall() + } + } + }) + } else if (status.state === 'error') { + toastApi.dismiss(downloadToastId) + if ('userInitiated' in status && status.userInitiated) { + toastApi.error('Could not check for updates.', { + description: createElement( + 'span', + null, + status.message, + ' You can download the latest version manually from ', + createElement( + 'a', + { + href: 'https://github.com/stablyai/orca/releases/latest', + target: '_blank', + rel: 'noopener noreferrer', + style: { textDecoration: 'underline' } + }, + 'our GitHub releases page' + ), + '.' + ), + id: checkingToastId + }) + checkingToastId = undefined + } + } + } + } +} diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 635d87ddba3..a63ccb880e2 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1,20 +1,16 @@ -import { useEffect, createElement } from 'react' -import { toast } from 'sonner' +import { useEffect } from 'react' import { useAppStore } from '../store' import { applyUIZoom } from '@/lib/ui-zoom' import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-activation' import type { UpdateStatus } from '../../../shared/types' +import { createUpdateToastController } from './update-toast-controller' const ZOOM_STEP = 0.5 -type ReleaseToastStatus = Extract - -function getReleaseUrl(status: ReleaseToastStatus): string { - return status.releaseUrl ?? `https://github.com/stablyai/orca/releases/tag/v${status.version}` -} export function useIpcEvents(): void { useEffect(() => { const unsubs: (() => void)[] = [] + const updateToastController = createUpdateToastController() unsubs.push( window.api.repos.onChanged(() => { @@ -60,82 +56,11 @@ export function useIpcEvents(): void { useAppStore.getState().setUpdateStatus(status as UpdateStatus) }) - let checkingToastId: string | number | undefined - const downloadToastId = 'update-download-progress' unsubs.push( window.api.updater.onStatus((raw) => { const status = raw as UpdateStatus useAppStore.getState().setUpdateStatus(status) - - // Show toasts for user-initiated checks - if (status.state === 'checking' && 'userInitiated' in status && status.userInitiated) { - checkingToastId = toast.loading('Checking for updates...') - } else if (status.state === 'idle') { - if (checkingToastId) { - toast.dismiss(checkingToastId) - checkingToastId = undefined - } - } else if (status.state === 'not-available') { - if ('userInitiated' in status && status.userInitiated) { - toast.success('You\u2019re on the latest version.', { id: checkingToastId }) - checkingToastId = undefined - } - } else if (status.state === 'available') { - if (checkingToastId) { - toast.dismiss(checkingToastId) - } - checkingToastId = undefined - } else if (status.state === 'downloading') { - toast.loading(`Downloading v${status.version}… ${status.percent}%`, { - id: downloadToastId, - duration: Infinity - }) - } else if (status.state === 'downloaded') { - toast.dismiss(downloadToastId) - const releaseUrl = getReleaseUrl(status) - toast.success(`Version ${status.version} is ready to install.`, { - description: createElement( - 'a', - { - href: releaseUrl, - target: '_blank', - rel: 'noopener noreferrer', - style: { textDecoration: 'underline' } - }, - 'Release notes' - ), - duration: Infinity, - action: { - label: 'Restart Now', - onClick: () => window.api.updater.quitAndInstall() - } - }) - } else if (status.state === 'error') { - toast.dismiss(downloadToastId) - if ('userInitiated' in status && status.userInitiated) { - toast.error('Could not check for updates.', { - description: createElement( - 'span', - null, - status.message, - ' You can download the latest version manually from ', - createElement( - 'a', - { - href: 'https://github.com/stablyai/orca/releases/latest', - target: '_blank', - rel: 'noopener noreferrer', - style: { textDecoration: 'underline' } - }, - 'our GitHub releases page' - ), - '.' - ), - id: checkingToastId - }) - checkingToastId = undefined - } - } + updateToastController.handleStatus(status) }) )