feat: add OpenCode update and in-app Browser features
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user