From abff6b38fc982fc349e85d543a1c8c2b9e9fd885 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:42:02 -0700 Subject: [PATCH] Improve update experience: toasts, graceful restart, sidebar indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add sonner toast notifications for menu-triggered "Check for Updates" with loading → success/error flow (auto-checks stay silent) - Fix macOS "quit unexpectedly" crash by gracefully destroying windows before calling autoUpdater.quitAndInstall() - Add update available/downloaded banner in sidebar toolbar footer - Move updateStatus to Zustand store for global access (Settings now reads from store instead of local state) - Replace native dialog feedback with renderer-side toasts throughout Co-Authored-By: Claude Opus 4.6 --- package.json | 1 + pnpm-lock.yaml | 14 +++ src/main/updater.ts | 85 +++++++---------- src/renderer/src/App.tsx | 6 ++ src/renderer/src/components/Settings.tsx | 9 +- .../src/components/sidebar/SidebarToolbar.tsx | 91 ++++++++++++------- src/renderer/src/hooks/useIpcEvents.ts | 42 +++++++++ src/renderer/src/store/slices/ui.ts | 9 +- src/shared/types.ts | 6 +- 9 files changed, 165 insertions(+), 98 deletions(-) diff --git a/package.json b/package.json index c1a950d0254..36874e9edeb 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "radix-ui": "^1.4.3", "shadcn": "^4.1.0", "simple-git": "^3.33.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", "zustand": "^5.0.12" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 081ed0fa1fa..1012d762404 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: simple-git: specifier: ^3.33.0 version: 3.33.0 + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -4043,6 +4046,12 @@ packages: solid-js@1.9.11: resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -8543,6 +8552,11 @@ snapshots: seroval: 1.5.1 seroval-plugins: 1.5.1(seroval@1.5.1) + sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + source-map-js@1.2.1: {} source-map-support@0.5.21: diff --git a/src/main/updater.ts b/src/main/updater.ts index 5b6674ad9f1..83e9e1f2350 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,10 +1,11 @@ -import { app, BrowserWindow, dialog } from 'electron' +import { app, BrowserWindow } from 'electron' import { autoUpdater } from 'electron-updater' import { is } from '@electron-toolkit/utils' import type { UpdateStatus } from '../shared/types' let mainWindowRef: BrowserWindow | null = null let currentStatus: UpdateStatus = { state: 'idle' } +let userInitiatedCheck = false function sendStatus(status: UpdateStatus): void { currentStatus = status @@ -26,56 +27,35 @@ export function checkForUpdates(): void { }) } -/** Menu-triggered check that shows native dialogs for feedback */ +/** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */ export function checkForUpdatesFromMenu(): void { if (!app.isPackaged || is.dev) { - dialog.showMessageBox({ type: 'info', message: 'You\u2019re on the latest version.' }) + sendStatus({ state: 'not-available', userInitiated: true }) return } - sendStatus({ state: 'checking' }) - - const onAvailable = (): void => { - cleanup() - } - const onNotAvailable = (): void => { - cleanup() - dialog.showMessageBox({ type: 'info', message: 'You\u2019re on the latest version.' }) - } - const onError = (err: Error): void => { - cleanup() - dialog.showMessageBox({ - type: 'error', - title: 'Update Error', - message: 'Could not check for updates.', - detail: err?.message - }) - } - - function cleanup(): void { - autoUpdater.off('update-available', onAvailable) - autoUpdater.off('update-not-available', onNotAvailable) - autoUpdater.off('error', onError) - } - - autoUpdater.once('update-available', onAvailable) - autoUpdater.once('update-not-available', onNotAvailable) - autoUpdater.once('error', onError) + userInitiatedCheck = true + sendStatus({ state: 'checking', userInitiated: true }) autoUpdater.checkForUpdates().catch((err) => { - sendStatus({ state: 'error', message: String(err?.message ?? err) }) - cleanup() - dialog.showMessageBox({ - type: 'error', - title: 'Update Error', - message: 'Could not check for updates.', - detail: String(err?.message ?? err) - }) + userInitiatedCheck = false + sendStatus({ state: 'error', message: String(err?.message ?? err), userInitiated: true }) }) } export function quitAndInstall(): void { - autoUpdater.quitAndInstall() + // Graceful shutdown: close all windows before letting the updater restart. + // This prevents macOS from showing "quit unexpectedly" dialogs because + // autoUpdater.quitAndInstall() calls app.exit() which bypasses lifecycle. + const windows = BrowserWindow.getAllWindows() + for (const win of windows) { + win.removeAllListeners('close') + win.destroy() + } + + setImmediate(() => { + autoUpdater.quitAndInstall(false, true) + }) } export function setupAutoUpdater(mainWindow: BrowserWindow): void { @@ -92,15 +72,18 @@ export function setupAutoUpdater(mainWindow: BrowserWindow): void { autoUpdater.allowPrerelease = true autoUpdater.on('checking-for-update', () => { - sendStatus({ state: 'checking' }) + sendStatus({ state: 'checking', userInitiated: userInitiatedCheck || undefined }) }) autoUpdater.on('update-available', (info) => { + userInitiatedCheck = false sendStatus({ state: 'available', version: info.version }) }) autoUpdater.on('update-not-available', () => { - sendStatus({ state: 'not-available' }) + const wasUserInitiated = userInitiatedCheck + userInitiatedCheck = false + sendStatus({ state: 'not-available', userInitiated: wasUserInitiated || undefined }) }) autoUpdater.on('download-progress', (progress) => { @@ -109,20 +92,16 @@ export function setupAutoUpdater(mainWindow: BrowserWindow): void { autoUpdater.on('update-downloaded', (info) => { sendStatus({ state: 'downloaded', version: info.version }) - dialog - .showMessageBox(mainWindow, { - type: 'info', - title: 'Update Ready', - message: `Version ${info.version} has been downloaded. Restart to install.`, - buttons: ['Restart Now', 'Later'] - }) - .then(({ response }) => { - if (response === 0) autoUpdater.quitAndInstall() - }) }) autoUpdater.on('error', (err) => { - sendStatus({ state: 'error', message: err?.message ?? 'Unknown error' }) + const wasUserInitiated = userInitiatedCheck + userInitiatedCheck = false + sendStatus({ + state: 'error', + message: err?.message ?? 'Unknown error', + userInitiated: wasUserInitiated || undefined + }) }) autoUpdater.checkForUpdatesAndNotify() diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 829172c25a0..57dbc621f99 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react' +import { Toaster } from 'sonner' import { Minimize2, PanelLeft } from 'lucide-react' import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal' import { syncZoomCSSVar } from '@/lib/ui-zoom' @@ -233,6 +234,11 @@ function App(): React.JSX.Element { {activeView === 'settings' ? : !activeWorktreeId ? : null} + ) } diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index 184959e1b8a..b406b9e47c2 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react' -import type { OrcaHooks, Repo, RepoHookSettings, UpdateStatus } from '../../../shared/types' +import type { OrcaHooks, Repo, RepoHookSettings } from '../../../shared/types' import { REPO_COLORS, getDefaultRepoHookSettings } from '../../../shared/constants' import { useAppStore } from '../store' import { ScrollArea } from './ui/scroll-area' @@ -448,17 +448,12 @@ function Settings(): React.JSX.Element { getFallbackTerminalFonts() ) const terminalFontsLoadedRef = useRef(false) - const [updateStatus, setUpdateStatus] = useState({ state: 'idle' }) + const updateStatus = useAppStore((s) => s.updateStatus) useEffect(() => { fetchSettings() }, [fetchSettings]) - useEffect(() => { - window.api.updater.getStatus().then(setUpdateStatus) - return window.api.updater.onStatus(setUpdateStatus) - }, []) - useEffect(() => { const media = window.matchMedia('(prefers-color-scheme: dark)') const handleChange = (event: MediaQueryListEvent): void => { diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.tsx index 86fdab7c35d..a7753f66ebe 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { FolderPlus, Settings } from 'lucide-react' +import { Download, FolderPlus, Settings } from 'lucide-react' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' @@ -7,40 +7,65 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip const SidebarToolbar = React.memo(function SidebarToolbar() { const addRepo = useAppStore((s) => s.addRepo) const setActiveView = useAppStore((s) => s.setActiveView) + const updateStatus = useAppStore((s) => s.updateStatus) + + const showUpdateBanner = + updateStatus.state === 'downloaded' || updateStatus.state === 'available' return ( -
- - - - - - Open folder picker to add a repo - - - - - - - - Settings - - +
+ {showUpdateBanner && ( + + )} +
+ + + + + + Open folder picker to add a repo + + + + + + + + Settings + + +
) }) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 13232f948b2..09a364b47d1 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1,6 +1,8 @@ import { useEffect } from 'react' +import { toast } from 'sonner' import { useAppStore } from '../store' import { applyUIZoom } from '@/lib/ui-zoom' +import type { UpdateStatus } from '../../../shared/types' const ZOOM_STEP = 0.5 @@ -26,6 +28,46 @@ export function useIpcEvents(): void { }) ) + // Hydrate initial update status then subscribe to changes + window.api.updater.getStatus().then((status) => { + useAppStore.getState().setUpdateStatus(status as UpdateStatus) + }) + + let checkingToastId: string | number | undefined + 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 === '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 === 'downloaded') { + toast.success(`Version ${status.version} is ready to install.`, { + duration: Infinity, + action: { + label: 'Restart Now', + onClick: () => window.api.updater.quitAndInstall() + } + }) + } else if (status.state === 'error' && 'userInitiated' in status && status.userInitiated) { + toast.error('Could not check for updates.', { + description: status.message, + id: checkingToastId + }) + checkingToastId = undefined + } + }) + ) + // Browser zoom fallback when no terminal is active unsubs.push( window.api.ui.onTerminalZoom((direction) => { diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 267faa06a24..b4f605f8d05 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -1,6 +1,6 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import type { PersistedUIState } from '../../../../shared/types' +import type { PersistedUIState, UpdateStatus } from '../../../../shared/types' export interface UISlice { sidebarOpen: boolean @@ -29,6 +29,8 @@ export interface UISlice { clearPendingRevealWorktreeId: () => void persistedUIReady: boolean hydratePersistedUI: (ui: PersistedUIState) => void + updateStatus: UpdateStatus + setUpdateStatus: (status: UpdateStatus) => void } export const createUISlice: StateCreator = (set) => ({ @@ -72,5 +74,8 @@ export const createUISlice: StateCreator = (set) => ( groupBy: ui.groupBy, sortBy: ui.sortBy, persistedUIReady: true - }) + }), + + updateStatus: { state: 'idle' }, + setUpdateStatus: (status) => set({ updateStatus: status }) }) diff --git a/src/shared/types.ts b/src/shared/types.ts index fdac95f9e6e..dac711d2d4b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -123,12 +123,12 @@ export interface RepoHookSettings { // ─── Updater ───────────────────────────────────────────────────────── export type UpdateStatus = | { state: 'idle' } - | { state: 'checking' } + | { state: 'checking'; userInitiated?: boolean } | { state: 'available'; version: string } - | { state: 'not-available' } + | { state: 'not-available'; userInitiated?: boolean } | { state: 'downloading'; percent: number } | { state: 'downloaded'; version: string } - | { state: 'error'; message: string } + | { state: 'error'; message: string; userInitiated?: boolean } // ─── Settings ──────────────────────────────────────────────────────── export interface GlobalSettings {