diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 4c9869f18ed..890a9405170 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -19,6 +19,16 @@ const { threadMessagesMock } = vi.hoisted(() => ({ threadMessagesMock: vi.fn(() =>
), })); +const { + captureComposerViewportSnapshotMock, + restoreComposerViewportSnapshotMock, + shouldPreserveComposerViewportMock, +} = vi.hoisted(() => ({ + captureComposerViewportSnapshotMock: vi.fn(), + restoreComposerViewportSnapshotMock: vi.fn(), + shouldPreserveComposerViewportMock: vi.fn(), +})); + vi.mock("@assistant-ui/react", () => ({ AssistantRuntimeProvider: ({ children }: { children: ReactNode }) =>
{children}
, ThreadPrimitive: { @@ -55,6 +65,16 @@ vi.mock("./transcript/useLiveRunTranscripts", () => ({ }), })); +vi.mock("../lib/issue-chat-scroll", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + captureComposerViewportSnapshot: captureComposerViewportSnapshotMock.mockImplementation(actual.captureComposerViewportSnapshot), + restoreComposerViewportSnapshot: restoreComposerViewportSnapshotMock.mockImplementation(actual.restoreComposerViewportSnapshot), + shouldPreserveComposerViewport: shouldPreserveComposerViewportMock.mockImplementation(actual.shouldPreserveComposerViewport), + }; +}); + vi.mock("./MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: ReactNode }) =>
{children}
, })); @@ -133,6 +153,9 @@ describe("IssueChatThread", () => { appendMock.mockReset(); markdownEditorFocusMock.mockReset(); threadMessagesMock.mockReset(); + captureComposerViewportSnapshotMock.mockClear(); + restoreComposerViewportSnapshotMock.mockClear(); + shouldPreserveComposerViewportMock.mockClear(); }); it("drops the count heading and does not use an internal scrollbox", () => { @@ -495,6 +518,62 @@ describe("IssueChatThread", () => { }); }); + it("requests composer viewport restoration when live messages arrive while the composer is visible", () => { + const root = createRoot(container); + const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {}); + shouldPreserveComposerViewportMock.mockReturnValue(true); + captureComposerViewportSnapshotMock.mockReturnValue({ composerViewportTop: 420 }); + + act(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + act(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect(restoreComposerViewportSnapshotMock).toHaveBeenCalled(); + + scrollByMock.mockRestore(); + act(() => { + root.unmount(); + }); + }); + it("folds chain-of-thought when the same message transitions from running to complete", () => { expect(resolveAssistantMessageFoldedState({ messageId: "message-1", diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index d0b64e61a5e..a8f48cd9aec 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -15,6 +15,7 @@ import { useContext, useEffect, useImperativeHandle, + useLayoutEffect, useMemo, useRef, useState, @@ -70,6 +71,7 @@ import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; import { captureComposerViewportSnapshot, restoreComposerViewportSnapshot, + shouldPreserveComposerViewport, } from "../lib/issue-chat-scroll"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { timeAgo } from "../lib/timeAgo"; @@ -1884,6 +1886,9 @@ export function IssueChatThread({ const location = useLocation(); const hasScrolledRef = useRef(false); const bottomAnchorRef = useRef(null); + const composerViewportAnchorRef = useRef(null); + const composerViewportSnapshotRef = useRef>(null); + const preserveComposerViewportRef = useRef(false); const displayLiveRuns = useMemo(() => { const deduped = new Map(); for (const run of liveRuns) { @@ -1988,6 +1993,19 @@ export function IssueChatThread({ onCancel: onCancelRun, }); + useLayoutEffect(() => { + const composerElement = composerViewportAnchorRef.current; + if (preserveComposerViewportRef.current) { + restoreComposerViewportSnapshot( + composerViewportSnapshotRef.current, + composerElement, + ); + } + + composerViewportSnapshotRef.current = captureComposerViewportSnapshot(composerElement); + preserveComposerViewportRef.current = shouldPreserveComposerViewport(composerElement); + }, [messages]); + useEffect(() => { const hash = location.hash; if (!(hash.startsWith("#comment-") || hash.startsWith("#activity-") || hash.startsWith("#run-"))) return; @@ -2096,20 +2114,22 @@ export function IssueChatThread({ {showComposer ? ( - +
+ +
) : null}
diff --git a/ui/src/lib/issue-chat-scroll.test.ts b/ui/src/lib/issue-chat-scroll.test.ts index d3f3411f007..3621aaec754 100644 --- a/ui/src/lib/issue-chat-scroll.test.ts +++ b/ui/src/lib/issue-chat-scroll.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { captureComposerViewportSnapshot, restoreComposerViewportSnapshot, + shouldPreserveComposerViewport, } from "./issue-chat-scroll"; function mockTop(element: HTMLElement, top: number) { @@ -70,4 +71,28 @@ describe("issue-chat-scroll", () => { composer.remove(); mainContent.remove(); }); + + it("preserves the composer viewport when the composer is visible", () => { + const composer = document.createElement("div"); + document.body.appendChild(composer); + mockTop(composer, 540); + + expect(shouldPreserveComposerViewport(composer)).toBe(true); + + composer.remove(); + }); + + it("preserves the composer viewport when focus stays inside the composer", () => { + const composer = document.createElement("div"); + const input = document.createElement("textarea"); + composer.appendChild(input); + document.body.appendChild(composer); + mockTop(composer, 1200); + + input.focus(); + + expect(shouldPreserveComposerViewport(composer)).toBe(true); + + composer.remove(); + }); }); diff --git a/ui/src/lib/issue-chat-scroll.ts b/ui/src/lib/issue-chat-scroll.ts index edc7a58e1c8..1e7e5da135a 100644 --- a/ui/src/lib/issue-chat-scroll.ts +++ b/ui/src/lib/issue-chat-scroll.ts @@ -36,6 +36,22 @@ export function captureComposerViewportSnapshot( }; } +export function shouldPreserveComposerViewport( + composerElement: HTMLElement | null, + doc: Document = document, +) { + if (!composerElement) return false; + + const activeElement = doc.activeElement; + if (activeElement instanceof Node && composerElement.contains(activeElement)) { + return true; + } + + const viewportHeight = doc.defaultView?.innerHeight ?? window.innerHeight; + const rect = composerElement.getBoundingClientRect(); + return rect.bottom > 0 && rect.top < viewportHeight; +} + export function restoreComposerViewportSnapshot( snapshot: ComposerViewportSnapshot | null, composerElement: HTMLElement | null,