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…

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
| Item | Installs |
|---|---|
codex | Codex 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-pro | Codex 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. |
cursor | Cursor plugin bundle installed to .cursor-plugin/, skills/, assets/, hooks, scripts, MCP config, and worker/critic proof-loop prompts. |
claude-code | Claude Code Replay QA plugin installed to .claude/skills/replay-qa/, including plugin commands and script-backed skill helpers. |
claude-pro | Claude Code Replay.io Pro plugin installed to .claude/skills/replayio/, .claude/agents/, hooks, scripts, MCP config, commands, and Replay Pro skill guidance. |
opencode | OpenCode plugin bundle installed to .opencode/plugins/, .opencode/agent/, AGENTS.md, and opencode.json, including worker/critic proof-loop subagents. |
obvious-ai | Skills-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 the registry before publishing changes:
npx shadcn@latest registry validate registry.jsonhooks/register.tsx 888 lines1/* @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 <project id>.</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}
888hooks/detect.ts 221 lines1// 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}
221hooks/scrubber.tsx 88 lines1/* @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}
88hooks/camera.tsx 107 lines1/* @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