feat: 增加共享 Agent Browser 调试能力
需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。 实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
This commit is contained in:
680
src/pages/Chat/AgentBrowserPanel.tsx
Normal file
680
src/pages/Chat/AgentBrowserPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user