diff --git a/packages/ui/src/components/update/OpenCodeUpdateToast.tsx b/packages/ui/src/components/update/OpenCodeUpdateToast.tsx index 116be1a4..9c7fe1a3 100644 --- a/packages/ui/src/components/update/OpenCodeUpdateToast.tsx +++ b/packages/ui/src/components/update/OpenCodeUpdateToast.tsx @@ -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).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 { diff --git a/packages/ui/src/components/update/__tests__/openCodeUpdateDedup.test.ts b/packages/ui/src/components/update/__tests__/openCodeUpdateDedup.test.ts new file mode 100644 index 00000000..64d8bf42 --- /dev/null +++ b/packages/ui/src/components/update/__tests__/openCodeUpdateDedup.test.ts @@ -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(''); + }); +}); diff --git a/packages/ui/src/components/update/openCodeUpdateDedup.ts b/packages/ui/src/components/update/openCodeUpdateDedup.ts new file mode 100644 index 00000000..be2cd54e --- /dev/null +++ b/packages/ui/src/components/update/openCodeUpdateDedup.ts @@ -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; +} + +/** + * 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(); +}; diff --git a/packages/ui/src/hooks/usePwaInstallPrompt.ts b/packages/ui/src/hooks/usePwaInstallPrompt.ts index 54e25c7d..7f2f829c 100644 --- a/packages/ui/src/hooks/usePwaInstallPrompt.ts +++ b/packages/ui/src/hooks/usePwaInstallPrompt.ts @@ -3,7 +3,8 @@ import { toast } from '@/components/ui'; import { isWebRuntime } from '@/lib/desktop'; import { usePwaDetection } from '@/hooks/usePwaDetection'; import { useI18n } from '@/lib/i18n'; -import { getSafeSessionStorage } from '@/stores/utils/safeStorage'; +import { getSafeSessionStorage, getSafeStorage } from '@/stores/utils/safeStorage'; +import { shouldShowPwaInstallToast } from '@/components/update/openCodeUpdateDedup'; type InstallPromptOutcome = 'accepted' | 'dismissed'; @@ -13,6 +14,7 @@ type BeforeInstallPromptEvent = Event & { }; const INSTALL_TOAST_SESSION_KEY = 'pwa-install-toast-shown'; +const INSTALL_TOAST_DISMISSED_KEY = 'pwa-install-toast-dismissed'; export const usePwaInstallPrompt = () => { const { browserTab } = usePwaDetection(); @@ -64,12 +66,14 @@ export const usePwaInstallPrompt = () => { installEvent.preventDefault(); deferredPrompt = installEvent; + const localStorage = getSafeStorage(); const sessionStorage = getSafeSessionStorage(); - if (sessionStorage.getItem(INSTALL_TOAST_SESSION_KEY) === 'true') { - return; - } - - if (installToastId !== null) { + const decision = shouldShowPwaInstallToast({ + dismissed: localStorage.getItem(INSTALL_TOAST_DISMISSED_KEY), + sessionShown: sessionStorage.getItem(INSTALL_TOAST_SESSION_KEY), + hasActiveToast: installToastId !== null, + }); + if (!decision) { return; } @@ -83,6 +87,13 @@ export const usePwaInstallPrompt = () => { void triggerInstall(); }, }, + cancel: { + label: tRef.current('pwa.installPrompt.dismiss'), + onClick: () => { + getSafeStorage().setItem(INSTALL_TOAST_DISMISSED_KEY, 'true'); + dismissInstallToast(); + }, + }, }); }; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 5b08f74a..f85abbfb 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -16,6 +16,7 @@ export const dict = { 'common.revealPath.fileManager': 'Open in File Manager', 'pwa.installPrompt.description': 'Install OpenChamber for quicker access', 'pwa.installPrompt.action': 'Install', + 'pwa.installPrompt.dismiss': 'Dismiss', 'pwa.installPrompt.started': 'Install started', 'pwa.installPrompt.installed': 'OpenChamber installed', 'layout.mainTab.chat': 'Chat', @@ -2210,6 +2211,7 @@ export const dict = { 'opencodeUpdate.toast.available.title': 'OpenCode update available', 'opencodeUpdate.toast.available.description': 'Version {version} is ready to install.', 'opencodeUpdate.toast.actions.update': 'Update', + 'opencodeUpdate.toast.actions.dismiss': 'Dismiss', 'opencodeUpdate.toast.actions.reload': 'Reload OpenCode', 'opencodeUpdate.toast.upgrading.title': 'Updating OpenCode...', 'opencodeUpdate.toast.upgrading.description': 'Keep OpenChamber open.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index be3dd967..a0269d6d 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -17,6 +17,7 @@ export const dict: Record = { "common.revealPath.fileManager": "Abrir en gestor de archivos", "pwa.installPrompt.description": "Instala OpenChamber para acceder más rápido", "pwa.installPrompt.action": "Instalar", + "pwa.installPrompt.dismiss": "Descartar", "pwa.installPrompt.started": "Instalación iniciada", "pwa.installPrompt.installed": "OpenChamber se instaló", "layout.mainTab.chat": "Chat", @@ -2176,6 +2177,7 @@ export const dict: Record = { "opencodeUpdate.toast.available.title": "Actualización de OpenCode disponible", "opencodeUpdate.toast.available.description": "La versión {version} está lista para instalar.", "opencodeUpdate.toast.actions.update": "Actualizar", + "opencodeUpdate.toast.actions.dismiss": "Descartar", "opencodeUpdate.toast.actions.reload": "Recargar OpenCode", "opencodeUpdate.toast.upgrading.title": "Actualizando OpenCode...", "opencodeUpdate.toast.upgrading.description": "Mantén OpenChamber abierto.", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index ab1a9b87..f8ef57f6 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -17,6 +17,7 @@ export const dict: Record = { 'common.revealPath.fileManager': '파일 관리자에서 열기', 'pwa.installPrompt.description': '더 빠르게 접근하려면 OpenChamber를 설치하세요', 'pwa.installPrompt.action': '설치', + 'pwa.installPrompt.dismiss': '닫기', 'pwa.installPrompt.started': '설치가 시작되었습니다', 'pwa.installPrompt.installed': 'OpenChamber가 설치되었습니다', 'layout.mainTab.chat': '채팅', @@ -2210,6 +2211,7 @@ export const dict: Record = { 'opencodeUpdate.toast.available.title': 'OpenCode 업데이트 사용 가능', 'opencodeUpdate.toast.available.description': '버전 {version}을 설치할 수 있습니다.', 'opencodeUpdate.toast.actions.update': '업데이트', + 'opencodeUpdate.toast.actions.dismiss': '닫기', 'opencodeUpdate.toast.actions.reload': 'OpenCode 다시 로드', 'opencodeUpdate.toast.upgrading.title': 'OpenCode 업데이트 중...', 'opencodeUpdate.toast.upgrading.description': 'OpenChamber를 열어 두세요.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index d3cef39b..f2aa3233 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -18,6 +18,7 @@ export const dict: Record = { 'common.revealPath.fileManager': 'Otwórz w Menedżerze plików', 'pwa.installPrompt.description': 'Zainstaluj OpenChamber, aby mieć szybszy dostęp', 'pwa.installPrompt.action': 'Zainstaluj', + 'pwa.installPrompt.dismiss': 'Odrzuć', 'pwa.installPrompt.started': 'Instalacja rozpoczęta', 'pwa.installPrompt.installed': 'OpenChamber został zainstalowany', 'layout.mainTab.chat': 'Czat', @@ -2191,6 +2192,7 @@ export const dict: Record = { 'opencodeUpdate.toast.available.title': 'Dostępna aktualizacja OpenCode', 'opencodeUpdate.toast.available.description': 'Wersja {version} jest gotowa do instalacji.', 'opencodeUpdate.toast.actions.update': 'Aktualizuj', + 'opencodeUpdate.toast.actions.dismiss': 'Odrzuć', 'opencodeUpdate.toast.actions.reload': 'Przeładuj OpenCode', 'opencodeUpdate.toast.upgrading.title': 'Aktualizowanie OpenCode...', 'opencodeUpdate.toast.upgrading.description': 'Pozostaw OpenChamber otwarte.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 3bcc5fc7..05e75d04 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -17,6 +17,7 @@ export const dict: Record = { "common.revealPath.fileManager": "Abrir no gerenciador de arquivos", "pwa.installPrompt.description": "Instale o OpenChamber para acessar mais rápido", "pwa.installPrompt.action": "Instalar", + "pwa.installPrompt.dismiss": "Dispensar", "pwa.installPrompt.started": "Instalação iniciada", "pwa.installPrompt.installed": "OpenChamber instalado", "layout.mainTab.chat": "Chat", @@ -2176,6 +2177,7 @@ export const dict: Record = { "opencodeUpdate.toast.available.title": "Atualização do OpenCode disponível", "opencodeUpdate.toast.available.description": "A versão {version} está pronta para instalar.", "opencodeUpdate.toast.actions.update": "Atualizar", + "opencodeUpdate.toast.actions.dismiss": "Dispensar", "opencodeUpdate.toast.actions.reload": "Recarregar OpenCode", "opencodeUpdate.toast.upgrading.title": "Atualizando OpenCode...", "opencodeUpdate.toast.upgrading.description": "Mantenha o OpenChamber aberto.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index a43bbeb9..d3292661 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -17,6 +17,7 @@ export const dict: Record = { "common.revealPath.fileManager": "Відкрити у файловому менеджері", "pwa.installPrompt.description": "Установіть OpenChamber для швидшого доступу", "pwa.installPrompt.action": "Установити", + "pwa.installPrompt.dismiss": "Сховати", "pwa.installPrompt.started": "Установлення розпочато", "pwa.installPrompt.installed": "OpenChamber встановлено", "layout.mainTab.chat": "Чат", @@ -2176,6 +2177,7 @@ export const dict: Record = { "opencodeUpdate.toast.available.title": "Доступне оновлення OpenCode", "opencodeUpdate.toast.available.description": "Версія {version} готова до встановлення.", "opencodeUpdate.toast.actions.update": "Оновити", + "opencodeUpdate.toast.actions.dismiss": "Сховати", "opencodeUpdate.toast.actions.reload": "Перезавантажити OpenCode", "opencodeUpdate.toast.upgrading.title": "Оновлення OpenCode...", "opencodeUpdate.toast.upgrading.description": "Залиште OpenChamber відкритим.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index f5c4759e..721cb7b7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -17,6 +17,7 @@ export const dict: Record = { 'common.revealPath.fileManager': '在文件管理器中打开', 'pwa.installPrompt.description': '安装 OpenChamber 以便更快访问', 'pwa.installPrompt.action': '安装', + 'pwa.installPrompt.dismiss': '忽略', 'pwa.installPrompt.started': '已开始安装', 'pwa.installPrompt.installed': 'OpenChamber 已安装', 'layout.mainTab.chat': '聊天', @@ -2176,6 +2177,7 @@ export const dict: Record = { 'opencodeUpdate.toast.available.title': 'OpenCode 有可用更新', 'opencodeUpdate.toast.available.description': '版本 {version} 已可安装。', 'opencodeUpdate.toast.actions.update': '更新', + 'opencodeUpdate.toast.actions.dismiss': '忽略', 'opencodeUpdate.toast.actions.reload': '重载 OpenCode', 'opencodeUpdate.toast.upgrading.title': '正在更新 OpenCode...', 'opencodeUpdate.toast.upgrading.description': '请保持 OpenChamber 打开。',