fix(agent-browser): restore bounded presentation lifecycle
This commit is contained in:
@@ -80,9 +80,9 @@ function requireSuccess<T extends { success: boolean; error?: string }>(
|
||||
}
|
||||
|
||||
export async function getAgentBrowserState(
|
||||
projectPath: string,
|
||||
projectId: string,
|
||||
): Promise<AgentBrowserSnapshot> {
|
||||
const query = new URLSearchParams({ project_path: projectPath });
|
||||
const query = new URLSearchParams({ project_id: projectId });
|
||||
const response = await hostApiFetch<BrowserEnvelope>(
|
||||
`/api/agent-browser/state?${query.toString()}`,
|
||||
);
|
||||
@@ -90,14 +90,14 @@ export async function getAgentBrowserState(
|
||||
}
|
||||
|
||||
export async function openAgentBrowser(input: {
|
||||
projectPath: string;
|
||||
projectId: string;
|
||||
url: string;
|
||||
bounds?: AgentBrowserBounds;
|
||||
}): Promise<AgentBrowserSnapshot> {
|
||||
const response = await hostApiFetch<BrowserEnvelope>(
|
||||
'/api/agent-browser/open',
|
||||
jsonBody({
|
||||
project_path: input.projectPath,
|
||||
project_id: input.projectId,
|
||||
url: input.url,
|
||||
visible: true,
|
||||
...(input.bounds ? { bounds: input.bounds } : {}),
|
||||
@@ -107,14 +107,14 @@ export async function openAgentBrowser(input: {
|
||||
}
|
||||
|
||||
export async function presentAgentBrowser(input: {
|
||||
projectPath: string;
|
||||
projectId: string;
|
||||
visible: boolean;
|
||||
bounds?: AgentBrowserBounds;
|
||||
}): Promise<AgentBrowserSnapshot> {
|
||||
const response = await hostApiFetch<BrowserEnvelope>(
|
||||
'/api/agent-browser/present',
|
||||
jsonBody({
|
||||
project_path: input.projectPath,
|
||||
project_id: input.projectId,
|
||||
visible: input.visible,
|
||||
...(input.bounds ? { bounds: input.bounds } : {}),
|
||||
}),
|
||||
@@ -123,13 +123,13 @@ export async function presentAgentBrowser(input: {
|
||||
}
|
||||
|
||||
export async function setAgentBrowserDiagnostics(input: {
|
||||
projectPath: string;
|
||||
projectId: string;
|
||||
enabled: boolean;
|
||||
}): Promise<AgentBrowserSnapshot> {
|
||||
const response = await hostApiFetch<BrowserEnvelope>(
|
||||
'/api/agent-browser/diagnostics',
|
||||
jsonBody({
|
||||
project_path: input.projectPath,
|
||||
project_id: input.projectId,
|
||||
enabled: input.enabled,
|
||||
}),
|
||||
);
|
||||
@@ -137,14 +137,14 @@ export async function setAgentBrowserDiagnostics(input: {
|
||||
}
|
||||
|
||||
export async function navigateAgentBrowser(input: {
|
||||
projectPath: string;
|
||||
projectId: string;
|
||||
action: AgentBrowserNavigateAction;
|
||||
url?: string;
|
||||
}): Promise<AgentBrowserSnapshot> {
|
||||
const response = await hostApiFetch<BrowserEnvelope>(
|
||||
'/api/agent-browser/navigate',
|
||||
jsonBody({
|
||||
project_path: input.projectPath,
|
||||
project_id: input.projectId,
|
||||
action: input.action,
|
||||
...(input.url ? { url: input.url } : {}),
|
||||
}),
|
||||
@@ -153,7 +153,7 @@ export async function navigateAgentBrowser(input: {
|
||||
}
|
||||
|
||||
export async function sendAgentBrowserCdp(input: {
|
||||
projectPath: string;
|
||||
projectId: string;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
sessionRef?: string;
|
||||
@@ -162,7 +162,7 @@ export async function sendAgentBrowserCdp(input: {
|
||||
const response = await hostApiFetch<CdpResultEnvelope>(
|
||||
'/api/agent-browser/cdp/send',
|
||||
jsonBody({
|
||||
project_path: input.projectPath,
|
||||
project_id: input.projectId,
|
||||
method: input.method,
|
||||
...(input.params ? { params: input.params } : {}),
|
||||
...(input.sessionRef ? { session_ref: input.sessionRef } : {}),
|
||||
@@ -173,7 +173,7 @@ export async function sendAgentBrowserCdp(input: {
|
||||
}
|
||||
|
||||
export async function readAgentBrowserEvents(input: {
|
||||
projectPath: string;
|
||||
projectId: string;
|
||||
after?: number;
|
||||
methods?: string[];
|
||||
limit?: number;
|
||||
@@ -182,7 +182,7 @@ export async function readAgentBrowserEvents(input: {
|
||||
const response = await hostApiFetch<EventPageEnvelope>(
|
||||
'/api/agent-browser/cdp/events',
|
||||
jsonBody({
|
||||
project_path: input.projectPath,
|
||||
project_id: input.projectId,
|
||||
...(input.after !== undefined ? { after: input.after } : {}),
|
||||
...(input.methods ? { methods: input.methods } : {}),
|
||||
...(input.limit !== undefined ? { limit: input.limit } : {}),
|
||||
@@ -193,7 +193,7 @@ export async function readAgentBrowserEvents(input: {
|
||||
}
|
||||
|
||||
export async function readAgentBrowserPayload(input: {
|
||||
projectPath: string;
|
||||
projectId: string;
|
||||
handle: string;
|
||||
offset?: number;
|
||||
maxBytes?: number;
|
||||
@@ -201,7 +201,7 @@ export async function readAgentBrowserPayload(input: {
|
||||
const response = await hostApiFetch<PayloadEnvelope>(
|
||||
'/api/agent-browser/payload/read',
|
||||
jsonBody({
|
||||
project_path: input.projectPath,
|
||||
project_id: input.projectId,
|
||||
handle: input.handle,
|
||||
...(input.offset !== undefined ? { offset: input.offset } : {}),
|
||||
...(input.maxBytes !== undefined ? { max_bytes: input.maxBytes } : {}),
|
||||
@@ -211,21 +211,21 @@ export async function readAgentBrowserPayload(input: {
|
||||
}
|
||||
|
||||
export async function closeAgentBrowser(
|
||||
projectPath: string,
|
||||
projectId: string,
|
||||
): Promise<AgentBrowserSnapshot> {
|
||||
const response = await hostApiFetch<BrowserEnvelope>(
|
||||
'/api/agent-browser/close',
|
||||
jsonBody({ project_path: projectPath }),
|
||||
jsonBody({ project_id: projectId }),
|
||||
);
|
||||
return requireSuccess(response).browser;
|
||||
}
|
||||
|
||||
export async function resetAgentBrowserProfile(
|
||||
projectPath: string,
|
||||
projectId: string,
|
||||
): Promise<AgentBrowserSnapshot> {
|
||||
const response = await hostApiFetch<BrowserEnvelope>(
|
||||
'/api/agent-browser/reset-profile',
|
||||
jsonBody({ project_path: projectPath }),
|
||||
jsonBody({ project_id: projectId }),
|
||||
);
|
||||
return requireSuccess(response).browser;
|
||||
}
|
||||
|
||||
553
src/pages/Chat/AgentBrowserPanel.tsx
Normal file
553
src/pages/Chat/AgentBrowserPanel.tsx
Normal file
@@ -0,0 +1,553 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Globe2,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
closeAgentBrowser,
|
||||
deriveAgentBrowserDiagnostics,
|
||||
getAgentBrowserState,
|
||||
navigateAgentBrowser,
|
||||
openAgentBrowser,
|
||||
presentAgentBrowser,
|
||||
readAgentBrowserEvents,
|
||||
setAgentBrowserDiagnostics,
|
||||
} from '@/lib/agent-browser';
|
||||
import { subscribeHostEvent } from '@/lib/host-events';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
AgentBrowserBounds,
|
||||
AgentBrowserCdpEvent,
|
||||
AgentBrowserSnapshot,
|
||||
} from '../../../shared/agent-browser';
|
||||
|
||||
type DiagnosticsTab = 'console' | 'network';
|
||||
|
||||
export interface AgentBrowserPanelProps {
|
||||
projectId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
}
|
||||
|
||||
const EVENT_METHODS = [
|
||||
'Runtime.consoleAPICalled',
|
||||
'Runtime.exceptionThrown',
|
||||
'Log.entryAdded',
|
||||
'Network.requestWillBeSent',
|
||||
'Network.responseReceived',
|
||||
'Network.loadingFinished',
|
||||
'Network.loadingFailed',
|
||||
];
|
||||
const MAX_RENDERED_EVENTS = 500;
|
||||
const EVENT_WAIT_MS = 5_000;
|
||||
const EVENT_DRAIN_DELAY_MS = 100;
|
||||
const EVENT_EMPTY_DELAY_MS = 250;
|
||||
|
||||
function normalizeAddress(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
return /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed)
|
||||
? trimmed
|
||||
: `http://${trimmed}`;
|
||||
}
|
||||
|
||||
function readBounds(element: HTMLElement | null): AgentBrowserBounds | undefined {
|
||||
if (!element) return undefined;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const bounds = {
|
||||
x: Math.round(rect.left),
|
||||
y: Math.round(rect.top),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
};
|
||||
return bounds.width > 0 && bounds.height > 0 ? bounds : undefined;
|
||||
}
|
||||
|
||||
function browserIsActive(snapshot: AgentBrowserSnapshot | null): boolean {
|
||||
return Boolean(
|
||||
snapshot?.browserId
|
||||
&& snapshot.state !== 'closed'
|
||||
&& snapshot.state !== 'closing',
|
||||
);
|
||||
}
|
||||
|
||||
function modalOccludesBrowser(): boolean {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="dialog"], [role="alertdialog"]'),
|
||||
).some((element) => {
|
||||
if (element.hidden || element.getAttribute('data-state') === 'closed') return false;
|
||||
const style = window.getComputedStyle(element);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden';
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
return Number.isNaN(date.getTime())
|
||||
? '--:--:--'
|
||||
: date.toLocaleTimeString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function consoleTone(level: string): string {
|
||||
if (level === 'error' || level === 'assert') return 'text-destructive';
|
||||
if (level === 'warning' || level === 'warn') return 'text-amber-700';
|
||||
return 'text-foreground';
|
||||
}
|
||||
|
||||
function statusTone(status?: number): string {
|
||||
if (status === undefined) return 'text-muted-foreground';
|
||||
if (status >= 500) return 'text-destructive';
|
||||
if (status >= 400) return 'text-amber-700';
|
||||
return 'text-emerald-700';
|
||||
}
|
||||
|
||||
export function AgentBrowserPanel({
|
||||
projectId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AgentBrowserPanelProps) {
|
||||
const [snapshot, setSnapshot] = useState<AgentBrowserSnapshot | null>(null);
|
||||
const [address, setAddress] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [occluded, setOccluded] = useState(false);
|
||||
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<DiagnosticsTab>('console');
|
||||
const [events, setEvents] = useState<AgentBrowserCdpEvent[]>([]);
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const addressInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const eventCursorRef = useRef(0);
|
||||
const wasOpenRef = useRef(open);
|
||||
const browserActive = browserIsActive(snapshot);
|
||||
const diagnostics = useMemo(() => deriveAgentBrowserDiagnostics(events), [events]);
|
||||
|
||||
const applySnapshot = useCallback((next: AgentBrowserSnapshot) => {
|
||||
setSnapshot(next);
|
||||
if (next.url && document.activeElement !== addressInputRef.current) {
|
||||
setAddress(next.url);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSnapshot(null);
|
||||
setAddress('');
|
||||
setError(null);
|
||||
setDiagnosticsOpen(false);
|
||||
setEvents([]);
|
||||
eventCursorRef.current = 0;
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return undefined;
|
||||
const unsubscribeShow = subscribeHostEvent<AgentBrowserSnapshot>(
|
||||
'agent-browser:show',
|
||||
(next) => {
|
||||
if (next.projectId !== projectId) return;
|
||||
applySnapshot(next);
|
||||
onOpenChange(true);
|
||||
},
|
||||
);
|
||||
const unsubscribeState = subscribeHostEvent<AgentBrowserSnapshot>(
|
||||
'agent-browser:state',
|
||||
(next) => {
|
||||
if (next.projectId && next.projectId !== projectId) return;
|
||||
applySnapshot(next);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
unsubscribeShow();
|
||||
unsubscribeState();
|
||||
};
|
||||
}, [applySnapshot, onOpenChange, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !projectId) return undefined;
|
||||
let cancelled = false;
|
||||
void getAgentBrowserState(projectId)
|
||||
.then((next) => {
|
||||
if (!cancelled) applySnapshot(next);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSnapshot(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applySnapshot, open, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !projectId) return undefined;
|
||||
let cancelled = false;
|
||||
const syncAfterResume = () => {
|
||||
if (document.visibilityState === 'hidden') return;
|
||||
void getAgentBrowserState(projectId)
|
||||
.then((next) => {
|
||||
if (!cancelled) applySnapshot(next);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSnapshot(null);
|
||||
});
|
||||
};
|
||||
window.addEventListener('focus', syncAfterResume);
|
||||
document.addEventListener('visibilitychange', syncAfterResume);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener('focus', syncAfterResume);
|
||||
document.removeEventListener('visibilitychange', syncAfterResume);
|
||||
};
|
||||
}, [applySnapshot, open, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const wasOpen = wasOpenRef.current;
|
||||
wasOpenRef.current = open;
|
||||
if (!wasOpen || open || !projectId) return;
|
||||
setSnapshot(null);
|
||||
setDiagnosticsOpen(false);
|
||||
setEvents([]);
|
||||
eventCursorRef.current = 0;
|
||||
void closeAgentBrowser(projectId).catch(() => undefined);
|
||||
}, [open, projectId]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (projectId) void closeAgentBrowser(projectId).catch(() => undefined);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
eventCursorRef.current = 0;
|
||||
setEvents([]);
|
||||
}, [snapshot?.browserId, snapshot?.generation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setOccluded(false);
|
||||
return undefined;
|
||||
}
|
||||
const update = () => setOccluded(modalOccludesBrowser());
|
||||
update();
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-state', 'hidden', 'style'],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !browserActive) return undefined;
|
||||
if (!open || occluded) {
|
||||
void presentAgentBrowser({ projectId, visible: false }).catch(() => undefined);
|
||||
return undefined;
|
||||
}
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return undefined;
|
||||
let cancelled = false;
|
||||
let timer: number | null = null;
|
||||
let lastBounds = '';
|
||||
const schedule = () => {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
timer = null;
|
||||
const bounds = readBounds(viewport);
|
||||
if (!bounds) return;
|
||||
const key = `${bounds.x}:${bounds.y}:${bounds.width}:${bounds.height}`;
|
||||
if (key === lastBounds) return;
|
||||
lastBounds = key;
|
||||
void presentAgentBrowser({ projectId, visible: true, bounds })
|
||||
.then((next) => {
|
||||
if (!cancelled) applySnapshot(next);
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (!cancelled) {
|
||||
setError(cause instanceof Error ? cause.message : '无法显示开发浏览器');
|
||||
}
|
||||
});
|
||||
}, 50);
|
||||
};
|
||||
const observer = typeof ResizeObserver === 'undefined'
|
||||
? null
|
||||
: new ResizeObserver(schedule);
|
||||
observer?.observe(viewport);
|
||||
window.addEventListener('resize', schedule);
|
||||
schedule();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
observer?.disconnect();
|
||||
window.removeEventListener('resize', schedule);
|
||||
void presentAgentBrowser({ projectId, visible: false }).catch(() => undefined);
|
||||
};
|
||||
}, [applySnapshot, browserActive, occluded, open, projectId, snapshot?.generation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !diagnosticsOpen || !projectId || snapshot?.state !== 'attached') {
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
void setAgentBrowserDiagnostics({ projectId, enabled: true })
|
||||
.then((next) => {
|
||||
if (!cancelled) applySnapshot(next);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void setAgentBrowserDiagnostics({ projectId, enabled: false }).catch(() => undefined);
|
||||
};
|
||||
}, [applySnapshot, diagnosticsOpen, open, projectId, snapshot?.browserId, snapshot?.generation, snapshot?.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !diagnosticsOpen || !projectId || snapshot?.state !== 'attached') {
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
let inFlight = false;
|
||||
let timer: number | null = null;
|
||||
const poll = async () => {
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
let retryDelay = EVENT_EMPTY_DELAY_MS;
|
||||
try {
|
||||
const page = await readAgentBrowserEvents({
|
||||
projectId,
|
||||
after: eventCursorRef.current,
|
||||
methods: EVENT_METHODS,
|
||||
limit: 200,
|
||||
waitMs: EVENT_WAIT_MS,
|
||||
});
|
||||
if (cancelled) return;
|
||||
eventCursorRef.current = page.nextCursor;
|
||||
setEvents((current) => {
|
||||
const base = page.gap ? [] : current;
|
||||
return [...base, ...page.events].slice(-MAX_RENDERED_EVENTS);
|
||||
});
|
||||
retryDelay = page.events.length > 0 || page.hasMore
|
||||
? EVENT_DRAIN_DELAY_MS
|
||||
: EVENT_EMPTY_DELAY_MS;
|
||||
} catch {
|
||||
// The state event or the next explicit open reports actionable failures.
|
||||
} finally {
|
||||
inFlight = false;
|
||||
if (!cancelled) {
|
||||
timer = window.setTimeout(() => {
|
||||
timer = null;
|
||||
void poll();
|
||||
}, retryDelay);
|
||||
}
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, [diagnosticsOpen, open, projectId, snapshot?.generation, snapshot?.state]);
|
||||
|
||||
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 (!projectId) return;
|
||||
const url = normalizeAddress(address);
|
||||
if (!url) return;
|
||||
setAddress(url);
|
||||
void runBrowserAction(() => browserActive
|
||||
? navigateAgentBrowser({ projectId, action: 'url', url })
|
||||
: openAgentBrowser({ projectId, url, bounds: readBounds(viewportRef.current) }));
|
||||
}, [address, browserActive, projectId, runBrowserAction]);
|
||||
|
||||
const handleNavigate = useCallback((action: 'back' | 'forward' | 'reload') => {
|
||||
if (!projectId || !browserActive) return;
|
||||
void runBrowserAction(() => navigateAgentBrowser({ projectId, action }));
|
||||
}, [browserActive, projectId, runBrowserAction]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="relative flex w-[400px] min-w-[300px] max-w-[48vw] shrink-0 flex-col overflow-hidden border-l border-border/80 bg-background shadow-[-16px_0_32px_hsl(220_20%_16%_/_0.06)] max-[900px]:fixed max-[900px]:inset-y-10 max-[900px]:right-0 max-[900px]:z-40 max-[900px]:max-w-[min(92vw,420px)]"
|
||||
data-testid="agent-browser-panel"
|
||||
>
|
||||
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-border/70 px-2.5">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-brand-soft text-brand">
|
||||
<Globe2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
<h2 className="min-w-0 flex-1 truncate text-xs font-semibold">开发浏览器</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md text-muted-foreground"
|
||||
aria-label="关闭开发浏览器"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form className="flex h-10 shrink-0 items-center gap-1 border-b border-border/70 px-2" onSubmit={handleOpenAddress}>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 rounded-md" aria-label="后退" disabled={!snapshot?.canGoBack || busy} onClick={() => handleNavigate('back')}>
|
||||
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 rounded-md" aria-label="前进" disabled={!snapshot?.canGoForward || busy} onClick={() => handleNavigate('forward')}>
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 rounded-md" aria-label="刷新网页" disabled={!browserActive || busy} onClick={() => handleNavigate('reload')}>
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', busy && 'animate-spin')} aria-hidden="true" />
|
||||
</Button>
|
||||
<input
|
||||
ref={addressInputRef}
|
||||
value={address}
|
||||
onChange={(event) => setAddress(event.target.value)}
|
||||
aria-label="网页地址"
|
||||
className="h-7 min-w-0 flex-1 rounded-lg border border-border/90 bg-surface-input px-2.5 text-[11px] font-medium outline-none focus:border-brand/40 focus:ring-2 focus:ring-brand/15"
|
||||
placeholder="输入 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 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 shadow-soft">
|
||||
<Globe2 className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<p className="text-[13px] font-semibold text-foreground">开始浏览</p>
|
||||
<p className="text-[11px]">输入 URL,或等待智能体打开调试页面</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="flex shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background"
|
||||
style={{ height: diagnosticsOpen ? '240px' : '42px' }}
|
||||
data-state={diagnosticsOpen ? 'open' : 'closed'}
|
||||
data-testid="agent-browser-diagnostics"
|
||||
>
|
||||
<div className="flex h-10 shrink-0 items-center justify-between border-b border-border/70 bg-surface-input px-1.5">
|
||||
<div className="flex min-w-0 items-center">
|
||||
{(['console', 'network'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
className={cn(
|
||||
'h-10 border-b-2 px-2.5 text-[11px] font-medium',
|
||||
activeTab === tab
|
||||
? 'border-brand text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={() => {
|
||||
setActiveTab(tab);
|
||||
setDiagnosticsOpen(true);
|
||||
}}
|
||||
>
|
||||
{tab === 'console'
|
||||
? `Console ${diagnostics.console.length}`
|
||||
: `Network ${diagnostics.network.length}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md text-muted-foreground"
|
||||
aria-label={diagnosticsOpen ? '收起调试面板' : '展开调试面板'}
|
||||
onClick={() => setDiagnosticsOpen((current) => !current)}
|
||||
>
|
||||
{diagnosticsOpen
|
||||
? <ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
: <ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{diagnosticsOpen ? (
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{activeTab === 'console' ? (
|
||||
<div role="log" aria-label="Console 日志" className="h-full overflow-auto font-mono text-[10px] tabular-nums">
|
||||
{diagnostics.console.length === 0 ? (
|
||||
<p className="p-3 font-sans text-muted-foreground">暂无 Console 记录</p>
|
||||
) : diagnostics.console.map((entry) => (
|
||||
<div key={entry.id} className={cn('flex gap-2 border-b border-border/60 px-2 py-1.5 leading-4', consoleTone(entry.level))}>
|
||||
<span className="shrink-0 text-muted-foreground">{formatTime(entry.timestamp)}</span>
|
||||
<span className="shrink-0 font-semibold uppercase">{entry.level}</span>
|
||||
<span className="min-w-0 whitespace-pre-wrap break-all">{entry.text || '(empty)'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-auto" aria-label="Network 请求">
|
||||
{diagnostics.network.length === 0 ? (
|
||||
<p className="p-3 text-[10px] text-muted-foreground">暂无 Network 请求</p>
|
||||
) : (
|
||||
<table className="w-full table-fixed text-left font-mono text-[10px] tabular-nums">
|
||||
<thead className="sticky top-0 bg-surface-input">
|
||||
<tr className="border-b border-border/70">
|
||||
<th className="w-12 px-2 py-1.5 font-semibold">方法</th>
|
||||
<th className="w-12 px-1 py-1.5 font-semibold">状态</th>
|
||||
<th className="px-1 py-1.5 font-semibold">地址</th>
|
||||
<th className="w-14 px-2 py-1.5 text-right font-semibold">耗时</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{diagnostics.network.map((entry) => (
|
||||
<tr key={`${entry.requestId}:${entry.sequence}`} className="border-b border-border/60">
|
||||
<td className="truncate px-2 py-1.5 font-semibold">{entry.method}</td>
|
||||
<td className={cn('truncate px-1 py-1.5 font-semibold', statusTone(entry.status))}>
|
||||
{entry.errorText ? '失败' : entry.status ?? '…'}
|
||||
</td>
|
||||
<td className="truncate px-1 py-1.5" title={entry.url}>{entry.url}</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{entry.durationMs === undefined ? '…' : `${Math.round(entry.durationMs)}ms`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
CodingConversationMetadata,
|
||||
} from '@/types/coding-project';
|
||||
import { CodingComposer } from './CodingComposer';
|
||||
import { AgentBrowserPanel } from './AgentBrowserPanel';
|
||||
import { CodingChangesSummary } from './CodingChangesSummary';
|
||||
import { CodingConversationSidebar } from './CodingConversationSidebar';
|
||||
import { CodingConversationHeader } from './CodingConversationHeader';
|
||||
@@ -147,6 +148,7 @@ export function CodingChatPanel({
|
||||
const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState<
|
||||
Record<string, LocalComposerAttachment[]>
|
||||
>({});
|
||||
const [agentBrowserOpen, setAgentBrowserOpen] = useState(false);
|
||||
const appliedNavigationDraftRef = useRef<string | null>(null);
|
||||
const automaticCreationKeyRef = useRef<string | null>(null);
|
||||
const selectedConversationContextRef = useRef<string | null>(null);
|
||||
@@ -241,6 +243,10 @@ export function CodingChatPanel({
|
||||
void loadWorkspace().catch(() => undefined);
|
||||
}, [loadWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
setAgentBrowserOpen(false);
|
||||
}, [activeProject?.id]);
|
||||
|
||||
useEffect(() => () => disconnectEvents(), [disconnectEvents]);
|
||||
|
||||
useEffect(() => subscribeHostEvent('lifecycle:sleep', () => {
|
||||
@@ -642,6 +648,9 @@ export function CodingChatPanel({
|
||||
onRecover={async () => {
|
||||
if (targetConversationId) await recoverConversation(targetConversationId);
|
||||
}}
|
||||
browserOpen={agentBrowserOpen}
|
||||
browserAvailable={Boolean(activeProject)}
|
||||
onToggleBrowser={() => setAgentBrowserOpen((current) => !current)}
|
||||
/>
|
||||
|
||||
{(workspaceError || conversationMetadataError || connectionError) && (
|
||||
@@ -761,6 +770,12 @@ export function CodingChatPanel({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AgentBrowserPanel
|
||||
projectId={activeProject?.id ?? null}
|
||||
open={agentBrowserOpen}
|
||||
onOpenChange={setAgentBrowserOpen}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { createPortal } from 'react-dom';
|
||||
import {
|
||||
LoaderCircle,
|
||||
Pencil,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
RotateCcw,
|
||||
Square,
|
||||
} from 'lucide-react';
|
||||
@@ -55,12 +57,18 @@ export function CodingConversationHeader({
|
||||
onRename,
|
||||
onAbort,
|
||||
onRecover,
|
||||
browserOpen = false,
|
||||
browserAvailable = false,
|
||||
onToggleBrowser,
|
||||
}: {
|
||||
conversation: CodingConversationMetadata | null;
|
||||
snapshot: ConversationSnapshot | null;
|
||||
onRename(title: string): Promise<void>;
|
||||
onAbort(): Promise<void>;
|
||||
onRecover(): Promise<void>;
|
||||
browserOpen?: boolean;
|
||||
browserAvailable?: boolean;
|
||||
onToggleBrowser?(): void;
|
||||
}) {
|
||||
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||
@@ -121,6 +129,23 @@ export function CodingConversationHeader({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{onToggleBrowser ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={iconButtonClass}
|
||||
disabled={!browserAvailable}
|
||||
aria-label={browserOpen ? '关闭开发浏览器' : '打开开发浏览器'}
|
||||
aria-pressed={browserOpen}
|
||||
title={browserOpen ? '关闭开发浏览器' : '打开开发浏览器'}
|
||||
onClick={onToggleBrowser}
|
||||
>
|
||||
{browserOpen
|
||||
? <PanelRightClose className="h-4 w-4" aria-hidden="true" />
|
||||
: <PanelRightOpen className="h-4 w-4" aria-hidden="true" />}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{running && runStatus !== 'aborting' && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user