feat: integrate pixel teacher presence and resilient classroom preview
This commit is contained in:
1 parent
12d800ec51
commit
7951cca700
60 files changed
+3313
-180
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user