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
10 changes: 10 additions & 0 deletions traces/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions traces/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"test:watch": "vitest"
},
"dependencies": {
"lucide-react": "1.35.0",
"next": "^15.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
Expand Down
2 changes: 1 addition & 1 deletion traces/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
whatever height is left rather than subtracting a number that was only ever true for one of
those states.
*/}
<body className="flex h-screen flex-col overflow-hidden bg-zinc-950 font-sans text-zinc-200 antialiased">
<body className="flex h-screen flex-col overflow-hidden bg-base font-sans text-ink antialiased">
<ToolSurface />
{children}
</body>
Expand Down
98 changes: 76 additions & 22 deletions traces/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client'

import { Keyboard } 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 All @@ -25,6 +26,11 @@ import { ResizableSplit } from '@/components/ui/resizable-split'
* comes from flexing against the layout rather than from subtracting the banner's height, because that
* banner is one line when healthy and several when it has something to report.
*
* The frame is what must not scroll — a *panel* may, and two do: the agent column, and the empty state on
* the stage. That distinction matters more than the 1280×720 the rule was written for, because this is
* also judged in the ChatGPT desktop in-app browser, whose window is whatever width the user left it.
* Verified at 720, 900, 1100 and 1440.
*
* MarkPointOverlay is no longer mounted here: it belongs to the stage it dims, and mounting it in both
* places would have rendered the question twice the day `pendingAsk` first got set.
*
Expand All @@ -35,8 +41,9 @@ import { ResizableSplit } from '@/components/ui/resizable-split'
* - `esc` releases focus from whatever is being typed in, which is the shortcut that makes the other two
* safe to use. Space plays only while nothing has the keyboard, so without a way back out, the first
* `a` costs the player its controls until someone clicks the stage.
* - the keys are listed in the header rather than hidden in a help dialog. Five of them fit in the space
* the description was already truncating, and a shortcut nobody is told about is one nobody presses.
* - the keys are listed in the header rather than hidden in a help dialog, because a shortcut nobody is
* told about is one nobody presses. Below `lg` there is no room to list five of them, so they collapse
* into a disclosure rather than disappearing — see `ShortcutLegend`.
*/

/** Bound below and listed in the header, so the legend cannot drift from what is actually handled. */
Expand All @@ -54,6 +61,10 @@ const LEGEND = [
{ keys: 'esc', does: 'release' },
] as const

/** 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.'

/**
* Whether a keystroke is part of something being written.
*
Expand Down Expand Up @@ -108,12 +119,17 @@ export default function Home() {

return (
<main className="flex min-h-0 flex-1 flex-col">
<header className="flex shrink-0 items-start justify-between gap-4 border-b border-zinc-800 px-3 py-1.5">
<header className="relative flex shrink-0 items-start justify-between gap-3 border-b border-line px-3 py-1.5">
<div className="flex min-w-0 items-baseline gap-2">
<h1 className="shrink-0 text-xs font-medium tracking-tight text-zinc-100">Traces</h1>
<p className="truncate text-[11px] text-zinc-500">
Session replay an AI agent can interrogate — it reads the DOM at any moment, binary-searches
the timeline, and asks you to look when it cannot see.
<h1 className="shrink-0 text-xs font-medium tracking-tight text-ink">Traces</h1>
{/*
Short enough to sit at 900px without truncating, and hidden below `md` rather than clipped.
The sentence that used to be here — the one that explained what interrogating a replay means —
moved to `StageEmptyState`, where it has room and where it is actually wanted. `truncate` stays
as a guard so a future edit to this string cannot push the picker off the right edge.
*/}
<p className="hidden truncate text-[11px] text-muted md:block">
agent-interrogable session replay
</p>
</div>

Expand Down Expand Up @@ -151,23 +167,61 @@ export default function Home() {
/**
* The keys, in the header.
*
* Hidden below `lg` rather than wrapped: it is a convenience for a keyboard, and the layout it would push
* around is the one thing on this page that must not start scrolling.
* 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
* collapses to a disclosure instead of vanishing.
*
* `<details>` rather than a button with state: it is keyboard-reachable and toggleable with no JavaScript
* and no focus management, and the panel is absolutely positioned so opening it cannot push the layout —
* which is the one thing this page must not do.
*/
function ShortcutLegend() {
return (
<ul
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."
className="hidden shrink-0 items-center gap-2 pt-0.5 text-[10px] text-zinc-600 lg:flex"
>
{LEGEND.map((item) => (
<li key={item.keys} className="flex items-center gap-1">
<kbd className="border border-zinc-800 px-1 font-mono text-[9px] text-zinc-400">
{item.keys}
</kbd>
<span>{item.does}</span>
</li>
))}
</ul>
<>
<ul
title={LEGEND_TITLE}
className="hidden shrink-0 items-center gap-2 pt-0.5 text-[10px] text-faint lg:flex"
>
{LEGEND.map((item) => (
<li key={item.keys} className="flex items-center gap-1">
<LegendKey keys={item.keys} />
<span>{item.does}</span>
</li>
))}
</ul>

<details className="relative shrink-0 lg:hidden">
<summary
title={LEGEND_TITLE}
className="flex cursor-pointer list-none items-center gap-1 border border-line px-1 py-0.5 text-[10px] text-muted marker:content-none hover:border-faint hover:text-ink focus-visible:border-ink focus-visible:text-ink focus-visible:outline-none [&::-webkit-details-marker]:hidden"
>
{/*
Names the control rather than decorating a heading: collapsed, this is one word in a crowded
header, and the glyph is what makes it findable at a glance. The word beside it is still the
accessible name, so the icon stays hidden from assistive tech.
*/}
<Keyboard aria-hidden size={12} strokeWidth={1.5} />
keys
</summary>

{/*
`raised` rather than a heavier border to lift the popover off the header. Drop shadows are out,
so elevation here is carried by the surface token that exists for it.
*/}
<ul className="absolute right-0 top-[calc(100%+3px)] z-20 w-max space-y-1 border border-line bg-raised px-2 py-1.5 text-[10px] text-muted">
{LEGEND.map((item) => (
<li key={item.keys} className="flex items-center gap-1.5">
<LegendKey keys={item.keys} />
<span>{item.does}</span>
</li>
))}
</ul>
</details>
</>
)
}

function LegendKey({ keys }: { keys: string }) {
return <kbd className="border border-line px-1 font-mono text-[9px] text-muted">{keys}</kbd>
}
49 changes: 36 additions & 13 deletions traces/src/app/tool-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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 @@ -18,25 +19,47 @@ import { ToolStatusBanner } from '@/components/ui/tool-status-banner'
* Failure is loud on purpose. If the origin trial token is missing, `registerTools` returns
* `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.
*/
export function ToolSurface() {
const [registration, setRegistration] = useState<RegistrationResult | null>(null)

useEffect(() => {
let result: RegistrationResult
try {
result = registerTools()
} catch (error: unknown) {
// If registration throws, the app still loads and says why it can't.
const message = error instanceof Error ? error.message : 'registration failed'
result = { mode: 'unavailable', registered: [] }
// eslint-disable-next-line no-console -- the only diagnostic path before the banner is real
console.warn(`[traces] tool registration unavailable: ${message}`)
}
/*
* `registerTools` is async because the spec's `registerTool` rejects rather than throws. The flag
* is what keeps React 19's double mount honest: the first pass is aborted on cleanup and resolves
* with nothing registered, and without this guard that empty result can land after the second
* pass's real one and grey out a banner over sixteen live tools.
*/
let active = true

setRegistration(result)
return () => unregisterTools()
void registerTools().then(
(result) => {
if (active) setRegistration(result)
},
(error: unknown) => {
// If registration rejects, the app still loads and says why it can't.
const message = error instanceof Error ? error.message : 'registration failed'
// eslint-disable-next-line no-console -- the only diagnostic path before the banner is real
console.warn(`[traces] tool registration unavailable: ${message}`)
if (active) setRegistration({ mode: 'unavailable', registered: [] })
},
)

return () => {
active = false
unregisterTools()
}
}, [])

return <ToolStatusBanner registration={registration} />
return (
<>
<ToolStatusBanner registration={registration} />
<WebMcpBadge registration={registration} />
</>
)
}
23 changes: 14 additions & 9 deletions traces/src/components/agent/activity-feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect, useRef } from 'react'
import { AuthorBadge } from '@/components/ui/author-badge'
import { formatAgo, useWallClock } from '@/components/ui/use-clock'
import { SectionHeading } from '@/components/ui/section-heading'
import { sessionActions, useSessionStore } from '@/lib/store/session'
import type { ActivityEntry } from '@/types/domain'

Expand Down Expand Up @@ -58,13 +59,17 @@ export function ActivityFeed() {
}, [activity])

return (
<section className="flex min-h-[8rem] flex-1 flex-col p-3">
<div className="mb-2 flex shrink-0 items-baseline justify-between gap-2">
<h2 className="text-[11px] uppercase tracking-wide text-zinc-500">Activity</h2>
/*
`panel` rather than the column's `base`. This is the only section that is a *record* rather than
something to act on, and after four hairline-separated slabs another hairline says nothing. A change
of ground does: everything above it is open work, everything on this surface already happened.
*/
<section className="flex min-h-[8rem] flex-1 flex-col bg-panel p-3">
<SectionHeading rank="record" label="Activity">
{activity.length > 0 ? (
<span className="font-mono text-[10px] text-zinc-600">{activity.length}</span>
<span className="ml-auto font-mono text-[10px] text-faint">{activity.length}</span>
) : null}
</div>
</SectionHeading>

<div ref={scrollRef} className="min-h-0 flex-1 overflow-auto [overflow-anchor:none]">
{activity.length === 0 ? (
Expand All @@ -84,7 +89,7 @@ export function ActivityFeed() {

function FeedRow({ entry, now }: { entry: ActivityEntry; now: number | null }) {
return (
<li className="flex items-baseline gap-1 text-xs leading-relaxed text-zinc-400">
<li className="flex items-baseline gap-1 text-xs leading-relaxed text-muted">
<span className="min-w-0">{entry.description}</span>
<AuthorBadge author={entry.author} />

Expand All @@ -94,7 +99,7 @@ function FeedRow({ entry, now }: { entry: ActivityEntry; now: number | null }) {
the build could not have made.
*/}
<span
className="font-mono text-[10px] text-zinc-600"
className="font-mono text-[10px] text-faint"
title={new Date(entry.at).toLocaleTimeString()}
>
{now === null ? '' : formatAgo(entry.at, now)}
Expand All @@ -105,7 +110,7 @@ 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="text-[10px] uppercase tracking-wide text-zinc-500 underline decoration-dotted hover:text-zinc-100"
className="text-[10px] uppercase tracking-wide text-muted underline decoration-dotted hover:text-ink focus-visible:bg-raised focus-visible:text-ink focus-visible:outline-none"
>
undo
</button>
Expand All @@ -117,7 +122,7 @@ function FeedRow({ entry, now }: { entry: ActivityEntry; now: number | null }) {

function EmptyFeed() {
return (
<p className="text-[11px] leading-relaxed text-zinc-600">
<p className="text-[11px] 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.
Expand Down
21 changes: 11 additions & 10 deletions traces/src/components/agent/agent-lane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState } from 'react'
import { AuthorBadge } from '@/components/ui/author-badge'
import { formatAgo, useWallClock } from '@/components/ui/use-clock'
import { SectionHeading } from '@/components/ui/section-heading'
import { sessionActions, useSessionStore } from '@/lib/store/session'
import type { Task, TaskStatus } from '@/types/domain'

Expand Down Expand Up @@ -52,9 +53,9 @@ const EXAMPLES = [
]

const TREATMENTS: Record<TaskStatus, { row: string; label: string; text: string }> = {
open: { row: 'border-zinc-800', label: 'text-zinc-500', text: 'text-zinc-300' },
claimed: { row: 'border-amber-500/50', label: 'text-amber-300', text: 'text-zinc-100' },
done: { row: 'border-zinc-800', label: 'text-zinc-600', text: 'text-zinc-500 line-through' },
open: { row: 'border-line', label: 'text-muted', text: 'text-ink' },
claimed: { row: 'border-warn/50', label: 'text-warn', text: 'text-ink' },
done: { row: 'border-line', label: 'text-faint', text: 'text-muted line-through' },
}

export function AgentLane() {
Expand All @@ -70,8 +71,8 @@ export function AgentLane() {
}

return (
<section className="border-b border-zinc-800 p-3">
<h2 className="mb-2 text-[11px] uppercase tracking-wide text-zinc-500">Agent lane</h2>
<section className="border-b border-line p-3">
<SectionHeading label="Agent lane" />

{tasks.length === 0 ? (
<EmptyLane onPick={setDraft} />
Expand All @@ -96,7 +97,7 @@ export function AgentLane() {
rows={2}
placeholder="Hand the agent a task — Enter to send, Shift+Enter for a newline"
aria-label="Hand the agent a task"
className="w-full resize-none border border-zinc-800 bg-zinc-950 px-2 py-1 text-xs leading-relaxed text-zinc-200 placeholder:text-zinc-600 focus:border-zinc-600 focus:outline-none"
className="w-full resize-none border border-line bg-base px-2 py-1 text-xs leading-relaxed text-ink placeholder:text-faint focus:border-ink focus:outline-none"
/>
</section>
)
Expand All @@ -114,14 +115,14 @@ function TaskRow({ task, now }: { task: Task; now: number | null }) {
duration under prefers-reduced-motion, which turns this into a static dot instead of removing it.
*/}
{task.status === 'claimed' ? (
<span aria-hidden className="h-1 w-1 shrink-0 animate-pulse rounded-full bg-amber-400" />
<span aria-hidden className="h-1 w-1 shrink-0 animate-pulse rounded-full bg-warn" />
) : null}

<span className={treatment.text}>{task.text}</span>
<AuthorBadge author={task.author} />

{task.status === 'claimed' && task.claimedAt !== undefined && now !== null ? (
<span className="ml-auto shrink-0 font-mono text-[10px] text-zinc-600">
<span className="ml-auto shrink-0 font-mono text-[10px] text-faint">
working, claimed {formatAgo(task.claimedAt, now)}
</span>
) : null}
Expand All @@ -131,7 +132,7 @@ function TaskRow({ task, now }: { task: Task; now: number | null }) {

function EmptyLane({ onPick }: { onPick: (text: string) => void }) {
return (
<div className="mb-2 text-[10px] leading-relaxed text-zinc-600">
<div className="mb-2 text-[10px] leading-relaxed text-faint">
<p>
Nothing queued. An agent calling <span className="font-mono">claim_next_task</span> waits here
until you add something, then takes it without being asked twice.
Expand All @@ -143,7 +144,7 @@ function EmptyLane({ onPick }: { onPick: (text: string) => void }) {
<button
type="button"
onClick={() => onPick(example)}
className="text-left text-zinc-500 underline decoration-dotted hover:text-zinc-200"
className="text-left text-muted underline decoration-dotted hover:text-ink focus-visible:bg-raised focus-visible:text-ink focus-visible:outline-none"
>
{example}
</button>
Expand Down
Loading