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.
This commit is contained in:
Jinjing
2026-04-04 15:15:46 -07:00
committed by GitHub
parent ea5a989edf
commit 8e9b505073
5 changed files with 381 additions and 152 deletions
-2
View File
@@ -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 ? <RightSidebar /> : null}
</div>
<QuickOpen />
<UpdateReminder />
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
</div>
)
@@ -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<UpdateStatus, { state: 'available' | 'downloaded' }>
): 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.
<div className="fixed bottom-14 right-4 z-50 flex w-72 items-center gap-2 rounded-[var(--radius)] border border-border bg-popover px-3 py-2.5 text-popover-foreground shadow-md">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{label} <span className="text-muted-foreground">v{updateStatus.version}</span>
</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<a
href={getReleaseUrl(updateStatus)}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
>
Notes
</a>
<Button
variant="default"
size="sm"
className="h-7 px-2.5 text-xs"
onClick={() => {
if (isDownloaded) {
void window.api.updater.quitAndInstall()
} else {
void window.api.updater.download()
}
}}
>
{isDownloaded ? 'Restart' : 'Install'}
</Button>
{!isDownloaded ? (
<button
onClick={() => dismissUpdate()}
aria-label="Dismiss"
className="ml-0.5 rounded-sm p-0.5 text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
) : null}
</div>
</div>
)
}
@@ -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<typeof createToastApi>) {
const lastCall = toastApi.info.mock.calls.at(-1) as [string, Record<string, unknown>]
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)
})
})
@@ -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<UpdateStatus, { state: 'available' | 'downloaded' }>
type ToastApi = Pick<typeof toast, 'loading' | 'info' | 'success' | 'error' | 'dismiss'>
type UpdaterApi = {
download: () => Promise<void>
quitAndInstall: () => Promise<unknown>
}
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
}
}
}
}
}
+4 -79
View File
@@ -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<UpdateStatus, { state: 'available' | 'downloaded' }>
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)
})
)