Add long-thread issue chat perf fixture

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 11:11:26 -05:00
1 parent 647c2c487e
commit a2be4580ae
8 files changed
+636 -8

No files matched your search

+2 -1
View File
@@ -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",
+100
View File
@@ -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();
}
+3
View File
@@ -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);
+17 -7
View File
@@ -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;
+213
View File
@@ -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>
);
}