diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts
index 6c8d54ef..db6f5e75 100644
--- a/desktop/src/main/updater.ts
+++ b/desktop/src/main/updater.ts
@@ -131,6 +131,15 @@ export function initUpdater(windowGetter: () => BrowserWindow | null): void {
// reply "not-available" so the menu can show "up to date".
export function checkForUpdates(): void {
if (!app.isPackaged) {
+ // Dev-only UI harness: set COW_MOCK_UPDATE=1 to simulate an available
+ // update so the update panel/menu interactions can be exercised in
+ // `npm run dev` (where there's no real feed). Never runs in a packaged app.
+ if (process.env.COW_MOCK_UPDATE) {
+ const version = process.env.COW_MOCK_UPDATE_VERSION || '9.9.9'
+ log(`checkForUpdates: not packaged, MOCK available version=${version}`)
+ send({ state: 'available', version })
+ return
+ }
log('checkForUpdates: not packaged, replying not-available')
send({ state: 'not-available' })
return
diff --git a/desktop/src/renderer/src/components/UpdateBanner.tsx b/desktop/src/renderer/src/components/UpdateBanner.tsx
index 8c95ac10..b51d1e8b 100644
--- a/desktop/src/renderer/src/components/UpdateBanner.tsx
+++ b/desktop/src/renderer/src/components/UpdateBanner.tsx
@@ -1,61 +1,41 @@
-import React, { useEffect, useState } from 'react'
+import React from 'react'
import { Download, RefreshCw, X, Loader2 } from 'lucide-react'
import { t } from '../i18n'
-import { useUpdateStore, hasPendingUpdate } from '../store/updateStore'
+import { useUpdateStore, hasAvailableUpdate } from '../store/updateStore'
-// Compact update panel anchored to the NavRail footer. Only mounts content
-// when there's a pending update; otherwise renders nothing so it stays out of
-// the way until electron-updater reports a new version.
+// Compact update panel anchored to the NavRail footer. Shown whenever an update
+// is available AND the panel is open (auto-opened on detection, re-openable via
+// "check for update"). Dismissing (×) just closes it; the menu can re-open it.
const UpdateBanner: React.FC = () => {
const state = useUpdateStore()
- const [open, setOpen] = useState(false)
+ const open = state.panelOpen
- const pending = hasPendingUpdate(state)
+ const available = hasAvailableUpdate(state)
const status = state.status
- // Auto-open the panel the moment a new version is first detected.
- useEffect(() => {
- if (status?.state === 'available') setOpen(true)
- }, [status?.state])
-
- if (!pending) return null
+ // Render nothing when there's no update, or the panel is closed (dismissed).
+ if (!available || !open) return null
const version = state.version
const downloading = status?.state === 'downloading'
const downloaded = status?.state === 'downloaded'
return (
-
- {/* Collapsed pill: a red-dotted button that re-opens the panel. */}
- {!open && (
-
- )}
-
- {open && (
-
)
}
diff --git a/desktop/src/renderer/src/layout/NavRail.tsx b/desktop/src/renderer/src/layout/NavRail.tsx
index 925c0525..5b4dbeab 100644
--- a/desktop/src/renderer/src/layout/NavRail.tsx
+++ b/desktop/src/renderer/src/layout/NavRail.tsx
@@ -28,7 +28,7 @@ import { t, getLang, setLang, Lang } from '../i18n'
import { useUIStore } from '../store/uiStore'
import { useTheme } from '../hooks/useTheme'
import { usePlatform } from '../hooks/usePlatform'
-import { useUpdateStore, hasPendingUpdate } from '../store/updateStore'
+import { useUpdateStore, hasPendingUpdate, hasAvailableUpdate } from '../store/updateStore'
import UpdateBanner from '../components/UpdateBanner'
// Fallback shown when app.getVersion() is unavailable (dev/web preview). Keep
@@ -83,7 +83,11 @@ const NavRail: React.FC = ({ onLangChange }) => {
const width = collapsed ? 'w-[56px]' : 'w-[208px]'
const updateState = useUpdateStore()
+ // Footer dot: hidden once dismissed for this version (user asked for this).
const pendingUpdate = hasPendingUpdate(updateState)
+ // Menu "check for update" dot: stays as long as an update actually exists,
+ // even after dismissing the footer badge.
+ const availableUpdate = hasAvailableUpdate(updateState)
const checking = updateState.status?.state === 'checking'
const [menuOpen, setMenuOpen] = useState(false)
@@ -148,7 +152,10 @@ const NavRail: React.FC = ({ onLangChange }) => {
const checkUpdate = () => {
setCheckedManually(true)
- window.electronAPI?.checkForUpdate?.()
+ // Re-open the update panel if an update is already known; also kicks a
+ // fresh check. Closing the menu so the re-opened panel is visible.
+ setMenuOpen(false)
+ updateState.recheck()
}
return (
@@ -211,8 +218,8 @@ const NavRail: React.FC = ({ onLangChange }) => {
{
setMenuOpen(false)
navigate('/logs')
diff --git a/desktop/src/renderer/src/store/updateStore.ts b/desktop/src/renderer/src/store/updateStore.ts
index 10863304..9af2d396 100644
--- a/desktop/src/renderer/src/store/updateStore.ts
+++ b/desktop/src/renderer/src/store/updateStore.ts
@@ -9,9 +9,19 @@ interface UpdateState {
percent: number
/** User dismissed the badge for this version (don't nag again until next). */
dismissedVersion: string | null
+ /** Whether the update panel is currently shown. Lifted here so the "check
+ * for update" menu item can re-open it on demand. */
+ panelOpen: boolean
setStatus: (s: UpdateStatus) => void
+ /** Dismiss the floating badge/panel for the current version (footer dot goes
+ * away), but keep the update itself known so the menu can still surface it. */
dismiss: () => void
+ openPanel: () => void
+ closePanel: () => void
+ /** User explicitly clicked "check for update": ask main to re-check, and if
+ * an update is already known, re-open the panel immediately (undismiss). */
+ recheck: () => void
// Actions proxied to the main process via the preload bridge.
download: () => void
@@ -23,16 +33,32 @@ export const useUpdateStore = create((set, get) => ({
version: null,
percent: 0,
dismissedVersion: null,
+ panelOpen: false,
setStatus: (s) =>
set(() => {
- if (s.state === 'available') return { status: s, version: s.version, percent: 0 }
+ // A newly detected version auto-opens the panel.
+ if (s.state === 'available') return { status: s, version: s.version, percent: 0, panelOpen: true }
if (s.state === 'downloading') return { status: s, percent: s.percent }
if (s.state === 'downloaded') return { status: s, version: s.version, percent: 100 }
return { status: s }
}),
- dismiss: () => set((st) => ({ dismissedVersion: st.version })),
+ dismiss: () => set((st) => ({ dismissedVersion: st.version, panelOpen: false })),
+ openPanel: () => set({ panelOpen: true }),
+ closePanel: () => set({ panelOpen: false }),
+
+ recheck: () => {
+ const st = get()
+ // If we already know about an available/downloaded update, just re-open the
+ // panel (clearing the dismiss for this version) instead of waiting on a
+ // network round-trip — the user asked to see it.
+ if (hasAvailableUpdate(st)) {
+ set({ dismissedVersion: null, panelOpen: true })
+ }
+ // Always kick a fresh check too (picks up newer versions / recovers errors).
+ window.electronAPI?.checkForUpdate?.()
+ },
download: () => window.electronAPI?.downloadUpdate?.(),
install: () => window.electronAPI?.installUpdate?.(),
@@ -45,11 +71,18 @@ export function initUpdateListener(): (() => void) | undefined {
})
}
-// Whether a new version should be surfaced (available/downloading/downloaded
-// and not dismissed for that version).
-export function hasPendingUpdate(state: UpdateState): boolean {
+// Whether an update exists at all (available/downloading/downloaded),
+// regardless of dismiss. Drives the "check for update" menu item's dot, which
+// should persist as long as an update is actually available.
+export function hasAvailableUpdate(state: UpdateState): boolean {
const s = state.status
if (!s) return false
- const active = s.state === 'available' || s.state === 'downloading' || s.state === 'downloaded'
- return active && state.dismissedVersion !== state.version
+ return s.state === 'available' || s.state === 'downloading' || s.state === 'downloaded'
+}
+
+// Whether a new version should be surfaced in the floating footer badge:
+// available and not dismissed for that version. Dismissing hides only this,
+// not the menu dot (hasAvailableUpdate).
+export function hasPendingUpdate(state: UpdateState): boolean {
+ return hasAvailableUpdate(state) && state.dismissedVersion !== state.version
}