Traces
-- Session replay an AI agent can interrogate — it reads the DOM at any moment, binary-searches - the timeline, and asks you to look when it cannot see. +
Traces
+ {/* + Short enough to sit at 900px without truncating, and hidden below `md` rather than clipped. + The sentence that used to be here — the one that explained what interrogating a replay means — + moved to `StageEmptyState`, where it has room and where it is actually wanted. `truncate` stays + as a guard so a future edit to this string cannot push the picker off the right edge. + */} ++ agent-interrogable session replay
-
- {LEGEND.map((item) => (
-
- - - {item.keys} - - {item.does} - - ))} -
-
+ {LEGEND.map((item) => (
+
-
+
+ {item.does} +
+ ))}
+
+ {/*
+ Names the control rather than decorating a heading: collapsed, this is one word in a crowded
+ header, and the glyph is what makes it findable at a glance. The word beside it is still the
+ accessible name, so the icon stays hidden from assistive tech.
+ */}
+
+ keys
+
+
+ {/*
+ `raised` rather than a heavier border to lift the popover off the header. Drop shadows are out,
+ so elevation here is carried by the surface token that exists for it.
+ */}
+ -
+ {LEGEND.map((item) => (
+
-
+
+ {item.does} +
+ ))}
+
Activity
+ /* + `panel` rather than the column's `base`. This is the only section that is a *record* rather than + something to act on, and after four hairline-separated slabs another hairline says nothing. A change + of ground does: everything above it is open work, everything on this surface already happened. + */ ++
Every action lands here as it happens, labelled with who took it — the agent seeking, bisecting and
annotating, and you marking, rejecting and answering. Anything the agent did can be undone from its own
line.
diff --git a/traces/src/components/agent/agent-lane.tsx b/traces/src/components/agent/agent-lane.tsx
index cbe306d..1f2f6dc 100644
--- a/traces/src/components/agent/agent-lane.tsx
+++ b/traces/src/components/agent/agent-lane.tsx
@@ -3,6 +3,7 @@
import { useState } from 'react'
import { AuthorBadge } from '@/components/ui/author-badge'
import { formatAgo, useWallClock } from '@/components/ui/use-clock'
+import { SectionHeading } from '@/components/ui/section-heading'
import { sessionActions, useSessionStore } from '@/lib/store/session'
import type { Task, TaskStatus } from '@/types/domain'
@@ -52,9 +53,9 @@ const EXAMPLES = [
]
const TREATMENTS: Record
Nothing queued. An agent calling claim_next_task waits here
until you add something, then takes it without being asked twice.
@@ -143,7 +144,7 @@ function EmptyLane({ onPick }: { onPick: (text: string) => void }) {
diff --git a/traces/src/components/agent/ask-human-visual-prompt.tsx b/traces/src/components/agent/ask-human-visual-prompt.tsx
index 6c84f27..b18568c 100644
--- a/traces/src/components/agent/ask-human-visual-prompt.tsx
+++ b/traces/src/components/agent/ask-human-visual-prompt.tsx
@@ -1,8 +1,10 @@
'use client'
+import { Eye } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { AuthorBadge } from '@/components/ui/author-badge'
import { formatSeconds } from '@/components/ui/format-time'
+import { SectionHeading } from '@/components/ui/section-heading'
import { useWallClock } from '@/components/ui/use-clock'
import { GATE_TIMEOUT_MS } from '@/lib/webmcp/blocking'
import { useSessionStore } from '@/lib/store/session'
@@ -92,19 +94,26 @@ export function AskHumanVisualPrompt() {
const timedOut = waitedMs > GATE_TIMEOUT_MS
return (
- {pendingAsk.question} {pendingAsk.question}
+
Answer on the player: put the playhead on the moment you mean, then pick one of the options over
the replay.
{pendingAsk.hintAtMs !== undefined
@@ -113,7 +122,7 @@ export function AskHumanVisualPrompt() {
+
The agent’s call has already returned — it waited {Math.round(GATE_TIMEOUT_MS / 1000)}s and got a
ticket back, so it is retrying rather than sitting still. Your answer still reaches it.
{resolved.question} {resolved.question}
+
{resolved.outcome}
+
The moment you marked is now a marker on the timeline, and the agent has the timestamp.
+
The agent was told you skipped it, rather than being left waiting.
{hypothesis.text}
+
No evidence attached — nothing on the timeline backs this one up yet.
+
Your wording, not the agent’s.
@@ -153,13 +156,14 @@ export function ReportDraft() {
+
{unverified === 1 ? 'One step is' : `${unverified} steps are`} marked unverified: no recorded
event in this session matches {unverified === 1 ? 'it' : 'them'}, so the agent inferred{' '}
{unverified === 1 ? 'it' : 'them'} rather than finding {unverified === 1 ? 'it' : 'them'}. Check{' '}
@@ -187,7 +191,7 @@ export function ReportDraft() {
{report.evidence.map((item, index) => (
+
+
+
{!decision.reached
? 'No agent was waiting on this draft, so there was nothing to answer. Anything you changed is still saved above.'
: decision.kind === 'approved'
@@ -255,14 +266,14 @@ export function ReportDraft() {
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
{text} {text}
+
+ {pendingAsk.question} {pendingAsk.question}
+
marking
- {formatSeconds(currentTime)}
+ {formatSeconds(currentTime)}
— click the timeline to mark a different moment
+
The agent suggested {formatSeconds(hintAtMs)}
{isAtHint ? (
', where the playhead is now.'
@@ -105,7 +108,7 @@ export function MarkPointOverlay() {
@@ -120,9 +123,9 @@ export function MarkPointOverlay() {
key={choice}
type="button"
onClick={() => answer(choice)}
- className="flex items-baseline gap-1 border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-200 hover:border-amber-400/60 hover:text-zinc-50"
+ className="flex items-baseline gap-1 border border-line bg-raised px-2 py-1 text-xs text-ink hover:border-warn/60 focus-visible:border-warn focus-visible:outline-none"
>
- {index + 1}
+ {index + 1}
{choice}
))}
@@ -135,7 +138,7 @@ export function MarkPointOverlay() {
type="button"
onClick={() => sessionActions().clearAsk()}
title="Close the question without answering. The agent is told you skipped it."
- className="ml-auto px-1 text-[10px] uppercase tracking-wide text-zinc-500 hover:text-zinc-200"
+ className="ml-auto px-1 text-[10px] uppercase tracking-wide text-muted hover:text-ink focus-visible:bg-raised focus-visible:text-ink focus-visible:outline-none"
>
skip
diff --git a/traces/src/components/player/player-controls.tsx b/traces/src/components/player/player-controls.tsx
index e426972..1bf1d74 100644
--- a/traces/src/components/player/player-controls.tsx
+++ b/traces/src/components/player/player-controls.tsx
@@ -1,5 +1,6 @@
'use client'
+import { Pause, Play } from 'lucide-react'
import { useEffect } from 'react'
import {
PLAYBACK_SPEEDS,
@@ -101,20 +102,35 @@ export function PlayerControls() {
}, [disabled, playback])
return (
-
{viewport.width}×{viewport.height} · {Math.round(scale * 100)}%
No recording loaded.
- Traces replays a recorded browser session and lets an agent interrogate it — read the DOM at any
- moment, binary-search the timeline, ask a human to look. Pick a sample from the header to start.
-
- Or record your own against bugbait — README, “Making your own
- recordings”.
- The replay engine did not start. {message}
+
+ {message}
The timeline and the tool surface are still live, but nothing can read the DOM until this is
fixed. Reload after loading a different recording.
+ It reads the DOM at any moment, binary-searches the timeline for where the page went wrong, and
+ asks you to look when it cannot see.
+
+ A recording is a stream of mutation events, and replaying it rebuilds the page as a live
+ document — so every node is really present and really queryable at whichever millisecond the
+ playhead is on. That is why the tools register on a page in your browser rather than behind an
+ API: nothing on a server holds this DOM.
+
+
+ The bar at the top of the window says which of those you are on, and whether the sixteen tools
+ registered. Everything below works without WebMCP; only the agent needs it.
+
+
The shared timeline appears here once a recording is loaded.
-
+
Everything you mark and everything the agent finds lands on this one axis, labelled by who
found it.
@@ -97,8 +97,19 @@ export function Timeline() {
}
return (
+ /*
+ * `overflow-x-clip` is the frame guard, not styling. Every layer here is positioned by percentage and
+ * several are centred on it — `EventTrack`'s hit target is 9px with `-translate-x-1/2`, so an event at
+ * the end of the recording puts 4.5px of a button past the right edge, and the ticks and the playhead
+ * each put 1px there. `` is `overflow-hidden`, but with `` visible that propagates to the
+ * viewport and leaves the *body* visible, so those pixels do not vanish: they make the document 725px
+ * wide at a 720px window. Nobody can scroll to them with a wheel, and tabbing onto the last event
+ * marker scrolls the whole frame sideways to reveal it, which is the one thing this page must never do.
+ * Measured with `race-condition` loaded at 720px. `clip` rather than `hidden` so the vertical axis can
+ * stay `visible` — `hidden` on one axis computes the other to `auto` and hands the timeline a scrollbar.
+ */
- {error.id} did not load: {error.message}.{' '}
-
- Samples live in traces/public/recordings/ — record one
- against bugbait if it is not there yet.
+ {DEFINITION}
+ {REACH} {tool.name}
+ {tool.summary}
+ Agent lane
+
- Agent needs your eyes
-
-
+
- {resolved.answered ? 'You answered the agent' : 'You skipped the agent’s question'}
-
+ Hypotheses
+
{hypotheses.map((hypothesis, index) => (
@@ -83,25 +84,39 @@ function HypothesisCard({ hypothesis, position }: { hypothesis: Hypothesis; posi
return (
@@ -205,7 +209,7 @@ export function ReportDraft() {
: 'Send the report back approved. This is what the agent’s call is waiting on.'
: 'No agent is waiting on this draft, but your edits are still recorded.'
}
- className="border border-sky-500/50 bg-sky-500/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-sky-200 hover:border-sky-400"
+ className="border border-human/50 bg-human/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-human hover:border-human focus-visible:border-ink focus-visible:outline-none"
>
{edited ? 'approve with edits' : 'approve'}
@@ -218,7 +222,7 @@ export function ReportDraft() {
type="button"
onClick={() => commit(false)}
title="Tell the agent the draft is not good enough. It is asked what to support better, not to resend it."
- className="border border-zinc-800 px-2 py-0.5 text-[10px] uppercase tracking-wide text-zinc-500 hover:border-zinc-600 hover:text-zinc-200"
+ className="border border-line px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted hover:border-faint hover:text-ink focus-visible:border-ink focus-visible:outline-none"
>
reject
@@ -227,20 +231,27 @@ export function ReportDraft() {
type="button"
onClick={copy}
title="Copy the report as Markdown, ready to paste into a tracker."
- className="ml-auto border border-zinc-800 px-2 py-0.5 text-[10px] uppercase tracking-wide text-zinc-400 hover:border-zinc-600 hover:text-zinc-100"
+ className="ml-auto flex items-center gap-1 border border-line px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted hover:border-faint hover:text-ink focus-visible:border-ink focus-visible:outline-none"
>
+ {/* The glyph is the state: `Check` only ever appears after a copy actually succeeded. */}
+ {copied === 'ok' ? (
+ Report draft
+
{report.steps.map((step, index) => (
{unverified > 0 ? (
- {label}
+ {label}
- {SAMPLE_RECORDINGS.map((sample) => (
-
-
-
+ Session replay an AI agent can interrogate
+
+
+
+
+
+
+
+ {SAMPLE_RECORDINGS.map((sample) => (
+
+
+ {error ? (
+
+
+
+
+ chrome://flags/#enable-webmcp-testing, then reload.
+ {label}
+ {children}
+ Origin-Trial header is
missing or expired.
-
+
Set NEXT_PUBLIC_WEBMCP_ORIGIN_TRIAL_TOKEN in .env.local and restart —
README, “Getting WebMCP in your browser”. Needs Chrome 149+ or Edge 150+ and a{' '}
token for this origin
@@ -110,18 +128,47 @@ export function ToolStatusBanner({ registration }: ToolStatusBannerProps) {
)
}
+ /**
+ * The host is present and rejected everything. `error`, `role="alert"`, and it names both cheap causes:
+ * the isolation header, and the cached response that still lacks it — a hard reload is the fix people
+ * do not think to try, because the page it produces looks identical.
+ */
+ if (registration.mode === 'native' && count === 0) {
+ return (
+ document.modelContext exists{browser && ` in ${browser}`}, so this looks healthy
+ and is not: every registerTool call threw and nothing here is agent-callable.
+
+
+ WebMCP refuses to register unless the document is origin-isolated. Check that the response
+ carries Origin-Agent-Cluster: ?1 — and hard-reload, because a response cached from
+ before that header was added produces exactly this. The console has one{' '}
+ host rejected tool warning per tool with the reason.
+
+ window.tracesTools. No agent can see them, so a run recorded
against the polyfill is a rehearsal, not a demo.
@@ -132,22 +179,26 @@ export function ToolStatusBanner({ registration }: ToolStatusBannerProps) {
return (
document.modelContext
{browser && ` in ${browser}`}
diff --git a/traces/src/components/ui/use-sample-loader.ts b/traces/src/components/ui/use-sample-loader.ts
new file mode 100644
index 0000000..c405a58
--- /dev/null
+++ b/traces/src/components/ui/use-sample-loader.ts
@@ -0,0 +1,66 @@
+'use client'
+
+import { useCallback, useState } from 'react'
+import { sampleRecordingUrl, type SampleRecording } from '@/components/ui/sample-recordings'
+import { buildCheckpointIndex } from '@/lib/replay/checkpoint-index'
+import { loadRecordingFile } from '@/lib/replay/load-recording-file'
+import { sessionActions } from '@/lib/store/session'
+
+/**
+ * Fetching a sample recording into the store, with the loading and error states attached.
+ *
+ * Extracted from `RecordingPicker` when the empty state grew a one-click load of its own. Two copies of
+ * this would have been two copies of the interesting part — `fetch` resolving happily on a 404, and the
+ * decision that `id` and `label` come from `SAMPLE_RECORDINGS` rather than from the file — and the copy
+ * that drifts is always the one nobody is looking at.
+ *
+ * Each caller holds its own instance, so the empty state's spinner does not appear in the header. That is
+ * the right way round: the recording they load is the one that unmounts the empty state, so its own
+ * feedback has to be local or it would report on a component that no longer exists.
+ */
+
+/** Which sample failed, and why. `id` so the message can name the file rather than "the recording". */
+export type SampleLoadError = { id: string; message: string }
+
+export type SampleLoader = {
+ load: (sample: SampleRecording) => PromiseStatus
+
+ Tools on this page ({tools.length})
+
+
+ {tools.map((tool) => (
+
+ Try asking
+ {/*
+ Quoted and left as prose rather than made copyable. A copy button here would need its own
+ clipboard-failure path — `report-draft.tsx` has one because a report is the artefact worth
+ that code, and a four-word prompt someone can retype is not.
+ */}
+
+ {EXAMPLES.map((example) => (
+
+