662 lines
26 KiB
TypeScript
662 lines
26 KiB
TypeScript
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> | 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,
|
||
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;
|
||
|
||
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>}
|
||
|
||
<form className="flex h-10 shrink-0 items-center gap-1 border-b border-border/70 px-2" onSubmit={handleOpenAddress}>
|
||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 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 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 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>
|
||
|
||
{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>
|
||
);
|
||
}
|