import React from 'react'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { DiffViewIcon } from '@/components/icons/DiffIcon'; 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 { GitView } from '@/components/views/GitView'; import { PullRequestView } from '@/components/views/PullRequestView'; import { TerminalView } from '@/components/views/TerminalView'; import { WalkthroughView } from '@/components/views/walkthrough/WalkthroughView'; import { PlanView } from '@/components/views/PlanView'; import { ProjectContextPanel } from './RightSidebarTabs'; import { SidebarFilesTree } from './SidebarFilesTree'; 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, type PendingDiffScope } from '@/stores/useUIStore'; import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useInputStore } from '@/sync/input-store'; import { markSessionViewed } from '@/sync/notification-store'; import { setExternallyViewedSession, useDirectoryStore } from '@/sync/sync-context'; import { ContextPanelContent } from './ContextSidebarTab'; import { toast } from '@/components/ui'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync, refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth'; import { getRuntimeUrlResolver } from '@/lib/runtime-url'; import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch'; import { getActiveRelayDescriptor } from '@/lib/relay/runtime-tunnel'; import { getPreviewTargetRecoveryAction } from '@/lib/preview/proxy-response'; import { Icon } from "@/components/icon/Icon"; import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo"; import { invokeDesktopCommand } from '@/lib/desktopNative'; import { EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST, EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE, getOrCreateEmbeddedSessionChatURL, type EmbeddedSessionChatURLCacheEntry, type EmbeddedSessionRuntimeBootstrap, } from './contextPanelEmbeddedChat'; import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry'; import { isTerminalEventTarget } from '@/lib/terminalFocus'; 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; const CONTEXT_PANEL_DEFAULT_WIDTH = 600; const RESIZE_FOLLOW_INTERVAL_MS = 100; const CONTEXT_TAB_LABEL_MAX_CHARS = 24; type TranslateFn = ReturnType['t']; const EMPTY_SESSION_TITLE_MAP = new Map(); 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; navigation?: unknown; }; 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 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 getAvailablePanelWidth = (panel: HTMLElement | null): number | null => { const parentWidth = panel?.parentElement?.clientWidth; if (!parentWidth || parentWidth <= 0) { return null; } return parentWidth; }; 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 === 'walkthrough') return t('contextPanel.mode.walkthrough'); if (mode === 'plan') return t('contextPanel.mode.plan'); if (mode === 'preview') return t('contextPanel.mode.preview'); if (mode === 'browser') return t('contextPanel.mode.browser'); if (mode === 'git') return t('layout.rightSidebar.git'); if (mode === 'pr') return t('contextPanel.mode.pr'); if (mode === 'notes') return t('contextRail.surface.notes'); if (mode === 'terminal') return t('layout.mainTab.terminal'); 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; dedupeKey?: string; sessionTitleFallback?: string | null; stagedDiff?: boolean }, sessionTitleById: ReadonlyMap, t: TranslateFn ): string => { if (tab.mode === 'chat') { const sessionID = getSessionIDFromDedupeKey(tab.dedupeKey); if (sessionID) { const sessionTitle = sessionTitleById.get(sessionID)?.trim(); if (sessionTitle) { return sessionTitle; } } const sessionTitleFallback = tab.sessionTitleFallback?.trim(); if (sessionTitleFallback) { return sessionTitleFallback; } return t('contextPanel.mode.chat'); } 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 t('contextPanel.mode.diff'); } return getModeLabel(tab.mode, t); }; const getTabIcon = (tab: { mode: ContextPanelMode; targetPath: string | null }): React.ReactNode | undefined => { if (tab.mode === 'file') { return tab.targetPath ? : undefined; } if (tab.mode === 'diff') { return ; } if (tab.mode === 'walkthrough') { return ; } if (tab.mode === 'git') { return ; } if (tab.mode === 'pr') { return ; } if (tab.mode === 'notes') { return ; } if (tab.mode === 'terminal') { return ; } if (tab.mode === 'plan') { return ; } if (tab.mode === 'context') { return ; } if (tab.mode === 'chat') { return ; } if (tab.mode === 'preview') { return ; } if (tab.mode === 'browser') { return ; } return undefined; }; const EDITOR_TREE_MIN_WIDTH = 200; const EDITOR_TREE_MAX_WIDTH = 480; // The editor surface's file-tree column: docked on the right, resizable from // its left edge, and animated open/closed like the app sidebars. const EditorTreeColumn: React.FC<{ visible: boolean }> = ({ visible }) => { const { t } = useI18n(); const width = useUIStore((state) => state.contextEditorTreeWidth); const setWidth = useUIStore((state) => state.setContextEditorTreeWidth); const [isResizing, setIsResizing] = React.useState(false); const startXRef = React.useRef(0); const startWidthRef = React.useRef(width); const liveWidthRef = React.useRef(null); const pointerIDRef = React.useRef(null); const columnRef = React.useRef(null); const clampTreeWidth = React.useCallback((value: number) => { return Math.min(EDITOR_TREE_MAX_WIDTH, Math.max(EDITOR_TREE_MIN_WIDTH, Math.round(value))); }, []); const applyLiveTreeWidth = React.useCallback((nextWidth: number) => { const column = columnRef.current; if (!column) { return; } column.style.width = `${nextWidth}px`; column.style.setProperty('--oc-editor-tree-width', `${nextWidth}px`); }, []); const handlePointerDown = (event: React.PointerEvent) => { if (!visible) { return; } try { event.currentTarget.setPointerCapture(event.pointerId); } catch { // ignore } pointerIDRef.current = event.pointerId; setIsResizing(true); startXRef.current = event.clientX; startWidthRef.current = width; liveWidthRef.current = width; event.preventDefault(); }; const handlePointerMove = (event: React.PointerEvent) => { if (!isResizing || pointerIDRef.current !== event.pointerId) { return; } const delta = startXRef.current - event.clientX; const nextWidth = clampTreeWidth(startWidthRef.current + delta); if (liveWidthRef.current === nextWidth) { return; } liveWidthRef.current = nextWidth; applyLiveTreeWidth(nextWidth); }; const handlePointerEnd = (event: React.PointerEvent) => { if (pointerIDRef.current !== event.pointerId) { return; } try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { // ignore } const finalWidth = clampTreeWidth(liveWidthRef.current ?? width); pointerIDRef.current = null; liveWidthRef.current = null; setIsResizing(false); setWidth(finalWidth); }; const appliedWidth = visible ? width : 0; return (
{visible && (
)}
); }; 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 areTitleMapsEqual = (a: ReadonlyMap, b: ReadonlyMap): boolean => { if (a.size !== b.size) return false; for (const [key, value] of a) { if (b.get(key) !== value) return false; } return true; }; const buildSessionTitleMap = (sessions: Array<{ id: string; title?: string | null }>, sessionIDs: readonly string[]): Map => { if (sessionIDs.length === 0) return EMPTY_SESSION_TITLE_MAP; const wanted = new Set(sessionIDs); const next = new Map(); for (const session of sessions) { if (!wanted.has(session.id)) continue; const title = session.title?.trim(); if (title) next.set(session.id, title); } return next.size === 0 ? EMPTY_SESSION_TITLE_MAP : next; }; const useSessionTitleMap = (directory: string | undefined, sessionIDs: readonly string[]): ReadonlyMap => { const store = useDirectoryStore(directory); const snapshotRef = React.useRef>(EMPTY_SESSION_TITLE_MAP); const sessionIDsRef = React.useRef(sessionIDs); sessionIDsRef.current = sessionIDs; return React.useSyncExternalStore( store.subscribe, React.useCallback(() => { const next = buildSessionTitleMap(store.getState().session, sessionIDsRef.current); if (areTitleMapsEqual(snapshotRef.current, next)) { return snapshotRef.current; } snapshotRef.current = next; return next; }, [store]), () => EMPTY_SESSION_TITLE_MAP, ); }; 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 DESKTOP_BROWSER_SAME_WEBVIEW_NAVIGATION_SCRIPT = `(() => { if (window.__openchamberSameWebviewNavigationInstalled) return; window.__openchamberSameWebviewNavigationInstalled = true; const navigate = (rawUrl) => { if (typeof rawUrl !== 'string' || rawUrl.length === 0) return false; try { const url = new URL(rawUrl, window.location.href); if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; window.location.assign(url.href); return true; } catch (_error) { return false; } }; const originalOpen = window.open.bind(window); window.open = (url, target, features) => { if (navigate(url)) return null; return originalOpen(url, target, features); }; document.addEventListener('click', (event) => { if (event.defaultPrevented) return; const target = event.target; if (!(target instanceof Element)) return; const anchor = target.closest('a[target="_blank"][href]'); if (!(anchor instanceof HTMLAnchorElement)) return; if (!navigate(anchor.href)) return; event.preventDefault(); event.stopPropagation(); }, true); })()`; 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 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 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; previewToken?: string; expiresAt: number } | { status: 'error'; message: string }; const getPreviewProxyOrigin = (proxySrc: string): string => { if (typeof window === 'undefined') return ''; try { return new URL(proxySrc || window.location.href, window.location.href).origin; } catch { return window.location.origin; } }; const postPreviewBridgeMessage = (frameWindow: Window, proxySrc: string, payload: Record): void => { const targetOrigin = getPreviewProxyOrigin(proxySrc); frameWindow.postMessage(payload, targetOrigin); }; const stripPreviewTokenFromUrl = (value: string): string => { if (!value) return value; try { const parsed = new URL(value); parsed.searchParams.delete('oc_preview_token'); parsed.searchParams.delete('oc_client_token'); parsed.searchParams.delete('oc_url_token'); return parsed.toString(); } catch { return value; } }; const stripPreviewQueryParams = (value: string): string => { if (!value) return value; try { const parsed = new URL(value); parsed.searchParams.delete('ocPreview'); parsed.searchParams.delete('oc_preview_token'); parsed.searchParams.delete('oc_client_token'); parsed.searchParams.delete('oc_url_token'); return parsed.toString(); } catch { return value; } }; const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => { const { t } = useI18n(); 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({ status: 'idle' }); const [urlAuthReadyKey, setUrlAuthReadyKey] = React.useState(''); const iframeRef = React.useRef(null); const nextConsoleEventIdRef = React.useRef(1); const [bridgeReady, setBridgeReady] = React.useState(false); const [consoleOpen, setConsoleOpen] = React.useState(false); const [consoleFilter, setConsoleFilter] = React.useState('all'); const [consoleEvents, setConsoleEvents] = React.useState([]); const [inspectMode, setInspectMode] = React.useState(false); const [hoverTarget, setHoverTarget] = React.useState(null); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open); const effectiveDirectory = useEffectiveDirectory(); 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() : ''; const proxyCacheKey = targetKey ? `${getRuntimeApiBaseUrl() || 'same-origin'}|${targetKey}` : ''; const previewColorScheme = currentTheme.metadata.variant; React.useEffect(() => { if (!targetKey || !isLoopback) { setProxyState({ status: 'idle' }); return; } const cached = getCachedProxyTarget(proxyCacheKey); if (cached?.previewToken) { setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, previewToken: cached.previewToken, expiresAt: cached.expiresAt }); return; } if (cached) { previewProxyTargetCache.delete(proxyCacheKey); } let cancelled = false; setProxyState({ status: 'loading' }); void (async () => { try { const response = await runtimeFetch('/api/preview/targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ url: targetKey }), }); if (!response.ok) { previewProxyTargetCache.delete(proxyCacheKey); 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; previewToken?: unknown; expiresAt?: unknown }; const proxyBasePath = typeof body.proxyBasePath === 'string' ? body.proxyBasePath : ''; const previewToken = typeof body.previewToken === 'string' ? body.previewToken : ''; const expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : 0; if (!proxyBasePath || !previewToken) { previewProxyTargetCache.delete(proxyCacheKey); if (!cancelled) { setProxyState({ status: 'error', message: t('contextPanel.preview.proxyError') }); } return; } previewProxyTargetCache.set(proxyCacheKey, { proxyBasePath, previewToken, expiresAt }); if (!cancelled) { setProxyState({ status: 'ready', proxyBasePath, previewToken, expiresAt }); } } catch (error) { previewProxyTargetCache.delete(proxyCacheKey); if (!cancelled) { const message = error instanceof Error ? error.message : String(error); setProxyState({ status: 'error', message }); } } })(); return () => { cancelled = true; }; }, [isLoopback, proxyCacheKey, proxyRegistrationNonce, t, targetKey]); const directSrc = normalizedUrl && (normalizedUrl.protocol === 'http:' || normalizedUrl.protocol === 'https:') ? normalizedUrl.toString() : ''; const proxyUrlAuthKey = isLoopback && proxyState.status === 'ready' ? `${proxyState.proxyBasePath}|${proxyState.previewToken || ''}|${reloadNonce}` : ''; React.useEffect(() => { if (!proxyUrlAuthKey) { setUrlAuthReadyKey(''); return; } let cancelled = false; setUrlAuthReadyKey(''); void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl()) .then((token) => { if (!cancelled && token) setUrlAuthReadyKey(proxyUrlAuthKey); }) .catch(() => {}); return () => { cancelled = true; }; }, [proxyUrlAuthKey]); const proxySrc = isLoopback && proxyState.status === 'ready' && normalizedUrl && urlAuthReadyKey === proxyUrlAuthKey ? (() => { const path = normalizedUrl.pathname || '/'; const searchParams = new URLSearchParams(normalizedUrl.search); searchParams.delete('oc_url_token'); searchParams.delete('oc_client_token'); searchParams.set('ocPreview', String(reloadNonce)); searchParams.set('oc_preview_token', proxyState.previewToken || ''); const search = searchParams.toString(); const hash = normalizedUrl.hash || ''; return getRuntimeUrlResolver().authenticatedAsset(`${proxyState.proxyBasePath}${path}${search ? `?${search}` : ''}${hash}`); })() : ''; const effectiveSrc = isLoopback ? proxySrc : directSrc; const headerSrc = isLoopback ? stripPreviewTokenFromUrl(proxySrc) : directSrc; const showLoading = isLoopback && (proxyState.status === 'loading' || proxyState.status === 'idle' || urlAuthReadyKey !== proxyUrlAuthKey); const showError = isLoopback && proxyState.status === 'error'; const attachPreviewAnnotation = React.useCallback((target: PreviewElementMetadata) => { const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); if (!sessionKey || !effectiveDirectory) { 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({ directory: effectiveDirectory, 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, effectiveDirectory, 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; } postPreviewBridgeMessage(frameWindow, proxySrc, { source: 'openchamber-preview-parent', version: 1, type: 'set-inspect-mode', enabled: inspectMode, }); }, [bridgeReady, inspectMode, proxySrc]); React.useEffect(() => { const frameWindow = iframeRef.current?.contentWindow; if (!bridgeReady || !frameWindow) { return; } postPreviewBridgeMessage(frameWindow, proxySrc, { source: 'openchamber-preview-parent', version: 1, type: 'set-color-scheme', scheme: previewColorScheme, }); }, [bridgeReady, previewColorScheme, proxySrc]); 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) => { 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) => { 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 : ''; 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 || !effectiveDirectory) { 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({ directory: effectiveDirectory, 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, effectiveDirectory, 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 GET // request and surface a friendly overlay when the upstream is unreachable. type UpstreamState = 'unknown' | 'starting' | 'reachable' | 'unreachable'; const [upstreamState, setUpstreamState] = React.useState('unknown'); const upstreamProbeStartedAtRef = React.useRef(0); const upstreamProbeAttemptRef = React.useRef(0); const upstreamProbeKeyRef = React.useRef(''); const proxyRecoveryAttemptedKeyRef = React.useRef(''); const PREVIEW_STARTUP_GRACE_MS = 15_000; React.useEffect(() => { if (!proxySrc) { setUpstreamState('unknown'); upstreamProbeKeyRef.current = ''; upstreamProbeStartedAtRef.current = 0; upstreamProbeAttemptRef.current = 0; return; } let cancelled = false; let retryTimeout: ReturnType | null = null; if (upstreamProbeKeyRef.current !== proxyCacheKey) { upstreamProbeKeyRef.current = proxyCacheKey; upstreamProbeStartedAtRef.current = Date.now(); upstreamProbeAttemptRef.current = 0; } const scheduleRetry = (delay: number) => { retryTimeout = setTimeout(() => { if (!cancelled) bumpReload(); }, delay); }; setUpstreamState('unknown'); void (async () => { const probe = async (): Promise => { try { return await runtimeFetch(proxySrc, { method: 'GET', credentials: 'include', cache: 'no-store', redirect: 'manual', }); } catch { return null; } }; const response = await probe(); if (cancelled) return; if (!response) { setUpstreamState('unreachable'); scheduleRetry(5000); return; } const recoveryAction = getPreviewTargetRecoveryAction( response.headers, proxyRecoveryAttemptedKeyRef.current === proxyCacheKey, ); if (recoveryAction !== 'none') { previewProxyTargetCache.delete(proxyCacheKey); if (recoveryAction === 'retry-registration') { proxyRecoveryAttemptedKeyRef.current = proxyCacheKey; setProxyState({ status: 'loading' }); bumpProxyRegistration(); } else { const errorBody = await response.json().catch(() => ({})); if (cancelled) return; const message = typeof errorBody?.error === 'string' ? errorBody.error : `HTTP ${response.status}`; setProxyState({ status: 'error', message }); } 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) { proxyRecoveryAttemptedKeyRef.current = ''; 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))); scheduleRetry(delay); return; } setUpstreamState('unreachable'); scheduleRetry(5000); })(); return () => { cancelled = true; if (retryTimeout) clearTimeout(retryTimeout); }; }, [proxyCacheKey, proxySrc, reloadNonce]); 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) => { if (!isLoopback || proxyState.status !== 'ready') { return; } if (typeof window === 'undefined') { return; } const frameWindow = event.currentTarget.contentWindow; if (!frameWindow) { return; } try { const location = frameWindow.location; const proxyOrigin = getPreviewProxyOrigin(proxySrc); if (location.origin !== proxyOrigin) { 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, proxySrc, proxyState]); return (
{headerSrc || rawUrl || t('contextPanel.preview.empty')}
{isLoopback ? ( ) : null} {isLoopback ? ( ) : null}
{showUpstreamStarting ? (
{t('contextPanel.preview.startingServer')}
{t('contextPanel.preview.startingServerHint')}
) : showUpstreamUnreachable ? (
{t('contextPanel.preview.upstreamUnreachable')}
{t('contextPanel.preview.upstreamUnreachableHint')}
) : effectiveSrc && (!isLoopback || upstreamState === 'reachable') ? (