Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,54 @@ reproduce the bug, and download the recording:
| `localhost:3001/checkout?bug=race` | the dropdown renders before its data arrives and never re-renders |
| `localhost:3001/checkout?bug=overlay` | the pay button is present and enabled but covered, so clicks never land |

Drop the downloaded JSON into Traces. **Keep recordings of real users out of version control** — see
[T5](docs/threat-model.md#t5--a-real-user-session-ending-up-in-a-public-repository).
The download lands as `<bug>.session.json`. To open it, use **Load a file…** — the last row of the
recording menu in the header, and a matching row under the three samples on the empty stage. You can
also drop the file anywhere on the empty stage. Nothing is uploaded, because there is nowhere to upload
to: the file is read in the tab and parsed into memory. The name becomes the recording's label, and a
slug of it becomes its id.

**Keep recordings of real users out of version control** — see
[T5](docs/threat-model.md#t5--a-real-user-session-ending-up-in-a-public-repository). Loading a file
never writes to `traces/public/recordings/`, so a recording you open stays out of the repository unless
you put it there yourself.

### Recording your own app

`npm i rrweb` and calling `record()` produces a file Traces will load and then serve badly. Five
options do the actual work, and `bugbait/src/lib/record.ts` is the reference implementation:

| Add this | What silently breaks without it |
|---|---|
| `checkoutEveryNms: 5000` | rrweb emits **one** full snapshot, at the start. The checkpoint index has a single entry, so every seek replays from the beginning — and `bisect`, which seeks repeatedly by design, pays that cost on each step. Nothing errors; the player just crawls |
| `maskAllInputs: true` | every keystroke is recorded verbatim. See below — this one is not a preference |
| a `window.fetch` patch emitting `addCustomEvent('network-request', …)` | `read_network` returns nothing. rrweb records DOM mutations, not requests; the network panel of a recording is whatever you put there yourself |
| normalising console events to `{ type: 3, source: 11 }` | the console plugin emits `{ type: 6, data: { plugin: 'rrweb/console@1' } }`, which nothing downstream reads. `consoleErrors` stays `0` on a session full of errors — the most misleading of the five, because zero looks like an answer |
| stamping `userAgent` onto the first Meta event | the session summary cannot say what browser it was |

**`maskAllInputs: true` is mandatory, not a preference.** rrweb records input values by default, so a
recorder without it is a credential logger: the password, the card number and the one-time code are all
in the JSON, in plain text, for anyone the file is later shared with. Traces truncates a `value` to 20
characters when a tool reads one, which limits what an agent sees and does nothing about what the file
contains. Mask at the recorder or accept that the recording is a secret.

**A gap worth knowing before you trust `read_network`:** a `fetch` patch sees `fetch` only.
`XMLHttpRequest` traffic — anything on axios's default adapter, jQuery, or an older SDK — is invisible
to it, and a recording of such an app will show an empty network timeline rather than an error. Every
fixture here uses `fetch`, so none of them expose this.

**The cost, measured** from the three files in `traces/public/recordings/`: 184–213 KiB for ~45 seconds
of a small checkout page, or roughly 4–5 KiB per second, at 209–223 events. A recording is JSON and
compresses well in transit; in memory it is the whole array. A ten-minute session of a heavier app is
plausibly tens of megabytes, which is what the 64 MB ceiling on a loaded file is sized for.

### Opening a recording you didn't make

Worth stating, since the feature above invites it. Scripts inside a recording **cannot execute**: the
replay iframe is sandboxed with `allow-same-origin` and not `allow-scripts`, so a `<script>` in the
captured DOM is inert markup. Sub-resources are a different matter — images, stylesheets and fonts
referenced by the recorded page **are fetched from their original URLs** when the DOM is rebuilt, which
tells those origins that the recording was replayed and when. The JSON itself is parsed in the tab and
goes nowhere; there is no upload endpoint to send it to.

---

Expand Down
Binary file added traces/public/image/ChatGPT.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added traces/public/image/Chrome.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 12 additions & 7 deletions traces/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
* unreadable in a way that a plain rule is not. The track and the fill behind the handle *are*
* Tailwind, in the component, because those are ordinary elements.
*
* The input itself is transparent and full-height: it is the 20px hit area over a 4px track, so the
* The input itself is transparent and full-height: it is the 24px hit area over a 6px track, so the
* handle can be aimed with a mouse and seen on video. Suppressing the native track is what makes the
* painted one visible underneath.
*/
Expand All @@ -82,31 +82,36 @@

.traces-scrubber::-webkit-slider-runnable-track {
background: transparent;
height: 100%;
height: 6px;
}

.traces-scrubber::-moz-range-track {
background: transparent;
height: 100%;
height: 6px;
border: none;
}

.traces-scrubber::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 11px;
height: 11px;
width: 13px;
height: 13px;
/* Track is 6px, thumb is 13px: this is what sits the disc on the centre of the bar. */
margin-top: -3.5px;
border-radius: 9999px;
background: theme('colors.ink');
/* A ring in the surface colour, so the handle reads as sitting *on* the track rather than in it. */
border: 2px solid theme('colors.panel');
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.12);
}

.traces-scrubber::-moz-range-thumb {
width: 11px;
height: 11px;
width: 13px;
height: 13px;
border-radius: 9999px;
background: theme('colors.ink');
border: 2px solid theme('colors.panel');
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.12);
}

.traces-scrubber:disabled::-webkit-slider-thumb {
Expand Down
28 changes: 16 additions & 12 deletions traces/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { ArrowLeftRight, Keyboard, KeyboardOff, ListTodo, Play, SlidersHorizontal } from 'lucide-react'
import { ChevronsLeftRight, CirclePlay, Command, Film, Inbox, LogOut } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useEffect } from 'react'
import { AgentLane, AGENT_LANE_INPUT_ID } from '@/components/agent/agent-lane'
Expand All @@ -13,6 +13,7 @@ import { ReplayStage } from '@/components/player/replay-stage'
import { Timeline } from '@/components/timeline/timeline'
import { RecordingPicker } from '@/components/ui/recording-picker'
import { ResizableSplit } from '@/components/ui/resizable-split'
import { ErrorToasts } from '@/components/ui/error-toast'
import { TOOL_STATUS_SLOT_ID } from '@/components/ui/tool-status-banner'

/**
Expand Down Expand Up @@ -61,11 +62,11 @@ const GITHUB_URL = 'https://github.com/ribdsp/Traces'
* text: a glyph for "space" is a puzzle, and the point of this list is to be pressed, not decoded.
*/
const LEGEND: readonly { keys: readonly string[]; short: string; does: string; icon: LucideIcon }[] = [
{ keys: ['space'], short: 'Play', does: 'Play / pause', icon: Play },
{ keys: ['←', '→'], short: 'Step', does: 'Step 100ms', icon: ArrowLeftRight },
{ keys: [FOCUS_KEYS.agent], short: 'Queue', does: 'Focus queue', icon: ListTodo },
{ keys: [FOCUS_KEYS.player], short: 'Player', does: 'Focus player', icon: SlidersHorizontal },
{ keys: ['esc'], short: 'Release', does: 'Release focus', icon: KeyboardOff },
{ keys: ['space'], short: 'Play', does: 'Play / pause', icon: CirclePlay },
{ keys: ['←', '→'], short: 'Step', does: 'Step 100ms', icon: ChevronsLeftRight },
{ keys: [FOCUS_KEYS.agent], short: 'Queue', does: 'Focus queue', icon: Inbox },
{ keys: [FOCUS_KEYS.player], short: 'Player', does: 'Focus player', icon: Film },
{ keys: ['esc'], short: 'Release', does: 'Release focus', icon: LogOut },
]

/** Shared by both renderings of the legend, so the prose and the disclosure cannot disagree. */
Expand Down Expand Up @@ -193,6 +194,7 @@ export default function Home() {
/>

<Timeline />
<ErrorToasts />
</main>
)
}
Expand Down Expand Up @@ -234,20 +236,22 @@ function ShortcutLegend() {
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.
*/}
<Keyboard aria-hidden size={13} strokeWidth={1.75} />
keys
<Command aria-hidden size={13} strokeWidth={1.75} />
Keys
</summary>

{/*
`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.
*/}
<ul className="absolute right-0 top-[calc(100%+4px)] z-20 w-max min-w-[12.5rem] space-y-1 rounded-md border border-line-strong bg-raised px-2 py-1.5 text-label text-muted shadow-raised">
<ul className="absolute right-0 top-[calc(100%+4px)] z-20 w-max min-w-[14rem] space-y-0.5 rounded-md border border-line-strong bg-raised p-1 text-label text-muted shadow-raised">
{LEGEND.map((item) => (
<li key={item.does} className="flex items-center gap-2">
<li key={item.does} className="flex items-center gap-2 rounded-sm px-1.5 py-1 hover:bg-panel">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border border-line bg-base text-muted">
<item.icon aria-hidden size={12} strokeWidth={1.75} />
</span>
<span className="min-w-[7rem] text-ink">{item.does}</span>
<LegendKeys keys={item.keys} />
<item.icon aria-hidden size={13} strokeWidth={1.75} className="shrink-0 text-muted" />
<span className="text-ink">{item.does}</span>
</li>
))}
</ul>
Expand Down
80 changes: 47 additions & 33 deletions traces/src/components/agent/activity-feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { Bot, History, Undo2, User } from 'lucide-react'
import { useEffect, useRef } 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'
Expand All @@ -24,8 +23,8 @@ import type { ActivityEntry, Author } from '@/types/domain'
* while an agent works is the case that matters: `[overflow-anchor:none]` plus a scroll correction keeps
* the same lines under the eye in every browser, rather than depending on native scroll anchoring, which
* Safari does not implement.
* - "3s ago", from `entry.at`, which is wall-clock — never recording time. The exact clock time is in the
* title, for anyone cross-referencing this against a console log.
* - live seconds ("12s ago"), from `entry.at`, which is wall-clock — never recording time. The exact clock
* time is in the title, for anyone cross-referencing this against a console log.
* - undo where `undoable` is set. Only agent entries carry it, which is the asymmetry the project argues
* for: the human can revert the agent, and the agent cannot revert the human.
* - an empty state that says what will appear here. "No activity" describes the widget; this describes the
Expand All @@ -51,8 +50,8 @@ export function ActivityFeed() {
const scrollRef = useRef<HTMLDivElement>(null)
const previousHeight = useRef(0)

/** Coarse: the labels are minutes and seconds, and a feed that re-renders faster than it changes is waste. */
const now = useWallClock(5_000)
/** One second: the labels are live seconds, and a coarser tick would sit still between minute jumps. */
const now = useWallClock(1_000)

useEffect(() => {
const list = scrollRef.current
Expand Down Expand Up @@ -89,7 +88,7 @@ export function ActivityFeed() {
{activity.length === 0 ? (
<EmptyFeed />
) : (
<ul className="space-y-1">
<ul className="space-y-1.5">
{/* Newest first. Reversed for display only — the store's order is the record. */}
{[...activity].reverse().map((entry) => (
<FeedRow key={entry.id} entry={entry} now={now} />
Expand All @@ -101,38 +100,53 @@ export function ActivityFeed() {
)
}

const ROW: Record<Author, { surface: string; mark: string }> = {
human: { surface: 'bg-human/5', mark: 'bg-human/15 text-human' },
agent: { surface: 'bg-agent/5', mark: 'bg-agent/15 text-agent' },
}

function FeedRow({ entry, now }: { entry: ActivityEntry; now: number | null }) {
return (
<li
className={`flex items-center gap-1 border-l-2 pl-1.5 text-body leading-relaxed ${RAILS[entry.author]}`}
>
<span className="min-w-0 text-ink">{entry.description}</span>
<AuthorBadge author={entry.author} variant="icon" />
const isAgent = entry.author === 'agent'
const Icon = isAgent ? Bot : User
const tone = ROW[entry.author]

<span className="ml-auto flex shrink-0 items-baseline gap-1.5 pl-1">
{/*
Absolute time until the clock starts, so the prerendered first paint is not a wall-clock read
the build could not have made.
*/}
return (
<li className={`rounded-sm border-l-2 ${RAILS[entry.author]} ${tone.surface} px-1.5 py-1.5`}>
<div className="flex gap-1.5">
<span
className="font-mono text-label tabular-nums text-faint"
title={new Date(entry.at).toLocaleTimeString()}
title={isAgent ? 'agent' : 'you'}
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-sm ${tone.mark}`}
>
{now === null ? '' : formatAgo(entry.at, now)}
<Icon aria-hidden size={12} strokeWidth={1.75} />
<span className="sr-only">{isAgent ? 'agent' : 'you'}</span>
</span>

{entry.undoable ? (
<button
type="button"
onClick={() => sessionActions().undo(entry.id)}
title="Undo exactly this contribution. Everything else the agent did stays."
className="inline-flex items-center gap-0.5 rounded-sm text-label uppercase tracking-wide text-muted underline decoration-dotted hover:text-ink"
>
<Undo2 aria-hidden size={12} strokeWidth={1.75} />
undo
</button>
) : null}
</span>
<div className="min-w-0 flex-1">
<p className="text-body leading-snug text-ink">{entry.description}</p>
<p className="mt-0.5 flex items-center gap-1.5">
{/*
Absolute time until the clock starts, so the prerendered first paint is not a wall-clock read
the build could not have made.
*/}
<span
className="font-mono text-label tabular-nums text-faint"
title={new Date(entry.at).toLocaleTimeString()}
>
{now === null ? '' : formatAgo(entry.at, now)}
</span>
{entry.undoable ? (
<button
type="button"
onClick={() => sessionActions().undo(entry.id)}
title="Undo exactly this contribution. Everything else the agent did stays."
className="inline-flex items-center gap-0.5 rounded-sm text-label uppercase tracking-wide text-muted underline decoration-dotted hover:text-ink"
>
<Undo2 aria-hidden size={12} strokeWidth={1.75} />
undo
</button>
) : null}
</p>
</div>
</div>
</li>
)
}
Expand Down
34 changes: 23 additions & 11 deletions traces/src/components/agent/ask-human-visual-prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ type Exchange = {
answered: boolean
}

/** Compact wait, so a stale gate does not print `waiting 9437s`. */
function formatWait(ms: number): string {
const seconds = Math.max(0, Math.round(ms / 1000))
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ${seconds % 60}s`
const hours = Math.floor(minutes / 60)
return `${hours}h ${minutes % 60}m`
}

export function AskHumanVisualPrompt() {
const pendingAsk = useSessionStore((s) => s.pendingAsk)
const [resolved, setResolved] = useState<Exchange | null>(null)
Expand Down Expand Up @@ -97,40 +107,42 @@ export function AskHumanVisualPrompt() {
const timedOut = waitedMs > GATE_TIMEOUT_MS

return (
<section className="border-b border-warn/30 bg-warn/5 p-3">
<section className="border-b border-warn/40 bg-warn/5 p-3">
{/*
`Eye` rather than a warning triangle: nothing is broken — the agent has hit a judgement a person has
to make by looking, which is also what the answer consists of. `MarkPointOverlay` carries the same
glyph on the player, so the two halves of this one interaction are recognisable as each other.
*/}
<SectionHeading rank="alert" label="Agent needs your eyes" icon={Eye}>
<span className="ml-auto flex shrink-0 items-center gap-1.5 font-mono text-label tabular-nums text-muted">
<span className="ml-auto flex shrink-0 items-center gap-1.5 rounded-sm border border-warn/30 bg-warn/10 px-1.5 py-px font-mono text-label tabular-nums text-warn">
{/*
`animate-pulse` rather than a spinner, for the reason `webmcp-badge.tsx` uses two chevrons:
`animate-pulse` rather than a spinner, for the reason `recording-picker.tsx` uses two chevrons:
`globals.css` zeroes animation duration under `prefers-reduced-motion`, which lands opacity on
1 and leaves a solid amber dot. A rotating glyph would stop dead and read as a hang. Nothing
here depends on the motion either way — the word "waiting" and a count that climbs every
second already say it, and the dot is the part that catches an eye that was elsewhere.
*/}
<span aria-hidden className="h-1.5 w-1.5 animate-pulse rounded-full bg-warn" />
waiting {Math.round(waitedMs / 1000)}s
{formatWait(waitedMs)}
</span>
</SectionHeading>

<p className="text-body leading-relaxed text-ink">{pendingAsk.question}</p>
<p className="rounded-sm border border-warn/20 bg-panel/60 px-2 py-1.5 text-body leading-relaxed text-ink">
{pendingAsk.question}
</p>

<p className="mt-1.5 text-meta leading-relaxed text-muted">
Answer on the player: put the playhead on the moment you mean, then pick one of the options over
the replay.
Answer on the player — put the playhead on the moment you mean, then pick an option over the
replay.
{pendingAsk.hintAtMs !== undefined
? ` The agent suggested ${formatSeconds(pendingAsk.hintAtMs)}.`
? ` Suggested ${formatSeconds(pendingAsk.hintAtMs)}.`
: ''}
</p>

{timedOut ? (
<p className="mt-1.5 border-l-2 border-warn/40 pl-2 text-meta leading-relaxed text-warn/80">
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.
<p className="mt-1.5 rounded-sm border border-warn/30 bg-warn/10 px-2 py-1.5 text-meta leading-relaxed text-warn">
The first call already returned a ticket after {Math.round(GATE_TIMEOUT_MS / 1000)}s and is
retrying. Your answer still reaches it.
</p>
) : null}
</section>
Expand Down
Loading
Loading