mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
## 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>
100 lines
4.0 KiB
TypeScript
100 lines
4.0 KiB
TypeScript
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());
|
|
}
|