fix(agent-browser): restore bounded presentation lifecycle

This commit is contained in:
2026-09-06 12:50:18 +08:00
parent 5c61110f46
commit 9fed0cc7c4
20 changed files with 1714 additions and 49 deletions

View File

@@ -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;
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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"