Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Agent screen

New

A small live view of the computer an agent works on, which opens into a window where you can teach it a task.

agent-screen/agent-screen.tsx
Live · interactive
Agent's screen

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • An agent that works on a computer of its own, where people want to glance at what it is doing without leaving the page.
  • A dashboard of agents: one small live view per agent, its name under it, each opening to a full window.
  • Teaching an agent by showing it: Teach a task records the reader’s own clicks on the agent’s screen.
  • A remote screen that takes a moment to connect, where the spinner and “Connecting to…” say it is on its way.

When not to use

  • Reporting what an agent did in words or steps. Use a progress list or a log, which can be read and searched.
  • Real remote control with keyboard and clipboard. Use a full remote-desktop client.
  • Several screens to watch at once in detail: the view is 340 px at most and the window opens one at a time.
  • An agent that only calls tools or APIs: there is no screen to show, so a picture of one would mislead.

You can add this component with the shadcn CLI or by hand:

Into components/agent-screen of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/agent-screen.json

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

agent-panel.tsx
import { AgentScreen } from './agent-screen';export function AgentPanel({ agent }: { agent: AgentSession }) {  return (    <AgentScreen      name={agent.name}      status={agent.connected ? 'working' : 'loading'}      showStatusSwitch={false}      onRecordingChange={(recording, seconds) => {        if (!recording) agent.saveDemonstration(seconds);      }}      autoplay={false}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

Watch an agent at work on its own desktop: its cursor glides to a shop’s search field, types a flavour, adds it, then jots a line in its notes. Hover the view for an Open button; open it and the screen fills a window over the page, where “Teach a task” starts a recording with a red timer by the agent’s name, until you press End. Before the screen is there, the view shows a spinner and “Connecting to agent’s screen”.

  • The desktop is drawn in code: a soft gradient wallpaper, a menu bar, a notes window behind a browser on a shop’s flavours, two widgets and a dock. Places are shares of the screen and sizes are cqw (the screen is a size container), so the 340 px view and the 960 px window show the same picture.
  • The agent plays its task as a script of awaited steps: glide, click, type a character every 55 to 95 ms, wait. A click replays a 280 ms press of the arrow and a ripple; the search dims the flavours that do not match; a click on the notes brings that window to the front.
  • Its cursor is a layer as big as the screen, moved by a transform in percent of it, so every glide (380 to 950 ms, by distance) is one compositor transition whatever the screen’s size; the 30 px arrow keeps its size, as a real pointer does.
  • The agent only works while the view is on screen, the tab is visible and motion is welcome; stopped, it starts its task over when it runs again, gliding on from where its cursor was.
  • On hover or focus, a veil (rgb(17 19 24 / 0.18)) comes over the view and the Open button rises 4 px as it fades in, both in 150 ms, while the view’s shadow lifts.
  • The window is a <dialog> opened with showModal(): it sits in the top layer, keeps focus inside, gives it back on close, and Escape, the collapse button or a click on the dimmed page close it. The page dims in 180 ms and the window pops in from a 0.95 scale in 240 ms; it closes at once.
  • The big screen is as large as fits: 960 px at most, 90% of the viewport’s width, and the viewport’s height less 150 px, in the screen’s own proportions.
  • Over it, the reader’s pointer is drawn as the agent’s arrow (cursor: none and a layer that follows pointermove). While a task is recorded, the agent stands still, its arrow goes, and each of the reader’s clicks rings out in red; the timer reads its start time every 250 ms, so it never drifts, and its dot pulses every 1.1 s.
  • While on screen and untouched, a demo pointer hovers the view after a whole task of the agent’s, then flips the switch to Loading and back; any input of the reader’s hands it over and undoes a loading state the demo left.
  • Every colour is a light-dark() token on the root; the desktop keeps its own colours, as a picture of another screen does. Shadows that differ between the schemes are written once with all their layers, the layers a scheme does not use being transparent.
PropTypeDefaultDescription
namestring'Agent'Whose screen it is: the window’s title, and “<name>’s screen” under the view.
status'working' | 'loading'—Whether the screen is there or still connecting, when the parent controls it. A connecting view cannot be opened.
defaultStatus'working' | 'loading''working'The status to start with, when it is not controlled.
onStatusChange(status: AgentStatus) => void—Called when the switch, or the demo, asks for the other status.
showStatusSwitchbooleantrueWhether the Working / Loading switch shows at the foot of the card.
connectingLabelstring“Connecting to <name>’s screen”What the view says while it connects.
livebooleantrueWhether the agent works on its desktop: its cursor moves, clicks and types.
defaultStateAgentScreenState—A state to open in: hover (the view drawn hovered until the reader’s first input), open (the window open) or recording (the window recording, this many seconds in). Setting it turns the demo off.
windowInlinebooleanfalseDraws the open window in the page’s flow, non-modal, instead of over the page.
onOpenChange(open: boolean) => void—Called when the big window opens or closes.
onRecordingChange(recording: boolean, seconds: number) => void—Called when a recording starts (true, 0) and when it ends (false, its length in seconds); closing the window ends it.
autoplaybooleantrueWhile on screen and untouched, a demo pointer hovers the view and flips it to loading and back.
colorScheme'light' | 'dark' | 'system'—Light or dark colours, or the reader’s system setting. Without it, the page’s color-scheme decides.
classNamestring—Classes for the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

agent-screen.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useRef,  useState,  type PointerEvent as ReactPointerEvent,} from 'react';import { DemoPointer, runDemo, type Demo } from './demo-pointer';import { CursorArt, INITIAL, Scene, useAgent } from './scene';import { useAutoplay } from './use-autoplay';import styles from './agent-screen.module.css';/* * Agent screen: a small live view of the computer an agent works on, with its name under it. * The agent's cursor glides about its desktop, clicks and types (scene.tsx). Hover the view and a * dark veil comes over it with an "Open" button; open it and the screen fills a window over the * page, where the reader can teach the agent a task: "Teach a task" starts a recording, with a red * timer by the agent's name and an "End" button in place of it. * * - The window is a <dialog> opened with showModal(): it sits in the top layer, keeps focus inside,
agent-screen.module.css
/* Agent screen (agent-screen.tsx): a soft grey card; in its middle the agent's view, 340 px wide   at most, its name under it, and the Working / Loading switch at the card's foot. Opened, the   view fills a window over the page (a <dialog> in the top layer).   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page (light unless the page says otherwise), or the one   its colorScheme prop sets (data-color-scheme). Shadows that differ between the schemes are   written once with every layer, the layers a scheme does not use being transparent. The dialog   is the root's child in the document, so it inherits the same tokens from the top layer.   Motion. The view rises in once (380 ms); its veil and Open button fade in on hover (150 ms);   the window pops in (240 ms) over a page that dims (180 ms) and closes at once. Under reduced   motion all of it is instant. */.root {  /* The card, the view's own ground, and the ink in three strengths. */  --canvas: oklch(96.1% 0.002 247.84);  --inset: oklch(97.9% 0.002 247.839);  --ink: oklch(24.7% 0.006 258.361);  --ink-2: oklch(50.6% 0.01 264.477);  --ink-3: oklch(69.5% 0.009 264.505);  --line: oklch(94.6% 0.003 264.542);  /* The switch's track, the raised surfaces (its thumb, the window, the buttons), their hover. */  --field: oklch(96.1% 0.001 286.375);
scene.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useRef, useState, type CSSProperties } from 'react';import styles from './scene.module.css';/* * The agent's desktop, drawn in code: a soft wallpaper, a menu bar, a notes window behind a * browser that shows a small shop's flavours, two widgets and a dock. Everything is placed in * shares of the screen and sized in cqw, so the same drawing reads the same in the 340 px tile * and in the 960 px window. * * The agent works on it in a loop (useAgent): its cursor glides to the shop's search field, * clicks, types a flavour and sends it, adds the flavour that is left, then brings the notes to * the front and writes a line there. The cursor is a full-screen layer moved by a transform in * percent of the screen, so a glide is one compositor transition, whatever the screen's size. *//** A point on the screen, in percent of its width (x) and height (y). */type Point = { x: number; y: number };/** The windows, in percent of the screen. */const BROWSER = { x: 5, y: 29, w: 55, h: 59 };
scene.module.css
/* The agent's desktop (scene.tsx). It fills the box it is given and is drawn in shares of it:   places in percent, sizes in cqw (the screen is the container), so the 340 px tile and the 960 px   window show the same picture. It is a picture of another computer's screen, so its colours are   its own and stay the same in both schemes. Only the agent's cursor keeps a fixed size, 30 px, as   a real pointer does. */.scene {  position: absolute;  inset: 0;  overflow: hidden;  container-type: inline-size;  color: #1d1f24;  font-family: var(--sans, system-ui, sans-serif);  letter-spacing: 0;  line-height: 1.25;  white-space: nowrap;  user-select: none;}.scene span {  display: block;}.wallpaper {  position: absolute;
demo-pointer.tsx
'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, glide * the pointer onto a control, press it and call the component's own handler, as a click would. * Stopping the demo (the reader takes over, or it scrolls away) ends the step in progress at once: * its promise rejects, which ends the script right there, and the pointer fades out. * * The pointer is an HTML box around a still drawing: it glides by an animation the compositor * plays, and the press scales a box that keeps a transform at rest, so nothing it does needs a * layout. */export type Point = { x: number; y: number };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;  /** Presses and lets go; `action` runs as the pointer goes down. */  press: (action: () => void) => Promise<void>;
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. `visible` and `reduced` are there for any other * work that should only run on screen, or only with motion. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {
  • The view is opened by a real button, “Open agent’s screen”, with aria-haspopup="dialog"; its Open label shows on focus as on hover.
  • The window is a modal <dialog> named “Agent’s screen”: focus moves into it, stays there, and returns to the Open button when it closes; Escape closes it.
  • Teach a task, End and Collapse are buttons; the timer is labelled “Recording, 00:04”, and a polite live region says when a recording starts and ends (with its length) and when the view connects.
  • The Working / Loading switch is a group of two toggle buttons with aria-pressed.
  • The desktop and both arrows are decorative and hidden from assistive technology.
  • Under reduced motion the agent does not work, the demo does not play, and the view, window and buttons change at once.