perf: add repeatable issue-detail baseline rig (#10409)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The issue detail page is a core operator surface where perceived
latency directly affects task navigation
> - Performance work needs repeatable evidence so later optimizations
can be compared against the same scenarios
> - The page did not expose stable user-timing marks for its header or
first useful content
> - There was also no isolated seeded browser rig that measured warm
navigation, cold deep links, waterfalls, or server time
> - This pull request adds the instrumentation and a one-command
Playwright baseline harness
> - The benefit is that issue-page performance changes can be validated
with reproducible median measurements instead of anecdotes

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting: `ui/`, `server/`, and browser performance tooling.

**Problem or motivation**

The issue detail page performs a large client bootstrap and request
fan-out, but the repository lacks stable user-timing boundaries and a
repeatable benchmark. That makes performance changes difficult to
compare and allows regressions to be judged from anecdotes instead of
consistent evidence.

**Proposed solution**

Add stable header/content paint measures, development/QA-only lifecycle
vital reporting, aggregate server timing for the issue endpoint, and a
seeded Playwright command that runs warm/cold scenarios under throttled
and unthrottled profiles with N≥5 median reporting.

**Alternatives considered**

Ad hoc DevTools recordings were rejected because they are not repeatable
or reviewable. Production telemetry was rejected because this baseline
should not change production data collection. A unit-only harness was
rejected because it cannot capture browser bootstrap, rendering, and
network waterfall costs.

**Roadmap alignment**

The roadmap calls for agent performance to be measurable over time. This
change applies that evidence-first principle to a core operator page and
does not duplicate a listed roadmap deliverable.

**Additional context**

The generated report includes warm and cold medians, TTFB/FCP/LCP where
applicable, request and byte totals before first useful content,
JavaScript bytes, and issue endpoint server timing.

## What Changed

- Added `issue-detail:navigate→header-paint` and
`issue-detail:navigate→content-paint` user-timing measures to the issue
detail page.
- Added development/QA-only TTFB, LCP, and INP console reporting without
production telemetry delivery.
- Added `Server-Timing` for `GET /api/issues/:id`.
- Added `pnpm exec playwright test --config
tests/perf/issue-detail/playwright.config.ts`, which seeds an isolated
instance and runs N≥5 warm/cold samples under unthrottled and Fast 4G/4x
CPU profiles.
- Added Markdown, raw JSON, and Chrome-trace outputs with median
baseline tables and waterfall data.

## Verification

- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm check:token-gates`
- `npx playwright test --config
tests/perf/issue-detail/playwright.config.ts --list`
- `pnpm exec playwright test --config
tests/perf/issue-detail/playwright.config.ts` — passed 20 samples in 9.4
minutes (5 runs × 2 scenarios × 2 profiles) for the baseline;
post-review integrity reruns also exercised the corrected paths, while
this shared runner intermittently killed Chromium processes, so the rig
now performs one bounded browser-crash retry per sample.
- Baseline medians: warm unthrottled 278/447 ms header/content; cold
unthrottled 646/646 ms; warm throttled 1240/2060 ms; cold throttled
3932/3933 ms.

## Risks

- Low product risk: the new browser measurements are development/QA
tooling and the UI timing work does not change visible layout.
- `Server-Timing` exposes only aggregate handler duration, not query
contents or private identifiers.
- Native INP reporting uses supported browser event timing entries and
silently no-ops where unsupported.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5.4, tool-assisted coding and browser execution with
reasoning enabled; context-window size is not exposed in this
environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Dev Agent <dev@paperclip.ing>
This commit is contained in:
Dotta 2026-08-11 15:18:29 -04:00 committed by GitHub
parent 5bb2490b86
commit 2494a2a0fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 551 additions and 2 deletions

1
.gitignore vendored
View File

@ -60,6 +60,7 @@ tests/e2e/test-results/
tests/e2e/playwright-report/
tests/release-smoke/test-results/
tests/release-smoke/playwright-report/
test-results/issue-detail-perf/
tests/storybook-visual/.cache/
tests/storybook-visual/.snapshots/
tests/storybook-visual/baseline-review/

View File

@ -6026,6 +6026,7 @@ export function issueRoutes(
});
router.get("/issues/:id", async (req, res) => {
const requestStartedAt = performance.now();
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found");
if (!issue) return;
@ -6086,6 +6087,7 @@ export function issueRoutes(
? await executionWorkspacesSvc.getById(issue.executionWorkspaceId)
: null;
const workProducts = await workProductsSvc.listForIssue(issue.id);
res.setHeader("Server-Timing", `paperclip_issue;dur=${(performance.now() - requestStartedAt).toFixed(1)}`);
res.json({
...issue,
...inboxArchiveFields,

View File

@ -0,0 +1,27 @@
# Issue-detail performance baseline
Run the repeatable baseline from the repository root:
```sh
pnpm exec playwright test --config tests/perf/issue-detail/playwright.config.ts
```
The rig starts an isolated seeded Paperclip instance, runs five samples for each scenario/profile, and writes median-ready raw data plus a Markdown table to `test-results/issue-detail-perf/`.
Scenarios:
- S1 warm in-app navigation: loads the Issues list, clears the waterfall, and clicks the seeded issue.
- S2 cold open: creates a fresh browser context and deep-links to the seeded issue.
Profiles:
- Unthrottled.
- Fast 4G network with 4x CPU slowdown.
Override the sample count (minimum five) or port when needed:
```sh
PAPERCLIP_ISSUE_PERF_RUNS=7 PAPERCLIP_ISSUE_PERF_PORT=3210 pnpm exec playwright test --config tests/perf/issue-detail/playwright.config.ts
```
Outputs include `baseline.md`, `baseline.json`, and a Chrome trace for the first run of each scenario/profile. Open `*.trace.json` in Chrome DevTools Performance to inspect the `issue-detail:*` user-timing marks.

View File

@ -0,0 +1,339 @@
import fs from "node:fs/promises";
import path from "node:path";
import { chromium, expect, test, type Browser, type BrowserContext, type Page } from "@playwright/test";
const requestedRuns = Number(process.env.PAPERCLIP_ISSUE_PERF_RUNS ?? 5);
const RUNS = Number.isFinite(requestedRuns) ? Math.max(5, Math.floor(requestedRuns)) : 5;
const OUTPUT_DIR = path.resolve(process.cwd(), "test-results/issue-detail-perf");
const PAGE_READY_TIMEOUT_MS = 90_000;
const HEADER_MEASURE = "issue-detail:navigate→header-paint";
const CONTENT_MEASURE = "issue-detail:navigate→content-paint";
type Profile = {
name: "unthrottled" | "fast-4g-4x-cpu";
latencyMs?: number;
downloadBytesPerSecond?: number;
uploadBytesPerSecond?: number;
cpuSlowdownRate?: number;
};
type NetworkRecord = {
requestId: string;
url: string;
method: string;
type: string;
encodedDataLength: number;
mimeType?: string;
status?: number;
responseHeaders?: Record<string, string>;
ttfbMs?: number;
completedAtEpochMs?: number;
};
type RunMetrics = {
scenario: "S1 warm in-app navigation" | "S2 cold open";
profile: Profile["name"];
run: number;
headerPaintMs: number;
contentPaintMs: number;
ttfbMs: number | null;
fcpMs: number | null;
lcpMs: number | null;
requestCountBeforeContentPaint: number;
apiRequestCountBeforeContentPaint: number;
bytesBeforeContentPaint: number;
jsBytesBeforeContentPaint: number;
issueApiTtfbMs: number | null;
issueApiServerTiming: string | null;
};
type Seed = {
companyId: string;
prefix: string;
issueId: string;
identifier: string;
title: string;
};
const PROFILES: Profile[] = [
{ name: "unthrottled" },
{
name: "fast-4g-4x-cpu",
latencyMs: 50,
downloadBytesPerSecond: Math.floor(9 * 1024 * 1024 / 8),
uploadBytesPerSecond: Math.floor(1.5 * 1024 * 1024 / 8),
cpuSlowdownRate: 4,
},
];
function median(values: number[]): number {
const sorted = [...values].sort((left, right) => left - right);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
}
function formatMs(value: number | null): string {
return value === null ? "n/a" : `${Math.round(value)} ms`;
}
function formatBytes(value: number): string {
return `${(value / 1024).toFixed(1)} KiB`;
}
async function seed(request: Page["request"]): Promise<Seed> {
const companyResponse = await request.post("/api/companies", {
data: { name: `Issue Perf ${Date.now()}` },
});
expect(companyResponse.ok(), await companyResponse.text()).toBe(true);
const company = await companyResponse.json();
const title = `Issue detail performance baseline ${Date.now()}`;
const issueResponse = await request.post(`/api/companies/${company.id}/issues`, {
data: {
title,
description: "A seeded issue description used to measure the first meaningful issue-detail content paint.\n\n".repeat(12),
priority: "high",
status: "todo",
},
});
expect(issueResponse.ok(), await issueResponse.text()).toBe(true);
const issue = await issueResponse.json();
for (let index = 1; index <= 12; index += 1) {
const commentResponse = await request.post(`/api/issues/${issue.id}/comments`, {
data: { body: `Seeded performance comment ${index}: ${"content ".repeat(24)}` },
});
expect(commentResponse.ok(), await commentResponse.text()).toBe(true);
}
return {
companyId: company.id,
prefix: company.issuePrefix ?? company.prefix,
issueId: issue.id,
identifier: issue.identifier,
title,
};
}
async function configureProfile(context: BrowserContext, page: Page, profile: Profile) {
const session = await context.newCDPSession(page);
await session.send("Network.enable");
if (profile.latencyMs !== undefined) {
await session.send("Network.emulateNetworkConditions", {
offline: false,
latency: profile.latencyMs,
downloadThroughput: profile.downloadBytesPerSecond,
uploadThroughput: profile.uploadBytesPerSecond,
connectionType: "cellular4g",
});
await session.send("Emulation.setCPUThrottlingRate", { rate: profile.cpuSlowdownRate });
}
return session;
}
async function installVitalObserver(page: Page, cold: boolean): Promise<void> {
await page.addInitScript(({ coldStart }) => {
if (coldStart) window.__PAPERCLIP_ISSUE_DETAIL_NAVIGATE_START__ = 0;
(window as Window & { __issuePerfLcp?: number }).__issuePerfLcp = undefined;
try {
new PerformanceObserver((list) => {
const latest = list.getEntries().at(-1);
if (latest) (window as Window & { __issuePerfLcp?: number }).__issuePerfLcp = latest.startTime;
}).observe({ type: "largest-contentful-paint", buffered: true });
} catch {}
}, { coldStart: cold });
}
async function startNetworkCapture(session: Awaited<ReturnType<typeof configureProfile>>) {
const records = new Map<string, NetworkRecord>();
let cdpEpochOffsetMs: number | null = null;
session.on("Network.requestWillBeSent", (event) => {
cdpEpochOffsetMs ??= event.wallTime * 1000 - event.timestamp * 1000;
records.set(event.requestId, {
requestId: event.requestId,
url: event.request.url,
method: event.request.method,
type: event.type ?? "Other",
encodedDataLength: 0,
});
});
session.on("Network.responseReceived", (event) => {
const record = records.get(event.requestId);
if (!record) return;
record.mimeType = event.response.mimeType;
record.status = event.response.status;
record.responseHeaders = Object.fromEntries(
Object.entries(event.response.headers).map(([key, value]) => [key.toLowerCase(), String(value)]),
);
if (event.response.timing) {
record.ttfbMs = event.response.timing.receiveHeadersEnd - event.response.timing.sendStart;
}
});
session.on("Network.loadingFinished", (event) => {
const record = records.get(event.requestId);
if (!record) return;
record.encodedDataLength = event.encodedDataLength;
if (cdpEpochOffsetMs !== null) record.completedAtEpochMs = cdpEpochOffsetMs + event.timestamp * 1000;
});
return records;
}
async function writeTrace(tracePath: string, metrics: RunMetrics, records: Map<string, NetworkRecord>) {
await fs.mkdir(path.dirname(tracePath), { recursive: true });
const traceEvents = [
{ name: "issue-detail:navigate", cat: "blink.user_timing", ph: "i", ts: 0, pid: 1, tid: 1, s: "t" },
{ name: HEADER_MEASURE, cat: "blink.user_timing", ph: "X", ts: 0, dur: metrics.headerPaintMs * 1000, pid: 1, tid: 1 },
{ name: CONTENT_MEASURE, cat: "blink.user_timing", ph: "X", ts: 0, dur: metrics.contentPaintMs * 1000, pid: 1, tid: 1 },
...[...records.values()].map((record, index) => ({
name: record.url,
cat: "loading",
ph: "i",
ts: index + 1,
pid: 1,
tid: 2,
s: "t",
args: { method: record.method, type: record.type, bytes: record.encodedDataLength, status: record.status },
})),
];
await fs.writeFile(tracePath, JSON.stringify({ traceEvents }));
}
async function readPaintMetrics(page: Page) {
await page.waitForFunction((measureName) => performance.getEntriesByName(measureName).length > 0, CONTENT_MEASURE);
await page.waitForTimeout(150);
return page.evaluate(({ headerMeasure, contentMeasure }) => {
const navigation = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined;
const fcp = performance.getEntriesByName("first-contentful-paint")[0];
return {
headerPaintMs: performance.getEntriesByName(headerMeasure)[0]?.duration ?? NaN,
contentPaintMs: performance.getEntriesByName(contentMeasure)[0]?.duration ?? NaN,
ttfbMs: navigation ? navigation.responseStart - navigation.requestStart : null,
fcpMs: fcp?.startTime ?? null,
lcpMs: (window as Window & { __issuePerfLcp?: number }).__issuePerfLcp ?? null,
contentPaintEpochMs: performance.timeOrigin + (performance.getEntriesByName(contentMeasure)[0]?.startTime ?? 0) + (performance.getEntriesByName(contentMeasure)[0]?.duration ?? 0),
};
}, { headerMeasure: HEADER_MEASURE, contentMeasure: CONTENT_MEASURE });
}
function summarizeNetwork(records: Map<string, NetworkRecord>, seedData: Seed, contentPaintEpochMs: number) {
const completed = [...records.values()].filter((record) =>
record.encodedDataLength > 0
&& record.completedAtEpochMs !== undefined
&& record.completedAtEpochMs <= contentPaintEpochMs
);
const api = completed.filter((record) => new URL(record.url).pathname.startsWith("/api/"));
const scripts = completed.filter((record) => record.type === "Script" || record.mimeType?.includes("javascript"));
const issueApi = completed.find((record) => {
const pathname = new URL(record.url).pathname;
return record.method === "GET" && (pathname === `/api/issues/${seedData.issueId}` || pathname === `/api/issues/${seedData.identifier}`);
});
return {
requestCountBeforeContentPaint: completed.length,
apiRequestCountBeforeContentPaint: api.length,
bytesBeforeContentPaint: completed.reduce((sum, record) => sum + record.encodedDataLength, 0),
jsBytesBeforeContentPaint: scripts.reduce((sum, record) => sum + record.encodedDataLength, 0),
issueApiTtfbMs: issueApi?.ttfbMs ?? null,
issueApiServerTiming: issueApi?.responseHeaders?.["server-timing"] ?? null,
};
}
async function runScenario(browser: Browser, baseURL: string, seedData: Seed, profile: Profile, scenario: RunMetrics["scenario"], run: number): Promise<RunMetrics> {
const context = await browser.newContext({ baseURL });
const page = await context.newPage();
await installVitalObserver(page, scenario.startsWith("S2"));
const session = await configureProfile(context, page, profile);
const network = await startNetworkCapture(session);
const tracePath = path.join(OUTPUT_DIR, `${scenario.slice(0, 2).toLowerCase()}-${profile.name}-run-${run}.trace.json`);
if (scenario.startsWith("S1")) {
await page.goto(`/${seedData.prefix}/issues`);
const issueLink = page.locator("[data-inbox-issue-link]", { hasText: seedData.title }).first();
await expect(issueLink).toBeVisible({ timeout: PAGE_READY_TIMEOUT_MS });
network.clear();
await page.evaluate(() => {
window.__PAPERCLIP_ISSUE_DETAIL_NAVIGATE_START__ = performance.now();
});
await issueLink.click();
} else {
await page.goto(`/${seedData.prefix}/issues/${seedData.identifier}`);
}
await expect(page.getByTestId("issue-detail-header")).toBeVisible({ timeout: PAGE_READY_TIMEOUT_MS });
const paint = await readPaintMetrics(page);
const summary = summarizeNetwork(network, seedData, paint.contentPaintEpochMs);
const { contentPaintEpochMs: _contentPaintEpochMs, ...reportedPaint } = paint;
const scenarioPaint = scenario.startsWith("S1")
? { ...reportedPaint, ttfbMs: null, fcpMs: null, lcpMs: null }
: reportedPaint;
const metrics = { scenario, profile: profile.name, run, ...scenarioPaint, ...summary };
if (run === 1) await writeTrace(tracePath, metrics, network);
if (scenario.startsWith("S2") && profile.name === "unthrottled" && run === 1) {
await page.screenshot({ path: path.join(OUTPUT_DIR, "issue-detail-loaded.png"), fullPage: true });
}
await context.close();
expect(Number.isFinite(paint.headerPaintMs)).toBe(true);
expect(Number.isFinite(paint.contentPaintMs)).toBe(true);
return metrics;
}
async function runScenarioWithBrowserRetry(baseURL: string, seedData: Seed, profile: Profile, scenario: RunMetrics["scenario"], run: number): Promise<RunMetrics> {
for (let attempt = 1; attempt <= 2; attempt += 1) {
const sampleBrowser = await chromium.launch();
try {
return await runScenario(sampleBrowser, baseURL, seedData, profile, scenario, run);
} catch (error) {
const browserClosed = error instanceof Error
&& (/Target page, context or browser has been closed/.test(error.message) || /Channel closed/.test(error.message));
if (!browserClosed || attempt === 2) throw error;
} finally {
await sampleBrowser.close().catch(() => undefined);
}
}
throw new Error("Unreachable browser retry state");
}
function buildMarkdown(results: RunMetrics[]): string {
const rows = PROFILES.flatMap((profile) => ["S1 warm in-app navigation", "S2 cold open"].map((scenario) => {
const samples = results.filter((result) => result.profile === profile.name && result.scenario === scenario);
const nullableMedian = (values: Array<number | null>) => {
const present = values.filter((value): value is number => value !== null);
return present.length > 0 ? median(present) : null;
};
const serverTimingMs = nullableMedian(samples.map((sample) => {
const match = sample.issueApiServerTiming?.match(/dur=([0-9.]+)/);
return match ? Number(match[1]) : null;
}));
return `| ${scenario} | ${profile.name} | ${samples.length} | ${formatMs(median(samples.map((sample) => sample.headerPaintMs)))} | ${formatMs(median(samples.map((sample) => sample.contentPaintMs)))} | ${formatMs(nullableMedian(samples.map((sample) => sample.ttfbMs)))} | ${formatMs(nullableMedian(samples.map((sample) => sample.fcpMs)))} | ${formatMs(nullableMedian(samples.map((sample) => sample.lcpMs)))} | ${Math.round(median(samples.map((sample) => sample.requestCountBeforeContentPaint)))} | ${Math.round(median(samples.map((sample) => sample.apiRequestCountBeforeContentPaint)))} | ${formatBytes(median(samples.map((sample) => sample.bytesBeforeContentPaint)))} | ${formatBytes(median(samples.map((sample) => sample.jsBytesBeforeContentPaint)))} | ${formatMs(nullableMedian(samples.map((sample) => sample.issueApiTtfbMs)))} | ${formatMs(serverTimingMs)} |`;
}));
return [
"# Issue-detail performance baseline",
"",
`Runs per scenario/profile: ${RUNS}`,
"",
"| Scenario | Profile | N | Header paint | Content paint | TTFB | FCP | LCP | Requests before content | API requests before content | Bytes before content | JS bytes before content | GET issue TTFB | GET issue server timing |",
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
...rows,
"",
"Raw samples and Chrome traces are in the same output directory.",
].join("\n");
}
test("issue-detail baseline", async ({ request, baseURL }) => {
expect(baseURL).toBeTruthy();
await fs.mkdir(OUTPUT_DIR, { recursive: true });
const seedData = await seed(request);
const results: RunMetrics[] = [];
for (const profile of PROFILES) {
for (let run = 1; run <= RUNS; run += 1) {
results.push(await runScenarioWithBrowserRetry(baseURL!, seedData, profile, "S1 warm in-app navigation", run));
results.push(await runScenarioWithBrowserRetry(baseURL!, seedData, profile, "S2 cold open", run));
}
}
const report = buildMarkdown(results);
await fs.writeFile(path.join(OUTPUT_DIR, "baseline.json"), JSON.stringify({ seed: seedData, results }, null, 2));
await fs.writeFile(path.join(OUTPUT_DIR, "baseline.md"), report);
console.log(`\n${report}\n`);
});

View File

@ -0,0 +1,49 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { defineConfig } from "@playwright/test";
const PORT = Number(process.env.PAPERCLIP_ISSUE_PERF_PORT ?? 3201);
const BASE_URL = `http://127.0.0.1:${PORT}`;
const PAPERCLIP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-issue-perf-home-"));
const PAPERCLIP_INSTANCE_ID = "playwright-issue-perf";
const PAPERCLIP_CONFIG = path.join(PAPERCLIP_HOME, "instances", PAPERCLIP_INSTANCE_ID, "config.json");
process.env.PAPERCLIP_HOME = PAPERCLIP_HOME;
process.env.PAPERCLIP_CONFIG = PAPERCLIP_CONFIG;
export default defineConfig({
testDir: ".",
testMatch: "issue-detail.perf.spec.ts",
timeout: 30 * 60_000,
workers: 1,
fullyParallel: false,
use: {
baseURL: BASE_URL,
browserName: "chromium",
headless: true,
},
webServer: {
command: "pnpm paperclipai onboard --yes --run",
url: `${BASE_URL}/api/health`,
reuseExistingServer: false,
timeout: 120_000,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
NODE_ENV: "development",
PORT: String(PORT),
PAPERCLIP_HOME,
PAPERCLIP_INSTANCE_ID,
PAPERCLIP_CONFIG,
PAPERCLIP_AGENT_JWT_SECRET: "playwright-issue-perf-agent-jwt-secret",
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: "playwright-issue-perf-tool-action-signing-secret",
PAPERCLIP_BIND: "loopback",
PAPERCLIP_DEPLOYMENT_MODE: "local_trusted",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "private",
},
},
outputDir: "../../../test-results/issue-detail-perf/playwright",
reporter: [["list"]],
});

View File

@ -0,0 +1,99 @@
export const ISSUE_DETAIL_NAVIGATE_MARK = "issue-detail:navigate";
export const ISSUE_DETAIL_HEADER_PAINT_MARK = "issue-detail:header-paint";
export const ISSUE_DETAIL_CONTENT_PAINT_MARK = "issue-detail:content-paint";
export const ISSUE_DETAIL_HEADER_MEASURE = "issue-detail:navigate→header-paint";
export const ISSUE_DETAIL_CONTENT_MEASURE = "issue-detail:navigate→content-paint";
let issueDetailNavigationGeneration = 0;
declare global {
interface Window {
__PAPERCLIP_ISSUE_DETAIL_NAVIGATE_START__?: number;
}
}
function supportsPerformanceMarks(): boolean {
return typeof window !== "undefined" && typeof performance !== "undefined" && typeof performance.mark === "function";
}
export function beginIssueDetailNavigation(): void {
issueDetailNavigationGeneration += 1;
if (!supportsPerformanceMarks()) return;
performance.clearMarks(ISSUE_DETAIL_NAVIGATE_MARK);
performance.clearMarks(ISSUE_DETAIL_HEADER_PAINT_MARK);
performance.clearMarks(ISSUE_DETAIL_CONTENT_PAINT_MARK);
performance.clearMeasures(ISSUE_DETAIL_HEADER_MEASURE);
performance.clearMeasures(ISSUE_DETAIL_CONTENT_MEASURE);
const externallyCapturedStart = window.__PAPERCLIP_ISSUE_DETAIL_NAVIGATE_START__;
delete window.__PAPERCLIP_ISSUE_DETAIL_NAVIGATE_START__;
performance.mark(ISSUE_DETAIL_NAVIGATE_MARK, {
startTime: typeof externallyCapturedStart === "number" ? externallyCapturedStart : performance.now(),
});
}
export function scheduleIssueDetailPaintMeasure(
markName: typeof ISSUE_DETAIL_HEADER_PAINT_MARK | typeof ISSUE_DETAIL_CONTENT_PAINT_MARK,
measureName: typeof ISSUE_DETAIL_HEADER_MEASURE | typeof ISSUE_DETAIL_CONTENT_MEASURE,
): void {
if (!supportsPerformanceMarks() || performance.getEntriesByName(measureName).length > 0) return;
const navigationGeneration = issueDetailNavigationGeneration;
requestAnimationFrame(() => {
if (navigationGeneration !== issueDetailNavigationGeneration) return;
requestAnimationFrame(() => {
if (navigationGeneration !== issueDetailNavigationGeneration) return;
if (performance.getEntriesByName(measureName).length > 0) return;
if (performance.getEntriesByName(ISSUE_DETAIL_NAVIGATE_MARK).length === 0) {
performance.mark(ISSUE_DETAIL_NAVIGATE_MARK);
}
performance.mark(markName);
performance.measure(measureName, ISSUE_DETAIL_NAVIGATE_MARK, markName);
});
});
}
type VitalMetric = {
name: "TTFB" | "LCP" | "INP";
value: number;
};
function reportVital(metric: VitalMetric): void {
console.info("[issue-detail:web-vital]", metric);
}
export function reportIssueDetailWebVitals(): () => void {
if (typeof window === "undefined" || typeof PerformanceObserver === "undefined") return () => undefined;
const observers: PerformanceObserver[] = [];
const navigation = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined;
if (navigation) {
reportVital({ name: "TTFB", value: navigation.responseStart - navigation.requestStart });
}
try {
const lcpObserver = new PerformanceObserver((list) => {
const latest = list.getEntries().at(-1);
if (latest) reportVital({ name: "LCP", value: latest.startTime });
});
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
observers.push(lcpObserver);
} catch {}
try {
let longestInteraction = 0;
const inpObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const interactionEntry = entry as PerformanceEventTiming & { interactionId?: number };
if (!interactionEntry.interactionId) continue;
longestInteraction = Math.max(longestInteraction, interactionEntry.duration);
}
if (longestInteraction > 0) reportVital({ name: "INP", value: longestInteraction });
});
inpObserver.observe({ type: "event", buffered: true, durationThreshold: 40 } as PerformanceObserverInit);
observers.push(inpObserver);
} catch {}
return () => observers.forEach((observer) => observer.disconnect());
}

View File

@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode, type Ref } from "react";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode, type Ref } from "react";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { Link, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query";
@ -40,6 +40,15 @@ import {
} from "../lib/issueDetailBreadcrumb";
import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun";
import { getIssueDetailQueryOptions } from "../lib/issueDetailCache";
import {
beginIssueDetailNavigation,
ISSUE_DETAIL_CONTENT_MEASURE,
ISSUE_DETAIL_CONTENT_PAINT_MARK,
ISSUE_DETAIL_HEADER_MEASURE,
ISSUE_DETAIL_HEADER_PAINT_MARK,
reportIssueDetailWebVitals,
scheduleIssueDetailPaintMeasure,
} from "../lib/issue-detail-performance";
import {
beginLocalInboxArchive,
boundLocalInboxArchive,
@ -1712,6 +1721,25 @@ export function IssueDetail() {
() => flattenIssueCommentPages(commentPages?.pages),
[commentPages?.pages],
);
useLayoutEffect(() => {
beginIssueDetailNavigation();
}, [issueId]);
useEffect(() => {
if (!(import.meta.env.DEV || import.meta.env.MODE === "qa")) return;
return reportIssueDetailWebVitals();
}, [issueId]);
useEffect(() => {
if (!issue) return;
scheduleIssueDetailPaintMeasure(ISSUE_DETAIL_HEADER_PAINT_MARK, ISSUE_DETAIL_HEADER_MEASURE);
}, [issue?.id]);
useEffect(() => {
if (!issue || commentsLoading) return;
scheduleIssueDetailPaintMeasure(ISSUE_DETAIL_CONTENT_PAINT_MARK, ISSUE_DETAIL_CONTENT_MEASURE);
}, [commentsLoading, issue?.id]);
const shouldPrefetchOlderComments = useMemo(
() =>
shouldAutoloadOlderIssueComments({
@ -4259,7 +4287,10 @@ export function IssueDetail() {
);
const issueHeaderBlock = (
<div className={cn("space-y-3", shellSectionClass)}>
<div
data-testid="issue-detail-header"
className={cn("space-y-3", shellSectionClass)}
>
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<StatusIcon
status={issue.status}
@ -4990,6 +5021,7 @@ export function IssueDetail() {
(symmetric, so the centered column keeps the same axis) and the
scrollbar sits flush against the properties-pane border. */}
<TabsContent
data-testid="issue-detail-content"
value="chat"
className={
taskChatShellEnabled