Agent statusBrowser session
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callBrowser session
The agent browsing for you, step by step, with Pause and Take over. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add browser-sessionReact
npx designme add browser-session --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Browser session shows an agent browsing the web for a person: a small browser frame with the address bar and the page it is on, the steps it takes under it, and Pause and Take over. When the page gets in its way, like a cookie banner over the table it came to read, the step turns amber, says why, and waits for the person to let it go on or take over.
- When an agent uses a browser on the person's behalf, to search, open sites, click and read pages.
- When people should see where the agent is and what it does next, and be able to stop it at any moment.
- When the agent can hit something it shouldn't decide alone, like a cookie banner, a login or a payment form, and needs a person's go-ahead.
- When a person may want to step in and use the page themselves, then hand it back.
urlfills the address bar, with a padlock, or a magnifier whensearchmarks it as a search.childrenis the page itself, 156px tall: a screenshot or live view of the agent's browser, or a wireframe drawn with thedm-bws-pgclasses, as in the demo.cursordraws the agent's own pointer over the page, in the accent colour, anddownpresses it.stepslists what the agent does. A queued step shows a dot, the running one spins, a finished one gets a green check, and a waiting one an amber mark. Each can carry adetailline, like "2 of 3 prices".askadds a button under the waiting step, like "Allow closing it", with Take over beside it.- Pause holds the agent and turns into Resume. Take over hands the page to the person: the frame turns the accent colour, the page's own controls become usable, and the button reads Hand back.
- The line under the title reads Browsing, Paused, Needs you, You have control or Done, with a clock that counts while the agent browses.
summaryshows under the steps once every step is done.
- The steps are an ordered list, and the running or waiting step is marked as the current step.
- Pause and Take over are buttons: Pause is named Pause or Resume, and Take over is a toggle button that reads Hand back while pressed.
- The line under the title is announced politely when it changes.
- While the agent drives, the page is inert, so nothing in it can be focused or pressed until the person takes over.
- With reduced motion nothing glides or fades; the steps and the page still change.
Usage
Import it and drive it from your own state.
Import
import { BrowserSession } from '@/components/designme/browser-session/BrowserSession';Example
The demo above as a React component, 133 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { BrowserSession, type BrowserStep } from './BrowserSession';
import { cx } from '../lib/cx';
import { Icon } from '../lib/icons';
import { useScript, useStory, useWait } from '../lib/useScript';
/* ---------- Browser session: the agent searches, opens the site, goes to pricing, stops at a cookie banner and asks; once
it may close it, it reads the table and takes the three prices ---------- */
const LABELS = ['Search "northwind pricing"', 'Open northwind.app', 'Click "Pricing"', 'Read the plans table', 'Extract prices'];
type PageName = 'search' | 'home' | 'pricing';
// the page, drawn small with the component's own wireframe classes; in your app this is a screenshot or live view
function Page({ page, scroll, banner, hits, aim, onBanner }: { page: PageName; scroll: number; banner: boolean; hits: number; aim: string | null; onBanner: () => void }) {
const k = (key: string) => ({ 'data-k': key, className: aim === key ? 'dm-aim' : undefined });
const nav = (on: boolean) => (
<div className="dm-bws-nav"><b>Northwind</b><span>Product</span><span data-k="pricing" className={cx(on && 'dm-on', aim === 'pricing' && 'dm-aim')}>Pricing</span><span>Docs</span></div>
);
return (
<>
{page === 'search' && (
<div key="search" className="dm-bws-pg dm-enter">
<div className="dm-bws-q"><Icon name="search" size="xs" /><span>northwind pricing</span></div>
<div className="dm-bws-res"><b {...k('site')}>Northwind · Docs your team reads</b><span>northwind.app</span></div>
<div className="dm-bws-res"><b>Northwind review: is it worth it?</b><span>softwarenotes.io</span></div>
<div className="dm-bws-res"><b>Northwind alternatives</b><span>stackcompare.com</span></div>
</div>
)}
{page === 'home' && (
<div key="home" className="dm-bws-pg dm-enter">
{nav(false)}<div className="dm-bws-hd">Docs your team actually reads</div>
<span className="dm-bws-ln" style={{ width: '92%' }} /><span className="dm-bws-ln" style={{ width: '68%' }} /><span className="dm-bws-cta">Start free</span>
</div>
)}
{page === 'pricing' && (
<div key="pricing" className="dm-bws-pg dm-enter" style={{ transform: `translateY(${-scroll}px)` }}>
{nav(true)}<div className="dm-bws-hd">Plans and pricing</div><span className="dm-bws-ln" style={{ width: '78%' }} />
<div className="dm-bws-tb">
<div><span>Plan</span><span>Per seat, monthly</span></div>
{[['Starter', '$8'], ['Team', '$16'], ['Business', '$32']].map(([plan, price], i) => (
<div key={plan}><span>{plan}</span><span data-k={'p' + i} className={cx('dm-num', i < hits && 'dm-hit', aim === 'p' + i && 'dm-aim')}>{price}</span></div>
))}
</div>
<span className="dm-bws-ln" style={{ width: '64%' }} /><span className="dm-bws-ln" style={{ width: '40%' }} />
</div>
)}
<div className={cx('dm-bws-ck', banner && 'dm-on')}>
<span>This site uses cookies</span>
<button type="button" data-k="reject" className={aim === 'reject' ? 'dm-aim' : undefined} onClick={onBanner}>Reject all</button>
<button type="button" onClick={onBanner}>Accept all</button>
</div>
</>
);
}
export default function BrowserSessionDemo() {
const ref = useRef<HTMLDivElement>(null);
const later = useWait();
const [steps, setSteps] = useState<BrowserStep[]>(LABELS.map(label => ({ label, state: 'queued' })));
const [page, setPage] = useState<PageName>('search');
const [url, setUrl] = useState('northwind pricing');
const [scroll, setScroll] = useState(0);
const [banner, setBanner] = useState(false);
const [blocked, setBlocked] = useState(false);
const [hits, setHits] = useState(0);
const [aim, setAim] = useState<string | null>(null);
const [cursor, setCursor] = useState<{ x: number; y: number; down?: boolean } | null>(null);
const [paused, setPaused] = useState(false);
const [mine, setMine] = useState(false);
const [summary, setSummary] = useState<string>();
// the run reads these between its moves, so Pause and Take over hold it where it is
const held = useRef({ paused, mine }); held.current = { paused, mine };
const unblock = useRef<() => void>(undefined);
const step = (i: number, state: BrowserStep['state'], detail?: string) => setSteps(s => s.map((x, j) => j === i ? { ...x, state, detail } : x));
const vp = () => ref.current?.querySelector<HTMLElement>('.dm-bws-vp');
const where = (key: string) => {
const v = vp(), el = v?.querySelector<HTMLElement>(`[data-k="${key}"]`); if (!v || !el) return null;
const a = v.getBoundingClientRect(), b = el.getBoundingClientRect();
return { x: b.left - a.left + Math.min(b.width * .5, 36), y: b.top - a.top + b.height * .6 };
};
// waits that only count while the agent may move
const act = async (wait: (ms: number) => Promise<void>, ms: number) => { for (let left = ms; left > 0;) { await wait(100); if (!held.current.paused && !held.current.mine) left -= 100; } };
const pressAt = async (wait: (ms: number) => Promise<void>, key: string) => {
await wait(50); const p = where(key); if (!p) return;
setAim(key); setCursor(p); await act(wait, 800);
setCursor({ ...p, down: true }); await act(wait, 200); setCursor(p); setAim(null);
};
useScript(async ({ wait }) => {
step(0, 'running'); await act(wait, 1800); step(0, 'done'); step(1, 'running');
await pressAt(wait, 'site'); setPage('home'); setUrl('northwind.app');
await act(wait, 1200); step(1, 'done'); step(2, 'running');
await pressAt(wait, 'pricing'); setPage('pricing'); setUrl('northwind.app/pricing');
await act(wait, 1000); step(2, 'done'); step(3, 'running');
await act(wait, 600);
const pg = vp()?.querySelector<HTMLElement>('.dm-bws-pg'), tb = pg?.querySelector<HTMLElement>('.dm-bws-tb');
if (pg && tb) setScroll(Math.max(0, Math.min(pg.offsetHeight - vp()!.clientHeight, tb.offsetTop - 8)));
await act(wait, 1200); setBanner(true); await act(wait, 500);
// the banner covers the table: the agent stops and asks
setBlocked(true); step(3, 'waiting');
await new Promise<void>(res => { unblock.current = res; });
step(3, 'running');
await act(wait, 1400); step(3, 'done', '3 plans, priced per seat'); step(4, 'running', '0 of 3 prices');
for (let i = 0; i < 3; i++) { await pressAt(wait, 'p' + i); setHits(i + 1); step(4, 'running', `${i + 1} of 3 prices`); }
await act(wait, 500); setCursor(null);
step(4, 'done', '$8, $16 and $32 per seat'); setSummary('Done · 3 prices saved to Pricing research');
});
// Allow lets the agent close the banner itself, with Reject all
const allow = async () => {
setBlocked(false); step(3, 'running', 'Closing the banner');
await pressAt(later, 'reject'); setBanner(false); unblock.current?.();
};
// with the page in hand, the person can close the banner themselves
const closeBanner = () => { if (!mine || !blocked) return; setBanner(false); setBlocked(false); unblock.current?.(); };
// the preview's story: the agent stops at the cookie banner, and the person lets it close it
useStory(ref, async ({ wait, press }) => { await wait(10000); press('.dm-bws-ask .dm-primary'); });
const shown = steps.map((s, i) => i === 3 && blocked ? { ...s, detail: mine ? 'Close the banner, then hand back' : 'A cookie banner covers the table' } : s);
return (
<div ref={ref} style={{ width: '100%' }}>
<BrowserSession title="Pricing research" url={url} search={page === 'search'} cursor={cursor} steps={shown}
ask={blocked ? { label: 'Allow closing it', onAllow: () => void allow() } : undefined}
paused={paused} onPausedChange={setPaused} inControl={mine} onInControlChange={setMine} summary={summary}>
<Page page={page} scroll={scroll} banner={banner} hits={hits} aim={aim} onBanner={closeBanner} />
</BrowserSession>
</div>
);
}
Props
Everything BrowserSession takes.
| Prop | Type | What it does |
|---|---|---|
titlerequired | string | What the agent is browsing for, like "Pricing research" |
urlrequired | string | The address bar: the page's address, or the search being typed |
search | boolean | Marks `url` as a search, with a magnifier instead of the padlock |
children | ReactNode | The page, 156px tall: a screenshot or live view of the agent's browser, or a wireframe drawn with the dm-bws-pg classes |
cursor | { x: number; y: number; down?: boolean } | null | The agent's own pointer, in pixels from the page's top left. `down` presses it. Hidden while the person has control |
stepsrequired | BrowserStep[] | |
ask | { label: string; onAllow: () => void } | While a step waits for the person: the button that lets the agent go on, like "Allow closing it". Take over sits beside it |
paused | boolean | Pause holds the agent where it is; the button turns into Resume |
defaultPaused | boolean | |
onPausedChange | (paused: boolean) => void | |
inControl | boolean | Take over hands the page to the person: its frame turns the accent colour, its controls become usable, and the button reads Hand back |
defaultInControl | boolean | |
onInControlChange | (inControl: boolean) => void | |
seconds | number | Seconds to show as m:ss. Leave it out to let the clock count while the agent is browsing |
status | string | The line under the title. Defaults to Browsing, Paused, Needs you, You have control or Done, then the clock |
summary | string | Shown under the steps once every step is done, like "Done · 3 prices saved to Pricing research" |
className | string |
Code
The full source, as the CLI installs it.
BrowserSession.tsxReact · 129 lines
'use client';
import { useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useSeconds } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { enter, Spin, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './browser-session.css';
export type BrowserStepState = 'queued' | 'running' | 'waiting' | 'done';
export interface BrowserStep {
/** What the agent does, like 'Click "Pricing"' */
label: string;
state: BrowserStepState;
/** A quiet line under the step, like "2 of 3 prices". On a waiting step it says what is in the way, in amber */
detail?: string;
}
export interface BrowserSessionProps {
/** What the agent is browsing for, like "Pricing research" */
title: string;
/** The address bar: the page's address, or the search being typed */
url: string;
/** Marks `url` as a search, with a magnifier instead of the padlock */
search?: boolean;
/** The page, 156px tall: a screenshot or live view of the agent's browser, or a wireframe drawn with the dm-bws-pg classes */
children?: ReactNode;
/** The agent's own pointer, in pixels from the page's top left. `down` presses it. Hidden while the person has control */
cursor?: { x: number; y: number; down?: boolean } | null;
steps: BrowserStep[];
/** While a step waits for the person: the button that lets the agent go on, like "Allow closing it". Take over sits beside it */
ask?: { label: string; onAllow: () => void };
/** Pause holds the agent where it is; the button turns into Resume */
paused?: boolean;
defaultPaused?: boolean;
onPausedChange?: (paused: boolean) => void;
/** Take over hands the page to the person: its frame turns the accent colour, its controls become usable, and the button reads Hand back */
inControl?: boolean;
defaultInControl?: boolean;
onInControlChange?: (inControl: boolean) => void;
/** Seconds to show as m:ss. Leave it out to let the clock count while the agent is browsing */
seconds?: number;
/** The line under the title. Defaults to Browsing, Paused, Needs you, You have control or Done, then the clock */
status?: string;
/** Shown under the steps once every step is done, like "Done · 3 prices saved to Pricing research" */
summary?: string;
className?: string;
}
const CLS: Record<BrowserStepState, string> = { queued: 'dm-queued', running: 'dm-run', waiting: 'dm-wait', done: 'dm-done' };
const clock = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
function Glyph({ state }: { state: BrowserStepState }) {
// a glyph plays its entrance when its step changes state, not when it first renders
const [first] = useState(state);
const e = state !== first ? enter() : undefined;
return (
<span key={state} className={cx('dm-g', e?.className)} style={e?.style} aria-hidden="true">
{state === 'running' ? <Spin /> : state === 'done' ? <Icon name="ok" size="xs" /> : state === 'waiting' ? <Icon name="alert" size="xs" /> : <span className="dm-dot" />}
</span>
);
}
/** The agent browsing the web for the person: a small browser frame with the page it is on, the steps it takes under it,
and Pause and Take over. When a step waits for the person, it shows why and a button to let the agent go on. */
export function BrowserSession({ title, url, search, children, cursor, steps, ask, paused, defaultPaused = false, onPausedChange,
inControl, defaultInControl = false, onInControlChange, seconds, status, summary, className }: BrowserSessionProps) {
const [isPaused, setPaused] = useControllable(paused, defaultPaused, onPausedChange);
const [mine, setMine] = useControllable(inControl, defaultInControl, onInControlChange);
const done = steps.length > 0 && steps.every(s => s.state === 'done');
const waiting = steps.some(s => s.state === 'waiting');
const counted = useSeconds(seconds === undefined && !done && !waiting && !isPaused && !mine);
const secs = seconds ?? counted;
const line = status ?? (done ? 'Done' : mine ? 'You have control' : isPaused ? 'Paused' : waiting ? 'Needs you' : 'Browsing');
const take = () => { setMine(!mine); if (!mine && isPaused) setPaused(false); };
const sum = done && summary ? enter() : undefined;
return (
<div data-dm="" className={cx('dm-bws dm-panel', mine && 'dm-you', isPaused && !mine && 'dm-paused', className)}>
<div className="dm-bws-h">
<div className="dm-bws-ht"><b>{title}</b><span className="dm-muted dm-bws-st"><span aria-live="polite"><Swap>{line}</Swap></span> · <span className="dm-num">{clock(secs)}</span></span></div>
<div className="dm-bws-ctl">
<Tip label={isPaused ? 'Resume' : 'Pause'}>
<button type="button" className="dm-ib dm-bws-pause" aria-label={isPaused ? 'Resume' : 'Pause'} disabled={mine || done} onClick={() => setPaused(!isPaused)}>
<Icon name={isPaused ? 'play' : 'pause'} />
</button>
</Tip>
<button type="button" className="dm-pill dm-line dm-sm dm-bws-take" aria-pressed={mine} disabled={done} onClick={take}>{mine ? 'Hand back' : 'Take over'}</button>
</div>
</div>
<div className="dm-bws-win">
<div className="dm-bws-bar">
<span className="dm-bws-dots" aria-hidden="true"><i /><i /><i /></span>
<div className="dm-bws-url"><Icon name={search ? 'search' : 'lock'} size="xs" /><Swap>{url}</Swap></div>
</div>
{/* while the agent drives, the page is a picture of its browser; with Take over it is the person's to use */}
<div className="dm-bws-vp" inert={!mine}>
{children}
<span className={cx('dm-bws-cur', cursor && !mine && 'dm-on', cursor?.down && 'dm-down')} style={cursor ? { transform: `translate(${cursor.x}px,${cursor.y}px)` } : undefined} aria-hidden="true">
<svg viewBox="0 0 14 14"><path d="M2 1.5l9.5 5-4.2 1.2-2 4.3z" /></svg>
</span>
</div>
</div>
<ol className="dm-bws-steps" aria-label="Steps">
{steps.map((s, i) => {
const asking = s.state === 'waiting' && ask && !mine;
return (
<li key={i} className={cx('dm-bws-s', CLS[s.state])} aria-current={s.state === 'running' || s.state === 'waiting' ? 'step' : undefined}>
<Glyph state={s.state} />
<span className="dm-bws-t">
<span>{s.label}</span>
{s.detail && <Swap className="dm-d">{s.detail}</Swap>}
{asking && (
<div className={cx('dm-bws-ask', enter().className)}>
<button type="button" className="dm-pill dm-primary dm-sm" onClick={ask.onAllow}>{ask.label}</button>
<button type="button" className="dm-pill dm-soft dm-sm" onClick={take}>Take over</button>
</div>
)}
</span>
</li>
);
})}
</ol>
{sum && <div className={cx('dm-bws-sum', sum.className)} style={sum.style}><Icon name="ok" size="xs" /><span>{summary}</span></div>}
</div>
);
}
browser-session.cssReact · 70 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Browser session ---------- */
/* the agent browsing for you: a small browser frame with the page it is on drawn as a wireframe, the steps under it, and
Pause and Take over. When the page gets in its way it stops and asks, and with Take over the page is yours to use */
.dm-bws{width:100%;max-width:440px;margin:0 auto;padding:6px}
.dm-bws-h{display:flex;align-items:center;justify-content:space-between;gap:var(--dm-s-3);padding:var(--dm-s-2) 10px 10px}
.dm-bws-ht{display:flex;flex-direction:column;min-width:0}
.dm-bws-ht b{font-weight:500;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-bws-st{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-bws-ctl{display:flex;align-items:center;gap:var(--dm-s-1);flex:none}
.dm-bws-take[aria-pressed="true"]{background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent));box-shadow:none}
.dm-bws-win{margin:0 var(--dm-s-1);border-radius:var(--dm-r-in);border:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);overflow:hidden;transition:border-color .3s}
.dm-bws.dm-you .dm-bws-win{border-color:var(--dm-accent-mark,var(--dm-accent))}
.dm-bws-bar{display:flex;align-items:center;gap:var(--dm-s-2);height:36px;padding:0 var(--dm-s-2) 0 10px;background:var(--dm-wash);border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-bws-dots{display:flex;gap:var(--dm-s-1);flex:none}
.dm-bws-dots i{width:7px;height:7px;border-radius:50%;background:var(--dm-faint)}
.dm-bws-url{flex:1;min-width:0;display:flex;align-items:center;gap:6px;height:24px;padding:0 10px;border-radius:var(--dm-r-pill);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:12px;color:var(--dm-fg2)}
.dm-bws-url .dm-i{color:var(--dm-muted)}
.dm-bws-url span{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-bws-vp{position:relative;height:156px;overflow:hidden;background:var(--dm-surface)}
/* the page itself, drawn small: real words where they matter, grey lines for the rest */
.dm-bws-pg{position:absolute;left:0;right:0;top:0;padding:var(--dm-s-3) 14px;display:flex;flex-direction:column;gap:10px;font-size:12px;color:var(--dm-fg2);transition:transform .8s var(--dm-ease)}
.dm-bws-ln{display:block;height:6px;border-radius:3px;background:var(--dm-chip)}
.dm-bws-nav{display:flex;align-items:center;gap:var(--dm-s-3);font-size:11.5px;color:var(--dm-muted);white-space:nowrap}
.dm-bws-nav b{display:flex;align-items:center;gap:6px;margin-right:auto;font-weight:500;color:var(--dm-fg)}
.dm-bws-nav b::before{content:"";width:12px;height:12px;border-radius:var(--dm-r-xs);background:var(--dm-fg)}
.dm-bws-nav .dm-on{color:var(--dm-fg)}
.dm-bws-hd{font-size:13px;font-weight:500;color:var(--dm-fg)}
.dm-bws-q{display:flex;align-items:center;gap:6px;height:26px;padding:0 10px;border-radius:var(--dm-r-pill);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);color:var(--dm-fg);white-space:nowrap;overflow:hidden}
.dm-bws-res{display:flex;flex-direction:column;gap:2px;min-width:0}
.dm-bws-res b{font-weight:500;color:var(--dm-pb);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;align-self:flex-start;max-width:100%}
.dm-bws-res span{font-size:11.5px;color:var(--dm-muted)}
.dm-bws-cta{align-self:flex-start;height:22px;padding:0 10px;display:inline-flex;align-items:center;border-radius:var(--dm-r-pill);background:var(--dm-chip);font-size:11.5px;color:var(--dm-fg2)}
.dm-bws-tb{display:flex;flex-direction:column;border-radius:var(--dm-r-sm);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-bws-tb div{display:flex;justify-content:space-between;gap:var(--dm-s-3);padding:5px 10px;border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-bws-tb div:first-child{border-top:0;font-size:11.5px;color:var(--dm-muted)}
.dm-bws-tb .dm-num{padding:0 var(--dm-s-1);margin:0 -4px;border-radius:var(--dm-r-xs);color:var(--dm-fg);transition:background .3s,color .3s}
/* a price the agent has taken is marked like a highlighter, in the accent fill */
.dm-bws-tb .dm-num.dm-hit{background:var(--dm-accent);color:var(--dm-on-accent)}
/* what the agent is about to press gets a ring, and its own pointer glides there */
.dm-bws-vp .dm-aim{outline:1.5px solid var(--dm-accent-mark,var(--dm-accent));outline-offset:2px;border-radius:var(--dm-r-xs)}
.dm-bws-cur{position:absolute;left:0;top:0;width:14px;height:14px;z-index:2;color:var(--dm-accent-mark,var(--dm-accent));pointer-events:none;opacity:0;transition:transform .7s var(--dm-ease),opacity .2s}
.dm-bws-cur.dm-on{opacity:1}
.dm-bws-cur svg{display:block;width:14px;height:14px;transition:transform .16s var(--dm-ease)}
.dm-bws-cur svg path{fill:currentColor;stroke:var(--dm-surface);stroke-width:1.4;stroke-linejoin:round}
.dm-bws-cur.dm-down svg{transform:scale(.8)}
/* the page's own cookie banner, the thing in the agent's way */
.dm-bws-ck{position:absolute;left:8px;right:8px;bottom:8px;z-index:1;display:flex;align-items:center;gap:6px;padding:6px 6px 6px 10px;border-radius:10px;background:var(--dm-raised);box-shadow:var(--dm-shadow-pop);font-size:12px;color:var(--dm-fg);transform:translateY(calc(100% + 12px));transition:transform .5s var(--dm-ease)}
.dm-bws-ck.dm-on{transform:none}
.dm-bws-ck span{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-bws-ck button{flex:none;height:24px;padding:0 9px;border-radius:var(--dm-r-pill);font-size:11.5px;background:var(--dm-chip);color:var(--dm-fg)}
.dm-bws-ck button+button{background:var(--dm-ink);color:var(--dm-on-ink)}
.dm-bws:not(.dm-you) .dm-bws-ck button{cursor:default}
.dm-bws-steps{list-style:none;margin:0;padding:var(--dm-s-2) 0 0;display:flex;flex-direction:column}
.dm-bws-s{display:flex;align-items:flex-start;gap:10px;padding:6px 10px;font-size:13px;color:var(--dm-muted)}
.dm-bws-s .dm-g{width:16px;height:19.5px;display:grid;place-items:center;flex:none;color:var(--dm-faint)}
.dm-bws-s .dm-g .dm-spin{width:11px;height:11px;color:var(--dm-accent-fg,var(--dm-accent))}
.dm-bws-s.dm-done{color:var(--dm-fg2)}.dm-bws-s.dm-done .dm-g{color:var(--dm-ok)}
.dm-bws-s.dm-run,.dm-bws-s.dm-wait{color:var(--dm-fg)}
.dm-bws-s.dm-wait .dm-g{color:var(--dm-warn)}
.dm-bws.dm-paused .dm-bws-s .dm-g .dm-spin{animation-play-state:paused;color:var(--dm-muted)}
.dm-bws-t{flex:1;min-width:0;display:flex;flex-direction:column}
.dm-bws-t>span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-bws-t .dm-d{font-size:12px;color:var(--dm-muted)}
.dm-bws-s.dm-wait .dm-d{color:var(--dm-warn)}
.dm-bws-ask{display:flex;flex-wrap:wrap;align-items:center;gap:var(--dm-s-2);margin-top:var(--dm-s-2)}
.dm-bws-sum{display:flex;align-items:center;gap:var(--dm-s-2);margin:var(--dm-s-2) var(--dm-s-1) 2px;padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-ok-bg);font-size:13px}
.dm-bws-sum svg{color:var(--dm-ok);flex:none}
.dm-bws-sum span{min-width:0}
browser-session.htmlHTML · 10 lines
<div class="card" data-c="browser-session">
<div class="stage-in">
<div class="bws panel">
<div class="bws-h"><div class="bws-ht"><b>Pricing research</b><span class="muted bws-st" aria-live="polite"></span></div><div class="bws-ctl"><button class="ib bws-pause" aria-label="Pause" data-tip="Pause"><svg class="i s"><use href="#i-pause"/></svg></button><button class="pill line sm bws-take" aria-pressed="false">Take over</button></div></div>
<div class="bws-win"><div class="bws-bar"><span class="bws-dots" aria-hidden="true"><i></i><i></i><i></i></span><div class="bws-url"><svg class="i xs"><use href="#i-search"/></svg><span></span></div></div><div class="bws-vp"></div></div>
<ol class="bws-steps" aria-label="Steps"></ol>
</div>
</div>
</div>
browser-session.cssHTML · 69 lines
/* ---------- Browser session ---------- */
/* the agent browsing for you: a small browser frame with the page it is on drawn as a wireframe, the steps under it, and
Pause and Take over. When the page gets in its way it stops and asks, and with Take over the page is yours to use */
.bws{width:100%;max-width:440px;margin:0 auto;padding:6px}
.bws-h{display:flex;align-items:center;justify-content:space-between;gap:var(--s-3);padding:var(--s-2) 10px 10px}
.bws-ht{display:flex;flex-direction:column;min-width:0}
.bws-ht b{font-weight:500;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bws-st{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bws-ctl{display:flex;align-items:center;gap:var(--s-1);flex:none}
.bws-take[aria-pressed="true"]{background:var(--accent-bg);color:var(--accent-fg,var(--accent));box-shadow:none}
.bws-win{margin:0 var(--s-1);border-radius:var(--r-in);border:1px solid color-mix(in srgb,var(--line) 50%,transparent);overflow:hidden;transition:border-color .3s}
.bws.you .bws-win{border-color:var(--accent-mark,var(--accent))}
.bws-bar{display:flex;align-items:center;gap:var(--s-2);height:36px;padding:0 var(--s-2) 0 10px;background:var(--wash);border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.bws-dots{display:flex;gap:var(--s-1);flex:none}
.bws-dots i{width:7px;height:7px;border-radius:50%;background:var(--faint)}
.bws-url{flex:1;min-width:0;display:flex;align-items:center;gap:6px;height:24px;padding:0 10px;border-radius:var(--r-pill);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--line) 50%,transparent);font-size:12px;color:var(--fg2)}
.bws-url .i{color:var(--muted)}
.bws-url span{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bws-vp{position:relative;height:156px;overflow:hidden;background:var(--surface)}
/* the page itself, drawn small: real words where they matter, grey lines for the rest */
.bws-pg{position:absolute;left:0;right:0;top:0;padding:var(--s-3) 14px;display:flex;flex-direction:column;gap:10px;font-size:12px;color:var(--fg2);transition:transform .8s var(--ease)}
.bws-ln{display:block;height:6px;border-radius:3px;background:var(--chip)}
.bws-nav{display:flex;align-items:center;gap:var(--s-3);font-size:11.5px;color:var(--muted);white-space:nowrap}
.bws-nav b{display:flex;align-items:center;gap:6px;margin-right:auto;font-weight:500;color:var(--fg)}
.bws-nav b::before{content:"";width:12px;height:12px;border-radius:var(--r-xs);background:var(--fg)}
.bws-nav .on{color:var(--fg)}
.bws-hd{font-size:13px;font-weight:500;color:var(--fg)}
.bws-q{display:flex;align-items:center;gap:6px;height:26px;padding:0 10px;border-radius:var(--r-pill);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--line) 50%,transparent);color:var(--fg);white-space:nowrap;overflow:hidden}
.bws-res{display:flex;flex-direction:column;gap:2px;min-width:0}
.bws-res b{font-weight:500;color:var(--pb);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;align-self:flex-start;max-width:100%}
.bws-res span{font-size:11.5px;color:var(--muted)}
.bws-cta{align-self:flex-start;height:22px;padding:0 10px;display:inline-flex;align-items:center;border-radius:var(--r-pill);background:var(--chip);font-size:11.5px;color:var(--fg2)}
.bws-tb{display:flex;flex-direction:column;border-radius:var(--r-sm);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--line) 50%,transparent)}
.bws-tb div{display:flex;justify-content:space-between;gap:var(--s-3);padding:5px 10px;border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.bws-tb div:first-child{border-top:0;font-size:11.5px;color:var(--muted)}
.bws-tb .num{padding:0 var(--s-1);margin:0 -4px;border-radius:var(--r-xs);color:var(--fg);transition:background .3s,color .3s}
/* a price the agent has taken is marked like a highlighter, in the accent fill */
.bws-tb .num.hit{background:var(--accent);color:var(--on-accent)}
/* what the agent is about to press gets a ring, and its own pointer glides there */
.bws-vp .aim{outline:1.5px solid var(--accent-mark,var(--accent));outline-offset:2px;border-radius:var(--r-xs)}
.bws-cur{position:absolute;left:0;top:0;width:14px;height:14px;z-index:2;color:var(--accent-mark,var(--accent));pointer-events:none;opacity:0;transition:transform .7s var(--ease),opacity .2s}
.bws-cur.on{opacity:1}
.bws-cur svg{display:block;width:14px;height:14px;transition:transform .16s var(--ease)}
.bws-cur svg path{fill:currentColor;stroke:var(--surface);stroke-width:1.4;stroke-linejoin:round}
.bws-cur.down svg{transform:scale(.8)}
/* the page's own cookie banner, the thing in the agent's way */
.bws-ck{position:absolute;left:8px;right:8px;bottom:8px;z-index:1;display:flex;align-items:center;gap:6px;padding:6px 6px 6px 10px;border-radius:10px;background:var(--raised);box-shadow:var(--shadow-pop);font-size:12px;color:var(--fg);transform:translateY(calc(100% + 12px));transition:transform .5s var(--ease)}
.bws-ck.on{transform:none}
.bws-ck span{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bws-ck button{flex:none;height:24px;padding:0 9px;border-radius:var(--r-pill);font-size:11.5px;background:var(--chip);color:var(--fg)}
.bws-ck button+button{background:var(--ink);color:var(--on-ink)}
.bws:not(.you) .bws-ck button{cursor:default}
.bws-steps{list-style:none;margin:0;padding:var(--s-2) 0 0;display:flex;flex-direction:column}
.bws-s{display:flex;align-items:flex-start;gap:10px;padding:6px 10px;font-size:13px;color:var(--muted)}
.bws-s .g{width:16px;height:19.5px;display:grid;place-items:center;flex:none;color:var(--faint)}
.bws-s .g .spin{width:11px;height:11px;color:var(--accent-fg,var(--accent))}
.bws-s.done{color:var(--fg2)}.bws-s.done .g{color:var(--ok)}
.bws-s.run,.bws-s.wait{color:var(--fg)}
.bws-s.wait .g{color:var(--warn)}
.bws.paused .bws-s .g .spin{animation-play-state:paused;color:var(--muted)}
.bws-t{flex:1;min-width:0;display:flex;flex-direction:column}
.bws-t>span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bws-t .d{font-size:12px;color:var(--muted)}
.bws-s.wait .d{color:var(--warn)}
.bws-ask{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2);margin-top:var(--s-2)}
.bws-sum{display:flex;align-items:center;gap:var(--s-2);margin:var(--s-2) var(--s-1) 2px;padding:10px var(--s-3);border-radius:var(--r-in);background:var(--ok-bg);font-size:13px}
.bws-sum svg{color:var(--ok);flex:none}
.bws-sum span{min-width:0}
browser-session.jsHTML · 103 lines
DM.def('browser-session', (r, c) => {
r.innerHTML = r.innerHTML.replace('{pause}', I('pause')).replace('{search}', I('search','xs'));
const root = $('.bws', r), stEl = $('.bws-st', r), vp = $('.bws-vp', r), url = $('.bws-url', r), list = $('.bws-steps', r);
const pauseB = $('.bws-pause', r), takeB = $('.bws-take', r);
// the steps the agent planned, and the pages it passes through, drawn small: a search, the site, its pricing page
const S = [{ t:'Search "northwind pricing"' }, { t:'Open northwind.app' }, { t:'Click "Pricing"' }, { t:'Read the plans table' }, { t:'Extract prices' }];
const NAV = on => `<div class="bws-nav"><b>Northwind</b><span>Product</span><span data-k="pricing"${on ? ' class="on"' : ''}>Pricing</span><span>Docs</span></div>`;
const PAGES = {
search:`<div class="bws-pg"><div class="bws-q">${I('search','xs')}<span>northwind pricing</span></div>
<div class="bws-res"><b data-k="site">Northwind · Docs your team reads</b><span>northwind.app</span></div>
<div class="bws-res"><b>Northwind review: is it worth it?</b><span>softwarenotes.io</span></div>
<div class="bws-res"><b>Northwind alternatives</b><span>stackcompare.com</span></div></div>`,
home:`<div class="bws-pg">${NAV(false)}<div class="bws-hd">Docs your team actually reads</div><span class="bws-ln" style="width:92%"></span><span class="bws-ln" style="width:68%"></span><span class="bws-cta">Start free</span></div>`,
pricing:`<div class="bws-pg">${NAV(true)}<div class="bws-hd">Plans and pricing</div><span class="bws-ln" style="width:78%"></span>
<div class="bws-tb"><div><span>Plan</span><span>Per seat, monthly</span></div><div><span>Starter</span><span class="num">$8</span></div><div><span>Team</span><span class="num">$16</span></div><div><span>Business</span><span class="num">$32</span></div></div><span class="bws-ln" style="width:64%"></span><span class="bws-ln" style="width:40%"></span></div>`
};
const G = { queued:'<span class="dot"></span>', run:'<span class="spin"></span>', wait:I('alert','xs'), done:I('ok','xs') };
S.forEach(x => { x.el = html(`<li class="bws-s queued"><span class="g">${G.queued}</span><span class="bws-t"><span>${x.t}</span><span class="d" hidden></span></span></li>`); list.appendChild(x.el); });
function step(i, s, d){
const x = S[i], de = $('.d', x.el); if (x.s === s && de.textContent === (d || '')) return;
x.s = s; x.el.className = 'bws-s ' + s; const g = $('.g', x.el); g.innerHTML = G[s]; enter(g);
if (s === 'run' || s === 'wait') x.el.setAttribute('aria-current', 'step'); else x.el.removeAttribute('aria-current');
if (!d){ de.hidden = true; de.textContent = ''; } else if (de.hidden){ de.textContent = d; de.hidden = false; enter(de); } else swap(de, d);
}
// the page's cookie banner waits below the fold of the viewport, and the agent's own pointer above it
const ck = html('<div class="bws-ck"><span>This site uses cookies</span><button type="button">Reject all</button><button type="button">Accept all</button></div>');
const cur = html('<span class="bws-cur" aria-hidden="true"><svg viewBox="0 0 14 14"><path d="M2 1.5l9.5 5-4.2 1.2-2 4.3z"/></svg></span>');
vp.append(ck, cur);
function page(k, u){
const old = $('.bws-pg', vp), pg = html(PAGES[k]); vp.insertBefore(pg, ck); if (old) old.remove(); enter(pg);
$('svg', url).outerHTML = I(k === 'search' ? 'search' : 'lock', 'xs'); swap($('span', url), u);
}
// the person can pause the agent or take the page over; either holds it wherever it is, and it waits for them when
// the page blocks it
let paused = false, you = false, blocked = false, finished = false, secs = 0, unblock = null;
const held = () => paused || you;
async function act(ms){ for (let left = ms; left > 0;){ await c.wait(100); if (!held()) left -= 100; } }
async function aim(el){
const v = vp.getBoundingClientRect(), b = el.getBoundingClientRect(), x = b.left - v.left + Math.min(b.width * .5, 36), y = b.top - v.top + b.height * .6;
if (!cur.classList.contains('on')){ cur.style.transition = 'none'; cur.style.transform = `translate(${x + 30}px,${y + 40}px)`; void cur.offsetWidth; cur.style.transition = ''; cur.classList.add('on'); }
cur.style.transform = `translate(${x}px,${y}px)`; el.classList.add('aim');
await act(800); cur.classList.add('down'); await act(200); cur.classList.remove('down'); el.classList.remove('aim');
}
const clock = () => Math.floor(secs / 60) + ':' + String(secs % 60).padStart(2, '0');
stEl.innerHTML = '<span class="m">Browsing</span> · <span class="num ck">0:00</span>';
c.every(1000, () => { if (!held() && !blocked && !finished){ secs++; $('.ck', stEl).textContent = clock(); } });
function update(){
root.classList.toggle('you', you); root.classList.toggle('paused', paused && !you);
swap($('.m', stEl), finished ? 'Done' : you ? 'You have control' : paused ? 'Paused' : blocked ? 'Needs you' : 'Browsing');
const pl = paused ? 'Resume' : 'Pause'; pauseB.innerHTML = I(paused ? 'play' : 'pause'); pauseB.setAttribute('aria-label', pl); pauseB.dataset.tip = pl;
pauseB.disabled = you || finished; takeB.disabled = finished;
takeB.setAttribute('aria-pressed', you); takeB.textContent = you ? 'Hand back' : 'Take over';
// while the person has the page, its buttons are theirs and the agent's pointer steps aside
vp.inert = !you; cur.style.visibility = you ? 'hidden' : '';
const ask = $('.bws-ask', r);
if (blocked && !you && !ask) showAsk(); else if ((!blocked || you) && ask) ask.remove();
if (blocked) step(3, 'wait', you ? 'Close the banner, then hand back' : 'A cookie banner covers the table');
}
function showAsk(){
const ask = html('<div class="bws-ask"><button class="pill primary sm bws-allow">Allow closing it</button><button class="pill soft sm bws-you">Take over</button></div>');
$('.bws-t', S[3].el).appendChild(ask); enter(ask);
$('.bws-allow', ask).onclick = () => c.run(async () => {
// the agent closes it the privacy-friendly way, with Reject all
blocked = false; update(); step(3, 'run', 'Closing the banner');
await aim($('button', ck)); ck.classList.remove('on'); unblock();
});
$('.bws-you', ask).onclick = () => takeB.click();
}
pauseB.onclick = () => { paused = !paused; update(); };
takeB.onclick = () => { you = !you; if (you) paused = false; update(); };
// with the page in hand, either banner button closes it
$$('button', ck).forEach(b => b.onclick = () => { if (!you || !blocked) return; ck.classList.remove('on'); blocked = false; update(); unblock(); });
// the run: search, open the site, go to pricing and scroll to the table, where a cookie banner covers it and the agent
// asks before touching it. Once it is gone, it reads the table and takes the three prices
c.run(async () => {
page('search', 'northwind pricing'); step(0, 'run');
await act(1800); step(0, 'done'); step(1, 'run');
await aim($('[data-k="site"]', vp)); page('home', 'northwind.app');
await act(1200); step(1, 'done'); step(2, 'run');
await aim($('[data-k="pricing"]', vp)); page('pricing', 'northwind.app/pricing');
await act(1000); step(2, 'done'); step(3, 'run');
await act(600);
const pg = $('.bws-pg', vp); pg.style.transform = `translateY(${-Math.max(0, Math.min(pg.offsetHeight - vp.clientHeight, $('.bws-tb', pg).offsetTop - 8))}px)`;
await act(1200); ck.classList.add('on'); await act(500);
blocked = true; update();
await new Promise(res => { unblock = res; });
step(3, 'run');
await act(1400); step(3, 'done', '3 plans, priced per seat'); step(4, 'run', '0 of 3 prices');
const cells = $$('.bws-tb .num', vp);
for (let i = 0; i < cells.length; i++){ await aim(cells[i]); cells[i].classList.add('hit'); step(4, 'run', `${i + 1} of 3 prices`); }
await act(500); cur.classList.remove('on');
finished = true; step(4, 'done', '$8, $16 and $32 per seat'); update();
const sm = html(`<div class="bws-sum">${I('ok','xs')}<span>Done · 3 prices saved to Pricing research</span></div>`); root.appendChild(sm); enter(sm);
});
update();
// the preview's story: the agent stops at the cookie banner, the person allows it to close it, and it finishes
c.story(async s => {
await s.until(() => $('.bws-allow', r)); await s.beat(3);
await s.tap($('.bws-allow', r)); s.away();
await s.until(() => finished); await s.beat(3);
});
});
Questions
What people ask about it.
- Does the component run a browser?
- No. It shows one. Pass the address, a picture or live view of the page, the agent's pointer and its steps from your own agent, and handle Pause, Take over and the ask in your code.
- What happens when the person takes over?
onInControlChangeis called withtrue, the page becomes usable and the agent's pointer hides. Hold your agent until it is called withfalse, when the person presses Hand back.- How did the demo close the cookie banner?
- Once the person allowed it, the agent pressed Reject all, so it never agreed to cookies for them. With Take over the person can close it with either button.
- Can I show a real screenshot instead of the wireframe?
- Yes. Pass an image or a video as
children. The page area is 156px tall and clips what doesn't fit.
More in Agent status
Show what the agent is doing, how far along it is, and what it is working with.