diff --git a/bun.lock b/bun.lock index feb3f111..e962c162 100644 --- a/bun.lock +++ b/bun.lock @@ -98,7 +98,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.11.6", + "version": "1.11.7", "devDependencies": { "@tauri-apps/cli": "^2", "@types/node": "^24.3.1", @@ -107,7 +107,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.11.6", + "version": "1.11.7", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -122,7 +122,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.11.6", + "version": "1.11.7", "dependencies": { "@base-ui/react": "^1.4.0", "@codemirror/autocomplete": "^6.20.0", @@ -159,6 +159,7 @@ "@tanstack/react-virtual": "^3.13.18", "@types/react-syntax-highlighter": "^15.5.13", "@xenova/transformers": "^2.17.2", + "@zumer/snapdom": "^2.12.0", "beautiful-mermaid": "^1.1.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -222,7 +223,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.11.6", + "version": "1.11.7", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "^1.15.10", @@ -245,7 +246,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.11.6", + "version": "1.11.7", "bin": { "openchamber": "./bin/cli.js", }, @@ -1445,6 +1446,8 @@ "@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="], + "@zumer/snapdom": ["@zumer/snapdom@2.12.0", "", {}, "sha512-TdLGu+1RkKI3JKfMFvn1gRPD/rl8hrAeN6aFjjd0w7S39nulbd94ChxSlfH7nSCm8kzuQkeWFxIsij4+Mk1RDg=="], + "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index 084fd44c..b93fb042 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -11,6 +11,7 @@ "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js" }, "dependencies": { + "@base-ui/react": "^1.4.0", "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", "@codemirror/lang-cpp": "^6.0.3", @@ -41,11 +42,11 @@ "@lezer/highlight": "^1.2.3", "@opencode-ai/sdk": "^1.15.10", "@pierre/diffs": "1.1.0-beta.13", - "@base-ui/react": "^1.4.0", "@simplewebauthn/browser": "13.3.0", "@tanstack/react-virtual": "^3.13.18", "@types/react-syntax-highlighter": "^15.5.13", "@xenova/transformers": "^2.17.2", + "@zumer/snapdom": "^2.12.0", "beautiful-mermaid": "^1.1.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -61,8 +62,6 @@ "katex": "^0.16.21", "marked": "^17.0.3", "morphdom": "^2.7.7", - "rehype-katex": "^7.0.1", - "remark-math": "^6.0.0", "motion": "^12.23.24", "next-themes": "^0.4.6", "prismjs": "^1.30.0", @@ -70,6 +69,8 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-syntax-highlighter": "^15.6.6", + "rehype-katex": "^7.0.1", + "remark-math": "^6.0.0", "remend": "^1.2.1", "simple-git": "^3.28.0", "sonner": "^2.0.7", diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 816bbbef..d3f178b2 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -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; - computedStyle: Record; - 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; - 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 => { - const tauri = typeof window !== 'undefined' - ? (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record) => Promise } } }).__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((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((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 => { - if (!base64) return null; - try { - const image = new Image(); - await new Promise((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((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 (iframe: HTMLIFrameElement, script: string): Promise => { + 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(); -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 = ({ 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 = ({ initialUrl, directory, tabID }) => { + const { t } = useI18n(); + const iframeRef = React.useRef(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(() => 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(null); + const [proxyState, setProxyState] = React.useState({ 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(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) => { + 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(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 ( +
+
+ + + +
{ event.preventDefault(); applyUrl(urlInput); }}> + 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')} + /> +
+ + +
+
+ {iframeSrc ? ( +
+