SLOPSHOPPER

replay-qa

Use Replay QA from Claude Code with script-backed skills and slash commands, and watch explorations and journey runs stream live: inline rrweb players in the…

newpanerowsguardcommandtoast
v0.11.0MITupdated 2026-10-20replayio/plugins/claude-code/replay-qa
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · replay-qa
│ ┃ replay-qa-canvas ✕ › fix the failing auth test and add an audit log call │ ┃ No project: run /replayqa canvas <project │ ┃ id>. ● replay-qa: replay-qa: live relay did not start: relay exited before │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /replayqa │ ⎿ replay-qa: The Replay QA relay is not running: relay exited befo │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · replay-qa-canvas
No project: run /replayqa canvas <project id>.
README

Replay.io Plugins

This repository is a shadcn GitHub registry for Replay.io agent and editor plugin bundles.

Install a bundle with shadcn add:

npx shadcn@latest add replayio/plugins/codex
npx shadcn@latest add replayio/plugins/codex-pro
npx shadcn@latest add replayio/plugins/cursor
npx shadcn@latest add replayio/plugins/claude-code
npx shadcn@latest add replayio/plugins/claude-pro
npx shadcn@latest add replayio/plugins/opencode
npx shadcn@latest add replayio/plugins/obvious-ai

codex and claude-code are Replay QA for production, staging, and PR preview QA runs. codex-pro and claude-pro are direct Replay.io dev tools bundles with Replay MCP, Replay Chromium, worker/critic proof-loop prompts, browser lifecycle screencast capture, ffmpeg-backed MP4 output, side-by-side video stitching, upload hooks, and MP4 video embedding.

Install the Codex bundles as a Codex plugin marketplace:

codex plugin marketplace add https://github.com/replayio/plugins --ref main
codex plugin add replay-qa@replayio-plugins
codex plugin add replayio@replayio-plugins

If you use sparse checkout when adding the marketplace, include both the marketplace manifest and the plugin bundle path:

codex plugin marketplace add https://github.com/replayio/plugins \
  --ref main \
  --sparse .agents/plugins/marketplace.json \
  --sparse codex/replay-qa \
  --sparse codex/replayio

Install the Claude Code plugins from the Claude Code plugin marketplace:

claude plugin marketplace add replayio/plugins
claude plugin install replay-qa@replayio
claude plugin install replayio@replayio

Registry Items

ItemInstalls
codexCodex Replay QA app installed to .codex-plugin/, skills/replay-qa/, assets/, and packaged scripts for project setup, status, bugs, journeys, test runs, explorations, and fix verification.
codex-proCodex Replay.io Pro dev tools installed to .codex-plugin/, .mcp.json, .app.json, agents/openai.yaml, hooks.json, scripts/, skills/replayio/, and assets/, with worker/critic proof-loop prompts, browser-open/browser-close lifecycle scripts, a side-by-side stitch helper, and hooks for screencast capture, ffmpeg-backed MP4 output, and Replay uploads.
cursorCursor plugin bundle installed to .cursor-plugin/, skills/, assets/, hooks, scripts, MCP config, and worker/critic proof-loop prompts.
claude-codeClaude Code Replay QA plugin installed to .claude/skills/replay-qa/, including plugin commands and script-backed skill helpers.
claude-proClaude Code Replay.io Pro plugin installed to .claude/skills/replayio/, .claude/agents/, hooks, scripts, MCP config, commands, and Replay Pro skill guidance.
opencodeOpenCode plugin bundle installed to .opencode/plugins/, .opencode/agent/, AGENTS.md, and opencode.json, including worker/critic proof-loop subagents.
obvious-aiSkills-only Obvious bundle installed to .obvious/skills/replay-qa-api/SKILL.md, including direct Replay QA API, Replay CLI token fallback, artifact, and project-reuse guidance.

The registry reads generated source files from the top-level platform directories, but each item installs into the platform-specific destination paths that plugin hosts expect.

Validate

Validate the registry before publishing changes:

npx shadcn@latest registry validate registry.json
Source 4 files
hooks/register.tsx 888 lines
1/* @jsx h */
2import type { EngineInterface, Register } from 'claude-code'
3
4import { isRunningStatus, parseQaCommand, parseQaOutput, type QaCommand } from './detect.ts'
5
6// Live Replay QA in the transcript. When Claude starts an exploration, or a bug marked fixed makes
7// the backend re-test a journey, the relay (live/relay.mjs) reads that run's rrweb stream from the
8// QA API as it happens, and a headless renderer plays it into PNG frames this module draws as an
9// Image on the call's own row, scrubbable once it ends. `/replayqa canvas` opens a pane holding
10// every journey of the project as rrweb players, the running ones streaming in parallel.
11
12type Relay = { port: number; token: string; dataDir: string }
13
14type RecordingInfo = {
15  id: string
16  kind: 'task' | 'test_run' | 'exploration'
17  projectId: string
18  sourceId: string
19  title: string
20  status: 'loading' | 'live' | 'ended' | 'error'
21  error: string | null
22  url: string | null
23  eventCount: number
24  durationMs: number
25  note: { text: string; type: string; step: { index: number; action: string } | null } | null
26  lastError: string | null
27}
28
29type FrameInfo = {
30  file: string
31  generation: number
32  width: number
33  height: number
34  kind: 'recording' | 'canvas'
35  mode: 'live' | 'replay' | 'canvas'
36  status: 'playing' | 'paused' | 'finished' | 'stopped'
37  currentMs: number
38  totalMs: number
39  /** When `currentMs` was true, on the relay's clock (the same machine's). */
40  at: number
41  /** Bumped by every seek, play and pause. */
42  seq: number
43  recording: RecordingInfo | null
44}
45
46type Tile = {
47  key: string
48  kind: 'test_run' | 'exploration' | 'journey'
49  sourceId: string
50  recordingId: string | null
51  title: string
52  status: string
53  isLive: boolean
54  bugs: number
55  journeyId: string | null
56  startedAt: string | null
57}
58
59type Overview = {
60  project: { id: string; name: string; target_url: string | null; status: string } | null
61  /** The QA server's API base when it is not the default one (a PR preview, staging). */
62  server?: string | null
63  error: string | null
64  updatedAt: number
65  following: boolean
66  counts: { live: number; journeys: number; bugs: number; failed: number }
67  tiles: Tile[]
68}
69
70type RowKind = 'exploration' | 'test_run'
71
72/** A transcript row that holds a live player. */
73type Row = { projectId: string; recordingId: string; kind: RowKind; sourceId: string; title: string }
74
75type PlayerAction = 'play' | 'pause' | 'toggle' | 'seek' | 'seekBy' | 'replay' | 'stop'
76
77type Pending = { rowId: string; projectId: string; since: number; journeyId?: string }
78
79const PANE = 'replay-qa-canvas'
80const IMAGE_KEY = 'player'
81const CANVAS_KEY = 'canvas'
82const STORE_PREFIX = 'qa-player:'
83const LAST_PROJECT_KEY = 'qa-last-project'
84const MAX_CELLS = 255
85// A terminal cell is about twice as tall as it is wide.
86const CELL_ASPECT = 2.1
87const OVERVIEW_MS = 4_000
88const PENDING_MS = 1_500
89/** A re-test the backend was asked to start is awaited this long. */
90const PENDING_GIVE_UP_MS = 90_000
91/** A followed project with no live work is let go after this, unless its canvas is open. */
92const IDLE_UNFOLLOW_MS = 15 * 60_000
93
94const state = {
95  /** The session's working directory: where a Bash command runs unless it `cd`s. */
96  cwd: null as string | null,
97  relay: null as Relay | null,
98  relayError: null as string | null,
99  hasLogo: false,
100  /** Transcript row (tool_use_id) -> the player it holds. */
101  rows: new Map<string, Row>(),
102  /** Why a row has no player (the relay refused, the token is missing). */
103  rowErrors: new Map<string, string>(),
104  frames: {} as Record<string, FrameInfo>,
105  /** What each Image was last drawn with, so a frame of the same size can be blitted. */
106  mounted: new Map<string, { columns: number; rows: number; width: number; height: number }>(),
107  offscreen: new Set<string>(),
108  watcher: 0,
109  overviewWatcher: 0,
110  lastProjectId: null as string | null,
111  overviews: new Map<string, Overview>(),
112  /** project id -> when it last had live work (or was followed), for letting idle ones go. */
113  activeAt: new Map<string, number>(),
114  pending: [] as Pending[],
115  /** Recordings a restored row asked the relay to load again. */
116  requested: new Set<string>(),
117  /** Recordings that ended while their screencast was live, parked at the end. */
118  parked: new Set<string>(),
119  canvasProjectId: null as string | null,
120  /** Where the canvas picture sits in the pane body, for the wheel; the screencast size last asked for. */
121  canvasPicture: null as { top: number; columns: number; rows: number } | null,
122  canvasAsked: null as { width: number; height: number } | null,
123  /** The canvas camera as its last move left it: the zoom, and where the minimap sits (fractions of the picture). */
124  camera: null as CameraState | null,
125  /** Camera moves go to the relay one after another, in order. */
126  cameraQueue: Promise.resolve() as Promise<unknown>,
127  statusLine: undefined as string | undefined,
128}
129
130// ------------------------------------------------------------------- relay
131
132const relayUrl = (path: string) => {
133  const relay = state.relay
134  if (!relay) throw new Error('the Replay QA relay is not running')
135  const join = path.includes('?') ? '&' : '?'
136  return `http://127.0.0.1:${relay.port}${path}${join}token=${relay.token}`
137}
138
139async function relayJson<T>($: EngineInterface, path: string, body?: unknown): Promise<T> {
140  const response = await $.http.fetch(relayUrl(path), body === undefined
141    ? {}
142    : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
143  const parsed = response.text ? JSON.parse(response.text) : null
144  if (!response.ok) throw new Error(parsed?.error ?? `relay answered ${response.status}`)
145  return parsed as T
146}
147
148/** Starts the relay for the session's life and resolves once it is listening. */
149function startRelay($: EngineInterface): Promise<Relay> {
150  return new Promise((resolve, reject) => {
151    void (async () => {
152      let stdout = ''
153      let ready = false
154      try {
155        const child = $.process.spawn({ argv: ['node', `${$.plugin.root}/live/relay.mjs`] })
156        for await (const piece of child) {
157          if (piece.stream === 'stderr') {
158            $.ui.log(piece.text.trimEnd(), { to: 'debug' })
159            continue
160          }
161          if (ready) continue
162          stdout += piece.text
163          const line = stdout.split('\n').find(text => text.startsWith('{') && text.endsWith('}'))
164          if (!line) continue
165          ready = true
166          resolve(JSON.parse(line) as Relay)
167        }
168      } catch (err) {
169        if (!ready) reject(err)
170      }
171      state.relay = null
172      if (!ready) reject(new Error(`relay exited before listening: ${stdout.slice(-300)}`))
173    })()
174  })
175}
176
177const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
178
179/** The frame id the relay gives a project's canvas screencast. */
180const canvasFrameId = (projectId: string) => `canvas-${projectId.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 120)}`
181
182function imageSource(frame: { file: string; generation: number }) {
183  return { file: frame.file, format: 'png' as const, generation: frame.generation }
184}
185
186function openUrl($: EngineInterface, url: string): void {
187  // The URL carries the relay's token: never put it in a toast or the transcript.
188  void $.process.run(['open', url]).catch(() => $.ui.toast('Could not open the browser'))
189}
190
191const playerPage = (recordingId: string) => relayUrl(`/player/${encodeURIComponent(recordingId)}?mode=auto&chrome=1`)
192const canvasPage = (projectId: string) => relayUrl(`/canvas/${encodeURIComponent(projectId)}`)
193
194// ---------------------------------------------------------------- projects
195
196/**
197 * `.replay/config.json` at the git root: the scripts keep `qa-project-id` there, and the replayqa
198 * CLI the QA server its tunnel talks to (`proxy.qa-url`, set for a PR preview or staging).
199 */
200async function readConfig($: EngineInterface): Promise<{ projectId: string | null; qaUrl: string | null }> {
201  try {
202    const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 5_000, ...(state.cwd ? { cwd: state.cwd } : {}) })
203    const root = top.exitCode === 0 ? top.stdout.trim() : state.cwd
204    if (!root) return { projectId: null, qaUrl: null }
205    const config = JSON.parse(await $.fs.read(`${root}/.replay/config.json`)) as Record<string, any>
206    const text = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : null)
207    return { projectId: text(config['qa-project-id']), qaUrl: text(config.proxy?.['qa-url']) }
208  } catch {
209    return { projectId: null, qaUrl: null }
210  }
211}
212
213/** The project to act on: the one named, else the repo's configured one, else the last seen. */
214async function resolveProjectId($: EngineInterface, named?: string | null): Promise<string | null> {
215  return named || (await readConfig($)).projectId || state.lastProjectId
216}
217
218/**
219 * A `/replayqa` argument: a project id, or a dashboard URL, which also names the QA server, such
220 * as a PR preview's `https://pr-1--qa.netlify.app/projects/proj-x/test-runs/…`.
221 */
222function parseProjectArg(arg: string | undefined): { projectId?: string; server?: string } {
223  if (!arg) return {}
224  const projectId = /proj-[A-Za-z0-9_-]+/.exec(arg)?.[0]
225  return { ...(projectId ? { projectId } : {}), ...(/^https?:\/\//.test(arg) ? { server: arg } : {}) }
226}
227
228/** The QA server a project is on when nothing named one: the repo's `proxy.qa-url`, for the repo's project. */
229async function configServer($: EngineInterface, projectId: string): Promise<string | undefined> {
230  const config = await readConfig($)
231  return config.qaUrl && config.projectId === projectId ? config.qaUrl : undefined
232}
233
234async function rememberProject($: EngineInterface, projectId: string): Promise<void> {
235  if (state.lastProjectId === projectId) return
236  state.lastProjectId = projectId
237  await $.store.set(LAST_PROJECT_KEY, projectId)
238}
239
240/**
241 * Turns the project's running work into recordings as it starts; resolves with its overview.
242 * `server` names the QA server it is on when that is not the default (the relay also finds a
243 * running `replayqa proxy --qa-url` tunnel for it by itself).
244 */
245async function followProject($: EngineInterface, projectId: string, server?: string): Promise<Overview> {
246  const overview = await relayJson<Overview>($, '/qa/follow', { projectId, on: true, ...(server ? { server } : {}) })
247  state.overviews.set(projectId, overview)
248  state.activeAt.set(projectId, Date.now())
249  await rememberProject($, projectId)
250  if (overview.error) $.ui.toast(`Replay QA: ${overview.error}`)
251  return overview
252}
253
254async function unfollowProject($: EngineInterface, projectId: string): Promise<void> {
255  await relayJson($, '/qa/follow', { projectId, on: false })
256  state.overviews.delete(projectId)
257  state.activeAt.delete(projectId)
258}
259
260function liveCount(): number {
261  let total = 0
262  for (const o of state.overviews.values()) total += o.counts.live
263  return total
264}
265
266function updateStatusLine($: EngineInterface): void {
267  const live = liveCount()
268  const line = live > 0 ? `◉ Replay QA: ${live} live` : undefined
269  if (line === state.statusLine) return
270  state.statusLine = line
271  $.ui.status(line)
272}
273
274// ------------------------------------------------------------------ players
275
276/** Gives a transcript row the player of one exploration or run, and starts its screencast. */
277async function attachRow(
278  $: EngineInterface,
279  rowId: string,
280  spec: { projectId: string; kind: RowKind; sourceId: string; title: string; goal?: string | null },
281): Promise<void> {
282  try {
283    const watched = await relayJson<{ recording: RecordingInfo }>($, '/qa/watch', {
284      projectId: spec.projectId, kind: spec.kind, sourceId: spec.sourceId, title: spec.title, goal: spec.goal ?? null,
285    })
286    const row: Row = { projectId: spec.projectId, recordingId: watched.recording.id, kind: spec.kind, sourceId: spec.sourceId, title: spec.title }
287    state.rows.set(rowId, row)
288    state.rowErrors.delete(rowId)
289    await $.store.set(`${STORE_PREFIX}${rowId}`, row)
290    await relayJson($, `/recordings/${encodeURIComponent(row.recordingId)}/player`, { mode: 'live' })
291  } catch (err) {
292    state.rowErrors.set(rowId, message(err))
293    $.ui.toast(`Replay QA: ${message(err)}`)
294  }
295  $.ui.invalidate('ui.render')
296}
297
298/**
299 * The live test run a re-test the backend was asked to start became. Other journeys may be running
300 * too, so a run is taken only if it is of the journey re-tested, or (the journey unknown) started
301 * after the command did.
302 */
303function freshLiveRun(projectId: string, want: { journeyId?: string; since: number }): Tile | undefined {
304  const shown = new Set([...state.rows.values()].map(r => r.recordingId))
305  const live = (state.overviews.get(projectId)?.tiles ?? []).filter(t => t.isLive && t.kind === 'test_run' && t.recordingId && !shown.has(t.recordingId))
306  if (want.journeyId) return live.find(t => t.journeyId === want.journeyId)
307  return live.find(t => (t.startedAt ? Date.parse(t.startedAt) : 0) >= want.since - 10_000)
308}
309
310/** Rows waiting for a re-test run get it as soon as the project overview shows one running. */
311async function settlePending($: EngineInterface): Promise<void> {
312  if (state.pending.length === 0) return
313  const waiting: Pending[] = []
314  for (const item of state.pending) {
315    const tile = freshLiveRun(item.projectId, item)
316    if (tile) {
317      await attachRow($, item.rowId, { projectId: item.projectId, kind: 'test_run', sourceId: tile.sourceId, title: tile.title })
318    } else if (Date.now() - item.since < PENDING_GIVE_UP_MS) {
319      waiting.push(item)
320    } else {
321      state.rowErrors.set(item.rowId, 'No test run started yet: the backend may still be queueing it. Open the canvas to watch for it.')
322      $.ui.invalidate('ui.render')
323    }
324  }
325  state.pending = waiting
326}
327
328/**
329 * What a Replay QA call (a Bash script, or a QA MCP tool) did: follow its project, and give the
330 * row the exploration it started, or the re-test run it caused.
331 */
332async function handleQaResult(
333  $: EngineInterface,
334  rowId: string,
335  command: QaCommand | null,
336  output: string,
337  since: number,
338): Promise<void> {
339  const parsed = parseQaOutput(output)
340  const startedExploration = parsed.explorationId && isRunningStatus(parsed.explorationStatus) && (!command || command.script === 'start-exploration')
341  const wantsFollow = Boolean(startedExploration || command?.startsRun || (command && ['status', 'full-qa', 'bootstrap'].includes(command.script)))
342  if (!wantsFollow) return
343  const projectId = parsed.projectId ?? (await resolveProjectId($, command?.projectId))
344  if (!projectId) {
345    $.ui.log('replay-qa: no project id in the output, the arguments or .replay/config.json')
346    return
347  }
348  const server = command?.server ?? parsed.qaUrl ?? (await configServer($, projectId))
349  const overview = await followProject($, projectId, server)
350  if (startedExploration && parsed.explorationId) {
351    await attachRow($, rowId, {
352      projectId, kind: 'exploration', sourceId: parsed.explorationId,
353      title: parsed.prompt ? `Exploration: ${parsed.prompt.slice(0, 70)}` : `Exploration ${parsed.explorationId}`, goal: parsed.prompt,
354    })
355  } else if (command?.startsRun) {
356    // The backend queues the run on its own; the row takes the first live run that appears.
357    const want = { journeyId: parsed.journeyId, since }
358    const tile = freshLiveRun(projectId, want)
359    if (tile) await attachRow($, rowId, { projectId, kind: 'test_run', sourceId: tile.sourceId, title: tile.title })
360    else if (!overview.error) state.pending.push({ rowId, projectId, ...want })
361  }
362}
363
364async function play($: EngineInterface, recordingId: string, mode: PlayerAction, ms?: number): Promise<void> {
365  try {
366    await relayJson($, `/recordings/${encodeURIComponent(recordingId)}/player`, { mode, ms })
367  } catch (err) {
368    $.ui.toast(`Replay QA player: ${message(err)}`)
369  }
370}
371
372/** After a restart a row has no player: ask the relay to load its recording again, once. */
373async function reviveRow($: EngineInterface, row: Row): Promise<void> {
374  if (state.requested.has(row.recordingId)) return
375  state.requested.add(row.recordingId)
376  try {
377    await relayJson($, '/qa/watch', { projectId: row.projectId, kind: row.kind, sourceId: row.sourceId, title: row.title })
378    await relayJson($, `/recordings/${encodeURIComponent(row.recordingId)}/player`, { mode: 'live' })
379  } catch (err) {
380    state.requested.delete(row.recordingId)
381    $.ui.log(`replay-qa: could not load ${row.recordingId}: ${message(err)}`)
382  }
383}
384
385// ------------------------------------------------------------------ canvas
386
387async function showCanvas($: EngineInterface, projectId: string, server?: string): Promise<void> {
388  await followProject($, projectId, server)
389  state.canvasProjectId = projectId
390  await $.ui.open({ id: PANE, title: 'Replay QA', rows: 26 })
391  await relayJson($, `/canvas/${encodeURIComponent(projectId)}/view`, { on: true, width: 1600, height: 900 })
392  $.ui.invalidate('ui.render')
393}
394
395async function hideCanvas($: EngineInterface): Promise<void> {
396  const projectId = state.canvasProjectId
397  state.canvasProjectId = null
398  state.canvasPicture = null
399  state.canvasAsked = null
400  state.camera = null
401  if (projectId) await relayJson($, `/canvas/${encodeURIComponent(projectId)}/view`, { on: false }).catch(() => {})
402}
403
404type CameraMove =
405  | { op: 'zoom'; factor: number; x?: number; y?: number }
406  | { op: 'pan'; dx: number; dy: number }
407  | { op: 'focus'; x: number; y: number }
408  | { op: 'minimap'; x: number; y: number }
409  | { op: 'gesture'; id: number; sx: number; sy: number; x: number; y: number; done: boolean }
410  | { op: 'fit' }
411  | { op: 'actual' }
412  | { op: 'state' }
413
414type Rect = { x: number; y: number; w: number; h: number }
415type CameraState = { k: number; minimap: Rect | null }
416
417/**
418 * Moves the canvas screencast's camera (points and distances are fractions of the picture), and keeps
419 * what it answers: the zoom the footer shows and the minimap the camera layer lets the pointer jump on.
420 */
421function moveCamera($: EngineInterface, move: CameraMove): void {
422  const projectId = state.canvasProjectId
423  if (!projectId) return
424  state.cameraQueue = state.cameraQueue
425    .then(async () => {
426      const { result } = await relayJson<{ result: CameraState | null }>($, `/canvas/${encodeURIComponent(projectId)}/camera`, move)
427      if (!result || typeof result.k !== 'number' || state.canvasProjectId !== projectId) return
428      const before = state.camera
429      state.camera = { k: result.k, minimap: result.minimap ?? null }
430      const pct = (c: CameraState | null) => (c ? Math.round(c.k * 100) : null)
431      if (pct(before) !== pct(state.camera) || JSON.stringify(before?.minimap) !== JSON.stringify(state.camera.minimap)) $.ui.invalidate('ui.render')
432    })
433    .catch(err => $.ui.log(`replay-qa: canvas camera: ${message(err)}`))
434}
435
436/** The screencast takes the pane picture's shape: asked again when it changes by more than a little. */
437function sizeCanvas($: EngineInterface, projectId: string, columns: number, rows: number): void {
438  const width = 1600
439  const height = Math.round((width * rows * CELL_ASPECT) / columns)
440  const asked = state.canvasAsked
441  if (asked && Math.abs(asked.height - height) / asked.height < 0.04) return
442  state.canvasAsked = { width, height }
443  void relayJson($, `/canvas/${encodeURIComponent(projectId)}/view`, { on: true, width, height })
444    .then(() => moveCamera($, { op: 'state' }))
445    .catch(err => $.ui.log(`replay-qa: canvas resize: ${message(err)}`))
446}
447
448const fraction = (v: unknown, lo = 0, hi = 1) => (typeof v === 'number' && Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : null)
449
450// ---------------------------------------------------------------- watchers
451
452/** Follows the relay's frame feed: blits new frames, parks ended streams, redraws on change. */
453async function watchFrames($: EngineInterface): Promise<void> {
454  const watcher = ++state.watcher
455  let version = -1
456  while (state.watcher === watcher && state.relay) {
457    let snapshot: { version: number; frames: Record<string, FrameInfo> }
458    try {
459      snapshot = await relayJson($, `/frames?version=${version}`)
460    } catch {
461      await $.clock.sleep(1000)
462      continue
463    }
464    version = snapshot.version
465    let redraw = false
466    for (const [id, fresh] of Object.entries(snapshot.frames)) {
467      const before = state.frames[id]
468      state.frames[id] = fresh
469      if (fresh.kind === 'canvas') {
470        if (!state.canvasProjectId || id !== canvasFrameId(state.canvasProjectId)) continue
471        const mounted = state.mounted.get(PANE)
472        const fits = mounted && mounted.width === fresh.width && mounted.height === fresh.height
473        if (!before || !fits || before.generation === 0 || before.status !== fresh.status) {
474          redraw = true
475        } else if (fresh.generation !== before.generation) {
476          const blit = await $.ui.blit({ requestId: PANE, key: CANVAS_KEY, source: imageSource(fresh) })
477          if (blit.deny) redraw = true
478        }
479        continue
480      }
481      const rec = fresh.recording
482      // A stream that ended while its screencast followed it live is parked on its last moment.
483      if (rec && rec.status === 'ended' && fresh.mode === 'live' && !state.parked.has(id)) {
484        state.parked.add(id)
485        await play($, id, 'seek', rec.durationMs)
486      }
487      const rows = [...state.rows.entries()].filter(([, row]) => row.recordingId === id)
488      if (rows.length === 0) continue
489      const changedShape = !before
490        || before.status !== fresh.status
491        || before.mode !== fresh.mode
492        || before.seq !== fresh.seq
493        || before.totalMs !== fresh.totalMs
494        || before.recording?.status !== rec?.status
495        || before.recording?.note?.text !== rec?.note?.text
496        || before.recording?.lastError !== rec?.lastError
497        || Math.floor((before.recording?.eventCount ?? 0) / 25) !== Math.floor((rec?.eventCount ?? 0) / 25)
498      for (const [rowId] of rows) {
499        const mounted = state.mounted.get(rowId)
500        const fits = mounted && mounted.width === fresh.width && mounted.height === fresh.height
501        if (changedShape || !fits || before?.generation === 0) {
502          redraw = true
503          continue
504        }
505        if (fresh.generation === before?.generation || state.offscreen.has(rowId)) continue
506        const blit = await $.ui.blit({ requestId: rowId, key: IMAGE_KEY, source: imageSource(fresh) })
507        if (blit.deny) redraw = true
508      }
509    }
510    if (redraw) $.ui.invalidate('ui.render')
511  }
512}
513
514/** Keeps each followed project's overview fresh (status line, canvas header) and settles waiting rows. */
515async function watchOverviews($: EngineInterface): Promise<void> {
516  const watcher = ++state.overviewWatcher
517  while (state.overviewWatcher === watcher && state.relay) {
518    let changed = false
519    for (const projectId of [...state.overviews.keys()]) {
520      try {
521        const before = state.overviews.get(projectId)
522        const overview = await relayJson<Overview>($, `/qa/overview?project=${encodeURIComponent(projectId)}`)
523        state.overviews.set(projectId, overview)
524        if (!before || before.counts.live !== overview.counts.live || before.error !== overview.error || before.counts.bugs !== overview.counts.bugs || before.counts.failed !== overview.counts.failed) changed = true
525        if (overview.counts.live > 0 || state.canvasProjectId === projectId) state.activeAt.set(projectId, Date.now())
526        else if (Date.now() - (state.activeAt.get(projectId) ?? Date.now()) > IDLE_UNFOLLOW_MS) await unfollowProject($, projectId)
527      } catch (err) {
528        $.ui.log(`replay-qa: overview of ${projectId}: ${message(err)}`)
529      }
530    }
531    await settlePending($)
532    updateStatusLine($)
533    if (changed && state.canvasProjectId) $.ui.invalidate('ui.render')
534    // The canvas fits itself as tiles come and go: keep the zoom and the minimap the pane shows in step.
535    if (state.canvasProjectId) moveCamera($, { op: 'state' })
536    await $.clock.sleep(state.pending.length ? PENDING_MS : OVERVIEW_MS)
537  }
538}
539
540// ----------------------------------------------------------------- drawing
541
542function recordingLabel(row: Row, rec: RecordingInfo | null | undefined): string {
543  const what = row.kind === 'exploration' ? 'exploration' : 'journey'
544  const events = rec ? ` · ${rec.eventCount.toLocaleString()} events` : ''
545  const state = !rec ? 'starting' : rec.status === 'live' ? '◉ LIVE' : rec.status === 'loading' ? '… loading' : rec.status === 'error' ? '⚠ error' : '■ ended'
546  return `Replay QA · ${what}  ${state}${events}`
547}
548
549export const register: Register = on => {
550  on('session.start', async ($, e, next) => {
551    state.cwd = e.cwd
552    const started = await next(e)
553    try {
554      state.hasLogo = await $.fs.exists(`${$.plugin.root}/assets/replay-logo.png`)
555    } catch {}
556    for (const key of await $.store.keys()) {
557      if (key.startsWith(STORE_PREFIX)) {
558        const row = (await $.store.get(key)) as Row | undefined
559        if (row?.recordingId) state.rows.set(key.slice(STORE_PREFIX.length), row)
560      } else if (key === LAST_PROJECT_KEY) {
561        const id = await $.store.get(key)
562        if (typeof id === 'string') state.lastProjectId = id
563      }
564    }
565    try {
566      state.relay = await startRelay($)
567      // They end with the session: a wait the unloading engine aborts is not an error.
568      void watchFrames($).catch(() => {})
569      void watchOverviews($).catch(() => {})
570    } catch (err) {
571      state.relayError = message(err)
572      $.ui.log(`replay-qa: live relay did not start: ${state.relayError}`)
573    }
574    await $.command.register({
575      name: 'replayqa',
576      description: 'Live Replay QA: the canvas of every journey, running explorations and re-tests',
577      argumentHint: '[canvas|follow|unfollow|live|open] [project id, dashboard URL or recording]',
578    })
579    return started
580  })
581
582  on('command.run', { command: 'replayqa' }, async ($, e) => {
583    if (!state.relay) return { text: `The Replay QA relay is not running${state.relayError ? `: ${state.relayError}` : ''}.` }
584    const [verb = '', arg] = e.args.trim().split(/\s+/)
585    try {
586      if (verb === 'canvas' || verb === 'follow' || verb === 'unfollow') {
587        const named = parseProjectArg(arg)
588        const projectId = await resolveProjectId($, named.projectId)
589        if (!projectId) return { text: 'No Replay QA project: pass its id (proj-…) or dashboard URL, or run a Replay QA script first so .replay/config.json names one.' }
590        const server = named.server ?? (await configServer($, projectId))
591        if (verb === 'unfollow') {
592          await unfollowProject($, projectId)
593          updateStatusLine($)
594          return { text: `No longer following ${projectId}.` }
595        }
596        if (verb === 'canvas') {
597          await showCanvas($, projectId, server)
598          const o = state.overviews.get(projectId)
599          return { text: `Replay QA canvas for ${o?.project?.name ?? projectId}: ${o?.counts.live ?? 0} live, ${o?.counts.journeys ?? 0} journeys. Running work streams in as it starts.` }
600        }
601        const o = await followProject($, projectId, server)
602        return { text: `Following ${o.project?.name ?? projectId}: ${o.counts.live} live. Running explorations and test runs stream into the canvas (/replayqa canvas).` }
603      }
604      if (verb === 'live' || verb === 'open') {
605        const recordings = await relayJson<RecordingInfo[]>($, '/recordings')
606        if (verb === 'open') {
607          const rec = [...recordings].reverse().find(r => !arg || arg === 'latest' || r.id === arg || r.sourceId === arg)
608          if (!rec) return { text: recordings.length ? `No recording matches ${arg}.` : 'No Replay QA recordings yet.' }
609          openUrl($, playerPage(rec.id))
610          return { text: `Opened ${rec.title} (${rec.id}) in your browser.` }
611        }
612        const live = recordings.filter(r => r.status === 'live' || r.status === 'loading')
613        if (live.length === 0) return { text: 'Nothing is running in the Replay QA projects being followed.' }
614        return { text: `Live now:\n${live.map(r => `- ◉ ${r.title} (${r.id}) · ${r.eventCount.toLocaleString()} events`).join('\n')}\n\nOpen one with /replayqa open <id>.` }
615      }
616      const followed = [...state.overviews.values()]
617      if (followed.length === 0) return { text: `Replay QA live view is ready. Start an exploration, or use /replayqa canvas${state.lastProjectId ? ` (last project ${state.lastProjectId})` : ' <project id>'}.` }
618      return { text: `Following ${followed.map(o => `${o.project?.name ?? '?'} (${o.counts.live} live, ${o.counts.journeys} journeys, ${o.counts.bugs} with bugs)`).join('; ')}. /replayqa canvas shows them all.` }
619    } catch (err) {
620      return { text: `Replay QA: ${message(err)}` }
621    }
622  })
623
624  // What the Clients post: the canvas camera layer (drags, double-clicks and keys over the picture), and
625  // a player row's seek bar. Validated: it is client input.
626  on('ui.message', async ($, e, next) => {
627    // `e.module` is the path under the plugin's folder (`hooks/camera.tsx`); the `./` form is the Client's own.
628    const module = e.module.replace(/^(?:\.\/|hooks\/)/, '')
629    if (module === 'scrubber.tsx') {
630      const row = state.rows.get(e.requestId)
631      const data = e.data as { type?: string; ms?: number }
632      if (row && (data.type === 'seek' || data.type === 'seekBy' || data.type === 'toggle')) await play($, row.recordingId, data.type, data.ms)
633      return next(e)
634    }
635    if (module !== 'camera.tsx' || e.requestId !== PANE) return next(e)
636    const data = (e.data ?? {}) as Record<string, unknown>
637    if (data.type === 'gesture' && typeof data.id === 'number') {
638      // A press or drag as it stands; the page decides from where it began whether it scrubs, presses or pans.
639      const at = (v: unknown) => fraction(v, -2, 3) ?? 0.5
640      moveCamera($, { op: 'gesture', id: data.id, sx: at(data.sx), sy: at(data.sy), x: at(data.x), y: at(data.y), done: data.done === true })
641    } else if (data.type === 'focus') {
642      moveCamera($, { op: 'focus', x: fraction(data.x) ?? 0.5, y: fraction(data.y) ?? 0.5 })
643    } else if (data.type === 'minimap') {
644      moveCamera($, { op: 'minimap', x: fraction(data.x) ?? 0.5, y: fraction(data.y) ?? 0.5 })
645    } else if (data.type === 'zoom') {
646      moveCamera($, { op: 'zoom', factor: fraction(data.factor, 0.5, 2) ?? 1 })
647    } else if (data.type === 'pan') {
648      moveCamera($, { op: 'pan', dx: fraction(data.dx, -1, 1) ?? 0, dy: fraction(data.dy, -1, 1) ?? 0 })
649    } else if (data.type === 'fit' || data.type === 'actual') {
650      moveCamera($, { op: data.type })
651    }
652    return next(e)
653  })
654
655  // The wheel (two-finger scroll) over the canvas picture zooms around the pointer; elsewhere the pane
656  // scrolls as usual. Drag pans.
657  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
658    const picture = state.canvasPicture
659    const p = e.pointer
660    if (!picture || !p || e.origin.kind !== 'person') return next(e)
661    const row = p.row - picture.top
662    if (row < 0 || row >= picture.rows || p.column < 0 || p.column >= picture.columns) return next(e)
663    moveCamera($, { op: 'zoom', factor: Math.pow(1.15, -Math.max(-6, Math.min(6, e.by))), x: (p.column + 0.5) / picture.columns, y: (row + 0.5) / picture.rows })
664    return { deny: 'the canvas zoomed instead' }
665  })
666
667  // The result of a Replay QA script run from Bash.
668  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
669    if (e.run_in_background) return next(e)
670    const command = parseQaCommand(e.command)
671    const since = Date.now()
672    const ran = await next(e)
673    if (!command || ran.deny !== undefined || ran.isError || !state.relay) return ran
674    try {
675      await handleQaResult($, e.tool_use_id, command, ran.text ?? '', since)
676    } catch (err) {
677      $.ui.log(`replay-qa: ${message(err)}`)
678      $.ui.toast(`Replay QA live view: ${message(err)}`)
679    }
680    return ran
681  })
682
683  // The same for the Replay QA MCP server's tools (an exploration started through them).
684  on('tool.call', { tool: /^mcp__.*replay[-_]?qa.*__/ }, async ($, e, next) => {
685    const since = Date.now()
686    const ran = await next(e)
687    if (ran.deny !== undefined || ran.isError || !state.relay) return ran
688    try {
689      await handleQaResult($, e.tool_use_id, null, ran.text ?? '', since)
690    } catch (err) {
691      $.ui.log(`replay-qa: ${message(err)}`)
692    }
693    return ran
694  })
695
696  // The engine folds runs of tool calls into one count line; a group that holds a player is unfolded.
697  on('ui.render', { component: 'ToolGroup' }, ($, e, next) =>
698    !e.props.isExpanded && e.props.calls.some(c => c.tool_use_id && state.rows.has(c.tool_use_id))
699      ? next({ ...e, props: { ...e.props, isExpanded: true } })
700      : next(e))
701
702  // The call's result block would repeat the player.
703  on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
704    if (!state.rows.has(e.requestId) || e.props.isErrored) return next(e)
705    const { Box } = $.ui.resolve(e)
706    return <Box />
707  })
708
709  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
710    const id = e.requestId
711    const row = state.rows.get(id)
712    const rowError = state.rowErrors.get(id)
713    const waiting = state.pending.some(p => p.rowId === id)
714    if ((!row && !rowError && !waiting) || e.props.isErrored) return next(e)
715    if (e.props.onScreen === null) state.offscreen.add(id)
716    else state.offscreen.delete(id)
717
718    const ui = $.ui.resolve(e)
719    const { Box, Text, Button } = ui
720    const command = (e.props.input as { command?: string } | undefined)?.command?.split('\n')[0]
721    const logo = e.surface === 'terminal' && state.hasLogo
722      ? (() => {
723          const { Image } = $.ui.resolve(e)
724          return <Image key="logo" source={{ file: `${$.plugin.root}/assets/replay-logo.png`, format: 'png' }} columns={2} rows={1} alt="◆" />
725        })()
726      : <Text color="#F02D5E" bold>◆</Text>
727    const header = (label: string, live = false) => (
728      <Box flexDirection="row" gap={1}>
729        {logo}
730        <Text bold={live}>{label}</Text>
731      </Box>
732    )
733    const commandLine = <Text dimColor wrap="truncate-end">$ {command ?? e.props.tool}</Text>
734
735    if (!row) {
736      return (
737        <Box flexDirection="column">
738          {commandLine}
739          {header('Replay QA · live view')}
740          {rowError ? <Text color="red" wrap="wrap">⚠ {rowError}</Text> : <Text dimColor>Waiting for the backend to start the run…</Text>}
741          {state.canvasProjectId === null && state.lastProjectId ? <Button key="canvas" label="Open canvas" onPress={() => { void showCanvas($, state.lastProjectId!) }} /> : null}
742        </Box>
743      )
744    }
745
746    // A restored row has no player yet in a new session: ask the relay to load it, once.
747    const frame = state.frames[row.recordingId]
748    if (!frame && state.relay && e.props.onScreen !== null) void reviveRow($, row)
749
750    const rec = frame?.recording
751    const width = frame?.width ?? 1280
752    const height = frame?.height ?? 720
753    const columns = Math.max(20, Math.min(MAX_CELLS, (e.viewport?.columns ?? 100) - 6))
754    const rows = Math.max(6, Math.min(48, Math.round((columns * height) / width / CELL_ASPECT)))
755    const dataDir = state.relay?.dataDir
756    const savedFrame = dataDir ? `${dataDir}/frames/${row.recordingId.replace(/[^A-Za-z0-9_.-]/g, '_')}.png` : null
757    const source = frame && frame.generation > 0
758      ? imageSource(frame)
759      : savedFrame && (await $.fs.exists(savedFrame)) ? imageSource({ file: savedFrame, generation: 0 }) : null
760
761    const isFollowing = rec?.status === 'live' || rec?.status === 'loading'
762    const isLive = isFollowing && frame?.mode === 'live'
763    const isPlaying = frame?.status === 'playing' && frame.mode === 'replay'
764    const error = rowError ?? rec?.error ?? rec?.lastError ?? null
765    const picture = !source
766      ? <Text dimColor>{state.relay ? (rec?.status === 'error' ? 'No picture: the recording could not be read.' : 'Waiting for the first frame…') : 'The Replay QA relay is not running.'}</Text>
767      : e.surface === 'terminal'
768        ? (() => {
769            state.mounted.set(id, { columns, rows, width, height })
770            const { Image } = $.ui.resolve(e)
771            return <Image key={IMAGE_KEY} source={source} columns={columns} rows={rows} alt={`Replay QA ${row.title}`} />
772          })()
773        : <Text dimColor>Inline frames draw in the terminal; use Open in browser.</Text>
774
775    const scrubber = e.surface === 'terminal' && state.relay && frame
776      ? (() => {
777          const { Client } = $.ui.resolve(e)
778          return (
779            <Client
780              key="scrubber"
781              module="./scrubber.tsx"
782              width={columns}
783              height={1}
784              props={{ currentMs: frame.currentMs ?? 0, totalMs: frame.totalMs ?? 0, isPlaying, at: frame.at ?? Date.now(), isLive }}
785            />
786          )
787        })()
788      : null
789
790    return (
791      <Box flexDirection="column">
792        {commandLine}
793        {header(recordingLabel(row, rec), isLive)}
794        <Text dimColor wrap="truncate-end">{row.title}</Text>
795        {picture}
796        {scrubber}
797        {rec?.note?.text ? <Text dimColor wrap="truncate-end">▸ {rec.note.step ? `step ${rec.note.step.index}: ` : ''}{rec.note.text}</Text> : null}
798        {error ? <Text color="red" wrap="wrap">⚠ {error}</Text> : null}
799        {state.relay ? (
800          <Box flexDirection="row" gap={1} flexWrap="wrap">
801            {isLive
802              ? <Button key="stop" label="■ Stop preview" onPress={() => play($, row.recordingId, 'stop')} />
803              : (
804                <Box flexDirection="row" gap={1}>
805                  <Button key="start" label="⏮" onPress={() => play($, row.recordingId, 'seek', 0)} />
806                  <Button key="back" label="−10s" onPress={() => play($, row.recordingId, 'seekBy', -10_000)} />
807                  <Button key="toggle" variant="primary" label={isPlaying ? '❚❚ Pause' : '▶ Play'} onPress={() => play($, row.recordingId, isPlaying ? 'pause' : 'play')} />
808                  <Button key="fwd" label="+10s" onPress={() => play($, row.recordingId, 'seekBy', 10_000)} />
809                </Box>
810              )}
811            <Button key="canvas" label="Show canvas pane" onPress={() => { void showCanvas($, row.projectId) }} />
812            <Button key="open-canvas" label="Open canvas" onPress={() => openUrl($, canvasPage(row.projectId))} />
813            <Button key="open" label="Open in browser" onPress={() => openUrl($, playerPage(row.recordingId))} />
814          </Box>
815        ) : null}
816      </Box>
817    )
818  })
819
820  // The canvas pane: every journey of the project as rrweb players, as one picture.
821  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
822    const projectId = state.canvasProjectId
823    const { Box, Text, Button } = $.ui.resolve(e)
824    if (!projectId) return <Text dimColor>No project: run /replayqa canvas &lt;project id&gt;.</Text>
825    const overview = state.overviews.get(projectId)
826    const frame = state.frames[canvasFrameId(projectId)]
827    const bodyRows = e.props.scroll.bodyRows > 0 ? e.props.scroll.bodyRows : Math.max(8, (e.viewport?.rows ?? 30) - 8)
828    // The picture is the canvas, filling the pane between the header line and the footer line;
829    // the screencast is asked for that shape, so nothing is letterboxed.
830    const columns = Math.max(20, Math.min(MAX_CELLS, e.props.bodyColumns > 0 ? e.props.bodyColumns : (e.viewport?.columns ?? 100)))
831    const rows = Math.max(6, bodyRows - 2)
832    const width = frame?.width ?? 1600
833    const height = frame?.height ?? 900
834
835    const name = overview?.project?.name ?? projectId
836    const counts = overview?.counts
837    const server = overview?.server ? new URL(overview.server).host : null
838    const header = (
839      <Box flexDirection="row" gap={2} height={1} overflow="hidden">
840        <Text bold wrap="truncate-end">{name}</Text>
841        <Text color={counts && counts.live > 0 ? '#F02D5E' : undefined} dimColor={!counts || counts.live === 0}>● {counts?.live ?? 0} live</Text>
842        <Text dimColor wrap="truncate-end">{counts?.journeys ?? 0} journeys · {counts?.bugs ?? 0} with bugs · {counts?.failed ?? 0} failed{server ? ` · ${server}` : ''}</Text>
843      </Box>
844    )
845    const picture = e.surface !== 'terminal'
846      ? <Text dimColor>The canvas draws in the terminal; open it in a browser with the button below.</Text>
847      : frame && frame.generation > 0
848        ? (() => {
849            state.mounted.set(PANE, { columns, rows, width, height })
850            state.canvasPicture = { top: 1, columns, rows }
851            sizeCanvas($, projectId, columns, rows)
852            const { Image, Client } = $.ui.resolve(e)
853            return (
854              <Box key="stage" width={columns} height={rows}>
855                <Image key={CANVAS_KEY} source={imageSource(frame)} columns={columns} rows={rows} alt={`Replay QA canvas for ${name}`} />
856                <Box position="absolute" top={0} left={0} width={columns} height={rows}>
857                  <Client key="camera" module="./camera.tsx" width={columns} height={rows} props={{ minimap: state.camera?.minimap ?? null }} />
858                </Box>
859              </Box>
860            )
861          })()
862        : <Text dimColor>{overview?.error ? `⚠ ${overview.error}` : 'Loading the canvas…'}</Text>
863    return (
864      <Box flexDirection="column">
865        {header}
866        {picture}
867        <Box flexDirection="row" gap={1} height={1} overflow="hidden">
868          <Button key="zoomOut" label="−" onPress={() => moveCamera($, { op: 'zoom', factor: 0.8 })} />
869          <Button key="zoomPct" label={state.camera ? `${Math.round(state.camera.k * 100)}%` : '100%'} onPress={() => moveCamera($, { op: 'actual' })} />
870          <Button key="zoomIn" label="+" onPress={() => moveCamera($, { op: 'zoom', factor: 1.25 })} />
871          <Button key="fit" label="Fit" onPress={() => moveCamera($, { op: 'fit' })} />
872          <Button key="open" label="Open in browser" onPress={() => openUrl($, canvasPage(projectId))} />
873          <Button key="close" label="Close" onPress={() => { void $.ui.close({ id: PANE }) }} />
874          {overview?.error && frame && frame.generation > 0
875            ? <Text color="red" wrap="truncate-end">⚠ {overview.error}</Text>
876            : <Text dimColor wrap="truncate-end">scroll to zoom · drag to pan · ▶ or a tile's timeline to play · double-click a tile · click the minimap to jump</Text>}
877        </Box>
878      </Box>
879    )
880  })
881
882  on('ui.close', { id: PANE }, async ($, e, next) => {
883    const closed = await next(e)
884    await hideCanvas($)
885    return closed
886  })
887}
888
hooks/detect.ts 221 lines
1// Recognizes the Replay QA scripts a Bash command runs, and reads what they (or the QA MCP
2// tools) printed: the project, a newly started exploration, bug and run ids.
3//
4// The scripts print pretty JSON through `qa.printJson` (a response object) or `printSection`
5// (`=== Title ===` followed by JSON), so output is read by finding JSON values in the text,
6// with a regex fallback on the ids, which are recognizable by prefix (`proj-`, `expl-`, `run-`,
7// `bug-`).
8
9export type QaScript =
10  | 'start-exploration'
11  | 'rerun-journeys'
12  | 'mark-bug'
13  | 'report-missing-bug'
14  | 'full-qa'
15  | 'status'
16  | 'bootstrap'
17
18const SCRIPTS: QaScript[] = ['start-exploration', 'rerun-journeys', 'mark-bug', 'report-missing-bug', 'full-qa', 'status', 'bootstrap']
19const SCRIPT_WORD = new RegExp(`(?:^|/)(${SCRIPTS.join('|')})\\.js["']?$`)
20const RUNNERS = new Set(['node', 'bun', 'deno', 'tsx', 'nodejs'])
21const PROJECT_ID = /proj-[A-Za-z0-9_-]+/
22
23export type QaCommand = {
24  script: QaScript
25  projectId?: string
26  bugId?: string
27  bugStatus?: string
28  /** The backend will start a test run of its own because of this command (a bug marked fixed, a missing bug reported). */
29  startsRun: boolean
30  /** The QA server the script talks to when it is not the default: REPLAY_QA_API_BASE or REPLAY_QA_URL set on the command. */
31  server?: string
32}
33
34/** Shell words of one simple command, quotes removed (no expansion). */
35function words(segment: string): string[] {
36  const out: string[] = []
37  const re = /"((?:\\.|[^"\\])*)"|'([^']*)'|(\S+)/g
38  for (let m = re.exec(segment); m; m = re.exec(segment)) out.push(m[1] ?? m[2] ?? m[3] ?? '')
39  return out
40}
41
42const SEGMENTS = /&&|\|\||[;\n|]/
43const VALUE_FLAGS = new Set([
44  '--project-id', '--project', '--project-url', '--bug-id', '--status', '--prompt', '--title', '--description', '--name',
45  '--target-url', '--instructions', '--journey-id', '--page', '--page-size', '--interval-ms',
46])
47
48/** Names a segment assigns: `SCRIPT=".../start-exploration.js"` and `FOO=bar node x.js`. */
49function assignments(argv: string[]): Record<string, string> {
50  const out: Record<string, string> = {}
51  for (const w of argv) {
52    const m = /^(?:export\s+)?([A-Za-z_]\w*)=(.*)$/.exec(w)
53    if (m) out[m[1]!] = m[2]!.replace(/["']/g, '')
54  }
55  return out
56}
57
58/** The Replay QA script a command runs, with the arguments that matter; null when it runs none. */
59export function parseQaCommand(command: string): QaCommand | null {
60  const env: Record<string, string> = {}
61  for (const segment of command.split(SEGMENTS)) {
62    const argv = words(segment.trim())
63    Object.assign(env, assignments(argv))
64    for (let i = 0; i < argv.length; i++) {
65      const raw = argv[i]!
66      if (/^(?:export\s+)?[A-Za-z_]\w*=/.test(raw)) continue
67      // `node "$SCRIPT"` where an earlier assignment named the file.
68      const variable = /^\$\{?([A-Za-z_]\w*)\}?$/.exec(raw)?.[1]
69      const word = variable ? (env[variable] ?? '') : raw
70      const script = SCRIPT_WORD.exec(word)?.[1] as QaScript | undefined
71      if (!script) continue
72      // It runs only when something executes it: a runner before it, or the word is the command.
73      const before = argv.slice(0, i).filter(w => !/^[A-Za-z_]\w*=/.test(w) && w !== 'export')
74      const runs = before.length === 0 || before.some(w => RUNNERS.has(w.split('/').pop()!))
75      if (!runs) continue
76      return fromArgs(script, argv.slice(i + 1), env)
77    }
78  }
79  return null
80}
81
82function fromArgs(script: QaScript, args: string[], env: Record<string, string>): QaCommand {
83  const flags: Record<string, string> = {}
84  const positional: string[] = []
85  for (let i = 0; i < args.length; i++) {
86    const arg = args[i]!
87    if (arg === '--') { positional.push(...args.slice(i + 1)); break }
88    const eq = /^(--[\w-]+)=(.*)$/.exec(arg)
89    if (eq) { flags[eq[1]!] = eq[2]!; continue }
90    if (VALUE_FLAGS.has(arg) && args[i + 1] !== undefined) { flags[arg] = args[++i]!; continue }
91    if (arg.startsWith('--')) continue
92    positional.push(arg)
93  }
94  const projectId =
95    PROJECT_ID.exec(flags['--project-id'] ?? flags['--project'] ?? flags['--project-url'] ?? '')?.[0]
96    ?? positional.map(p => PROJECT_ID.exec(p)?.[0]).find(Boolean)
97    ?? PROJECT_ID.exec(env.REPLAY_QA_PROJECT_ID ?? '')?.[0]
98  const bugId = flags['--bug-id'] ?? (script === 'mark-bug' ? positional.find(p => !PROJECT_ID.test(p)) : undefined)
99  const bugStatus = script === 'mark-bug'
100    ? flags['--status'] ?? positional.filter(p => p !== bugId && !PROJECT_ID.test(p))[0] ?? 'fixed'
101    : undefined
102  const startsRun =
103    (script === 'mark-bug' && bugStatus === 'fixed')
104    || (script === 'rerun-journeys' && Boolean(bugId))
105    || script === 'report-missing-bug'
106  const server = env.REPLAY_QA_API_BASE || env.REPLAY_QA_URL
107  return { script, ...(projectId ? { projectId } : {}), ...(bugId ? { bugId } : {}), ...(bugStatus ? { bugStatus } : {}), startsRun, ...(server ? { server } : {}) }
108}
109
110// ------------------------------------------------------------------- output
111
112export type QaOutput = {
113  projectId?: string
114  /** An exploration the output is about: its top-level object (one just started), not one listed in a status. */
115  explorationId?: string
116  explorationStatus?: string
117  prompt?: string
118  runIds: string[]
119  bugId?: string
120  /** The journey a bug record belongs to: the one the backend re-tests when the bug is marked fixed. */
121  journeyId?: string
122  /** Every exploration id mentioned anywhere, for callers that want to match one. */
123  seenExplorationIds: string[]
124  /** The QA server named by a reverse-proxy runbook's `replayqa proxy … --qa-url <url>`. */
125  qaUrl?: string
126}
127
128/** JSON values in a text: the whole text if it is JSON, else each `{`/`[` line start that parses. */
129export function jsonValues(text: string): unknown[] {
130  const trimmed = text.trim()
131  try {
132    return [JSON.parse(trimmed)]
133  } catch {}
134  const out: unknown[] = []
135  const lines = text.split('\n')
136  let offset = 0
137  // Where the last value ended: lines inside it are part of it, not values of their own.
138  let consumed = -1
139  for (const line of lines) {
140    const start = offset + (line.length - line.trimStart().length)
141    offset += line.length + 1
142    const ch = line.trimStart()[0]
143    if (start <= consumed || (ch !== '{' && ch !== '[')) continue
144    const end = matching(text, start)
145    if (end < 0) continue
146    try {
147      out.push(JSON.parse(text.slice(start, end + 1)))
148      consumed = end
149    } catch {}
150    if (out.length >= 20) break
151  }
152  return out
153}
154
155/** Index of the bracket closing the one at `start`, aware of strings; -1 when unbalanced. */
156function matching(text: string, start: number): number {
157  let depth = 0
158  let inString = false
159  for (let i = start; i < text.length; i++) {
160    const c = text[i]!
161    if (inString) {
162      if (c === '\\') i++
163      else if (c === '"') inString = false
164      continue
165    }
166    if (c === '"') inString = true
167    else if (c === '{' || c === '[') depth++
168    else if (c === '}' || c === ']') {
169      depth--
170      if (depth === 0) return i
171    }
172  }
173  return -1
174}
175
176export function parseQaOutput(text: string): QaOutput {
177  const out: QaOutput = { runIds: [], seenExplorationIds: [] }
178  const add = (list: string[], id: string) => { if (!list.includes(id)) list.push(id) }
179  const values = jsonValues(text)
180
181  const walk = (value: unknown, top: boolean, depth: number) => {
182    if (!value || typeof value !== 'object' || depth > 8) return
183    if (Array.isArray(value)) { for (const v of value) walk(v, false, depth + 1); return }
184    const o = value as Record<string, unknown>
185    const id = typeof o.id === 'string' ? o.id : ''
186    if (/^proj-/.test(id)) out.projectId ??= id
187    if (typeof o.project_id === 'string' && /^proj-/.test(o.project_id)) out.projectId ??= o.project_id
188    if (/^expl-/.test(id)) {
189      add(out.seenExplorationIds, id)
190      if (top && !out.explorationId) {
191        out.explorationId = id
192        if (typeof o.status === 'string') out.explorationStatus = o.status
193        if (typeof o.prompt === 'string') out.prompt = o.prompt
194      }
195    }
196    if (typeof o.exploration_id === 'string' && /^expl-/.test(o.exploration_id)) add(out.seenExplorationIds, o.exploration_id)
197    if (/^run-/.test(id) && out.runIds.length < 20) add(out.runIds, id)
198    if (typeof o.test_run_id === 'string' && /^run-/.test(o.test_run_id) && out.runIds.length < 20) add(out.runIds, o.test_run_id)
199    if (/^bug-/.test(id)) {
200      out.bugId ??= id
201      if (typeof o.journey_id === 'string') out.journeyId ??= o.journey_id
202    }
203    if (typeof o.bug_id === 'string') out.bugId ??= o.bug_id
204    for (const v of Object.values(o)) walk(v, false, depth + 1)
205  }
206  for (const v of values) walk(v, true, 0)
207
208  const anyProject = out.projectId ? undefined : PROJECT_ID.exec(text)?.[0]
209  if (anyProject) out.projectId = anyProject
210  for (const m of text.matchAll(/\bexpl-[A-Za-z0-9_-]+/g)) add(out.seenExplorationIds, m[0])
211  for (const m of text.matchAll(/\brun-[A-Za-z0-9_-]+/g)) if (out.runIds.length < 20) add(out.runIds, m[0])
212  const qaUrl = /--qa-url(?:=|\s+)["']?(https?:\/\/[^\s"'\\]+)/.exec(text)?.[1]
213  if (qaUrl) out.qaUrl = qaUrl
214  return out
215}
216
217/** Is this status one of a running exploration or test run? */
218export function isRunningStatus(status: string | undefined): boolean {
219  return status === 'in-progress' || status === 'running' || status === 'pending' || status === 'queued'
220}
221
hooks/scrubber.tsx 88 lines
1/* @jsx h */
2import type { ClientKeyEvent, ClientPointerEvent, ClientSurface } from 'claude-code'
3
4// The player's seek bar, drawn in the terminal and driven by the pointer:
5// click or drag anywhere on the bar to seek, Space toggles play, ←/→ step 5s.
6// Between the plugin's redraws it advances the playhead on its own clock, so
7// steady playback costs no redraw of the transcript.
8
9export type ScrubberProps = {
10  currentMs: number
11  totalMs: number
12  /** Whether the playhead is moving: `currentMs` was true at `at`. */
13  isPlaying: boolean
14  at: number
15  isLive: boolean
16}
17
18type State = { tick: number; dragRatio: number | null }
19
20/** Each instance's newest props, for its timer, which outlives a render. */
21const latest = new WeakMap<object, ScrubberProps>()
22
23const clock = (ms: number) => {
24  const s = Math.max(0, Math.round(ms / 1000))
25  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
26}
27
28export default function Scrubber(raw: unknown, surface: ClientSurface<State>) {
29  const { Box, Text } = surface.elements
30  const props = raw as ScrubberProps
31  latest.set(surface, props)
32
33  const now = () => {
34    const moved = props.isPlaying ? Date.now() - props.at : 0
35    return Math.max(0, Math.min(props.totalMs, props.currentMs + moved))
36  }
37  const totalLabel = ` ${clock(props.totalMs)}`
38  const leftWidth = 6
39  const barWidth = Math.max(4, surface.columns - leftWidth - totalLabel.length)
40  const ratioAt = (x: number) => Math.max(0, Math.min(1, (x - leftWidth) / Math.max(1, barWidth - 1)))
41
42  if (surface.state === undefined) {
43    surface.setState({ tick: 0, dragRatio: null })
44    surface.every(250, () => {
45      const s = surface.state
46      if (s && latest.get(surface)?.isPlaying) surface.setState({ ...s, tick: s.tick + 1 })
47    })
48  }
49  surface.onPointer((event: ClientPointerEvent) => {
50    const s = surface.state
51    if (!s || props.isLive || props.totalMs <= 0) return
52    if (event.type === 'down') surface.setState({ ...s, dragRatio: ratioAt(event.x) })
53    else if (event.type === 'move' && s.dragRatio !== null) surface.setState({ ...s, dragRatio: ratioAt(event.x) })
54    else if (event.type === 'up' && s.dragRatio !== null) {
55      surface.post({ type: 'seek', ms: Math.round(ratioAt(event.x) * props.totalMs) })
56      surface.setState({ ...s, dragRatio: null })
57    } else if (event.type === 'leave' && s.dragRatio !== null) surface.setState({ ...s, dragRatio: null })
58  })
59  surface.onKey((event: ClientKeyEvent) => {
60    if (props.isLive) return
61    if (event.key === ' ' || event.key === 'space') surface.post({ type: 'toggle' })
62    else if (event.key === 'left') surface.post({ type: 'seekBy', ms: -5000 })
63    else if (event.key === 'right') surface.post({ type: 'seekBy', ms: 5000 })
64  })
65
66  if (props.isLive) {
67    return (
68      <Box flexDirection="row">
69        <Text color="red">● LIVE </Text>
70        <Text dimColor>scrub once the browser closes, or press ▶ Replay to scrub what is recorded so far</Text>
71      </Box>
72    )
73  }
74
75  const ratio = surface.state?.dragRatio ?? (props.totalMs > 0 ? now() / props.totalMs : 0)
76  const head = Math.round(ratio * (barWidth - 1))
77  const shown = surface.state?.dragRatio != null ? ratio * props.totalMs : now()
78  return (
79    <Box flexDirection="row">
80      <Text>{clock(shown).padStart(leftWidth - 1)} </Text>
81      <Text color="red">{'━'.repeat(head)}</Text>
82      <Text bold>●</Text>
83      <Text dimColor>{'─'.repeat(Math.max(0, barWidth - head - 1))}</Text>
84      <Text dimColor>{totalLabel}</Text>
85    </Box>
86  )
87}
88
hooks/camera.tsx 107 lines
1/* @jsx h */
2import type { ClientKeyEvent, ClientPointerEvent, ClientSurface } from 'claude-code'
3
4// Laid over the canvas picture in the pane, it turns the pointer and keys into camera moves for
5// the hooks module, which hands them to the relay's screencast: a press or drag is a gesture the
6// page decides from where it began (a tile's timeline scrubs, its ▶ and Live buttons press, anywhere
7// else pans), double-click a tile to focus it (the background to fit), press or drag on the minimap
8// to move the view there, and once clicked + − 0 1 and the arrows. The wheel zooms through the pane's ui.scroll instead, as a
9// Client gets no wheel.
10//
11// Points and distances are posted as fractions of the picture, so nothing here knows pixels.
12
13export type CameraProps = {
14  /** Where the canvas draws its minimap, in fractions of the picture; null before it reported one. */
15  minimap: { x: number; y: number; w: number; h: number } | null
16}
17
18/**
19 * A gesture posts all of itself each time (where it began, where it is, whether it ended): a post
20 * replaced within a frame loses nothing. One that began on the minimap moves the view instead.
21 */
22type Drag = { id: number; x: number; y: number; moved: boolean; onMinimap: boolean }
23type Local = { drag: Drag | null; lastClick: { at: number; x: number; y: number } | null; drags: number }
24
25/** Each instance's pointer state, kept off `surface.state` so moving the pointer redraws nothing. */
26const locals = new WeakMap<object, Local>()
27
28const DOUBLE_CLICK_MS = 400
29
30export default function Camera(raw: unknown, surface: ClientSurface) {
31  const props = (raw ?? { minimap: null }) as CameraProps
32  const { Box } = surface.elements
33  let local = locals.get(surface)
34  if (!local) locals.set(surface, (local = { drag: null, lastClick: null, drags: 0 }))
35  const l = local
36
37  const at = (e: ClientPointerEvent) => ({ x: e.fine?.x ?? e.x + 0.5, y: e.fine?.y ?? e.y + 0.5 })
38  const columns = () => Math.max(1, surface.columns)
39  const rows = () => Math.max(1, surface.rows)
40  /** The point as fractions of the minimap, or null when it is not over the minimap. */
41  const onMinimap = (p: { x: number; y: number }) => {
42    const m = props.minimap
43    if (!m || m.w <= 0 || m.h <= 0) return null
44    const u = (p.x / columns() - m.x) / m.w
45    const v = (p.y / rows() - m.y) / m.h
46    return u >= 0 && u <= 1 && v >= 0 && v <= 1 ? { x: u, y: v } : null
47  }
48  const gesture = (drag: Drag, p: { x: number; y: number }, done: boolean) =>
49    surface.post({ type: 'gesture', id: drag.id, sx: drag.x / columns(), sy: drag.y / rows(), x: p.x / columns(), y: p.y / rows(), done })
50  const jump = (p: { x: number; y: number }) => {
51    const m = props.minimap
52    if (!m) return
53    const clamp = (n: number) => Math.max(0, Math.min(1, n))
54    surface.post({ type: 'minimap', x: clamp((p.x / columns() - m.x) / m.w), y: clamp((p.y / rows() - m.y) / m.h) })
55  }
56
57  surface.onPointer((e: ClientPointerEvent) => {
58    const p = at(e)
59    if (e.type === 'down' && e.button === 'left') {
60      const mini = onMinimap(p)
61      l.drag = { id: ++l.drags, x: p.x, y: p.y, moved: false, onMinimap: Boolean(mini) }
62      if (mini) {
63        l.lastClick = null
64        jump(p)
65      } else {
66        gesture(l.drag, p, false)
67      }
68    } else if (e.type === 'move' && l.drag?.onMinimap && e.button) {
69      jump(p)
70    } else if (e.type === 'up' && l.drag?.onMinimap) {
71      l.drag = null
72    } else if (e.type === 'move' && l.drag && e.button) {
73      if (Math.abs(p.x - l.drag.x) + Math.abs(p.y - l.drag.y) >= 0.5) l.drag.moved = true
74      if (l.drag.moved) gesture(l.drag, p, false)
75    } else if (e.type === 'up' && l.drag) {
76      const drag = l.drag
77      l.drag = null
78      if (drag.moved) {
79        gesture(drag, p, true)
80        return
81      }
82      const now = Date.now()
83      const last = l.lastClick
84      if (last && now - last.at < DOUBLE_CLICK_MS && Math.abs(last.x - p.x) < 2 && Math.abs(last.y - p.y) < 1) {
85        l.lastClick = null
86        surface.post({ type: 'focus', x: p.x / columns(), y: p.y / rows() })
87      } else {
88        l.lastClick = { at: now, x: p.x, y: p.y }
89        gesture(drag, p, true)
90      }
91    }
92  })
93
94  surface.onKey((e: ClientKeyEvent) => {
95    if (e.key === '+' || e.key === '=') surface.post({ type: 'zoom', factor: 1.25 })
96    else if (e.key === '-' || e.key === '_') surface.post({ type: 'zoom', factor: 0.8 })
97    else if (e.key === '0') surface.post({ type: 'fit' })
98    else if (e.key === '1') surface.post({ type: 'actual' })
99    else if (e.key === 'left') surface.post({ type: 'pan', dx: 0.1, dy: 0 })
100    else if (e.key === 'right') surface.post({ type: 'pan', dx: -0.1, dy: 0 })
101    else if (e.key === 'up') surface.post({ type: 'pan', dx: 0, dy: 0.1 })
102    else if (e.key === 'down') surface.post({ type: 'pan', dx: 0, dy: -0.1 })
103  })
104
105  return <Box width={surface.columns} height={surface.rows} />
106}
107