mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40:39 +09:00
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:
parent
cd606563f6
commit
87f19cd9a6
17 changed files with 1161 additions and 121 deletions
|
|
@ -1,6 +1,6 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createRef, forwardRef, useImperativeHandle } from "react";
|
||||
import { act, createRef, forwardRef, useImperativeHandle, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
|
@ -601,6 +601,71 @@ describe("IssueChatThread", () => {
|
|||
scrollHost.remove();
|
||||
});
|
||||
|
||||
it("cancels jump-to-latest settling when the user scrolls manually", () => {
|
||||
vi.useFakeTimers();
|
||||
container.remove();
|
||||
const scrollHost = document.createElement("main");
|
||||
scrollHost.id = "main-content";
|
||||
scrollHost.style.overflowY = "auto";
|
||||
scrollHost.style.overflow = "auto";
|
||||
scrollHost.style.height = "640px";
|
||||
document.body.appendChild(scrollHost);
|
||||
container = document.createElement("div");
|
||||
scrollHost.appendChild(container);
|
||||
|
||||
const elementScrollToMock = vi.fn();
|
||||
scrollHost.scrollTo = elementScrollToMock as unknown as typeof scrollHost.scrollTo;
|
||||
const originalScrollIntoView = Element.prototype.scrollIntoView;
|
||||
const scrollIntoViewMock = vi.fn();
|
||||
Element.prototype.scrollIntoView = scrollIntoViewMock as unknown as typeof Element.prototype.scrollIntoView;
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={issueChatLongThreadComments}
|
||||
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||
timelineEvents={issueChatLongThreadEvents}
|
||||
liveRuns={[]}
|
||||
agentMap={issueChatLongThreadAgentMap}
|
||||
currentUserId="user-board"
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const jump = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Jump to latest",
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(jump).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
jump?.click();
|
||||
});
|
||||
|
||||
expect(elementScrollToMock.mock.calls.some(([arg]) => hasSmoothScrollBehavior(arg))).toBe(true);
|
||||
const scrollCallsAfterClick = elementScrollToMock.mock.calls.length;
|
||||
|
||||
act(() => {
|
||||
scrollHost.dispatchEvent(new WheelEvent("wheel", { bubbles: true }));
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(elementScrollToMock).toHaveBeenCalledTimes(scrollCallsAfterClick);
|
||||
expect(scrollIntoViewMock).not.toHaveBeenCalled();
|
||||
|
||||
Element.prototype.scrollIntoView = originalScrollIntoView;
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
scrollHost.remove();
|
||||
});
|
||||
|
||||
// Regression for PAP-2672: when the merged feed ends with a non-comment row
|
||||
// (run/timeline/embedded output) we still want Jump to latest to land on the
|
||||
// last comment, not whichever activity row sorts last.
|
||||
|
|
@ -757,6 +822,78 @@ describe("IssueChatThread", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("uses comments rendered by onRefreshLatestComments before resolving latest", async () => {
|
||||
const scrolledIds: string[] = [];
|
||||
const originalScrollIntoView = Element.prototype.scrollIntoView;
|
||||
Element.prototype.scrollIntoView = vi.fn(function scrollIntoView(this: Element) {
|
||||
scrolledIds.push(this.id);
|
||||
}) as unknown as typeof Element.prototype.scrollIntoView;
|
||||
|
||||
const olderComment = {
|
||||
id: "comment-before-refresh",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: "agent-perf-codex",
|
||||
authorUserId: null,
|
||||
body: "Older loaded comment",
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
};
|
||||
const latestComment = {
|
||||
...olderComment,
|
||||
id: "comment-after-refresh",
|
||||
body: "Latest fetched comment",
|
||||
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
};
|
||||
|
||||
function RefreshingThread() {
|
||||
const [comments, setComments] = useState([olderComment]);
|
||||
return (
|
||||
<IssueChatThread
|
||||
comments={comments}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
agentMap={issueChatLongThreadAgentMap}
|
||||
currentUserId="user-board"
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
onRefreshLatestComments={async () => {
|
||||
setComments([olderComment, latestComment]);
|
||||
await new Promise((resolve) => window.requestAnimationFrame(resolve));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<RefreshingThread />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const jump = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Jump to latest",
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(jump).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
jump?.click();
|
||||
await new Promise((resolve) => window.requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
expect(scrolledIds).toContain("comment-comment-after-refresh");
|
||||
|
||||
Element.prototype.scrollIntoView = originalScrollIntoView;
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("findLatestCommentMessageIndex prefers the last comment-anchored row (PAP-2672)", () => {
|
||||
const messages = [
|
||||
{ metadata: { custom: { anchorId: "comment-a" } } },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue