diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 931242fd1c3..68edd254243 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -65,6 +65,10 @@ import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; import { AgentIcon } from "./AgentIconPicker"; import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; +import { + captureComposerViewportSnapshot, + restoreComposerViewportSnapshot, +} from "../lib/issue-chat-scroll"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { timeAgo } from "../lib/timeAgo"; import { @@ -1615,6 +1619,13 @@ const IssueChatComposer = forwardRef(null); const draftTimer = useRef | null>(null); + function queueViewportRestore(snapshot: ReturnType) { + if (!snapshot) return; + requestAnimationFrame(() => { + restoreComposerViewportSnapshot(snapshot, composerContainerRef.current); + }); + } + function focusComposer() { if (typeof composerContainerRef.current?.scrollIntoView === "function") { composerContainerRef.current.scrollIntoView({ behavior: "smooth", block: "end" }); @@ -1668,11 +1679,12 @@ const IssueChatComposer = forwardRef ({}), + } as DOMRect); +} + +describe("issue-chat-scroll", () => { + it("restores page scroll when the composer shifts in the viewport", () => { + const composer = document.createElement("div"); + document.body.appendChild(composer); + const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {}); + + mockTop(composer, 420); + const snapshot = captureComposerViewportSnapshot(composer); + + mockTop(composer, 560); + restoreComposerViewportSnapshot(snapshot, composer); + + expect(scrollByMock).toHaveBeenCalledWith({ top: 140, left: 0, behavior: "auto" }); + + scrollByMock.mockRestore(); + composer.remove(); + }); + + it("restores main-content scroll when the layout uses an internal scroller", () => { + const mainContent = document.createElement("main"); + mainContent.id = "main-content"; + mainContent.style.overflowY = "auto"; + Object.defineProperty(mainContent, "scrollHeight", { + configurable: true, + value: 1800, + }); + Object.defineProperty(mainContent, "clientHeight", { + configurable: true, + value: 900, + }); + mainContent.scrollTop = 240; + document.body.appendChild(mainContent); + + const composer = document.createElement("div"); + document.body.appendChild(composer); + const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {}); + + mockTop(composer, 300); + const snapshot = captureComposerViewportSnapshot(composer); + + mockTop(composer, 380); + restoreComposerViewportSnapshot(snapshot, composer); + + expect(mainContent.scrollTop).toBe(320); + expect(scrollByMock).not.toHaveBeenCalled(); + + scrollByMock.mockRestore(); + composer.remove(); + mainContent.remove(); + }); +}); diff --git a/ui/src/lib/issue-chat-scroll.ts b/ui/src/lib/issue-chat-scroll.ts new file mode 100644 index 00000000000..edc7a58e1c8 --- /dev/null +++ b/ui/src/lib/issue-chat-scroll.ts @@ -0,0 +1,57 @@ +export type IssueChatScrollTarget = + | { type: "element"; element: HTMLElement } + | { type: "window" }; + +export interface ComposerViewportSnapshot { + composerViewportTop: number; +} + +export function resolveIssueChatScrollTarget( + doc: Document = document, + win: Window = window, +): IssueChatScrollTarget { + const mainContent = doc.getElementById("main-content"); + + if (mainContent instanceof HTMLElement) { + const overflowY = win.getComputedStyle(mainContent).overflowY; + const usesOwnScroll = + (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") + && mainContent.scrollHeight > mainContent.clientHeight + 1; + + if (usesOwnScroll) { + return { type: "element", element: mainContent }; + } + } + + return { type: "window" }; +} + +export function captureComposerViewportSnapshot( + composerElement: HTMLElement | null, +): ComposerViewportSnapshot | null { + if (!composerElement) return null; + + return { + composerViewportTop: composerElement.getBoundingClientRect().top, + }; +} + +export function restoreComposerViewportSnapshot( + snapshot: ComposerViewportSnapshot | null, + composerElement: HTMLElement | null, + doc: Document = document, + win: Window = window, +) { + if (!snapshot || !composerElement) return; + + const delta = composerElement.getBoundingClientRect().top - snapshot.composerViewportTop; + if (!Number.isFinite(delta) || Math.abs(delta) < 1) return; + + const target = resolveIssueChatScrollTarget(doc, win); + if (target.type === "element") { + target.element.scrollTop += delta; + return; + } + + win.scrollBy({ top: delta, left: 0, behavior: "auto" }); +}