Merge pull request #37462 from NousResearch/bb/desktop-update-throttle
fix(desktop): throttle the update-available toast
This commit is contained in:
77
apps/desktop/src/store/updates.test.ts
Normal file
77
apps/desktop/src/store/updates.test.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DesktopUpdateStatus } from '@/global'
|
||||
|
||||
const storage = new Map<string, string>()
|
||||
|
||||
vi.mock('@/lib/storage', () => ({
|
||||
persistString: (key: string, value: null | string) => {
|
||||
if (value === null) {
|
||||
storage.delete(key)
|
||||
} else {
|
||||
storage.set(key, value)
|
||||
}
|
||||
},
|
||||
storedString: (key: string) => storage.get(key) ?? null
|
||||
}))
|
||||
|
||||
const notifySpy = vi.fn()
|
||||
const dismissSpy = vi.fn()
|
||||
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
notify: (...args: unknown[]) => notifySpy(...args),
|
||||
dismissNotification: (...args: unknown[]) => dismissSpy(...args)
|
||||
}))
|
||||
|
||||
const { maybeNotifyUpdateAvailable } = await import('./updates')
|
||||
|
||||
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
|
||||
supported: true,
|
||||
behind: 3,
|
||||
targetSha: 'sha-a',
|
||||
fetchedAt: 0,
|
||||
...over
|
||||
})
|
||||
|
||||
const lastToast = () => notifySpy.mock.calls.at(-1)?.[0] as { onDismiss: () => void }
|
||||
|
||||
describe('maybeNotifyUpdateAvailable', () => {
|
||||
beforeEach(() => {
|
||||
storage.clear()
|
||||
notifySpy.mockClear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows when an update is available and not snoozed', () => {
|
||||
maybeNotifyUpdateAvailable(status())
|
||||
expect(notifySpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stays quiet for new commits once the toast was closed', () => {
|
||||
maybeNotifyUpdateAvailable(status())
|
||||
lastToast().onDismiss() // user closes it → cooldown starts
|
||||
notifySpy.mockClear()
|
||||
|
||||
// A different commit lands while still within the cooldown window.
|
||||
maybeNotifyUpdateAvailable(status({ targetSha: 'sha-b', behind: 9 }))
|
||||
expect(notifySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-shows once the cooldown elapses', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
|
||||
maybeNotifyUpdateAvailable(status())
|
||||
lastToast().onDismiss()
|
||||
notifySpy.mockClear()
|
||||
|
||||
vi.setSystemTime(25 * 60 * 60 * 1000) // > 24h cooldown
|
||||
maybeNotifyUpdateAvailable(status({ targetSha: 'sha-b' }))
|
||||
expect(notifySpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does nothing when already up to date', () => {
|
||||
maybeNotifyUpdateAvailable(status({ behind: 0 }))
|
||||
expect(notifySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -48,7 +48,22 @@ export const setUpdateOverlayOpen = (open: boolean) => $updateOverlayOpen.set(op
|
||||
export const resetUpdateApplyState = () => $updateApply.set(IDLE)
|
||||
|
||||
const UPDATE_TOAST_ID = 'desktop-update-available'
|
||||
const UPDATE_TOAST_DISMISSED_KEY = 'hermes:update-toast-dismissed-sha'
|
||||
// Time-based snooze instead of per-sha dismissal: this repo lands ~100 commits
|
||||
// a day, so a "don't show this exact sha again" guard re-popped the toast on
|
||||
// every new commit. We instead suppress the toast for a cooldown window that
|
||||
// (re)starts whenever the user closes it.
|
||||
const UPDATE_TOAST_SNOOZE_KEY = 'hermes:update-toast-snooze-until'
|
||||
const UPDATE_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
function snoozeUpdateToast(): void {
|
||||
persistString(UPDATE_TOAST_SNOOZE_KEY, String(Date.now() + UPDATE_TOAST_COOLDOWN_MS))
|
||||
}
|
||||
|
||||
function isUpdateToastSnoozed(): boolean {
|
||||
const until = Number(storedString(UPDATE_TOAST_SNOOZE_KEY) || 0)
|
||||
|
||||
return Number.isFinite(until) && Date.now() < until
|
||||
}
|
||||
|
||||
// Must match tui_gateway's DESKTOP_BACKEND_CONTRACT that this build was written
|
||||
// against. The backend reports its own value in session runtime info; a lower
|
||||
@ -74,25 +89,18 @@ export function reportBackendContract(contract: number | undefined): void {
|
||||
durationMs: 0,
|
||||
id: SKEW_TOAST_ID,
|
||||
kind: 'warning',
|
||||
message:
|
||||
'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
|
||||
message: 'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
|
||||
title: 'Backend out of date'
|
||||
})
|
||||
}
|
||||
|
||||
function markToastDismissed(sha: string | undefined) {
|
||||
if (sha) {
|
||||
persistString(UPDATE_TOAST_DISMISSED_KEY, sha)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a one-shot toast the first time we see a particular target commit so
|
||||
* users don't have to notice the status-bar version pill turning colors.
|
||||
* Dismissal is remembered per-target-sha so the toast doesn't keep popping
|
||||
* back for the same update across restarts.
|
||||
* Fire a toast when an update is available, at most once per cooldown window.
|
||||
* Closing the toast — dismissing it or opening the updates window from it —
|
||||
* (re)starts the cooldown, so a busy upstream branch doesn't re-spam the user
|
||||
* on every new commit. The snooze is persisted, so it survives relaunches too.
|
||||
*/
|
||||
function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
if (!status || status.supported === false || status.error || !status.targetSha) {
|
||||
return
|
||||
}
|
||||
@ -101,7 +109,7 @@ function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (storedString(UPDATE_TOAST_DISMISSED_KEY) === status.targetSha) {
|
||||
if (isUpdateToastSnoozed()) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -110,13 +118,12 @@ function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
}
|
||||
|
||||
const behind = status.behind ?? 0
|
||||
const targetSha = status.targetSha
|
||||
|
||||
notify({
|
||||
action: {
|
||||
label: "See what's new",
|
||||
onClick: () => {
|
||||
markToastDismissed(targetSha)
|
||||
snoozeUpdateToast()
|
||||
openUpdatesWindow()
|
||||
}
|
||||
},
|
||||
@ -124,7 +131,7 @@ function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
id: UPDATE_TOAST_ID,
|
||||
kind: 'info',
|
||||
message: `${behind} new change${behind === 1 ? '' : 's'} available.`,
|
||||
onDismiss: () => markToastDismissed(targetSha),
|
||||
onDismiss: () => snoozeUpdateToast(),
|
||||
title: 'Update ready'
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user