Improve issue thread scale and markdown polish (#4861)
## 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>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
#!/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();
|
||||
}
|
||||
Reference in New Issue
Block a user