diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 43cc8f97f53..f3901a2e42a 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -10,6 +10,7 @@ import { IssueChatThread, VIRTUALIZED_THREAD_ROW_THRESHOLD, canStopIssueChatRun, + findLatestCommentMessageIndex, resolveAssistantMessageFoldedState, resolveIssueChatHumanAuthor, } from "./IssueChatThread"; @@ -27,6 +28,10 @@ import { issueChatLongThreadLinkedRuns, issueChatLongThreadTranscriptsByRunId, } from "../fixtures/issueChatLongThreadFixture"; +import type { + IssueChatLinkedRun, + IssueChatTranscriptEntry, +} from "../lib/issue-chat-messages"; function hasSmoothScrollBehavior(arg: unknown) { return typeof arg === "object" @@ -531,6 +536,136 @@ describe("IssueChatThread", () => { 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. + it("targets the latest comment row when trailing rows are non-comments (PAP-2672)", () => { + const lastComment = issueChatLongThreadComments.at(-1); + expect(lastComment).toBeDefined(); + const trailingRunStart = new Date(new Date(lastComment!.createdAt).getTime() + 60_000); + const trailingRun: IssueChatLinkedRun = { + runId: "trailing-run-pap-2672", + status: "failed", + agentId: "agent-perf-codex", + agentName: "TrailingRunner", + adapterType: "codex_local", + createdAt: trailingRunStart, + startedAt: trailingRunStart, + finishedAt: trailingRunStart, + hasStoredOutput: true, + }; + const trailingTranscriptEntries: readonly IssueChatTranscriptEntry[] = [ + { + kind: "assistant", + ts: trailingRunStart.toISOString(), + text: "Trailing run posted after the latest comment.", + }, + ]; + const transcriptsByRunId = new Map(issueChatLongThreadTranscriptsByRunId); + transcriptsByRunId.set(trailingRun.runId, trailingTranscriptEntries); + const linkedRuns: IssueChatLinkedRun[] = [ + ...issueChatLongThreadLinkedRuns, + trailingRun, + ]; + + container.remove(); + const scrollHost = document.createElement("main"); + scrollHost.id = "main-content"; + scrollHost.style.overflowY = "auto"; + scrollHost.style.overflow = "auto"; + scrollHost.style.height = "800px"; + Object.defineProperty(scrollHost, "scrollHeight", { + configurable: true, + get: () => 200_000, + }); + Object.defineProperty(scrollHost, "clientHeight", { + configurable: true, + get: () => 800, + }); + 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 root = createRoot(container); + act(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + transcriptsByRunId={transcriptsByRunId} + hasOutputForRun={(runId) => transcriptsByRunId.has(runId)} + /> + , + ); + }); + + const virtualizerEl = container.querySelector( + '[data-testid="issue-chat-thread-virtualizer"]', + ); + expect(virtualizerEl).not.toBeNull(); + const totalMergedRows = Number(virtualizerEl?.dataset.virtualCount ?? "0"); + expect(totalMergedRows).toBeGreaterThan(VIRTUALIZED_THREAD_ROW_THRESHOLD); + + elementScrollToMock.mockClear(); + + const jump = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Jump to latest", + ) as HTMLButtonElement | undefined; + expect(jump).toBeDefined(); + + act(() => { + jump?.click(); + }); + + const smoothCalls = elementScrollToMock.mock.calls + .map((call) => call[0] as ScrollToOptions) + .filter(hasSmoothScrollBehavior); + expect(smoothCalls.length).toBeGreaterThan(0); + + // For align="end" with the very last index, tanstack-virtual short-circuits + // to getMaxScrollOffset() (= scrollHeight - clientHeight = 199_200 here). + // A jump to the latest comment row (one slot earlier) lands at item.end - + // clientHeight, which is strictly less. Asserting top < maxScrollOffset + // proves the button isn't routing to the trailing run row. + const maxScrollOffset = 200_000 - 800; + const lastTop = smoothCalls[smoothCalls.length - 1]?.top; + expect(typeof lastTop).toBe("number"); + expect(lastTop as number).toBeLessThan(maxScrollOffset); + expect(lastTop as number).toBeGreaterThan(0); + + act(() => { + root.unmount(); + }); + scrollHost.remove(); + }); + + it("findLatestCommentMessageIndex prefers the last comment-anchored row (PAP-2672)", () => { + const messages = [ + { metadata: { custom: { anchorId: "comment-a" } } }, + { metadata: { custom: { anchorId: "run-1" } } }, + { metadata: { custom: { anchorId: "comment-b" } } }, + { metadata: { custom: { anchorId: "run-2" } } }, + { metadata: { custom: { anchorId: "activity-3" } } }, + ]; + expect(findLatestCommentMessageIndex(messages as never)).toBe(2); + expect( + findLatestCommentMessageIndex([ + { metadata: { custom: { anchorId: "run-only" } } }, + ] as never), + ).toBe(-1); + expect(findLatestCommentMessageIndex([] as never)).toBe(-1); + }); + it("keeps the direct render path for short threads under the virtualization threshold", () => { const root = createRoot(container); const directComments = issueChatLongThreadComments.slice(0, 12);