mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Add long-thread issue chat perf fixture
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
647c2c487e
commit
a2be4580ae
8 files changed
+636
-8
No files matched your search
+2
-1
@@ -41,7 +41,8 @@
|
||||
"evals:smoke": "cd evals/promptfoo && npx promptfoo@0.103.3 eval",
|
||||
"test:release-smoke": "npx playwright test --config tests/release-smoke/playwright.config.ts",
|
||||
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed",
|
||||
"metrics:paperclip-commits": "tsx scripts/paperclip-commit-metrics.ts"
|
||||
"metrics:paperclip-commits": "tsx scripts/paperclip-commit-metrics.ts",
|
||||
"perf:issue-chat-long-thread": "node scripts/measure-issue-chat-long-thread.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/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");
|
||||
return document.querySelectorAll('[data-testid="issue-chat-message-row"]').length >= 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 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")),
|
||||
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();
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail";
|
||||
import { Workspaces } from "./pages/Workspaces";
|
||||
import { Issues } from "./pages/Issues";
|
||||
import { IssueDetail } from "./pages/IssueDetail";
|
||||
import { IssueChatLongThreadPerf } from "./pages/IssueChatLongThreadPerf";
|
||||
import { Routines } from "./pages/Routines";
|
||||
import { RoutineDetail } from "./pages/RoutineDetail";
|
||||
import { UserProfile } from "./pages/UserProfile";
|
||||
@@ -98,6 +99,7 @@ function boardRoutes() {
|
||||
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
|
||||
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
|
||||
<Route path="issues/:issueId" element={<IssueDetail />} />
|
||||
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
|
||||
<Route path="routines" element={<Routines />} />
|
||||
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
||||
@@ -256,6 +258,7 @@ export function App() {
|
||||
<Route path="board-claim/:token" element={<BoardClaimPage />} />
|
||||
<Route path="cli-auth/:id" element={<CliAuthPage />} />
|
||||
<Route path="invite/:token" element={<InviteLandingPage />} />
|
||||
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
|
||||
|
||||
<Route element={<CloudAccessGate />}>
|
||||
<Route index element={<CompanyRootRedirect />} />
|
||||
|
||||
@@ -17,6 +17,13 @@ import type {
|
||||
RequestConfirmationInteraction,
|
||||
SuggestTasksInteraction,
|
||||
} from "../lib/issue-thread-interactions";
|
||||
import {
|
||||
issueChatLongThreadAgentMap,
|
||||
issueChatLongThreadComments,
|
||||
issueChatLongThreadEvents,
|
||||
issueChatLongThreadLinkedRuns,
|
||||
issueChatLongThreadTranscriptsByRunId,
|
||||
} from "../fixtures/issueChatLongThreadFixture";
|
||||
|
||||
const { markdownEditorFocusMock } = vi.hoisted(() => ({
|
||||
markdownEditorFocusMock: vi.fn(),
|
||||
@@ -318,6 +325,39 @@ describe("IssueChatThread", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("mounts one measurable row per merged long-thread message", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={issueChatLongThreadComments}
|
||||
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||
timelineEvents={issueChatLongThreadEvents}
|
||||
liveRuns={[]}
|
||||
agentMap={issueChatLongThreadAgentMap}
|
||||
currentUserId="user-board"
|
||||
onAdd={async () => {}}
|
||||
showComposer={false}
|
||||
showJumpToLatest={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const rows = container.querySelectorAll('[data-testid="issue-chat-message-row"]');
|
||||
expect(rows.length).toBeGreaterThanOrEqual(450);
|
||||
expect(container.querySelectorAll('[data-message-role="assistant"]').length).toBeGreaterThanOrEqual(150);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows explicit follow-up badges and event copy", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
|
||||
@@ -2798,13 +2798,23 @@ export function IssueChatThread({
|
||||
// index-scoped message providers; live transcripts can shrink
|
||||
// or regroup while the runtime still holds stale indices.
|
||||
messages.map((message) => {
|
||||
if (message.role === "user") {
|
||||
return <IssueChatUserMessage key={message.id} message={message} />;
|
||||
}
|
||||
if (message.role === "assistant") {
|
||||
return <IssueChatAssistantMessage key={message.id} message={message} />;
|
||||
}
|
||||
return <IssueChatSystemMessage key={message.id} message={message} />;
|
||||
const custom = message.metadata?.custom as Record<string, unknown> | undefined;
|
||||
const kind = typeof custom?.kind === "string" ? custom.kind : message.role;
|
||||
const renderedMessage = message.role === "user"
|
||||
? <IssueChatUserMessage message={message} />
|
||||
: message.role === "assistant"
|
||||
? <IssueChatAssistantMessage message={message} />
|
||||
: <IssueChatSystemMessage message={message} />;
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
data-testid="issue-chat-message-row"
|
||||
data-message-role={message.role}
|
||||
data-message-kind={kind}
|
||||
>
|
||||
{renderedMessage}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{showComposer ? (
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
issueChatLongThreadAgentMap,
|
||||
issueChatLongThreadComments,
|
||||
issueChatLongThreadEvents,
|
||||
issueChatLongThreadLinkedRuns,
|
||||
issueChatLongThreadMarkdownCommentIds,
|
||||
issueChatLongThreadTranscriptsByRunId,
|
||||
LONG_THREAD_COMMENT_COUNT,
|
||||
LONG_THREAD_MARKDOWN_COMMENT_COUNT,
|
||||
} from "./issueChatLongThreadFixture";
|
||||
import { buildIssueChatMessages } from "../lib/issue-chat-messages";
|
||||
|
||||
describe("issueChatLongThreadFixture", () => {
|
||||
it("builds a deterministic long issue-thread shape", () => {
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: issueChatLongThreadComments,
|
||||
timelineEvents: issueChatLongThreadEvents,
|
||||
linkedRuns: issueChatLongThreadLinkedRuns,
|
||||
liveRuns: [],
|
||||
agentMap: issueChatLongThreadAgentMap,
|
||||
currentUserId: "user-board",
|
||||
transcriptsByRunId: issueChatLongThreadTranscriptsByRunId,
|
||||
hasOutputForRun: (runId) => issueChatLongThreadTranscriptsByRunId.has(runId),
|
||||
});
|
||||
|
||||
expect(issueChatLongThreadComments).toHaveLength(LONG_THREAD_COMMENT_COUNT);
|
||||
expect(issueChatLongThreadMarkdownCommentIds.size).toBe(LONG_THREAD_MARKDOWN_COMMENT_COUNT);
|
||||
expect(messages.length).toBeGreaterThanOrEqual(450);
|
||||
expect(messages.filter((message) => message.role === "assistant").length).toBeGreaterThanOrEqual(
|
||||
LONG_THREAD_MARKDOWN_COMMENT_COUNT,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps markdown rows markdown-heavy enough to exercise MarkdownBody", () => {
|
||||
const markdownComments = issueChatLongThreadComments.filter((comment) =>
|
||||
issueChatLongThreadMarkdownCommentIds.has(comment.id),
|
||||
);
|
||||
|
||||
expect(markdownComments).toHaveLength(LONG_THREAD_MARKDOWN_COMMENT_COUNT);
|
||||
for (const comment of markdownComments.slice(0, 5)) {
|
||||
expect(comment.body).toContain("## Baseline note");
|
||||
expect(comment.body).toContain("```ts");
|
||||
expect(comment.body).toContain("| Metric | Value |");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import type { LiveRunForIssue } from "../api/heartbeats";
|
||||
import type {
|
||||
IssueChatComment,
|
||||
IssueChatLinkedRun,
|
||||
IssueChatTranscriptEntry,
|
||||
} from "../lib/issue-chat-messages";
|
||||
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
|
||||
export const LONG_THREAD_COMMENT_COUNT = 469;
|
||||
export const LONG_THREAD_MARKDOWN_COMMENT_COUNT = 150;
|
||||
export const LONG_THREAD_EVENT_COUNT = 12;
|
||||
export const LONG_THREAD_LINKED_RUN_COUNT = 6;
|
||||
|
||||
const baseTime = new Date("2026-04-28T14:00:00.000Z").getTime();
|
||||
|
||||
function atMinute(offset: number) {
|
||||
return new Date(baseTime + offset * 60_000);
|
||||
}
|
||||
|
||||
function createAgent(id: string, name: string, icon: string, urlKey: string): Agent {
|
||||
const now = new Date("2026-04-28T14:00:00.000Z");
|
||||
return {
|
||||
id,
|
||||
companyId: "company-long-thread",
|
||||
name,
|
||||
urlKey,
|
||||
role: "engineer",
|
||||
title: null,
|
||||
icon,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
};
|
||||
}
|
||||
|
||||
const primaryAgent = createAgent("agent-perf-codex", "CodexCoder", "code", "codexcoder");
|
||||
const reviewerAgent = createAgent("agent-perf-reviewer", "ReviewBot", "sparkles", "reviewbot");
|
||||
|
||||
export const issueChatLongThreadAgentMap = new Map<string, Agent>([
|
||||
[primaryAgent.id, primaryAgent],
|
||||
[reviewerAgent.id, reviewerAgent],
|
||||
]);
|
||||
|
||||
function markdownBody(index: number) {
|
||||
return [
|
||||
`## Baseline note ${index}`,
|
||||
"",
|
||||
`This assistant update captures a deterministic markdown-heavy row for long-thread rendering. It references [PAP-${2600 + index}](/PAP/issues/PAP-${2600 + index}) and includes enough structure to exercise markdown parsing.`,
|
||||
"",
|
||||
"- Parsed checklist item one with inline `code`",
|
||||
"- Parsed checklist item two with **bold** and _italic_ text",
|
||||
"- Parsed checklist item three with a link to [Paperclip](/PAP/dashboard)",
|
||||
"",
|
||||
"| Metric | Value |",
|
||||
"| --- | ---: |",
|
||||
`| Fixture row | ${index} |`,
|
||||
`| Synthetic tokens | ${1200 + index} |`,
|
||||
"",
|
||||
"```ts",
|
||||
`const fixtureRow${index} = { markdown: true, deterministic: true };`,
|
||||
"```",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function plainUserBody(index: number) {
|
||||
return `Board checkpoint ${index}: keep the issue-detail page responsive while the thread is full of historical comments.`;
|
||||
}
|
||||
|
||||
function plainAssistantBody(index: number) {
|
||||
return `Processed checkpoint ${index}. The current direct-render path should keep this row mounted with the rest of the thread.`;
|
||||
}
|
||||
|
||||
function createComment(index: number): IssueChatComment {
|
||||
const isMarkdown = index < LONG_THREAD_MARKDOWN_COMMENT_COUNT;
|
||||
const isAssistant = isMarkdown || index % 4 === 1 || index % 4 === 2;
|
||||
const authorAgentId = isAssistant
|
||||
? (index % 7 === 0 ? reviewerAgent.id : primaryAgent.id)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: `long-thread-comment-${String(index + 1).padStart(3, "0")}`,
|
||||
companyId: "company-long-thread",
|
||||
issueId: "issue-long-thread",
|
||||
authorAgentId,
|
||||
authorUserId: authorAgentId ? null : "user-board",
|
||||
body: isMarkdown
|
||||
? markdownBody(index + 1)
|
||||
: authorAgentId
|
||||
? plainAssistantBody(index + 1)
|
||||
: plainUserBody(index + 1),
|
||||
createdAt: atMinute(index),
|
||||
updatedAt: atMinute(index),
|
||||
};
|
||||
}
|
||||
|
||||
export const issueChatLongThreadComments: IssueChatComment[] = Array.from(
|
||||
{ length: LONG_THREAD_COMMENT_COUNT },
|
||||
(_, index) => createComment(index),
|
||||
);
|
||||
|
||||
export const issueChatLongThreadMarkdownCommentIds = new Set(
|
||||
issueChatLongThreadComments
|
||||
.slice(0, LONG_THREAD_MARKDOWN_COMMENT_COUNT)
|
||||
.map((comment) => comment.id),
|
||||
);
|
||||
|
||||
export const issueChatLongThreadEvents: IssueTimelineEvent[] = Array.from(
|
||||
{ length: LONG_THREAD_EVENT_COUNT },
|
||||
(_, index) => ({
|
||||
id: `long-thread-event-${index + 1}`,
|
||||
createdAt: atMinute(index * 36 + 18),
|
||||
actorType: index % 3 === 0 ? "user" : "agent",
|
||||
actorId: index % 3 === 0 ? "user-board" : primaryAgent.id,
|
||||
statusChange: index % 2 === 0
|
||||
? { from: index === 0 ? "todo" : "in_progress", to: "in_progress" }
|
||||
: undefined,
|
||||
assigneeChange: index % 2 === 1
|
||||
? {
|
||||
from: { agentId: null, userId: null },
|
||||
to: { agentId: index % 4 === 1 ? primaryAgent.id : reviewerAgent.id, userId: null },
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
export const issueChatLongThreadLinkedRuns: IssueChatLinkedRun[] = Array.from(
|
||||
{ length: LONG_THREAD_LINKED_RUN_COUNT },
|
||||
(_, index) => ({
|
||||
runId: `long-thread-run-${index + 1}`,
|
||||
status: index % 3 === 0 ? "failed" : index % 3 === 1 ? "timed_out" : "succeeded",
|
||||
agentId: index % 2 === 0 ? primaryAgent.id : reviewerAgent.id,
|
||||
agentName: index % 2 === 0 ? primaryAgent.name : reviewerAgent.name,
|
||||
adapterType: "codex_local",
|
||||
createdAt: atMinute(index * 72 + 12),
|
||||
startedAt: atMinute(index * 72 + 12),
|
||||
finishedAt: atMinute(index * 72 + 16),
|
||||
hasStoredOutput: true,
|
||||
}),
|
||||
);
|
||||
|
||||
export const issueChatLongThreadLiveRuns: LiveRunForIssue[] = [];
|
||||
|
||||
export const issueChatLongThreadTranscriptsByRunId = new Map<string, readonly IssueChatTranscriptEntry[]>(
|
||||
issueChatLongThreadLinkedRuns.map((run, index) => [
|
||||
run.runId,
|
||||
[
|
||||
{
|
||||
kind: "thinking",
|
||||
ts: atMinute(index * 72 + 13).toISOString(),
|
||||
text: `Inspecting long-thread segment ${index + 1}.`,
|
||||
},
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts: atMinute(index * 72 + 14).toISOString(),
|
||||
name: "read_file",
|
||||
toolUseId: `long-thread-tool-${index + 1}`,
|
||||
input: { path: "ui/src/components/IssueChatThread.tsx" },
|
||||
},
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts: atMinute(index * 72 + 15).toISOString(),
|
||||
toolUseId: `long-thread-tool-${index + 1}`,
|
||||
content: "Confirmed the direct-render fixture keeps the full message subtree mounted.",
|
||||
isError: run.status !== "succeeded",
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: atMinute(index * 72 + 16).toISOString(),
|
||||
text: `Run ${index + 1} produced a compact transcript row for adjacent run context.`,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
export const issueChatLongThreadFixtureContext = {
|
||||
issue: {
|
||||
identifier: "PAP-PERF",
|
||||
title: "Long-thread rendering baseline fixture",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
projectName: "Paperclip App",
|
||||
},
|
||||
documents: [
|
||||
"Implementation Plan",
|
||||
"Profiler Notes",
|
||||
"Release Checklist",
|
||||
"QA Readout",
|
||||
],
|
||||
subIssues: [
|
||||
"Phase 1: Add long-thread perf fixture and baseline",
|
||||
"Phase 2: Isolate issue-thread row rendering and Markdown work",
|
||||
"Phase 3: Apply virtualization and guard scroll behavior",
|
||||
"Phase 4: Verify production issue profile improvement",
|
||||
],
|
||||
sidebarStats: [
|
||||
["Comments", String(LONG_THREAD_COMMENT_COUNT)],
|
||||
["Markdown bodies", String(LONG_THREAD_MARKDOWN_COMMENT_COUNT)],
|
||||
["Timeline events", String(LONG_THREAD_EVENT_COUNT)],
|
||||
["Linked runs", String(LONG_THREAD_LINKED_RUN_COUNT)],
|
||||
],
|
||||
} as const;
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Profiler, useEffect, useLayoutEffect, useMemo, useRef, useState, type ProfilerOnRenderCallback } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { IssueChatThread } from "../components/IssueChatThread";
|
||||
import {
|
||||
issueChatLongThreadAgentMap,
|
||||
issueChatLongThreadComments,
|
||||
issueChatLongThreadEvents,
|
||||
issueChatLongThreadFixtureContext,
|
||||
issueChatLongThreadLinkedRuns,
|
||||
issueChatLongThreadLiveRuns,
|
||||
issueChatLongThreadMarkdownCommentIds,
|
||||
issueChatLongThreadTranscriptsByRunId,
|
||||
LONG_THREAD_COMMENT_COUNT,
|
||||
LONG_THREAD_MARKDOWN_COMMENT_COUNT,
|
||||
} from "../fixtures/issueChatLongThreadFixture";
|
||||
|
||||
const noop = async () => {};
|
||||
|
||||
type RenderMetrics = {
|
||||
commitCount: number;
|
||||
mountActualDuration: number | null;
|
||||
latestActualDuration: number | null;
|
||||
maxActualDuration: number;
|
||||
totalActualDuration: number;
|
||||
};
|
||||
|
||||
const initialMetrics: RenderMetrics = {
|
||||
commitCount: 0,
|
||||
mountActualDuration: null,
|
||||
latestActualDuration: null,
|
||||
maxActualDuration: 0,
|
||||
totalActualDuration: 0,
|
||||
};
|
||||
|
||||
function formatMs(value: number | null) {
|
||||
if (value === null || !Number.isFinite(value)) return "pending";
|
||||
return `${value.toFixed(1)} ms`;
|
||||
}
|
||||
|
||||
function MetricTile({ label, value, testId }: { label: string; value: string; testId: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-background px-3 py-2">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div data-testid={testId} className="mt-1 font-mono text-sm text-foreground">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueChatLongThreadPerf() {
|
||||
const [metrics, setMetrics] = useState<RenderMetrics>(initialMetrics);
|
||||
const metricsRef = useRef<RenderMetrics>(initialMetrics);
|
||||
const renderStartedAtRef = useRef(performance.now());
|
||||
const publishTimerRef = useRef<number | null>(null);
|
||||
const publishedRef = useRef(false);
|
||||
const fixture = issueChatLongThreadFixtureContext;
|
||||
const rowTarget = useMemo(
|
||||
() => LONG_THREAD_COMMENT_COUNT + issueChatLongThreadEvents.length + issueChatLongThreadLinkedRuns.length,
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (publishTimerRef.current !== null) {
|
||||
window.clearTimeout(publishTimerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (publishedRef.current || metricsRef.current.commitCount > 0) return;
|
||||
const mountDuration = performance.now() - renderStartedAtRef.current;
|
||||
const next = {
|
||||
commitCount: 1,
|
||||
mountActualDuration: mountDuration,
|
||||
latestActualDuration: mountDuration,
|
||||
maxActualDuration: mountDuration,
|
||||
totalActualDuration: mountDuration,
|
||||
};
|
||||
metricsRef.current = next;
|
||||
publishedRef.current = true;
|
||||
setMetrics(next);
|
||||
}, []);
|
||||
|
||||
const handleRender: ProfilerOnRenderCallback = (_id, phase, actualDuration) => {
|
||||
const current = metricsRef.current;
|
||||
metricsRef.current = {
|
||||
commitCount: current.commitCount + 1,
|
||||
mountActualDuration: phase === "mount" && current.mountActualDuration === null
|
||||
? actualDuration
|
||||
: current.mountActualDuration,
|
||||
latestActualDuration: actualDuration,
|
||||
maxActualDuration: Math.max(current.maxActualDuration, actualDuration),
|
||||
totalActualDuration: current.totalActualDuration + actualDuration,
|
||||
};
|
||||
|
||||
if (publishedRef.current || publishTimerRef.current !== null) return;
|
||||
publishTimerRef.current = window.setTimeout(() => {
|
||||
publishTimerRef.current = null;
|
||||
publishedRef.current = true;
|
||||
setMetrics(metricsRef.current);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="issue-chat-long-thread-perf" className="space-y-5">
|
||||
<div className="flex flex-col gap-3 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-[11px]">
|
||||
{fixture.issue.identifier}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{fixture.issue.status.replace(/_/g, " ")}</Badge>
|
||||
<Badge variant="outline">{fixture.issue.projectName}</Badge>
|
||||
</div>
|
||||
<h1 className="mt-3 text-2xl font-semibold tracking-tight">{fixture.issue.title}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
Deterministic local fixture for measuring the current direct-render issue chat path with
|
||||
hundreds of merged thread rows, markdown-heavy assistant bodies, linked runs, documents,
|
||||
sub-issues, and sidebar context.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid min-w-[280px] grid-cols-2 gap-2">
|
||||
<MetricTile label="Fixture rows" value={String(rowTarget)} testId="perf-fixture-row-target" />
|
||||
<MetricTile label="Markdown rows" value={String(LONG_THREAD_MARKDOWN_COMMENT_COUNT)} testId="perf-fixture-markdown-rows" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<main className="min-w-0 space-y-4">
|
||||
<Card className="border-border/70">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Issue documents</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 sm:grid-cols-2">
|
||||
{fixture.documents.map((document) => (
|
||||
<div key={document} className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
|
||||
{document}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/70">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Sub-issues</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{fixture.subIssues.map((subIssue, index) => (
|
||||
<div key={subIssue} className="flex items-center gap-3 rounded-md border border-border bg-background px-3 py-2 text-sm">
|
||||
<span className="font-mono text-xs text-muted-foreground">#{index + 1}</span>
|
||||
<span>{subIssue}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Profiler id="issue-chat-long-thread" onRender={handleRender}>
|
||||
<IssueChatThread
|
||||
comments={issueChatLongThreadComments}
|
||||
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||
timelineEvents={issueChatLongThreadEvents}
|
||||
liveRuns={issueChatLongThreadLiveRuns}
|
||||
issueStatus="in_progress"
|
||||
agentMap={issueChatLongThreadAgentMap}
|
||||
currentUserId="user-board"
|
||||
onAdd={noop}
|
||||
showComposer={false}
|
||||
showJumpToLatest={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
|
||||
/>
|
||||
</Profiler>
|
||||
</main>
|
||||
|
||||
<aside className="space-y-4 xl:sticky xl:top-4 xl:self-start">
|
||||
<Card className="border-border/70">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Baseline metrics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2">
|
||||
<MetricTile label="Profiler commits" value={String(metrics.commitCount)} testId="perf-commit-count" />
|
||||
<MetricTile label="Mount duration" value={formatMs(metrics.mountActualDuration)} testId="perf-mount-duration" />
|
||||
<MetricTile label="Latest duration" value={formatMs(metrics.latestActualDuration)} testId="perf-latest-duration" />
|
||||
<MetricTile label="Max duration" value={formatMs(metrics.maxActualDuration)} testId="perf-max-duration" />
|
||||
<MetricTile label="Total duration" value={formatMs(metrics.totalActualDuration)} testId="perf-total-duration" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/70">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Fixture shape</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{fixture.sidebarStats.map(([label, value]) => (
|
||||
<div key={label} className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-mono">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="hidden" data-testid="perf-markdown-comment-id-sample">
|
||||
{[...issueChatLongThreadMarkdownCommentIds].slice(0, 3).join(",")}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user