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:
@@ -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();
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -17,6 +17,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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.",
|
||||
|
||||
@@ -17,6 +17,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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를 열어 두세요.',
|
||||
|
||||
@@ -18,6 +18,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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.',
|
||||
|
||||
@@ -17,6 +17,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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.",
|
||||
|
||||
@@ -17,6 +17,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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 відкритим.",
|
||||
|
||||
@@ -17,6 +17,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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 打开。',
|
||||
|
||||
Reference in New Issue
Block a user