Improve update experience: toasts, graceful restart, sidebar indicator

- 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 <noreply@anthropic.com>
This commit is contained in:
Neil
2026-03-21 18:42:02 -07:00
co-authored by Claude Opus 4.6
parent c6acd8b2c2
commit abff6b38fc
9 changed files with 165 additions and 98 deletions
+1
View File
@@ -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"
+14
View File
@@ -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:
+32 -53
View File
@@ -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()
+6
View File
@@ -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' ? <Settings /> : !activeWorktreeId ? <Landing /> : null}
</div>
</div>
<Toaster
theme="system"
position="bottom-right"
toastOptions={{ className: 'font-sans text-sm' }}
/>
</div>
)
}
+2 -7
View File
@@ -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<UpdateStatus>({ 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 => {
@@ -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 (
<div className="mt-auto flex items-center justify-between px-2 py-1.5 border-t border-sidebar-border shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="xs"
onClick={() => addRepo()}
className="gap-1.5 text-muted-foreground"
>
<FolderPlus className="size-3.5" />
<span className="text-[11px]">Add Repo</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Open folder picker to add a repo
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setActiveView('settings')}
className="text-muted-foreground"
>
<Settings className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Settings
</TooltipContent>
</Tooltip>
<div className="mt-auto shrink-0">
{showUpdateBanner && (
<button
onClick={() =>
updateStatus.state === 'downloaded'
? window.api.updater.quitAndInstall()
: undefined
}
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-[11px] font-medium text-primary bg-primary/10 hover:bg-primary/15 transition-colors cursor-pointer border-t border-sidebar-border"
>
<Download className="size-3.5 shrink-0" />
{updateStatus.state === 'downloaded' ? (
<span>Restart now (update)</span>
) : (
<span>
Downloading <span className="font-semibold">v{updateStatus.version}</span>…
</span>
)}
</button>
)}
<div className="flex items-center justify-between px-2 py-1.5 border-t border-sidebar-border">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="xs"
onClick={() => addRepo()}
className="gap-1.5 text-muted-foreground"
>
<FolderPlus className="size-3.5" />
<span className="text-[11px]">Add Repo</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Open folder picker to add a repo
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setActiveView('settings')}
className="text-muted-foreground"
>
<Settings className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Settings
</TooltipContent>
</Tooltip>
</div>
</div>
)
})
+42
View File
@@ -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) => {
+7 -2
View File
@@ -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<AppState, [], [], UISlice> = (set) => ({
@@ -72,5 +74,8 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set) => (
groupBy: ui.groupBy,
sortBy: ui.sortBy,
persistedUIReady: true
})
}),
updateStatus: { state: 'idle' },
setUpdateStatus: (status) => set({ updateStatus: status })
})
+3 -3
View File
@@ -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 {