feat: integrate pixel teacher presence and resilient classroom preview

This commit is contained in:
鲨鱼辣椒 committed 2026-09-23 18:23:53 +08:00
1 parent 12d800ec51
commit 7951cca700
60 files changed
+3313 -180

No files matched your search

+107 -18
View File
@@ -28,6 +28,11 @@ import {
} from '@/lib/agent-browser';
import { subscribeHostEvent } from '@/lib/host-events';
import { cn } from '@/lib/utils';
import type { CodingConversationMetadata } from '@/types/coding-project';
import { useWorkPreview } from './use-work-preview';
import { usePreviewClearance } from './use-preview-clearance';
import { codingRecovery, type CodingRecoveryAction } from '../../../shared/coding-recovery';
import type { WorkPreviewResult } from '../../../shared/coding-work-preview';
import type {
AgentBrowserBounds,
AgentBrowserCdpEvent,
@@ -41,6 +46,12 @@ export interface AgentBrowserPanelProps {
open: boolean;
onOpenChange(open: boolean): void;
embedded?: boolean;
teacherBubble?: HTMLElement | null;
conversationId?: string | null;
onWorkConversation?(conversation: CodingConversationMetadata): void;
workRevision?: number;
onWorkResult?(result: WorkPreviewResult): void;
onRecovery?(action: CodingRecoveryAction, work: boolean): Promise<void> | void;
}
const EVENT_METHODS = [
@@ -121,6 +132,12 @@ export function AgentBrowserPanel({
open,
onOpenChange,
embedded = false,
teacherBubble,
conversationId,
onWorkConversation,
workRevision,
onWorkResult,
onRecovery,
}: AgentBrowserPanelProps) {
const [snapshot, setSnapshot] = useState<AgentBrowserSnapshot | null>(null);
const [address, setAddress] = useState('');
@@ -131,23 +148,64 @@ export function AgentBrowserPanel({
const [activeTab, setActiveTab] = useState<DiagnosticsTab>('console');
const [events, setEvents] = useState<AgentBrowserCdpEvent[]>([]);
const viewportRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLElement | null>(null);
usePreviewClearance(panelRef, viewportRef, teacherBubble, embedded && open);
const addressInputRef = useRef<HTMLInputElement | null>(null);
const eventCursorRef = useRef(0);
const snapshotGenerationRef = useRef(0);
const presentationRetries = useRef(0);
const [presentationRevision, setPresentationRevision] = useState(0);
const [presentationFailed, setPresentationFailed] = useState(false);
const wasOpenRef = useRef(open);
const browserActive = browserIsActive(snapshot);
const diagnostics = useMemo(() => deriveAgentBrowserDiagnostics(events), [events]);
const applySnapshot = useCallback((next: AgentBrowserSnapshot) => {
// A slow foreground state read must not replace a newly restored view.
if (next.generation < snapshotGenerationRef.current) return;
snapshotGenerationRef.current = next.generation;
setSnapshot(next);
if (browserIsActive(next)) setError(null);
if (next.url && document.activeElement !== addressInputRef.current) {
setAddress(next.url);
}
}, []);
const work = useWorkPreview({
projectId, conversationId, enabled: embedded && open, revision: workRevision,
onResult: (result) => {
if (result.browser) applySnapshot(result.browser);
if (result.conversation) onWorkConversation?.(result.conversation);
onWorkResult?.(result);
},
});
const workPending = embedded && (work.result?.status !== 'ready' || presentationFailed);
const recovery = codingRecovery(work.result?.errorCode, true);
const transportFailure = ['NETWORK', 'TIMEOUT'].includes(work.result?.errorCode ?? '');
const showBrowser = work.showBrowser;
const restoreWork = work.restore;
const retryPresentation = useCallback(() => {
presentationRetries.current = 0;
setPresentationFailed(false);
setError(null);
setPresentationRevision((value) => value + 1);
restoreWork();
}, [restoreWork]);
useEffect(() => {
if (embedded && open && snapshot && !browserActive && !presentationFailed && work.result?.status === 'ready') {
restoreWork();
}
}, [browserActive, embedded, open, presentationFailed, restoreWork, snapshot, work.result?.status]);
useEffect(() => {
snapshotGenerationRef.current = 0;
setSnapshot(null);
setAddress('');
setError(null);
presentationRetries.current = 0;
setPresentationFailed(false);
setDiagnosticsOpen(false);
setEvents([]);
eventCursorRef.current = 0;
@@ -258,7 +316,7 @@ export function AgentBrowserPanel({
useEffect(() => {
if (!projectId || !browserActive) return undefined;
if (!open || occluded) {
if (!open || occluded || workPending) {
void presentAgentBrowser({ projectId, visible: false }).catch(() => undefined);
return undefined;
}
@@ -278,11 +336,22 @@ export function AgentBrowserPanel({
lastBounds = key;
void presentAgentBrowser({ projectId, visible: true, bounds })
.then((next) => {
if (!cancelled) applySnapshot(next);
if (!cancelled) {
applySnapshot(next);
if (browserIsActive(next) && next.visible) presentationRetries.current = 0;
}
})
.catch((cause) => {
if (!cancelled) {
setError(cause instanceof Error ? cause.message : '无法显示开发浏览器');
if (embedded) {
if (presentationRetries.current++ < 2) {
setSnapshot(null);
setPresentationRevision((value) => value + 1);
restoreWork();
} else {
setPresentationFailed(true);
}
} else setError(cause instanceof Error ? cause.message : '无法显示开发浏览器');
}
});
}, 50);
@@ -300,10 +369,10 @@ export function AgentBrowserPanel({
window.removeEventListener('resize', schedule);
void presentAgentBrowser({ projectId, visible: false }).catch(() => undefined);
};
}, [applySnapshot, browserActive, occluded, open, projectId, snapshot?.generation]);
}, [applySnapshot, browserActive, embedded, occluded, open, presentationRevision, projectId, restoreWork, snapshot?.generation, workPending]);
useEffect(() => {
if (!open || !diagnosticsOpen || !projectId || snapshot?.state !== 'attached') {
if (embedded || !open || !diagnosticsOpen || !projectId || snapshot?.state !== 'attached') {
return undefined;
}
let cancelled = false;
@@ -316,10 +385,10 @@ export function AgentBrowserPanel({
cancelled = true;
void setAgentBrowserDiagnostics({ projectId, enabled: false }).catch(() => undefined);
};
}, [applySnapshot, diagnosticsOpen, open, projectId, snapshot?.browserId, snapshot?.generation, snapshot?.state]);
}, [applySnapshot, diagnosticsOpen, embedded, open, projectId, snapshot?.browserId, snapshot?.generation, snapshot?.state]);
useEffect(() => {
if (!open || !diagnosticsOpen || !projectId || snapshot?.state !== 'attached') {
if (embedded || !open || !diagnosticsOpen || !projectId || snapshot?.state !== 'attached') {
return undefined;
}
let cancelled = false;
@@ -363,7 +432,7 @@ export function AgentBrowserPanel({
cancelled = true;
if (timer !== null) window.clearTimeout(timer);
};
}, [diagnosticsOpen, open, projectId, snapshot?.generation, snapshot?.state]);
}, [diagnosticsOpen, embedded, open, projectId, snapshot?.generation, snapshot?.state]);
const runBrowserAction = useCallback(async (
action: () => Promise<AgentBrowserSnapshot>,
@@ -372,12 +441,19 @@ export function AgentBrowserPanel({
setError(null);
try {
applySnapshot(await action());
presentationRetries.current = 0;
setPresentationFailed(false);
showBrowser();
} catch (cause) {
setError(cause instanceof Error ? cause.message : '开发浏览器操作失败');
if (embedded) {
setSnapshot(null);
setPresentationRevision((value) => value + 1);
restoreWork();
} else setError(cause instanceof Error ? cause.message : '开发浏览器操作失败');
} finally {
setBusy(false);
}
}, [applySnapshot]);
}, [applySnapshot, embedded, restoreWork, showBrowser]);
const handleOpenAddress = useCallback((event?: FormEvent) => {
event?.preventDefault();
@@ -399,6 +475,7 @@ export function AgentBrowserPanel({
return (
<aside
ref={panelRef}
className={cn(embedded ? 'relative flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden bg-background' : '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"
>
@@ -412,7 +489,7 @@ export function AgentBrowserPanel({
variant="ghost"
size="icon"
className="h-8 w-8 rounded-md text-muted-foreground"
aria-label="关闭开发浏览器"
aria-label={embedded ? '收起作品' : '关闭开发浏览器'}
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" aria-hidden="true" />
@@ -435,7 +512,7 @@ export function AgentBrowserPanel({
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={embedded ? '作品运行后,在这里打开它的地址' : '输入 URL'}
placeholder={embedded ? '作品打开后会显示地址' : '输入 URL'}
spellCheck={false}
/>
</form>
@@ -450,20 +527,32 @@ export function AgentBrowserPanel({
ref={viewportRef}
className="relative min-h-[180px] flex-1 bg-surface-tertiary"
data-testid="agent-browser-viewport"
aria-label="开发浏览器页面"
aria-label={embedded ? '作品画面' : '开发浏览器页面'}
>
{!browserActive ? (
{!browserActive || workPending ? (
<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">{embedded ? '作品会在这里展开' : '开始浏览'}</p>
<p className="text-xs leading-6">{embedded ? '先在操作对话里制作作品,再打开它的预览地址。' : '输入 URL,或等待智能体打开调试页面'}</p>
<p role="status" className="text-[13px] font-semibold text-foreground">{embedded
? presentationFailed ? '作品画面暂时没能显示' : work.result?.status === 'failed' ? '作品还没有打开' : work.result?.status === 'waiting' ? '等当前步骤完成,就帮你打开作品' : '正在打开你的作品…'
: '开始浏览'}</p>
<p className="text-xs leading-6">{embedded
? presentationFailed ? '点“重新打开作品”,接着看你的作品。' : work.result?.message ?? '准备好后,页面会自动出现在这里。'
: '输入 URL,或等待智能体打开调试页面'}</p>
{embedded && <div className="flex gap-2">
{presentationFailed ? <Button variant="outline" size="sm" onClick={retryPresentation}>重新打开作品</Button> : work.result?.status === 'failed' && <Button variant="outline" size="sm" onClick={async () => {
if (transportFailure || recovery.action === 'check-work' || recovery.action === 'refresh') work.retry();
else try { await onRecovery?.(recovery.action, true); }
catch { setError('暂时没能继续,请稍后再点一次。'); }
}}>{transportFailure ? '再试一次' : recovery.label}</Button>}
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>回到操作对话</Button>
</div>}
</div>
) : null}
</div>
<section
{!embedded && <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'}
@@ -556,7 +645,7 @@ export function AgentBrowserPanel({
)}
</div>
) : null}
</section>
</section>}
</aside>
);
}