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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user