From 878956d529dcb332bd04a16e68c4bc1147f35d3d Mon Sep 17 00:00:00 2001 From: ribdsp <113304041+ribdsp@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:56:30 +0700 Subject: [PATCH] feat(ui): consume the design tokens and publish the tool surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries the WebMCP registration fix too, which cannot be split out: the async plumbing and the new badge both land in app/tool-surface.tsx. WebMCP registration Per the W3C spec (webmachinelearning/webmcp, index.bs:605) registerTool returns Promise and reports every failure by rejecting: SecurityError when the document is not origin-keyed, NotAllowedError when the tools policy forbids it, plus rejection for a duplicate name, an empty description or an invalid schema. The synchronous try/catch around it caught none of those, so all sixteen names were recorded as registered regardless and the banner could report "WebMCP live, 16 tools" over a page with nothing agent-callable — the exact failure next.config.mjs warns about, in the one place that could not detect it. Registration is now awaited and a name recorded only once its promise resolves. ToolSurface guards React 19's double mount so the aborted first pass cannot overwrite the second pass's real result. types/webmcp.d.ts widened to the spec: getTools(), the second options argument on execute, title, and annotations. Ten tools now carry annotations - readOnlyHint where a tool only reads, untrustedContentHint where its result carries text or DOM taken from the recording, which is content we do not control. Design tokens tailwind.config.ts defined a palette with a rationale per choice and zero consumers; 22 files used raw zinc-* and amber-* instead, which is why the UI read as generic grey rather than deliberate near-black. Migrated call site by call site rather than swept, since zinc-800 was a fill in some places and a border in others. human and agent stay authorship-only, so a control either party drives gets ink plus a non-colour affordance instead of a tint. Hierarchy and icons New SectionHeading with three ranks. The headings were text-muted, dimmer than their own body copy; they now outrank it. Icons normalised to size 12 and strokeWidth 1.5, one glyph per severity, and omitted wherever a glyph would only be decoration. WebMCP panel New WebMcpBadge, docked bottom-right above the timeline, following the pattern OpenAI's own WebMCP showcases use: a one-line definition, an access statement, a grid of tool cards, and "Try asking" prompts a judge can paste into ChatGPT's in-app browser. The list comes from document.modelContext.getTools() and re-reads on toolchange, so it reflects the host rather than our own array. Degraded states render as a div rather than a button, so amber and red cannot be dismissed. npx tsc --noEmit clean; 23 files, 289 tests passing. --- traces/package-lock.json | 10 + traces/package.json | 1 + traces/src/app/layout.tsx | 2 +- traces/src/app/page.tsx | 98 ++++-- traces/src/app/tool-surface.tsx | 49 ++- traces/src/components/agent/activity-feed.tsx | 23 +- traces/src/components/agent/agent-lane.tsx | 21 +- .../agent/ask-human-visual-prompt.tsx | 46 ++- .../src/components/agent/hypothesis-cards.tsx | 61 ++-- traces/src/components/agent/report-draft.tsx | 62 ++-- .../components/player/mark-point-overlay.tsx | 25 +- .../src/components/player/player-controls.tsx | 41 ++- traces/src/components/player/replay-stage.tsx | 50 +-- .../components/player/stage-empty-state.tsx | 118 +++++++ .../components/timeline/annotation-marker.tsx | 17 +- .../src/components/timeline/bisect-trace.tsx | 24 +- .../src/components/timeline/event-track.tsx | 18 +- traces/src/components/timeline/timeline.tsx | 29 +- traces/src/components/ui/author-badge.tsx | 7 +- traces/src/components/ui/recording-picker.tsx | 82 ++--- traces/src/components/ui/resizable-split.tsx | 6 +- traces/src/components/ui/section-heading.tsx | 61 ++++ .../src/components/ui/tool-status-banner.tsx | 109 ++++-- traces/src/components/ui/use-sample-loader.ts | 66 ++++ traces/src/components/ui/webmcp-badge.tsx | 323 ++++++++++++++++++ traces/src/lib/webmcp/polyfill.ts | 21 +- traces/src/lib/webmcp/register-tools.test.ts | 112 ++++++ traces/src/lib/webmcp/register-tools.ts | 68 +++- traces/src/lib/webmcp/tool-types.ts | 9 + traces/src/lib/webmcp/tools/bisect.ts | 8 + traces/src/lib/webmcp/tools/diff-dom.ts | 4 + traces/src/lib/webmcp/tools/find-element.ts | 4 + traces/src/lib/webmcp/tools/list-events.ts | 4 + traces/src/lib/webmcp/tools/measure-layout.ts | 8 + traces/src/lib/webmcp/tools/propose-report.ts | 8 + traces/src/lib/webmcp/tools/read-console.ts | 3 + traces/src/lib/webmcp/tools/read-dom-at.ts | 4 + traces/src/lib/webmcp/tools/read-network.ts | 4 + .../src/lib/webmcp/tools/read-session-meta.ts | 4 + traces/src/types/webmcp.d.ts | 60 +++- 40 files changed, 1349 insertions(+), 321 deletions(-) create mode 100644 traces/src/components/player/stage-empty-state.tsx create mode 100644 traces/src/components/ui/section-heading.tsx create mode 100644 traces/src/components/ui/use-sample-loader.ts create mode 100644 traces/src/components/ui/webmcp-badge.tsx create mode 100644 traces/src/lib/webmcp/register-tools.test.ts diff --git a/traces/package-lock.json b/traces/package-lock.json index 6c18ffe..3accc5e 100644 --- a/traces/package-lock.json +++ b/traces/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "lucide-react": "1.35.0", "next": "^15.5.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -5544,6 +5545,15 @@ "dev": true, "license": "ISC" }, + "node_modules/lucide-react": { + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.35.0.tgz", + "integrity": "sha512-yXCCWxGFYT6bLIPYC4SY6fPQPRs/d797rRIue+J9XP2Td6vQvD53gaQRBCnIVT1kTQRHtAtxlfOQNWAuIF8ELg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/traces/package.json b/traces/package.json index dc118ff..6c2019e 100644 --- a/traces/package.json +++ b/traces/package.json @@ -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", diff --git a/traces/src/app/layout.tsx b/traces/src/app/layout.tsx index 7ce5ea7..7146306 100644 --- a/traces/src/app/layout.tsx +++ b/traces/src/app/layout.tsx @@ -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. */} - + {children} diff --git a/traces/src/app/page.tsx b/traces/src/app/page.tsx index 7d8f802..69e4f38 100644 --- a/traces/src/app/page.tsx +++ b/traces/src/app/page.tsx @@ -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' @@ -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. * @@ -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. */ @@ -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. * @@ -108,12 +119,17 @@ export default function Home() { return (
-
+
-

Traces

-

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

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 — + 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. + */} +

+ agent-interrogable session replay

@@ -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. + * + * `
` 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 ( -
    - {LEGEND.map((item) => ( -
  • - - {item.keys} - - {item.does} -
  • - ))} -
+ <> +
    + {LEGEND.map((item) => ( +
  • + + {item.does} +
  • + ))} +
+ +
+ + {/* + 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. + */} + + keys + + + {/* + `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. + */} +
    + {LEGEND.map((item) => ( +
  • + + {item.does} +
  • + ))} +
+
+ ) } + +function LegendKey({ keys }: { keys: string }) { + return {keys} +} diff --git a/traces/src/app/tool-surface.tsx b/traces/src/app/tool-surface.tsx index 5e4edec..d8420b7 100644 --- a/traces/src/app/tool-surface.tsx +++ b/traces/src/app/tool-surface.tsx @@ -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. @@ -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(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 + return ( + <> + + + + ) } diff --git a/traces/src/components/agent/activity-feed.tsx b/traces/src/components/agent/activity-feed.tsx index 36d05a8..d1226d0 100644 --- a/traces/src/components/agent/activity-feed.tsx +++ b/traces/src/components/agent/activity-feed.tsx @@ -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' @@ -58,13 +59,17 @@ export function ActivityFeed() { }, [activity]) return ( -
-
-

Activity

+ /* + `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. + */ +
+ {activity.length > 0 ? ( - {activity.length} + {activity.length} ) : null} -
+
{activity.length === 0 ? ( @@ -84,7 +89,7 @@ export function ActivityFeed() { function FeedRow({ entry, now }: { entry: ActivityEntry; now: number | null }) { return ( -
  • +
  • {entry.description} @@ -94,7 +99,7 @@ function FeedRow({ entry, now }: { entry: ActivityEntry; now: number | null }) { the build could not have made. */} {now === null ? '' : formatAgo(entry.at, now)} @@ -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 @@ -117,7 +122,7 @@ 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. diff --git a/traces/src/components/agent/agent-lane.tsx b/traces/src/components/agent/agent-lane.tsx index cbe306d..1f2f6dc 100644 --- a/traces/src/components/agent/agent-lane.tsx +++ b/traces/src/components/agent/agent-lane.tsx @@ -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' @@ -52,9 +53,9 @@ const EXAMPLES = [ ] const TREATMENTS: Record = { - 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() { @@ -70,8 +71,8 @@ export function AgentLane() { } return ( -

    -

    Agent lane

    +
    + {tasks.length === 0 ? ( @@ -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" />
    ) @@ -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' ? ( - + ) : null} {task.text} {task.status === 'claimed' && task.claimedAt !== undefined && now !== null ? ( - + working, claimed {formatAgo(task.claimedAt, now)} ) : null} @@ -131,7 +132,7 @@ function TaskRow({ task, now }: { task: Task; now: number | null }) { function EmptyLane({ onPick }: { onPick: (text: string) => void }) { return ( -
    +

    Nothing queued. An agent calling claim_next_task waits here until you add something, then takes it without being asked twice. @@ -143,7 +144,7 @@ function EmptyLane({ onPick }: { onPick: (text: string) => void }) { diff --git a/traces/src/components/agent/ask-human-visual-prompt.tsx b/traces/src/components/agent/ask-human-visual-prompt.tsx index 6c84f27..b18568c 100644 --- a/traces/src/components/agent/ask-human-visual-prompt.tsx +++ b/traces/src/components/agent/ask-human-visual-prompt.tsx @@ -1,8 +1,10 @@ 'use client' +import { Eye } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { AuthorBadge } from '@/components/ui/author-badge' import { formatSeconds } from '@/components/ui/format-time' +import { SectionHeading } from '@/components/ui/section-heading' import { useWallClock } from '@/components/ui/use-clock' import { GATE_TIMEOUT_MS } from '@/lib/webmcp/blocking' import { useSessionStore } from '@/lib/store/session' @@ -92,19 +94,26 @@ export function AskHumanVisualPrompt() { const timedOut = waitedMs > GATE_TIMEOUT_MS return ( -

    -
    -

    - Agent needs your eyes -

    - +
    + {/* + The one glyph in the column, on the one section that can stop the agent. `Eye` rather than a warning + triangle: nothing is broken — the agent has hit a judgement a person has to make by looking, which is + also what the answer consists of. `MarkPointOverlay` carries the same glyph on the player, so the two + halves of this one interaction are recognisable as each other. + */} + } + > + waiting {Math.round(waitedMs / 1000)}s -
    + -

    {pendingAsk.question}

    +

    {pendingAsk.question}

    -

    +

    Answer on the player: put the playhead on the moment you mean, then pick one of the options over the replay. {pendingAsk.hintAtMs !== undefined @@ -113,7 +122,7 @@ export function AskHumanVisualPrompt() {

    {timedOut ? ( -

    +

    The agent’s call has already returned — it waited {Math.round(GATE_TIMEOUT_MS / 1000)}s and got a ticket back, so it is retrying rather than sitting still. Your answer still reaches it.

    @@ -129,24 +138,25 @@ export function AskHumanVisualPrompt() { * the moment it is answered, and quiet enough that it stops competing with whatever the agent does next. */ return ( -
    -

    - {resolved.answered ? 'You answered the agent' : 'You skipped the agent’s question'} -

    +
    + -

    {resolved.question}

    +

    {resolved.question}

    -

    +

    {resolved.outcome}

    {resolved.answered ? ( -

    +

    The moment you marked is now a marker on the timeline, and the agent has the timestamp.

    ) : ( -

    +

    The agent was told you skipped it, rather than being left waiting.

    )} diff --git a/traces/src/components/agent/hypothesis-cards.tsx b/traces/src/components/agent/hypothesis-cards.tsx index ab91256..f533ca9 100644 --- a/traces/src/components/agent/hypothesis-cards.tsx +++ b/traces/src/components/agent/hypothesis-cards.tsx @@ -1,7 +1,9 @@ 'use client' +import { Check, X } from 'lucide-react' import { AuthorBadge } from '@/components/ui/author-badge' import { formatSeconds } from '@/components/ui/format-time' +import { SectionHeading } from '@/components/ui/section-heading' import { sessionActions, useSessionStore } from '@/lib/store/session' import type { Hypothesis, HypothesisStatus } from '@/types/domain' @@ -37,9 +39,9 @@ import type { Hypothesis, HypothesisStatus } from '@/types/domain' */ const TREATMENTS: Record = { - proposed: { card: 'border-zinc-800', text: 'text-zinc-200', tag: null }, - promoted: { card: 'border-sky-500/50 bg-sky-500/5', text: 'text-zinc-100', tag: 'promoted' }, - rejected: { card: 'border-zinc-900 opacity-50', text: 'text-zinc-400 line-through', tag: 'rejected' }, + proposed: { card: 'border-line', text: 'text-ink', tag: null }, + promoted: { card: 'border-human/50 bg-human/5', text: 'text-ink', tag: 'promoted' }, + rejected: { card: 'border-panel opacity-50', text: 'text-muted line-through', tag: 'rejected' }, } export function HypothesisCards() { @@ -52,17 +54,16 @@ export function HypothesisCards() { const undecided = hypotheses.filter((hypothesis) => hypothesis.status === 'proposed').length return ( -
    -
    -

    Hypotheses

    +
    + {undecided > 0 ? ( - + {undecided === hypotheses.length ? 'the agent is waiting on your call' : `${undecided} still undecided`} ) : null} -
    +
      {hypotheses.map((hypothesis, index) => ( @@ -83,25 +84,39 @@ function HypothesisCard({ hypothesis, position }: { hypothesis: Hypothesis; posi return (
    • - {position} + {position}

      {hypothesis.text}

      - confidence + confidence + {/* + `agent`, not a severity: this bar is the agent's assessment of its own reasoning, which is + exactly what that token means everywhere else. The track is a surface, so it takes `raised`. + */} {treatment.tag ? ( - + /* + The verdict, as a glyph and a word. Both tags were `muted` and the same size, so telling a promoted + card from a rejected one down a stack of five meant reading two words that share four letters. The + glyph is the status; `tag` is non-null for exactly the two decided states, so the pair is complete. + */ + + {hypothesis.status === 'promoted' ? ( + + ) : ( + + )} {treatment.tag} ) : null} @@ -115,16 +130,20 @@ function HypothesisCard({ hypothesis, position }: { hypothesis: Hypothesis; posi type="button" onClick={() => sessionActions().setCurrentTime(item.atMs, 'human')} title={`Seek to ${item.atMs}ms — ${item.note}`} - className="flex items-baseline gap-1 border border-zinc-800 px-1 py-0.5 text-left hover:border-zinc-600" + className="flex items-baseline gap-1 border border-line px-1 py-0.5 text-left hover:border-faint focus-visible:border-ink focus-visible:outline-none" > - {formatSeconds(item.atMs)} - {item.note} + {/* + `ink`, not `human`: a seek is an affordance both parties use, and spending an authorship + token on one would make it decorative. The border and the mono type carry the link. + */} + {formatSeconds(item.atMs)} + {item.note}
    • ))}
    ) : ( -

    +

    No evidence attached — nothing on the timeline backs this one up yet.

    )} @@ -134,14 +153,14 @@ function HypothesisCard({ hypothesis, position }: { hypothesis: Hypothesis; posi label="promote" active={hypothesis.status === 'promoted'} decided={decided} - activeClass="border-sky-500/60 text-sky-200" + activeClass="border-human/60 text-human" onClick={() => sessionActions().promoteHypothesis(hypothesis.id, 'human')} /> sessionActions().rejectHypothesis(hypothesis.id, 'human')} />
    @@ -174,10 +193,10 @@ function Verdict({ ? `Change the record to ${label}d. The agent already has your first answer — this does not ask it again.` : `Mark this ${label}d. This is what the agent's call is waiting for.` } - className={`border px-1.5 py-0.5 text-[10px] uppercase tracking-wide ${ + className={`border px-1.5 py-0.5 text-[10px] uppercase tracking-wide focus-visible:border-ink focus-visible:outline-none ${ active ? `${activeClass} cursor-default` - : 'border-zinc-800 text-zinc-500 hover:border-zinc-600 hover:text-zinc-200' + : 'border-line text-muted hover:border-faint hover:text-ink' }`} > {label} diff --git a/traces/src/components/agent/report-draft.tsx b/traces/src/components/agent/report-draft.tsx index 46ac58b..54b3242 100644 --- a/traces/src/components/agent/report-draft.tsx +++ b/traces/src/components/agent/report-draft.tsx @@ -1,8 +1,10 @@ 'use client' +import { Check, Copy, TriangleAlert } from 'lucide-react' import { useEffect, useState } from 'react' import { AuthorBadge } from '@/components/ui/author-badge' import { formatSeconds } from '@/components/ui/format-time' +import { SectionHeading } from '@/components/ui/section-heading' import { sessionActions, useSessionStore } from '@/lib/store/session' import { answerReportReview, hasPendingReportReview } from '@/lib/webmcp/tools/propose-report' import type { Recording, Report } from '@/types/domain' @@ -102,16 +104,17 @@ export function ReportDraft() { const unverified = report.steps.filter((step) => !step.verified).length return ( -
    -
    -

    Report draft

    +
    + {awaitingAgent ? ( - waiting on your decision + + waiting on your decision + ) : decision !== null ? ( - {decision.kind} + {decision.kind} ) : null} -
    + {/* Editable in place: it reads as text until it has focus. A titled box with a pencil icon would make @@ -121,7 +124,7 @@ export function ReportDraft() { value={title} onChange={(event) => setTitle(event.target.value)} aria-label="Report title" - className="w-full border border-transparent bg-transparent text-xs font-medium text-zinc-100 hover:border-zinc-800 focus:border-zinc-600 focus:outline-none" + className="w-full border border-transparent bg-transparent text-xs font-medium text-ink hover:border-line focus:border-ink focus:outline-none" />