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
83 changes: 58 additions & 25 deletions traces/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -121,14 +127,24 @@ export default function Home() {
return (
<main className="flex min-h-0 flex-1 flex-col">
<header className="relative flex shrink-0 items-center justify-between gap-3 border-b border-line bg-panel px-3 py-2">
<div className="flex min-w-0 items-baseline gap-2">
<div className="flex min-w-0 items-center gap-2">
{/*
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.
*/}
<h1 className="shrink-0 text-title font-semibold tracking-tight text-ink">Traces</h1>
<span aria-hidden className="hidden h-3 w-px shrink-0 self-center bg-line-strong md:block" />
<a
href={GITHUB_URL}
target="_blank"
rel="noreferrer"
title="Traces on GitHub"
aria-label="Traces on GitHub"
className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-sm border border-line-strong bg-raised text-muted shadow-raised hover:border-faint hover:text-ink"
>
<GithubMark />
</a>
<span aria-hidden className="hidden h-3 w-px shrink-0 bg-line-strong md:block" />
{/*
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 —
Expand Down Expand Up @@ -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.
*
* `<details>` rather than a button with state: it is keyboard-reachable and toggleable with no JavaScript
Expand All @@ -198,17 +214,17 @@ function ShortcutLegend() {
<>
<ul
title={LEGEND_TITLE}
className="hidden shrink-0 items-center gap-2 text-label text-faint lg:flex"
className="hidden shrink-0 items-center gap-2.5 text-label text-faint xl:flex"
>
{LEGEND.map((item) => (
<li key={item.keys} className="flex items-center gap-1">
<LegendKey keys={item.keys} />
<span>{item.does}</span>
<li key={item.does} className="flex items-center gap-1">
<LegendKeys keys={item.keys} />
<span>{item.short}</span>
</li>
))}
</ul>

<details className="relative shrink-0 lg:hidden">
<details className="relative shrink-0 xl:hidden">
<summary
title={LEGEND_TITLE}
className="flex cursor-pointer list-none items-center gap-1 rounded-sm border border-line-strong bg-raised px-1.5 py-0.5 text-label text-muted shadow-raised marker:content-none hover:border-faint hover:text-ink [&::-webkit-details-marker]:hidden"
Expand All @@ -226,11 +242,12 @@ function ShortcutLegend() {
`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 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-[12.5rem] space-y-1 rounded-md border border-line-strong bg-raised px-2 py-1.5 text-label text-muted shadow-raised">
{LEGEND.map((item) => (
<li key={item.keys} className="flex items-center gap-1.5">
<LegendKey keys={item.keys} />
<span>{item.does}</span>
<li key={item.does} className="flex items-center gap-2">
<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 All @@ -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 (
<span className="flex items-center gap-0.5">
{keys.map((key) => (
<kbd
key={key}
className="rounded-sm border border-line-strong bg-base px-1 font-mono text-micro text-muted"
>
{key}
</kbd>
))}
</span>
)
}

/** Lucide dropped brand icons; this is the GitHub mark, sized to the 13px header controls. */
function GithubMark() {
return (
<kbd className="rounded-sm border border-line-strong bg-base px-1 font-mono text-micro text-muted">
{keys}
</kbd>
<svg aria-hidden viewBox="0 0 16 16" width={13} height={13} fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z" />
</svg>
)
}
15 changes: 4 additions & 11 deletions traces/src/app/tool-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<RegistrationResult | null>(null)
Expand Down Expand Up @@ -56,10 +54,5 @@ export function ToolSurface() {
}
}, [])

return (
<>
<ToolStatusBanner registration={registration} />
<WebMcpBadge registration={registration} />
</>
)
return <ToolStatusBanner registration={registration} />
}
36 changes: 25 additions & 11 deletions traces/src/components/agent/activity-feed.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -103,10 +104,10 @@ export function ActivityFeed() {
function FeedRow({ entry, now }: { entry: ActivityEntry; now: number | null }) {
return (
<li
className={`flex items-baseline gap-1 border-l-2 pl-1.5 text-body leading-relaxed ${RAILS[entry.author]}`}
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} />
<AuthorBadge author={entry.author} variant="icon" />

<span className="ml-auto flex shrink-0 items-baseline gap-1.5 pl-1">
{/*
Expand All @@ -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"
>
<Undo2 aria-hidden size={12} strokeWidth={1.75} />
undo
</button>
) : null}
Expand All @@ -137,10 +139,22 @@ function FeedRow({ entry, now }: { entry: ActivityEntry; now: number | null }) {

function EmptyFeed() {
return (
<p className="text-meta leading-relaxed text-faint">
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.
</p>
<div className="space-y-2">
<ul className="space-y-1">
<li className="flex items-center gap-1.5 text-body text-muted">
<User aria-hidden size={13} strokeWidth={1.75} className="shrink-0 text-human" />
<span className="sr-only">You</span>
<span>You mark, reject, and answer.</span>
</li>
<li className="flex items-center gap-1.5 text-body text-muted">
<Bot aria-hidden size={13} strokeWidth={1.75} className="shrink-0 text-agent" />
<span className="sr-only">Agent</span>
<span>The agent seeks, bisects, and annotates.</span>
</li>
</ul>
<p className="text-body leading-relaxed text-muted">
Each action lands here as it happens. Undo anything the agent did from its own line.
</p>
</div>
)
}
42 changes: 25 additions & 17 deletions traces/src/components/agent/agent-lane.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 },
]

/**
Expand Down Expand Up @@ -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}`}
>
<span
className={`shrink-0 rounded-sm px-1 font-mono text-label uppercase tracking-wide ${treatment.chip}`}
className={`inline-flex shrink-0 items-center gap-1 rounded-sm px-1 font-mono text-label uppercase tracking-wide ${treatment.chip}`}
>
{task.status === 'open' ? (
<Circle aria-hidden size={10} strokeWidth={1.75} />
) : null}
{task.status === 'done' ? (
<Check aria-hidden size={10} strokeWidth={2} />
) : null}
{task.status}
</span>

Expand All @@ -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' ? (
<span aria-hidden className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-warn" />
<span aria-hidden className="h-1.5 w-1.5 shrink-0 self-center animate-pulse rounded-full bg-warn" />
) : null}

<span className={`min-w-0 ${treatment.text}`}>{task.text}</span>
Expand All @@ -183,26 +190,27 @@ function TaskRow({ task, now }: { task: Task; now: number | null }) {

function EmptyLane({ onPick }: { onPick: (text: string) => void }) {
return (
<div className="mb-2 text-meta leading-relaxed text-faint">
<div className="mb-2 text-body leading-relaxed">
<p className="text-muted">
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{' '}
<span className="font-mono text-ink">claim_next_task</span>, which{' '}
<span className="text-ink">blocks</span> instead of returning empty: whatever you type below is
what that call returns, at the moment you press Enter.
<span className="text-ink">blocks</span> until you press Enter — whatever you type below is what
that call returns.
</p>

<p className="mt-1.5">Something worth handing over:</p>
<p className="mt-2 text-meta text-faint">Something worth handing over:</p>

<ul className="mt-1 space-y-0.5">
<ul className="mt-1 space-y-1">
{EXAMPLES.map((example) => (
<li key={example}>
<li key={example.text}>
<button
type="button"
onClick={() => onPick(example)}
onClick={() => onPick(example.text)}
title="Put this in the box below. It is not queued until you press Enter."
className="rounded-sm text-left text-meta text-muted underline decoration-dotted hover:text-ink"
className="flex w-full items-start gap-1.5 rounded-sm border border-line px-1.5 py-1 text-left text-meta text-muted hover:border-faint hover:bg-raised/60 hover:text-ink"
>
{example}
<example.icon aria-hidden size={13} strokeWidth={1.75} className="mt-0.5 shrink-0 text-faint" />
<span>{example.text}</span>
</button>
</li>
))}
Expand Down
Loading