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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1161 additions and 121 deletions

View file

@ -417,6 +417,7 @@ export function NewIssueDialog() {
const [isFileDragOver, setIsFileDragOver] = useState(false);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const executionWorkspaceDefaultProjectId = useRef<string | null>(null);
const initializationKeyRef = useRef<string | null>(null);
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
@ -673,7 +674,13 @@ export function NewIssueDialog() {
// Restore draft or apply defaults when dialog opens
useEffect(() => {
if (!newIssueOpen) return;
if (!newIssueOpen) {
initializationKeyRef.current = null;
return;
}
const initializationKey = `${selectedCompanyId ?? ""}:${JSON.stringify(newIssueDefaults)}`;
if (initializationKeyRef.current === initializationKey) return;
initializationKeyRef.current = initializationKey;
setDialogCompanyId(selectedCompanyId);
executionWorkspaceDefaultProjectId.current = null;
@ -681,6 +688,7 @@ export function NewIssueDialog() {
if (newIssueDefaults.parentId) {
const defaultProjectId = newIssueDefaults.projectId ?? "";
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
const defaultProjectWorkspaceId = newIssueDefaults.projectWorkspaceId
?? defaultProjectWorkspaceIdForProject(defaultProject);
const defaultExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceId
@ -697,7 +705,9 @@ export function NewIssueDialog() {
setAssigneeChrome(false);
setExecutionWorkspaceMode(defaultExecutionWorkspaceMode);
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || defaultProject
? defaultProjectId || null
: null;
} else if (newIssueDefaults.title) {
setIssueText(newIssueDefaults.title, newIssueDefaults.description ?? "");
setStatus(newIssueDefaults.status ?? "todo");
@ -716,7 +726,7 @@ export function NewIssueDialog() {
setAssigneeChrome(false);
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
setSelectedExecutionWorkspaceId("");
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
executionWorkspaceDefaultProjectId.current = defaultProject ? defaultProjectId || null : null;
} else if (draft && draft.title.trim()) {
const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId;
const restoredProject = orderedProjects.find((project) => project.id === restoredProjectId);
@ -742,7 +752,9 @@ export function NewIssueDialog() {
?? (draft.useIsolatedExecutionWorkspace ? "isolated_workspace" : defaultExecutionWorkspaceModeForProject(restoredProject)),
);
setSelectedExecutionWorkspaceId(draft.selectedExecutionWorkspaceId ?? "");
executionWorkspaceDefaultProjectId.current = restoredProjectId || null;
executionWorkspaceDefaultProjectId.current = draft.projectWorkspaceId || restoredProject
? restoredProjectId || null
: null;
} else {
const defaultProjectId = newIssueDefaults.projectId ?? "";
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
@ -761,9 +773,9 @@ export function NewIssueDialog() {
setAssigneeChrome(false);
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
setSelectedExecutionWorkspaceId("");
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
executionWorkspaceDefaultProjectId.current = defaultProject ? defaultProjectId || null : null;
}
}, [newIssueOpen, newIssueDefaults, orderedProjects, setIssueText]);
}, [newIssueOpen, newIssueDefaults, orderedProjects, selectedCompanyId, setIssueText]);
useEffect(() => {
if (!supportsAssigneeOverrides) {
@ -815,6 +827,7 @@ export function NewIssueDialog() {
setIsFileDragOver(false);
setCompanyOpen(false);
executionWorkspaceDefaultProjectId.current = null;
initializationKeyRef.current = null;
}
function handleCompanyChange(companyId: string) {
@ -1060,7 +1073,12 @@ export function NewIssueDialog() {
}, [orderedProjects]);
useEffect(() => {
if (!newIssueOpen || !projectId || executionWorkspaceDefaultProjectId.current === projectId) {
if (
!newIssueOpen ||
!projectId ||
selectedExecutionWorkspaceId ||
executionWorkspaceDefaultProjectId.current === projectId
) {
return;
}
const project = orderedProjects.find((entry) => entry.id === projectId);
@ -1069,7 +1087,7 @@ export function NewIssueDialog() {
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(project));
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(project));
setSelectedExecutionWorkspaceId("");
}, [newIssueOpen, orderedProjects, projectId]);
}, [newIssueOpen, orderedProjects, projectId, selectedExecutionWorkspaceId]);
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
() => {
return [...(assigneeAdapterModels ?? [])]