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
@@ -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;
};