diff --git a/docs/screenshots/process-opencode-transcript/01-before-raw-jsonl-passthrough.png b/docs/screenshots/process-opencode-transcript/01-before-raw-jsonl-passthrough.png new file mode 100644 index 00000000000..fd28a9fe496 Binary files /dev/null and b/docs/screenshots/process-opencode-transcript/01-before-raw-jsonl-passthrough.png differ diff --git a/docs/screenshots/process-opencode-transcript/02-after-rich-opencode-transcript.png b/docs/screenshots/process-opencode-transcript/02-after-rich-opencode-transcript.png new file mode 100644 index 00000000000..19dbe30519f Binary files /dev/null and b/docs/screenshots/process-opencode-transcript/02-after-rich-opencode-transcript.png differ diff --git a/tools/process-opencode-transcript-screenshots/capture.mjs b/tools/process-opencode-transcript-screenshots/capture.mjs new file mode 100644 index 00000000000..1ac5980934c --- /dev/null +++ b/tools/process-opencode-transcript-screenshots/capture.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/** + * Capture before/after screenshots of the process-adapter OpenCode transcript + * rendering for PR review. + * + * Boots the UI Vite dev server and uses Playwright (chromium) to render the + * `/tests/screenshots/process-opencode-transcript` dev-only route (see + * `ui/src/pages/__screenshots__/ProcessOpencodeTranscriptScreenshots.tsx`). + * One PNG per `[data-screenshot-id]` section is written to + * `docs/screenshots/process-opencode-transcript/`. + * + * Usage: + * node tools/process-opencode-transcript-screenshots/capture.mjs + * + * Idempotent — overwrites existing PNGs. + */ +import { spawn } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "@playwright/test"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const UI_DIR = path.join(REPO_ROOT, "ui"); +const OUTPUT_DIR = path.join(REPO_ROOT, "docs", "screenshots", "process-opencode-transcript"); +const PORT = Number(process.env.SCREENSHOT_PORT ?? 5173); +const BASE_URL = `http://127.0.0.1:${PORT}`; +const SCREENSHOT_PATH = "/tests/screenshots/process-opencode-transcript"; + +const SECTIONS = [ + "01-before-raw-jsonl-passthrough", + "02-after-rich-opencode-transcript", +]; + +async function waitForServer(url, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(url, { method: "GET" }); + if (res.ok || res.status === 404) return; + } catch { + // not ready + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Server at ${url} never became ready`); +} + +async function startDevServer() { + const proc = spawn( + "pnpm", + ["vite", "--port", String(PORT), "--host", "127.0.0.1", "--strictPort"], + { + cwd: UI_DIR, + env: { ...process.env, NODE_ENV: "development" }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + proc.stdout?.on("data", (chunk) => { + process.stdout.write(`[vite] ${chunk}`); + }); + proc.stderr?.on("data", (chunk) => { + process.stderr.write(`[vite] ${chunk}`); + }); + return proc; +} + +async function captureScreenshots() { + mkdirSync(OUTPUT_DIR, { recursive: true }); + + const browser = await chromium.launch(); + try { + const context = await browser.newContext({ + viewport: { width: 1280, height: 1600 }, + deviceScaleFactor: 2, + }); + const page = await context.newPage(); + page.on("console", (msg) => { + if (msg.type() === "error") { + console.error(`[browser console error] ${msg.text()}`); + } + }); + await page.goto(`${BASE_URL}${SCREENSHOT_PATH}`, { waitUntil: "networkidle" }); + await page.waitForSelector(`[data-screenshot-id="${SECTIONS[0]}"]`, { timeout: 15_000 }); + + for (const section of SECTIONS) { + const locator = page.locator(`[data-screenshot-id="${section}"]`); + await locator.scrollIntoViewIfNeeded(); + await page.waitForTimeout(200); + const filename = path.join(OUTPUT_DIR, `${section}.png`); + await locator.screenshot({ path: filename }); + console.log(`Wrote ${path.relative(REPO_ROOT, filename)}`); + } + } finally { + await browser.close(); + } +} + +async function main() { + console.log(`[capture] booting vite dev server on ${BASE_URL}`); + const server = await startDevServer(); + try { + await waitForServer(BASE_URL); + console.log(`[capture] vite ready, capturing screenshots`); + await captureScreenshots(); + } finally { + if (server.exitCode === null) { + server.kill("SIGTERM"); + await new Promise((resolve) => setTimeout(resolve, 500)); + if (server.exitCode === null) server.kill("SIGKILL"); + } + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 4a9e971f7c8..c288ff98d46 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -19,6 +19,7 @@ import { Issues } from "./pages/Issues"; import { Search } from "./pages/Search"; import { IssueDetail } from "./pages/IssueDetail"; import { IssueChatLongThreadPerf } from "./pages/IssueChatLongThreadPerf"; +import { ProcessOpencodeTranscriptScreenshots } from "./pages/__screenshots__/ProcessOpencodeTranscriptScreenshots"; import { Routines } from "./pages/Routines"; import { Learnings, PipelineItemDetail, PipelineItemLegacyRedirect, Pipelines, ReviewQueue } from "./pages/Pipelines"; import { PipelineSettings } from "./pages/PipelineSettings"; @@ -393,6 +394,12 @@ export function App() { } /> } /> } /> + {import.meta.env.DEV ? ( + } + /> + ) : null} } /> } /> diff --git a/ui/src/pages/__screenshots__/ProcessOpencodeTranscriptScreenshots.tsx b/ui/src/pages/__screenshots__/ProcessOpencodeTranscriptScreenshots.tsx new file mode 100644 index 00000000000..b4261bfa87b --- /dev/null +++ b/ui/src/pages/__screenshots__/ProcessOpencodeTranscriptScreenshots.tsx @@ -0,0 +1,125 @@ +// Static before/after rendering of an OpenCode run executed through the +// generic process adapter, captured for PR review. +// +// Reachable only in dev (`import.meta.env.DEV`) at +// `/tests/screenshots/process-opencode-transcript`. The capture script under +// `tools/process-opencode-transcript-screenshots/capture.mjs` boots the vite +// dev server, navigates here, and screenshots each ``. +// +// "Before" reproduces the previous pass-through parser (every stdout line +// becomes a raw stdout entry); "after" runs the same stream through the real +// process adapter module — the exact path the Run page uses — so the +// screenshots show precisely what this PR changes. + +import type { ReactNode } from "react"; +import type { TranscriptEntry } from "../../adapters"; +import { buildTranscript, type RunLogChunk } from "../../adapters/transcript"; +import { processUIAdapter } from "../../adapters/process"; +import { RunTranscriptView } from "../../components/transcript/RunTranscriptView"; +import { Badge } from "@/components/ui/badge"; + +const ts = "2026-07-04T12:00:00.000Z"; + +// A representative process-adapter run: a wrapper script prints two plain +// bootstrap lines, then execs an OpenCode harness that emits OpenCode JSONL. +const MIXED_STREAM_LINES: string[] = [ + "Syncing skills ...", + "Starting agent ...", + JSON.stringify({ type: "step_start", sessionID: "ses_x", part: { type: "step-start" } }), + JSON.stringify({ + type: "reasoning", + sessionID: "ses_x", + part: { type: "reasoning", text: "The board asked for a hiring plan. I should check the current staffing files first." }, + }), + JSON.stringify({ + type: "text", + sessionID: "ses_x", + part: { type: "text", text: "I'll review the current staffing files, then draft the hiring plan." }, + }), + JSON.stringify({ + type: "tool_use", + part: { + type: "tool", + tool: "bash", + callID: "tool-1", + state: { + status: "completed", + input: { command: "ls docs/staffing" }, + output: "headcount.md\nhiring-plan-q2.md", + }, + }, + }), + JSON.stringify({ + type: "text", + sessionID: "ses_x", + part: { type: "text", text: "Found the Q2 plan — I'll extend it with the two open engineering roles." }, + }), + JSON.stringify({ + type: "step_finish", + part: { reason: "tool-calls", tokens: { input: 9338, output: 59, reasoning: 559 } }, + }), +]; + +function mixedStreamChunks(): RunLogChunk[] { + return [{ ts, stream: "stdout", chunk: MIXED_STREAM_LINES.join("\n") + "\n" }]; +} + +// The previous parser wrapped every line as raw stdout — reproduce it verbatim +// so the "before" section shows what the Run page used to render. +const beforeEntries: TranscriptEntry[] = MIXED_STREAM_LINES.map((line) => ({ + kind: "stdout", + ts, + text: line, +})); + +const afterEntries: TranscriptEntry[] = buildTranscript(mixedStreamChunks(), processUIAdapter); + +function ScreenshotSection({ + id, + title, + description, + children, +}: { + id: string; + title: string; + description: string; + children: ReactNode; +}) { + return ( + + + + {title} + + {description} + + {children} + + ); +} + +export function ProcessOpencodeTranscriptScreenshots() { + return ( + + + + + + + + + + ); +}