Agent statusWeb search
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 callWeb 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add web-searchReact
npx designme add web-search --reactAstro
npx designme add web-search --astroThe 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.
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.
- A header that says what is happening (Searching for “…”, Reading 4 pages) and, once done, sums it up; it opens and folds the trail.
- Query rows with the query, the result count (No results in the warning colour) and the time; each opens to its pages.
- A query asked again in other words is marked "Asked again in other words" under it, right after the one that found nothing.
- Page rows with the site's initial, the title, the site and date, and a tag: Reading (with a spinner), Read, Skimmed, or Skipped with the reason as a warning tag.
- A stack of up to four site initials in the header, with the rest counted (+3).
- Citations in the answer ([1], [2]) are buttons: pointing at one or tabbing to it lights the page it came from (or its search, when that is folded) and its site's initial, and its tooltip names the page.
- Page titles cut short with an ellipsis show in full in a tooltip on hover and on keyboard focus.
- The header and each query are buttons with aria-expanded; a closed trail or query is inert and hidden from screen readers.
- Each page row can take the focus and has a label with its title, site, date and status, like "Anyone switched from Stripe Billing to Orb?, reddit.com, May 2026: Skipped: blocked".
- Citations have labels that name their source, like "Source 2: Metronome vs Orb: a fair comparison, metronome.com".
- A role="status" region announces only changes of phase (each new search, then Done), never every page.
- Statuses are words, not only colours.
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" />Props
Everything WebSearch takes.
| Prop | Type | What it does |
|---|---|---|
queriesrequired | WebQuery[] | |
done | boolean | True once the searching and reading are over: the trail folds to its summary |
answer | string | The answer, with sources cited as [1], [2]. Each number points at the page with that `cite` |
open | boolean | Whether the trail is open. Left to the component, it folds once `done` turns true |
onOpenChange | (open: boolean) => void | |
bare | boolean | Without the panel, for a chat stream (true) |
className | string |
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);
});
});
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] inanswer. Each number becomes a button tied to the page with thatcite. - Can I keep the trail open when it is done?
- Yes. Control
openandonOpenChange. Left to itself, the trail folds oncedoneturns 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.
More in Agent status
Show what the agent is doing, how far along it is, and what it is working with.