From 58edd614edc12eb407f05ec5404cbf422ecb1f32 Mon Sep 17 00:00:00 2001 From: ribdsp <113304041+ribdsp@users.noreply.github.com> Date: Sun, 30 Aug 2026 04:18:23 +0700 Subject: [PATCH 1/3] refactor: fold the docked WebMCP chip into the header pill Two chips stating the same tool count taught the eye that WebMCP was decoration. The header pill is now both the health signal and the trigger for the catalogue, and the docked overlay above the timeline is gone -- it covered the end of the axis, where the markers a reader is meant to click live. The rule that a degraded surface must not be hideable is unchanged, and is still discharged by the banner row: full width, in flow, no close control. Only the explanation moved. Two chevrons rather than one rotated, so open-vs-closed survives prefers-reduced-motion. --- traces/src/app/tool-surface.tsx | 15 +- .../src/components/ui/tool-status-banner.tsx | 106 +++++-- traces/src/components/ui/webmcp-badge.tsx | 284 ++++++------------ 3 files changed, 174 insertions(+), 231 deletions(-) diff --git a/traces/src/app/tool-surface.tsx b/traces/src/app/tool-surface.tsx index d8420b7..e2b4334 100644 --- a/traces/src/app/tool-surface.tsx +++ b/traces/src/app/tool-surface.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from 'react' import { registerTools, unregisterTools, type RegistrationResult } from '@/lib/webmcp/register-tools' import { ToolStatusBanner } from '@/components/ui/tool-status-banner' -import { WebMcpBadge } from '@/components/ui/webmcp-badge' /** * Registers every tool exactly once, and reports whether it worked. @@ -20,10 +19,9 @@ import { WebMcpBadge } from '@/components/ui/webmcp-badge' * `unavailable` and the banner says so, because the alternative is a page that looks perfect and does * nothing — a bug that is invisible until someone else opens it. * - * Both consumers of `registration` live here, and they are not redundant. `ToolStatusBanner` is in flow - * and always visible: it is the health signal, and it is what the paragraph above is about. - * `WebMcpBadge` is a docked overlay that explains what WebMCP is and lists what this page exposes — the - * thing a judge opens once. Neither can be folded into the other without one of the two jobs losing. + * `ToolStatusBanner` is the only consumer of `registration` in the tree. The header pill is the health + * signal; opening it reveals the catalogue. A second docked chip used to say the same count again, and + * that was the one that got ignored. */ export function ToolSurface() { const [registration, setRegistration] = useState(null) @@ -56,10 +54,5 @@ export function ToolSurface() { } }, []) - return ( - <> - - - - ) + return } diff --git a/traces/src/components/ui/tool-status-banner.tsx b/traces/src/components/ui/tool-status-banner.tsx index be8f136..61c90da 100644 --- a/traces/src/components/ui/tool-status-banner.tsx +++ b/traces/src/components/ui/tool-status-banner.tsx @@ -1,8 +1,9 @@ 'use client' -import { TriangleAlert } from 'lucide-react' -import { useEffect, useState } from 'react' +import { ChevronDown, ChevronUp, TriangleAlert } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' +import { WebMcpPanel } from '@/components/ui/webmcp-badge' import { onToolChange } from '@/lib/webmcp/tool-change' import type { RegistrationResult } from '@/lib/webmcp/register-tools' @@ -174,6 +175,7 @@ export function ToolStatusBanner({ registration }: ToolStatusBannerProps) { changes={changes} browser={browser} ready={registration !== null} + registration={registration} />, slot, ) @@ -206,16 +208,25 @@ interface StatusPillProps { changes: number browser: string ready: boolean + registration: RegistrationResult | null } /** * Five words in the header: a state dot, the surface's name, and how many tools are on it. * - * `role="status"` rather than nothing, because this inherited the healthy row's live region along with - * its job — a surface that registers late, or grows a tool mid-session, should still be announced. - * `aria-live` politeness is the default for `status`, which is right for a count that changes on its own. + * The pill is also the trigger for the explanation panel that used to live as a second chip above the + * timeline. One control, two jobs that do not fight: the visible count is the health signal, the + * dropdown is the catalogue. A `role="status"` live region sits beside the button rather than on it, + * because a button that is also a live region is two roles on one node. + * + * Two chevrons rather than one rotated: under `prefers-reduced-motion` a transform-based caret would + * sit at one angle in both states. Swapping the glyph is state, not animation. Same reasoning as + * `recording-picker.tsx`. */ -function StatusPill({ health, count, changes, browser, ready }: StatusPillProps) { +function StatusPill({ health, count, changes, browser, ready, registration }: StatusPillProps) { + const [open, setOpen] = useState(false) + const wrapRef = useRef(null) + const where = browser ? ` in ${browser}` : '' const title = !ready ? 'Registering the WebMCP tool surface…' @@ -225,30 +236,67 @@ function StatusPill({ health, count, changes, browser, ready }: StatusPillProps) ? `${count} tools registered against the local development shim, not the browser's own WebMCP. No external agent can see them.` : 'No tools are agent-callable. The banner below the header says why.' + useEffect(() => { + if (!open) return + + const onPointerDown = (event: PointerEvent) => { + const wrap = wrapRef.current + if (wrap && event.target instanceof Node && !wrap.contains(event.target)) setOpen(false) + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false) + } + + document.addEventListener('pointerdown', onPointerDown) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('pointerdown', onPointerDown) + document.removeEventListener('keydown', onKeyDown) + } + }, [open]) + return ( -
- {health === 'warn' || health === 'error' ? ( - - ) : ( - - )} - WebMCP - - - {ready ? count : '–'} - {changes > 0 ? ( - - {' '} - +{changes} - - ) : null} - - {/* The dot is decoration to a screen reader; this is the state it stands for. */} - — {STATE_WORD[health]} +
+
+ WebMCP — {STATE_WORD[health]} + {ready ? `, ${count} tools` : ''} +
+ + {open ? ( +
+ +
+ ) : null}
) } diff --git a/traces/src/components/ui/webmcp-badge.tsx b/traces/src/components/ui/webmcp-badge.tsx index 6363ad8..e1c0913 100644 --- a/traces/src/components/ui/webmcp-badge.tsx +++ b/traces/src/components/ui/webmcp-badge.tsx @@ -1,50 +1,31 @@ 'use client' -import { ChevronDown, ChevronUp, TriangleAlert } from 'lucide-react' +import { Activity, MessageSquare, Wrench } from 'lucide-react' import { useEffect, useState } from 'react' -import { TIMELINE_HEIGHT_PX } from '@/components/timeline/axis' import { allTools } from '@/lib/webmcp/register-tools' import { onToolChange } from '@/lib/webmcp/tool-change' import type { RegistrationResult } from '@/lib/webmcp/register-tools' /** - * The corner badge that explains what WebMCP is and what this page exposes. + * The explanation that opens from the header's WebMCP pill. * - * Deliberately the shape judges have already seen: a small docked chip — status dot, the word WebMCP, a - * chevron — that opens onto a definition, a status block, the tool list, and a few prompts to paste. The - * pattern is borrowed on purpose. Someone evaluating a WebMCP entry recognises it in under a second, and - * a second spent recognising the affordance is a second not spent reading our prose. + * It used to be a second chip, docked above the timeline. Two chips stating the same count taught the + * eye that WebMCP was a decoration; one pill in the header that *opens* is both the health signal and + * the catalogue. `ToolStatusBanner` still owns the live region and the degraded row — this file only + * describes the surface, it never announces it. * - * It is not the health indicator. `ToolStatusBanner` is, it is always in flow at the top of the page, and - * it cannot be collapsed. This panel is the *explanation* — which is why the two duplicate a little text - * and why only one of them carries a live region: the banner announces, this describes. + * Two things here are load-bearing rather than stylistic: * - * Three things here are load-bearing rather than stylistic: - * - * - **The red state renders no collapse control at all.** Not "collapsed by default", not "reopens on - * change" — while the surface is unavailable the panel is open and there is no button to close it. What - * makes that safe to relax for amber, and why it is not relaxed here, is worked out at `expanded` - * below; the short version is that an error panel cannot grow tall enough to cover anything, because - * the tool grid it would have grown by is exactly what an error state does not have. * - **The tool list comes from the host, not from us.** `document.modelContext.getTools()` reports what * the browser actually holds, so a tool the host rejected cannot appear here. Reading our own * `allTools` array instead would render sixteen confident cards on a page where zero are callable. - * - **The chevron is two icons, not one rotated one.** Under `prefers-reduced-motion` `globals.css` - * collapses transitions to nothing, so a transform-based caret would sit at one angle in both states - * and leave open-vs-closed signalled by nothing. Swapping the glyph is state, not animation, and - * survives. Every other reduced-motion fallback in the app cites this file, so: the rule is that the - * signal has to exist in the static frame. - * - * Docked to the right edge above the timeline rather than floating in the corner, because the last few - * percent of that axis is the end of the recording — markers a judge is meant to click. Covering them to - * advertise the tool surface would be the panel damaging the thing it describes. The offset is - * `TIMELINE_HEIGHT_PX` itself rather than a matching Tailwind step: it was `bottom-24` against a 96px - * timeline, the timeline became 112px, and a literal that has to be remembered is a literal that goes - * stale silently — the badge simply started overlapping the ruler it was written to clear. + * - **The panel is a dropdown, not a dock.** Opening it from the header means it can be dismissed, so + * the old rule that a red panel had no close button does not apply: the thing that must not be + * hideable is the banner row, and that row is still in flow with no close control. */ const DEFINITION = - 'WebMCP exposes structured website tools that compatible AI agents can discover and use.' + 'Structured tools on this page that a compatible agent can discover and call. Not a service: nothing here runs once the tab closes.' /** * The part people get wrong about WebMCP, so it is stated in the status block in every state: this is not @@ -53,8 +34,7 @@ const DEFINITION = * Kept to two lines. Every line spent here is a line of the tool grid pushed below the fold of a panel * that is capped in height, and the grid is the part that answers "what can it actually do". */ -const REACH = - 'Reachable only while this page is open and you have granted access. Nothing here runs on a server.' +const REACH = 'Reachable only while this page is open and you have granted access.' /** * Prompts that are pasteable as-is, in the order an investigation actually goes: orient, narrow, check, @@ -75,8 +55,7 @@ type Health = 'idle' | 'live' | 'warn' | 'error' * * Extracting a shared helper would put the banner's branch structure behind an abstraction, and the banner * is the one component in this app whose whole job is being trusted about the surface's health. Five lines - * repeated is cheaper than a refactor there. If the banner's ordering ever changes, this changes with it — - * `native` with nothing registered is a red state, not a green one with an unlucky count. + * repeated is cheaper than a refactor there. */ function healthOf(registration: RegistrationResult | null): Health { if (registration === null) return 'idle' @@ -86,14 +65,6 @@ function healthOf(registration: RegistrationResult | null): Health { return 'live' } -const DOT: Record = { - idle: 'bg-faint', - live: 'bg-ok', - warn: 'bg-warn', - error: 'bg-error', -} - -/** What the dot means, in words, for a screen reader and for anyone who cannot tell the dots apart. */ const STATE_WORD: Record = { idle: 'still registering', live: 'live', @@ -101,6 +72,13 @@ const STATE_WORD: Record = { error: 'unavailable', } +const STATE_CHIP: Record = { + idle: 'border-line text-muted', + live: 'border-ok/40 text-ok', + warn: 'border-warn/40 text-warn', + error: 'border-error/50 text-error', +} + type ToolCard = { name: string; summary: string; full: string } /** @@ -121,8 +99,7 @@ function cardsOf(tools: readonly { name: string; description: string }[]): ToolC })) } -export function WebMcpBadge({ registration }: { registration: RegistrationResult | null }) { - const [open, setOpen] = useState(false) +export function WebMcpPanel({ registration }: { registration: RegistrationResult | null }) { const [hosted, setHosted] = useState(null) /** @@ -164,29 +141,6 @@ export function WebMcpBadge({ registration }: { registration: RegistrationResult const health = healthOf(registration) - /* - * Red is not collapsible. Amber no longer is either way — it opens on arrival like any other state and can - * be closed — and the distinction is mechanical rather than a softening of the rule above. - * - * The rule is that a degraded surface must not be hideable. What discharges it is `ToolStatusBanner`: - * full-width, in flow, no close control, naming the mode in words at the top of the page. This panel was - * additionally forced open as a second guarantee, and at 720px that guarantee costs the entire agent - * column — measured: the panel is 288px of tool grid starting 112px off the bottom, which lands squarely - * on the task-queue textarea, the control `page.tsx` focuses on `a`. Occluding a focusable input with - * something that has no close button is the bug this file's own note says must never happen, and in the - * `polyfill` state, which is what Chrome shows without the flag, it happens every time. - * - * `error` is exempt because the panel is short there *by construction*: the tool grid is gated on - * `tools.length > 0` and an error state has none, so what is forced open is a definition, a sentence and - * four prompts — roughly 180px, which clears the input at the height a recording is made at. The state - * that can grow to cover something is the state that is no longer pinned open. - * - * The chip keeps the amber triangle and the screen-reader word in both cases, so collapsing changes how - * much is said, never whether the page admits it. - */ - const degraded = health === 'warn' || health === 'error' - const expanded = health === 'error' || open - /* * The host's list when we have one, ours when we do not — and ours is filtered to what actually * registered, never the full `allTools` array. `registered` carries names only, so the descriptions are @@ -198,135 +152,83 @@ export function WebMcpBadge({ registration }: { registration: RegistrationResult const tools = hosted !== null && hosted.length > 0 ? hosted : cardsOf(fallback) - const header = ( - <> - {degraded ? ( - - ) : ( - - )} - WebMCP - — {STATE_WORD[health]} - - ) - - /* - * The chip is flush to the right edge, so only its left corners are visible, and the top-left one is only - * an outside corner while the panel above it is closed. Rounding it unconditionally would notch the seam - * between the two. - */ - const chipRadius = expanded ? 'rounded-bl-md' : 'rounded-bl-md rounded-tl-md' - return (
- {expanded ? ( - /* - The height cap is a functional limit, not a taste one. This panel is forced open in every degraded - state, and a taller one reaches up past the agent lane's input — a control `page.tsx` focuses by - keyboard shortcut, which must never be covered by something with no close button. 18rem plus the - 112px the timeline takes puts its top edge 400px off the bottom, which clears the input at the - 720px recording height, and the overflow scrolls. - */ -
-

{DEFINITION}

- -
-

Status

-

- -

-

{REACH}

-
- - {tools.length > 0 ? ( -
-

- Tools on this page ({tools.length}) -

-
    - {tools.map((tool) => ( -
  • -

    {tool.name}

    - {/* - Two lines, hard. `firstSentence` is already the short form and it is still six lines - wide for `read_session_meta` in a 145px column, which turns sixteen cards into a wall - of prose nobody reads. The clamp is what makes this a scannable index; `title` on the - card keeps the sentence available to anyone who wants it. - */} -

    - {tool.summary} -

    -
  • - ))} -
-
- ) : null} - -
-

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) => ( -
  • - “{example}” -
  • - ))} -
-
+
+
+ WebMCP + + {STATE_WORD[health]} + +
+

{DEFINITION}

+
+ +
+

+ + Status +

+

+ +

+

{REACH}

+
+ + {tools.length > 0 ? ( +
+

+ + Tools on this page + {tools.length} +

+
    + {tools.map((tool) => ( +
  • +

    {tool.name}

    + {/* + Two lines, hard. `firstSentence` is already the short form and it is still six lines + wide for `read_session_meta` in a 145px column, which turns sixteen cards into a wall + of prose nobody reads. The clamp is what makes this a scannable index; `title` on the + card keeps the sentence available to anyone who wants it. + */} +

    {tool.summary}

    +
  • + ))} +
) : null} - {health === 'error' ? ( - /* - No button, on purpose. The chip still names the surface so the badge does not vanish in the state - it matters most, but there is nothing here to click, so there is nothing here that can hide it. - */ -
- {header} -
- ) : ( - - )} +
+

+ + 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) => ( +
  • + “{example}” +
  • + ))} +
+
) } @@ -360,4 +262,4 @@ function StatusSentence({ health, count }: { health: Health; count: number }) { } return <>{count} tools registered with the browser and callable by a connected agent. -} +} \ No newline at end of file From 870baf38e0372157e995f4a03dec6ddaa35257ff Mon Sep 17 00:00:00 2001 From: ribdsp <113304041+ribdsp@users.noreply.github.com> Date: Sun, 30 Aug 2026 04:18:37 +0700 Subject: [PATCH 2/3] feat: swap the speed segments for a compact upward menu Three equal rectangles cost width the scrubber needs at 720px, which is the width this is judged at. A single trigger reading "1x" answers the question the control exists for. The menu opens upward so it lands on the replay rather than on the timeline. The transport button is filled and inverted against ink -- a 14px hairline triangle does not survive being video. --- .../src/components/player/player-controls.tsx | 150 ++++++++++++------ 1 file changed, 105 insertions(+), 45 deletions(-) diff --git a/traces/src/components/player/player-controls.tsx b/traces/src/components/player/player-controls.tsx index 2a7a0f5..1000682 100644 --- a/traces/src/components/player/player-controls.tsx +++ b/traces/src/components/player/player-controls.tsx @@ -1,13 +1,14 @@ 'use client' -import { Pause, Play } from 'lucide-react' -import { useEffect } from 'react' +import { Check, ChevronDown, ChevronUp, Pause, Play } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' import { PLAYBACK_SPEEDS, STEP_COARSE_MS, STEP_MS, useLastSeekAuthor, usePlayback, + type PlaybackSpeed, } from '@/components/player/use-playhead' import { AuthorBadge } from '@/components/ui/author-badge' import { sessionActions, useSessionStore } from '@/lib/store/session' @@ -27,8 +28,8 @@ import { sessionActions, useSessionStore } from '@/lib/store/session' * frame follows through `usePlayheadSync`, which is what keeps one clock authoritative instead of two. * * What shipped, and why: - * - play/pause and 0.5× / 1× / 2×, held in local state by `usePlayback` — neither is the agent's - * business, and `SessionState` is frozen + * - play/pause and a speed dropdown (0.5× / 1× / 2×), held in local state by `usePlayback` — neither + * is the agent's business, and `SessionState` is frozen * - a scrubber writing through `setCurrentTime(atMs, 'human')`, quantised to the arrow-key step so a * focused slider and the global shortcut agree * - a marker when the agent moved the playhead last, derived by `useLastSeekAuthor` rather than from a @@ -46,10 +47,9 @@ import { sessionActions, useSessionStore } from '@/lib/store/session' * `globals.css`, because `::-webkit-slider-thumb` cannot be reached from a utility class without a * dozen arbitrary variants that nobody will read twice. * - * The speeds are one segmented control rather than three separate buttons. Three equal bordered - * rectangles state that there are three options and stay silent about which one is on; a single - * enclosure with one raised segment answers "what speed is this playing at" from across a room, which - * is the question the control exists for. + * Speed is a compact dropdown rather than three segments. Three equal rectangles cost width the + * scrubber needs at 720px, and a single trigger that reads "1×" already answers what speed this is + * playing at. The menu opens upward so it sits on the replay, not on the timeline. */ /** So Day 6's shortcut can put focus on the scrubber without threading a ref through the layout. */ @@ -136,15 +136,15 @@ export function PlayerControls() { disabled={disabled} title={playback.isPlaying ? 'Pause (space)' : 'Play (space)'} aria-label={playback.isPlaying ? 'Pause' : 'Play'} - className="flex h-6 w-9 shrink-0 items-center justify-center rounded-sm border border-line-strong bg-raised text-ink shadow-raised hover:border-faint disabled:border-line disabled:bg-panel disabled:text-faint" + className="flex h-7 w-7 shrink-0 items-center justify-center rounded-sm bg-ink text-panel shadow-raised hover:opacity-90 disabled:border disabled:border-line disabled:bg-panel disabled:text-faint disabled:opacity-100" > {/* The glyph is the whole control, so `aria-label` above is its only name — do not drop it. - Filled rather than outlined: a transport button is the one place in this UI that should read - as a solid target, and a 14px hairline triangle does not survive being video. */} + Filled, and inverted against `ink`: a transport button is the one place in this UI that + should read as a solid target, and a 14px hairline triangle does not survive being video. */} {playback.isPlaying ? ( - + ) : ( - + )} @@ -195,38 +195,11 @@ export function PlayerControls() { />
- {/* - One enclosure, three segments. `aria-pressed` per segment rather than a radiogroup: these are - toggles onto a live player, not a form value that gets submitted, and `pressed` is what a screen - reader should read back for "2× is on". - */} -
- {PLAYBACK_SPEEDS.map((speed) => { - const active = playback.speed === speed - - return ( - - ) - })} -
+ {/* A playhead that moves on its own is uncanny until it is attributed. This is deliberately quiet — @@ -244,3 +217,90 @@ export function PlayerControls() {
) } + +function SpeedMenu({ + speed, + disabled, + onChange, +}: { + speed: PlaybackSpeed + disabled: boolean + onChange: (speed: PlaybackSpeed) => void +}) { + const [open, setOpen] = useState(false) + const wrapRef = useRef(null) + + useEffect(() => { + if (!open) return + + const onPointerDown = (event: PointerEvent) => { + const wrap = wrapRef.current + if (wrap && event.target instanceof Node && !wrap.contains(event.target)) setOpen(false) + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false) + } + + document.addEventListener('pointerdown', onPointerDown) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('pointerdown', onPointerDown) + document.removeEventListener('keydown', onKeyDown) + } + }, [open]) + + return ( +
+ + + {open ? ( +
    + {PLAYBACK_SPEEDS.map((option) => { + const active = option === speed + return ( +
  • + +
  • + ) + })} +
+ ) : null} +
+ ) +} From ca9d904fa601f2b920f954d866de28533c6a6132 Mon Sep 17 00:00:00 2001 From: ribdsp <113304041+ribdsp@users.noreply.github.com> Date: Sun, 30 Aug 2026 04:18:37 +0700 Subject: [PATCH 3/3] feat: give the agent column and the empty states a shape per action Icons where the glyph carries the action rather than decorating it: a status shape per queued task, a distinct mark per example prompt, a verb shape in the shortcut legend, and a ghost axis on the empty timeline so a blank strip reads as waiting rather than as failed to draw. Empty states now name the condition before the invitation -- "nothing is loaded" precedes "load a sample". Adds a GitHub mark to the header. Lucide dropped brand icons, so the path is inline. AuthorBadge gains an icon variant for the activity feed, where the row is already prose and a second word fights the line. The word stays in sr-only and in the tooltip; the shape differs between agent and human, so nothing here depends on telling violet from blue. --- traces/src/app/page.tsx | 83 +++++++++++++------ traces/src/components/agent/activity-feed.tsx | 36 +++++--- traces/src/components/agent/agent-lane.tsx | 42 ++++++---- .../components/player/stage-empty-state.tsx | 80 ++++++++++-------- traces/src/components/timeline/timeline.tsx | 41 +++++++-- traces/src/components/ui/author-badge.tsx | 26 +++++- 6 files changed, 211 insertions(+), 97 deletions(-) diff --git a/traces/src/app/page.tsx b/traces/src/app/page.tsx index c217745..65f35ea 100644 --- a/traces/src/app/page.tsx +++ b/traces/src/app/page.tsx @@ -1,6 +1,7 @@ 'use client' -import { Keyboard } from 'lucide-react' +import { ArrowLeftRight, Keyboard, KeyboardOff, ListTodo, Play, SlidersHorizontal } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' import { useEffect } from 'react' import { AgentLane, AGENT_LANE_INPUT_ID } from '@/components/agent/agent-lane' import { ActivityFeed } from '@/components/agent/activity-feed' @@ -50,21 +51,26 @@ import { TOOL_STATUS_SLOT_ID } from '@/components/ui/tool-status-banner' /** Bound below and listed in the header, so the legend cannot drift from what is actually handled. */ const FOCUS_KEYS = { agent: 'a', player: 'p' } as const +const GITHUB_URL = 'https://github.com/ribdsp/Traces' + /** * Space and the arrows belong to `PlayerControls` — they are listed here because the legend is about the * keyboard, not about which component owns which key. + * + * `does` is the action in a short verb phrase, and `icon` is that action as a shape. The key caps stay + * text: a glyph for "space" is a puzzle, and the point of this list is to be pressed, not decoded. */ -const LEGEND = [ - { keys: 'space', does: 'play' }, - { keys: '←→', does: 'step' }, - { keys: FOCUS_KEYS.agent, does: 'agent lane' }, - { keys: FOCUS_KEYS.player, does: 'player' }, - { keys: 'esc', does: 'release' }, -] as const +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 }, +] /** Shared by both renderings of the legend, so the prose and the disclosure cannot disagree. */ const LEGEND_TITLE = - 'Keyboard: space plays and pauses, the arrows step 100ms (hold shift for a second), a focuses the agent lane, p focuses the scrubber, and escape hands the keyboard back to the player.' + 'Keyboard: space plays and pauses, the arrows step 100ms (hold shift for a second), a focuses the task queue, p focuses the scrubber, and escape hands the keyboard back to the player.' /** * Whether a keystroke is part of something being written. @@ -121,14 +127,24 @@ export default function Home() { return (
-
+
{/* The wordmark is the one place in this app allowed to be a size larger than its neighbours. It is not decoration: a screen recording that opens on a grey instrument with no name on it is a recording nobody can attribute afterwards. */}

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 — @@ -186,7 +202,7 @@ export default function Home() { * * Two renderings of one `LEGEND`, because the narrow case is the common case: this is judged in the * ChatGPT desktop in-app browser, which is a window of arbitrary width, and the legend used to be - * `hidden lg:flex` — so on the screen it most needed to teach, it taught nothing at all. Below `lg` it + * `hidden lg:flex` — so on the screen it most needed to teach, it taught nothing at all. Below `xl` it * collapses to a disclosure instead of vanishing. * * `
` rather than a button with state: it is keyboard-reachable and toggleable with no JavaScript @@ -198,17 +214,17 @@ function ShortcutLegend() { <>
    {LEGEND.map((item) => ( -
  • - - {item.does} +
  • + + {item.short}
  • ))}
-
+
+
    {LEGEND.map((item) => ( -
  • - - {item.does} +
  • + + + {item.does}
  • ))}
@@ -241,10 +258,26 @@ function ShortcutLegend() { /** 10px is the documented floor for a key cap and nothing else: `esc` set at 13px is wider than the * word it labels, and the legend is five of them in a header that has to survive 720px. */ -function LegendKey({ keys }: { keys: string }) { +function LegendKeys({ keys }: { keys: readonly string[] }) { + return ( + + {keys.map((key) => ( + + {key} + + ))} + + ) +} + +/** Lucide dropped brand icons; this is the GitHub mark, sized to the 13px header controls. */ +function GithubMark() { return ( - - {keys} - + + + ) } diff --git a/traces/src/components/agent/activity-feed.tsx b/traces/src/components/agent/activity-feed.tsx index 2ed2851..fb4e546 100644 --- a/traces/src/components/agent/activity-feed.tsx +++ b/traces/src/components/agent/activity-feed.tsx @@ -1,6 +1,6 @@ 'use client' -import { History } from 'lucide-react' +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' @@ -32,8 +32,9 @@ import type { ActivityEntry, Author } from '@/types/domain' * mechanism, and the mechanism is the thing being demonstrated. * - a 2px rail down the left of every row in its author's colour. This is the surface that proves two * parties are working on one session, and a reader should be able to see the interleaving from across - * the room without reading a word of it. The badge stays: the rail is the pattern, the word is the fact, - * and nothing here may depend on telling violet from blue. + * the room without reading a word of it. Authorship in this feed is an icon (with the word in + * `sr-only`): the rail is the pattern, the shape is the fact, and nothing here may depend on telling + * violet from blue. */ /** Authorship, as an edge. The same two colours as the badge, which is the only other place they mean this. */ @@ -103,10 +104,10 @@ export function ActivityFeed() { function FeedRow({ entry, now }: { entry: ActivityEntry; now: number | null }) { return (
  • {entry.description} - + {/* @@ -125,8 +126,9 @@ function FeedRow({ entry, now }: { entry: ActivityEntry; now: number | null }) { type="button" onClick={() => sessionActions().undo(entry.id)} title="Undo exactly this contribution. Everything else the agent did stays." - className="rounded-sm text-label uppercase tracking-wide text-muted underline decoration-dotted hover:text-ink" + className="inline-flex items-center gap-0.5 rounded-sm text-label uppercase tracking-wide text-muted underline decoration-dotted hover:text-ink" > + undo ) : null} @@ -137,10 +139,22 @@ function FeedRow({ entry, now }: { entry: ActivityEntry; now: number | null }) { function EmptyFeed() { return ( -

    - 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. -

    +
    +
      +
    • + + You + You mark, reject, and answer. +
    • +
    • + + Agent + The agent seeks, bisects, and annotates. +
    • +
    +

    + Each action lands here as it happens. Undo anything the agent did from its own line. +

    +
    ) } diff --git a/traces/src/components/agent/agent-lane.tsx b/traces/src/components/agent/agent-lane.tsx index 1e69db2..9f19469 100644 --- a/traces/src/components/agent/agent-lane.tsx +++ b/traces/src/components/agent/agent-lane.tsx @@ -1,6 +1,7 @@ 'use client' -import { ListTodo } from 'lucide-react' +import { Check, Circle, CircleHelp, ListTodo, MousePointerClick, Search } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' import { useState } from 'react' import { AuthorBadge } from '@/components/ui/author-badge' import { formatAgo, useWallClock } from '@/components/ui/use-clock' @@ -56,10 +57,10 @@ export const AGENT_LANE_INPUT_ID = 'traces-agent-lane-input' * Each one is a real question about the `empty-province` sample, phrased the way the tools want to be * driven — a moment to find, then a claim to check. */ -const EXAMPLES = [ - 'Find when the province dropdown went empty', - 'Check whether the submit button was ever enabled', - 'Explain why the address form rejected a valid postcode', +const EXAMPLES: readonly { text: string; icon: LucideIcon }[] = [ + { text: 'Find when the province dropdown went empty', icon: Search }, + { text: 'Check whether the submit button was ever enabled', icon: MousePointerClick }, + { text: 'Explain why the address form rejected a valid postcode', icon: CircleHelp }, ] /** @@ -154,8 +155,14 @@ function TaskRow({ task, now }: { task: Task; now: number | null }) { className={`flex items-baseline gap-1.5 rounded-sm border-l-2 py-1 pl-2 pr-1.5 text-body ${treatment.rail} ${treatment.row}`} > + {task.status === 'open' ? ( + + ) : null} + {task.status === 'done' ? ( + + ) : null} {task.status} @@ -166,7 +173,7 @@ function TaskRow({ task, now }: { task: Task; now: number | null }) { are what carry the state when the motion is gone. */} {task.status === 'claimed' ? ( - + ) : null} {task.text} @@ -183,26 +190,27 @@ function TaskRow({ task, now }: { task: Task; now: number | null }) { function EmptyLane({ onPick }: { onPick: (text: string) => void }) { return ( -
    +

    - Nothing queued — and nothing is polling for it either. An agent takes work from here by calling{' '} + Nothing in the queue. An agent takes work from here by calling{' '} claim_next_task, which{' '} - blocks instead of returning empty: whatever you type below is - what that call returns, at the moment you press Enter. + blocks until you press Enter — whatever you type below is what + that call returns.

    -

    Something worth handing over:

    +

    Something worth handing over:

    -
      +
        {EXAMPLES.map((example) => ( -
      • +
      • ))} diff --git a/traces/src/components/player/stage-empty-state.tsx b/traces/src/components/player/stage-empty-state.tsx index e0ecd3b..6b3fe72 100644 --- a/traces/src/components/player/stage-empty-state.tsx +++ b/traces/src/components/player/stage-empty-state.tsx @@ -32,25 +32,24 @@ export function StageEmptyState() { const { load, loadingId, error } = useSampleLoader() return ( -
        +

        Session replay an AI agent can interrogate

        - 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. + Load a session, then ask what went wrong — at the exact millisecond it happened. When the agent + cannot see, it asks you to look.

        -
        +

        This panel is a DOM, not a video

        - 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. + Replaying a recording rebuilds the page as a live document. Every node is really there, and + queryable, at whichever millisecond the playhead is on. That is why the tools register in this + browser tab rather than behind an API: nothing on a server holds this DOM.

        @@ -59,6 +58,7 @@ export function StageEmptyState() { Load a recording +

        Three samples. One click each.

          {SAMPLE_RECORDINGS.map((sample) => ( @@ -71,24 +71,32 @@ export function StageEmptyState() { type="button" onClick={() => load(sample)} disabled={loadingId !== null} - className="flex w-full flex-col items-start gap-y-0.5 rounded-sm border border-line-strong px-2 py-1 text-left hover:border-faint hover:bg-raised/60 focus-visible:border-ink disabled:opacity-50 sm:flex-row sm:items-baseline sm:gap-x-2" + className="flex w-full items-start gap-2 rounded-sm border border-line-strong bg-raised/40 px-2 py-1.5 text-left hover:border-faint hover:bg-raised/60 focus-visible:border-ink disabled:opacity-50" > - {sample.id} - - {sample.id === loadingId ? ( - /* - The same dot the header's picker shows, for the same reason: this is the one wait in - the app the human did not choose to sit through. It settles solid under reduced - motion, and "loading…" is what carries the state in either case. - */ - - - loading… - - ) : ( - sample.blurb - )} + + {sample.id} + + {sample.id === loadingId ? ( + /* + The same dot the header's picker shows, for the same reason: this is the one wait in + the app the human did not choose to sit through. It settles solid under reduced + motion, and "loading…" is what carries the state in either case. + */ + + + loading… + + ) : ( + sample.blurb + )} + + ))} @@ -110,21 +118,27 @@ export function StageEmptyState() { Getting WebMCP -
            -
          • - ChatGPT desktop, in its in-app browser — works as it - comes. +
              +
            • + + + ChatGPT desktop, in its in-app browser — works as it + comes. +
            • -
            • - Chrome 149+ — turn on{' '} - chrome://flags/#enable-webmcp-testing, then reload. +
            • + + + Chrome 149+ — turn on{' '} + chrome://flags/#enable-webmcp-testing, then reload. +
            -

            +

            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.

        ) -} +} \ No newline at end of file diff --git a/traces/src/components/timeline/timeline.tsx b/traces/src/components/timeline/timeline.tsx index 9e1ef65..600bb77 100644 --- a/traces/src/components/timeline/timeline.tsx +++ b/traces/src/components/timeline/timeline.tsx @@ -1,5 +1,6 @@ 'use client' +import { Timer } from 'lucide-react' import { useState } from 'react' import { formatSeconds } from '@/components/ui/format-time' import { sessionActions, useSessionStore } from '@/lib/store/session' @@ -75,6 +76,9 @@ function tickIntervalFor(durationMs: number): number { */ const MINOR_TICKS_PER_LABEL = 5 +/** Evenly spaced decorative ticks for the empty axis. Percents, not milliseconds: there is no duration yet. */ +const GHOST_TICKS = [0, 12.5, 25, 37.5, 50, 62.5, 75, 87.5, 100] as const + /** Below this, a pointer move is not a new reading — it is the same instant, one pixel over. */ const HOVER_RESOLUTION_MS = 50 @@ -86,20 +90,41 @@ export function Timeline() { /** * The empty state is a sentence rather than a blank bar. A grey strip under the player reads as a - * timeline that failed to draw, and the one thing worth saying here is what this axis is *for* — - * it is the collaboration claim, and it is legible before any data arrives. + * timeline that failed to draw. Name the condition first — nothing is loaded — then the next + * action, then what this axis is *for*. The collaboration claim is still the point, but it is + * illegible if the reader cannot tell why the strip is blank. */ if (!recording) { return (
        -

        - 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. + {/* + A ghost of the loaded axis, so this strip reads as a timeline that is waiting rather than one + that failed to draw. Decorative, so it is hidden from assistive tech; the sentence below is the + name of the state. + */} +

        +
        + {GHOST_TICKS.map((at) => ( + + ))} +
        +
        +
        +

        + + + Nothing on this timeline yet — no recording is loaded. + + + Load a sample from the list above. Your marks and the agent's findings will then share this + axis, labelled by who found them.

        diff --git a/traces/src/components/ui/author-badge.tsx b/traces/src/components/ui/author-badge.tsx index e06372a..353086a 100644 --- a/traces/src/components/ui/author-badge.tsx +++ b/traces/src/components/ui/author-badge.tsx @@ -1,8 +1,15 @@ +import { Bot, User } from 'lucide-react' import type { Author } from '@/types/domain' interface AuthorBadgeProps { author: Author className?: string + /** + * `chip` is the default: a word, because colour is not enough. `icon` is for the activity feed, + * where the row is already labelled in prose and a second word fights the line. The word stays in + * `sr-only` either way — the shape is a glance, not a replacement for the name. + */ + variant?: 'chip' | 'icon' } /** @@ -14,7 +21,8 @@ interface AuthorBadgeProps { * differently, it is one file. * * Text, not just colour. This gets watched on a compressed video by people who may not distinguish - * violet from blue, and "AGENT" survives both. + * violet from blue, and "AGENT" survives both. The icon variant keeps the word for assistive tech + * and in the tooltip; it never relies on the glyph alone. * * The two colours are `agent` and `human` from the palette, which exist for this and are documented as * carrying authorship rather than decoration. Nothing else in the app may borrow them. @@ -23,8 +31,20 @@ interface AuthorBadgeProps { * unreadable in a compressed recording, and therefore unreadable in the only place this gets judged. At the * scale's floor for a chip it is legible without becoming a label competing with the line it annotates. */ -export function AuthorBadge({ author, className = '' }: AuthorBadgeProps) { +export function AuthorBadge({ author, className = '', variant = 'chip' }: AuthorBadgeProps) { const isAgent = author === 'agent' + const label = isAgent ? 'agent' : 'you' + const tone = isAgent ? 'text-agent' : 'text-human' + + if (variant === 'icon') { + const Icon = isAgent ? Bot : User + return ( + + + {label} + + ) + } return ( - {isAgent ? 'agent' : 'you'} + {label} ) }