feat: add OpenCode update and in-app Browser features

This commit is contained in:
Bohdan Triapitsyn
2026-05-14 14:45:04 +03:00
parent 92f1fa7dfe
commit a12be061e3
18 changed files with 1006 additions and 17 deletions
+19 -1
View File
@@ -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');
+3
View File
@@ -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) {
<div className="h-full text-foreground bg-background">
<EmbeddedSessionSelectionGate embeddedSessionChat={embeddedSessionChat} isVSCodeRuntime={isVSCodeRuntime} />
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
<OpenCodeUpdateToast />
<ChatView />
<Toaster />
</div>
@@ -824,6 +826,7 @@ function App({ apis }: AppProps) {
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
<div className={isDesktopRuntime ? 'h-full text-foreground bg-transparent' : 'h-full text-foreground bg-background'}>
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
<OpenCodeUpdateToast />
<MainLayout />
<Toaster />
{!isBootShell && (
@@ -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
? <FileTypeIcon filePath={tab.targetPath} className="h-3.5 w-3.5" />
@@ -315,6 +318,10 @@ const getTabIcon = (tab: { mode: 'diff' | 'file' | 'context' | 'plan' | 'chat' |
return <Icon name="global" className="h-3.5 w-3.5 text-[var(--status-info)]" />;
}
if (tab.mode === 'browser') {
return <Icon name="global" className="h-3.5 w-3.5" />;
}
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<File | null> => {
if (!base64) return null;
try {
const image = new Image();
await new Promise<void>((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<Blob | null>((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<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
);
};
type DesktopBrowserPaneProps = {
initialUrl: string;
directory: string;
tabID: string;
};
const DesktopBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, directory, tabID }) => {
const { t } = useI18n();
const webviewRef = React.useRef<WebviewElement | null>(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<ReturnType<typeof setTimeout> | 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 (
<div className="absolute inset-0 flex flex-col bg-background">
<div className="flex items-center gap-1 border-b border-border/40 bg-[var(--surface-background)] px-2 py-1">
<Button type="button" variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => { try { webviewRef.current?.goBack?.(); } catch { /* webview not ready */ } }}>
<Icon name="arrow-left" className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => { try { webviewRef.current?.goForward?.(); } catch { /* webview not ready */ } }}>
<Icon name="arrow-right" className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => { try { webviewRef.current?.reload?.(); } catch { /* webview not ready */ } }}>
<Icon name="refresh" className="h-3.5 w-3.5" />
</Button>
<form className="min-w-0 flex-1" onSubmit={(event) => { event.preventDefault(); loadUrl(urlInput); }}>
<input
value={urlInput}
onChange={(event) => 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')}
/>
</form>
<Button
type="button"
variant={isInspecting ? 'secondary' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={handleInspect}
title={t('contextPanel.preview.inspect.toggle')}
aria-label={t('contextPanel.preview.inspect.toggle')}
>
<Icon name="cursor" className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => void openExternalUrl(currentUrl)}>
<Icon name="external-link" className="h-3.5 w-3.5" />
</Button>
</div>
<div className="relative min-h-0 flex-1 bg-background">
<webview
ref={webviewRef}
src={normalizeBrowserUrl(initialUrl)}
partition="persist:openchamber-browser"
style={{ width: '100%', height: '100%', border: 'none' }}
/>
{(!currentUrl || currentUrl === 'about:blank') && !isLoading ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-6 bg-background p-6 text-center">
<OpenChamberLogo width={140} height={140} className="opacity-20" />
<span className="typography-ui-header text-muted-foreground">{t('contextPanel.browser.empty')}</span>
</div>
) : null}
{showLoading ? (
<div className="absolute inset-0 flex items-center justify-center bg-background/70 typography-micro text-muted-foreground">
{t('common.loading')}
</div>
) : null}
</div>
</div>
);
};
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) => (
<div
key={tab.id}
className={cn(
'absolute inset-0',
activeTab?.mode !== 'browser' && 'hidden'
)}
>
<DesktopBrowserPane initialUrl={tab.targetPath ?? ''} directory={directoryKey} tabID={tab.id} />
</div>
))}
{activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' ? activeNonChatContent : null}
</div>
</aside>
);
+21 -4
View File
@@ -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<HeaderProps> = ({
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<HeaderProps> = ({
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<HeaderProps> = ({
<TooltipContent>
<p>{t('header.actions.planWithShortcut', { shortcut: shortcutLabel('toggle_context_plan') })}</p>
</TooltipContent>
</Tooltip>
</Tooltip>
)}
<OpenInAppButton directory={actionDirectory} className="mr-1" />
<DesktopServicesMenu
@@ -1807,6 +1816,14 @@ export const Header: React.FC<HeaderProps> = ({
onClick={toggleBottomTerminal}
Icon={'terminal-box'}
/>
{hasElectronDesktopIPC ? (
<HeaderIconActionButton
title={t('contextPanel.browser.open')}
ariaLabel={t('contextPanel.browser.open')}
onClick={handleOpenContextBrowser}
Icon={'global'}
/>
) : null}
<HeaderIconActionButton
title={t('header.actions.rightSidebarWithShortcut', { shortcut: shortcutLabel('toggle_right_sidebar') })}
ariaLabel={t('header.actions.toggleRightSidebarAria')}
+35 -5
View File
@@ -22,6 +22,7 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
}) => {
const { t } = useI18n();
const [version, setVersion] = React.useState<string | null>(null);
const [openCodeVersion, setOpenCodeVersion] = React.useState<string | null>(null);
const [isCopyingDiagnostics, setIsCopyingDiagnostics] = React.useState(false);
const [copiedDiagnostics, setCopiedDiagnostics] = React.useState(false);
const [diagnosticsReport, setDiagnosticsReport] = React.useState<string | null>(null);
@@ -79,6 +80,32 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
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<AboutDialogProps> = ({
<div className="space-y-1">
<h2 className="text-lg font-semibold">OpenChamber</h2>
{displayVersion && (
<p className="typography-meta text-muted-foreground">
{t('aboutDialog.versionLabel', { version: displayVersion })}
</p>
)}
<div className="space-y-0.5 typography-meta text-muted-foreground">
{displayVersion && (
<p>{t('aboutDialog.openChamberVersionLabel', { version: displayVersion })}</p>
)}
{openCodeVersion && (
<p>{t('aboutDialog.openCodeVersionLabel', { version: openCodeVersion })}</p>
)}
</div>
</div>
<div className="flex flex-col items-center gap-2 pt-2">
@@ -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<string>());
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: <Icon name="refresh" className="h-4 w-4 animate-spin text-muted-foreground" />,
});
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: <Icon name="check" className="h-4 w-4 text-[var(--status-success)]" />,
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<ReturnType<typeof setTimeout>> = [];
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;
};
+19
View File
@@ -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',
+19
View File
@@ -752,6 +752,11 @@ export const dict: Record<I18nKey, string> = {
"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<I18nKey, string> = {
"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<I18nKey, string> = {
"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",
+19
View File
@@ -752,6 +752,11 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
'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<I18nKey, string> = {
'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': '스트리밍',
+19
View File
@@ -675,6 +675,8 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
'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<I18nKey, string> = {
'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',
@@ -752,6 +752,11 @@ export const dict: Record<I18nKey, string> = {
"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<I18nKey, string> = {
"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<I18nKey, string> = {
"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",
+19
View File
@@ -752,6 +752,11 @@ export const dict: Record<I18nKey, string> = {
"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<I18nKey, string> = {
"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<I18nKey, string> = {
"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": "Потокове передавання",
@@ -752,6 +752,11 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
'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<I18nKey, string> = {
'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': '流式',
+42 -2
View File
@@ -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<string, ContextPanelDirectoryState> => {
@@ -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<UIStore>()(
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());
+13
View File
@@ -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: () => {
+28
View File
@@ -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<unknown>;
}
namespace JSX {
interface IntrinsicElements {
webview: React.DetailedHTMLProps<
React.HTMLAttributes<WebviewElement> & {
src?: string;
partition?: string;
preload?: string;
nodeintegration?: string;
allowpopups?: string;
ref?: React.Ref<WebviewElement>;
},
WebviewElement
>;
}
}
}
export {};
@@ -82,6 +82,8 @@ export const createFeatureRoutesRuntime = (dependencies) => {
getProviderSources,
removeProviderConfig,
refreshOpenCodeAfterConfigChange,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
});
registerProjectIconRoutes(app, {
+130
View File
@@ -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 {