mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 16:02:46 +00:00
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:
@@ -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"
|
||||
|
||||
Generated
+14
@@ -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
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user