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:
Dotta
2026-04-30 13:18:01 -05:00
committed by GitHub
parent cd606563f6
commit 87f19cd9a6
17 changed files with 1161 additions and 121 deletions
+95 -31
View File
@@ -282,6 +282,51 @@ function buildChecklistStepNumberMap(issues: Issue[], nestingEnabled: boolean):
return stepNumberByIssueId;
}
function buildPreviousSiblingIssueIdMap(issues: Issue[], nestingEnabled: boolean): Map<string, string> {
const previousSiblingByIssueId = new Map<string, string>();
if (!nestingEnabled) {
const previousByParentId = new Map<string, Issue>();
for (const issue of issues) {
if (!issue.parentId) continue;
const previousSibling = previousByParentId.get(issue.parentId);
if (previousSibling) {
previousSiblingByIssueId.set(issue.id, previousSibling.id);
}
previousByParentId.set(issue.parentId, issue);
}
return previousSiblingByIssueId;
}
const { roots, childMap } = buildIssueTree(issues);
const visit = (siblings: Issue[]) => {
siblings.forEach((issue, index) => {
const previousSibling = index > 0 ? siblings[index - 1] : null;
if (issue.parentId && previousSibling?.parentId === issue.parentId) {
previousSiblingByIssueId.set(issue.id, previousSibling.id);
}
visit(childMap.get(issue.id) ?? []);
});
};
visit(roots);
return previousSiblingByIssueId;
}
function shouldSuppressSinglePreviousSiblingBlockerChip(
issue: Issue,
unresolvedVisibleBlockerIds: string[],
previousSiblingIssueId: string | undefined,
): boolean {
return Boolean(
issue.parentId
&& previousSiblingIssueId
&& (issue.blockedBy ?? []).length === 1
&& unresolvedVisibleBlockerIds.length === 1
&& unresolvedVisibleBlockerIds[0] === previousSiblingIssueId,
);
}
/* ── Component ── */
interface Agent {
@@ -878,6 +923,7 @@ export function IssuesList({
const visibleIssueIds = new Set(filtered.map((issue) => issue.id));
const stepNumberByIssueId = buildChecklistStepNumberMap(filtered, viewState.nestingEnabled);
const previousSiblingIssueIdByIssueId = buildPreviousSiblingIssueIdMap(filtered, viewState.nestingEnabled);
const unresolvedVisibleBlockersByIssueId = new Map<string, string[]>();
filtered.forEach((issue) => {
@@ -889,7 +935,12 @@ export function IssuesList({
if (!blockerIssue) return false;
return blockerIssue.status !== "done" && blockerIssue.status !== "cancelled";
});
unresolvedVisibleBlockersByIssueId.set(issue.id, unresolvedVisible);
const shouldSuppressChip = shouldSuppressSinglePreviousSiblingBlockerChip(
issue,
unresolvedVisible,
previousSiblingIssueIdByIssueId.get(issue.id),
);
unresolvedVisibleBlockersByIssueId.set(issue.id, shouldSuppressChip ? [] : unresolvedVisible);
});
const firstActionable = filtered.find((issue) => isActionableWorkflowStatus(issue.status)) ?? null;
@@ -1388,36 +1439,49 @@ export function IssuesList({
const doneRowTitleClass = checklistMeta && issue.status === "done"
? "text-muted-foreground"
: undefined;
const checklistDependencyChips = checklistMeta && unresolvedVisibleBlockers.length > 0 ? (
<>
{unresolvedVisibleBlockers.map((blockerId) => {
const blockerIssue = issueById.get(blockerId);
if (!blockerIssue) return null;
const label = blockerIssue.identifier ?? blockerIssue.id.slice(0, 8);
const blockerStep = checklistMeta.stepNumberByIssueId.get(blockerId);
const blockerStepSuffix = blockerStep ? ` \u00b7 step ${blockerStep}` : "";
const chipLabel = `blocked by ${label}${blockerStepSuffix}`;
return (
<button
key={blockerId}
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
const target = document.getElementById(`issue-workflow-row-${blockerId}`);
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "nearest" });
target.focus?.();
}}
className="inline-flex items-center rounded-full border border-amber-400/45 bg-amber-50/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 hover:bg-amber-100/80 dark:border-amber-300/35 dark:bg-amber-400/10 dark:text-amber-300"
title={chipLabel}
aria-label={chipLabel}
>
{chipLabel}
</button>
);
})}
</>
const visibleBlockerChips = unresolvedVisibleBlockers
.map((blockerId) => {
const blockerIssue = issueById.get(blockerId);
if (!blockerIssue) return null;
const label = blockerIssue.identifier ?? blockerIssue.id.slice(0, 8);
const blockerStep = checklistMeta?.stepNumberByIssueId.get(blockerId);
const blockerStepSuffix = blockerStep ? ` \u00b7 step ${blockerStep}` : "";
return { blockerId, chipLabel: `blocked by ${label}${blockerStepSuffix}` };
})
.filter((chip): chip is { blockerId: string; chipLabel: string } => chip !== null);
const firstVisibleBlockerChip = visibleBlockerChips[0] ?? null;
const additionalVisibleBlockerCount = Math.max(visibleBlockerChips.length - 1, 0);
const additionalVisibleBlockerLabel = additionalVisibleBlockerCount > 0
? ` ... and ${additionalVisibleBlockerCount} more`
: "";
const firstVisibleBlockerDisplayLabel = firstVisibleBlockerChip
? `${firstVisibleBlockerChip.chipLabel}${additionalVisibleBlockerLabel}`
: "";
const hiddenVisibleBlockerLabels = visibleBlockerChips
.slice(1)
.map((chip) => chip.chipLabel)
.join(", ");
const firstVisibleBlockerTitle = additionalVisibleBlockerCount > 0
? `${firstVisibleBlockerDisplayLabel}: ${hiddenVisibleBlockerLabels}`
: firstVisibleBlockerDisplayLabel;
const checklistDependencyChips = checklistMeta && firstVisibleBlockerChip ? (
<button
key={firstVisibleBlockerChip.blockerId}
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
const target = document.getElementById(`issue-workflow-row-${firstVisibleBlockerChip.blockerId}`);
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "nearest" });
target.focus?.();
}}
className="inline-flex items-center rounded-full border border-amber-400/45 bg-amber-50/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 hover:bg-amber-100/80 dark:border-amber-300/35 dark:bg-amber-400/10 dark:text-amber-300"
title={firstVisibleBlockerTitle}
aria-label={firstVisibleBlockerTitle}
>
{firstVisibleBlockerDisplayLabel}
</button>
) : null;
return (