87f19cd9a6
## Thinking Path > - Paperclip's board UI is the operator surface for supervising AI-agent companies. > - Issue threads are where operators read progress, respond to agents, inspect markdown, and jump through long histories. > - Large threads and rich markdown had become difficult to navigate and expensive to render. > - The previous rollup mixed these UI scale fixes with unrelated backend recovery, costs, backups, and settings changes. > - This pull request isolates the issue-thread scale and markdown polish work. > - The benefit is a reviewable UI slice that can merge independently of the backend reliability, database backup, workflow, and board QoL PRs. ## What Changed - Virtualized long issue chat threads and stabilized anchor/jump-to-latest behavior for large histories. - Added incremental issue-list row loading and tests for scroll-triggered pagination behavior. - Hardened markdown body rendering and markdown editor behavior around HTML tags, image drops, code-copy UI, and escaped newline handling. - Added a long-thread measurement harness at `scripts/measure-issue-chat-long-thread.mjs` plus `perf:issue-chat-long-thread`. - Added focused UI/lib regression coverage for thread rendering, markdown, optimistic comments, and message building. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run ui/src/components/IssueChatThread.test.tsx ui/src/components/IssuesList.test.tsx ui/src/components/MarkdownBody.test.tsx ui/src/components/MarkdownEditor.test.tsx ui/src/lib/issue-chat-messages.test.ts ui/src/lib/optimistic-issue-comments.test.ts` - Result: 6 test files passed, 170 tests passed. - UI screenshots not included because this PR is covered by targeted component tests and does not introduce a new page layout. ## Risks - Virtualization changes can affect scroll anchoring in edge cases on very long threads. - Markdown/editor hardening changes are intentionally defensive, but malformed content may render differently than before. > 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.5, code execution and GitHub CLI tool use, medium reasoning effort. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
109 lines
4.9 KiB
JavaScript
109 lines
4.9 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { chromium } from "@playwright/test";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
const baseUrl = (process.env.PAPERCLIP_PERF_BASE_URL || "http://localhost:3100").replace(/\/$/, "");
|
|
const companyPrefix = process.env.PAPERCLIP_PERF_COMPANY_PREFIX;
|
|
const url = companyPrefix
|
|
? `${baseUrl}/${companyPrefix}/tests/perf/long-thread`
|
|
: `${baseUrl}/tests/perf/long-thread`;
|
|
const origin = new URL(url).origin;
|
|
|
|
function loadBoardToken() {
|
|
const authPath = path.resolve(os.homedir(), ".paperclip/auth.json");
|
|
try {
|
|
const auth = JSON.parse(fs.readFileSync(authPath, "utf-8"));
|
|
const credentials = auth.credentials || {};
|
|
const matching = Object.values(credentials).find((entry) => {
|
|
if (!entry || !entry.token || !entry.apiBase) return false;
|
|
return new URL(entry.apiBase).origin === origin;
|
|
});
|
|
if (matching?.token) return matching.token;
|
|
const fallback = Object.values(credentials).find((entry) => entry?.token);
|
|
return fallback?.token ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
|
const boardToken = process.env.PAPERCLIP_PERF_BEARER_TOKEN || loadBoardToken();
|
|
|
|
if (boardToken) {
|
|
await page.route(`${origin}/**`, async (route) => {
|
|
await route.continue({
|
|
headers: { ...route.request().headers(), Authorization: `Bearer ${boardToken}` },
|
|
});
|
|
});
|
|
}
|
|
|
|
try {
|
|
const startedAt = Date.now();
|
|
await page.goto(url, { waitUntil: "networkidle" });
|
|
await page.waitForSelector('[data-testid="issue-chat-long-thread-perf"]', { timeout: 30_000 });
|
|
await page.waitForFunction(() => {
|
|
const target = Number(document.querySelector('[data-testid="perf-fixture-row-target"]')?.textContent ?? "450");
|
|
const renderedRows = document.querySelectorAll('[data-testid="issue-chat-message-row"]').length;
|
|
const virtualizer = document.querySelector('[data-testid="issue-chat-thread-virtualizer"]');
|
|
if (!virtualizer) return renderedRows >= target;
|
|
const virtualCount = Number(virtualizer.getAttribute("data-virtual-count") ?? "0");
|
|
return virtualCount >= target && renderedRows > 0 && renderedRows < target;
|
|
}, null, { timeout: 60_000 });
|
|
const rowReadyMs = Date.now() - startedAt;
|
|
|
|
const metrics = await page.evaluate(async () => {
|
|
const text = (testId) => document.querySelector(`[data-testid="${testId}"]`)?.textContent?.trim() ?? "";
|
|
const numericMs = (testId) => {
|
|
const value = text(testId).replace(/\s*ms$/, "");
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
};
|
|
|
|
const rowCount = document.querySelectorAll('[data-testid="issue-chat-message-row"]').length;
|
|
const virtualizer = document.querySelector('[data-testid="issue-chat-thread-virtualizer"]');
|
|
const virtualCount = Number(virtualizer?.getAttribute("data-virtual-count") ?? "0");
|
|
const assistantRowCount = document.querySelectorAll('[data-testid="issue-chat-message-row"][data-message-role="assistant"]').length;
|
|
const systemRowCount = document.querySelectorAll('[data-testid="issue-chat-message-row"][data-message-role="system"]').length;
|
|
const userRowCount = document.querySelectorAll('[data-testid="issue-chat-message-row"][data-message-role="user"]').length;
|
|
const markdownRows = Number(text("perf-fixture-markdown-rows"));
|
|
const commitCount = Number(text("perf-commit-count"));
|
|
const scrollStartY = window.scrollY;
|
|
const scrollTarget = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
const scrollStartedAt = performance.now();
|
|
window.scrollTo({ top: scrollTarget, behavior: "instant" });
|
|
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
const scrollResponsiveMs = performance.now() - scrollStartedAt;
|
|
|
|
return {
|
|
url: window.location.href,
|
|
fixtureRowTarget: Number(text("perf-fixture-row-target")),
|
|
virtualized: Boolean(virtualizer),
|
|
virtualCount,
|
|
rowCount,
|
|
assistantRowCount,
|
|
userRowCount,
|
|
systemRowCount,
|
|
markdownRows,
|
|
commitCount,
|
|
mountActualDurationMs: numericMs("perf-mount-duration"),
|
|
latestActualDurationMs: numericMs("perf-latest-duration"),
|
|
maxActualDurationMs: numericMs("perf-max-duration"),
|
|
totalActualDurationMs: numericMs("perf-total-duration"),
|
|
reactProfilerAvailable: commitCount > 0,
|
|
scrollResponsiveMs: Number(scrollResponsiveMs.toFixed(1)),
|
|
scrollDeltaPx: Math.round(Math.abs(window.scrollY - scrollStartY)),
|
|
documentHeightPx: Math.round(document.documentElement.scrollHeight),
|
|
};
|
|
});
|
|
|
|
const elapsedMs = Date.now() - startedAt;
|
|
console.log(JSON.stringify({ ...metrics, renderReadyMs: rowReadyMs, elapsedMs }, null, 2));
|
|
} finally {
|
|
await browser.close();
|
|
}
|