12 KiB
Capability Paperclip-style issue-thread UI
Capability renders the mock Paperclip issue as a native issue thread. A board user reads the thread, answers typed interactions inline, and inspects the evidence behind every mock mutation. The implementation follows the binding Capability issue-thread UX contract.
The same shell hosts a second primary path: the
clean-room live chat at #/chat, which starts a
blank thread on a freshly minted mock tenant instead of a preset scenario. It
reuses every surface described below — header, thread, composer, and Evidence
panel — and differs only in what the session is seeded with and in refusing any
non-live mode.
Authority boundary
browser -> package server -> CapabilityLiveSession -> paperclip-runnerd -> codex
(projection) CapabilitySemanticDispatcher -> ControlPlanePort mock
The browser holds no authority. It renders one shape,
CapabilityIssueThreadSnapshot (src/issue-thread/types.ts), and computes no
claim, policy decision, state diff, or parity verdict. Two producers emit that
shape:
capabilityIssueThreadFixture(slug)— deterministicfakesnapshots used by the screenshot matrix and the browser suite.projectCapabilityIssueThread({ snapshot })— the live projection. It runs in the package server and rearranges durable records only: the live session's transcript, evidence entries, semantic authorization records, and the serialized mock state.
The one browser-initiated mock mutation is an interaction response. It posts to
POST /api/capability/ui/interaction; CapabilityLiveSession.resolveInteraction stores
the typed response in the mock control plane before resuming the same Codex
thread, so the card only leaves submitting on server acknowledgement.
No provider, runner, or control-plane credential reaches the page. Redacted
fields render as ••• redacted with the redaction rule name, mock issues use
the reserved MCK- prefix, and no real Paperclip URL is ever rendered.
What the browser is allowed to see
The projection is an internal shape. What ships is the published DTO,
toCapabilityPublicThreadView (src/issue-thread/public-view.ts), which copies the
view field by field — so a field added to the projection, or to any record it
passes through, cannot reach a browser until it is listed there. Every response
path uses it: interim stream frames, the settled payload, and reconnect or
replay replies alike.
Three narrowings stack, so no single omission opens a disclosure path:
- At record time.
redactCapabilityEvidenceData(src/live/evidence-redaction.ts) is the only way an evidence entry enters a live session. Provider notifications are reduced to a coarse category, provider diagnostics to the fact that one occurred, tool arguments to the catalog-declared field names they used, and tool results to the outcome, revisions, and mock entity refs the UI resolves into cards. Provider thread and session identity, model and token metadata, and raw tool payloads are never retained, so no later reader, frame, or log can republish them. - In the projection.
Runner & eventsdetails are composed from the redacted record rather than stringified from it, andCalls & resultsnames the operation and its field count instead of echoing arguments. - In the DTO. Provider-authored turn and call identifiers are replaced by
in-view aliases (
turn-1,call-1) that stay consistent across anchors, evidence refs, and successive frames of one turn, and any value the caller declares withheld is scrubbed from the encoded result.
A streamed turn that fails answers with a code and fixed operator copy; the underlying message stays server side, because provider text can quote prompts and paths.
Session capability
Every session-scoped route is bound to a per-browser capability. The server
mints one on session creation, stores only its SHA-256 with the session record,
sets it as an HttpOnly; SameSite=Strict cookie, and compares it in constant
time on every read and mutation — message, reconnect, interaction, stop, reset,
and new chat. A valid session id presented without its capability is answered
404, exactly like an id that never existed, so an unauthorized caller cannot
tell a live session from a dead one.
Rotation belongs to the actions that start something new — New chat, a scenario
POST, reset. Each revokes the caller's existing bindings before issuing the
replacement cookie. Reopening a page whose stored id is simply gone mints a
session under the capability the browser already holds instead, because rotating
there would make two tabs of one surface revoke each other on every load while
protecting nothing: one browser is one principal, and cross-browser denial rests
on the binding rather than on how often the value changes.
The two surfaces use separate cookie names (paperclip_capability_issue,
paperclip_capability_chat) because they are separate pages of one origin: a single
name would make opening the explorer revoke the clean room. A script that drives
these routes has to behave like one browser — scripts/capability-cookie-jar.mjs is
what the smoke scripts use for that.
Surfaces
- Header — three identity chips (
Real Codex/Fake agent/Replay,Real runnerd/In-process runner, andMock Paperclipin every mode), status, priority, run state, and the Scenario/Replay/Reset/Stop controls.data-session-modecarries the mode as data, never as styling. - Thread — turn groups binding the contract's T1–T11 item types: user
messages, model prose, durable progress comments marked
Recorded to mock thread, collapsed tool strips, interaction cards, document revision cards, deliverables, delegation cards, terminal dispositions, typed denials, and muted system notices. - Composer — six mutually exclusive states behind
data-composer-state:ready,sending,streaming(input stays editable to steer; Stop is primary),waiting,reconnecting, anddisabled. Drafts survive refresh. - Evidence panel — eight accordion sections in fixed order: Tools exposed,
Calls & results, Authorization, Control plane, Runner & events, State diff,
Traceability, Parity. The Tools section groups
Agent tool — always,Agent tool — granted(with its grant), and a separatedControl plane (not exposed to the agent)list, because what the model cannot call is first-class evidence. Every strip, denial, and card deep-links into the matching record, and each record links back to its thread anchor. Live sessions additionally put a six-tab DevTools inspector above these sections: revision timeline, complete browser-safe company state, structural diff, protocol records, runtime, and authority. The inspector can pause live following, export redacted JSON, and fork a retained revision.
The panel is collapsed by default and resizable between 320px and 640px with a
keyboard-operable splitter. Below 1100px it becomes an overlay sheet that
Escape dismisses; below 768px the page switches to a Thread / Evidence
segmented control, with Stop kept outside the ⋯ menu while a turn is active.
Closing the panel by either route hands focus back to the visible control that
owns it.
- Replay strip — in
mode=replaya progress strip pins under the header withStep back,Next turn, andPlay all.?at=<ordinal>is the single source of truth for the parked ordinal, so the three controls and the deep link all move the same value;Play alladvances one ordinal every 800 ms and parks itself at the end of the recording.
Routes
#/issue/<fixtureProfile>?shot=<slug>&panel=<section>&rec=<id>&at=<ordinal>&seg=thread|evidence&mode=live
shotseeds one of the twelve deterministicfakestates.mode=liveopts into the package session server; the default isfake.capture=1freezes animation, caret, and smooth scrolling for screenshots.- The root element sets
data-thread-state="settled"once hydration, fixture load, and auto-scroll finish. Tooling waits for that attribute, never a timeout.
Commands
# Deterministic fake-mode app (no provider process)
pnpm --filter @paperclipai/paperclip-runner console:issue-thread
# Focused browser suite, including the axe gate on all 12 slugs × 2 viewports
pnpm --filter @paperclipai/paperclip-runner test:browser:scenarios
# View-model and live-projection unit tests
pnpm --filter @paperclipai/paperclip-runner exec vitest run src/issue-thread
# Screenshot matrix (12 slugs × 2 viewports) and its byte-stability check
# Recorded evidence generation is deferred from this release.
pnpm --filter @paperclipai/paperclip-runner check:capability:ui
# Real runnerd + real Codex through the same HTTP routes the browser uses
pnpm --filter @paperclipai/paperclip-runner smoke:capability:ui
# Recorded evidence generation is deferred from this release.
Hosts without the Playwright chromium system libraries can either run
pnpm --filter @paperclipai/paperclip-runner verify:rootless or set
PAPERCLIP_RUNNER_CHROMIUM_PATH to a preinstalled Chromium.
The committed PNGs are pinned to the Chromium build listed in
.paperclip-local/evidence/capability/ui/index.md, so check:capability:ui needs that same
browser. Point PAPERCLIP_RUNNER_CHROMIUM_PATH at the recorded browser before
comparing — and when that path is the agent-browser wrapper, also set
PAPERCLIP_CHROMIUM_BIN to the exact binary, because the wrapper otherwise
picks the newest installed Playwright Chromium. The issue-thread bundle
self-hosts its Latin Inter and DejaVu Sans Mono WOFF2 faces plus tiny status-glyph
subsets, so host fontconfig directories do not participate in capture. The recorder probes the
package-specific bundled families and refuses to record or compare when either
face is absent or fails to load; the drift report prints the Chromium version
and bundled-font probe it recorded with.
Accessibility
The suite enforces the contract's blocking gate: axe reports zero serious or
critical WCAG 2.1 A/AA violations on every screenshot route at both viewports.
Structure is one h1, header/main/complementary landmarks, a form
composer, section interaction cards labelled by their prompt, and tool strips
as disclosure buttons with aria-expanded. Every state chip pairs color with a
glyph and text, all actionable controls clear 44×44 CSS px on mobile, and
prefers-reduced-motion disables the pulse dot, banner slide, and smooth
scrolling.
Focus management (§9.2) is covered by named regressions in the browser suite:
- Opening Evidence moves focus to its heading; closing it — with the
Closebutton or with Escape on the overlay sheet — returns focus to the toggle. - Resolving an interaction card moves focus to the card's state chip. The
controls the user just operated unmount on resolve, so without this the
keyboard caret drops to
bodyat the moment the card changes. - The
waitingcomposer's anchor moves focus to the pending card's first control.
Contract deviations
One deviation is recorded against the Capability contract:
- §5 expired-family dimming. The contract asks for a 60% opacity body on
stale_targetand the other expired outcomes. A literal opacity drops that card's text to ~3.2:1 and fails the blocking axe gate in §9.7. The dim is implemented as a recessed surface plus muted text that still clears 4.5:1.
Determinism notes
Fake-mode fixtures render from authored data with a fixed clock, so two captures
of a slug from a clean checkout are byte-identical. Live sessions are not
byte-stable — a real model writes their prose — so live evidence lives in
.paperclip-local/evidence/capability/ui-live/ and is excluded from the determinism gate.
Durable comments in live mode carry the mock control plane's own deterministic
clock rather than wall time, because that is the timestamp on the mock record.