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/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/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/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} +
    + ) +} 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} ) } 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