docs(screenshots): add before/after transcript screenshots for PR review

Captures the mixed-stream fixture (two plain bootstrap lines followed by
OpenCode JSONL) rendered through the real process adapter module via a
dev-only route at /tests/screenshots/process-opencode-transcript (gated
on import.meta.env.DEV so it tree-shakes out of production builds). The
companion script at
tools/process-opencode-transcript-screenshots/capture.mjs boots vite,
navigates to the route, and writes one PNG per scenario:

  01-before-raw-jsonl-passthrough — previous pass-through parser dumping
                                    OpenCode JSONL as raw stdout
  02-after-rich-opencode-transcript — same stream with detection: plain
                                      lines stay raw, JSONL renders as
                                      the rich transcript

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jannes StubbemannandClaude Fable 5 committed 2026-07-04 20:01:26 +02:00
1 parent 15663725bb
commit 35b4769136
5 files changed
+252

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

@@ -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);
});
+7
View File
@@ -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() {
<Route path="cli-auth/:id" element={<CliAuthPage />} />
<Route path="invite/:token" element={<InviteLandingPage />} />
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
{import.meta.env.DEV ? (
<Route
path="tests/screenshots/process-opencode-transcript"
element={<ProcessOpencodeTranscriptScreenshots />}
/>
) : null}
<Route path="ux-lab/cloud-upstream" element={<CloudUpstreamUxLab />} />
<Route path="ux-lab/bootstrap-setup" element={<BootstrapSetupUxLab />} />
@@ -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 `<section
// data-screenshot-id>`.
//
// "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 (
<section
data-screenshot-id={id}
className="w-[960px] rounded-xl border border-border/70 bg-background p-5"
>
<div className="mb-4 flex items-center gap-2">
<Badge variant="outline" className="uppercase tracking-[0.18em] text-[10px]">
{title}
</Badge>
<span className="text-xs text-muted-foreground">{description}</span>
</div>
{children}
</section>
);
}
export function ProcessOpencodeTranscriptScreenshots() {
return (
<div className="flex min-h-screen flex-col items-start gap-8 bg-muted/30 p-8">
<ScreenshotSection
id="01-before-raw-jsonl-passthrough"
title="Before"
description="Process adapter pass-through: OpenCode JSONL dumped as raw stdout"
>
<RunTranscriptView entries={beforeEntries} mode="nice" />
</ScreenshotSection>
<ScreenshotSection
id="02-after-rich-opencode-transcript"
title="After"
description="Same stream with OpenCode JSONL detection: bootstrap lines stay raw, the rest renders richly"
>
<RunTranscriptView entries={afterEntries} mode="nice" />
</ScreenshotSection>
</div>
);
}