feat: remove legacy OpenCode runtime
Cut product flows over to Coding/Pi and retain only the migration-owned v1 boundary. Promote supported native optional packages because electron-builder omitted pnpm transitive optional closure from the packaged ASAR.
This commit is contained in:
1 parent
61fede2b4d
commit
5a275b93a7
199 files changed
+1330
-64725
No files matched your search
@@ -1,1000 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Globe2,
|
||||
GripHorizontal,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DISCLOSURE_EXIT_MS } from '@/components/ui/disclosure';
|
||||
import {
|
||||
deriveAgentBrowserDiagnostics,
|
||||
closeAgentBrowser,
|
||||
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;
|
||||
projectPath: string | null;
|
||||
}
|
||||
|
||||
const MIN_LAYOUT_COLUMN_WIDTH = 256;
|
||||
const DEFAULT_PANEL_WIDTH = 320;
|
||||
const MIN_PANEL_WIDTH = MIN_LAYOUT_COLUMN_WIDTH;
|
||||
const MAX_PANEL_WIDTH_RATIO = 0.72;
|
||||
const COLLAPSE_PANEL_WIDTH = MIN_LAYOUT_COLUMN_WIDTH;
|
||||
const CHAT_CANVAS_MIN_WIDTH = MIN_LAYOUT_COLUMN_WIDTH;
|
||||
const DIAGNOSTICS_COLLAPSED_HEIGHT = 42;
|
||||
const DEFAULT_DIAGNOSTICS_HEIGHT = 260;
|
||||
const MIN_DIAGNOSTICS_HEIGHT = 168;
|
||||
const DIAGNOSTICS_COLLAPSE_THRESHOLD = 96;
|
||||
const DIAGNOSTICS_MAX_HEIGHT_RATIO = 0.68;
|
||||
const TITLEBAR_BROWSER_WIDTH_VARIABLE = '--agent-browser-panel-width';
|
||||
const TITLEBAR_BROWSER_LOGO_GAP_VARIABLE = '--agent-browser-titlebar-logo-gap';
|
||||
const TITLEBAR_BROWSER_TRANSITION_DURATION_VARIABLE = '--agent-browser-panel-transition-duration';
|
||||
const EVENT_METHODS = [
|
||||
'Runtime.consoleAPICalled',
|
||||
'Runtime.exceptionThrown',
|
||||
'Log.entryAdded',
|
||||
'Network.requestWillBeSent',
|
||||
'Network.responseReceived',
|
||||
'Network.loadingFinished',
|
||||
'Network.loadingFailed',
|
||||
];
|
||||
|
||||
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 readPanelMaxWidth(element: HTMLElement | null): number {
|
||||
const layout = element?.closest<HTMLElement>('[data-testid="opencode-chat-layout"]')
|
||||
?? document.querySelector<HTMLElement>('[data-testid="opencode-chat-layout"]')
|
||||
?? element?.parentElement;
|
||||
const layoutWidth = layout?.getBoundingClientRect().width ?? 0;
|
||||
const parentWidth = element?.parentElement?.getBoundingClientRect().width ?? 0;
|
||||
const availableWidth = layoutWidth || parentWidth || window.innerWidth;
|
||||
const widthWithMinimumColumns = availableWidth - (CHAT_CANVAS_MIN_WIDTH * 2);
|
||||
return Math.max(
|
||||
MIN_PANEL_WIDTH,
|
||||
Math.min(
|
||||
Math.floor(availableWidth * MAX_PANEL_WIDTH_RATIO),
|
||||
Math.floor(widthWithMinimumColumns),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function readEqualPanelWidth(element: HTMLElement | null): number {
|
||||
const layout = element?.closest<HTMLElement>('[data-testid="opencode-chat-layout"]')
|
||||
?? document.querySelector<HTMLElement>('[data-testid="opencode-chat-layout"]')
|
||||
?? element?.parentElement;
|
||||
const layoutWidth = layout?.getBoundingClientRect().width ?? 0;
|
||||
const parentWidth = element?.parentElement?.getBoundingClientRect().width ?? 0;
|
||||
const availableWidth = layoutWidth || parentWidth || window.innerWidth;
|
||||
return Math.min(
|
||||
readPanelMaxWidth(element),
|
||||
Math.max(MIN_PANEL_WIDTH, Math.floor(availableWidth / 3)),
|
||||
);
|
||||
}
|
||||
|
||||
function clampPanelWidth(width: number, element: HTMLElement | null): number {
|
||||
return Math.min(
|
||||
Math.max(width, MIN_PANEL_WIDTH),
|
||||
readPanelMaxWidth(element),
|
||||
);
|
||||
}
|
||||
|
||||
function readDiagnosticsMaxHeight(element: HTMLElement | null): number {
|
||||
const panelHeight = element?.getBoundingClientRect().height || window.innerHeight;
|
||||
const availableHeight = Math.max(
|
||||
MIN_DIAGNOSTICS_HEIGHT,
|
||||
panelHeight - 180,
|
||||
);
|
||||
return Math.max(
|
||||
MIN_DIAGNOSTICS_HEIGHT,
|
||||
Math.min(
|
||||
Math.floor(panelHeight * DIAGNOSTICS_MAX_HEIGHT_RATIO),
|
||||
availableHeight,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function clampDiagnosticsHeight(height: number, element: HTMLElement | null): number {
|
||||
return Math.min(
|
||||
Math.max(height, MIN_DIAGNOSTICS_HEIGHT),
|
||||
readDiagnosticsMaxHeight(element),
|
||||
);
|
||||
}
|
||||
|
||||
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 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 [panelMounted, setPanelMounted] = useState(false);
|
||||
const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
||||
const [diagnosticsHeight, setDiagnosticsHeight] = useState(DEFAULT_DIAGNOSTICS_HEIGHT);
|
||||
const [isDiagnosticsResizing, setIsDiagnosticsResizing] = useState(false);
|
||||
const [snapshot, setSnapshot] = useState<AgentBrowserSnapshot | null>(null);
|
||||
const [address, setAddress] = useState('');
|
||||
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 panelRef = useRef<HTMLElement | HTMLDivElement | null>(null);
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const addressInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const eventCursorRef = useRef(0);
|
||||
const resizeRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
} | null>(null);
|
||||
const diagnosticsResizeRef = useRef<{
|
||||
pointerId: number;
|
||||
startY: number;
|
||||
startHeight: number;
|
||||
} | null>(null);
|
||||
const panelCloseTimerRef = useRef<number | null>(null);
|
||||
|
||||
const browserActive = isBrowserActive(snapshot);
|
||||
const diagnostics = useMemo(
|
||||
() => deriveAgentBrowserDiagnostics(events),
|
||||
[events],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (isOpen) {
|
||||
root.style.setProperty(TITLEBAR_BROWSER_WIDTH_VARIABLE, `${panelWidth}px`);
|
||||
root.style.setProperty(TITLEBAR_BROWSER_LOGO_GAP_VARIABLE, '4px');
|
||||
} else {
|
||||
root.style.removeProperty(TITLEBAR_BROWSER_WIDTH_VARIABLE);
|
||||
root.style.removeProperty(TITLEBAR_BROWSER_LOGO_GAP_VARIABLE);
|
||||
}
|
||||
root.style.setProperty(
|
||||
TITLEBAR_BROWSER_TRANSITION_DURATION_VARIABLE,
|
||||
isResizing ? '0ms' : '180ms',
|
||||
);
|
||||
return () => {
|
||||
root.style.removeProperty(TITLEBAR_BROWSER_WIDTH_VARIABLE);
|
||||
root.style.removeProperty(TITLEBAR_BROWSER_LOGO_GAP_VARIABLE);
|
||||
root.style.removeProperty(TITLEBAR_BROWSER_TRANSITION_DURATION_VARIABLE);
|
||||
};
|
||||
}, [isOpen, isResizing, panelWidth]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (panelCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(panelCloseTimerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
const syncPanelWidthToLayout = () => {
|
||||
setPanelWidth((current) => Math.min(current, readPanelMaxWidth(panelRef.current)));
|
||||
setDiagnosticsHeight((current) => clampDiagnosticsHeight(current, panelRef.current));
|
||||
};
|
||||
window.addEventListener('resize', syncPanelWidthToLayout);
|
||||
return () => window.removeEventListener('resize', syncPanelWidthToLayout);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setOccluded(false);
|
||||
return undefined;
|
||||
}
|
||||
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();
|
||||
}, [isOpen]);
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
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);
|
||||
setPanelMounted(false);
|
||||
setPanelWidth(DEFAULT_PANEL_WIDTH);
|
||||
setDiagnosticsOpen(false);
|
||||
setDiagnosticsHeight(DEFAULT_DIAGNOSTICS_HEIGHT);
|
||||
diagnosticsResizeRef.current = null;
|
||||
setIsDiagnosticsResizing(false);
|
||||
if (panelCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(panelCloseTimerRef.current);
|
||||
panelCloseTimerRef.current = null;
|
||||
}
|
||||
if (!projectPath) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
void getAgentBrowserState(projectPath)
|
||||
.then((next) => {
|
||||
if (cancelled || !isBrowserActive(next)) return;
|
||||
applySnapshot(next);
|
||||
})
|
||||
.catch(() => {
|
||||
// A missing browser is the normal initial state.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applySnapshot, projectPath]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (projectPath) {
|
||||
void closeAgentBrowser(projectPath).catch(() => undefined);
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
eventCursorRef.current = 0;
|
||||
setEvents([]);
|
||||
}, [snapshot?.browserId, snapshot?.projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectPath || !browserActive) return undefined;
|
||||
void setAgentBrowserDiagnostics({ projectPath, enabled: diagnosticsOpen })
|
||||
.then((next) => applySnapshot(next))
|
||||
.catch(() => undefined);
|
||||
if (!diagnosticsOpen) {
|
||||
eventCursorRef.current = 0;
|
||||
setEvents([]);
|
||||
}
|
||||
return undefined;
|
||||
}, [applySnapshot, browserActive, diagnosticsOpen, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !diagnosticsOpen || !projectPath || snapshot?.state !== 'attached') {
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
let inFlight = false;
|
||||
let pollTimer: number | null = null;
|
||||
|
||||
const poll = async () => {
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
let retryDelay = 250;
|
||||
try {
|
||||
const page = await readAgentBrowserEvents({
|
||||
projectPath,
|
||||
after: eventCursorRef.current,
|
||||
methods: EVENT_METHODS,
|
||||
limit: 200,
|
||||
waitMs: 5_000,
|
||||
});
|
||||
if (cancelled) return;
|
||||
eventCursorRef.current = page.nextCursor;
|
||||
setEvents((current) => {
|
||||
const base = page.gap ? [] : current;
|
||||
return [...base, ...page.events].slice(-500);
|
||||
});
|
||||
// A real request is held by Main for up to waitMs. Test doubles and
|
||||
// transient failures can return immediately; back off those empty
|
||||
// responses so a fast server cannot turn the long-poll into a hot
|
||||
// microtask loop.
|
||||
retryDelay = page.events.length > 0 || page.hasMore ? 0 : 250;
|
||||
} catch {
|
||||
// State polling presents attachment failures without turning the panel noisy.
|
||||
} finally {
|
||||
inFlight = false;
|
||||
if (!cancelled) {
|
||||
pollTimer = window.setTimeout(() => {
|
||||
pollTimer = null;
|
||||
void poll();
|
||||
}, retryDelay);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (pollTimer !== null) window.clearTimeout(pollTimer);
|
||||
};
|
||||
}, [diagnosticsOpen, 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);
|
||||
if (!url) return;
|
||||
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 closePanel = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setSnapshot(null);
|
||||
if (projectPath) {
|
||||
void closeAgentBrowser(projectPath).catch(() => undefined);
|
||||
}
|
||||
setPanelWidth(DEFAULT_PANEL_WIDTH);
|
||||
setDiagnosticsOpen(false);
|
||||
setDiagnosticsHeight(DEFAULT_DIAGNOSTICS_HEIGHT);
|
||||
diagnosticsResizeRef.current = null;
|
||||
setIsDiagnosticsResizing(false);
|
||||
if (panelCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(panelCloseTimerRef.current);
|
||||
}
|
||||
panelCloseTimerRef.current = window.setTimeout(() => {
|
||||
setPanelMounted(false);
|
||||
panelCloseTimerRef.current = null;
|
||||
}, DISCLOSURE_EXIT_MS);
|
||||
}, [projectPath]);
|
||||
|
||||
const handleResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
resizeRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startWidth: panelRef.current?.getBoundingClientRect().width || panelWidth,
|
||||
};
|
||||
setIsResizing(true);
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
}, [panelWidth]);
|
||||
|
||||
const handleResizePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const resize = resizeRef.current;
|
||||
if (!resize || resize.pointerId !== event.pointerId) return;
|
||||
const nextWidth = resize.startWidth + resize.startX - event.clientX;
|
||||
if (nextWidth < COLLAPSE_PANEL_WIDTH) {
|
||||
resizeRef.current = null;
|
||||
setIsResizing(false);
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
setPanelWidth(clampPanelWidth(nextWidth, panelRef.current));
|
||||
}, [closePanel]);
|
||||
|
||||
const finishResize = useCallback((event?: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const resize = resizeRef.current;
|
||||
if (!resize || (event && resize.pointerId !== event.pointerId)) return;
|
||||
resizeRef.current = null;
|
||||
setIsResizing(false);
|
||||
if (event?.currentTarget.hasPointerCapture?.(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDiagnosticsResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0 || !diagnosticsOpen) return;
|
||||
event.preventDefault();
|
||||
diagnosticsResizeRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startY: event.clientY,
|
||||
startHeight: diagnosticsHeight,
|
||||
};
|
||||
setIsDiagnosticsResizing(true);
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
}, [diagnosticsHeight, diagnosticsOpen]);
|
||||
|
||||
const handleDiagnosticsResizePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const resize = diagnosticsResizeRef.current;
|
||||
if (!resize || resize.pointerId !== event.pointerId) return;
|
||||
const nextHeight = resize.startHeight + resize.startY - event.clientY;
|
||||
if (nextHeight < DIAGNOSTICS_COLLAPSE_THRESHOLD) {
|
||||
diagnosticsResizeRef.current = null;
|
||||
setIsDiagnosticsResizing(false);
|
||||
setDiagnosticsOpen(false);
|
||||
return;
|
||||
}
|
||||
setDiagnosticsHeight(clampDiagnosticsHeight(nextHeight, panelRef.current));
|
||||
}, []);
|
||||
|
||||
const finishDiagnosticsResize = useCallback((event?: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const resize = diagnosticsResizeRef.current;
|
||||
if (!resize || (event && resize.pointerId !== event.pointerId)) return;
|
||||
diagnosticsResizeRef.current = null;
|
||||
setIsDiagnosticsResizing(false);
|
||||
if (event?.currentTarget.hasPointerCapture?.(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDiagnosticsResizeKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
|
||||
event.preventDefault();
|
||||
const delta = event.key === 'ArrowUp' ? 24 : -24;
|
||||
const nextHeight = diagnosticsHeight + delta;
|
||||
if (nextHeight < DIAGNOSTICS_COLLAPSE_THRESHOLD) {
|
||||
setDiagnosticsOpen(false);
|
||||
return;
|
||||
}
|
||||
setDiagnosticsHeight(clampDiagnosticsHeight(nextHeight, panelRef.current));
|
||||
}, [diagnosticsHeight]);
|
||||
|
||||
const handleResizeKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
|
||||
event.preventDefault();
|
||||
const delta = event.key === 'ArrowLeft' ? 32 : -32;
|
||||
const nextWidth = panelWidth + delta;
|
||||
if (nextWidth < COLLAPSE_PANEL_WIDTH) {
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
setPanelWidth(clampPanelWidth(nextWidth, panelRef.current));
|
||||
}, [closePanel, panelWidth]);
|
||||
|
||||
const openPanel = useCallback(() => {
|
||||
if (panelCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(panelCloseTimerRef.current);
|
||||
panelCloseTimerRef.current = null;
|
||||
}
|
||||
setPanelWidth(readEqualPanelWidth(panelRef.current));
|
||||
setPanelMounted(true);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handlePanelToggle = useCallback(() => {
|
||||
if (isOpen) {
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
openPanel();
|
||||
}, [closePanel, isOpen, openPanel]);
|
||||
|
||||
const handleDiagnosticsTab = useCallback((tab: DiagnosticsTab) => {
|
||||
if (activeTab === tab) {
|
||||
setDiagnosticsOpen((current) => !current);
|
||||
return;
|
||||
}
|
||||
setActiveTab(tab);
|
||||
setDiagnosticsOpen(true);
|
||||
}, [activeTab]);
|
||||
|
||||
const panelToggle = (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'no-drag relative flex h-10 w-10 items-center justify-center border-0 bg-transparent p-0 text-muted-foreground outline-none transition-colors duration-100 hover:bg-transparent hover:text-foreground focus-visible:ring-2 focus-visible:ring-brand/30',
|
||||
isOpen || browserActive
|
||||
? 'text-brand hover:text-brand'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
aria-label={isOpen ? '收起开发浏览器' : '打开开发浏览器'}
|
||||
aria-pressed={isOpen}
|
||||
title={isOpen
|
||||
? '收起开发浏览器'
|
||||
: browserActive
|
||||
? '开发浏览器已收起,AI 调试已暂停'
|
||||
: '打开开发浏览器'}
|
||||
data-testid="agent-browser-panel-open"
|
||||
disabled={!projectPath}
|
||||
onClick={handlePanelToggle}
|
||||
>
|
||||
{isOpen ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
|
||||
{browserActive ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute right-2 top-2 h-1.5 w-1.5 rounded-full bg-brand ring-2 ring-background"
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
const titlebarPanelToggle = createPortal(
|
||||
<div
|
||||
data-testid="agent-browser-titlebar-toggle"
|
||||
className="no-drag pointer-events-auto fixed right-0 top-0 z-[100] flex h-10 w-10 items-center justify-center"
|
||||
style={{ right: '0px' }}
|
||||
>
|
||||
{panelToggle}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{titlebarPanelToggle}
|
||||
{panelMounted ? (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="shrink-0 overflow-hidden"
|
||||
data-testid="agent-browser-panel-spacer"
|
||||
style={{
|
||||
width: isOpen ? `${panelWidth}px` : '0px',
|
||||
minWidth: isOpen ? `${MIN_LAYOUT_COLUMN_WIDTH}px` : '0px',
|
||||
maxWidth: `${MAX_PANEL_WIDTH_RATIO * 100}%`,
|
||||
}}
|
||||
/>
|
||||
{createPortal(
|
||||
<aside
|
||||
ref={panelRef}
|
||||
className={cn(
|
||||
'codex-browser-drawer fixed inset-y-0 right-0 z-40 flex min-h-0 min-w-[256px] flex-col overflow-hidden border-l border-border/80 bg-background text-foreground shadow-[-16px_0_32px_hsl(220_20%_16%_/_0.06)]',
|
||||
isResizing && 'select-none transition-none',
|
||||
)}
|
||||
style={{
|
||||
width: `${panelWidth}px`,
|
||||
maxWidth: `${MAX_PANEL_WIDTH_RATIO * 100}vw`,
|
||||
}}
|
||||
data-state={isOpen ? 'open' : 'closed'}
|
||||
data-testid="agent-browser-panel"
|
||||
>
|
||||
<div
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-label="调整开发浏览器宽度"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={MIN_PANEL_WIDTH}
|
||||
aria-valuemax={readPanelMaxWidth(panelRef.current)}
|
||||
aria-valuenow={Math.round(panelWidth)}
|
||||
className={cn(
|
||||
'group absolute inset-y-0 left-0 z-20 w-2 -translate-x-1/2 cursor-col-resize touch-none outline-none',
|
||||
isResizing && 'bg-brand/10',
|
||||
)}
|
||||
data-testid="agent-browser-panel-resize-handle"
|
||||
onKeyDown={handleResizeKeyDown}
|
||||
onPointerCancel={finishResize}
|
||||
onPointerDown={handleResizePointerDown}
|
||||
onPointerMove={handleResizePointerMove}
|
||||
onPointerUp={finishResize}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border/70 transition-colors duration-100 group-hover:bg-brand/70',
|
||||
isResizing && 'w-0.5 bg-brand',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-10 shrink-0 items-center gap-2.5 bg-background px-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
|
||||
<Globe2 className="h-3 w-3" aria-hidden="true" />
|
||||
</span>
|
||||
<h2 className="truncate text-xs font-semibold tracking-[-0.01em]">开发浏览器</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex h-9 shrink-0 items-center gap-1 bg-background px-2 py-1"
|
||||
onSubmit={handleOpenAddress}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground"
|
||||
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-7 w-7 rounded-md text-muted-foreground"
|
||||
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-7 w-7 rounded-md text-muted-foreground"
|
||||
aria-label="刷新网页"
|
||||
disabled={!browserActive || busy}
|
||||
onClick={() => handleNavigate('reload')}
|
||||
>
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', busy && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<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 text-foreground outline-none transition-colors duration-100 placeholder:text-muted-foreground 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 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-xl border border-border/70 bg-background text-muted-foreground shadow-soft">
|
||||
<Globe2 className="h-5 w-5" />
|
||||
</span>
|
||||
<p className="text-balance text-[13px] font-semibold tracking-[-0.01em] text-foreground">开始浏览</p>
|
||||
<p className="text-pretty text-[11px] font-medium">输入 URL 以打开页面</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background',
|
||||
isDiagnosticsResizing && 'select-none transition-none',
|
||||
)}
|
||||
style={{
|
||||
height: `${diagnosticsOpen ? diagnosticsHeight : DIAGNOSTICS_COLLAPSED_HEIGHT}px`,
|
||||
}}
|
||||
data-state={diagnosticsOpen ? 'open' : 'closed'}
|
||||
data-testid="agent-browser-diagnostics"
|
||||
>
|
||||
{diagnosticsOpen ? (
|
||||
<div
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-label="调整调试面板高度"
|
||||
aria-orientation="horizontal"
|
||||
aria-valuemin={MIN_DIAGNOSTICS_HEIGHT}
|
||||
aria-valuemax={readDiagnosticsMaxHeight(panelRef.current)}
|
||||
aria-valuenow={Math.round(diagnosticsHeight)}
|
||||
className={cn(
|
||||
'group flex h-3 shrink-0 cursor-row-resize touch-none items-center justify-center outline-none',
|
||||
isDiagnosticsResizing && 'bg-brand/10',
|
||||
)}
|
||||
data-testid="agent-browser-diagnostics-resize-handle"
|
||||
onKeyDown={handleDiagnosticsResizeKeyDown}
|
||||
onPointerCancel={finishDiagnosticsResize}
|
||||
onPointerDown={handleDiagnosticsResizePointerDown}
|
||||
onPointerMove={handleDiagnosticsResizePointerMove}
|
||||
onPointerUp={finishDiagnosticsResize}
|
||||
>
|
||||
<GripHorizontal
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 text-border transition-colors duration-150 group-hover:text-brand',
|
||||
isDiagnosticsResizing && 'text-brand',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<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}
|
||||
aria-expanded={activeTab === tab && diagnosticsOpen}
|
||||
className={cn(
|
||||
'relative flex h-10 items-center gap-1.5 border-b-2 px-2.5 text-[11px] font-medium transition-colors duration-100',
|
||||
activeTab === tab
|
||||
? 'border-brand text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={() => handleDiagnosticsTab(tab)}
|
||||
>
|
||||
{tab === 'console'
|
||||
? `Console ${diagnostics.console.length}`
|
||||
: `Network ${diagnostics.network.length}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{diagnosticsOpen ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md text-muted-foreground hover:text-foreground"
|
||||
aria-label="收起调试面板"
|
||||
title="收起调试面板"
|
||||
onClick={() => setDiagnosticsOpen(false)}
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md text-muted-foreground hover:text-foreground"
|
||||
aria-label="展开调试面板"
|
||||
title="展开调试面板"
|
||||
onClick={() => setDiagnosticsOpen(true)}
|
||||
>
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{diagnosticsOpen ? (
|
||||
<div id="agent-browser-diagnostics-content" 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 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="h-full 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>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user