Files
openchamber/packages/ui/src/components/layout/ContextPanel.tsx
T

2000 lines
73 KiB
TypeScript
Raw Normal View History

import React from 'react';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { Button } from '@/components/ui/button';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { DiffView } from '@/components/views/DiffView';
import { FilesView } from '@/components/views/FilesView';
import { PlanView } from '@/components/views/PlanView';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { openExternalUrl } from '@/lib/url';
import { copyTextToClipboard } from '@/lib/clipboard';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
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;
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
const CONTEXT_TAB_LABEL_MAX_CHARS = 24;
type TranslateFn = ReturnType<typeof useI18n>['t'];
type PreviewConsoleEvent = {
id: number;
level: 'log' | 'info' | 'warn' | 'error' | 'debug' | 'resource' | 'runtime';
message: string;
details?: string;
ts: number;
};
type PreviewConsoleFilter = 'all' | 'errors' | 'warnings' | 'logs';
type PreviewBridgeMessage = {
source?: string;
version?: number;
type?: string;
level?: PreviewConsoleEvent['level'];
args?: unknown[];
message?: unknown;
stack?: unknown;
filename?: unknown;
line?: unknown;
column?: unknown;
tag?: unknown;
url?: unknown;
outerHTML?: unknown;
title?: unknown;
ts?: unknown;
target?: unknown;
2026-05-07 00:02:01 +03:00
navigation?: unknown;
};
type PreviewElementMetadata = {
frame: 'top';
tag: string;
text: string;
selector: string;
path: string;
bounds: { x: number; y: number; width: number; height: number };
center: { x: number; y: number };
attributes: Record<string, string>;
computedStyle: Record<string, string>;
ancestry: Array<{ tag: string; id?: string; className?: string; selectorPart: string }>;
};
const PREVIEW_CONSOLE_EVENT_LIMIT = 200;
const getPreviewConsoleFilterMatch = (event: PreviewConsoleEvent, filter: PreviewConsoleFilter): boolean => {
if (filter === 'all') return true;
if (filter === 'errors') return event.level === 'error' || event.level === 'runtime' || event.level === 'resource';
if (filter === 'warnings') return event.level === 'warn';
return event.level === 'log' || event.level === 'info' || event.level === 'debug';
};
const isPreviewElementMetadata = (value: unknown): value is PreviewElementMetadata => {
if (!value || typeof value !== 'object') return false;
const record = value as Partial<PreviewElementMetadata>;
const bounds = record.bounds;
return typeof record.tag === 'string'
&& typeof record.selector === 'string'
&& typeof record.path === 'string'
&& Boolean(bounds)
&& typeof bounds?.x === 'number'
&& typeof bounds?.y === 'number'
&& typeof bounds?.width === 'number'
&& typeof bounds?.height === 'number';
};
const formatPreviewAnnotationMarkdown = ({
pageUrl,
viewport,
devicePixelRatio,
target,
screenshotAttached,
intro,
}: {
pageUrl: string;
viewport: { width: number; height: number };
devicePixelRatio: number;
target: PreviewElementMetadata;
screenshotAttached: boolean;
intro: string;
}): string => {
const text = target.text.trim();
const attributes = Object.entries(target.attributes)
.map(([key, value]) => `${key}="${value}"`)
.join(' ');
const styles = target.computedStyle;
const bounds = target.bounds;
const center = target.center;
const introLabel = intro.replace(/[.:]+$/g, '');
const ancestry = target.ancestry
.map((entry) => entry.selectorPart)
.join(' > ');
return [
`${introLabel}:`,
`Page: ${pageUrl || 'preview'}`,
`Viewport: ${viewport.width}x${viewport.height}, DPR ${devicePixelRatio}`,
`Screenshot: ${screenshotAttached ? 'attached' : 'not attached'}`,
`Element: ${target.tag}`,
text ? `Text: ${text}` : null,
`- Selector: ${target.selector}`,
`- Path: ${target.path}`,
ancestry ? `- Ancestry: ${ancestry}` : null,
attributes ? `- Attributes: ${attributes}` : null,
`- Bounds: x=${Math.round(bounds.x)}, y=${Math.round(bounds.y)}, width=${Math.round(bounds.width)}, height=${Math.round(bounds.height)}`,
`- Center: x=${Math.round(center.x)}, y=${Math.round(center.y)}`,
`Styles: display=${styles.display}; position=${styles.position}; font=${styles.fontWeight} ${styles.fontSize} / ${styles.lineHeight} ${styles.fontFamily}; color=${styles.color}; background=${styles.backgroundColor}; z-index=${styles.zIndex}`,
].filter((line): line is string => typeof line === 'string').join('\n');
};
const renderPreviewScreenshot = async (
iframe: HTMLIFrameElement,
target: PreviewElementMetadata,
): Promise<File | null> => {
const tauri = typeof window !== 'undefined'
? (window as unknown as { __TAURI__?: { core?: { invoke?: <T>(cmd: string, args?: Record<string, unknown>) => Promise<T> } } }).__TAURI__
: undefined;
if (typeof tauri?.core?.invoke === 'function') {
try {
const rect = iframe.getBoundingClientRect();
const capture = await tauri.core.invoke<{ mime: string; base64: string; width: number; height: number }>('desktop_capture_page_rect', {
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
});
const image = new Image();
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error('Failed to load desktop preview screenshot'));
image.src = `data:${capture.mime};base64,${capture.base64}`;
});
const width = Math.max(1, image.naturalWidth || capture.width || Math.floor(rect.width));
const height = Math.max(1, image.naturalHeight || capture.height || Math.floor(rect.height));
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, rect.width);
const yScale = height / Math.max(1, rect.height);
context.fillStyle = 'rgba(37, 99, 235, 0.28)';
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], `preview-annotation-${Date.now()}.jpg`, { type: 'image/jpeg' });
} catch (error) {
console.warn('[preview] failed to capture annotation screenshot:', error);
return null;
}
}
return null;
};
const normalizeDirectoryKey = (value: string): string => {
if (!value) return '';
const raw = value.replace(/\\/g, '/');
const hadUncPrefix = raw.startsWith('//');
let normalized = raw.replace(/\/+$/g, '');
normalized = normalized.replace(/\/+/g, '/');
if (hadUncPrefix && !normalized.startsWith('//')) {
normalized = `/${normalized}`;
}
if (normalized === '') {
return raw.startsWith('/') ? '/' : '';
}
return normalized;
};
const clampWidth = (width: number): number => {
if (!Number.isFinite(width)) {
return CONTEXT_PANEL_DEFAULT_WIDTH;
}
return Math.min(CONTEXT_PANEL_MAX_WIDTH, Math.max(CONTEXT_PANEL_MIN_WIDTH, Math.round(width)));
};
const getRelativePathLabel = (filePath: string | null, directory: string): string => {
if (!filePath) {
return '';
}
const normalizedFile = filePath.replace(/\\/g, '/');
const normalizedDir = directory.replace(/\\/g, '/').replace(/\/+$/, '');
if (normalizedDir && normalizedFile.startsWith(normalizedDir + '/')) {
return normalizedFile.slice(normalizedDir.length + 1);
}
return normalizedFile;
};
const getModeLabel = (
mode: ContextPanelMode,
t: TranslateFn
): string => {
if (mode === 'chat') return t('contextPanel.mode.chat');
if (mode === 'file') return t('contextPanel.mode.files');
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');
};
const getFileNameFromPath = (path: string | null): string | null => {
if (!path) {
return null;
}
const normalized = path.replace(/\\/g, '/').trim();
if (!normalized) {
return null;
}
const segments = normalized.split('/').filter(Boolean);
if (segments.length === 0) {
return normalized;
}
return segments[segments.length - 1] || null;
};
const getTabLabel = (
tab: { mode: ContextPanelMode; label: string | null; targetPath: string | null; stagedDiff?: boolean },
t: TranslateFn
): string => {
if (tab.label) {
return tab.label;
}
if (tab.mode === 'file') {
return getFileNameFromPath(tab.targetPath) || t('contextPanel.mode.files');
}
if (tab.mode === 'preview') {
const url = tab.targetPath;
if (url) {
try {
const parsed = new URL(url);
return parsed.host || parsed.hostname || t('contextPanel.mode.preview');
} catch {
// ignore invalid URL
}
}
return t('contextPanel.mode.preview');
}
if (tab.mode === 'diff') {
return tab.stagedDiff ? t('contextPanel.mode.stagedDiff') : t('contextPanel.mode.workingDiff');
}
return getModeLabel(tab.mode, t);
};
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" />
: undefined;
}
if (tab.mode === 'diff') {
return <Icon name="arrow-left-right" className="h-3.5 w-3.5" />;
}
if (tab.mode === 'plan') {
return <Icon name="file-text" className="h-3.5 w-3.5" />;
}
if (tab.mode === 'context') {
return <Icon name="donut-chart-fill" className="h-3.5 w-3.5" />;
}
if (tab.mode === 'chat') {
return <Icon name="chat-4" className="h-3.5 w-3.5" />;
}
if (tab.mode === 'preview') {
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;
};
const getSessionIDFromDedupeKey = (dedupeKey: string | undefined): string | null => {
if (!dedupeKey || !dedupeKey.startsWith('session:')) {
return null;
}
const sessionID = dedupeKey.slice('session:'.length).trim();
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, readOnly: boolean): string => {
if (typeof window === 'undefined') {
return '';
}
const url = new URL(window.location.pathname, window.location.origin);
url.searchParams.set('ocPanel', 'session-chat');
url.searchParams.set('sessionId', sessionID);
if (readOnly) {
url.searchParams.set('readOnly', '1');
} else {
url.searchParams.delete('readOnly');
}
if (directory && directory.trim().length > 0) {
url.searchParams.set('directory', directory);
} else {
url.searchParams.delete('directory');
}
url.hash = '';
return url.toString();
};
const truncateTabLabel = (value: string, maxChars: number): string => {
if (value.length <= maxChars) {
return value;
}
return `${value.slice(0, maxChars - 3)}...`;
};
type PreviewPaneProps = {
rawUrl: string;
onNavigate: (url: string) => void;
};
type PreviewProxyState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; proxyBasePath: string; expiresAt: number }
| { status: 'error'; message: string };
// Module-scoped, in-memory cache of registered proxy targets keyed by the
// fully-qualified upstream URL. Survives PreviewPane unmount/remount and tab
// switches, but intentionally does NOT survive a full page reload: the server
// holds the target map in memory and the auth cookie is HttpOnly + scoped to
// the proxy id, so a stale persisted entry would 404 after a server restart.
// Entries are evicted on registration error (refetched) or when the upstream
// returns 403 (cookie expired) / 404 (target unknown) at iframe load time.
type CachedProxyTarget = { proxyBasePath: string; expiresAt: number };
const previewProxyTargetCache = new Map<string, CachedProxyTarget>();
const PREVIEW_PROXY_CACHE_SAFETY_MS = 30_000;
const getCachedProxyTarget = (url: string): CachedProxyTarget | null => {
const entry = previewProxyTargetCache.get(url);
if (!entry) return null;
if (entry.expiresAt - Date.now() <= PREVIEW_PROXY_CACHE_SAFETY_MS) {
previewProxyTargetCache.delete(url);
return null;
}
return entry;
};
const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
const { t } = useI18n();
2026-05-07 00:02:01 +03:00
const { currentTheme } = useThemeSystem();
const [reloadNonce, bumpReload] = React.useReducer((x: number) => x + 1, 0);
const [proxyRegistrationNonce, bumpProxyRegistration] = React.useReducer((x: number) => x + 1, 0);
const [proxyState, setProxyState] = React.useState<PreviewProxyState>({ status: 'idle' });
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
const nextConsoleEventIdRef = React.useRef(1);
const [bridgeReady, setBridgeReady] = React.useState(false);
const [consoleOpen, setConsoleOpen] = React.useState(false);
const [consoleFilter, setConsoleFilter] = React.useState<PreviewConsoleFilter>('all');
const [consoleEvents, setConsoleEvents] = React.useState<PreviewConsoleEvent[]>([]);
const [inspectMode, setInspectMode] = React.useState(false);
const [hoverTarget, setHoverTarget] = React.useState<PreviewElementMetadata | null>(null);
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);
let parsedUrl: URL | null = null;
try {
parsedUrl = rawUrl ? new URL(rawUrl) : null;
} catch {
parsedUrl = null;
}
const isLoopback = parsedUrl
? (parsedUrl.hostname === 'localhost'
|| parsedUrl.hostname === '127.0.0.1'
|| parsedUrl.hostname === '::1'
|| parsedUrl.hostname === '[::1]'
|| parsedUrl.hostname === '0.0.0.0')
: false;
const normalizedUrl = parsedUrl
? (parsedUrl.hostname === '0.0.0.0'
? new URL(parsedUrl.toString().replace('0.0.0.0', '127.0.0.1'))
: parsedUrl)
: null;
const targetKey = normalizedUrl ? normalizedUrl.toString() : '';
2026-05-07 00:02:01 +03:00
const previewColorScheme = currentTheme.metadata.variant;
React.useEffect(() => {
if (!targetKey || !isLoopback) {
setProxyState({ status: 'idle' });
return;
}
const cached = getCachedProxyTarget(targetKey);
if (cached) {
setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, expiresAt: cached.expiresAt });
return;
}
let cancelled = false;
setProxyState({ status: 'loading' });
void (async () => {
try {
const response = await fetch('/api/preview/targets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ url: targetKey }),
});
if (!response.ok) {
previewProxyTargetCache.delete(targetKey);
const errorBody = await response.json().catch(() => ({}));
const message = typeof errorBody?.error === 'string'
? errorBody.error
: `HTTP ${response.status}`;
if (!cancelled) {
setProxyState({ status: 'error', message });
}
return;
}
const body = await response.json() as { proxyBasePath?: unknown; expiresAt?: unknown };
const proxyBasePath = typeof body.proxyBasePath === 'string' ? body.proxyBasePath : '';
const expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : 0;
if (!proxyBasePath) {
previewProxyTargetCache.delete(targetKey);
if (!cancelled) {
setProxyState({ status: 'error', message: t('contextPanel.preview.proxyError') });
}
return;
}
previewProxyTargetCache.set(targetKey, { proxyBasePath, expiresAt });
if (!cancelled) {
setProxyState({ status: 'ready', proxyBasePath, expiresAt });
}
} catch (error) {
previewProxyTargetCache.delete(targetKey);
if (!cancelled) {
const message = error instanceof Error ? error.message : String(error);
setProxyState({ status: 'error', message });
}
}
})();
return () => {
cancelled = true;
};
}, [isLoopback, proxyRegistrationNonce, t, targetKey]);
const directSrc = normalizedUrl
&& (normalizedUrl.protocol === 'http:' || normalizedUrl.protocol === 'https:')
? normalizedUrl.toString()
: '';
const proxySrc = isLoopback && proxyState.status === 'ready' && normalizedUrl
? (() => {
const path = normalizedUrl.pathname || '/';
const searchParams = new URLSearchParams(normalizedUrl.search);
searchParams.set('ocPreview', String(reloadNonce));
const search = searchParams.toString();
const hash = normalizedUrl.hash || '';
return `${proxyState.proxyBasePath}${path}${search ? `?${search}` : ''}${hash}`;
})()
: '';
const effectiveSrc = isLoopback ? proxySrc : directSrc;
const headerSrc = effectiveSrc || directSrc;
const showLoading = isLoopback && (proxyState.status === 'loading' || proxyState.status === 'idle');
const showError = isLoopback && proxyState.status === 'error';
const attachPreviewAnnotation = React.useCallback((target: PreviewElementMetadata) => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
if (!sessionKey) {
toast.error(t('contextPanel.preview.inspect.attachNoSession'));
return;
}
const pageUrl = rawUrl || effectiveSrc || '';
const viewport = typeof window !== 'undefined'
? { width: window.innerWidth, height: window.innerHeight }
: { width: 0, height: 0 };
const devicePixelRatio = typeof window !== 'undefined' ? window.devicePixelRatio : 1;
void (async () => {
let attachedScreenshot = false;
try {
const iframe = iframeRef.current;
const screenshot = iframe ? await renderPreviewScreenshot(iframe, target) : null;
if (screenshot) {
await addAttachedFile(screenshot);
attachedScreenshot = true;
}
} catch {
attachedScreenshot = false;
}
addInlineCommentDraft({
sessionKey,
source: 'preview-annotation',
fileLabel: pageUrl || 'preview',
startLine: 1,
endLine: 1,
code: formatPreviewAnnotationMarkdown({
pageUrl,
viewport,
devicePixelRatio,
target,
screenshotAttached: attachedScreenshot,
intro: t('contextPanel.preview.inspect.attachAnnotation'),
}),
language: 'markdown',
text: '',
});
toast.success(t('contextPanel.preview.inspect.attached'));
})();
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, effectiveSrc, newSessionDraftOpen, rawUrl, t]);
React.useEffect(() => {
setBridgeReady(false);
setConsoleEvents([]);
setConsoleOpen(false);
setConsoleFilter('all');
setInspectMode(false);
setHoverTarget(null);
nextConsoleEventIdRef.current = 1;
}, [effectiveSrc]);
React.useEffect(() => {
const frameWindow = iframeRef.current?.contentWindow;
if (!bridgeReady || !frameWindow) {
return;
}
frameWindow.postMessage({
source: 'openchamber-preview-parent',
version: 1,
type: 'set-inspect-mode',
enabled: inspectMode,
}, window.location.origin);
}, [bridgeReady, inspectMode]);
2026-05-07 00:02:01 +03:00
React.useEffect(() => {
const frameWindow = iframeRef.current?.contentWindow;
if (!bridgeReady || !frameWindow) {
return;
}
frameWindow.postMessage({
source: 'openchamber-preview-parent',
version: 1,
type: 'set-color-scheme',
scheme: previewColorScheme,
}, window.location.origin);
}, [bridgeReady, previewColorScheme]);
React.useEffect(() => {
if (!inspectMode || typeof window === 'undefined') return;
const handler = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopImmediatePropagation();
setInspectMode(false);
}
};
window.addEventListener('keydown', handler, true);
return () => window.removeEventListener('keydown', handler, true);
}, [inspectMode]);
React.useEffect(() => {
if (!isLoopback || typeof window === 'undefined') {
return;
}
const stringify = (value: unknown): string => {
if (typeof value === 'string') return value;
if (value === null || value === undefined) return '';
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
const pushConsoleEvent = (event: Omit<PreviewConsoleEvent, 'id'>) => {
const id = nextConsoleEventIdRef.current;
nextConsoleEventIdRef.current += 1;
setConsoleEvents((current) => {
const next = [...current, { ...event, id }];
return next.length > PREVIEW_CONSOLE_EVENT_LIMIT
? next.slice(next.length - PREVIEW_CONSOLE_EVENT_LIMIT)
: next;
});
};
const handler = (event: MessageEvent<PreviewBridgeMessage>) => {
if (event.source !== iframeRef.current?.contentWindow) {
return;
}
const data = event.data;
if (!data || data.source !== 'openchamber-preview-bridge' || data.version !== 1) {
return;
}
if (data.type === 'ready') {
setBridgeReady(true);
return;
}
if (data.type === 'console') {
const level = data.level === 'error' || data.level === 'warn' || data.level === 'info' || data.level === 'debug'
? data.level
: 'log';
const args = Array.isArray(data.args) ? data.args.map(stringify).filter(Boolean) : [];
pushConsoleEvent({
level,
message: args.join(' '),
ts: typeof data.ts === 'number' ? data.ts : Date.now(),
});
return;
}
if (data.type === 'runtime-error') {
const filename = stringify(data.filename);
const line = typeof data.line === 'number' ? data.line : null;
const column = typeof data.column === 'number' ? data.column : null;
const location = filename
? `${filename}${line !== null ? `:${line}${column !== null ? `:${column}` : ''}` : ''}`
: '';
const stack = stringify(data.stack);
pushConsoleEvent({
level: 'runtime',
message: stringify(data.message) || t('contextPanel.preview.console.runtimeError'),
details: [location, stack].filter(Boolean).join('\n'),
ts: typeof data.ts === 'number' ? data.ts : Date.now(),
});
return;
}
if (data.type === 'resource-error') {
const tag = stringify(data.tag) || 'resource';
const url = stringify(data.url);
pushConsoleEvent({
level: 'resource',
message: url ? `${tag}: ${url}` : tag,
details: stringify(data.outerHTML),
ts: typeof data.ts === 'number' ? data.ts : Date.now(),
});
return;
}
if (data.type === 'hover') {
setHoverTarget(isPreviewElementMetadata(data.target) ? data.target : null);
return;
}
if (data.type === 'select' && isPreviewElementMetadata(data.target)) {
setHoverTarget(data.target);
setInspectMode(false);
attachPreviewAnnotation(data.target);
return;
}
if (data.type === 'navigate-preview') {
const nextUrl = typeof data.url === 'string' ? data.url : '';
2026-05-07 00:02:01 +03:00
const navigation = data.navigation === 'external' ? 'external' : 'proxy';
if (nextUrl && navigation === 'external') {
void openExternalUrl(nextUrl);
return;
}
if (nextUrl) {
onNavigate(nextUrl);
}
}
};
window.addEventListener('message', handler);
return () => {
window.removeEventListener('message', handler);
};
}, [attachPreviewAnnotation, isLoopback, onNavigate, t]);
const consoleErrorCount = consoleEvents.filter((event) => event.level === 'error' || event.level === 'runtime' || event.level === 'resource').length;
const filteredConsoleEvents = consoleEvents.filter((event) => getPreviewConsoleFilterMatch(event, consoleFilter));
const copyConsoleEvents = React.useCallback(() => {
const header = [
`Preview URL: ${rawUrl || effectiveSrc || ''}`,
`Events: ${consoleEvents.length}`,
'',
].join('\n');
const text = consoleEvents.map((event) => {
const timestamp = new Date(event.ts).toISOString();
const details = event.details ? `\n${event.details}` : '';
return `[${timestamp}] [${event.level}] ${event.message}${details}`;
}).join('\n');
void copyTextToClipboard(`${header}${text}`).then((result) => {
if (result.ok) {
toast.success(t('contextPanel.preview.console.copied'));
} else {
toast.error(t('contextPanel.preview.console.copyFailed'));
}
});
}, [consoleEvents, effectiveSrc, rawUrl, t]);
const attachConsoleEvents = React.useCallback(() => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
if (!sessionKey) {
toast.error(t('contextPanel.preview.console.attachNoSession'));
return;
}
const header = [
`Preview URL: ${rawUrl || effectiveSrc || ''}`,
`Events: ${consoleEvents.length}`,
'',
].join('\n');
const text = consoleEvents.map((event) => {
const timestamp = new Date(event.ts).toISOString();
const details = event.details ? `\n${event.details}` : '';
return `[${timestamp}] [${event.level}] ${event.message}${details}`;
}).join('\n');
addInlineCommentDraft({
sessionKey,
source: 'preview-console',
fileLabel: rawUrl || effectiveSrc || 'preview',
startLine: 1,
endLine: Math.max(1, consoleEvents.length),
code: `${header}${text}`,
language: 'text',
text: t('contextPanel.preview.console.attachAnnotation'),
});
toast.success(t('contextPanel.preview.console.attached'));
}, [addInlineCommentDraft, consoleEvents, currentSessionId, effectiveSrc, newSessionDraftOpen, rawUrl, t]);
// Out-of-band upstream probe: iframes don't expose HTTP status to the parent,
// so when the proxy returns a 502 (upstream dev server is offline) the iframe
// would just render the raw JSON error body. Probe the proxy URL with a HEAD
// request and surface a friendly overlay when the upstream is unreachable.
type UpstreamState = 'unknown' | 'starting' | 'reachable' | 'unreachable';
const [upstreamState, setUpstreamState] = React.useState<UpstreamState>('unknown');
const upstreamProbeStartedAtRef = React.useRef<number>(0);
const upstreamProbeAttemptRef = React.useRef<number>(0);
const PREVIEW_STARTUP_GRACE_MS = 15_000;
React.useEffect(() => {
if (!proxySrc) {
setUpstreamState('unknown');
upstreamProbeStartedAtRef.current = 0;
upstreamProbeAttemptRef.current = 0;
return;
}
let cancelled = false;
if (!upstreamProbeStartedAtRef.current) {
upstreamProbeStartedAtRef.current = Date.now();
upstreamProbeAttemptRef.current = 0;
}
setUpstreamState('unknown');
void (async () => {
const probe = async (): Promise<Response | null> => {
try {
return await fetch(proxySrc, {
method: 'GET',
credentials: 'include',
cache: 'no-store',
redirect: 'manual',
});
} catch {
return null;
}
};
const response = await probe();
if (cancelled) return;
if (!response) {
// Network-level failure (e.g. server itself is down) — treat as unreachable.
setUpstreamState('unreachable');
return;
}
if (response.status === 403 || response.status === 404) {
previewProxyTargetCache.delete(targetKey);
setProxyState({ status: 'loading' });
bumpProxyRegistration();
return;
}
// The proxy emits 502 when the upstream is unreachable. Anything else
// (including 4xx from the upstream) means the upstream answered.
if (response.status !== 502) {
setUpstreamState('reachable');
return;
}
const startedAt = upstreamProbeStartedAtRef.current || Date.now();
const elapsed = Date.now() - startedAt;
if (elapsed < PREVIEW_STARTUP_GRACE_MS) {
// Dev servers can take a moment to bind. During the grace window,
// keep retrying and show a softer "starting" state.
setUpstreamState('starting');
upstreamProbeAttemptRef.current += 1;
const attempt = upstreamProbeAttemptRef.current;
const delay = Math.min(2000, 250 * Math.pow(2, Math.min(4, attempt)));
setTimeout(() => {
if (!cancelled) {
bumpReload();
}
}, delay).unref?.();
return;
}
setUpstreamState('unreachable');
})();
return () => {
cancelled = true;
};
}, [proxySrc, reloadNonce, targetKey]);
const showUpstreamStarting = isLoopback
&& proxyState.status === 'ready'
&& (upstreamState === 'unknown' || upstreamState === 'starting');
const showUpstreamUnreachable = isLoopback
&& proxyState.status === 'ready'
&& upstreamState === 'unreachable';
const handlePreviewFrameLoad = React.useCallback((event: React.SyntheticEvent<HTMLIFrameElement>) => {
if (!isLoopback || proxyState.status !== 'ready') {
return;
}
if (typeof window === 'undefined') {
return;
}
const frameWindow = event.currentTarget.contentWindow;
if (!frameWindow) {
return;
}
try {
const location = frameWindow.location;
if (location.origin !== window.location.origin) {
return;
}
if (location.pathname.startsWith(proxyState.proxyBasePath)) {
return;
}
const nextPath = `${proxyState.proxyBasePath}${location.pathname}${location.search}${location.hash}`;
frameWindow.location.replace(nextPath);
} catch {
// Cross-origin frames are expected for non-loopback/direct previews.
}
}, [isLoopback, proxyState]);
return (
<div className="absolute inset-0 flex flex-col">
<div className="flex items-center gap-1 border-b border-border/40 bg-[var(--surface-background)] px-2 py-1">
<div className="min-w-0 flex-1 truncate typography-micro text-muted-foreground" title={headerSrc || rawUrl}>
{headerSrc || rawUrl || t('contextPanel.preview.empty')}
</div>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => bumpReload()}
title={t('contextPanel.preview.actions.reload')}
aria-label={t('contextPanel.preview.actions.reload')}
disabled={!effectiveSrc}
>
<Icon name="refresh" className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => {
if (!directSrc) return;
void openExternalUrl(directSrc);
}}
title={t('contextPanel.preview.actions.openExternal')}
aria-label={t('contextPanel.preview.actions.openExternal')}
disabled={!directSrc}
>
<Icon name="external-link" className="h-3.5 w-3.5" />
</Button>
{isLoopback ? (
<Button
type="button"
size="sm"
variant={inspectMode ? 'secondary' : 'ghost'}
className="h-7 gap-1 px-2"
onClick={() => setInspectMode((value) => !value)}
title={t('contextPanel.preview.inspect.toggle')}
aria-label={t('contextPanel.preview.inspect.toggle')}
disabled={!bridgeReady}
>
<Icon name="cursor" className="h-3.5 w-3.5" />
</Button>
) : null}
{isLoopback ? (
<Button
type="button"
size="sm"
variant={consoleOpen ? 'secondary' : 'ghost'}
className="h-7 gap-1 px-2"
onClick={() => setConsoleOpen((value) => !value)}
title={bridgeReady ? t('contextPanel.preview.console.open') : t('contextPanel.preview.console.waiting')}
aria-label={bridgeReady ? t('contextPanel.preview.console.open') : t('contextPanel.preview.console.waiting')}
disabled={!bridgeReady && consoleEvents.length === 0}
>
<Icon name="terminal-box" className="h-3.5 w-3.5" />
{consoleErrorCount > 0 ? (
<span className="typography-micro text-status-error">{consoleErrorCount}</span>
) : null}
</Button>
) : null}
</div>
<div className="relative min-h-0 flex-1 bg-background">
{showUpstreamStarting ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground">
<div>{t('contextPanel.preview.startingServer')}</div>
<div className="text-xs opacity-70">{t('contextPanel.preview.startingServerHint')}</div>
</div>
) : showUpstreamUnreachable ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground">
<div>{t('contextPanel.preview.upstreamUnreachable')}</div>
<div className="text-xs opacity-70">{t('contextPanel.preview.upstreamUnreachableHint')}</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => bumpReload()}
>
{t('contextPanel.preview.actions.retry')}
</Button>
</div>
) : effectiveSrc && (!isLoopback || upstreamState === 'reachable') ? (
<div className="relative h-full w-full">
<iframe
ref={iframeRef}
key={`${effectiveSrc}:${reloadNonce}`}
src={effectiveSrc}
title={t('contextPanel.preview.iframeTitle')}
className="h-full w-full border-0"
2026-05-07 00:02:01 +03:00
style={{ colorScheme: previewColorScheme }}
onLoad={handlePreviewFrameLoad}
sandbox={isLoopback
? 'allow-scripts allow-same-origin allow-forms allow-popups allow-downloads'
: 'allow-scripts allow-forms'}
/>
{inspectMode && hoverTarget ? (
<div
className="pointer-events-none absolute rounded-sm border-2 border-[var(--interactive-focus-ring)] bg-[var(--interactive-focus-ring)]/35"
style={{
left: hoverTarget.bounds.x,
top: hoverTarget.bounds.y,
width: hoverTarget.bounds.width,
height: hoverTarget.bounds.height,
}}
>
<div className="absolute -top-6 left-0 max-w-64 truncate rounded bg-[var(--surface-elevated)] px-2 py-0.5 typography-micro text-foreground shadow">
{hoverTarget.tag}{hoverTarget.text ? ` · ${hoverTarget.text}` : ''}
</div>
</div>
) : null}
</div>
) : showLoading ? (
<div className="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">
{t('contextPanel.preview.loading')}
</div>
) : showError ? (
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-sm text-muted-foreground">
<div>{t('contextPanel.preview.proxyError')}</div>
{proxyState.status === 'error' ? (
<div className="text-center text-xs opacity-70">{proxyState.message}</div>
) : null}
</div>
) : (
<div className="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">
{t('contextPanel.preview.invalidUrl')}
</div>
)}
{consoleOpen ? (
<div className="absolute inset-x-3 bottom-3 z-10 max-h-[45%] overflow-hidden rounded-xl border border-border/70 bg-[var(--surface-elevated)] shadow-lg">
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2">
<div className="typography-ui-label text-foreground">{t('contextPanel.preview.console.title')}</div>
<div className="flex items-center gap-1">
<Button
type="button"
size="xs"
variant="ghost"
onClick={attachConsoleEvents}
disabled={consoleEvents.length === 0}
>
{t('contextPanel.preview.console.attach')}
</Button>
<Button
type="button"
size="xs"
variant="ghost"
onClick={copyConsoleEvents}
disabled={consoleEvents.length === 0}
>
{t('contextPanel.preview.console.copy')}
</Button>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => setConsoleEvents([])}
disabled={consoleEvents.length === 0}
>
{t('contextPanel.preview.console.clear')}
</Button>
</div>
</div>
<div className="flex items-center gap-1 border-b border-border/30 px-3 py-1.5">
{(['all', 'errors', 'warnings', 'logs'] as const).map((filter) => (
<Button
key={filter}
type="button"
size="xs"
variant={consoleFilter === filter ? 'secondary' : 'ghost'}
onClick={() => setConsoleFilter(filter)}
>
{filter === 'all'
? t('contextPanel.preview.console.filter.all')
: filter === 'errors'
? t('contextPanel.preview.console.filter.errors')
: filter === 'warnings'
? t('contextPanel.preview.console.filter.warnings')
: t('contextPanel.preview.console.filter.logs')}
</Button>
))}
</div>
<div className="max-h-64 overflow-auto p-2 typography-code text-xs">
{consoleEvents.length === 0 ? (
<div className="px-2 py-3 text-muted-foreground">{t('contextPanel.preview.console.empty')}</div>
) : filteredConsoleEvents.length === 0 ? (
<div className="px-2 py-3 text-muted-foreground">{t('contextPanel.preview.console.noFilteredEvents')}</div>
) : filteredConsoleEvents.map((event) => (
<div key={event.id} className="border-b border-border/30 px-2 py-1 last:border-b-0">
<div className="flex gap-2">
<span className={cn(
'shrink-0 uppercase',
event.level === 'error' || event.level === 'runtime' || event.level === 'resource'
? 'text-status-error'
: event.level === 'warn'
? 'text-status-warning'
: 'text-muted-foreground'
)}>
{event.level}
</span>
<span className="min-w-0 break-words text-foreground">{event.message}</span>
</div>
</div>
))}
</div>
</div>
) : null}
</div>
</div>
);
};
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() ?? '';
const directoryKey = React.useMemo(() => normalizeDirectoryKey(effectiveDirectory), [effectiveDirectory]);
const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined));
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
const closeContextPanelTab = useUIStore((state) => state.closeContextPanelTab);
const toggleContextPanelExpanded = useUIStore((state) => state.toggleContextPanelExpanded);
const setContextPanelWidth = useUIStore((state) => state.setContextPanelWidth);
const setActiveContextPanelTab = useUIStore((state) => state.setActiveContextPanelTab);
const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs);
const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath);
const openContextPreview = useUIStore((state) => state.openContextPreview);
const { themeMode, lightThemeId, darkThemeId, currentTheme } = useThemeSystem();
const tabs = React.useMemo(() => panelState?.tabs ?? [], [panelState?.tabs]);
const activeTab = tabs.find((tab) => tab.id === panelState?.activeTabId) ?? tabs[tabs.length - 1] ?? null;
const isOpen = Boolean(panelState?.isOpen && activeTab);
const isExpanded = Boolean(isOpen && panelState?.expanded);
const width = clampWidth(panelState?.width ?? CONTEXT_PANEL_DEFAULT_WIDTH);
const [isResizing, setIsResizing] = React.useState(false);
const startXRef = React.useRef(0);
const startWidthRef = React.useRef(width);
const resizingWidthRef = React.useRef<number | null>(null);
const activeResizePointerIDRef = React.useRef<number | null>(null);
const panelRef = React.useRef<HTMLElement | null>(null);
const chatFrameRefs = React.useRef<Map<string, HTMLIFrameElement>>(new Map());
const wasOpenRef = React.useRef(false);
React.useEffect(() => {
if (!isOpen || wasOpenRef.current) {
wasOpenRef.current = isOpen;
return;
}
const frame = window.requestAnimationFrame(() => {
panelRef.current?.focus({ preventScroll: true });
});
wasOpenRef.current = true;
return () => window.cancelAnimationFrame(frame);
}, [isOpen]);
const applyLiveWidth = React.useCallback((nextWidth: number) => {
const panel = panelRef.current;
if (!panel) {
return;
}
panel.style.setProperty('--oc-context-panel-width', `${nextWidth}px`);
}, []);
const handleResizeStart = React.useCallback((event: React.PointerEvent) => {
if (!isOpen || isExpanded || !directoryKey) {
return;
}
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// ignore; fallback listeners still handle drag
}
activeResizePointerIDRef.current = event.pointerId;
setIsResizing(true);
startXRef.current = event.clientX;
startWidthRef.current = width;
resizingWidthRef.current = width;
applyLiveWidth(width);
event.preventDefault();
}, [applyLiveWidth, directoryKey, isExpanded, isOpen, width]);
const handleResizeMove = React.useCallback((event: React.PointerEvent) => {
if (!isResizing || activeResizePointerIDRef.current !== event.pointerId) {
return;
}
const delta = startXRef.current - event.clientX;
const nextWidth = clampWidth(startWidthRef.current + delta);
if (resizingWidthRef.current === nextWidth) {
return;
}
resizingWidthRef.current = nextWidth;
applyLiveWidth(nextWidth);
}, [applyLiveWidth, isResizing]);
const handleResizeEnd = React.useCallback((event: React.PointerEvent) => {
if (activeResizePointerIDRef.current !== event.pointerId || !directoryKey) {
return;
}
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// ignore
}
const finalWidth = resizingWidthRef.current ?? width;
setIsResizing(false);
activeResizePointerIDRef.current = null;
resizingWidthRef.current = null;
setContextPanelWidth(directoryKey, finalWidth);
}, [directoryKey, setContextPanelWidth, width]);
React.useEffect(() => {
if (!isResizing) {
resizingWidthRef.current = null;
}
}, [isResizing]);
const handleClose = React.useCallback(() => {
if (!directoryKey) {
return;
}
closeContextPanel(directoryKey);
}, [closeContextPanel, directoryKey]);
const handleToggleExpanded = React.useCallback(() => {
if (!directoryKey) {
return;
}
toggleContextPanelExpanded(directoryKey);
}, [directoryKey, toggleContextPanelExpanded]);
const handlePanelKeyDownCapture = React.useCallback((event: React.KeyboardEvent<HTMLElement>) => {
if (event.key !== 'Escape') {
return;
}
event.preventDefault();
event.stopPropagation();
handleClose();
}, [handleClose]);
React.useEffect(() => {
if (!directoryKey || !activeTab) {
return;
}
if (activeTab.mode === 'file' && activeTab.targetPath) {
setSelectedFilePath(directoryKey, activeTab.targetPath);
return;
}
}, [activeTab, directoryKey, setSelectedFilePath]);
const activeChatTabID = activeTab?.mode === 'chat' ? activeTab.id : null;
const postThemeSyncToEmbeddedChat = React.useCallback(() => {
if (typeof window === 'undefined') {
return;
}
const payload = {
themeMode,
lightThemeId,
darkThemeId,
currentTheme,
};
for (const frame of chatFrameRefs.current.values()) {
const frameWindow = frame.contentWindow;
if (!frameWindow) {
continue;
}
const directThemeSync = (frameWindow as unknown as {
__openchamberApplyThemeSync?: (themePayload: typeof payload) => void;
}).__openchamberApplyThemeSync;
if (typeof directThemeSync === 'function') {
try {
directThemeSync(payload);
continue;
} catch {
// fallback to postMessage below
}
}
frameWindow.postMessage(
{
type: 'openchamber:theme-sync',
payload,
},
window.location.origin,
);
}
}, [currentTheme, darkThemeId, lightThemeId, themeMode]);
const postEmbeddedVisibilityToChats = React.useCallback(() => {
if (typeof window === 'undefined') {
return;
}
for (const [tabID, frame] of chatFrameRefs.current.entries()) {
const frameWindow = frame.contentWindow;
if (!frameWindow) {
continue;
}
const payload = { visible: activeChatTabID === tabID };
const directVisibilitySync = (frameWindow as unknown as {
__openchamberSetEmbeddedVisibility?: (visibilityPayload: typeof payload) => void;
}).__openchamberSetEmbeddedVisibility;
if (typeof directVisibilitySync === 'function') {
try {
directVisibilitySync(payload);
continue;
} catch {
// fallback to postMessage below
}
}
frameWindow.postMessage(
{
type: 'openchamber:embedded-visibility',
payload,
},
window.location.origin,
);
}
}, [activeChatTabID]);
React.useLayoutEffect(() => {
const hasAnyChatTab = tabs.some((tab) => tab.mode === 'chat');
if (!hasAnyChatTab) {
return;
}
postThemeSyncToEmbeddedChat();
postEmbeddedVisibilityToChats();
}, [darkThemeId, lightThemeId, postEmbeddedVisibilityToChats, postThemeSyncToEmbeddedChat, tabs, themeMode]);
const tabItems = React.useMemo(() => tabs.map((tab) => {
const rawLabel = getTabLabel(tab, t);
const label = truncateTabLabel(rawLabel, CONTEXT_TAB_LABEL_MAX_CHARS);
const tabPathLabel = getRelativePathLabel(tab.targetPath, effectiveDirectory);
return {
id: tab.id,
label,
icon: getTabIcon(tab),
title: tabPathLabel ? `${rawLabel}: ${tabPathLabel}` : rawLabel,
closeLabel: t('contextPanel.tab.closeTabAria', { label }),
};
}), [effectiveDirectory, t, tabs]);
const activeNonChatContent = activeTab?.mode === 'diff'
? (
<DiffView
key={activeTab.id}
hideStackedFileSidebar
stackedDefaultCollapsedAll
hideFileSelector
pinSelectedFileHeaderToTopOnNavigate
showOpenInEditorAction
diffScope={activeTab.stagedDiff ? 'staged' : 'working'}
targetFilePath={activeTab.targetPath}
/>
)
: activeTab?.mode === 'context'
? <ContextPanelContent />
: activeTab?.mode === 'plan'
? <PlanView targetPath={activeTab.targetPath} />
: activeTab?.mode === 'preview'
? <PreviewPane rawUrl={activeTab.targetPath ?? ''} onNavigate={(url) => openContextPreview(effectiveDirectory, url)} />
: (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<Icon name="global" className="h-12 w-12 text-muted-foreground/50" />
<div className="typography-ui-header text-foreground">{t('contextPanel.preview.title')}</div>
<div className="max-w-sm typography-micro text-muted-foreground">{t('contextPanel.preview.description')}</div>
</div>
);
const chatTabs = React.useMemo(
() => 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],
);
const isFileTabActive = activeTab?.mode === 'file';
const header = (
<header className="flex h-10 items-stretch border-b border-transparent">
<SortableTabsStrip
items={tabItems}
activeId={activeTab?.id ?? null}
onSelect={(tabID) => {
if (!directoryKey) {
return;
}
setActiveContextPanelTab(directoryKey, tabID);
}}
onClose={(tabID) => {
if (!directoryKey) {
return;
}
closeContextPanelTab(directoryKey, tabID);
}}
onReorder={(activeTabID, overTabID) => {
if (!directoryKey) {
return;
}
reorderContextPanelTabs(directoryKey, activeTabID, overTabID);
}}
layoutMode="scrollable"
variant="default"
/>
<div className="flex items-center gap-1 px-1.5">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleToggleExpanded}
className="h-7 w-7 p-0"
title={isExpanded ? t('contextPanel.actions.collapsePanel') : t('contextPanel.actions.expandPanel')}
aria-label={isExpanded ? t('contextPanel.actions.collapsePanel') : t('contextPanel.actions.expandPanel')}
>
{isExpanded ? <Icon name="fullscreen-exit" className="h-3.5 w-3.5" /> : <Icon name="fullscreen" className="h-3.5 w-3.5" />}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClose}
className="h-7 w-7 p-0"
title={t('contextPanel.actions.closePanel')}
aria-label={t('contextPanel.actions.closePanel')}
>
<Icon name="close" className="h-3.5 w-3.5" />
</Button>
</div>
</header>
);
if (!isOpen) {
return null;
}
const panelStyle: React.CSSProperties = isExpanded
? {
['--oc-context-panel-width' as string]: '100%',
width: '100%',
minWidth: '100%',
maxWidth: '100%',
}
: {
width: 'var(--oc-context-panel-width)',
minWidth: 'var(--oc-context-panel-width)',
maxWidth: 'var(--oc-context-panel-width)',
['--oc-context-panel-width' as string]: `${isResizing ? (resizingWidthRef.current ?? width) : width}px`,
};
return (
<aside
ref={panelRef}
data-context-panel="true"
tabIndex={-1}
className={cn(
'flex min-h-0 flex-col overflow-hidden bg-background',
!isExpanded && 'border-l border-border/40',
isExpanded
? 'absolute inset-0 z-20 min-w-0'
: 'relative h-full flex-shrink-0',
isResizing ? 'transition-none' : 'transition-[width] duration-200 ease-in-out'
)}
onKeyDownCapture={handlePanelKeyDownCapture}
style={panelStyle}
>
{!isExpanded && (
<div
className={cn(
'absolute left-0 top-0 z-20 h-full w-[3px] cursor-col-resize transition-colors hover:bg-[var(--interactive-border)]/80',
isResizing && 'bg-[var(--interactive-border)]'
)}
onPointerDown={handleResizeStart}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeEnd}
onPointerCancel={handleResizeEnd}
role="separator"
aria-orientation="vertical"
aria-label={t('contextPanel.actions.resizePanelAria')}
/>
)}
{header}
<div className={cn('relative min-h-0 flex-1 overflow-hidden', isResizing && 'pointer-events-none')}>
{hasFileTabs ? (
<div className={cn('absolute inset-0', isFileTabActive ? 'block' : 'hidden')}>
<FilesView mode="editor-only" />
</div>
) : null}
{chatTabs.map((tab) => {
const sessionID = getSessionIDFromDedupeKey(tab.dedupeKey);
if (!sessionID) {
return null;
}
const src = buildEmbeddedSessionChatURL(sessionID, directoryKey || null, tab.readOnly);
if (!src) {
return null;
}
return (
<iframe
key={tab.id}
ref={(node) => {
if (!node) {
chatFrameRefs.current.delete(tab.id);
return;
}
chatFrameRefs.current.set(tab.id, node);
}}
src={src}
title={t('contextPanel.iframe.sessionChatTitle', { sessionID })}
className={cn(
'absolute inset-0 h-full w-full border-0 bg-background',
activeChatTabID === tab.id ? 'block' : 'hidden'
)}
onLoad={() => {
postThemeSyncToEmbeddedChat();
postEmbeddedVisibilityToChats();
}}
/>
);
})}
{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>
);
};