import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent, } from 'react'; import { ArrowLeft, ArrowRight, ChevronDown, ChevronUp, Globe2, RefreshCw, X, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { closeAgentBrowser, deriveAgentBrowserDiagnostics, getAgentBrowserState, navigateAgentBrowser, openAgentBrowser, presentAgentBrowser, readAgentBrowserEvents, setAgentBrowserDiagnostics, } from '@/lib/agent-browser'; import { subscribeHostEvent } from '@/lib/host-events'; import { cn } from '@/lib/utils'; import type { CodingConversationMetadata } from '@/types/coding-project'; import { useWorkPreview } from './use-work-preview'; import { usePreviewClearance } from './use-preview-clearance'; import { codingRecovery, type CodingRecoveryAction } from '../../../shared/coding-recovery'; import type { WorkPreviewResult } from '../../../shared/coding-work-preview'; import type { AgentBrowserBounds, AgentBrowserCdpEvent, AgentBrowserSnapshot, } from '../../../shared/agent-browser'; type DiagnosticsTab = 'console' | 'network'; export interface AgentBrowserPanelProps { projectId: string | null; open: boolean; onOpenChange(open: boolean): void; embedded?: boolean; teacherBubble?: HTMLElement | null; teacherIntroduction?: HTMLElement | null; conversationId?: string | null; onWorkConversation?(conversation: CodingConversationMetadata): void; workRevision?: number; onWorkResult?(result: WorkPreviewResult): void; onRecovery?(action: CodingRecoveryAction, work: boolean): Promise | void; } const EVENT_METHODS = [ 'Runtime.consoleAPICalled', 'Runtime.exceptionThrown', 'Log.entryAdded', 'Network.requestWillBeSent', 'Network.responseReceived', 'Network.loadingFinished', 'Network.loadingFailed', ]; const MAX_RENDERED_EVENTS = 500; const EVENT_WAIT_MS = 5_000; const EVENT_DRAIN_DELAY_MS = 100; const EVENT_EMPTY_DELAY_MS = 250; function normalizeAddress(value: string): string | null { const trimmed = value.trim(); if (!trimmed) return null; return /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; } function readBounds(element: HTMLElement | null): AgentBrowserBounds | undefined { if (!element) return undefined; const rect = element.getBoundingClientRect(); const bounds = { x: Math.round(rect.left), y: Math.round(rect.top), width: Math.round(rect.width), height: Math.round(rect.height), }; return bounds.width > 0 && bounds.height > 0 ? bounds : undefined; } function browserIsActive(snapshot: AgentBrowserSnapshot | null): boolean { return Boolean( snapshot?.browserId && snapshot.state !== 'closed' && snapshot.state !== 'closing', ); } function modalOccludesBrowser(): boolean { return Array.from( document.querySelectorAll('[role="dialog"], [role="alertdialog"], #coding-consultation-dock'), ).some((element) => { if (element.hidden || element.getAttribute('data-state') === 'closed') return false; const style = window.getComputedStyle(element); if (element.id === 'coding-consultation-dock' && style.position !== 'absolute' && style.position !== 'fixed') return false; return style.display !== 'none' && style.visibility !== 'hidden'; }); } function formatTime(timestamp: number): string { const date = new Date(timestamp); return Number.isNaN(date.getTime()) ? '--:--:--' : date.toLocaleTimeString('zh-CN', { hour12: false }); } function consoleTone(level: string): string { if (level === 'error' || level === 'assert') return 'text-destructive'; if (level === 'warning' || level === 'warn') return 'text-amber-700'; return 'text-foreground'; } function statusTone(status?: number): string { if (status === undefined) return 'text-muted-foreground'; if (status >= 500) return 'text-destructive'; if (status >= 400) return 'text-amber-700'; return 'text-emerald-700'; } export function AgentBrowserPanel({ projectId, open, onOpenChange, embedded = false, teacherBubble, teacherIntroduction, conversationId, onWorkConversation, workRevision, onWorkResult, onRecovery, }: AgentBrowserPanelProps) { const [snapshot, setSnapshot] = useState(null); const [address, setAddress] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [occluded, setOccluded] = useState(false); const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); const [activeTab, setActiveTab] = useState('console'); const [events, setEvents] = useState([]); const viewportRef = useRef(null); const panelRef = useRef(null); usePreviewClearance(panelRef, viewportRef, teacherBubble, embedded && open, teacherIntroduction); const addressInputRef = useRef(null); const eventCursorRef = useRef(0); const snapshotGenerationRef = useRef(0); const presentationRetries = useRef(0); const [presentationRevision, setPresentationRevision] = useState(0); const [presentationFailed, setPresentationFailed] = useState(false); const wasOpenRef = useRef(open); const browserActive = browserIsActive(snapshot); const diagnostics = useMemo(() => deriveAgentBrowserDiagnostics(events), [events]); const applySnapshot = useCallback((next: AgentBrowserSnapshot) => { // A slow foreground state read must not replace a newly restored view. if (next.generation < snapshotGenerationRef.current) return; snapshotGenerationRef.current = next.generation; setSnapshot(next); if (browserIsActive(next)) setError(null); if (next.url && document.activeElement !== addressInputRef.current) { setAddress(next.url); } }, []); const work = useWorkPreview({ projectId, conversationId, enabled: embedded && open, revision: workRevision, onResult: (result) => { if (result.status === 'empty') { setError(null); setPresentationFailed(false); presentationRetries.current = 0; } if (result.browser) applySnapshot(result.browser); if (result.conversation) onWorkConversation?.(result.conversation); onWorkResult?.(result); }, }); const workEmpty = embedded && work.result?.status === 'empty'; const workPending = embedded && (work.result?.status !== 'ready' || presentationFailed); const recovery = codingRecovery(work.result?.errorCode, true); const transportFailure = ['NETWORK', 'TIMEOUT'].includes(work.result?.errorCode ?? ''); const showBrowser = work.showBrowser; const restoreWork = work.restore; const retryPresentation = useCallback(() => { presentationRetries.current = 0; setPresentationFailed(false); setError(null); setPresentationRevision((value) => value + 1); restoreWork(); }, [restoreWork]); useEffect(() => { if (embedded && open && snapshot && !browserActive && !presentationFailed && work.result?.status === 'ready') { restoreWork(); } }, [browserActive, embedded, open, presentationFailed, restoreWork, snapshot, work.result?.status]); useEffect(() => { snapshotGenerationRef.current = 0; setSnapshot(null); setAddress(''); setError(null); presentationRetries.current = 0; setPresentationFailed(false); setDiagnosticsOpen(false); setEvents([]); eventCursorRef.current = 0; }, [projectId]); useEffect(() => { if (!projectId) return undefined; const unsubscribeShow = subscribeHostEvent( 'agent-browser:show', (next) => { if (next.projectId !== projectId) return; applySnapshot(next); if (workEmpty) restoreWork(); onOpenChange(true); }, ); const unsubscribeState = subscribeHostEvent( 'agent-browser:state', (next) => { if (next.projectId && next.projectId !== projectId) return; applySnapshot(next); }, ); return () => { unsubscribeShow(); unsubscribeState(); }; }, [applySnapshot, onOpenChange, projectId, restoreWork, workEmpty]); useEffect(() => { if (!open || !projectId) return undefined; let cancelled = false; void getAgentBrowserState(projectId) .then((next) => { if (!cancelled) applySnapshot(next); }) .catch(() => { if (!cancelled) setSnapshot(null); }); return () => { cancelled = true; }; }, [applySnapshot, open, projectId]); useEffect(() => { if (!open || !projectId) return undefined; let cancelled = false; const syncAfterResume = () => { if (document.visibilityState === 'hidden') return; void getAgentBrowserState(projectId) .then((next) => { if (!cancelled) applySnapshot(next); }) .catch(() => { if (!cancelled) setSnapshot(null); }); }; window.addEventListener('focus', syncAfterResume); document.addEventListener('visibilitychange', syncAfterResume); return () => { cancelled = true; window.removeEventListener('focus', syncAfterResume); document.removeEventListener('visibilitychange', syncAfterResume); }; }, [applySnapshot, open, projectId]); useEffect(() => { const wasOpen = wasOpenRef.current; wasOpenRef.current = open; if (!wasOpen || open || !projectId) return; // Switching the central workspace tab must preserve the running work page. if (embedded) return; setSnapshot(null); setDiagnosticsOpen(false); setEvents([]); eventCursorRef.current = 0; void closeAgentBrowser(projectId).catch(() => undefined); }, [embedded, open, projectId]); useEffect(() => () => { if (projectId) void closeAgentBrowser(projectId).catch(() => undefined); }, [projectId]); useEffect(() => { eventCursorRef.current = 0; setEvents([]); }, [snapshot?.browserId, snapshot?.generation]); useEffect(() => { if (!open) { setOccluded(false); return undefined; } const update = () => setOccluded(modalOccludesBrowser()); update(); window.addEventListener('resize', update); const observer = new MutationObserver(update); observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'data-state', 'hidden', 'style'], childList: true, subtree: true, }); return () => { observer.disconnect(); window.removeEventListener('resize', update); }; }, [open]); useEffect(() => { if (!projectId || !browserActive) return undefined; if (!open || occluded || workPending) { void presentAgentBrowser({ projectId, visible: false }).catch(() => undefined); return undefined; } const viewport = viewportRef.current; if (!viewport) return undefined; let cancelled = false; let timer: number | null = null; let lastBounds = ''; const schedule = () => { if (timer !== null) window.clearTimeout(timer); timer = window.setTimeout(() => { timer = null; const bounds = readBounds(viewport); if (!bounds) return; const key = `${bounds.x}:${bounds.y}:${bounds.width}:${bounds.height}`; if (key === lastBounds) return; lastBounds = key; void presentAgentBrowser({ projectId, visible: true, bounds }) .then((next) => { if (!cancelled) { applySnapshot(next); if (browserIsActive(next) && next.visible) presentationRetries.current = 0; } }) .catch((cause) => { if (!cancelled) { if (embedded) { if (presentationRetries.current++ < 2) { setSnapshot(null); setPresentationRevision((value) => value + 1); restoreWork(); } else { setPresentationFailed(true); } } else setError(cause instanceof Error ? cause.message : '无法显示开发浏览器'); } }); }, 50); }; const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule); observer?.observe(viewport); window.addEventListener('resize', schedule); schedule(); return () => { cancelled = true; if (timer !== null) window.clearTimeout(timer); observer?.disconnect(); window.removeEventListener('resize', schedule); void presentAgentBrowser({ projectId, visible: false }).catch(() => undefined); }; }, [applySnapshot, browserActive, embedded, occluded, open, presentationRevision, projectId, restoreWork, snapshot?.generation, workPending]); useEffect(() => { if (embedded || !open || !diagnosticsOpen || !projectId || snapshot?.state !== 'attached') { return undefined; } let cancelled = false; void setAgentBrowserDiagnostics({ projectId, enabled: true }) .then((next) => { if (!cancelled) applySnapshot(next); }) .catch(() => undefined); return () => { cancelled = true; void setAgentBrowserDiagnostics({ projectId, enabled: false }).catch(() => undefined); }; }, [applySnapshot, diagnosticsOpen, embedded, open, projectId, snapshot?.browserId, snapshot?.generation, snapshot?.state]); useEffect(() => { if (embedded || !open || !diagnosticsOpen || !projectId || snapshot?.state !== 'attached') { return undefined; } let cancelled = false; let inFlight = false; let timer: number | null = null; const poll = async () => { if (inFlight) return; inFlight = true; let retryDelay = EVENT_EMPTY_DELAY_MS; try { const page = await readAgentBrowserEvents({ projectId, after: eventCursorRef.current, methods: EVENT_METHODS, limit: 200, waitMs: EVENT_WAIT_MS, }); if (cancelled) return; eventCursorRef.current = page.nextCursor; setEvents((current) => { const base = page.gap ? [] : current; return [...base, ...page.events].slice(-MAX_RENDERED_EVENTS); }); retryDelay = page.events.length > 0 || page.hasMore ? EVENT_DRAIN_DELAY_MS : EVENT_EMPTY_DELAY_MS; } catch { // The state event or the next explicit open reports actionable failures. } finally { inFlight = false; if (!cancelled) { timer = window.setTimeout(() => { timer = null; void poll(); }, retryDelay); } } }; void poll(); return () => { cancelled = true; if (timer !== null) window.clearTimeout(timer); }; }, [diagnosticsOpen, embedded, open, projectId, snapshot?.generation, snapshot?.state]); const runBrowserAction = useCallback(async ( action: () => Promise, ) => { setBusy(true); setError(null); try { applySnapshot(await action()); presentationRetries.current = 0; setPresentationFailed(false); showBrowser(); } catch (cause) { if (embedded) { setSnapshot(null); setPresentationRevision((value) => value + 1); restoreWork(); } else setError(cause instanceof Error ? cause.message : '开发浏览器操作失败'); } finally { setBusy(false); } }, [applySnapshot, embedded, restoreWork, showBrowser]); const handleOpenAddress = useCallback((event?: FormEvent) => { event?.preventDefault(); if (!projectId) return; const url = normalizeAddress(address); if (!url) return; setAddress(url); void runBrowserAction(() => browserActive ? navigateAgentBrowser({ projectId, action: 'url', url }) : openAgentBrowser({ projectId, url, bounds: readBounds(viewportRef.current) })); }, [address, browserActive, projectId, runBrowserAction]); const handleNavigate = useCallback((action: 'back' | 'forward' | 'reload') => { if (!projectId || !browserActive) return; void runBrowserAction(() => navigateAgentBrowser({ projectId, action })); }, [browserActive, projectId, runBrowserAction]); if (!open) return null; return ( ); }