677 lines
26 KiB
TypeScript
677 lines
26 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type FormEvent,
|
|
} from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
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;
|
|
toolbarContainer?: HTMLElement | null;
|
|
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> | 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<HTMLElement>('[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,
|
|
toolbarContainer,
|
|
teacherBubble,
|
|
teacherIntroduction,
|
|
conversationId,
|
|
onWorkConversation,
|
|
workRevision,
|
|
onWorkResult,
|
|
onRecovery,
|
|
}: AgentBrowserPanelProps) {
|
|
const [snapshot, setSnapshot] = useState<AgentBrowserSnapshot | null>(null);
|
|
const [address, setAddress] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [occluded, setOccluded] = useState(false);
|
|
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
|
const [activeTab, setActiveTab] = useState<DiagnosticsTab>('console');
|
|
const [events, setEvents] = useState<AgentBrowserCdpEvent[]>([]);
|
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
|
const panelRef = useRef<HTMLElement | null>(null);
|
|
usePreviewClearance(panelRef, viewportRef, teacherBubble, embedded && open, teacherIntroduction);
|
|
const addressInputRef = useRef<HTMLInputElement | null>(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<AgentBrowserSnapshot>(
|
|
'agent-browser:show',
|
|
(next) => {
|
|
if (next.projectId !== projectId) return;
|
|
applySnapshot(next);
|
|
if (workEmpty) restoreWork();
|
|
onOpenChange(true);
|
|
},
|
|
);
|
|
const unsubscribeState = subscribeHostEvent<AgentBrowserSnapshot>(
|
|
'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<AgentBrowserSnapshot>,
|
|
) => {
|
|
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;
|
|
|
|
const toolbar = (
|
|
<form
|
|
data-testid="agent-browser-toolbar"
|
|
aria-label="网页导航"
|
|
className={cn(
|
|
'flex shrink-0 items-center gap-1',
|
|
embedded && toolbarContainer ? 'h-8 w-full min-w-0' : 'h-10 border-b border-border/70 px-2',
|
|
)}
|
|
onSubmit={handleOpenAddress}
|
|
>
|
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 rounded-md" aria-label="后退" disabled={!snapshot?.canGoBack || busy} onClick={() => handleNavigate('back')}>
|
|
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
|
|
</Button>
|
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 rounded-md" aria-label="前进" disabled={!snapshot?.canGoForward || busy} onClick={() => handleNavigate('forward')}>
|
|
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
|
</Button>
|
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 rounded-md" aria-label="刷新网页" disabled={!browserActive || busy} onClick={() => handleNavigate('reload')}>
|
|
<RefreshCw className={cn('h-3.5 w-3.5', busy && 'animate-spin')} aria-hidden="true" />
|
|
</Button>
|
|
<input
|
|
ref={addressInputRef}
|
|
value={address}
|
|
onChange={(event) => setAddress(event.target.value)}
|
|
aria-label="网页地址"
|
|
className="h-7 min-w-0 flex-1 rounded-lg border border-border/90 bg-surface-input px-2.5 text-[11px] font-medium outline-none focus:border-brand/40 focus:ring-2 focus:ring-brand/15"
|
|
placeholder={embedded ? '作品打开后会显示地址' : '输入 URL'}
|
|
spellCheck={false}
|
|
/>
|
|
</form>
|
|
);
|
|
|
|
return (
|
|
<aside
|
|
ref={panelRef}
|
|
className={cn(embedded ? 'relative flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden bg-background' : 'relative flex w-[400px] min-w-[300px] max-w-[48vw] shrink-0 flex-col overflow-hidden border-l border-border/80 bg-background shadow-[-16px_0_32px_hsl(220_20%_16%_/_0.06)] max-[900px]:fixed max-[900px]:inset-y-10 max-[900px]:right-0 max-[900px]:z-40 max-[900px]:max-w-[min(92vw,420px)]')}
|
|
data-testid="agent-browser-panel"
|
|
aria-label={embedded ? '作品预览' : '开发浏览器'}
|
|
>
|
|
{!embedded && <div className="flex h-10 shrink-0 items-center gap-2 border-b border-border/70 px-2.5">
|
|
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-brand-soft text-brand">
|
|
<Globe2 className="h-3.5 w-3.5" aria-hidden="true" />
|
|
</span>
|
|
<h2 className="min-w-0 flex-1 truncate text-xs font-semibold">开发浏览器</h2>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 rounded-md text-muted-foreground"
|
|
aria-label="关闭开发浏览器"
|
|
onClick={() => onOpenChange(false)}
|
|
>
|
|
<X className="h-4 w-4" aria-hidden="true" />
|
|
</Button>
|
|
</div>}
|
|
|
|
{embedded && toolbarContainer ? createPortal(toolbar, toolbarContainer) : toolbar}
|
|
|
|
{error && !workEmpty ? (
|
|
<p role="alert" className="shrink-0 border-b border-destructive/15 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
|
|
<div
|
|
ref={viewportRef}
|
|
className="relative min-h-[180px] flex-1 bg-surface-tertiary"
|
|
data-testid="agent-browser-viewport"
|
|
aria-label={embedded ? '作品画面' : '开发浏览器页面'}
|
|
>
|
|
{!browserActive || workPending ? (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 p-6 text-center text-muted-foreground">
|
|
<span className="flex h-12 w-12 items-center justify-center rounded-xl border border-border/70 bg-background shadow-soft">
|
|
<Globe2 className="h-5 w-5" aria-hidden="true" />
|
|
</span>
|
|
<p role="status" className="text-[13px] font-semibold text-foreground">{embedded
|
|
? workEmpty ? '还没有作品,快和老师聊聊吧' : presentationFailed ? '作品画面暂时没能显示' : work.result?.status === 'failed' ? '作品还没有打开' : work.result?.status === 'waiting' ? '等当前步骤完成,就帮你打开作品' : '正在打开你的作品…'
|
|
: '开始浏览'}</p>
|
|
{!workEmpty && <p className="text-xs leading-6">{embedded
|
|
? presentationFailed ? '点“重新打开作品”,接着看你的作品。' : work.result?.message ?? '准备好后,页面会自动出现在这里。'
|
|
: '输入 URL,或等待智能体打开调试页面'}</p>}
|
|
{embedded && <div className="flex gap-2">
|
|
{!workEmpty && (presentationFailed ? <Button variant="outline" size="sm" onClick={retryPresentation}>重新打开作品</Button> : work.result?.status === 'failed' && <Button variant="outline" size="sm" onClick={async () => {
|
|
if (transportFailure || recovery.action === 'check-work' || recovery.action === 'refresh') work.retry();
|
|
else try { await onRecovery?.(recovery.action, true); }
|
|
catch { setError('暂时没能继续,请稍后再点一次。'); }
|
|
}}>{transportFailure ? '再试一次' : recovery.label}</Button>)}
|
|
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>回到操作对话</Button>
|
|
</div>}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{!embedded && <section
|
|
className="flex shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background"
|
|
style={{ height: diagnosticsOpen ? '240px' : '42px' }}
|
|
data-state={diagnosticsOpen ? 'open' : 'closed'}
|
|
data-testid="agent-browser-diagnostics"
|
|
>
|
|
<div className="flex h-10 shrink-0 items-center justify-between border-b border-border/70 bg-surface-input px-1.5">
|
|
<div className="flex min-w-0 items-center">
|
|
{(['console', 'network'] as const).map((tab) => (
|
|
<button
|
|
key={tab}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeTab === tab}
|
|
className={cn(
|
|
'h-10 border-b-2 px-2.5 text-[11px] font-medium',
|
|
activeTab === tab
|
|
? 'border-brand text-foreground'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground',
|
|
)}
|
|
onClick={() => {
|
|
setActiveTab(tab);
|
|
setDiagnosticsOpen(true);
|
|
}}
|
|
>
|
|
{tab === 'console'
|
|
? `Console ${diagnostics.console.length}`
|
|
: `Network ${diagnostics.network.length}`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 rounded-md text-muted-foreground"
|
|
aria-label={diagnosticsOpen ? '收起调试面板' : '展开调试面板'}
|
|
onClick={() => setDiagnosticsOpen((current) => !current)}
|
|
>
|
|
{diagnosticsOpen
|
|
? <ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
|
|
: <ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />}
|
|
</Button>
|
|
</div>
|
|
|
|
{diagnosticsOpen ? (
|
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
{activeTab === 'console' ? (
|
|
<div role="log" aria-label="Console 日志" className="h-full overflow-auto font-mono text-[10px] tabular-nums">
|
|
{diagnostics.console.length === 0 ? (
|
|
<p className="p-3 font-sans text-muted-foreground">暂无 Console 记录</p>
|
|
) : diagnostics.console.map((entry) => (
|
|
<div key={entry.id} className={cn('flex gap-2 border-b border-border/60 px-2 py-1.5 leading-4', consoleTone(entry.level))}>
|
|
<span className="shrink-0 text-muted-foreground">{formatTime(entry.timestamp)}</span>
|
|
<span className="shrink-0 font-semibold uppercase">{entry.level}</span>
|
|
<span className="min-w-0 whitespace-pre-wrap break-all">{entry.text || '(empty)'}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="h-full overflow-auto" aria-label="Network 请求">
|
|
{diagnostics.network.length === 0 ? (
|
|
<p className="p-3 text-[10px] text-muted-foreground">暂无 Network 请求</p>
|
|
) : (
|
|
<table className="w-full table-fixed text-left font-mono text-[10px] tabular-nums">
|
|
<thead className="sticky top-0 bg-surface-input">
|
|
<tr className="border-b border-border/70">
|
|
<th className="w-12 px-2 py-1.5 font-semibold">方法</th>
|
|
<th className="w-12 px-1 py-1.5 font-semibold">状态</th>
|
|
<th className="px-1 py-1.5 font-semibold">地址</th>
|
|
<th className="w-14 px-2 py-1.5 text-right font-semibold">耗时</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{diagnostics.network.map((entry) => (
|
|
<tr key={`${entry.requestId}:${entry.sequence}`} className="border-b border-border/60">
|
|
<td className="truncate px-2 py-1.5 font-semibold">{entry.method}</td>
|
|
<td className={cn('truncate px-1 py-1.5 font-semibold', statusTone(entry.status))}>
|
|
{entry.errorText ? '失败' : entry.status ?? '…'}
|
|
</td>
|
|
<td className="truncate px-1 py-1.5" title={entry.url}>{entry.url}</td>
|
|
<td className="px-2 py-1.5 text-right">
|
|
{entry.durationMs === undefined ? '…' : `${Math.round(entry.durationMs)}ms`}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</section>}
|
|
</aside>
|
|
);
|
|
}
|