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
+119 -21
View File
@@ -174,8 +174,14 @@ interface MentionState {
query: string;
top: number;
left: number;
/** Viewport-relative coords for portal positioning */
/**
* Caret-aligned viewport coords for portal positioning. `viewportTop` /
* `viewportBottom` describe the active text line, and `viewportLeft` is the
* caret X (right edge of the last typed character) so the menu can sit on
* the same line, just to the right of the cursor.
*/
viewportTop: number;
viewportBottom: number;
viewportLeft: number;
textNode: Text;
atPos: number;
@@ -201,6 +207,8 @@ const MENTION_MENU_HEIGHT = 208;
const MENTION_MENU_PADDING = 8;
const MENTION_MENU_ROW_HEIGHT = 34;
const MENTION_MENU_CHROME_HEIGHT = 8;
/** Roughly one space-width of breathing room between the caret and the menu. */
const MENTION_MENU_CARET_GAP = 10;
const CODE_BLOCK_LANGUAGES: Record<string, string> = {
txt: "Text",
@@ -263,6 +271,36 @@ export function findMentionMatch(
};
}
interface CaretRect {
top: number;
bottom: number;
/** Caret X — the right edge of the last typed character (or left edge of the next). */
x: number;
}
function measureCaretRect(textNode: Text, offset: number, atPos: number): CaretRect {
const length = textNode.textContent?.length ?? 0;
const rectFromRange = (start: number, end: number, side: "right" | "left"): CaretRect | null => {
if (start < 0 || end > length || end <= start) return null;
const range = document.createRange();
range.setStart(textNode, start);
range.setEnd(textNode, end);
const rect = range.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return null;
return { top: rect.top, bottom: rect.bottom, x: side === "right" ? rect.right : rect.left };
};
// Prefer the character immediately before the caret — its right edge IS the caret X
// and its top/bottom describe the active line. Falls back to the char after the caret
// and finally the @ marker if nothing else gives us a valid rect.
return (
rectFromRange(Math.max(0, offset - 1), offset, "right")
?? rectFromRange(offset, Math.min(length, offset + 1), "left")
?? rectFromRange(atPos, atPos + 1, "right")
?? { top: 0, bottom: 0, x: 0 }
);
}
function detectMention(container: HTMLElement): MentionState | null {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
@@ -277,21 +315,20 @@ function detectMention(container: HTMLElement): MentionState | null {
const match = findMentionMatch(text, offset);
if (!match) return null;
// Get position relative to container
const tempRange = document.createRange();
tempRange.setStart(textNode, match.atPos);
tempRange.setEnd(textNode, match.atPos + 1);
const rect = tempRange.getBoundingClientRect();
// Anchor the menu to the live caret so it tracks each typed character instead of
// staying glued to the @ marker.
const caret = measureCaretRect(textNode as Text, offset, match.atPos);
const containerRect = container.getBoundingClientRect();
return {
trigger: match.trigger,
marker: match.marker,
query: match.query,
top: rect.bottom - containerRect.top,
left: rect.left - containerRect.left,
viewportTop: rect.bottom,
viewportLeft: rect.left,
top: caret.top - containerRect.top,
left: caret.x - containerRect.left,
viewportTop: caret.top,
viewportBottom: caret.bottom,
viewportLeft: caret.x,
textNode: textNode as Text,
atPos: match.atPos,
endPos: match.endPos,
@@ -318,7 +355,7 @@ function getMentionMenuViewport(): MentionMenuViewport {
}
export function computeMentionMenuPosition(
anchor: Pick<MentionState, "viewportTop" | "viewportLeft">,
anchor: Pick<MentionState, "viewportTop" | "viewportBottom" | "viewportLeft">,
viewport: MentionMenuViewport,
menuSize: MentionMenuSize = { width: MENTION_MENU_WIDTH, height: MENTION_MENU_HEIGHT },
) {
@@ -327,10 +364,23 @@ export function computeMentionMenuPosition(
const minTop = viewport.offsetTop + MENTION_MENU_PADDING;
const maxTop = viewport.offsetTop + viewport.height - menuSize.height;
return {
top: Math.max(minTop, Math.min(viewport.offsetTop + anchor.viewportTop + 4, maxTop)),
left: Math.max(minLeft, Math.min(viewport.offsetLeft + anchor.viewportLeft, maxLeft)),
};
// Place the menu's top edge on the current line so it sits next to the caret.
// If it would overflow below, flip above so the menu's bottom hugs the line.
const desiredTop = viewport.offsetTop + anchor.viewportTop;
let top: number;
if (desiredTop > maxTop) {
const flipped = viewport.offsetTop + anchor.viewportBottom - menuSize.height;
top = Math.max(minTop, Math.min(flipped, maxTop));
} else {
top = Math.max(minTop, desiredTop);
}
// Place the menu's left edge a small gap to the right of the caret X so
// there's roughly a space-width of breathing room between cursor and menu.
const desiredLeft = viewport.offsetLeft + anchor.viewportLeft + MENTION_MENU_CARET_GAP;
const left = Math.max(minLeft, Math.min(desiredLeft, maxLeft));
return { top, left };
}
function getMentionMenuSize(optionCount: number): MentionMenuSize {
@@ -903,6 +953,44 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
}
}, [selectMention]);
// Touch handling for the mention menu. We deliberately do NOT preventDefault
// on touchstart so the browser can still scroll the menu vertically; instead
// we record the start point and only treat the gesture as a selection if the
// finger lifted with negligible movement (i.e., a tap, not a scroll).
const touchStartPointRef = useRef<{ x: number; y: number } | null>(null);
const TOUCH_TAP_THRESHOLD_PX = 8;
const handleAutocompleteTouchStart = useCallback((event: ReactTouchEvent<HTMLButtonElement>) => {
const touch = event.touches[0];
if (!touch) return;
touchStartPointRef.current = { x: touch.clientX, y: touch.clientY };
}, []);
const handleAutocompleteTouchMove = useCallback((event: ReactTouchEvent<HTMLButtonElement>) => {
const start = touchStartPointRef.current;
if (!start) return;
const touch = event.touches[0];
if (!touch) return;
if (Math.hypot(touch.clientX - start.x, touch.clientY - start.y) > TOUCH_TAP_THRESHOLD_PX) {
touchStartPointRef.current = null;
}
}, []);
const handleAutocompleteTouchEnd = useCallback((
event: ReactTouchEvent<HTMLButtonElement>,
option: AutocompleteOption,
) => {
const start = touchStartPointRef.current;
touchStartPointRef.current = null;
if (!start) return;
const touch = event.changedTouches[0];
if (!touch) return;
if (Math.hypot(touch.clientX - start.x, touch.clientY - start.y) > TOUCH_TAP_THRESHOLD_PX) {
return;
}
handleAutocompletePress(event, option);
}, [handleAutocompletePress]);
function hasFilePayload(evt: DragEvent<HTMLDivElement>) {
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
}
@@ -1131,26 +1219,36 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
/>
{/* Mention dropdown — rendered via portal so it isn't clipped by overflow containers */}
{mentionActive && filteredMentions.length > 0 &&
{mentionActive && filteredMentions.length > 0 && mentionMenuPosition &&
createPortal(
<div
className="fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
className="fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[208px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
style={{
top: Math.min(mentionState.viewportTop + 4, window.innerHeight - 208),
left: Math.max(8, Math.min(mentionState.viewportLeft, window.innerWidth - 188)),
top: mentionMenuPosition.top,
left: mentionMenuPosition.left,
touchAction: "pan-y",
WebkitOverflowScrolling: "touch",
}}
>
{filteredMentions.map((option, i) => (
<button
key={option.id}
type="button"
tabIndex={-1}
className={cn(
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
i === mentionIndex && "bg-accent",
)}
onPointerDown={(e) => handleAutocompletePress(e, option)}
onPointerDown={(e) => {
// Touch is handled via onTouchStart/onTouchEnd so vertical scrolling
// isn't swallowed; only handle mouse/pen here.
if (e.pointerType === "touch") return;
handleAutocompletePress(e, option);
}}
onMouseDown={(e) => handleAutocompletePress(e, option)}
onTouchStart={(e) => handleAutocompletePress(e, option)}
onTouchStart={handleAutocompleteTouchStart}
onTouchMove={handleAutocompleteTouchMove}
onTouchEnd={(e) => handleAutocompleteTouchEnd(e, option)}
onMouseEnter={() => {
if (mentionStateRef.current?.trigger === "skill") {
skillEnterArmedRef.current = true;