feat: 增加共享 Agent Browser 调试能力

需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。

实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
This commit is contained in:
2026-07-31 14:53:36 +08:00
parent 80e8386fa6
commit e97a7ce9df
39 changed files with 6734 additions and 9 deletions

409
src/lib/agent-browser.ts Normal file
View File

@@ -0,0 +1,409 @@
import { hostApiFetch } from '@/lib/host-api';
import type {
AgentBrowserBounds,
AgentBrowserCdpEvent,
AgentBrowserCdpEventPage,
AgentBrowserCdpResult,
AgentBrowserPayloadChunk,
AgentBrowserSnapshot,
} from '../../shared/agent-browser';
type BrowserEnvelope = {
success: boolean;
browser: AgentBrowserSnapshot;
error?: string;
};
type EventPageEnvelope = {
success: boolean;
page: AgentBrowserCdpEventPage;
error?: string;
};
type CdpResultEnvelope = {
success: boolean;
result: AgentBrowserCdpResult;
error?: string;
};
type PayloadEnvelope = {
success: boolean;
chunk: AgentBrowserPayloadChunk;
error?: string;
};
export type AgentBrowserNavigateAction = 'url' | 'back' | 'forward' | 'reload';
export interface AgentBrowserConsoleEntry {
id: number;
level: string;
text: string;
timestamp: number;
source?: string;
url?: string;
}
export interface AgentBrowserNetworkEntry {
requestId: string;
sequence: number;
method: string;
url: string;
resourceType?: string;
status?: number;
statusText?: string;
mimeType?: string;
durationMs?: number;
encodedDataLength?: number;
errorText?: string;
}
export interface AgentBrowserDiagnostics {
console: AgentBrowserConsoleEntry[];
network: AgentBrowserNetworkEntry[];
}
function jsonBody(body: Record<string, unknown>): RequestInit {
return {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
};
}
function requireSuccess<T extends { success: boolean; error?: string }>(
envelope: T,
): T {
if (!envelope.success) {
throw new Error(envelope.error || '开发浏览器请求失败');
}
return envelope;
}
export async function getAgentBrowserState(
projectPath: string,
): Promise<AgentBrowserSnapshot> {
const query = new URLSearchParams({ project_path: projectPath });
const response = await hostApiFetch<BrowserEnvelope>(
`/api/agent-browser/state?${query.toString()}`,
);
return requireSuccess(response).browser;
}
export async function openAgentBrowser(input: {
projectPath: string;
url: string;
bounds?: AgentBrowserBounds;
}): Promise<AgentBrowserSnapshot> {
const response = await hostApiFetch<BrowserEnvelope>(
'/api/agent-browser/open',
jsonBody({
project_path: input.projectPath,
url: input.url,
visible: true,
...(input.bounds ? { bounds: input.bounds } : {}),
}),
);
return requireSuccess(response).browser;
}
export async function presentAgentBrowser(input: {
projectPath: string;
visible: boolean;
bounds?: AgentBrowserBounds;
}): Promise<AgentBrowserSnapshot> {
const response = await hostApiFetch<BrowserEnvelope>(
'/api/agent-browser/present',
jsonBody({
project_path: input.projectPath,
visible: input.visible,
...(input.bounds ? { bounds: input.bounds } : {}),
}),
);
return requireSuccess(response).browser;
}
export async function navigateAgentBrowser(input: {
projectPath: string;
action: AgentBrowserNavigateAction;
url?: string;
}): Promise<AgentBrowserSnapshot> {
const response = await hostApiFetch<BrowserEnvelope>(
'/api/agent-browser/navigate',
jsonBody({
project_path: input.projectPath,
action: input.action,
...(input.url ? { url: input.url } : {}),
}),
);
return requireSuccess(response).browser;
}
export async function sendAgentBrowserCdp(input: {
projectPath: string;
method: string;
params?: Record<string, unknown>;
sessionRef?: string;
timeoutMs?: number;
}): Promise<AgentBrowserCdpResult> {
const response = await hostApiFetch<CdpResultEnvelope>(
'/api/agent-browser/cdp/send',
jsonBody({
project_path: input.projectPath,
method: input.method,
...(input.params ? { params: input.params } : {}),
...(input.sessionRef ? { session_ref: input.sessionRef } : {}),
...(input.timeoutMs !== undefined ? { timeout_ms: input.timeoutMs } : {}),
}),
);
return requireSuccess(response).result;
}
export async function readAgentBrowserEvents(input: {
projectPath: string;
after?: number;
methods?: string[];
limit?: number;
waitMs?: number;
}): Promise<AgentBrowserCdpEventPage> {
const response = await hostApiFetch<EventPageEnvelope>(
'/api/agent-browser/cdp/events',
jsonBody({
project_path: input.projectPath,
...(input.after !== undefined ? { after: input.after } : {}),
...(input.methods ? { methods: input.methods } : {}),
...(input.limit !== undefined ? { limit: input.limit } : {}),
...(input.waitMs !== undefined ? { wait_ms: input.waitMs } : {}),
}),
);
return requireSuccess(response).page;
}
export async function readAgentBrowserPayload(input: {
projectPath: string;
handle: string;
offset?: number;
maxBytes?: number;
}): Promise<AgentBrowserPayloadChunk> {
const response = await hostApiFetch<PayloadEnvelope>(
'/api/agent-browser/payload/read',
jsonBody({
project_path: input.projectPath,
handle: input.handle,
...(input.offset !== undefined ? { offset: input.offset } : {}),
...(input.maxBytes !== undefined ? { max_bytes: input.maxBytes } : {}),
}),
);
return requireSuccess(response).chunk;
}
export async function closeAgentBrowser(
projectPath: string,
): Promise<AgentBrowserSnapshot> {
const response = await hostApiFetch<BrowserEnvelope>(
'/api/agent-browser/close',
jsonBody({ project_path: projectPath }),
);
return requireSuccess(response).browser;
}
export async function resetAgentBrowserProfile(
projectPath: string,
): Promise<AgentBrowserSnapshot> {
const response = await hostApiFetch<BrowserEnvelope>(
'/api/agent-browser/reset-profile',
jsonBody({ project_path: projectPath }),
);
return requireSuccess(response).browser;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
function asNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function stringifyConsoleArgument(value: unknown): string {
const remoteObject = asRecord(value);
if (!remoteObject) return String(value ?? '');
if ('value' in remoteObject) {
const primitive = remoteObject.value;
if (typeof primitive === 'string') return primitive;
try {
return JSON.stringify(primitive);
} catch {
return String(primitive);
}
}
return asString(remoteObject.description)
?? asString(remoteObject.className)
?? asString(remoteObject.type)
?? '';
}
function consoleEntryFromEvent(
event: AgentBrowserCdpEvent,
): AgentBrowserConsoleEntry | null {
const params = asRecord(event.params);
if (!params) {
if (
event.payload
&& (
event.method === 'Runtime.consoleAPICalled'
|| event.method === 'Runtime.exceptionThrown'
|| event.method === 'Log.entryAdded'
)
) {
return {
id: event.sequence,
level: 'info',
text: `大型 Console 事件(${event.payload.byteLength} 字节),智能体可按需读取完整内容`,
timestamp: event.timestamp,
};
}
return null;
}
if (event.method === 'Runtime.consoleAPICalled') {
const args = Array.isArray(params.args) ? params.args : [];
return {
id: event.sequence,
level: asString(params.type) ?? 'log',
text: args.map(stringifyConsoleArgument).join(' '),
timestamp: event.timestamp,
};
}
if (event.method === 'Runtime.exceptionThrown') {
const details = asRecord(params.exceptionDetails);
const exception = asRecord(details?.exception);
const exceptionText = exception ? stringifyConsoleArgument(exception) : '';
return {
id: event.sequence,
level: 'error',
text: asString(exception?.description)
?? (exceptionText || undefined)
?? asString(details?.text)
?? '未捕获异常',
timestamp: event.timestamp,
url: asString(details?.url),
};
}
if (event.method === 'Log.entryAdded') {
const entry = asRecord(params.entry);
if (!entry) return null;
return {
id: event.sequence,
level: asString(entry.level) ?? 'log',
text: asString(entry.text) ?? '',
timestamp: event.timestamp,
source: asString(entry.source),
url: asString(entry.url),
};
}
return null;
}
export function deriveAgentBrowserDiagnostics(
events: AgentBrowserCdpEvent[],
): AgentBrowserDiagnostics {
const consoleEntries: AgentBrowserConsoleEntry[] = [];
const requests = new Map<string, AgentBrowserNetworkEntry & { startedAt: number }>();
const requestOrder: string[] = [];
for (const event of events) {
const consoleEntry = consoleEntryFromEvent(event);
if (consoleEntry) consoleEntries.push(consoleEntry);
const params = asRecord(event.params);
const requestId = asString(params?.requestId);
if (!params || !requestId) {
if (event.payload && event.method.startsWith('Network.')) {
const payloadKey = `${event.sessionRef ?? 'root'}:payload:${event.sequence}`;
requestOrder.push(payloadKey);
requests.set(payloadKey, {
requestId: `payload:${event.sequence}`,
sequence: event.sequence,
method: event.method.slice('Network.'.length),
url: `大型 Network 事件(${event.payload.byteLength} 字节),智能体可按需读取完整内容`,
resourceType: 'payload',
startedAt: event.timestamp,
});
}
continue;
}
const requestKey = `${event.sessionRef ?? 'root'}:${requestId}`;
if (event.method === 'Network.requestWillBeSent') {
const request = asRecord(params.request);
if (!requests.has(requestKey)) requestOrder.push(requestKey);
requests.set(requestKey, {
requestId,
sequence: event.sequence,
method: asString(request?.method) ?? 'GET',
url: asString(request?.url) ?? '',
resourceType: asString(params.type),
startedAt: event.timestamp,
});
continue;
}
const current = requests.get(requestKey);
if (!current) continue;
if (event.method === 'Network.responseReceived') {
const response = asRecord(params.response);
requests.set(requestKey, {
...current,
url: asString(response?.url) ?? current.url,
resourceType: asString(params.type) ?? current.resourceType,
status: asNumber(response?.status),
statusText: asString(response?.statusText),
mimeType: asString(response?.mimeType),
});
} else if (event.method === 'Network.loadingFinished') {
requests.set(requestKey, {
...current,
durationMs: Math.max(0, event.timestamp - current.startedAt),
encodedDataLength: asNumber(params.encodedDataLength),
});
} else if (event.method === 'Network.loadingFailed') {
requests.set(requestKey, {
...current,
durationMs: Math.max(0, event.timestamp - current.startedAt),
errorText: asString(params.errorText) ?? '请求失败',
});
}
}
return {
console: consoleEntries,
network: requestOrder
.map((requestId) => requests.get(requestId))
.filter((entry): entry is AgentBrowserNetworkEntry & { startedAt: number } => Boolean(entry))
.map((entry) => ({
requestId: entry.requestId,
sequence: entry.sequence,
method: entry.method,
url: entry.url,
...(entry.resourceType ? { resourceType: entry.resourceType } : {}),
...(entry.status !== undefined ? { status: entry.status } : {}),
...(entry.statusText ? { statusText: entry.statusText } : {}),
...(entry.mimeType ? { mimeType: entry.mimeType } : {}),
...(entry.durationMs !== undefined ? { durationMs: entry.durationMs } : {}),
...(entry.encodedDataLength !== undefined
? { encodedDataLength: entry.encodedDataLength }
: {}),
...(entry.errorText ? { errorText: entry.errorText } : {}),
})),
};
}

View File

@@ -8,6 +8,8 @@ const HOST_EVENT_TO_IPC_CHANNEL: Record<string, string> = {
'oauth:code': 'oauth:code',
'oauth:success': 'oauth:success',
'oauth:error': 'oauth:error',
'agent-browser:show': 'agent-browser:show',
'agent-browser:state': 'agent-browser:state',
};
function getEventSource(): EventSource {

View File

@@ -0,0 +1,680 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type FormEvent,
} from 'react';
import {
ArrowLeft,
ArrowRight,
Bug,
Globe2,
Loader2,
PanelRightClose,
PanelRightOpen,
Power,
RefreshCw,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
closeAgentBrowser,
deriveAgentBrowserDiagnostics,
getAgentBrowserState,
navigateAgentBrowser,
openAgentBrowser,
presentAgentBrowser,
readAgentBrowserEvents,
} 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 DEFAULT_URL = 'http://localhost:5173';
const EVENT_METHODS = [
'Runtime.consoleAPICalled',
'Runtime.exceptionThrown',
'Log.entryAdded',
'Network.requestWillBeSent',
'Network.responseReceived',
'Network.loadingFinished',
'Network.loadingFailed',
];
function normalizeAddress(value: string): string {
const trimmed = value.trim();
if (!trimmed) return DEFAULT_URL;
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 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 statusPresentation(snapshot: AgentBrowserSnapshot | null): {
label: string;
className: string;
} {
if (snapshot?.state === 'attached') {
return {
label: 'AI 可调试',
className: 'bg-emerald-50 text-emerald-700 ring-emerald-600/20',
};
}
if (snapshot?.state === 'suspended_devtools') {
return {
label: '原生 DevTools 占用',
className: 'bg-amber-50 text-amber-700 ring-amber-600/20',
};
}
if (snapshot?.state === 'opening' || snapshot?.state === 'attaching') {
return {
label: '正在连接',
className: 'bg-brand-soft text-brand ring-brand/20',
};
}
if (
snapshot?.state === 'crashed'
|| snapshot?.state === 'detached_fault'
) {
return {
label: '需要恢复',
className: 'bg-destructive/10 text-destructive ring-destructive/20',
};
}
return {
label: '尚未开启',
className: 'bg-surface-subtle text-muted-foreground ring-border',
};
}
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 [snapshot, setSnapshot] = useState<AgentBrowserSnapshot | null>(null);
const [address, setAddress] = useState(DEFAULT_URL);
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 viewportRef = useRef<HTMLDivElement | null>(null);
const addressInputRef = useRef<HTMLInputElement | null>(null);
const eventCursorRef = useRef(0);
const browserActive = isBrowserActive(snapshot);
const status = statusPresentation(snapshot);
const diagnostics = useMemo(
() => deriveAgentBrowserDiagnostics(events),
[events],
);
useEffect(() => {
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();
}, []);
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);
setIsOpen(true);
},
);
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);
if (!projectPath) return undefined;
let cancelled = false;
void getAgentBrowserState(projectPath)
.then((next) => {
if (cancelled || !isBrowserActive(next)) return;
applySnapshot(next);
setIsOpen(true);
})
.catch(() => {
// A missing browser is the normal initial state.
});
return () => {
cancelled = true;
};
}, [applySnapshot, projectPath]);
useEffect(() => {
if (!isOpen || !projectPath) return undefined;
let cancelled = false;
const refresh = async () => {
try {
const next = await getAgentBrowserState(projectPath);
if (!cancelled) {
applySnapshot(next);
setError(null);
}
} catch (cause) {
if (!cancelled) {
setError(cause instanceof Error ? cause.message : '无法读取开发浏览器状态');
}
}
};
void refresh();
const interval = window.setInterval(() => void refresh(), 2500);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [applySnapshot, isOpen, projectPath]);
useEffect(() => {
eventCursorRef.current = 0;
setEvents([]);
}, [snapshot?.browserId, snapshot?.projectPath]);
useEffect(() => {
if (!isOpen || !projectPath || snapshot?.state !== 'attached') {
return undefined;
}
let cancelled = false;
let inFlight = false;
const poll = async () => {
if (inFlight) return;
inFlight = true;
try {
const page = await readAgentBrowserEvents({
projectPath,
after: eventCursorRef.current,
methods: EVENT_METHODS,
limit: 200,
waitMs: 500,
});
if (cancelled) return;
eventCursorRef.current = page.nextCursor;
setEvents((current) => {
const base = page.gap ? [] : current;
return [...base, ...page.events].slice(-1000);
});
} catch {
// State polling presents attachment failures without turning the panel noisy.
} finally {
inFlight = false;
}
};
void poll();
const interval = window.setInterval(() => void poll(), 750);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [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);
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 handlePower = useCallback(() => {
if (!projectPath) return;
if (browserActive) {
void runBrowserAction(() => closeAgentBrowser(projectPath));
return;
}
const url = normalizeAddress(address);
setAddress(url);
void runBrowserAction(() => openAgentBrowser({
projectPath,
url,
bounds: readBounds(viewportRef.current),
}));
}, [address, browserActive, projectPath, runBrowserAction]);
if (!isOpen) {
return (
<div className="flex shrink-0 items-start border-l border-border/70 bg-surface-sidebar p-2">
<Button
type="button"
variant="ghost"
className={cn(
'relative h-10 w-10 rounded-full border border-border/70 p-0 shadow-soft transition-[background-color,color,box-shadow,transform] duration-150 ease-out active:scale-[0.96]',
browserActive
? 'bg-brand-soft text-brand hover:bg-brand-soft hover:shadow-float'
: 'bg-background text-muted-foreground hover:bg-surface-subtle hover:text-foreground',
)}
aria-label="打开开发浏览器"
title={browserActive ? '开发浏览器已收起AI 调试已暂停' : '打开开发浏览器'}
data-testid="agent-browser-panel-open"
disabled={!projectPath}
onClick={() => setIsOpen(true)}
>
<PanelRightOpen className="h-4 w-4" />
{browserActive ? (
<span
aria-hidden
className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-brand ring-2 ring-background"
/>
) : null}
</Button>
</div>
);
}
return (
<aside
className="flex min-h-0 w-[min(440px,42vw)] shrink-0 flex-col overflow-hidden border-l border-border/80 bg-background text-foreground"
data-testid="agent-browser-panel"
>
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border/70 bg-background/90 px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<Globe2 className="h-4 w-4 shrink-0 text-brand" />
<h2 className="truncate text-balance text-sm font-semibold"></h2>
<span
className={cn(
'shrink-0 rounded-full px-2 py-1 text-[10px] font-semibold ring-1 ring-inset',
status.className,
)}
data-testid="agent-browser-debug-status"
>
{status.label}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
'h-10 w-10 rounded-full text-muted-foreground active:scale-[0.96]',
browserActive
? 'bg-brand-soft text-brand hover:bg-brand-soft'
: 'hover:bg-surface-subtle hover:text-foreground',
)}
aria-label={browserActive ? '关闭 AI 调试' : '开启 AI 调试'}
aria-pressed={browserActive}
disabled={!projectPath || busy}
onClick={handlePower}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Power className="h-3.5 w-3.5" />}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-10 w-10 rounded-full text-muted-foreground hover:bg-surface-subtle hover:text-foreground active:scale-[0.96]"
aria-label="收起开发浏览器"
data-testid="agent-browser-panel-close"
onClick={() => setIsOpen(false)}
>
<PanelRightClose className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<form
className="flex shrink-0 items-center gap-1 border-b border-border/70 bg-surface-input p-2"
onSubmit={handleOpenAddress}
>
<Button
type="button"
variant="ghost"
size="icon"
className="h-10 w-10 shrink-0 rounded-full text-muted-foreground active:scale-[0.96]"
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-10 w-10 shrink-0 rounded-full text-muted-foreground active:scale-[0.96]"
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-10 w-10 shrink-0 rounded-full text-muted-foreground active:scale-[0.96]"
aria-label="刷新网页"
disabled={!browserActive || busy}
onClick={() => handleNavigate('reload')}
>
<RefreshCw className={cn('h-3.5 w-3.5', busy && 'animate-spin')} />
</Button>
<input
ref={addressInputRef}
value={address}
onChange={(event) => setAddress(event.target.value)}
aria-label="网页地址"
className="h-10 min-w-0 flex-1 rounded-lg border border-border bg-background px-3 text-xs font-medium text-foreground outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-muted-foreground focus:border-brand/40 focus:ring-2 focus:ring-brand/15"
placeholder="http://localhost:5173"
spellCheck={false}
/>
<Button
type="submit"
className="h-10 shrink-0 rounded-lg px-3 text-xs font-semibold active:scale-[0.96]"
disabled={!projectPath || busy}
>
</Button>
</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-2xl bg-background text-brand shadow-soft">
<Bug className="h-5 w-5" />
</span>
<p className="text-balance text-xs font-semibold text-foreground"></p>
<p className="text-pretty text-[11px] font-medium"> Console Network</p>
</div>
) : null}
</div>
<div className="flex h-52 shrink-0 flex-col border-t border-border/80 bg-background">
<div className="flex shrink-0 items-center justify-between border-b border-border/70 bg-surface-input px-2">
<div className="flex">
{(['console', 'network'] as const).map((tab) => (
<button
key={tab}
type="button"
role="tab"
aria-selected={activeTab === tab}
className={cn(
'min-h-10 border-b-2 px-3 py-2 text-[11px] font-medium transition-[border-color,color,transform] duration-150 active:scale-[0.96]',
activeTab === tab
? 'border-brand text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
onClick={() => setActiveTab(tab)}
>
{tab === 'console'
? `Console ${diagnostics.console.length}`
: `Network ${diagnostics.network.length}`}
</button>
))}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-10 w-10 rounded-full text-muted-foreground hover:text-foreground active:scale-[0.96]"
aria-label="清空调试记录"
onClick={() => setEvents([])}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{activeTab === 'console' ? (
<div
role="log"
aria-label="Console 日志"
className="min-h-0 flex-1 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="min-h-0 flex-1 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>
</aside>
);
}

View File

@@ -18,6 +18,7 @@ import { ChatMessage } from './ChatMessage';
import { ComposerAttachmentCard } from './ComposerAttachmentCard';
import { ExecutionGraphCard } from './ExecutionGraphCard';
import { GameAssetBrowser } from './GameAssetBrowser';
import { AgentBrowserPanel } from './AgentBrowserPanel';
import { OpencodeSessionDiffPreview } from './OpencodeSessionDiffPreview';
import {
buildMessageTextWithComposerAttachments,
@@ -3174,6 +3175,12 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
</div>
</form>
</div>
{!compactLayout ? (
<AgentBrowserPanel
projectId={activeProject?.id ?? null}
projectPath={activeProject?.path ?? null}
/>
) : null}
</div>
<ChatCommandRenameDialog
open={commands.dialog?.kind === 'rename'}