From a2be4580ae7955184b830d643173dbaee4cafb97 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 28 Apr 2026 11:11:26 -0500 Subject: [PATCH] Add long-thread issue chat perf fixture Co-Authored-By: Paperclip --- package.json | 3 +- scripts/measure-issue-chat-long-thread.mjs | 100 ++++++++ ui/src/App.tsx | 3 + ui/src/components/IssueChatThread.test.tsx | 40 ++++ ui/src/components/IssueChatThread.tsx | 24 +- .../issueChatLongThreadFixture.test.ts | 47 ++++ ui/src/fixtures/issueChatLongThreadFixture.ts | 214 ++++++++++++++++++ ui/src/pages/IssueChatLongThreadPerf.tsx | 213 +++++++++++++++++ 8 files changed, 636 insertions(+), 8 deletions(-) create mode 100644 scripts/measure-issue-chat-long-thread.mjs create mode 100644 ui/src/fixtures/issueChatLongThreadFixture.test.ts create mode 100644 ui/src/fixtures/issueChatLongThreadFixture.ts create mode 100644 ui/src/pages/IssueChatLongThreadPerf.tsx diff --git a/package.json b/package.json index d470705e7c3..c89b00b2d6a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/measure-issue-chat-long-thread.mjs b/scripts/measure-issue-chat-long-thread.mjs new file mode 100644 index 00000000000..faed5a7c7fb --- /dev/null +++ b/scripts/measure-issue-chat-long-thread.mjs @@ -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(); +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index a622eab15ca..b85acb5f2de 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> @@ -256,6 +258,7 @@ export function App() { } /> } /> } /> + } /> }> } /> diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 5ec92b11de8..7df2ed4c6f3 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -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( + + {}} + showComposer={false} + showJumpToLatest={false} + enableLiveTranscriptPolling={false} + transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} + hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)} + /> + , + ); + }); + + 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); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index bc9dc2cf630..e533ed23b32 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -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 ; - } - if (message.role === "assistant") { - return ; - } - return ; + const custom = message.metadata?.custom as Record | undefined; + const kind = typeof custom?.kind === "string" ? custom.kind : message.role; + const renderedMessage = message.role === "user" + ? + : message.role === "assistant" + ? + : ; + return ( +
+ {renderedMessage} +
+ ); }) )} {showComposer ? ( diff --git a/ui/src/fixtures/issueChatLongThreadFixture.test.ts b/ui/src/fixtures/issueChatLongThreadFixture.test.ts new file mode 100644 index 00000000000..5660e46d9d7 --- /dev/null +++ b/ui/src/fixtures/issueChatLongThreadFixture.test.ts @@ -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 |"); + } + }); +}); diff --git a/ui/src/fixtures/issueChatLongThreadFixture.ts b/ui/src/fixtures/issueChatLongThreadFixture.ts new file mode 100644 index 00000000000..96c9392fa8d --- /dev/null +++ b/ui/src/fixtures/issueChatLongThreadFixture.ts @@ -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([ + [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( + 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; diff --git a/ui/src/pages/IssueChatLongThreadPerf.tsx b/ui/src/pages/IssueChatLongThreadPerf.tsx new file mode 100644 index 00000000000..1ede09f2ce4 --- /dev/null +++ b/ui/src/pages/IssueChatLongThreadPerf.tsx @@ -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 ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +export function IssueChatLongThreadPerf() { + const [metrics, setMetrics] = useState(initialMetrics); + const metricsRef = useRef(initialMetrics); + const renderStartedAtRef = useRef(performance.now()); + const publishTimerRef = useRef(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 ( +
+
+
+
+ + {fixture.issue.identifier} + + {fixture.issue.status.replace(/_/g, " ")} + {fixture.issue.projectName} +
+

{fixture.issue.title}

+

+ 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. +

+
+
+ + +
+
+ +
+
+ + + Issue documents + + + {fixture.documents.map((document) => ( +
+ {document} +
+ ))} +
+
+ + + + Sub-issues + + + {fixture.subIssues.map((subIssue, index) => ( +
+ #{index + 1} + {subIssue} +
+ ))} +
+
+ + + issueChatLongThreadTranscriptsByRunId.has(runId)} + /> + +
+ + +
+
+ ); +}