Fix/dismissible infinite toasts (#1319)

* fix: make PWA install and OpenCode update toasts dismissible

Both 'Install OpenChamber' and 'OpenCode update available' toasts use
duration: Infinity with no close affordance, so they persist on screen
until the user accepts (install/update) or reloads the tab. For users
who do not want to install the PWA or upgrade right now, this is
intrusive and there is no opt-out.

Add a Dismiss button (sonner cancel action) to both toasts. When the
user dismisses:

- PWA: persist a flag in localStorage so the prompt does not reappear
  on future sessions. Accepting Install still works as before.
- OpenCode update: persist the dismissed version in localStorage. The
  toast will appear again only when a newer version becomes available.

Adds new i18n keys pwa.installPrompt.dismiss and
opencodeUpdate.toast.actions.dismiss across all seven locales (en,
es, ko, pl, pt-BR, uk, zh-CN).

* test: extract toast dedup helpers and cover with 28 unit tests

Lift the dismissal-decision logic out of usePwaInstallPrompt and
OpenCodeUpdateToast into a React-free sibling module so it can be
unit-tested directly. The React surfaces remain sole owners of side
effects (storage writes, toast.info, event listeners); the new module
only answers 'should we show?'.

New module openCodeUpdateDedup.ts exposes four helpers:
- shouldShowPwaInstallToast(input) - three gates: dismissed,
  sessionShown, hasActiveToast.
- shouldShowOpenCodeUpdateToast(input) - empty version, seen set,
  dismissed===version gates; a different dismissed version lets the
  toast resurface for the new release.
- resolveOpenCodeUpdateVersion(detail) - parses CustomEvent payloads
  defensively (null/non-object/non-string -> '').
- resolveOpenCodeUpgradeStatusVersion(status) - parses upgrade status
  payloads (status falsy / available!==true / latestVersion non-string
  -> '').

Consumers now call the helpers and only run the side-effect when the
decision is true. Behaviour is unchanged.

Coverage: 28 tests via bun:test, 33 expects, all pass first try.
This commit is contained in:
Roberto Bertó
2026-05-19 16:57:23 +03:00
committed by GitHub
parent bb87111c49
commit 46bef9b0c7
11 changed files with 395 additions and 22 deletions
@@ -4,17 +4,19 @@ import { toast } from '@/components/ui/toast';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
type OpenCodeUpdateAvailableEvent = CustomEvent<{ version?: unknown }>;
type OpenCodeUpgradeStatus = {
available?: boolean | null;
latestVersion?: string | null;
};
import { getSafeStorage } from '@/stores/utils/safeStorage';
import {
resolveOpenCodeUpdateVersion,
resolveOpenCodeUpgradeStatusVersion,
shouldShowOpenCodeUpdateToast,
type OpenCodeUpgradeStatusLike,
} from './openCodeUpdateDedup';
const UPDATE_TOAST_ID = 'opencode-update-available';
const UPGRADE_TOAST_ID = 'opencode-upgrade-progress';
const INITIAL_CHECK_DELAY_MS = 5_000;
const CHECK_RETRY_DELAYS_MS = [10_000, 60_000];
const UPDATE_TOAST_DISMISSED_VERSION_KEY = 'opencode-update-toast-dismissed-version';
export const OpenCodeUpdateToast: React.FC = () => {
const { t } = useI18n();
@@ -87,14 +89,19 @@ export const OpenCodeUpdateToast: React.FC = () => {
React.useEffect(() => {
const showUpdateAvailableToast = (version: string) => {
// Upstream setting wins over our dedup logic: if user disabled
// OpenCode update notifications, dismiss any active toast and bail
// before consulting dedup state.
if (!useUIStore.getState().showOpenCodeUpdateNotifications) {
toast.dismiss(UPDATE_TOAST_ID);
return;
}
if (!version) {
return;
}
if (seenVersionsRef.current.has(version)) {
const decision = shouldShowOpenCodeUpdateToast({
version,
dismissedVersion: getSafeStorage().getItem(UPDATE_TOAST_DISMISSED_VERSION_KEY),
seenVersions: seenVersionsRef.current,
});
if (!decision) {
return;
}
seenVersionsRef.current.add(version);
@@ -107,13 +114,18 @@ export const OpenCodeUpdateToast: React.FC = () => {
label: t('opencodeUpdate.toast.actions.update'),
onClick: runUpgrade,
},
cancel: {
label: t('opencodeUpdate.toast.actions.dismiss'),
onClick: () => {
getSafeStorage().setItem(UPDATE_TOAST_DISMISSED_VERSION_KEY, version);
toast.dismiss(UPDATE_TOAST_ID);
},
},
});
};
const onUpdateAvailable = (event: Event) => {
const version = typeof (event as OpenCodeUpdateAvailableEvent).detail?.version === 'string'
? String((event as OpenCodeUpdateAvailableEvent).detail.version).trim()
: '';
const version = resolveOpenCodeUpdateVersion((event as CustomEvent<unknown>).detail);
showUpdateAvailableToast(version);
};
@@ -124,9 +136,9 @@ export const OpenCodeUpdateToast: React.FC = () => {
try {
const response = await fetch('/api/opencode/upgrade-status', { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error(response.statusText || 'OpenCode upgrade status check failed');
const status = await response.json().catch(() => null) as OpenCodeUpgradeStatus | null;
const version = typeof status?.latestVersion === 'string' ? status.latestVersion.trim() : '';
if (!cancelled && status?.available === true && version) {
const status = await response.json().catch(() => null) as OpenCodeUpgradeStatusLike | null;
const version = resolveOpenCodeUpgradeStatusVersion(status);
if (!cancelled && version) {
showUpdateAvailableToast(version);
}
} catch {
@@ -0,0 +1,240 @@
import { describe, test, expect } from 'bun:test';
import {
resolveOpenCodeUpdateVersion,
resolveOpenCodeUpgradeStatusVersion,
shouldShowOpenCodeUpdateToast,
shouldShowPwaInstallToast,
} from '../openCodeUpdateDedup';
describe('shouldShowPwaInstallToast', () => {
test('returns true when nothing blocks the toast', () => {
expect(
shouldShowPwaInstallToast({
dismissed: null,
sessionShown: null,
hasActiveToast: false,
}),
).toBe(true);
});
test('returns false when persistent dismissal is set', () => {
expect(
shouldShowPwaInstallToast({
dismissed: 'true',
sessionShown: null,
hasActiveToast: false,
}),
).toBe(false);
});
test('returns false when the toast was already shown in this session', () => {
expect(
shouldShowPwaInstallToast({
dismissed: null,
sessionShown: 'true',
hasActiveToast: false,
}),
).toBe(false);
});
test('returns false when the effect already owns an active toast', () => {
expect(
shouldShowPwaInstallToast({
dismissed: null,
sessionShown: null,
hasActiveToast: true,
}),
).toBe(false);
});
test('treats non-"true" storage values as unset', () => {
expect(
shouldShowPwaInstallToast({
dismissed: 'false',
sessionShown: '0',
hasActiveToast: false,
}),
).toBe(true);
});
test('persistent dismissal wins even when session marker is also set', () => {
expect(
shouldShowPwaInstallToast({
dismissed: 'true',
sessionShown: 'true',
hasActiveToast: false,
}),
).toBe(false);
});
});
describe('shouldShowOpenCodeUpdateToast', () => {
test('returns true for a fresh version with no dismissal and an empty seen set', () => {
expect(
shouldShowOpenCodeUpdateToast({
version: '1.16.0',
dismissedVersion: null,
seenVersions: new Set(),
}),
).toBe(true);
});
test('returns false for an empty version string', () => {
expect(
shouldShowOpenCodeUpdateToast({
version: '',
dismissedVersion: null,
seenVersions: new Set(),
}),
).toBe(false);
});
test('returns false when the version was already surfaced in this session', () => {
expect(
shouldShowOpenCodeUpdateToast({
version: '1.16.0',
dismissedVersion: null,
seenVersions: new Set(['1.16.0']),
}),
).toBe(false);
});
test('returns false when the dismissed version matches the incoming version', () => {
expect(
shouldShowOpenCodeUpdateToast({
version: '1.16.0',
dismissedVersion: '1.16.0',
seenVersions: new Set(),
}),
).toBe(false);
});
test('returns true when a different version was previously dismissed', () => {
expect(
shouldShowOpenCodeUpdateToast({
version: '1.17.0',
dismissedVersion: '1.16.0',
seenVersions: new Set(),
}),
).toBe(true);
});
test('treats null dismissedVersion as no prior dismissal', () => {
expect(
shouldShowOpenCodeUpdateToast({
version: '1.16.0',
dismissedVersion: null,
seenVersions: new Set(['1.15.0']),
}),
).toBe(true);
});
test('seen set blocks even when dismissed version differs', () => {
expect(
shouldShowOpenCodeUpdateToast({
version: '1.16.0',
dismissedVersion: '1.15.0',
seenVersions: new Set(['1.16.0']),
}),
).toBe(false);
});
});
describe('resolveOpenCodeUpdateVersion', () => {
test('returns the trimmed version when detail.version is a string', () => {
expect(resolveOpenCodeUpdateVersion({ version: '1.16.0' })).toBe('1.16.0');
});
test('trims surrounding whitespace from a string version', () => {
expect(resolveOpenCodeUpdateVersion({ version: ' 1.16.0 ' })).toBe('1.16.0');
});
test('returns empty string when detail is null', () => {
expect(resolveOpenCodeUpdateVersion(null)).toBe('');
});
test('returns empty string when detail is undefined', () => {
expect(resolveOpenCodeUpdateVersion(undefined)).toBe('');
});
test('returns empty string when detail is not an object', () => {
expect(resolveOpenCodeUpdateVersion('1.16.0')).toBe('');
expect(resolveOpenCodeUpdateVersion(42)).toBe('');
expect(resolveOpenCodeUpdateVersion(true)).toBe('');
});
test('returns empty string when the version field is missing', () => {
expect(resolveOpenCodeUpdateVersion({})).toBe('');
});
test('returns empty string when the version field is non-string', () => {
expect(resolveOpenCodeUpdateVersion({ version: 116 })).toBe('');
expect(resolveOpenCodeUpdateVersion({ version: null })).toBe('');
expect(resolveOpenCodeUpdateVersion({ version: { major: 1 } })).toBe('');
});
});
describe('resolveOpenCodeUpgradeStatusVersion', () => {
test('returns the trimmed latestVersion when available is true', () => {
expect(
resolveOpenCodeUpgradeStatusVersion({
available: true,
latestVersion: '1.16.0',
}),
).toBe('1.16.0');
});
test('trims surrounding whitespace from latestVersion', () => {
expect(
resolveOpenCodeUpgradeStatusVersion({
available: true,
latestVersion: ' 1.16.0 ',
}),
).toBe('1.16.0');
});
test('returns empty string when status is null', () => {
expect(resolveOpenCodeUpgradeStatusVersion(null)).toBe('');
});
test('returns empty string when status is undefined', () => {
expect(resolveOpenCodeUpgradeStatusVersion(undefined)).toBe('');
});
test('returns empty string when available is false', () => {
expect(
resolveOpenCodeUpgradeStatusVersion({
available: false,
latestVersion: '1.16.0',
}),
).toBe('');
});
test('returns empty string when available is missing or null', () => {
expect(
resolveOpenCodeUpgradeStatusVersion({
latestVersion: '1.16.0',
}),
).toBe('');
expect(
resolveOpenCodeUpgradeStatusVersion({
available: null,
latestVersion: '1.16.0',
}),
).toBe('');
});
test('returns empty string when latestVersion is missing', () => {
expect(resolveOpenCodeUpgradeStatusVersion({ available: true })).toBe('');
});
test('returns empty string when latestVersion is non-string', () => {
expect(
resolveOpenCodeUpgradeStatusVersion({
available: true,
latestVersion: null,
}),
).toBe('');
});
});
@@ -0,0 +1,96 @@
/**
* Pure decision helpers for the OpenCode update toast and PWA install toast.
*
* Extracted from `OpenCodeUpdateToast.tsx` and `usePwaInstallPrompt.ts` so the
* dedup decisions can be unit-tested without a DOM, storage, or React. The
* React surfaces remain the sole owners of side effects (storage writes,
* `toast.info`, event listeners). This module only answers the question
* "given these inputs, should we show the toast?".
*
* Exposed for unit testing. Not part of the stable consumer surface.
*/
export interface PwaInstallToastDecisionInput {
/** Persistent localStorage entry: `'true'` when the user dismissed once. */
readonly dismissed: string | null;
/** Session-scoped sessionStorage flag set the first time the toast is shown in this tab. */
readonly sessionShown: string | null;
/** Whether the current React effect already holds a toast id. */
readonly hasActiveToast: boolean;
}
/**
* Returns `true` if the PWA install prompt toast should be shown for the
* incoming `beforeinstallprompt` event.
*
* The decision composes three gates (any failure short-circuits):
* 1. Persistent dismissal wins for all future visits.
* 2. Per-tab dedup avoids re-showing inside the same browsing session.
* 3. Re-entrancy guard prevents stacking when the effect already owns one.
*/
export const shouldShowPwaInstallToast = (input: PwaInstallToastDecisionInput): boolean => {
if (input.dismissed === 'true') return false;
if (input.sessionShown === 'true') return false;
if (input.hasActiveToast) return false;
return true;
};
export interface OpenCodeUpdateToastDecisionInput {
/** Version string reported by the server (already trimmed by the caller). */
readonly version: string;
/** Most recent version the user explicitly dismissed, or `null` if none. */
readonly dismissedVersion: string | null;
/** Set of versions already surfaced in this tab session. */
readonly seenVersions: ReadonlySet<string>;
}
/**
* Returns `true` if the OpenCode update toast should be shown for `version`.
*
* Empty/whitespace-only versions short-circuit to `false`. A non-null
* `dismissedVersion` matching the incoming version also short-circuits; a
* different `dismissedVersion` means a newer release has appeared since the
* last dismissal and the toast surfaces again.
*/
export const shouldShowOpenCodeUpdateToast = (
input: OpenCodeUpdateToastDecisionInput,
): boolean => {
if (!input.version) return false;
if (input.seenVersions.has(input.version)) return false;
if (input.dismissedVersion !== null && input.dismissedVersion === input.version) return false;
return true;
};
/**
* Coerces the `detail.version` carried by an `openchamber:opencode-update-available`
* CustomEvent into a trimmed string, or returns `''` when the payload is
* missing or shaped unexpectedly.
*
* Only `string` is accepted; numeric or boolean payloads are rejected because
* downstream callers compare versions by literal equality.
*/
export const resolveOpenCodeUpdateVersion = (detail: unknown): string => {
if (detail === null || typeof detail !== 'object') return '';
const candidate = (detail as { version?: unknown }).version;
if (typeof candidate !== 'string') return '';
return candidate.trim();
};
export interface OpenCodeUpgradeStatusLike {
readonly available?: boolean | null;
readonly latestVersion?: string | null;
}
/**
* Pulls the candidate version out of an `/api/opencode/upgrade-status` JSON
* payload. Returns `''` when the payload is missing the field, has the wrong
* type, or reports `available !== true`.
*/
export const resolveOpenCodeUpgradeStatusVersion = (
status: OpenCodeUpgradeStatusLike | null | undefined,
): string => {
if (!status) return '';
if (status.available !== true) return '';
if (typeof status.latestVersion !== 'string') return '';
return status.latestVersion.trim();
};