Web Preview
A browser pane an agent drives — read-only address bar, a history scrubber that follows the newest page until you step back, a load bar, fit-to-pane width presets and a console drawer.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/web-preview.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "WebPreview" component: the browser pane
an agent drives while a human watches. lucide-react for glyphs, a shadcn Button,
and two small hooks (useControllableState, useRovingTabindex) or their
equivalents. No iframe is required — the viewport is a slot.
The inversion that shapes everything: the machine navigates, the person
observes. So the address bar is READ-ONLY (an input would race the agent for
the same tab), and back/forward are a review scrubber over a history the caller
owns, not a navigation stack the component mutates.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
rest props spread on the root.
- history: WebPreviewPage[] — { url, title?, screenshot? }, oldest first.
- index?/defaultIndex?/onIndexChange? — cursor into history. Sentinel -1 (the
default) and any value >= history.length - 1 both mean "follow the tail", so
a controlled caller can re-pin without knowing the current length.
- status?: "idle" | "loading" | "error" (default "idle").
- progress?: number — 0..1 real progress; omit for an indeterminate bar.
- errorMessage?: string — body of the failure panel.
- logs?: WebPreviewLog[] — { id?, level: "log"|"info"|"warn"|"error", message,
time?, source?, count? }. `time` is PREFORMATTED text, never a Date:
formatting a Date inside the component disagrees between the server render
and the client one and hydrates red.
- consoleOpen?/defaultConsoleOpen?/onConsoleOpenChange? — drawer.
- device?/defaultDevice?/onDeviceChange? — "desktop" | "tablet" | "mobile".
- deviceWidths?: Partial<Record<Device, number>> — defaults 1280 / 834 / 390.
- onReload?: () => void — the reload button and the error panel's Retry exist
ONLY when this is given; never render a dead affordance.
- showExternalLink?: boolean (default false) — render an "open in a new tab"
anchor, and only for http(s) URLs.
- viewportHeight?: number | string (default 320), consoleMaxHeight? (176),
label?: string (accessible name of the pane).
- children — live viewport content; falls back to the active page's screenshot,
and to a "no capture" panel when there is neither.
Behavior — the cursor follows the tail, until it does not
- Derive everything from props each render; store no copy of the history. The
trail grows between renders and the cursor must still land correctly.
- following = requested < 0 || requested >= history.length - 1;
active = following ? last : clamp(requested, 0, last).
- While following, an appended page becomes the visible one. The moment the
viewer presses Back, the cursor is theirs: new pages keep arriving but the
pane does NOT jump forward. This is the one thing the component must never
do — yanking a reader out of the page they are auditing.
- Surface the gap instead: when the cursor is behind, the status bar shows a
"N newer pages" button that jumps back to the tail and re-pins; when it is at
the tail, show "active + 1 / total".
- Any move that LANDS on the newest page (that button, or Forward reaching the
end) stores the -1 sentinel, not today's last index. A numeric cursor equal to
`last` is "following" for exactly one render, and the next appended page would
leave the viewer one behind again — the takeover would never be given back.
- Back/forward disable at the ends. Alt+ArrowLeft / Alt+ArrowRight on the root
do the same thing (compose with any consumer onKeyDown, and bail out if the
event was already defaultPrevented).
Behavior — the load bar with no exit timer
- Determinate when `progress` is a finite number: width = clamp(progress,0,1),
role="progressbar" with aria-valuenow.
- Indeterminate otherwise: start at 8% and creep on an interval —
p += (0.92 - p) * 0.22 every ~220ms — approaching but never reaching the
ceiling, because a page load has no measurable total. Clear the interval on
unmount and whenever the status leaves "loading". Omit aria-valuenow (an
indeterminate progressbar must not claim a value).
- When loading ends the fill transitions to 100% and the track fades on the
SAME state change, so no timer is needed to hide it. Give the fill
key={runId}, incremented when loading turns true, so a new run remounts at
its start width instead of animating backwards from the previous 100%.
- Increment runId during render with the adjust-state-during-render pattern
(compare a previous-status state), not in an effect: an effect would show one
frame of the old bar.
- Under prefers-reduced-motion the creep never starts; the indeterminate bar is
a solid tinted track instead — still a visible "busy", with nothing moving.
Behavior — fit-to-pane scaling
- The page box keeps its REAL CSS width (the device preset) and is scaled
visually: media queries fire at the device width, not the pane width, so what
you see is the layout that width actually produces.
- Measure the host with ResizeObserver; scale = min(1, hostWidth / deviceWidth).
Read the box once synchronously in a layout effect before observing, or the
first paint shows one frame of an unscaled 1280px page. Disconnect on unmount.
- Position the page box absolutely and apply
`translateX((hostW - deviceW*scale)/2) scale(s)` with origin top-left, and set
its unscaled height to hostHeight / scale so the visual height fills the host
exactly. Report "1280px · 47%" in the status bar.
- A preset narrower than the pane renders at 1:1, centred on the pane's
backdrop — the responsive-design-mode look.
Behavior — chrome states vs page states
- status="error" and "nothing captured" are CHROME states: render them unscaled,
centred in the host, instead of inside the scaled page box (a 31%-scaled error
message is unreadable). The URL, the history and the console survive a failed
load — only the capture is replaced.
- An empty history renders "about:blank" in the address bar, disabled nav
buttons and a "nothing loaded yet" panel.
Behavior — console drawer
- A disclosure button in the status bar: aria-expanded + aria-controls, chevron
rotates 180deg, error and warning counts as pills on the trigger so a closed
drawer still reports trouble.
- Collapse with grid-template-rows 0fr→1fr over an overflow-hidden child, and
mark the region `inert` when closed: the list stays mounted (scroll position
and the stuck-to-bottom flag survive a close/open) while its links and text
leave the tab order and the a11y tree.
- The list follows the tail the same way the history does: new lines pin to the
bottom, unless the reader scrolled up — track "within 8px of the bottom" on
scroll and only auto-scroll when it holds.
- Level is never colour alone: each line carries a glyph (or a dot for plain
logs) plus an sr-only level word, and repeats collapse into a "×3" badge.
Rendering & styling
- Semantic tokens only: bg-card, bg-muted/40, bg-muted/30, bg-background,
border, text-foreground, text-muted-foreground, bg-primary + bg-primary/15
for the load bar, text-destructive for errors and the insecure-scheme glyph,
text-chart-1 for info and text-chart-3 for warnings (with /5, /10, /15 washes
for the row and the count pills). No hex, no rgb(), no oklch().
- The address hides the scheme and splits at the first "/": origin in
foreground, path in muted-foreground — the emphasis a real omnibox uses
against phishing — with a lock for https, a destructive shield plus an sr-only
"Not secure" for http, and a globe for anything else ("about:blank" prints
whole). The line is font-mono, selectable and truncates; an sr-only "Address:"
precedes it so a screen reader does not read a bare string.
- Only http(s) may become a real anchor: about:, file:, data: and sandbox-
internal addresses stay inert text.
- Width presets are a role="radiogroup" of role="radio" buttons with a roving
tabindex — one Tab stop, arrow keys move and select, aria-checked on each.
- Every transition (bar width, chevron, drawer height, hover colours) is paired
with motion-reduce:transition-none, and the reload spinner with
motion-reduce:animate-none. Nothing stops working when animation stops.
- One sr-only role="status" region announces "Loading <url>" / "Loaded <title>,
<url>" / "Failed to load <url>" — derived from props, so there is no timer
that could fire after unmount.
Customization levers
- Density: the chrome is `px-2 py-1.5` with size icon-sm buttons and the status
bar is text-xs. Drop the reload button (omit onReload) and the pane fits a
narrow sidebar; raise viewportHeight for a full-page run viewer.
- Sub-blocks are independent: delete the status bar for a bare frame, delete the
console for a screenshot viewer, delete the width presets when the run is
always desktop. Nothing else depends on them.
- Width presets: pass deviceWidths to match your own breakpoints (e.g.
{ desktop: 1440, tablet: 768, mobile: 360 }), or extend the DEVICES array with
a fourth preset — the roving group counts entries, nothing is hard-coded to 3.
- Creep shape: 0.92 ceiling / 0.22 rate / 220ms tick. Lower the rate for a
patient bar on slow captures; feed real progress instead when your runner
reports bytes or steps.
- Level palette: LEVEL maps each level to text / row wash / dot classes. Add
"debug" on chart-4 or route warnings through your own warning token by
editing that one object.
- Viewport source: pass children for a live iframe or a rendered DOM, or leave
it to the per-step screenshot. If you use an iframe, keep the sandbox
attribute narrow and remember it will NOT be scaled by media queries the way
a same-document page box is.
- Console density: consoleMaxHeight caps the drawer; drop `time` or `source`
from the line renderer for a two-column console.Concepts
- Tail-following cursor — the pane shows the newest page for as long as nobody is reading history; the first Back press transfers ownership of the cursor to the viewer, and later pages queue up behind a "N newer pages" jump instead of stealing the view.
- Read-only address — navigation belongs to the agent, so the URL is reported, not typed: no input to race the run with, and the origin/path split stays as the only trust signal the viewer needs.
- Asymptotic load bar — a page load has no measurable total, so the indeterminate bar approaches a ceiling it never reaches and only jumps to 100% when the status actually changes; honest motion beats a fake ETA.
- Fit-to-pane scaling — the page box keeps the real CSS width of the chosen preset and is shrunk with a transform, so breakpoints fire at 1280px even inside a 600px pane; the percentage in the status bar says how much you are zoomed out.
- Chrome state vs page state — "failed to load" and "no capture" belong to the frame, not to the document, so they render unscaled over the viewport while the URL, history and console stay exactly where they were.
- Stuck-to-bottom console — log lines pin to the newest arrival only while the reader is already at the bottom; scrolling up to read an error is a takeover the drawer respects until they scroll back down.
Computer Use Panel
A computer-use session viewer — screenshots with the agent's click, type, scroll and drag markers drawn on them, a synced action log you step through with the arrow keys, and live follow-and-takeover.
MCP Server List
A four-state MCP connections manager — transport and live status per server, an enable switch that is intent while status stays reality, cached tool chips with an honest count, and per-row reconnect or authorize.