diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts index 913e4fb11ee..bb74cd650c1 100644 --- a/apps/desktop/src/store/updates.test.ts +++ b/apps/desktop/src/store/updates.test.ts @@ -41,7 +41,7 @@ vi.mock('@/hermes', () => ({ getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args) })) -const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus, applyBackendUpdate, $backendUpdateApply } = await import('./updates') +const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus, applyBackendUpdate, $backendUpdateApply, reportBackendContract } = await import('./updates') const { setConnection } = await import('./session') const status = (over: Partial = {}): DesktopUpdateStatus => ({ @@ -95,6 +95,61 @@ describe('maybeNotifyUpdateAvailable', () => { }) }) +describe('reportBackendContract', () => { + beforeEach(() => { + storage.clear() + notifySpy.mockClear() + dismissSpy.mockClear() + vi.useRealTimers() + }) + + it('dismisses the toast when the backend meets the contract', () => { + reportBackendContract(2) + expect(dismissSpy).toHaveBeenCalledWith('backend-contract-skew') + expect(notifySpy).not.toHaveBeenCalled() + }) + + it('warns when the backend is behind (or reports no contract)', () => { + reportBackendContract(undefined) + expect(notifySpy).toHaveBeenCalledTimes(1) + reportBackendContract(1) + expect(notifySpy).toHaveBeenCalledTimes(2) + }) + + it('stays quiet on later session opens once the user closed it', () => { + reportBackendContract(1) + lastToast().onDismiss() // user closes it → cooldown starts + notifySpy.mockClear() + + // Opening another pre-existing session re-runs the check within cooldown. + reportBackendContract(1) + expect(notifySpy).not.toHaveBeenCalled() + }) + + it('reminds again after the cooldown elapses', () => { + vi.useFakeTimers() + vi.setSystemTime(0) + + reportBackendContract(1) + lastToast().onDismiss() + notifySpy.mockClear() + + vi.setSystemTime(25 * 60 * 60 * 1000) // > 24h cooldown + reportBackendContract(1) + expect(notifySpy).toHaveBeenCalledTimes(1) + }) + + it('clears the snooze once the backend catches up, so a regression warns again', () => { + reportBackendContract(1) + lastToast().onDismiss() + notifySpy.mockClear() + + reportBackendContract(2) // backend updated → satisfied, snooze cleared + reportBackendContract(1) // a later regression must warn immediately + expect(notifySpy).toHaveBeenCalledTimes(1) + }) +}) + describe('checkBackendUpdates', () => { beforeEach(() => { storage.clear() diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index 8b838d4aacd..b9338314e70 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -91,26 +91,60 @@ function isUpdateToastSnoozed(): boolean { // v2: requires the file.attach RPC (remote-gateway non-image file upload). const REQUIRED_BACKEND_CONTRACT = 2 const SKEW_TOAST_ID = 'backend-contract-skew' +// The contract check runs on every session.resume (applyRuntimeInfo), so +// without a snooze the warning re-popped on every thread the user opened, even +// right after they closed it. Mirror the update toast: persist a cooldown when +// the user dismisses it. It still reminds again after the window if the backend +// is still behind, and clears immediately once the backend catches up. +const SKEW_TOAST_SNOOZE_KEY = 'hermes:backend-skew-toast-snooze-until' +const SKEW_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000 + +function snoozeSkewToast(): void { + persistString(SKEW_TOAST_SNOOZE_KEY, String(Date.now() + SKEW_TOAST_COOLDOWN_MS)) +} + +function isSkewToastSnoozed(): boolean { + const until = Number(storedString(SKEW_TOAST_SNOOZE_KEY) || 0) + + return Number.isFinite(until) && Date.now() < until +} /** * Guard against a desktop GUI talking to a backend that predates its contract * (e.g. a bb/gui-built app pointed at a `main` checkout). Rather than failing - * cryptically downstream, surface a persistent warning with a one-click align - * that runs the normal update flow (which self-heals to the right branch). + * cryptically downstream, surface a warning with a one-click align that runs + * the normal update flow (which self-heals to the right branch). + * + * Runs on every session open; closing the toast snoozes it for a cooldown so it + * doesn't nag on every thread switch. */ export function reportBackendContract(contract: number | undefined): void { if ((contract ?? 0) >= REQUIRED_BACKEND_CONTRACT) { dismissNotification(SKEW_TOAST_ID) + // Backend caught up — forget any prior snooze so a future regression warns + // immediately rather than staying silent for the rest of the window. + persistString(SKEW_TOAST_SNOOZE_KEY, null) return } + if (isSkewToastSnoozed()) { + return + } + notify({ - action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyBackendUpdate() }, + action: { + label: translateNow('notifications.updateHermes'), + onClick: () => { + snoozeSkewToast() + void applyBackendUpdate() + } + }, durationMs: 0, id: SKEW_TOAST_ID, kind: 'warning', message: translateNow('notifications.backendOutOfDateMessage'), + onDismiss: () => snoozeSkewToast(), title: translateNow('notifications.backendOutOfDateTitle') }) }