feat: remove legacy OpenCode runtime

Cut product flows over to Coding/Pi and retain only the migration-owned v1 boundary. Promote supported native optional packages because electron-builder omitted pnpm transitive optional closure from the packaged ASAR.
This commit is contained in:
brother7 committed 2026-08-24 12:17:43 +08:00
1 parent 61fede2b4d
commit 5a275b93a7
199 files changed
+1330 -64725

No files matched your search

-1000
View File
@@ -1,1000 +0,0 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type FormEvent,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from 'react';
import { createPortal } from 'react-dom';
import {
ArrowLeft,
ArrowRight,
ChevronDown,
ChevronUp,
Globe2,
GripHorizontal,
PanelRightClose,
PanelRightOpen,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { DISCLOSURE_EXIT_MS } from '@/components/ui/disclosure';
import {
deriveAgentBrowserDiagnostics,
closeAgentBrowser,
getAgentBrowserState,
navigateAgentBrowser,
openAgentBrowser,
presentAgentBrowser,
readAgentBrowserEvents,
setAgentBrowserDiagnostics,
} from '@/lib/agent-browser';
import { subscribeHostEvent } from '@/lib/host-events';
import { cn } from '@/lib/utils';
import type {
AgentBrowserBounds,
AgentBrowserCdpEvent,
AgentBrowserSnapshot,
} from '../../../shared/agent-browser';
type DiagnosticsTab = 'console' | 'network';
export interface AgentBrowserPanelProps {
projectId?: string | null;
projectPath: string | null;
}
const MIN_LAYOUT_COLUMN_WIDTH = 256;
const DEFAULT_PANEL_WIDTH = 320;
const MIN_PANEL_WIDTH = MIN_LAYOUT_COLUMN_WIDTH;
const MAX_PANEL_WIDTH_RATIO = 0.72;
const COLLAPSE_PANEL_WIDTH = MIN_LAYOUT_COLUMN_WIDTH;
const CHAT_CANVAS_MIN_WIDTH = MIN_LAYOUT_COLUMN_WIDTH;
const DIAGNOSTICS_COLLAPSED_HEIGHT = 42;
const DEFAULT_DIAGNOSTICS_HEIGHT = 260;
const MIN_DIAGNOSTICS_HEIGHT = 168;
const DIAGNOSTICS_COLLAPSE_THRESHOLD = 96;
const DIAGNOSTICS_MAX_HEIGHT_RATIO = 0.68;
const TITLEBAR_BROWSER_WIDTH_VARIABLE = '--agent-browser-panel-width';
const TITLEBAR_BROWSER_LOGO_GAP_VARIABLE = '--agent-browser-titlebar-logo-gap';
const TITLEBAR_BROWSER_TRANSITION_DURATION_VARIABLE = '--agent-browser-panel-transition-duration';
const EVENT_METHODS = [
'Runtime.consoleAPICalled',
'Runtime.exceptionThrown',
'Log.entryAdded',
'Network.requestWillBeSent',
'Network.responseReceived',
'Network.loadingFinished',
'Network.loadingFailed',
];
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 readPanelMaxWidth(element: HTMLElement | null): number {
const layout = element?.closest<HTMLElement>('[data-testid="opencode-chat-layout"]')
?? document.querySelector<HTMLElement>('[data-testid="opencode-chat-layout"]')
?? element?.parentElement;
const layoutWidth = layout?.getBoundingClientRect().width ?? 0;
const parentWidth = element?.parentElement?.getBoundingClientRect().width ?? 0;
const availableWidth = layoutWidth || parentWidth || window.innerWidth;
const widthWithMinimumColumns = availableWidth - (CHAT_CANVAS_MIN_WIDTH * 2);
return Math.max(
MIN_PANEL_WIDTH,
Math.min(
Math.floor(availableWidth * MAX_PANEL_WIDTH_RATIO),
Math.floor(widthWithMinimumColumns),
),
);
}
function readEqualPanelWidth(element: HTMLElement | null): number {
const layout = element?.closest<HTMLElement>('[data-testid="opencode-chat-layout"]')
?? document.querySelector<HTMLElement>('[data-testid="opencode-chat-layout"]')
?? element?.parentElement;
const layoutWidth = layout?.getBoundingClientRect().width ?? 0;
const parentWidth = element?.parentElement?.getBoundingClientRect().width ?? 0;
const availableWidth = layoutWidth || parentWidth || window.innerWidth;
return Math.min(
readPanelMaxWidth(element),
Math.max(MIN_PANEL_WIDTH, Math.floor(availableWidth / 3)),
);
}
function clampPanelWidth(width: number, element: HTMLElement | null): number {
return Math.min(
Math.max(width, MIN_PANEL_WIDTH),
readPanelMaxWidth(element),
);
}
function readDiagnosticsMaxHeight(element: HTMLElement | null): number {
const panelHeight = element?.getBoundingClientRect().height || window.innerHeight;
const availableHeight = Math.max(
MIN_DIAGNOSTICS_HEIGHT,
panelHeight - 180,
);
return Math.max(
MIN_DIAGNOSTICS_HEIGHT,
Math.min(
Math.floor(panelHeight * DIAGNOSTICS_MAX_HEIGHT_RATIO),
availableHeight,
),
);
}
function clampDiagnosticsHeight(height: number, element: HTMLElement | null): number {
return Math.min(
Math.max(height, MIN_DIAGNOSTICS_HEIGHT),
readDiagnosticsMaxHeight(element),
);
}
function hasModalOcclusion(): boolean {
return Array.from(
document.querySelectorAll<HTMLElement>('[role="dialog"], [role="alertdialog"]'),
).some((element) => {
if (element.getAttribute('data-state') === 'closed' || element.hidden) return false;
const style = window.getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden';
});
}
function isBrowserActive(snapshot: AgentBrowserSnapshot | null): boolean {
return Boolean(
snapshot?.browserId
&& snapshot.state !== 'closed'
&& snapshot.state !== 'closing',
);
}
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, projectPath }: AgentBrowserPanelProps) {
const [isOpen, setIsOpen] = useState(false);
const [panelMounted, setPanelMounted] = useState(false);
const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
const [isResizing, setIsResizing] = useState(false);
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
const [diagnosticsHeight, setDiagnosticsHeight] = useState(DEFAULT_DIAGNOSTICS_HEIGHT);
const [isDiagnosticsResizing, setIsDiagnosticsResizing] = useState(false);
const [snapshot, setSnapshot] = useState<AgentBrowserSnapshot | null>(null);
const [address, setAddress] = useState('');
const [activeTab, setActiveTab] = useState<DiagnosticsTab>('console');
const [events, setEvents] = useState<AgentBrowserCdpEvent[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [occluded, setOccluded] = useState(false);
const panelRef = useRef<HTMLElement | HTMLDivElement | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
const addressInputRef = useRef<HTMLInputElement | null>(null);
const eventCursorRef = useRef(0);
const resizeRef = useRef<{
pointerId: number;
startX: number;
startWidth: number;
} | null>(null);
const diagnosticsResizeRef = useRef<{
pointerId: number;
startY: number;
startHeight: number;
} | null>(null);
const panelCloseTimerRef = useRef<number | null>(null);
const browserActive = isBrowserActive(snapshot);
const diagnostics = useMemo(
() => deriveAgentBrowserDiagnostics(events),
[events],
);
useEffect(() => {
const root = document.documentElement;
if (isOpen) {
root.style.setProperty(TITLEBAR_BROWSER_WIDTH_VARIABLE, `${panelWidth}px`);
root.style.setProperty(TITLEBAR_BROWSER_LOGO_GAP_VARIABLE, '4px');
} else {
root.style.removeProperty(TITLEBAR_BROWSER_WIDTH_VARIABLE);
root.style.removeProperty(TITLEBAR_BROWSER_LOGO_GAP_VARIABLE);
}
root.style.setProperty(
TITLEBAR_BROWSER_TRANSITION_DURATION_VARIABLE,
isResizing ? '0ms' : '180ms',
);
return () => {
root.style.removeProperty(TITLEBAR_BROWSER_WIDTH_VARIABLE);
root.style.removeProperty(TITLEBAR_BROWSER_LOGO_GAP_VARIABLE);
root.style.removeProperty(TITLEBAR_BROWSER_TRANSITION_DURATION_VARIABLE);
};
}, [isOpen, isResizing, panelWidth]);
useEffect(() => () => {
if (panelCloseTimerRef.current !== null) {
window.clearTimeout(panelCloseTimerRef.current);
}
}, []);
useEffect(() => {
if (!isOpen) return undefined;
const syncPanelWidthToLayout = () => {
setPanelWidth((current) => Math.min(current, readPanelMaxWidth(panelRef.current)));
setDiagnosticsHeight((current) => clampDiagnosticsHeight(current, panelRef.current));
};
window.addEventListener('resize', syncPanelWidthToLayout);
return () => window.removeEventListener('resize', syncPanelWidthToLayout);
}, [isOpen]);
useEffect(() => {
if (!isOpen) {
setOccluded(false);
return undefined;
}
const updateOcclusion = () => setOccluded(hasModalOcclusion());
updateOcclusion();
const observer = new MutationObserver(updateOcclusion);
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class', 'data-state', 'hidden', 'style'],
childList: true,
subtree: true,
});
return () => observer.disconnect();
}, [isOpen]);
const applySnapshot = useCallback((next: AgentBrowserSnapshot) => {
setSnapshot(next);
if (
next.url
&& document.activeElement !== addressInputRef.current
) {
setAddress(next.url);
}
}, []);
useEffect(() => {
if (!projectPath) return undefined;
const unsubscribeShow = subscribeHostEvent<AgentBrowserSnapshot>(
'agent-browser:show',
(next) => {
if (projectId && next.projectId !== projectId) return;
applySnapshot(next);
},
);
const unsubscribeState = subscribeHostEvent<AgentBrowserSnapshot>(
'agent-browser:state',
(next) => {
if (projectId && next.projectId && next.projectId !== projectId) return;
applySnapshot(next);
},
);
return () => {
unsubscribeShow();
unsubscribeState();
};
}, [applySnapshot, projectId, projectPath]);
useEffect(() => {
setSnapshot(null);
setEvents([]);
eventCursorRef.current = 0;
setError(null);
setIsOpen(false);
setPanelMounted(false);
setPanelWidth(DEFAULT_PANEL_WIDTH);
setDiagnosticsOpen(false);
setDiagnosticsHeight(DEFAULT_DIAGNOSTICS_HEIGHT);
diagnosticsResizeRef.current = null;
setIsDiagnosticsResizing(false);
if (panelCloseTimerRef.current !== null) {
window.clearTimeout(panelCloseTimerRef.current);
panelCloseTimerRef.current = null;
}
if (!projectPath) return undefined;
let cancelled = false;
void getAgentBrowserState(projectPath)
.then((next) => {
if (cancelled || !isBrowserActive(next)) return;
applySnapshot(next);
})
.catch(() => {
// A missing browser is the normal initial state.
});
return () => {
cancelled = true;
};
}, [applySnapshot, projectPath]);
useEffect(() => () => {
if (projectPath) {
void closeAgentBrowser(projectPath).catch(() => undefined);
}
}, [projectPath]);
useEffect(() => {
eventCursorRef.current = 0;
setEvents([]);
}, [snapshot?.browserId, snapshot?.projectPath]);
useEffect(() => {
if (!projectPath || !browserActive) return undefined;
void setAgentBrowserDiagnostics({ projectPath, enabled: diagnosticsOpen })
.then((next) => applySnapshot(next))
.catch(() => undefined);
if (!diagnosticsOpen) {
eventCursorRef.current = 0;
setEvents([]);
}
return undefined;
}, [applySnapshot, browserActive, diagnosticsOpen, projectPath]);
useEffect(() => {
if (!isOpen || !diagnosticsOpen || !projectPath || snapshot?.state !== 'attached') {
return undefined;
}
let cancelled = false;
let inFlight = false;
let pollTimer: number | null = null;
const poll = async () => {
if (inFlight) return;
inFlight = true;
let retryDelay = 250;
try {
const page = await readAgentBrowserEvents({
projectPath,
after: eventCursorRef.current,
methods: EVENT_METHODS,
limit: 200,
waitMs: 5_000,
});
if (cancelled) return;
eventCursorRef.current = page.nextCursor;
setEvents((current) => {
const base = page.gap ? [] : current;
return [...base, ...page.events].slice(-500);
});
// A real request is held by Main for up to waitMs. Test doubles and
// transient failures can return immediately; back off those empty
// responses so a fast server cannot turn the long-poll into a hot
// microtask loop.
retryDelay = page.events.length > 0 || page.hasMore ? 0 : 250;
} catch {
// State polling presents attachment failures without turning the panel noisy.
} finally {
inFlight = false;
if (!cancelled) {
pollTimer = window.setTimeout(() => {
pollTimer = null;
void poll();
}, retryDelay);
}
}
};
void poll();
return () => {
cancelled = true;
if (pollTimer !== null) window.clearTimeout(pollTimer);
};
}, [diagnosticsOpen, isOpen, projectPath, snapshot?.state, snapshot?.generation]);
useEffect(() => {
if (!isOpen || occluded || !projectPath || !browserActive) return undefined;
const viewport = viewportRef.current;
if (!viewport) return undefined;
let cancelled = false;
let timer: number | null = null;
let lastBounds = '';
const schedulePresentation = () => {
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({
projectPath,
visible: true,
bounds,
}).then((next) => {
if (!cancelled) applySnapshot(next);
}).catch((cause) => {
if (!cancelled) {
setError(cause instanceof Error ? cause.message : '无法显示开发浏览器');
}
});
}, 50);
};
const observer = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(schedulePresentation);
observer?.observe(viewport);
window.addEventListener('resize', schedulePresentation);
schedulePresentation();
return () => {
cancelled = true;
if (timer !== null) window.clearTimeout(timer);
observer?.disconnect();
window.removeEventListener('resize', schedulePresentation);
};
}, [applySnapshot, browserActive, isOpen, occluded, projectPath]);
useEffect(() => {
if (!projectPath || !browserActive) return undefined;
if (!isOpen || occluded) {
void presentAgentBrowser({
projectPath,
visible: false,
}).catch(() => undefined);
return undefined;
}
return () => {
void presentAgentBrowser({
projectPath,
visible: false,
}).catch(() => undefined);
};
}, [browserActive, isOpen, occluded, projectPath]);
const runBrowserAction = useCallback(async (
action: () => Promise<AgentBrowserSnapshot>,
) => {
setBusy(true);
setError(null);
try {
applySnapshot(await action());
} catch (cause) {
setError(cause instanceof Error ? cause.message : '开发浏览器操作失败');
} finally {
setBusy(false);
}
}, [applySnapshot]);
const handleOpenAddress = useCallback((event?: FormEvent) => {
event?.preventDefault();
if (!projectPath) return;
const url = normalizeAddress(address);
if (!url) return;
setAddress(url);
void runBrowserAction(() => browserActive
? navigateAgentBrowser({ projectPath, action: 'url', url })
: openAgentBrowser({
projectPath,
url,
bounds: readBounds(viewportRef.current),
}));
}, [address, browserActive, projectPath, runBrowserAction]);
const handleNavigate = useCallback((
action: 'back' | 'forward' | 'reload',
) => {
if (!projectPath || !browserActive) return;
void runBrowserAction(
() => navigateAgentBrowser({ projectPath, action }),
);
}, [browserActive, projectPath, runBrowserAction]);
const closePanel = useCallback(() => {
setIsOpen(false);
setSnapshot(null);
if (projectPath) {
void closeAgentBrowser(projectPath).catch(() => undefined);
}
setPanelWidth(DEFAULT_PANEL_WIDTH);
setDiagnosticsOpen(false);
setDiagnosticsHeight(DEFAULT_DIAGNOSTICS_HEIGHT);
diagnosticsResizeRef.current = null;
setIsDiagnosticsResizing(false);
if (panelCloseTimerRef.current !== null) {
window.clearTimeout(panelCloseTimerRef.current);
}
panelCloseTimerRef.current = window.setTimeout(() => {
setPanelMounted(false);
panelCloseTimerRef.current = null;
}, DISCLOSURE_EXIT_MS);
}, [projectPath]);
const handleResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
event.preventDefault();
resizeRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startWidth: panelRef.current?.getBoundingClientRect().width || panelWidth,
};
setIsResizing(true);
event.currentTarget.setPointerCapture?.(event.pointerId);
}, [panelWidth]);
const handleResizePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const resize = resizeRef.current;
if (!resize || resize.pointerId !== event.pointerId) return;
const nextWidth = resize.startWidth + resize.startX - event.clientX;
if (nextWidth < COLLAPSE_PANEL_WIDTH) {
resizeRef.current = null;
setIsResizing(false);
closePanel();
return;
}
setPanelWidth(clampPanelWidth(nextWidth, panelRef.current));
}, [closePanel]);
const finishResize = useCallback((event?: ReactPointerEvent<HTMLDivElement>) => {
const resize = resizeRef.current;
if (!resize || (event && resize.pointerId !== event.pointerId)) return;
resizeRef.current = null;
setIsResizing(false);
if (event?.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
}, []);
const handleDiagnosticsResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || !diagnosticsOpen) return;
event.preventDefault();
diagnosticsResizeRef.current = {
pointerId: event.pointerId,
startY: event.clientY,
startHeight: diagnosticsHeight,
};
setIsDiagnosticsResizing(true);
event.currentTarget.setPointerCapture?.(event.pointerId);
}, [diagnosticsHeight, diagnosticsOpen]);
const handleDiagnosticsResizePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const resize = diagnosticsResizeRef.current;
if (!resize || resize.pointerId !== event.pointerId) return;
const nextHeight = resize.startHeight + resize.startY - event.clientY;
if (nextHeight < DIAGNOSTICS_COLLAPSE_THRESHOLD) {
diagnosticsResizeRef.current = null;
setIsDiagnosticsResizing(false);
setDiagnosticsOpen(false);
return;
}
setDiagnosticsHeight(clampDiagnosticsHeight(nextHeight, panelRef.current));
}, []);
const finishDiagnosticsResize = useCallback((event?: ReactPointerEvent<HTMLDivElement>) => {
const resize = diagnosticsResizeRef.current;
if (!resize || (event && resize.pointerId !== event.pointerId)) return;
diagnosticsResizeRef.current = null;
setIsDiagnosticsResizing(false);
if (event?.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
}, []);
const handleDiagnosticsResizeKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
event.preventDefault();
const delta = event.key === 'ArrowUp' ? 24 : -24;
const nextHeight = diagnosticsHeight + delta;
if (nextHeight < DIAGNOSTICS_COLLAPSE_THRESHOLD) {
setDiagnosticsOpen(false);
return;
}
setDiagnosticsHeight(clampDiagnosticsHeight(nextHeight, panelRef.current));
}, [diagnosticsHeight]);
const handleResizeKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
const delta = event.key === 'ArrowLeft' ? 32 : -32;
const nextWidth = panelWidth + delta;
if (nextWidth < COLLAPSE_PANEL_WIDTH) {
closePanel();
return;
}
setPanelWidth(clampPanelWidth(nextWidth, panelRef.current));
}, [closePanel, panelWidth]);
const openPanel = useCallback(() => {
if (panelCloseTimerRef.current !== null) {
window.clearTimeout(panelCloseTimerRef.current);
panelCloseTimerRef.current = null;
}
setPanelWidth(readEqualPanelWidth(panelRef.current));
setPanelMounted(true);
setIsOpen(true);
}, []);
const handlePanelToggle = useCallback(() => {
if (isOpen) {
closePanel();
return;
}
openPanel();
}, [closePanel, isOpen, openPanel]);
const handleDiagnosticsTab = useCallback((tab: DiagnosticsTab) => {
if (activeTab === tab) {
setDiagnosticsOpen((current) => !current);
return;
}
setActiveTab(tab);
setDiagnosticsOpen(true);
}, [activeTab]);
const panelToggle = (
<button
type="button"
className={cn(
'no-drag relative flex h-10 w-10 items-center justify-center border-0 bg-transparent p-0 text-muted-foreground outline-none transition-colors duration-100 hover:bg-transparent hover:text-foreground focus-visible:ring-2 focus-visible:ring-brand/30',
isOpen || browserActive
? 'text-brand hover:text-brand'
: 'text-muted-foreground hover:text-foreground',
)}
aria-label={isOpen ? '收起开发浏览器' : '打开开发浏览器'}
aria-pressed={isOpen}
title={isOpen
? '收起开发浏览器'
: browserActive
? '开发浏览器已收起,AI 调试已暂停'
: '打开开发浏览器'}
data-testid="agent-browser-panel-open"
disabled={!projectPath}
onClick={handlePanelToggle}
>
{isOpen ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
{browserActive ? (
<span
aria-hidden
className="absolute right-2 top-2 h-1.5 w-1.5 rounded-full bg-brand ring-2 ring-background"
/>
) : null}
</button>
);
const titlebarPanelToggle = createPortal(
<div
data-testid="agent-browser-titlebar-toggle"
className="no-drag pointer-events-auto fixed right-0 top-0 z-[100] flex h-10 w-10 items-center justify-center"
style={{ right: '0px' }}
>
{panelToggle}
</div>,
document.body,
);
return (
<>
{titlebarPanelToggle}
{panelMounted ? (
<>
<div
aria-hidden="true"
className="shrink-0 overflow-hidden"
data-testid="agent-browser-panel-spacer"
style={{
width: isOpen ? `${panelWidth}px` : '0px',
minWidth: isOpen ? `${MIN_LAYOUT_COLUMN_WIDTH}px` : '0px',
maxWidth: `${MAX_PANEL_WIDTH_RATIO * 100}%`,
}}
/>
{createPortal(
<aside
ref={panelRef}
className={cn(
'codex-browser-drawer fixed inset-y-0 right-0 z-40 flex min-h-0 min-w-[256px] flex-col overflow-hidden border-l border-border/80 bg-background text-foreground shadow-[-16px_0_32px_hsl(220_20%_16%_/_0.06)]',
isResizing && 'select-none transition-none',
)}
style={{
width: `${panelWidth}px`,
maxWidth: `${MAX_PANEL_WIDTH_RATIO * 100}vw`,
}}
data-state={isOpen ? 'open' : 'closed'}
data-testid="agent-browser-panel"
>
<div
role="separator"
tabIndex={0}
aria-label="调整开发浏览器宽度"
aria-orientation="vertical"
aria-valuemin={MIN_PANEL_WIDTH}
aria-valuemax={readPanelMaxWidth(panelRef.current)}
aria-valuenow={Math.round(panelWidth)}
className={cn(
'group absolute inset-y-0 left-0 z-20 w-2 -translate-x-1/2 cursor-col-resize touch-none outline-none',
isResizing && 'bg-brand/10',
)}
data-testid="agent-browser-panel-resize-handle"
onKeyDown={handleResizeKeyDown}
onPointerCancel={finishResize}
onPointerDown={handleResizePointerDown}
onPointerMove={handleResizePointerMove}
onPointerUp={finishResize}
>
<span
aria-hidden
className={cn(
'absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border/70 transition-colors duration-100 group-hover:bg-brand/70',
isResizing && 'w-0.5 bg-brand',
)}
/>
</div>
<div className="flex h-10 shrink-0 items-center gap-2.5 bg-background px-2.5">
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
<Globe2 className="h-3 w-3" aria-hidden="true" />
</span>
<h2 className="truncate text-xs font-semibold tracking-[-0.01em]">开发浏览器</h2>
</div>
</div>
<form
className="flex h-9 shrink-0 items-center gap-1 bg-background px-2 py-1"
onSubmit={handleOpenAddress}
>
<div className="flex shrink-0 items-center gap-0.5">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground"
aria-label="后退"
disabled={!browserActive || !snapshot?.canGoBack || busy}
onClick={() => handleNavigate('back')}
>
<ArrowLeft className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground"
aria-label="前进"
disabled={!browserActive || !snapshot?.canGoForward || busy}
onClick={() => handleNavigate('forward')}
>
<ArrowRight className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground"
aria-label="刷新网页"
disabled={!browserActive || busy}
onClick={() => handleNavigate('reload')}
>
<RefreshCw className={cn('h-3.5 w-3.5', busy && 'animate-spin')} />
</Button>
</div>
<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 text-foreground outline-none transition-colors duration-100 placeholder:text-muted-foreground focus:border-brand/40 focus:ring-2 focus:ring-brand/15"
placeholder="输入 URL"
spellCheck={false}
/>
</form>
{error ? (
<p
role="alert"
className="shrink-0 border-b border-destructive/15 bg-destructive/5 px-3 py-2 text-xs font-medium 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="开发浏览器页面"
>
{!browserActive ? (
<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 text-muted-foreground shadow-soft">
<Globe2 className="h-5 w-5" />
</span>
<p className="text-balance text-[13px] font-semibold tracking-[-0.01em] text-foreground">开始浏览</p>
<p className="text-pretty text-[11px] font-medium">输入 URL 以打开页面</p>
</div>
) : null}
</div>
<div
className={cn(
'relative flex shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background',
isDiagnosticsResizing && 'select-none transition-none',
)}
style={{
height: `${diagnosticsOpen ? diagnosticsHeight : DIAGNOSTICS_COLLAPSED_HEIGHT}px`,
}}
data-state={diagnosticsOpen ? 'open' : 'closed'}
data-testid="agent-browser-diagnostics"
>
{diagnosticsOpen ? (
<div
role="separator"
tabIndex={0}
aria-label="调整调试面板高度"
aria-orientation="horizontal"
aria-valuemin={MIN_DIAGNOSTICS_HEIGHT}
aria-valuemax={readDiagnosticsMaxHeight(panelRef.current)}
aria-valuenow={Math.round(diagnosticsHeight)}
className={cn(
'group flex h-3 shrink-0 cursor-row-resize touch-none items-center justify-center outline-none',
isDiagnosticsResizing && 'bg-brand/10',
)}
data-testid="agent-browser-diagnostics-resize-handle"
onKeyDown={handleDiagnosticsResizeKeyDown}
onPointerCancel={finishDiagnosticsResize}
onPointerDown={handleDiagnosticsResizePointerDown}
onPointerMove={handleDiagnosticsResizePointerMove}
onPointerUp={finishDiagnosticsResize}
>
<GripHorizontal
className={cn(
'h-3.5 w-3.5 text-border transition-colors duration-150 group-hover:text-brand',
isDiagnosticsResizing && 'text-brand',
)}
aria-hidden="true"
/>
</div>
) : null}
<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}
aria-expanded={activeTab === tab && diagnosticsOpen}
className={cn(
'relative flex h-10 items-center gap-1.5 border-b-2 px-2.5 text-[11px] font-medium transition-colors duration-100',
activeTab === tab
? 'border-brand text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
onClick={() => handleDiagnosticsTab(tab)}
>
{tab === 'console'
? `Console ${diagnostics.console.length}`
: `Network ${diagnostics.network.length}`}
</button>
))}
</div>
<div className="flex shrink-0 items-center gap-0.5">
{diagnosticsOpen ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 rounded-md text-muted-foreground hover:text-foreground"
aria-label="收起调试面板"
title="收起调试面板"
onClick={() => setDiagnosticsOpen(false)}
>
<ChevronDown className="h-3.5 w-3.5" />
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 rounded-md text-muted-foreground hover:text-foreground"
aria-label="展开调试面板"
title="展开调试面板"
onClick={() => setDiagnosticsOpen(true)}
>
<ChevronUp className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
{diagnosticsOpen ? (
<div id="agent-browser-diagnostics-content" 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 font-medium 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] font-medium 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}
</div>
</aside>,
document.body,
)}
</>
) : null}
</>
);
}