Menu
popagent
publicLatest change 7f0ff66d6d9fb6468416c58bee46bd3d08169501 - Checkpoint browser channels and memory work by AkurAI Build
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ExternalLink, Globe2, Loader2, MonitorUp, MousePointer2, Power, X } from "lucide-react";
import type { BrowserSession } from "../api-types";
import { apiFetch } from "./api";
import { parseBrowserSse } from "./browser-stream";
import { toast } from "./toast";
import { useFocusTrap } from "./use-focus-trap";
type Frame = { data: string; viewport?: { width: number; height: number } };
export function BrowserPanel({ threadId }: { threadId: string }) {
const [sessions, setSessions] = useState<BrowserSession[]>([]);
const [selectedId, setSelectedId] = useState<string>();
const [dismissedId, setDismissedId] = useState<string>();
const [frame, setFrame] = useState<Frame>();
const [streaming, setStreaming] = useState(false);
const [takeover, setTakeover] = useState(false);
const [mobile, setMobile] = useState(() => window.matchMedia("(max-width: 767px)").matches);
const panelRef = useRef<HTMLElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const lastPointerMove = useRef(0);
const selected = useMemo(() => sessions.find((session) => session.id === selectedId) ?? sessions[0], [selectedId, sessions]);
const visible = Boolean(selected && dismissedId !== selected.id);
useFocusTrap(panelRef, visible && mobile);
useEffect(() => {
const media = window.matchMedia("(max-width: 767px)");
const change = () => setMobile(media.matches);
media.addEventListener("change", change);
return () => media.removeEventListener("change", change);
}, []);
useEffect(() => {
if (visible && mobile) panelRef.current?.querySelector<HTMLButtonElement>('[aria-label="Hide live browser"]')?.focus();
}, [mobile, visible]);
const loadSessions = useCallback(async () => {
const response = await apiFetch(`/api/browser/sessions?threadId=${encodeURIComponent(threadId)}`);
if (!response.ok) return;
const next = ((await response.json()) as { sessions: BrowserSession[] }).sessions;
setSessions(next);
setSelectedId((current) => next.some((session) => session.id === current) ? current : next[0]?.id);
setDismissedId((current) => current && next.some((session) => session.id === current) ? current : undefined);
}, [threadId]);
useEffect(() => {
setSessions([]);
setSelectedId(undefined);
setDismissedId(undefined);
setFrame(undefined);
setTakeover(false);
void loadSessions();
const timer = window.setInterval(() => void loadSessions(), 1_000);
return () => window.clearInterval(timer);
}, [loadSessions]);
const streamingSessionId = visible ? selected?.id : undefined;
useEffect(() => {
if (!streamingSessionId) return;
const controller = new AbortController();
setFrame(undefined);
setStreaming(true);
void apiFetch(`/api/browser/sessions/${encodeURIComponent(streamingSessionId)}/stream`, { signal: controller.signal })
.then(async (response) => {
if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let pending = "";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
pending += decoder.decode(chunk.value, { stream: true });
const parsed = parseBrowserSse(pending);
pending = parsed.rest;
for (const event of parsed.events) {
if (event.event === "frame" && event.data && typeof event.data === "object" && "data" in event.data) {
setFrame(event.data as Frame);
}
}
}
})
.catch((error) => {
if (!controller.signal.aborted) toast(`Browser stream unavailable: ${error instanceof Error ? error.message : String(error)}`);
})
.finally(() => { if (!controller.signal.aborted) setStreaming(false); });
return () => controller.abort();
}, [streamingSessionId]);
const setAdminTakeover = async (enabled: boolean) => {
if (!selected) return;
const response = await apiFetch(`/api/browser/sessions/${encodeURIComponent(selected.id)}/takeover`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (response.ok) setTakeover(enabled);
else toast("Browser takeover is unavailable for this session");
};
const sendInput = (payload: unknown) => {
if (!selected || !takeover) return;
void apiFetch(`/api/browser/sessions/${encodeURIComponent(selected.id)}/input`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
};
const takeoverSessionId = selected?.id;
useEffect(() => () => {
if (takeoverSessionId && takeover) void apiFetch(`/api/browser/sessions/${encodeURIComponent(takeoverSessionId)}/takeover`, {
method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled: false }),
});
}, [takeoverSessionId, takeover]);
const pointerEvent = (type: "mousePressed" | "mouseReleased" | "mouseMoved", event: React.PointerEvent<HTMLImageElement>) => {
if (!takeover) return;
if (type === "mouseMoved" && performance.now() - lastPointerMove.current < 50) return;
if (type === "mouseMoved") lastPointerMove.current = performance.now();
const rect = event.currentTarget.getBoundingClientRect();
const width = frame?.viewport?.width ?? event.currentTarget.naturalWidth;
const height = frame?.viewport?.height ?? event.currentTarget.naturalHeight;
const x = Math.max(0, Math.min(width, ((event.clientX - rect.left) / rect.width) * width));
const y = Math.max(0, Math.min(height, ((event.clientY - rect.top) / rect.height) * height));
sendInput({ kind: "mouse", event: { type, x, y, button: "left", clickCount: type === "mouseMoved" ? 0 : 1 } });
};
if (!selected) return null;
if (!visible) {
return <button type="button" onClick={() => setDismissedId(undefined)} className="fixed bottom-5 right-5 z-20 flex min-h-11 items-center gap-2 border border-accent/40 bg-panel px-3 text-sm shadow-lg" aria-label="Open live browser"><MonitorUp className="size-4 text-accent" />Live browser</button>;
}
return (
<aside ref={panelRef} role={mobile ? "dialog" : "complementary"} aria-modal={mobile || undefined} className="browser-panel fixed inset-0 z-40 flex min-w-0 flex-col border-l border-line bg-bg md:relative md:inset-auto md:z-0 md:w-[44vw] md:max-w-[720px]" aria-label="Live agent browser">
<header className="flex min-h-14 items-center gap-2 border-b border-line px-3">
<Globe2 className="size-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1"><div className="truncate text-xs font-medium">{selected.currentUrl ?? "Browser starting…"}</div><div className="text-[10px] uppercase tracking-wide text-ink-dim">{selected.access} · {selected.status}</div></div>
{sessions.length > 1 ? <select aria-label="Active browser session" value={selected.id} onChange={(event) => { setSelectedId(event.currentTarget.value); setTakeover(false); }} className="min-h-9 max-w-32 border border-line bg-panel px-2 text-xs">{sessions.map((session) => <option key={session.id} value={session.id}>{session.access}</option>)}</select> : null}
<button type="button" aria-label={takeover ? "Disable browser takeover" : "Enable browser takeover"} aria-pressed={takeover} disabled={selected.access !== "interactive"} onClick={() => void setAdminTakeover(!takeover)} className={`flex size-10 items-center justify-center border ${takeover ? "border-accent bg-accent-soft text-accent" : "border-line text-ink-muted"} disabled:opacity-35`}><MousePointer2 className="size-4" /></button>
{selected.currentUrl ? <a href={selected.currentUrl} target="_blank" rel="noreferrer" aria-label="Open current page in a new tab" className="flex size-10 items-center justify-center border border-line text-ink-muted"><ExternalLink className="size-4" /></a> : null}
<button type="button" aria-label="Close browser session" onClick={async () => { await apiFetch(`/api/browser/sessions/${encodeURIComponent(selected.id)}/close`, { method: "POST" }); await loadSessions(); }} className="flex size-10 items-center justify-center border border-line text-ink-muted hover:text-red-400"><Power className="size-4" /></button>
<button type="button" aria-label="Hide live browser" onClick={() => { void setAdminTakeover(false); setDismissedId(selected.id); }} className="flex size-10 items-center justify-center border border-line text-ink-muted"><X className="size-4" /></button>
</header>
<div
className={`relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black ${takeover ? "outline outline-1 outline-inset outline-accent" : ""}`}
tabIndex={takeover ? 0 : -1}
onKeyDown={(event) => {
if (!takeover || event.key === "Tab") return;
event.preventDefault();
sendInput({ kind: "keyboard", event: { type: "keyDown", key: event.key, code: event.code } });
}}
>
{frame ? <img ref={imageRef} src={`data:image/jpeg;base64,${frame.data}`} alt="Live view of the agent browser" draggable={false} className={`max-h-full max-w-full object-contain ${takeover ? "cursor-crosshair" : ""}`} onPointerMove={(event) => pointerEvent("mouseMoved", event)} onPointerDown={(event) => { event.currentTarget.setPointerCapture(event.pointerId); pointerEvent("mousePressed", event); }} onPointerUp={(event) => pointerEvent("mouseReleased", event)} /> : <div className="flex items-center gap-2 text-sm text-white/60">{streaming ? <Loader2 className="size-4 animate-spin" /> : null}{streaming ? "Waiting for browser frames…" : "Browser preview unavailable"}</div>}
</div>
<footer className="flex min-h-10 items-center justify-between border-t border-line px-3 text-[11px] text-ink-dim"><span>{selected.tabs.length} {selected.tabs.length === 1 ? "tab" : "tabs"}</span><span>{takeover ? "Admin control enabled" : "Agent has control"}</span></footer>
</aside>
);
}