mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
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:
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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user