From a12be061e3ff01e3186caa6009be70eb8d73c513 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 14 May 2026 14:45:04 +0300 Subject: [PATCH] feat: add OpenCode update and in-app Browser features --- packages/electron/main.mjs | 20 +- packages/ui/src/App.tsx | 3 + .../ui/src/components/layout/ContextPanel.tsx | 446 +++++++++++++++++- packages/ui/src/components/layout/Header.tsx | 25 +- packages/ui/src/components/ui/AboutDialog.tsx | 40 +- .../components/update/OpenCodeUpdateToast.tsx | 139 ++++++ packages/ui/src/lib/i18n/messages/en.ts | 19 + packages/ui/src/lib/i18n/messages/es.ts | 19 + packages/ui/src/lib/i18n/messages/ko.ts | 19 + packages/ui/src/lib/i18n/messages/pl.ts | 19 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 19 + packages/ui/src/lib/i18n/messages/uk.ts | 19 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 19 + packages/ui/src/stores/useUIStore.ts | 44 +- packages/ui/src/sync/sync-context.tsx | 13 + packages/ui/src/types/desktop.d.ts | 28 ++ .../lib/opencode/feature-routes-runtime.js | 2 + packages/web/server/lib/opencode/routes.js | 130 +++++ 18 files changed, 1006 insertions(+), 17 deletions(-) create mode 100644 packages/ui/src/components/update/OpenCodeUpdateToast.tsx diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 3d7b5d92..0d8c4747 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, Notification, powerMonitor, session, shell } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, Notification, powerMonitor, session, shell, webContents } from 'electron'; import contextMenu from 'electron-context-menu'; import log from 'electron-log/main.js'; import dgram from 'node:dgram'; @@ -1181,6 +1181,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url }) => { backgroundThrottling: true, contextIsolation: true, nodeIntegration: false, + webviewTag: true, // sandbox must stay off: the preload uses contextBridge + ipcRenderer // from Electron's Node layer. contextIsolation + nodeIntegration:false // keep the renderer world walled off from Node. Do NOT flip to true — @@ -1431,6 +1432,8 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj backgroundThrottling: true, contextIsolation: true, nodeIntegration: false, + webviewTag: true, + // sandbox must stay off sandbox: false, }, }); @@ -1859,6 +1862,21 @@ const handleInvoke = async (browserWindow, command, args = {}) => { case 'desktop_get_app_version': return APP_VERSION; + case 'desktop_browser_capture_page': { + const wcId = Number.isFinite(args.webContentsId) ? Math.trunc(args.webContentsId) : null; + if (wcId === null || wcId < 0) throw new Error('webContentsId is required'); + const wc = webContents.fromId(wcId); + if (!wc || wc.isDestroyed()) throw new Error('WebContents not found'); + const image = await wc.capturePage(); + const buffer = image.toJPEG(82); + return { + mime: 'image/jpeg', + base64: buffer.toString('base64'), + width: image.getSize().width, + height: image.getSize().height, + }; + } + case 'desktop_capture_page_rect': { if (!browserWindow || browserWindow.isDestroyed()) { throw new Error('Window is not available'); diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 7cc2d419..70c00590 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -49,6 +49,7 @@ import { useI18n } from '@/lib/i18n'; import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import { SyncAppEffects } from '@/apps/AppEffects'; import { useAppFontEffects } from '@/apps/useAppFontEffects'; +import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast'; // Lazy-loaded heavy views — loaded on demand to reduce initial bundle size. const OnboardingScreen = lazyWithChunkRecovery(() => @@ -781,6 +782,7 @@ function App({ apis }: AppProps) {
+
@@ -824,6 +826,7 @@ function App({ apis }: AppProps) {
+ {!isBootShell && ( diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 7f12ba0d..546b8e87 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -13,13 +13,15 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; -import { useUIStore } from '@/stores/useUIStore'; +import { useUIStore, type ContextPanelMode } from '@/stores/useUIStore'; import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useInputStore } from '@/sync/input-store'; import { ContextPanelContent } from './ContextSidebarTab'; import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; +import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo"; +import { invokeDesktopCommand } from '@/lib/desktopNative'; const CONTEXT_PANEL_MIN_WIDTH = 360; const CONTEXT_PANEL_MAX_WIDTH = 1400; @@ -231,7 +233,7 @@ const getRelativePathLabel = (filePath: string | null, directory: string): strin }; const getModeLabel = ( - mode: 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview', + mode: ContextPanelMode, t: TranslateFn ): string => { if (mode === 'chat') return t('contextPanel.mode.chat'); @@ -239,6 +241,7 @@ const getModeLabel = ( if (mode === 'diff') return t('contextPanel.mode.diff'); if (mode === 'plan') return t('contextPanel.mode.plan'); if (mode === 'preview') return t('contextPanel.mode.preview'); + if (mode === 'browser') return t('contextPanel.mode.browser'); return t('contextPanel.mode.context'); }; @@ -261,7 +264,7 @@ const getFileNameFromPath = (path: string | null): string | null => { }; const getTabLabel = ( - tab: { mode: 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview'; label: string | null; targetPath: string | null }, + tab: { mode: ContextPanelMode; label: string | null; targetPath: string | null }, t: TranslateFn ): string => { if (tab.label) { @@ -288,7 +291,7 @@ const getTabLabel = ( return getModeLabel(tab.mode, t); }; -const getTabIcon = (tab: { mode: 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview'; targetPath: string | null }): React.ReactNode | undefined => { +const getTabIcon = (tab: { mode: ContextPanelMode; targetPath: string | null }): React.ReactNode | undefined => { if (tab.mode === 'file') { return tab.targetPath ? @@ -315,6 +318,10 @@ const getTabIcon = (tab: { mode: 'diff' | 'file' | 'context' | 'plan' | 'chat' | return ; } + if (tab.mode === 'browser') { + return ; + } + return undefined; }; @@ -327,6 +334,158 @@ const getSessionIDFromDedupeKey = (dedupeKey: string | undefined): string | null return sessionID || null; }; +const DESKTOP_BROWSER_INSPECT_SCRIPT = `new Promise((resolve) => { + const existing = document.getElementById('__openchamber_desktop_browser_overlay'); + if (existing) existing.remove(); + if (typeof window.__openchamberDesktopBrowserCancelInspect === 'function') { + try { window.__openchamberDesktopBrowserCancelInspect(); } catch { /* webview not ready */ } + } + const overlay = document.createElement('div'); + overlay.id = '__openchamber_desktop_browser_overlay'; + overlay.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #60a5fa;background:rgba(96,165,250,.24);border-radius:3px;display:none;box-sizing:border-box;'; + document.documentElement.appendChild(overlay); + const cssEscape = (value) => { + try { return CSS.escape(value); } catch { return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\\\$&'); } + }; + const selectorPart = (element) => { + const tag = element.tagName.toLowerCase(); + if (element.id) return tag + '#' + cssEscape(element.id); + const className = String(element.className || '').trim().split(/\\s+/).filter(Boolean).slice(0, 3).map((part) => '.' + cssEscape(part)).join(''); + return tag + className; + }; + const metadata = (element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + const ancestry = []; + let current = element; + while (current && current.nodeType === Node.ELEMENT_NODE && ancestry.length < 8) { + ancestry.unshift({ tag: current.tagName.toLowerCase(), id: current.id || undefined, className: typeof current.className === 'string' ? current.className : undefined, selectorPart: selectorPart(current) }); + current = current.parentElement; + } + const attrs = {}; + for (const attr of Array.from(element.attributes || []).slice(0, 16)) attrs[attr.name] = attr.value.slice(0, 300); + const path = ancestry.map((entry) => entry.selectorPart).join(' > '); + return { + frame: 'top', + tag: element.tagName.toLowerCase(), + text: String(element.innerText || element.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 500), + selector: element.id ? '#' + cssEscape(element.id) : path, + path, + bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + center: { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }, + attributes: attrs, + computedStyle: { display: style.display, position: style.position, fontWeight: style.fontWeight, fontSize: style.fontSize, lineHeight: style.lineHeight, fontFamily: style.fontFamily, color: style.color, backgroundColor: style.backgroundColor, zIndex: style.zIndex }, + ancestry, + }; + }; + const move = (event) => { + const element = document.elementFromPoint(event.clientX, event.clientY); + if (!element || element === overlay || element === document.documentElement || element === document.body) return; + const rect = element.getBoundingClientRect(); + overlay.style.display = 'block'; + overlay.style.left = rect.left + 'px'; + overlay.style.top = rect.top + 'px'; + overlay.style.width = rect.width + 'px'; + overlay.style.height = rect.height + 'px'; + }; + const cleanup = () => { + window.removeEventListener('mousemove', move, true); + window.removeEventListener('click', click, true); + window.removeEventListener('keydown', keydown, true); + if (window.__openchamberDesktopBrowserCancelInspect === cancel) { + delete window.__openchamberDesktopBrowserCancelInspect; + } + }; + const cancel = () => { + cleanup(); + overlay.remove(); + resolve(null); + }; + const click = (event) => { + event.preventDefault(); + event.stopPropagation(); + const element = document.elementFromPoint(event.clientX, event.clientY); + const result = element ? metadata(element) : null; + cleanup(); + overlay.remove(); + resolve(result); + }; + const keydown = (event) => { + if (event.key !== 'Escape') return; + cancel(); + }; + window.__openchamberDesktopBrowserCancelInspect = cancel; + window.addEventListener('mousemove', move, true); + window.addEventListener('click', click, true); + window.addEventListener('keydown', keydown, true); +});`; + +const DESKTOP_BROWSER_CANCEL_INSPECT_SCRIPT = `(() => { + if (typeof window.__openchamberDesktopBrowserCancelInspect === 'function') { + window.__openchamberDesktopBrowserCancelInspect(); + return; + } + const overlay = document.getElementById('__openchamber_desktop_browser_overlay'); + if (overlay) overlay.remove(); +})()`; + +const normalizeBrowserUrl = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) return 'about:blank'; + try { + const parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return 'about:blank'; + return parsed.toString(); + } catch { + return 'about:blank'; + } +}; + +const desktopAnnotationToFile = async ( + base64: string, + screenshotWidth: number, + screenshotHeight: number, + cssWidth: number, + cssHeight: number, + target: PreviewElementMetadata, +): Promise => { + if (!base64) return null; + try { + const image = new Image(); + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error('Failed to load desktop browser screenshot')); + image.src = `data:image/jpeg;base64,${base64}`; + }); + + const width = Math.max(1, image.naturalWidth || screenshotWidth); + const height = Math.max(1, image.naturalHeight || screenshotHeight); + const maxOutputWidth = 1200; + const outputScale = Math.min(1, maxOutputWidth / width); + const canvas = document.createElement('canvas'); + canvas.width = Math.floor(width * outputScale); + canvas.height = Math.floor(height * outputScale); + const context = canvas.getContext('2d'); + if (!context) return null; + + context.scale(outputScale, outputScale); + context.drawImage(image, 0, 0, width, height); + const xScale = width / Math.max(1, cssWidth || width); + const yScale = height / Math.max(1, cssHeight || height); + context.fillStyle = 'rgba(37, 99, 235, 0.14)'; + context.strokeStyle = 'rgb(37, 99, 235)'; + context.lineWidth = Math.max(2, 2 * xScale); + context.fillRect(target.bounds.x * xScale, target.bounds.y * yScale, target.bounds.width * xScale, target.bounds.height * yScale); + context.strokeRect(target.bounds.x * xScale, target.bounds.y * yScale, target.bounds.width * xScale, target.bounds.height * yScale); + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.82)); + if (!blob) return null; + return new File([blob], `browser-annotation-${Date.now()}.jpg`, { type: 'image/jpeg' }); + } catch { + return null; + } +}; + const buildEmbeddedSessionChatURL = (sessionID: string, directory: string | null): string => { if (typeof window === 'undefined') { return ''; @@ -1116,6 +1275,268 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => { ); }; +type DesktopBrowserPaneProps = { + initialUrl: string; + directory: string; + tabID: string; +}; + +const DesktopBrowserPane: React.FC = ({ initialUrl, directory, tabID }) => { + const { t } = useI18n(); + const webviewRef = React.useRef(null); + const setContextPanelTabTargetPath = useUIStore((state) => state.setContextPanelTabTargetPath); + const normalized = normalizeBrowserUrl(initialUrl); + const startUrl = normalized !== 'about:blank' ? normalized : ''; + const [urlInput, setUrlInput] = React.useState(startUrl); + const [currentUrl, setCurrentUrl] = React.useState(startUrl); + const [isInspecting, setIsInspecting] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(true); + const loadingTimerRef = React.useRef | null>(null); + const showLoading = isLoading; + + const persistUrl = React.useCallback((url: string) => { + if (!url || url === 'about:blank' || !directory || !tabID) return; + setContextPanelTabTargetPath(directory, tabID, url); + }, [directory, tabID, setContextPanelTabTargetPath]); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open); + const addInlineCommentDraft = useInlineCommentDraftStore((state) => state.addDraft); + const addAttachedFile = useInputStore((state) => state.addAttachedFile); + + // Listen to webview navigation events + React.useEffect(() => { + const webview = webviewRef.current; + if (!webview) return; + + const syncUrl = () => { + try { + const url = webview.getURL(); + if (url && url !== 'about:blank') { + setCurrentUrl(url); + setUrlInput(url); + persistUrl(url); + } + } catch { /* webview not ready */ } + }; + + const onNavigate = (event: Event) => { + const detail = (event as CustomEvent<{ url: string }>).detail; + if (typeof detail?.url === 'string' && detail.url) { + setCurrentUrl(detail.url); + setUrlInput(detail.url); + persistUrl(detail.url); + } + }; + + const onStartLoading = () => { + if (loadingTimerRef.current) clearTimeout(loadingTimerRef.current); + loadingTimerRef.current = setTimeout(() => setIsLoading(true), 200); + }; + const onStopLoading = () => { + if (loadingTimerRef.current) clearTimeout(loadingTimerRef.current); + setIsLoading(false); + syncUrl(); + }; + + const onNewWindow = (event: Event) => { + const detail = (event as CustomEvent<{ url: string; disposition: string }>).detail; + if (detail?.disposition === 'new-window' || detail?.disposition === 'foreground-tab' || detail?.disposition === 'background-tab') { + event.preventDefault(); + const w = webviewRef.current; + if (typeof w?.loadURL === 'function' && detail.url) { + w.loadURL(detail.url); + } + } + }; + + webview.addEventListener('did-navigate', onNavigate); + webview.addEventListener('did-navigate-in-page', onNavigate); + webview.addEventListener('did-start-loading', onStartLoading); + webview.addEventListener('did-stop-loading', onStopLoading); + webview.addEventListener('new-window', onNewWindow); + + // Check current loading state imperatively — we may have missed the event + try { + if (!webview.isLoading()) { + setIsLoading(false); + syncUrl(); + } + } catch { /* webview not ready */ } + + return () => { + if (loadingTimerRef.current) clearTimeout(loadingTimerRef.current); + webview.removeEventListener('did-navigate', onNavigate); + webview.removeEventListener('did-navigate-in-page', onNavigate); + webview.removeEventListener('did-start-loading', onStartLoading); + webview.removeEventListener('did-stop-loading', onStopLoading); + webview.removeEventListener('new-window', onNewWindow); + }; + }, [persistUrl]); + + // Safety timeout: hide loading overlay after 30s even if events fire late + React.useEffect(() => { + const safety = setTimeout(() => setIsLoading(false), 30_000); + return () => clearTimeout(safety); + }, []); + + // Escape key cancels inspect mode + React.useEffect(() => { + if (!isInspecting) return; + const handler = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopImmediatePropagation(); + setIsInspecting(false); + const webview = webviewRef.current; + try { webview?.executeJavaScript?.(DESKTOP_BROWSER_CANCEL_INSPECT_SCRIPT).catch(() => {}); } catch { /* webview not ready */ } + }; + window.addEventListener('keydown', handler, true); + return () => window.removeEventListener('keydown', handler, true); + }, [isInspecting]); + + // Cancel inspect on unmount + React.useEffect(() => { + const webview = webviewRef.current; + return () => { + try { + const url = webview?.getURL?.(); + if (url && url !== 'about:blank') { + setContextPanelTabTargetPath(directory, tabID, url); + } + } catch { /* webview not ready */ } + try { webview?.executeJavaScript?.(DESKTOP_BROWSER_CANCEL_INSPECT_SCRIPT).catch(() => {}); } catch { /* webview not ready */ } + }; + }, [directory, tabID, setContextPanelTabTargetPath]); + + const loadUrl = React.useCallback((value: string) => { + const webview = webviewRef.current; + if (typeof webview?.loadURL !== 'function') return; + const nextUrl = normalizeBrowserUrl(value); + try { webview.loadURL(nextUrl); } catch { /* webview may not be ready */ } + }, []); + + const handleInspect = React.useCallback(() => { + const webview = webviewRef.current; + if (!webview) return; + + if (isInspecting) { + setIsInspecting(false); + try { webview.executeJavaScript?.(DESKTOP_BROWSER_CANCEL_INSPECT_SCRIPT).catch(() => {}); } catch { /* webview not ready */ } + return; + } + + setIsInspecting(true); + webview.executeJavaScript?.(DESKTOP_BROWSER_INSPECT_SCRIPT, true) + .then(async (target: unknown) => { + setIsInspecting(false); + if (!target || !isPreviewElementMetadata(target)) return; + + const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); + if (!sessionKey) { + toast.error(t('contextPanel.preview.inspect.attachNoSession')); + return; + } + + const wcId = typeof webview.getWebContentsId === 'function' ? webview.getWebContentsId() : null; + if (wcId === null || wcId === undefined) return; + + const capture = await invokeDesktopCommand<{ mime: string; base64: string; width: number; height: number }>( + 'desktop_browser_capture_page', { webContentsId: wcId } + ); + + const cssViewport = await webview.executeJavaScript?.( + '({ width: window.innerWidth, height: window.innerHeight })', true + ).catch(() => null) as { width: number; height: number } | null | undefined; + + const cssWidth = Number.isFinite(cssViewport?.width) ? (cssViewport as { width: number }).width : capture.width; + const cssHeight = Number.isFinite(cssViewport?.height) ? (cssViewport as { height: number }).height : capture.height; + + const file = await desktopAnnotationToFile(capture.base64, capture.width, capture.height, cssWidth, cssHeight, target); + const screenshotAttached = Boolean(file); + if (file) { + await addAttachedFile(file); + } + + addInlineCommentDraft({ + sessionKey, + source: 'preview-annotation', + fileLabel: currentUrl || 'browser', + startLine: 1, + endLine: 1, + code: formatPreviewAnnotationMarkdown({ + pageUrl: currentUrl, + viewport: { width: cssWidth, height: cssHeight }, + devicePixelRatio: window.devicePixelRatio || 1, + target, + screenshotAttached, + intro: t('contextPanel.preview.inspect.attachAnnotationWithScreenshot'), + }), + language: 'markdown', + text: '', + }); + toast.success(t('contextPanel.preview.inspect.attached')); + }) + .catch(() => setIsInspecting(false)); + }, [addAttachedFile, addInlineCommentDraft, currentSessionId, currentUrl, isInspecting, newSessionDraftOpen, t]); + + return ( +
+
+ + + +
{ event.preventDefault(); loadUrl(urlInput); }}> + setUrlInput(event.target.value)} + className="h-7 w-full rounded-md border border-border/50 bg-[var(--surface-elevated)] px-2 typography-micro text-foreground outline-none focus:border-[var(--interactive-focus-ring)]" + aria-label={t('contextPanel.browser.addressAria')} + /> +
+ + +
+
+ + {(!currentUrl || currentUrl === 'about:blank') && !isLoading ? ( +
+ + {t('contextPanel.browser.empty')} +
+ ) : null} + {showLoading ? ( +
+ {t('common.loading')} +
+ ) : null} +
+
+ ); +}; + export const ContextPanel: React.FC = () => { const { t } = useI18n(); const effectiveDirectory = useEffectiveDirectory() ?? ''; @@ -1390,6 +1811,10 @@ export const ContextPanel: React.FC = () => { () => tabs.filter((tab) => tab.mode === 'chat'), [tabs], ); + const browserTabs = React.useMemo( + () => tabs.filter((tab) => tab.mode === 'browser'), + [tabs], + ); const hasFileTabs = React.useMemo( () => tabs.some((tab) => tab.mode === 'file'), [tabs], @@ -1540,7 +1965,18 @@ export const ContextPanel: React.FC = () => { /> ); })} - {activeTab?.mode !== 'chat' && !isFileTabActive ? activeNonChatContent : null} + {browserTabs.map((tab) => ( +
+ +
+ ))} + {activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' ? activeNonChatContent : null}
); diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 5147f5a2..81defbdc 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -17,7 +17,7 @@ import { import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip'; import { DiffIcon } from '@/components/icons/DiffIcon'; -import { useUIStore, type MainTab } from '@/stores/useUIStore'; +import { useUIStore, type ContextPanelMode, type MainTab } from '@/stores/useUIStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; @@ -609,8 +609,8 @@ const normalize = (value: string): string => { const getActiveContextMode = (panelState: { isOpen: boolean; activeTabId: string | null; - tabs: Array<{ id: string; mode: 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview' }>; -} | undefined): 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview' | null => { + tabs: Array<{ id: string; mode: ContextPanelMode }>; +} | undefined): ContextPanelMode | null => { if (!panelState?.isOpen || !Array.isArray(panelState.tabs) || panelState.tabs.length === 0) { return null; } @@ -663,6 +663,7 @@ export const Header: React.FC = ({ const toggleRightSidebar = useUIStore((state) => state.toggleRightSidebar); const openContextOverview = useUIStore((state) => state.openContextOverview); const openContextPlan = useUIStore((state) => state.openContextPlan); + const openContextBrowser = useUIStore((state) => state.openContextBrowser); const closeContextPanel = useUIStore((state) => state.closeContextPanel); const contextPanelByDirectory = useUIStore((state) => state.contextPanelByDirectory); const activeMainTab = useUIStore((state) => state.activeMainTab); @@ -1343,6 +1344,14 @@ export const Header: React.FC = ({ openContextPlan(directory); }, [closeContextPanel, contextPanelByDirectory, openContextPlan, openDirectory]); + const handleOpenContextBrowser = React.useCallback(() => { + const directory = normalize(openDirectory || ''); + if (!directory) { + return; + } + openContextBrowser(directory); + }, [openContextBrowser, openDirectory]); + const isContextPlanActive = React.useMemo(() => { const directory = normalize(openDirectory || ''); if (!directory) { @@ -1770,7 +1779,7 @@ export const Header: React.FC = ({

{t('header.actions.planWithShortcut', { shortcut: shortcutLabel('toggle_context_plan') })}

- + )} = ({ onClick={toggleBottomTerminal} Icon={'terminal-box'} /> + {hasElectronDesktopIPC ? ( + + ) : null} = ({ }) => { const { t } = useI18n(); const [version, setVersion] = React.useState(null); + const [openCodeVersion, setOpenCodeVersion] = React.useState(null); const [isCopyingDiagnostics, setIsCopyingDiagnostics] = React.useState(false); const [copiedDiagnostics, setCopiedDiagnostics] = React.useState(false); const [diagnosticsReport, setDiagnosticsReport] = React.useState(null); @@ -79,6 +80,32 @@ export const AboutDialog: React.FC = ({ void fetchVersion(); }, [open]); + React.useEffect(() => { + if (!open) return; + + let cancelled = false; + const fetchOpenCodeVersion = async () => { + try { + const response = await fetch('/api/opencode/upgrade-status', { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) return; + const data = await response.json().catch(() => null) as null | { currentVersion?: unknown }; + const currentVersion = typeof data?.currentVersion === 'string' ? data.currentVersion.trim() : ''; + if (!cancelled && currentVersion) { + setOpenCodeVersion(currentVersion); + } + } catch { + // OpenCode version is best-effort in About. + } + }; + + void fetchOpenCodeVersion(); + return () => { + cancelled = true; + }; + }, [open]); + React.useEffect(() => { if (!open) { setDiagnosticsReport(null); @@ -118,11 +145,14 @@ export const AboutDialog: React.FC = ({

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 {