merge: 集成共享 Agent Browser 调试能力
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

需求:将 Agent Browser 独立功能提交合并到包含 AI 设计 Workspace 的最新主线。

实现:保留双方模块初始化、Host API 路由和 README 产品说明。

# Conflicts:
#	electron/api/context.ts
This commit is contained in:
2026-07-31 14:56:45 +08:00
39 changed files with 6734 additions and 9 deletions

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'}