Skip to content
Star on GitHub
Agent statusWeb search
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises, from Astro sites to React apps and AI products.

Book a call

Web search

The agent's searches as a trail: what it asked, what came back, what it read or skipped, and the answer with its sources. Free and MIT licensed, as HTML, a typed React component or an Astro component, in two styles with light and dark mode.

$npx designme add web-search

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add web-search

React

$npx designme add web-search --react

Astro

$npx designme add web-search --astro

The 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 islands. The Astro version lands in src/components/designme too, with the same markup, CSS and script as the HTML version. See Astro.

shadcn CLI, React

$npx shadcn@latest add https://designme.dev/r/shadcn/web-search.json

shadcn CLI, Astro

$npx shadcn@latest add https://designme.dev/r/shadcn/astro/web-search.json

In a project set up for shadcn/ui, its CLI adds the same files to your components folder, under designme/, with the shared core. To give the components your shadcn or Tailwind v4 theme, add the theme bridge.

02

About

What it is for and what it does.

The agent's web searches, shown as it makes them: each query with how many results came back and how long it took, the pages it opened under it with whether it read, skimmed or skipped each one and why (a paywall, a block, a timeout), and the sites it went to as a small stack of initials. A search that finds nothing is asked again in other words, right under it. Once done, the trail folds to one line, "3 searches, 6 pages read", and the answer streams in under it with numbered citations that light the pages they came from.

  • When an agent searches the web before it answers, and people want to see what it looked at.
  • When some pages couldn't be read and the answer should say so rather than hide it.
  • In a chat stream, above the answer the searches led to.
03

Usage

Import it in React and drive it from your own state, or place it in an Astro page.

React

import { WebSearch } from '@/components/designme/web-search/WebSearch';

React example

The demo above as a React component, 42 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: two searches (the first finds nothing and is asked
// again), six pages read and one blocked, then the answer streams in with its sources.
import { useEffect, useState } from 'react';
import { WebSearch, type WebPage, type WebQuery } from './WebSearch';
import { useWait } from '../lib/useScript';

const PLAN: (Omit<WebQuery, 'status' | 'pages'> & { pages?: WebPage[] })[] = [
  { key: 'a', query: 'stripe usage based billing limits 2026 enterprise tier', results: 0, time: '0.6s' },
  { key: 'b', query: 'stripe usage-based billing limits', results: 5, time: '0.9s', rewritten: true, pages: [
    { title: 'Usage-based billing', domain: 'stripe.com', date: 'Oct 2026', status: 'read' }, { title: 'Meters and billing thresholds', domain: 'stripe.com', date: 'Sep 2026', status: 'read', cite: 1 },
    { title: 'How we moved 40k customers to usage billing', domain: 'medium.com', date: 'Mar 2026', status: 'skimmed' }, { title: 'Why SaaS pricing is moving to usage', domain: 'ft.com', date: 'Aug 2026', status: 'paywall' }] },
  { key: 'c', query: 'metronome vs orb usage billing', results: 6, time: '1.1s', pages: [
    { title: 'Metronome vs Orb: a fair comparison', domain: 'metronome.com', date: 'Jul 2026', status: 'read', cite: 2 }, { title: 'Orb pricing and plans', domain: 'withorb.com', date: 'Oct 2026', status: 'read' },
    { title: 'orb-billing/examples: sending usage events', domain: 'github.com', date: 'Jun 2026', status: 'read' }, { title: 'Anyone switched from Stripe Billing to Orb?', domain: 'reddit.com', date: 'May 2026', status: 'blocked' }] }
];
const ANSWER = 'Stripe caps metered usage at 100 million events a month for each meter [1]. Metronome and Orb both take events in bulk with no monthly cap [2]. At your volume Stripe still fits, with room to grow.';

export default function WebSearchDemo() {
  const wait = useWait();
  const [queries, setQueries] = useState<WebQuery[]>([]);
  const [done, setDone] = useState(false);
  const [answer, setAnswer] = useState('');
  // the agent at work: nothing here waits on the visitor, so it carries on under the pointer
  useEffect(() => { void (async () => {
    await wait(500);
    for (const q of PLAN) {
      setQueries(qs => [...qs, { ...q, status: 'searching', pages: undefined }]); await wait(1100);
      setQueries(qs => qs.map(x => x.key === q.key ? { ...x, status: 'done' } : x));
      for (const [i, p] of (q.pages || []).entries()) {
        setQueries(qs => qs.map(x => x.key === q.key ? { ...x, pages: [...(x.pages || []), { ...p, status: 'reading' }] } : x)); await wait(420);
        setQueries(qs => qs.map(x => x.key === q.key ? { ...x, pages: x.pages!.map((y, j) => j === i ? p : y) } : x));
      }
      await wait(500);
    }
    setDone(true); await wait(400);
    const ws = ANSWER.split(' ');
    for (let i = 1; i <= ws.length; i++) { setAnswer(ws.slice(0, i).join(' ')); await wait(50); }
  })(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
  return <div style={{ width: '100%' }}><WebSearch queries={queries} done={done} answer={answer} /></div>;
}

Astro

In a page in src/pages. It plays the demo above; change its web-search.js to show your own data. Attributes like class go on its root element.

---
import WebSearch from '../components/designme/web-search/WebSearch.astro';
---
<WebSearch />
<WebSearch data-style="soft" class="my-class" />
04

Props

Everything WebSearch takes.

PropTypeWhat it does
queriesrequiredWebQuery[]
donebooleanTrue once the searching and reading are over: the trail folds to its summary
answerstringThe answer, with sources cited as [1], [2]. Each number points at the page with that `cite`
openbooleanWhether the trail is open. Left to the component, it folds once `done` turns true
onOpenChange(open: boolean) => void
barebooleanWithout the panel, for a chat stream (true)
classNamestring
05

Code

The full source, as the CLI installs it.

WebSearch.tsxReact · 116 lines
'use client';
import { Fragment, useEffect, useRef, useState } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Fold, Spin, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './web-search.css';

export interface WebPage {
  title: string;
  /** Like "stripe.com" */
  domain: string;
  /** Like "Oct 2026" */
  date?: string;
  /** reading while the agent opens it; the skipped ones say why */
  status: 'reading' | 'read' | 'skimmed' | 'paywall' | 'blocked' | 'timeout';
  /** The number the answer cites it by, as [1] */
  cite?: number;
}

export interface WebQuery {
  key: string;
  query: string;
  /** searching shimmers; done shows the count and the time */
  status: 'searching' | 'done';
  /** How many results came back; 0 says No results */
  results?: number;
  /** Like "0.9s" */
  time?: string;
  /** This query asks the one before it again in other words */
  rewritten?: boolean;
  pages?: WebPage[];
}

export interface WebSearchProps {
  queries: WebQuery[];
  /** True once the searching and reading are over: the trail folds to its summary */
  done?: boolean;
  /** The answer, with sources cited as [1], [2]. Each number points at the page with that `cite` */
  answer?: string;
  /** Whether the trail is open. Left to the component, it folds once `done` turns true */
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Without the panel, for a chat stream (true) */
  bare?: boolean;
  className?: string;
}

const STATUS: Record<WebPage['status'], [string, string]> = { reading: ['Reading', ''], read: ['Read', ''], skimmed: ['Skimmed', ''], paywall: ['Skipped: paywall', 'dm-warn'], blocked: ['Skipped: blocked', 'dm-warn'], timeout: ['Skipped: timeout', 'dm-warn'] };

/** The agent's web searches as a trail: each query and what came back, each page read, skimmed or skipped and why, a summary once done, and an answer whose citations light their pages. */
export function WebSearch({ queries, done = false, answer, open, onOpenChange, bare = true, className }: WebSearchProps) {
  const [inner, setInner] = useState(true);
  useEffect(() => { if (done && open === undefined) setInner(false); }, [done]); // eslint-disable-line react-hooks/exhaustive-deps
  const isOpen = open ?? inner;
  const setOpen = (o: boolean) => { if (open === undefined) setInner(o); onOpenChange?.(o); };
  // each query's pages are open while it is being read, and fold once it is done; a person can open any of them
  const [qOpen, setQOpen] = useControllable<Record<string, boolean>>(undefined, {});
  const [hot, setHot] = useState<number | null>(null);
  const pages = queries.flatMap(q => (q.pages || []).map(p => ({ ...p, q: q.key })));
  const sites = [...new Set(pages.map(p => p.domain))];
  const read = pages.filter(p => p.status === 'read' || p.status === 'skimmed').length;
  const current = queries.find(q => q.status === 'searching'), reading = pages.filter(p => p.status === 'reading').length;
  const line = done ? `${queries.length} searches, ${read} pages read` : current ? `Searching for “${current.query}”` : reading ? `Reading ${reading} pages` : 'Searching the web';
  const cited = (n: number) => pages.find(p => p.cite === n);
  // only changes of phase are announced
  const [said, setSaid] = useState('');
  const last = useRef('');
  useEffect(() => { const t = done ? `Done: ${line}` : current ? `Searching for ${current.query}` : ''; if (t && t !== last.current) { last.current = t; setSaid(t); } }, [done, current?.key]); // eslint-disable-line react-hooks/exhaustive-deps
  const hotPage = hot != null ? cited(hot) : undefined;
  return (
    <div data-dm="" className={cx('dm-wsx', !bare && 'dm-panel', className)} style={bare ? { paddingInline: 0 } : undefined}>
      <button type="button" className="dm-wsx-h" aria-expanded={isOpen} onClick={() => setOpen(!isOpen)}>
        <span className="dm-wsx-ic">{done ? <Icon name="globe" size="xs" /> : <Spin />}</span>
        <Swap className={cx('dm-wsx-st', !done && 'dm-shim')}>{line}</Swap>
        <span className="dm-wsx-marks" aria-hidden="true">{sites.slice(0, 4).map(s => <span key={s} className={cx(hotPage?.domain === s && 'dm-hl')}>{s[0].toUpperCase()}</span>)}{sites.length > 4 && <span className="dm-n">+{sites.length - 4}</span>}</span>
        <Icon name="chev-d" size="xs" />
      </button>
      <Fold open={isOpen}>
        <div className="dm-wsx-list">
          {queries.map(q => {
            const has = !!q.pages?.length, auto = q.pages?.some(p => p.status === 'reading') ?? false, o = has && (qOpen[q.key] ?? auto);
            const lit = hotPage && hotPage.q === q.key;
            return (
              <div key={q.key} className={cx('dm-wsx-q', lit && !o && 'dm-hl', 'dm-enter')}>
                <button type="button" className={cx('dm-wsx-qh', !has && 'dm-empty')} aria-expanded={o} onClick={() => has && setQOpen({ ...qOpen, [q.key]: !o })}
                  aria-label={q.status === 'done' ? `${q.query}: ${q.results ? q.results + ' results' : 'no results'}${q.time ? ', ' + q.time : ''}` : undefined}>
                  <Icon name="search" size="xs" /><span className={cx('dm-t', q.status === 'searching' && 'dm-shim')}>{q.query}</span>
                  <span className={cx('dm-n', q.status === 'done' && !q.results && 'dm-none')}>{q.status === 'searching' ? 'Searching' : q.results ? `${q.results} results` : 'No results'}</span>
                  <span className="dm-tm dm-num">{q.status === 'done' ? q.time : ''}</span><Icon name="chev-r" size="xs" />
                </button>
                {q.rewritten && <span className="dm-wsx-rw"><Icon name="refresh" size="xs" />Asked again in other words</span>}
                {has && <Fold open={o}><ul className="dm-wsx-rs">{q.pages!.map(p => (
                  <Tip key={p.title} label={`${p.title} · ${p.domain}`}>
                    <li tabIndex={0} className={cx('dm-wsx-r', 'dm-enter', p.status !== 'read' && p.status !== 'skimmed' && p.status !== 'reading' && 'dm-skip', lit && hotPage?.title === p.title && 'dm-hl')}
                      aria-label={`${p.title}, ${p.domain}${p.date ? ', ' + p.date : ''}: ${STATUS[p.status][0]}`}>
                      <span className="dm-wsx-fav" aria-hidden="true">{p.domain[0].toUpperCase()}</span>
                      <span className="dm-tx"><span className="dm-t">{p.title}</span><span className="dm-d">{p.domain}{p.date ? ' · ' + p.date : ''}</span></span>
                      <span className={cx('dm-tag', STATUS[p.status][1])}>{p.status === 'reading' && <Spin />}{STATUS[p.status][0]}</span>
                    </li>
                  </Tip>))}</ul></Fold>}
              </div>
            );
          })}
        </div>
      </Fold>
      {answer && <div className="dm-wsx-ans">{answer.split(/(\[\d+\])/).map((part, i) => { const m = part.match(/^\[(\d+)\]$/); if (!m) return <Fragment key={i}>{part}</Fragment>;
        const n = +m[1], p = cited(n);
        return <Tip key={i} label={p ? `${p.title} · ${p.domain}` : `Source ${n}`}><button type="button" className={cx('dm-wsx-cite', hot === n && 'dm-on')} aria-label={p ? `Source ${n}: ${p.title}, ${p.domain}` : `Source ${n}`}
          onPointerEnter={() => setHot(n)} onPointerLeave={() => setHot(null)} onFocus={() => setHot(n)} onBlur={() => setHot(null)}>{n}</button></Tip>; })}</div>}
      <p className="dm-wsx-sr" role="status">{said}</p>
    </div>
  );
}
web-search.cssReact · 48 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Web search ---------- */
/* the agent's searches as a trail: each query with what came back, each page with whether it was read, skimmed or skipped and
   why; once done it folds to one line, and the answer under it points back at its pages */
.dm-wsx{padding:6px;display:flex;flex-direction:column;font-size:13px}
.dm-wsx-h{display:flex;align-items:center;gap:10px;width:100%;padding:var(--dm-s-2) 10px;border-radius:var(--dm-r-in);text-align:left;font-size:13.5px;transition:background var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-wsx-h:hover{background:var(--dm-row)}}
.dm-wsx-ic{width:16px;height:16px;flex:none;display:grid;place-items:center;color:var(--dm-fg2)}
.dm-wsx-ic .dm-spin{width:11px;height:11px}
.dm-wsx-st{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-wsx-h>.dm-i{color:var(--dm-muted);transition:transform .32s var(--dm-ease)}
.dm-wsx-h[aria-expanded="false"]>.dm-i{transform:rotate(-90deg)}
/* the sites it went to, as a small stack of initials, the rest counted */
.dm-wsx-marks{display:flex;align-items:center}
.dm-wsx-marks span{width:20px;height:20px;border-radius:50%;display:grid;place-items:center;margin-left:-5px;background:var(--dm-chip);color:var(--dm-fg2);font-size:10.5px;font-weight:500;box-shadow:0 0 0 2px var(--dm-surface);transition:background .2s,color .2s}
.dm-wsx-marks span.dm-n{background:var(--dm-surface);color:var(--dm-muted);box-shadow:0 0 0 2px var(--dm-surface),inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:10px}
.dm-wsx-marks span.dm-hl{background:var(--dm-fg2);color:var(--dm-surface)}
.dm-wsx-list{display:flex;flex-direction:column;gap:2px;padding:2px 0 var(--dm-s-1)}
.dm-wsx-q{display:flex;flex-direction:column}
.dm-wsx-qh{display:grid;grid-template-columns:16px minmax(0,1fr) auto auto 12px;align-items:center;column-gap:var(--dm-s-2);width:100%;padding:6px 10px;border-radius:calc(10px * var(--dm-rk));text-align:left;transition:background var(--dm-t-fast)}
.dm-wsx-q.dm-hl>.dm-wsx-qh{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.dm-wsx-qh:hover{background:var(--dm-row)}}
.dm-wsx-qh>.dm-i:first-child{color:var(--dm-muted)}
.dm-wsx-qh .dm-t{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--dm-fg)}
.dm-wsx-qh .dm-n,.dm-wsx-qh .dm-tm{font-size:12px;color:var(--dm-muted);white-space:nowrap}
.dm-wsx-qh .dm-n.dm-none{color:var(--dm-warn)}
.dm-wsx-qh>.dm-i:last-child{color:var(--dm-muted);transition:transform .32s var(--dm-ease)}
.dm-wsx-qh[aria-expanded="true"]>.dm-i:last-child{transform:rotate(90deg)}
.dm-wsx-qh[aria-expanded="false"].dm-empty>.dm-i:last-child{visibility:hidden}
.dm-wsx-rw{display:flex;align-items:center;gap:6px;padding:0 10px 2px 34px;font-size:12px;color:var(--dm-muted)}
.dm-wsx-rs{list-style:none;margin:0;padding:2px 0 var(--dm-s-1) 26px;display:flex;flex-direction:column;gap:2px}
.dm-wsx-r{display:grid;grid-template-columns:20px minmax(0,1fr) auto;column-gap:var(--dm-s-2);align-items:center;padding:5px var(--dm-s-2);border-radius:calc(9px * var(--dm-rk));transition:background var(--dm-t-fast);cursor:default}
.dm-wsx-r.dm-hl{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.dm-wsx-r:hover{background:var(--dm-row)}}
.dm-wsx-r:focus-visible{outline-offset:-2px}
.dm-wsx-fav{width:20px;height:20px;border-radius:calc(6px * var(--dm-rk));display:grid;place-items:center;background:var(--dm-chip);color:var(--dm-fg2);font-size:10.5px;font-weight:500}
.dm-wsx-r .dm-tx{display:flex;flex-direction:column;min-width:0;line-height:1.35}
.dm-wsx-r .dm-t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12.5px;color:var(--dm-fg)}
.dm-wsx-r .dm-d{font-size:11.5px;color:var(--dm-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-wsx-r.dm-skip .dm-t{color:var(--dm-muted)}
.dm-wsx-r .dm-tag .dm-spin{width:9px;height:9px}
.dm-wsx-ans{padding:var(--dm-s-2) 10px 6px;font-size:13.5px;line-height:1.6;color:var(--dm-fg)}
.dm-wsx-ans:empty{display:none}
.dm-wsx-cite{display:inline-grid;place-items:center;min-width:18px;height:17px;padding:0 4px;margin:0 1px;border-radius:calc(5px * var(--dm-rk));background:var(--dm-chip);color:var(--dm-fg2);font-size:11px;font-weight:500;vertical-align:1px;transition:background var(--dm-t-fast),color var(--dm-t-fast)}
.dm-wsx-cite.dm-on{background:var(--dm-fg2);color:var(--dm-surface)}
@media (hover:hover) and (pointer:fine){.dm-wsx-cite:hover{background:var(--dm-fg2);color:var(--dm-surface)}}
.dm-wsx-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
WebSearch.astroAstro · 29 lines
---
// Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync.
// Web search: The agent's searches as a trail: what it asked, what came back, what it read or skipped, and the answer with its sources.
// The markup, CSS and script are designme.dev's own, run by the shared core in ../core, which mounts the component when it scrolls
// into view and plays its demo. To show your own data, change web-search.js. The markup is kept exactly as it is (is:raw).
import type { HTMLAttributes } from 'astro/types';
import '../core/designme.css';
import './web-search.css';

export interface Props extends HTMLAttributes<'div'> {
  /** "soft" draws it in the Soft style. Leave it out for Blueprint, or for the style set on <html> (data-style="soft") */
  'data-style'?: 'soft';
}
const { class: className, ...rest } = Astro.props;
---
<div class:list={['card', 'bare', className]} data-c="web-search" {...rest}>
  <div class="stage-in"><Fragment is:raw>
<div class="wsx panel">
  <button class="wsx-h" aria-expanded="true"><span class="wsx-ic"><span class="spin"></span></span><span class="wsx-st shim">Searching the web</span><span class="wsx-marks" aria-hidden="true"></span><svg class="i xs"><use href="#i-chev-d"/></svg></button>
  <div class="fold wsx-fold"><div><div class="wsx-list"></div></div></div>
  <div class="wsx-ans"></div>
  <p class="wsx-sr" role="status"></p>
</div>
  </Fragment></div>
</div>
<script>
  import './web-search.js';
</script>
web-search.htmlHTML · 11 lines
<div class="card bare" data-c="web-search">
  <div class="stage-in">
    <div class="wsx panel">
      <button class="wsx-h" aria-expanded="true"><span class="wsx-ic"><span class="spin"></span></span><span class="wsx-st shim">Searching the web</span><span class="wsx-marks" aria-hidden="true"></span><svg class="i xs"><use href="#i-chev-d"/></svg></button>
      <div class="fold wsx-fold"><div><div class="wsx-list"></div></div></div>
      <div class="wsx-ans"></div>
      <p class="wsx-sr" role="status"></p>
    </div>
  </div>
</div>
web-search.cssHTML · 47 lines
/* ---------- Web search ---------- */
/* the agent's searches as a trail: each query with what came back, each page with whether it was read, skimmed or skipped and
   why; once done it folds to one line, and the answer under it points back at its pages */
.wsx{padding:6px;display:flex;flex-direction:column;font-size:13px}
.wsx-h{display:flex;align-items:center;gap:10px;width:100%;padding:var(--dm-s-2) 10px;border-radius:var(--dm-r-in);text-align:left;font-size:13.5px;transition:background var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.wsx-h:hover{background:var(--dm-row)}}
.wsx-ic{width:16px;height:16px;flex:none;display:grid;place-items:center;color:var(--dm-fg2)}
.wsx-ic .spin{width:11px;height:11px}
.wsx-st{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.wsx-h>.i{color:var(--dm-muted);transition:transform .32s var(--dm-ease)}
.wsx-h[aria-expanded="false"]>.i{transform:rotate(-90deg)}
/* the sites it went to, as a small stack of initials, the rest counted */
.wsx-marks{display:flex;align-items:center}
.wsx-marks span{width:20px;height:20px;border-radius:50%;display:grid;place-items:center;margin-left:-5px;background:var(--dm-chip);color:var(--dm-fg2);font-size:10.5px;font-weight:500;box-shadow:0 0 0 2px var(--dm-surface);transition:background .2s,color .2s}
.wsx-marks span.n{background:var(--dm-surface);color:var(--dm-muted);box-shadow:0 0 0 2px var(--dm-surface),inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:10px}
.wsx-marks span.hl{background:var(--dm-fg2);color:var(--dm-surface)}
.wsx-list{display:flex;flex-direction:column;gap:2px;padding:2px 0 var(--dm-s-1)}
.wsx-q{display:flex;flex-direction:column}
.wsx-qh{display:grid;grid-template-columns:16px minmax(0,1fr) auto auto 12px;align-items:center;column-gap:var(--dm-s-2);width:100%;padding:6px 10px;border-radius:calc(10px * var(--dm-rk));text-align:left;transition:background var(--dm-t-fast)}
.wsx-q.hl>.wsx-qh{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.wsx-qh:hover{background:var(--dm-row)}}
.wsx-qh>.i:first-child{color:var(--dm-muted)}
.wsx-qh .t{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--dm-fg)}
.wsx-qh .n,.wsx-qh .tm{font-size:12px;color:var(--dm-muted);white-space:nowrap}
.wsx-qh .n.none{color:var(--dm-warn)}
.wsx-qh>.i:last-child{color:var(--dm-muted);transition:transform .32s var(--dm-ease)}
.wsx-qh[aria-expanded="true"]>.i:last-child{transform:rotate(90deg)}
.wsx-qh[aria-expanded="false"].empty>.i:last-child{visibility:hidden}
.wsx-rw{display:flex;align-items:center;gap:6px;padding:0 10px 2px 34px;font-size:12px;color:var(--dm-muted)}
.wsx-rs{list-style:none;margin:0;padding:2px 0 var(--dm-s-1) 26px;display:flex;flex-direction:column;gap:2px}
.wsx-r{display:grid;grid-template-columns:20px minmax(0,1fr) auto;column-gap:var(--dm-s-2);align-items:center;padding:5px var(--dm-s-2);border-radius:calc(9px * var(--dm-rk));transition:background var(--dm-t-fast);cursor:default}
.wsx-r.hl{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.wsx-r:hover{background:var(--dm-row)}}
.wsx-r:focus-visible{outline-offset:-2px}
.wsx-fav{width:20px;height:20px;border-radius:calc(6px * var(--dm-rk));display:grid;place-items:center;background:var(--dm-chip);color:var(--dm-fg2);font-size:10.5px;font-weight:500}
.wsx-r .tx{display:flex;flex-direction:column;min-width:0;line-height:1.35}
.wsx-r .t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12.5px;color:var(--dm-fg)}
.wsx-r .d{font-size:11.5px;color:var(--dm-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.wsx-r.skip .t{color:var(--dm-muted)}
.wsx-r .tag .spin{width:9px;height:9px}
.wsx-ans{padding:var(--dm-s-2) 10px 6px;font-size:13.5px;line-height:1.6;color:var(--dm-fg)}
.wsx-ans:empty{display:none}
.wsx-cite{display:inline-grid;place-items:center;min-width:18px;height:17px;padding:0 4px;margin:0 1px;border-radius:calc(5px * var(--dm-rk));background:var(--dm-chip);color:var(--dm-fg2);font-size:11px;font-weight:500;vertical-align:1px;transition:background var(--dm-t-fast),color var(--dm-t-fast)}
.wsx-cite.on{background:var(--dm-fg2);color:var(--dm-surface)}
@media (hover:hover) and (pointer:fine){.wsx-cite:hover{background:var(--dm-fg2);color:var(--dm-surface)}}
.wsx-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
web-search.jsHTML · 72 lines
DM.def('web-search', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{chev}', I('chev-d','xs'));
  // the searches: what was asked, how many results, how long it took, and the pages it opened. The first finds nothing and is
  // asked again in other words
  const Q = [
    { q:'stripe usage based billing limits 2026 enterprise tier', n:0, ms:'0.6s' },
    { q:'stripe usage-based billing limits', n:5, ms:'0.9s', from:0, p:[
      ['Usage-based billing', 'stripe.com', 'Oct 2026', 'read'], ['Meters and billing thresholds', 'stripe.com', 'Sep 2026', 'read', 1],
      ['How we moved 40k customers to usage billing', 'medium.com', 'Mar 2026', 'skim'], ['Why SaaS pricing is moving to usage', 'ft.com', 'Aug 2026', 'paywall'] ] },
    { q:'metronome vs orb usage billing', n:6, ms:'1.1s', p:[
      ['Metronome vs Orb: a fair comparison', 'metronome.com', 'Jul 2026', 'read', 2], ['Orb pricing and plans', 'withorb.com', 'Oct 2026', 'read'],
      ['orb-billing/examples: sending usage events', 'github.com', 'Jun 2026', 'read'], ['Anyone switched from Stripe Billing to Orb?', 'reddit.com', 'May 2026', 'blocked'] ] }
  ];
  const ST = { read:['Read', ''], skim:['Skimmed', ''], paywall:['Skipped: paywall', 'warn'], blocked:['Skipped: blocked', 'warn'], timeout:['Skipped: timeout', 'warn'] };
  const root = $('.wsx', r), head = $('.wsx-h', r), ic = $('.wsx-ic', r), st = $('.wsx-st', r), marks = $('.wsx-marks', r), fold = $('.wsx-fold', r), list = $('.wsx-list', r), ans = $('.wsx-ans', r), sr = $('.wsx-sr', r);
  const say = t => { sr.textContent = t; };
  const setOpen = o => { head.setAttribute('aria-expanded', o); fold.classList.toggle('shut', !o); };
  head.onclick = () => setOpen(head.getAttribute('aria-expanded') !== 'true');
  const sites = [];
  const drawMarks = () => { const u = [...new Set(sites)]; marks.innerHTML = u.slice(0, 4).map(s => `<span data-s="${s}">${s[0].toUpperCase()}</span>`).join('') + (u.length > 4 ? `<span class="n">+${u.length - 4}</span>` : ''); };
  const qOpen = (el, o) => { const b = $('.wsx-qh', el), f = $('.fold', el); if (!f) return; b.setAttribute('aria-expanded', o); f.classList.toggle('shut', !o); };
  const pages = {};
  let done = false;
  c.run(async () => {
    await c.wait(500);
    for (const [k, q] of Q.entries()){
      const el = html(`<div class="wsx-q" data-k="${k}"><button class="wsx-qh${q.n ? '' : ' empty'}" aria-expanded="false">${I('search','xs')}<span class="t shim">${q.q}</span><span class="n">Searching</span><span class="tm num"></span>${I('chev-r','xs')}</button>${q.from != null ? `<span class="wsx-rw">${I('refresh','xs')}Asked again in other words</span>` : ''}${q.p ? '<div class="fold shut"><div><ul class="wsx-rs"></ul></div></div>' : ''}</div>`);
      list.appendChild(el); enter(el); swap(st, `Searching for “${q.q}”`);
      const b = $('.wsx-qh', el); b.onclick = () => { if ($('.fold', el)) qOpen(el, b.getAttribute('aria-expanded') !== 'true'); };
      await c.wait(1100);
      $('.t', b).classList.remove('shim'); $('.tm', b).textContent = q.ms;
      const n = $('.n', b); n.textContent = q.n ? `${q.n} results` : 'No results'; n.classList.toggle('none', !q.n);
      b.setAttribute('aria-label', `${q.q}: ${q.n ? q.n + ' results' : 'no results'}, ${q.ms}`);
      say(q.n ? `${q.n} results for ${q.q}` : `No results for ${q.q}. Asking again in other words`);
      if (!q.p){ await c.wait(500); continue; }
      qOpen(el, true); const ul = $('.wsx-rs', el);
      swap(st, `Reading ${q.p.length} pages`);
      for (const [t, d, when, s, cite] of q.p){
        const li = html(`<li class="wsx-r" tabindex="0" data-tip="${t} · ${d}"><span class="wsx-fav" aria-hidden="true">${d[0].toUpperCase()}</span><span class="tx"><span class="t">${t}</span><span class="d">${d} · ${when}</span></span><span class="tag"><span class="spin"></span>Reading</span></li>`);
        ul.appendChild(li); enter(li); sites.push(d); drawMarks(); if (cite) pages[cite] = { li, q:el, d };
        await c.wait(420);
        const g = $('.tag', li); g.className = 'tag ' + ST[s][1]; g.textContent = ST[s][0]; li.classList.toggle('skip', s !== 'read' && s !== 'skim');
        li.setAttribute('aria-label', `${t}, ${d}, ${when}: ${ST[s][0]}`);
      }
      await c.wait(500); qOpen(el, false);
    }
    // done: the trail folds to one line, and the answer streams in under it, citing its pages
    done = true; ic.innerHTML = I('globe','xs'); st.classList.remove('shim');
    const read = Q.flatMap(q => q.p || []).filter(p => p[3] === 'read' || p[3] === 'skim').length;
    swap(st, `${Q.length} searches, ${read} pages read`); say(`Done: ${Q.length} searches, ${read} pages read`);
    await c.wait(400); hold(root); setOpen(false);
    const A = 'Stripe caps metered usage at 100 million events a month for each meter [1]. Metronome and Orb both take events in bulk with no monthly cap [2]. At your volume Stripe still fits, with room to grow.'.split(' ');
    for (let i = 1; i <= A.length; i++){ ans.innerHTML = A.slice(0, i).join(' ').replace(/\[(\d)\]/g, (m, k) => `<button class="wsx-cite" data-c="${k}" aria-label="Source ${k}: ${$('.t', pages[k].li).textContent}, ${pages[k].d}" data-tip="${$('.t', pages[k].li).textContent} · ${pages[k].d}">${k}</button>`); await c.wait(50); }

  });
  // a citation lights the page it came from (and the query that found it), and its site's mark
  const light = k => { $$('.wsx-r.hl, .wsx-q.hl', r).forEach(x => x.classList.remove('hl')); $$('.wsx-marks span', r).forEach(x => x.classList.remove('hl')); $$('.wsx-cite', r).forEach(x => x.classList.toggle('on', x.dataset.c === k)); if (!k) return;
    const p = pages[k]; const shown = !$('.fold', p.q).classList.contains('shut') && head.getAttribute('aria-expanded') === 'true'; (shown ? p.li : p.q).classList.add('hl'); const m = $(`.wsx-marks span[data-s="${p.d}"]`, r); if (m) m.classList.add('hl'); };
  c.on(ans, 'pointerover', e => { const b = e.target.closest('.wsx-cite'); if (b) light(b.dataset.c); });
  c.on(ans, 'pointerout', e => { const b = e.target.closest('.wsx-cite'); if (b && !b.contains(e.relatedTarget)) light(null); });
  c.on(ans, 'focusin', e => { const b = e.target.closest('.wsx-cite'); if (b) light(b.dataset.c); });
  c.on(ans, 'focusout', () => light(null));
  // the preview's story: open the trail again, open the search that found source 2, and point at [2]
  if (r === c.st) c.story(async s => {
    await s.until(() => done && $('.wsx-cite[data-c="2"]', r)); await s.beat(4);
    await s.tap(head); await s.beat(2);
    await s.tap($('.wsx-q[data-k="2"] .wsx-qh', r)); await s.beat(2);
    const b = $('.wsx-cite[data-c="2"]', r); await s.point(b); light('2'); await s.beat(4);
    light(null); s.away(); await s.beat(2);
  });
});
06

Questions

What people ask about it.

How do I link a citation to a page?
Give the page cite (1, 2…) and write [1], [2] in answer. Each number becomes a button tied to the page with that cite.
Can I keep the trail open when it is done?
Yes. Control open and onOpenChange. Left to itself, the trail folds once done turns true.
Does it run the searches?
No. It shows what your agent reports: pass the queries and pages as they arrive and update their status.
07

More in Agent status

Show what the agent is doing, how far along it is, and what it is working with.

ThinkingThe live trace, written as the agent works, then folded away.
Reasoning trailThe agent's plan, notes, sources and edits, after it thinks.
Agent lanesSeveral agents working in parallel, each with its own lane and hand-offs.
Agent treeAn orchestrator and its sub-agents, with cost rolled up per branch.
Agent inboxEvery run that needs you, is running or is done, in one list.
Trace viewOne agent run as a waterfall of spans, with latency and tokens.
Browser sessionThe agent browsing for you, step by step, with Pause and Take over.
Agent phasesOne line for which phase the agent is in, from thinking to done, that stops to ask you, says when it is slow, and folds to how long it took.
All 20 in Agent status →