From 23c0578bd75d9ef8e7a3ac8d90eda2db157978eb Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Tue, 2 Jun 2026 10:26:52 -0500 Subject: [PATCH] Merge pull request #37462 from NousResearch/bb/desktop-update-throttle fix(desktop): throttle the update-available toast --- apps/desktop/src/store/updates.test.ts | 77 ++++++++++++++++++++++++++ apps/desktop/src/store/updates.ts | 43 ++++++++------ 2 files changed, 102 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/store/updates.test.ts diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts new file mode 100644 index 000000000..d013a9359 --- /dev/null +++ b/apps/desktop/src/store/updates.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { DesktopUpdateStatus } from '@/global' + +const storage = new Map() + +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 => ({ + 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() + }) +}) diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index 603b1c742..e28f1e74f 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -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' }) }