= ({
OpenChamber
- {displayVersion && (
-
- {t('aboutDialog.versionLabel', { version: displayVersion })}
-
- )}
+
+ {displayVersion && (
+
{t('aboutDialog.openChamberVersionLabel', { version: displayVersion })}
+ )}
+ {openCodeVersion && (
+
{t('aboutDialog.openCodeVersionLabel', { version: openCodeVersion })}
+ )}
+
diff --git a/packages/ui/src/components/update/OpenCodeUpdateToast.tsx b/packages/ui/src/components/update/OpenCodeUpdateToast.tsx
new file mode 100644
index 00000000..a4b26f58
--- /dev/null
+++ b/packages/ui/src/components/update/OpenCodeUpdateToast.tsx
@@ -0,0 +1,139 @@
+import * as React from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { toast } from '@/components/ui/toast';
+import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
+import { useI18n } from '@/lib/i18n';
+
+type OpenCodeUpdateAvailableEvent = CustomEvent<{ version?: unknown }>;
+type OpenCodeUpgradeStatus = {
+ available?: boolean | null;
+ latestVersion?: string | null;
+};
+
+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];
+
+export const OpenCodeUpdateToast: React.FC = () => {
+ const { t } = useI18n();
+ const seenVersionsRef = React.useRef(new Set());
+ const upgradingRef = React.useRef(false);
+
+ const reloadOpenCode = React.useCallback(() => {
+ toast.dismiss(UPGRADE_TOAST_ID);
+ void reloadOpenCodeConfiguration({
+ message: t('opencodeUpdate.toast.reload.message'),
+ mode: 'projects',
+ scopes: ['all'],
+ });
+ }, [t]);
+
+ const runUpgrade = React.useCallback(async () => {
+ if (upgradingRef.current) return;
+ upgradingRef.current = true;
+ toast.dismiss(UPDATE_TOAST_ID);
+ toast.message(t('opencodeUpdate.toast.upgrading.title'), {
+ id: UPGRADE_TOAST_ID,
+ description: t('opencodeUpdate.toast.upgrading.description'),
+ duration: Infinity,
+ icon: ,
+ });
+
+ try {
+ const response = await fetch('/api/opencode/upgrade', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ body: JSON.stringify({}),
+ });
+ const payload = await response.json().catch(() => null) as null | { success?: boolean; version?: string; error?: string };
+ if (!response.ok || payload?.success === false) {
+ throw new Error(payload?.error || response.statusText || t('opencodeUpdate.toast.failed.description'));
+ }
+
+ toast.success(t('opencodeUpdate.toast.updated.title'), {
+ id: UPGRADE_TOAST_ID,
+ description: payload?.version
+ ? t('opencodeUpdate.toast.updated.descriptionWithVersion', { version: payload.version })
+ : t('opencodeUpdate.toast.updated.description'),
+ duration: Infinity,
+ icon: ,
+ action: {
+ label: t('opencodeUpdate.toast.actions.reload'),
+ onClick: reloadOpenCode,
+ },
+ });
+ } catch (error) {
+ toast.error(t('opencodeUpdate.toast.failed.title'), {
+ id: UPGRADE_TOAST_ID,
+ description: error instanceof Error ? error.message : t('opencodeUpdate.toast.failed.description'),
+ duration: Infinity,
+ });
+ } finally {
+ upgradingRef.current = false;
+ }
+ }, [reloadOpenCode, t]);
+
+ React.useEffect(() => {
+ const showUpdateAvailableToast = (version: string) => {
+ if (!version) {
+ return;
+ }
+ if (seenVersionsRef.current.has(version)) {
+ return;
+ }
+ seenVersionsRef.current.add(version);
+
+ toast.info(t('opencodeUpdate.toast.available.title'), {
+ id: UPDATE_TOAST_ID,
+ description: t('opencodeUpdate.toast.available.description', { version }),
+ duration: Infinity,
+ action: {
+ label: t('opencodeUpdate.toast.actions.update'),
+ onClick: runUpgrade,
+ },
+ });
+ };
+
+ const onUpdateAvailable = (event: Event) => {
+ const version = typeof (event as OpenCodeUpdateAvailableEvent).detail?.version === 'string'
+ ? String((event as OpenCodeUpdateAvailableEvent).detail.version).trim()
+ : '';
+ showUpdateAvailableToast(version);
+ };
+
+ let cancelled = false;
+ const timeoutIds: Array> = [];
+
+ const checkForUpdate = async (attempt: number) => {
+ 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) {
+ showUpdateAvailableToast(version);
+ }
+ } catch {
+ const delay = CHECK_RETRY_DELAYS_MS[attempt];
+ if (!cancelled && delay !== undefined) {
+ timeoutIds.push(setTimeout(() => { void checkForUpdate(attempt + 1); }, delay));
+ }
+ }
+ };
+
+ timeoutIds.push(setTimeout(() => { void checkForUpdate(0); }, INITIAL_CHECK_DELAY_MS));
+
+ window.addEventListener('openchamber:opencode-update-available', onUpdateAvailable);
+ return () => {
+ cancelled = true;
+ for (const timeoutId of timeoutIds) clearTimeout(timeoutId);
+ window.removeEventListener('openchamber:opencode-update-available', onUpdateAvailable);
+ };
+ }, [runUpgrade, t]);
+
+ return null;
+};
diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts
index 4bc0285c..5ab852e1 100644
--- a/packages/ui/src/lib/i18n/messages/en.ts
+++ b/packages/ui/src/lib/i18n/messages/en.ts
@@ -751,6 +751,11 @@ export const dict = {
'contextPanel.mode.plan': 'Plan',
'contextPanel.mode.context': 'Context',
'contextPanel.mode.preview': 'Preview',
+ 'contextPanel.mode.browser': 'Browser',
+ 'contextPanel.browser.open': 'Open browser panel',
+ 'contextPanel.browser.addressAria': 'Browser address',
+ 'contextPanel.browser.empty': 'Web browser',
+ 'contextPanel.browser.emptyHint': 'Enter an address above to start browsing the web',
'contextPanel.tab.closeTabAria': 'Close {label} tab',
'contextPanel.actions.collapsePanel': 'Collapse panel',
'contextPanel.actions.expandPanel': 'Expand panel',
@@ -1149,6 +1154,8 @@ export const dict = {
'directoryTree.section.pinned': 'Pinned',
'directoryTree.section.browse': 'Browse',
'aboutDialog.versionLabel': 'Version {version}',
+ 'aboutDialog.openChamberVersionLabel': 'OpenChamber version {version}',
+ 'aboutDialog.openCodeVersionLabel': 'OpenCode version {version}',
'aboutDialog.actions.copyDiagnostics': 'Copy diagnostics',
'aboutDialog.actions.preparingDiagnostics': 'Preparing diagnostics...',
'aboutDialog.actions.diagnosticsCopied': 'Diagnostics copied',
@@ -2149,6 +2156,18 @@ export const dict = {
'updateDialog.status.updating': 'Updating...',
'updateDialog.error.updateFailed': 'Update failed',
'updateDialog.error.takingLonger': 'Update is taking longer than expected. Wait a bit and refresh, or run: openchamber update',
+ '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.reload': 'Reload OpenCode',
+ 'opencodeUpdate.toast.upgrading.title': 'Updating OpenCode...',
+ 'opencodeUpdate.toast.upgrading.description': 'Keep OpenChamber open.',
+ 'opencodeUpdate.toast.updated.title': 'OpenCode updated',
+ 'opencodeUpdate.toast.updated.description': 'Reload OpenCode to start using the updated version.',
+ 'opencodeUpdate.toast.updated.descriptionWithVersion': 'Version {version} is installed. Reload OpenCode to use it.',
+ 'opencodeUpdate.toast.failed.title': 'Could not update OpenCode',
+ 'opencodeUpdate.toast.failed.description': 'The OpenCode upgrade failed.',
+ 'opencodeUpdate.toast.reload.message': 'Restarting OpenCode...',
'memoryDebugPanel.title': 'Debug Panel',
'memoryDebugPanel.tabs.memory': 'Memory',
'memoryDebugPanel.tabs.streaming': 'Streaming',
diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts
index 42765179..7b9db6ba 100644
--- a/packages/ui/src/lib/i18n/messages/es.ts
+++ b/packages/ui/src/lib/i18n/messages/es.ts
@@ -752,6 +752,11 @@ export const dict: Record = {
"contextPanel.mode.plan": "Plan",
"contextPanel.mode.context": "Contexto",
"contextPanel.mode.preview": "Vista previa",
+ "contextPanel.mode.browser": "Navegador",
+ "contextPanel.browser.open": "Abrir panel del navegador",
+ "contextPanel.browser.addressAria": "Dirección del navegador",
+ "contextPanel.browser.empty": "Navegador web",
+ "contextPanel.browser.emptyHint": "Ingrese una dirección arriba para comenzar a navegar",
"contextPanel.tab.closeTabAria": "Cerrar pestaña {label}",
"contextPanel.actions.collapsePanel": "Colapsar panel",
"contextPanel.actions.expandPanel": "Expandir panel",
@@ -1115,6 +1120,8 @@ export const dict: Record = {
"directoryTree.section.pinned": "Fijados",
"directoryTree.section.browse": "Explorar",
"aboutDialog.versionLabel": "Versión {version}",
+ "aboutDialog.openChamberVersionLabel": "Versión de OpenChamber {version}",
+ "aboutDialog.openCodeVersionLabel": "Versión de OpenCode {version}",
"aboutDialog.actions.copyDiagnostics": "Copiar diagnósticos",
"aboutDialog.actions.preparingDiagnostics": "Preparando diagnósticos...",
"aboutDialog.actions.diagnosticsCopied": "Diagnósticos copiados",
@@ -2115,6 +2122,18 @@ export const dict: Record = {
"updateDialog.status.updating": "Actualizando...",
"updateDialog.error.updateFailed": "No se pudo actualizar",
"updateDialog.error.takingLonger": "La actualización está tardando más de lo esperado. Espera un poco y refresca, o ejecuta: openchamber update",
+ "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.reload": "Recargar OpenCode",
+ "opencodeUpdate.toast.upgrading.title": "Actualizando OpenCode...",
+ "opencodeUpdate.toast.upgrading.description": "Mantén OpenChamber abierto.",
+ "opencodeUpdate.toast.updated.title": "OpenCode actualizado",
+ "opencodeUpdate.toast.updated.description": "Recarga OpenCode para empezar a usar la versión actualizada.",
+ "opencodeUpdate.toast.updated.descriptionWithVersion": "La versión {version} está instalada. Recarga OpenCode para usarla.",
+ "opencodeUpdate.toast.failed.title": "No se pudo actualizar OpenCode",
+ "opencodeUpdate.toast.failed.description": "La actualización de OpenCode falló.",
+ "opencodeUpdate.toast.reload.message": "Reiniciando OpenCode...",
"memoryDebugPanel.title": "Panel de depuración",
"memoryDebugPanel.tabs.memory": "Memoria",
"memoryDebugPanel.tabs.streaming": "Transmisión",
diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts
index 518ad051..e8fcfcfe 100644
--- a/packages/ui/src/lib/i18n/messages/ko.ts
+++ b/packages/ui/src/lib/i18n/messages/ko.ts
@@ -752,6 +752,11 @@ export const dict: Record = {
'contextPanel.mode.plan': '계획',
'contextPanel.mode.context': '컨텍스트',
'contextPanel.mode.preview': '미리보기',
+ 'contextPanel.mode.browser': '브라우저',
+ 'contextPanel.browser.open': '브라우저 패널 열기',
+ 'contextPanel.browser.addressAria': '브라우저 주소',
+ 'contextPanel.browser.empty': '웹 브라우저',
+ 'contextPanel.browser.emptyHint': '위에 주소를 입력하여 탐색을 시작하세요',
'contextPanel.preview.actions.reload': '미리보기 새로고침',
'contextPanel.preview.actions.openExternal': '브라우저에서 열기',
'contextPanel.preview.actions.retry': '다시 시도',
@@ -1151,6 +1156,8 @@ export const dict: Record = {
'directoryTree.section.pinned': '고정됨',
'directoryTree.section.browse': '찾아보기',
'aboutDialog.versionLabel': '버전 {version}',
+ 'aboutDialog.openChamberVersionLabel': 'OpenChamber 버전 {version}',
+ 'aboutDialog.openCodeVersionLabel': 'OpenCode 버전 {version}',
'aboutDialog.actions.copyDiagnostics': '진단 정보 복사',
'aboutDialog.actions.preparingDiagnostics': '진단 정보 준비 중…',
'aboutDialog.actions.diagnosticsCopied': '진단 정보 복사됨',
@@ -2149,6 +2156,18 @@ export const dict: Record = {
'updateDialog.status.updating': '업데이트 중…',
'updateDialog.error.updateFailed': '업데이트 실패',
'updateDialog.error.takingLonger': '업데이트가 예상보다 오래 걸립니다. 잠시 기다린 뒤 새로고침하거나 `openchamber update`를 실행하세요.',
+ 'opencodeUpdate.toast.available.title': 'OpenCode 업데이트 사용 가능',
+ 'opencodeUpdate.toast.available.description': '버전 {version}을 설치할 수 있습니다.',
+ 'opencodeUpdate.toast.actions.update': '업데이트',
+ 'opencodeUpdate.toast.actions.reload': 'OpenCode 다시 로드',
+ 'opencodeUpdate.toast.upgrading.title': 'OpenCode 업데이트 중...',
+ 'opencodeUpdate.toast.upgrading.description': 'OpenChamber를 열어 두세요.',
+ 'opencodeUpdate.toast.updated.title': 'OpenCode 업데이트 완료',
+ 'opencodeUpdate.toast.updated.description': '업데이트된 버전을 사용하려면 OpenCode를 다시 로드하세요.',
+ 'opencodeUpdate.toast.updated.descriptionWithVersion': '버전 {version}이 설치되었습니다. 사용하려면 OpenCode를 다시 로드하세요.',
+ 'opencodeUpdate.toast.failed.title': 'OpenCode를 업데이트할 수 없음',
+ 'opencodeUpdate.toast.failed.description': 'OpenCode 업그레이드에 실패했습니다.',
+ 'opencodeUpdate.toast.reload.message': 'OpenCode 다시 시작 중...',
'memoryDebugPanel.title': '디버그 패널',
'memoryDebugPanel.tabs.memory': '메모리',
'memoryDebugPanel.tabs.streaming': '스트리밍',
diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts
index 4b313f2b..dd6bd3b4 100644
--- a/packages/ui/src/lib/i18n/messages/pl.ts
+++ b/packages/ui/src/lib/i18n/messages/pl.ts
@@ -675,6 +675,8 @@ export const dict: Record = {
'aboutDialog.toast.diagnosticsCopied': 'Diagnostyka skopiowana',
'aboutDialog.toast.diagnosticsNotReady': 'Diagnostyka nie jest jeszcze gotowa. Poczekaj chwilę i spróbuj ponownie.',
'aboutDialog.versionLabel': 'Wersja {version}',
+ 'aboutDialog.openChamberVersionLabel': 'Wersja OpenChamber {version}',
+ 'aboutDialog.openCodeVersionLabel': 'Wersja OpenCode {version}',
'agentManager.detail.actions.copyWorktreePath': 'Kopiuj ścieżkę drzewa pracy',
'agentManager.detail.actions.keepThisRemoveOthers': 'Zostaw to, usuń pozostałe',
'agentManager.detail.actions.removeThisWorktree': 'Usuń to drzewo pracy',
@@ -1014,6 +1016,11 @@ export const dict: Record = {
'contextPanel.mode.files': 'Pliki',
'contextPanel.mode.plan': 'Plan',
'contextPanel.mode.preview': 'Podgląd',
+ 'contextPanel.mode.browser': 'Przeglądarka',
+ 'contextPanel.browser.open': 'Otwórz panel przeglądarki',
+ 'contextPanel.browser.addressAria': 'Adres przeglądarki',
+ 'contextPanel.browser.empty': 'Przeglądarka internetowa',
+ 'contextPanel.browser.emptyHint': 'Wprowadź adres powyżej, aby rozpocząć przeglądanie',
'contextPanel.preview.actions.openExternal': 'Otwórz w przeglądarce',
'contextPanel.preview.actions.reload': 'Odśwież podgląd',
'contextPanel.preview.actions.retry': 'Ponów',
@@ -2130,6 +2137,18 @@ export const dict: Record = {
'updateDialog.status.serverRestarting': 'Ponowne uruchamianie serwera...',
'updateDialog.status.updating': 'Aktualizowanie...',
'updateDialog.status.waitingForServer': 'Oczekiwanie na serwer...',
+ '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.reload': 'Przeładuj OpenCode',
+ 'opencodeUpdate.toast.upgrading.title': 'Aktualizowanie OpenCode...',
+ 'opencodeUpdate.toast.upgrading.description': 'Pozostaw OpenChamber otwarte.',
+ 'opencodeUpdate.toast.updated.title': 'OpenCode zaktualizowany',
+ 'opencodeUpdate.toast.updated.description': 'Przeładuj OpenCode, aby zacząć używać zaktualizowanej wersji.',
+ 'opencodeUpdate.toast.updated.descriptionWithVersion': 'Wersja {version} jest zainstalowana. Przeładuj OpenCode, aby jej użyć.',
+ 'opencodeUpdate.toast.failed.title': 'Nie udało się zaktualizować OpenCode',
+ 'opencodeUpdate.toast.failed.description': 'Aktualizacja OpenCode nie powiodła się.',
+ 'opencodeUpdate.toast.reload.message': 'Ponowne uruchamianie OpenCode...',
'vscodeLayout.actions.backToSessionsAria': 'Powrót do sesji',
'vscodeLayout.actions.newSessionAria': 'Nowa sesja',
'vscodeLayout.actions.openAgentManagerAria': 'Otwórz menedżer agentów',
diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts
index 7c96e6d1..d38206f2 100644
--- a/packages/ui/src/lib/i18n/messages/pt-BR.ts
+++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts
@@ -752,6 +752,11 @@ export const dict: Record = {
"contextPanel.mode.plan": "Plano",
"contextPanel.mode.context": "Contexto",
"contextPanel.mode.preview": "Prévia",
+ "contextPanel.mode.browser": "Navegador",
+ "contextPanel.browser.open": "Abrir painel do navegador",
+ "contextPanel.browser.addressAria": "Endereço do navegador",
+ "contextPanel.browser.empty": "Navegador web",
+ "contextPanel.browser.emptyHint": "Digite um endereço acima para começar a navegar",
"contextPanel.tab.closeTabAria": "Fechar aba {label}",
"contextPanel.actions.collapsePanel": "Recolher painel",
"contextPanel.actions.expandPanel": "Expandir painel",
@@ -1115,6 +1120,8 @@ export const dict: Record = {
"directoryTree.section.pinned": "Fixados",
"directoryTree.section.browse": "Explorar",
"aboutDialog.versionLabel": "Versão {version}",
+ "aboutDialog.openChamberVersionLabel": "Versão do OpenChamber {version}",
+ "aboutDialog.openCodeVersionLabel": "Versão do OpenCode {version}",
"aboutDialog.actions.copyDiagnostics": "Copiar diagnósticos",
"aboutDialog.actions.preparingDiagnostics": "Preparando diagnósticos...",
"aboutDialog.actions.diagnosticsCopied": "Diagnósticos copiados",
@@ -2115,6 +2122,18 @@ export const dict: Record = {
"updateDialog.status.updating": "Atualizando...",
"updateDialog.error.updateFailed": "Não foi possível atualizar",
"updateDialog.error.takingLonger": "A atualização está demorando mais do que o esperado. Aguarde um pouco e atualize, ou execute: openchamber update",
+ "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.reload": "Recarregar OpenCode",
+ "opencodeUpdate.toast.upgrading.title": "Atualizando OpenCode...",
+ "opencodeUpdate.toast.upgrading.description": "Mantenha o OpenChamber aberto.",
+ "opencodeUpdate.toast.updated.title": "OpenCode atualizado",
+ "opencodeUpdate.toast.updated.description": "Recarregue o OpenCode para começar a usar a versão atualizada.",
+ "opencodeUpdate.toast.updated.descriptionWithVersion": "A versão {version} está instalada. Recarregue o OpenCode para usá-la.",
+ "opencodeUpdate.toast.failed.title": "Não foi possível atualizar o OpenCode",
+ "opencodeUpdate.toast.failed.description": "A atualização do OpenCode falhou.",
+ "opencodeUpdate.toast.reload.message": "Reiniciando OpenCode...",
"memoryDebugPanel.title": "Painel de depuração",
"memoryDebugPanel.tabs.memory": "Memória",
"memoryDebugPanel.tabs.streaming": "Transmissão",
diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts
index bb12a831..8f272382 100644
--- a/packages/ui/src/lib/i18n/messages/uk.ts
+++ b/packages/ui/src/lib/i18n/messages/uk.ts
@@ -752,6 +752,11 @@ export const dict: Record = {
"contextPanel.mode.plan": "План",
"contextPanel.mode.context": "Контекст",
"contextPanel.mode.preview": "Перегляд",
+ "contextPanel.mode.browser": "Браузер",
+ "contextPanel.browser.open": "Відкрити панель браузера",
+ "contextPanel.browser.addressAria": "Адреса браузера",
+ "contextPanel.browser.empty": "Веб-браузер",
+ "contextPanel.browser.emptyHint": "Введіть адресу вище, щоб почати перегляд",
"contextPanel.tab.closeTabAria": "Закрити вкладку {label}",
"contextPanel.actions.collapsePanel": "Згорнути панель",
"contextPanel.actions.expandPanel": "Розгорнути панель",
@@ -1115,6 +1120,8 @@ export const dict: Record = {
"directoryTree.section.pinned": "Закріплено",
"directoryTree.section.browse": "Огляд",
"aboutDialog.versionLabel": "Версія {version}",
+ "aboutDialog.openChamberVersionLabel": "Версія OpenChamber {version}",
+ "aboutDialog.openCodeVersionLabel": "Версія OpenCode {version}",
"aboutDialog.actions.copyDiagnostics": "Скопіювати діагностику",
"aboutDialog.actions.preparingDiagnostics": "Підготовка діагностики...",
"aboutDialog.actions.diagnosticsCopied": "Діагностику скопійовано",
@@ -2115,6 +2122,18 @@ export const dict: Record = {
"updateDialog.status.updating": "Оновлення...",
"updateDialog.error.updateFailed": "Помилка оновлення",
"updateDialog.error.takingLonger": "Оновлення триває довше, ніж очікувалося. Зачекайте трохи та оновіть або запустіть: openchamber update",
+ "opencodeUpdate.toast.available.title": "Доступне оновлення OpenCode",
+ "opencodeUpdate.toast.available.description": "Версія {version} готова до встановлення.",
+ "opencodeUpdate.toast.actions.update": "Оновити",
+ "opencodeUpdate.toast.actions.reload": "Перезавантажити OpenCode",
+ "opencodeUpdate.toast.upgrading.title": "Оновлення OpenCode...",
+ "opencodeUpdate.toast.upgrading.description": "Залиште OpenChamber відкритим.",
+ "opencodeUpdate.toast.updated.title": "OpenCode оновлено",
+ "opencodeUpdate.toast.updated.description": "Перезавантажте OpenCode, щоб почати використовувати оновлену версію.",
+ "opencodeUpdate.toast.updated.descriptionWithVersion": "Версію {version} встановлено. Перезавантажте OpenCode, щоб її використовувати.",
+ "opencodeUpdate.toast.failed.title": "Не вдалося оновити OpenCode",
+ "opencodeUpdate.toast.failed.description": "Оновлення OpenCode не вдалося.",
+ "opencodeUpdate.toast.reload.message": "Перезапуск OpenCode...",
"memoryDebugPanel.title": "Панель налагодження",
"memoryDebugPanel.tabs.memory": "Пам'ять",
"memoryDebugPanel.tabs.streaming": "Потокове передавання",
diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts
index 5b48c1a7..bbe98594 100644
--- a/packages/ui/src/lib/i18n/messages/zh-CN.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts
@@ -752,6 +752,11 @@ export const dict: Record = {
'contextPanel.mode.plan': '计划',
'contextPanel.mode.context': '上下文',
'contextPanel.mode.preview': '预览',
+ 'contextPanel.mode.browser': '浏览器',
+ 'contextPanel.browser.open': '打开浏览器面板',
+ 'contextPanel.browser.addressAria': '浏览器地址',
+ 'contextPanel.browser.empty': '网页浏览器',
+ 'contextPanel.browser.emptyHint': '在上方输入网址开始浏览',
'contextPanel.tab.closeTabAria': '关闭 {label} 标签',
'contextPanel.actions.collapsePanel': '折叠面板',
'contextPanel.actions.expandPanel': '展开面板',
@@ -1115,6 +1120,8 @@ export const dict: Record = {
'directoryTree.section.pinned': '已固定',
'directoryTree.section.browse': '浏览',
'aboutDialog.versionLabel': '版本 {version}',
+ 'aboutDialog.openChamberVersionLabel': 'OpenChamber 版本 {version}',
+ 'aboutDialog.openCodeVersionLabel': 'OpenCode 版本 {version}',
'aboutDialog.actions.copyDiagnostics': '复制诊断信息',
'aboutDialog.actions.preparingDiagnostics': '正在准备诊断信息...',
'aboutDialog.actions.diagnosticsCopied': '诊断信息已复制',
@@ -2115,6 +2122,18 @@ export const dict: Record = {
'updateDialog.status.updating': '更新中...',
'updateDialog.error.updateFailed': '更新失败',
'updateDialog.error.takingLonger': '更新耗时超出预期。请稍等后刷新,或运行:openchamber update',
+ 'opencodeUpdate.toast.available.title': 'OpenCode 有可用更新',
+ 'opencodeUpdate.toast.available.description': '版本 {version} 已可安装。',
+ 'opencodeUpdate.toast.actions.update': '更新',
+ 'opencodeUpdate.toast.actions.reload': '重载 OpenCode',
+ 'opencodeUpdate.toast.upgrading.title': '正在更新 OpenCode...',
+ 'opencodeUpdate.toast.upgrading.description': '请保持 OpenChamber 打开。',
+ 'opencodeUpdate.toast.updated.title': 'OpenCode 已更新',
+ 'opencodeUpdate.toast.updated.description': '重载 OpenCode 以开始使用更新后的版本。',
+ 'opencodeUpdate.toast.updated.descriptionWithVersion': '版本 {version} 已安装。重载 OpenCode 后即可使用。',
+ 'opencodeUpdate.toast.failed.title': '无法更新 OpenCode',
+ 'opencodeUpdate.toast.failed.description': 'OpenCode 升级失败。',
+ 'opencodeUpdate.toast.reload.message': '正在重启 OpenCode...',
'memoryDebugPanel.title': '调试面板',
'memoryDebugPanel.tabs.memory': '内存',
'memoryDebugPanel.tabs.streaming': '流式',
diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts
index d3b1bfb2..3c314da9 100644
--- a/packages/ui/src/stores/useUIStore.ts
+++ b/packages/ui/src/stores/useUIStore.ts
@@ -9,7 +9,7 @@ import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobi
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files';
export type RightSidebarTab = 'git' | 'files' | 'context';
-export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview';
+export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview' | 'browser';
export type MermaidRenderingMode = 'svg' | 'ascii';
export type UserMessageRenderingMode = 'markdown' | 'plain';
export type ChatRenderMode = 'sorted' | 'live';
@@ -242,7 +242,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
touchedAt?: unknown;
};
- if (candidate.mode !== 'diff' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'preview') {
+ if (candidate.mode !== 'diff' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'preview' && candidate.mode !== 'browser') {
continue;
}
@@ -386,6 +386,17 @@ const reorderContextPanelTabs = (
};
};
+const setContextPanelTabTargetPath = (
+ current: ContextPanelDirectoryState,
+ tabID: string,
+ targetPath: string,
+): ContextPanelDirectoryState => ({
+ ...current,
+ tabs: current.tabs.map((tab) =>
+ tab.id === tabID ? { ...tab, targetPath } : tab,
+ ),
+});
+
const sanitizeContextPanelByDirectory = (
value: unknown,
): Record => {
@@ -595,6 +606,8 @@ interface UIStore {
openContextOverview: (directory: string) => void;
openContextPlan: (directory: string) => void;
openContextPreview: (directory: string, url: string) => void;
+ openContextBrowser: (directory: string, url?: string) => void;
+ setContextPanelTabTargetPath: (directory: string, tabID: string, targetPath: string) => void;
setActiveContextPanelTab: (directory: string, tabID: string) => void;
reorderContextPanelTabs: (directory: string, activeTabID: string, overTabID: string) => void;
closeContextPanelTab: (directory: string, tabID: string) => void;
@@ -1023,6 +1036,33 @@ export const useUIStore = create()(
label,
});
},
+ openContextBrowser: (directory, url = '') => {
+ const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
+ if (!normalizedDirectory) return;
+ const targetUrl = typeof url === 'string' && url.trim().length > 0 ? url.trim() : '';
+ get().openContextPanelTab(normalizedDirectory, {
+ mode: 'browser',
+ targetPath: targetUrl,
+ dedupeKey: 'desktop-browser',
+ label: 'Browser',
+ });
+ },
+
+ setContextPanelTabTargetPath: (directory, tabID, targetPath) => {
+ const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
+ const normalizedTabID = (tabID || '').trim();
+ if (!normalizedDirectory || !normalizedTabID) return;
+ set((state) => {
+ const current = state.contextPanelByDirectory[normalizedDirectory];
+ if (!current) return state;
+ return {
+ contextPanelByDirectory: {
+ ...state.contextPanelByDirectory,
+ [normalizedDirectory]: setContextPanelTabTargetPath(current, normalizedTabID, targetPath),
+ },
+ };
+ });
+ },
setActiveContextPanelTab: (directory, tabID) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx
index 46fbf1fc..a68814a4 100644
--- a/packages/ui/src/sync/sync-context.tsx
+++ b/packages/ui/src/sync/sync-context.tsx
@@ -1270,6 +1270,11 @@ function handleEvent(
// Provider
// ---------------------------------------------------------------------------
+const dispatchOpenCodeUpdateAvailable = (payload: { version: string }) => {
+ if (typeof window === "undefined") return
+ window.dispatchEvent(new CustomEvent("openchamber:opencode-update-available", { detail: payload }))
+}
+
export function SyncProvider(props: {
sdk: OpencodeClient
directory: string
@@ -1427,6 +1432,14 @@ export function SyncProvider(props: {
return resolveDirectoryFromRoutingIndex(routingIndex, directory, payload, childStores)
},
onEvent: (directory, payload) => {
+ if (payload.type === "installation.update-available") {
+ const version = typeof (payload.properties as { version?: unknown })?.version === "string"
+ ? (payload.properties as { version: string }).version
+ : ""
+ if (version) {
+ dispatchOpenCodeUpdateAvailable({ version })
+ }
+ }
handleEvent(directory, payload, childStores, routingIndex)
},
onReconnect: () => {
diff --git a/packages/ui/src/types/desktop.d.ts b/packages/ui/src/types/desktop.d.ts
index a9683d18..24f3c16b 100644
--- a/packages/ui/src/types/desktop.d.ts
+++ b/packages/ui/src/types/desktop.d.ts
@@ -8,6 +8,34 @@ declare global {
__OPENCHAMBER_ELECTRON__?: { runtime?: string };
__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__?: DesktopBootOutcome;
}
+
+ interface WebviewElement extends HTMLElement {
+ loadURL(url: string): void;
+ goBack(): void;
+ goForward(): void;
+ reload(): void;
+ getURL(): string;
+ getTitle(): string;
+ isLoading(): boolean;
+ getWebContentsId(): number;
+ executeJavaScript(code: string, userGesture?: boolean): Promise;
+ }
+
+ namespace JSX {
+ interface IntrinsicElements {
+ webview: React.DetailedHTMLProps<
+ React.HTMLAttributes & {
+ src?: string;
+ partition?: string;
+ preload?: string;
+ nodeintegration?: string;
+ allowpopups?: string;
+ ref?: React.Ref;
+ },
+ WebviewElement
+ >;
+ }
+ }
}
export {};
diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js
index 25c52f40..2d96d213 100644
--- a/packages/web/server/lib/opencode/feature-routes-runtime.js
+++ b/packages/web/server/lib/opencode/feature-routes-runtime.js
@@ -82,6 +82,8 @@ export const createFeatureRoutesRuntime = (dependencies) => {
getProviderSources,
removeProviderConfig,
refreshOpenCodeAfterConfigChange,
+ buildOpenCodeUrl,
+ getOpenCodeAuthHeaders,
});
registerProjectIconRoutes(app, {
diff --git a/packages/web/server/lib/opencode/routes.js b/packages/web/server/lib/opencode/routes.js
index 3e812015..24b8bb13 100644
--- a/packages/web/server/lib/opencode/routes.js
+++ b/packages/web/server/lib/opencode/routes.js
@@ -18,6 +18,8 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
getProviderSources,
removeProviderConfig,
refreshOpenCodeAfterConfigChange,
+ buildOpenCodeUrl,
+ getOpenCodeAuthHeaders,
} = dependencies;
let authLibrary = null;
@@ -39,6 +41,69 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
return trimmed || null;
};
+ const parseVersionForComparison = (value) => {
+ const normalized = String(value || '').replace(/^v/, '').split('+')[0];
+ const prereleaseIndex = normalized.indexOf('-');
+ const core = prereleaseIndex >= 0 ? normalized.slice(0, prereleaseIndex) : normalized;
+ const parts = core.split('.').map((part) => {
+ const parsed = Number.parseInt(part || '0', 10);
+ return Number.isFinite(parsed) ? parsed : 0;
+ });
+ return { parts, prerelease: prereleaseIndex >= 0 };
+ };
+
+ const compareVersions = (left, right) => {
+ const a = parseVersionForComparison(left);
+ const b = parseVersionForComparison(right);
+ const length = Math.max(a.parts.length, b.parts.length);
+ for (let index = 0; index < length; index += 1) {
+ const diff = (a.parts[index] || 0) - (b.parts[index] || 0);
+ if (diff !== 0) return diff;
+ }
+ if (a.prerelease !== b.prerelease) return a.prerelease ? -1 : 1;
+ return 0;
+ };
+
+ const fetchLatestOpenCodeVersionFromGithub = async () => {
+ const response = await fetch('https://api.github.com/repos/anomalyco/opencode/releases/latest', {
+ headers: { Accept: 'application/json' },
+ signal: AbortSignal.timeout(10_000),
+ });
+ if (!response.ok) {
+ throw new Error(`OpenCode releases responded with ${response.status}`);
+ }
+ const payload = await response.json();
+ const tag = typeof payload?.tag_name === 'string' ? payload.tag_name.trim() : '';
+ return tag.replace(/^v/, '');
+ };
+
+ const fetchLatestOpenCodeVersionFromNpm = async () => {
+ const response = await fetch('https://registry.npmjs.org/opencode-ai/latest', {
+ headers: { Accept: 'application/json' },
+ signal: AbortSignal.timeout(10_000),
+ });
+ if (!response.ok) {
+ throw new Error(`OpenCode npm registry responded with ${response.status}`);
+ }
+ const payload = await response.json();
+ return typeof payload?.version === 'string' ? payload.version.trim().replace(/^v/, '') : '';
+ };
+
+ const fetchLatestOpenCodeVersion = async () => {
+ const results = await Promise.allSettled([
+ fetchLatestOpenCodeVersionFromNpm(),
+ fetchLatestOpenCodeVersionFromGithub(),
+ ]);
+ const versions = results
+ .filter((result) => result.status === 'fulfilled' && result.value)
+ .map((result) => result.value);
+ if (versions.length === 0) {
+ const failure = results.find((result) => result.status === 'rejected');
+ throw failure?.reason instanceof Error ? failure.reason : new Error('Failed to resolve latest OpenCode version');
+ }
+ return versions.sort((left, right) => compareVersions(right, left))[0];
+ };
+
const pruneExpiredPendingMcpAuthContexts = () => {
const now = Date.now();
for (const [state, entry] of pendingMcpAuthContextByState.entries()) {
@@ -69,6 +134,71 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
}
});
+ app.post('/api/opencode/upgrade', async (req, res) => {
+ try {
+ const target = typeof req.body?.target === 'string' && req.body.target.trim().length > 0
+ ? req.body.target.trim()
+ : undefined;
+ const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ ...getOpenCodeAuthHeaders(),
+ },
+ body: JSON.stringify(target ? { target } : {}),
+ });
+ const payload = await response.json().catch(() => null);
+ if (!response.ok) {
+ return res.status(response.status).json({
+ success: false,
+ error: payload?.error || response.statusText || 'Failed to upgrade OpenCode',
+ });
+ }
+ return res.json(payload ?? { success: true });
+ } catch (error) {
+ console.error('Failed to upgrade OpenCode:', error);
+ return res.status(500).json({
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to upgrade OpenCode',
+ });
+ }
+ });
+
+ app.get('/api/opencode/upgrade-status', async (_req, res) => {
+ try {
+ const [healthResponse, latestVersion] = await Promise.all([
+ fetch(buildOpenCodeUrl('/global/health', ''), {
+ method: 'GET',
+ headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
+ }),
+ fetchLatestOpenCodeVersion(),
+ ]);
+ const health = await healthResponse.json().catch(() => null);
+ if (!healthResponse.ok) {
+ return res.status(healthResponse.status).json({
+ available: null,
+ error: health?.error || healthResponse.statusText || 'Failed to read OpenCode version',
+ });
+ }
+ const currentVersion = typeof health?.version === 'string' ? health.version.replace(/^v/, '') : null;
+ if (!currentVersion || !latestVersion) {
+ return res.json({ available: null, currentVersion, latestVersion: latestVersion || null });
+ }
+ const available = compareVersions(latestVersion, currentVersion) > 0;
+ return res.json({
+ available,
+ currentVersion,
+ latestVersion,
+ });
+ } catch (error) {
+ return res.status(500).json({
+ available: null,
+ error: error instanceof Error ? error.message : 'Failed to check OpenCode upgrade status',
+ });
+ }
+ });
+
app.put('/api/config/settings', async (req, res) => {
console.log('[API:PUT /api/config/settings] Received request');
try {