forked from farhoodlabs/paperclip
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:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user