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) }) )