feat: browser-side annotation screenshots for web preview
Capture the preview/browser iframe DOM with snapDOM (html-to-image fallback) so web annotation screenshots match the visible viewport, without a headless Chromium dependency. - Preserve document scroll via viewport crop and re-bake nested scroll (e.g. the Starlight sidebar) deterministically on the clone - Pin position:fixed elements to their measured viewport rect so headers and sidebars land correctly in the crop - Extract preview capture/proxy helpers into lib/preview/screenshot-capture.ts to slim down ContextPanel - Guard the external preview proxy against SSRF to private, loopback and reserved/link-local addresses (incl. cloud metadata) - Fully validate preview bridge messages before formatting/use - Warn on the empty browser tab that pages run with full access, so users browse untrusted sites knowingly
This commit is contained in:
@@ -22,6 +22,16 @@ import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo";
|
||||
import { invokeDesktopCommand } from '@/lib/desktopNative';
|
||||
import {
|
||||
type PreviewElementMetadata,
|
||||
isPreviewElementMetadata,
|
||||
formatPreviewAnnotationMarkdown,
|
||||
renderPreviewScreenshot,
|
||||
desktopAnnotationToFile,
|
||||
getCachedProxyTarget,
|
||||
getBrowserProxyTargetKey,
|
||||
previewProxyTargetCache,
|
||||
} from '@/lib/preview/screenshot-capture';
|
||||
|
||||
const CONTEXT_PANEL_MIN_WIDTH = 380;
|
||||
const CONTEXT_PANEL_MAX_WIDTH = 1400;
|
||||
@@ -59,18 +69,6 @@ type PreviewBridgeMessage = {
|
||||
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;
|
||||
|
||||
@@ -81,117 +79,6 @@ const getPreviewConsoleFilterMatch = (event: PreviewConsoleEvent, filter: Previe
|
||||
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 '';
|
||||
@@ -498,51 +385,18 @@ const normalizeBrowserUrl = (value: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
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 runIframeScript = async <T,>(iframe: HTMLIFrameElement, script: string): Promise<T> => {
|
||||
const frameWindow = iframe.contentWindow;
|
||||
if (!frameWindow) {
|
||||
throw new Error('Iframe window is not available');
|
||||
}
|
||||
|
||||
const evaluate = (frameWindow as Window & { eval: (code: string) => unknown }).eval;
|
||||
const result = evaluate.call(frameWindow, script) as unknown;
|
||||
return await Promise.resolve(result) as T;
|
||||
};
|
||||
|
||||
|
||||
const buildEmbeddedSessionChatURL = (sessionID: string, directory: string | null, readOnly: boolean): string => {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
@@ -585,27 +439,6 @@ type PreviewProxyState =
|
||||
| { 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();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
@@ -1343,6 +1176,459 @@ type DesktopBrowserPaneProps = {
|
||||
tabID: string;
|
||||
};
|
||||
|
||||
const isElectronBrowserRuntime = (): boolean => {
|
||||
return typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__);
|
||||
};
|
||||
|
||||
const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, directory, tabID }) => {
|
||||
const { t } = useI18n();
|
||||
const iframeRef = React.useRef<HTMLIFrameElement | 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 [history, setHistory] = React.useState<string[]>(() => startUrl ? [startUrl] : []);
|
||||
const [historyIndex, setHistoryIndex] = React.useState(() => startUrl ? 0 : -1);
|
||||
const [reloadNonce, bumpReload] = React.useReducer((value: number) => value + 1, 0);
|
||||
const [isLoading, setIsLoading] = React.useState(Boolean(startUrl));
|
||||
const [isInspecting, setIsInspecting] = React.useState(false);
|
||||
const [hoverTarget, setHoverTarget] = React.useState<PreviewElementMetadata | null>(null);
|
||||
const [proxyState, setProxyState] = React.useState<PreviewProxyState>({ status: 'idle' });
|
||||
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);
|
||||
|
||||
const persistUrl = React.useCallback((url: string) => {
|
||||
if (!url || url === 'about:blank' || !directory || !tabID) return;
|
||||
setContextPanelTabTargetPath(directory, tabID, url);
|
||||
}, [directory, tabID, setContextPanelTabTargetPath]);
|
||||
|
||||
const applyUrl = React.useCallback((url: string, options?: { replaceHistory?: boolean }) => {
|
||||
const normalizedUrl = normalizeBrowserUrl(url);
|
||||
const nextUrl = normalizedUrl !== 'about:blank' ? normalizedUrl : '';
|
||||
setCurrentUrl(nextUrl);
|
||||
setUrlInput(nextUrl);
|
||||
setIsLoading(Boolean(nextUrl));
|
||||
persistUrl(nextUrl);
|
||||
|
||||
setHistory((current) => {
|
||||
if (!nextUrl) {
|
||||
setHistoryIndex(-1);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (options?.replaceHistory) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const kept = historyIndex >= 0 ? current.slice(0, historyIndex + 1) : [];
|
||||
const previous = kept[kept.length - 1];
|
||||
if (previous === nextUrl) {
|
||||
setHistoryIndex(kept.length - 1);
|
||||
return kept;
|
||||
}
|
||||
|
||||
const nextHistory = [...kept, nextUrl];
|
||||
setHistoryIndex(nextHistory.length - 1);
|
||||
return nextHistory;
|
||||
});
|
||||
}, [historyIndex, persistUrl]);
|
||||
|
||||
const goToHistory = React.useCallback((nextIndex: number) => {
|
||||
const nextUrl = history[nextIndex];
|
||||
if (!nextUrl) return;
|
||||
setHistoryIndex(nextIndex);
|
||||
setCurrentUrl(nextUrl);
|
||||
setUrlInput(nextUrl);
|
||||
setIsLoading(true);
|
||||
persistUrl(nextUrl);
|
||||
}, [history, persistUrl]);
|
||||
|
||||
const handleReload = React.useCallback(() => {
|
||||
if (!currentUrl) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
iframeRef.current?.contentWindow?.location.reload();
|
||||
} catch {
|
||||
bumpReload();
|
||||
}
|
||||
}, [currentUrl]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentUrl) {
|
||||
setProxyState({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
|
||||
const proxyTargetKey = getBrowserProxyTargetKey(currentUrl);
|
||||
const cached = getCachedProxyTarget(proxyTargetKey);
|
||||
if (cached) {
|
||||
setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, expiresAt: cached.expiresAt });
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setProxyState({ status: 'loading' });
|
||||
setIsLoading(true);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/preview/targets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ url: currentUrl, allowExternal: true }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
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) {
|
||||
if (!cancelled) {
|
||||
setProxyState({ status: 'error', message: t('contextPanel.preview.proxyError') });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
previewProxyTargetCache.set(proxyTargetKey, { proxyBasePath, expiresAt });
|
||||
if (!cancelled) {
|
||||
setProxyState({ status: 'ready', proxyBasePath, expiresAt });
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setProxyState({ status: 'error', message });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentUrl, t]);
|
||||
|
||||
const proxySrc = React.useMemo(() => {
|
||||
if (!currentUrl || proxyState.status !== 'ready') return '';
|
||||
try {
|
||||
const parsed = new URL(currentUrl);
|
||||
const path = parsed.pathname || '/';
|
||||
return `${proxyState.proxyBasePath}${path}${parsed.search}${parsed.hash}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [currentUrl, proxyState]);
|
||||
|
||||
const iframeSrc = proxySrc || (proxyState.status === 'error' ? currentUrl : '');
|
||||
|
||||
const getCurrentUrlFromFrameUrl = React.useCallback((frameUrl: string): string => {
|
||||
if (!frameUrl || !currentUrl || proxyState.status !== 'ready') return '';
|
||||
try {
|
||||
const parsedFrameUrl = new URL(frameUrl, window.location.origin);
|
||||
const proxyBasePath = proxyState.proxyBasePath.endsWith('/')
|
||||
? proxyState.proxyBasePath.slice(0, -1)
|
||||
: proxyState.proxyBasePath;
|
||||
if (parsedFrameUrl.origin !== window.location.origin || !parsedFrameUrl.pathname.startsWith(proxyBasePath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const rest = parsedFrameUrl.pathname.slice(proxyBasePath.length) || '/';
|
||||
const upstreamOrigin = new URL(currentUrl).origin;
|
||||
return new URL(`${rest}${parsedFrameUrl.search}${parsedFrameUrl.hash}`, upstreamOrigin).toString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [currentUrl, proxyState]);
|
||||
|
||||
const getUpstreamUrlFromLocalFrameUrl = React.useCallback((frameUrl: string): string => {
|
||||
if (!frameUrl || !currentUrl || proxyState.status !== 'ready') return '';
|
||||
try {
|
||||
const parsedFrameUrl = new URL(frameUrl, window.location.origin);
|
||||
const upstreamOrigin = new URL(currentUrl).origin;
|
||||
if (parsedFrameUrl.origin !== window.location.origin || upstreamOrigin === window.location.origin) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const proxyBasePath = proxyState.proxyBasePath.endsWith('/')
|
||||
? proxyState.proxyBasePath.slice(0, -1)
|
||||
: proxyState.proxyBasePath;
|
||||
if (parsedFrameUrl.pathname.startsWith(proxyBasePath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new URL(`${parsedFrameUrl.pathname}${parsedFrameUrl.search}${parsedFrameUrl.hash}`, upstreamOrigin).toString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [currentUrl, proxyState]);
|
||||
|
||||
const postInspectMode = React.useCallback((enabled: boolean) => {
|
||||
const frameWindow = iframeRef.current?.contentWindow;
|
||||
if (!frameWindow) return;
|
||||
frameWindow.postMessage({
|
||||
source: 'openchamber-preview-parent',
|
||||
version: 1,
|
||||
type: 'set-inspect-mode',
|
||||
enabled,
|
||||
}, window.location.origin);
|
||||
}, []);
|
||||
|
||||
const attachBrowserAnnotation = React.useCallback(async (target: PreviewElementMetadata) => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||
if (!sessionKey) {
|
||||
toast.error(t('contextPanel.preview.inspect.attachNoSession'));
|
||||
return;
|
||||
}
|
||||
|
||||
const iframe = iframeRef.current;
|
||||
const frameWindow = iframe?.contentWindow;
|
||||
const rect = iframe?.getBoundingClientRect();
|
||||
const viewport = {
|
||||
width: Number.isFinite(frameWindow?.innerWidth) ? frameWindow?.innerWidth ?? rect?.width ?? 0 : rect?.width ?? 0,
|
||||
height: Number.isFinite(frameWindow?.innerHeight) ? frameWindow?.innerHeight ?? rect?.height ?? 0 : rect?.height ?? 0,
|
||||
};
|
||||
|
||||
const file = iframe ? await renderPreviewScreenshot(iframe, target) : null;
|
||||
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,
|
||||
devicePixelRatio: window.devicePixelRatio || 1,
|
||||
target,
|
||||
screenshotAttached,
|
||||
intro: t(screenshotAttached
|
||||
? 'contextPanel.preview.inspect.attachAnnotationWithScreenshot'
|
||||
: 'contextPanel.preview.inspect.attachAnnotation'),
|
||||
}),
|
||||
language: 'markdown',
|
||||
text: '',
|
||||
});
|
||||
toast.success(t('contextPanel.preview.inspect.attached'));
|
||||
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, currentUrl, newSessionDraftOpen, t]);
|
||||
|
||||
const cancelInspect = React.useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
setHoverTarget(null);
|
||||
postInspectMode(false);
|
||||
if (!iframe) return;
|
||||
void runIframeScript<unknown>(iframe, DESKTOP_BROWSER_CANCEL_INSPECT_SCRIPT).catch(() => {});
|
||||
}, [postInspectMode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isInspecting) return;
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
setIsInspecting(false);
|
||||
cancelInspect();
|
||||
};
|
||||
window.addEventListener('keydown', handler, true);
|
||||
return () => window.removeEventListener('keydown', handler, true);
|
||||
}, [cancelInspect, isInspecting]);
|
||||
|
||||
React.useEffect(() => () => cancelInspect(), [cancelInspect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
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') {
|
||||
const frameUrl = typeof data.url === 'string' ? data.url : '';
|
||||
const nextUrl = getCurrentUrlFromFrameUrl(frameUrl);
|
||||
if (nextUrl && nextUrl !== currentUrl) {
|
||||
applyUrl(nextUrl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'hover') {
|
||||
setHoverTarget(isPreviewElementMetadata(data.target) ? data.target : null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'select' && isPreviewElementMetadata(data.target)) {
|
||||
setHoverTarget(null);
|
||||
setIsInspecting(false);
|
||||
postInspectMode(false);
|
||||
void attachBrowserAnnotation(data.target);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'navigate-preview') {
|
||||
const nextUrl = typeof data.url === 'string' ? data.url : '';
|
||||
const upstreamUrl = getUpstreamUrlFromLocalFrameUrl(nextUrl);
|
||||
if (upstreamUrl) {
|
||||
applyUrl(upstreamUrl);
|
||||
return;
|
||||
}
|
||||
if (nextUrl) {
|
||||
applyUrl(nextUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handler);
|
||||
return () => window.removeEventListener('message', handler);
|
||||
}, [applyUrl, attachBrowserAnnotation, currentUrl, getCurrentUrlFromFrameUrl, getUpstreamUrlFromLocalFrameUrl, postInspectMode]);
|
||||
|
||||
const handleInspect = React.useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe || !currentUrl) return;
|
||||
|
||||
if (isInspecting) {
|
||||
setIsInspecting(false);
|
||||
cancelInspect();
|
||||
return;
|
||||
}
|
||||
|
||||
if (proxySrc) {
|
||||
setHoverTarget(null);
|
||||
setIsInspecting(true);
|
||||
postInspectMode(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsInspecting(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const target = await runIframeScript<unknown>(iframe, DESKTOP_BROWSER_INSPECT_SCRIPT);
|
||||
setIsInspecting(false);
|
||||
if (!target || !isPreviewElementMetadata(target)) return;
|
||||
await attachBrowserAnnotation(target);
|
||||
} catch {
|
||||
setIsInspecting(false);
|
||||
toast.error(t('contextPanel.browser.inspectUnavailable'));
|
||||
}
|
||||
})();
|
||||
}, [attachBrowserAnnotation, cancelInspect, currentUrl, isInspecting, postInspectMode, proxySrc, t]);
|
||||
|
||||
const handleIframeLoad = React.useCallback(() => {
|
||||
try {
|
||||
const frameUrl = iframeRef.current?.contentWindow?.location.href || '';
|
||||
const upstreamUrl = getUpstreamUrlFromLocalFrameUrl(frameUrl);
|
||||
if (upstreamUrl) {
|
||||
setIsLoading(true);
|
||||
applyUrl(upstreamUrl);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin direct iframe fallback; regular load handling still applies.
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
if (isInspecting && proxySrc) {
|
||||
postInspectMode(true);
|
||||
}
|
||||
}, [applyUrl, getUpstreamUrlFromLocalFrameUrl, isInspecting, postInspectMode, proxySrc]);
|
||||
|
||||
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" disabled={historyIndex <= 0} onClick={() => goToHistory(historyIndex - 1)}>
|
||||
<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" disabled={historyIndex < 0 || historyIndex >= history.length - 1} onClick={() => goToHistory(historyIndex + 1)}>
|
||||
<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" disabled={!currentUrl} onClick={handleReload}>
|
||||
<Icon name="refresh" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<form className="min-w-0 flex-1" onSubmit={(event) => { event.preventDefault(); applyUrl(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"
|
||||
disabled={!currentUrl}
|
||||
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" disabled={!currentUrl} 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">
|
||||
{iframeSrc ? (
|
||||
<div className="absolute inset-0">
|
||||
<iframe
|
||||
key={`${iframeSrc}:${reloadNonce}`}
|
||||
ref={iframeRef}
|
||||
src={iframeSrc}
|
||||
title={t('contextPanel.browser.empty')}
|
||||
className="absolute inset-0 h-full w-full border-0 bg-background"
|
||||
allow="clipboard-read; clipboard-write; fullscreen"
|
||||
allowFullScreen
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
{isInspecting && 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>
|
||||
) : (
|
||||
<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>
|
||||
<span className="max-w-sm typography-micro text-muted-foreground">{t('contextPanel.browser.emptyHint')}</span>
|
||||
<span className="max-w-md typography-micro leading-relaxed text-status-warning/70">{t('contextPanel.browser.trustNotice')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
const DesktopBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, directory, tabID }) => {
|
||||
const { t } = useI18n();
|
||||
const webviewRef = React.useRef<WebviewElement | null>(null);
|
||||
@@ -1932,6 +2218,7 @@ export const ContextPanel: React.FC = () => {
|
||||
() => tabs.filter((tab) => tab.mode === 'browser'),
|
||||
[tabs],
|
||||
);
|
||||
const BrowserPane = isElectronBrowserRuntime() ? DesktopBrowserPane : IframeBrowserPane;
|
||||
const hasFileTabs = React.useMemo(
|
||||
() => tabs.some((tab) => tab.mode === 'file'),
|
||||
[tabs],
|
||||
@@ -2095,10 +2382,10 @@ export const ContextPanel: React.FC = () => {
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
'absolute inset-0',
|
||||
activeTab?.mode !== 'browser' && 'hidden'
|
||||
activeTab?.id !== tab.id && 'hidden'
|
||||
)}
|
||||
>
|
||||
<DesktopBrowserPane initialUrl={tab.targetPath ?? ''} directory={directoryKey} tabID={tab.id} />
|
||||
<BrowserPane initialUrl={tab.targetPath ?? ''} directory={directoryKey} tabID={tab.id} />
|
||||
</div>
|
||||
))}
|
||||
{activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' ? activeNonChatContent : null}
|
||||
|
||||
@@ -1960,7 +1960,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
onClick={toggleBottomTerminal}
|
||||
Icon={'terminal-box'}
|
||||
/>
|
||||
{hasElectronDesktopIPC ? (
|
||||
{!isMobile ? (
|
||||
<HeaderIconActionButton
|
||||
title={t('contextPanel.browser.open')}
|
||||
ariaLabel={t('contextPanel.browser.open')}
|
||||
|
||||
Reference in New Issue
Block a user