fix(ui): preserve issue thread scroll on comment submit

This commit is contained in:
Dotta committed 2026-04-14 11:45:22 -05:00
1 parent 093691b683
commit 71e9aeb06d
3 files changed
+146 -1

No files matched your search

+16 -1
View File
@@ -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<IssueChatComposerHandle, IssueChatComposerP
const composerContainerRef = useRef<HTMLDivElement | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
function queueViewportRestore(snapshot: ReturnType<typeof captureComposerViewportSnapshot>) {
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<IssueChatComposerHandle, IssueChatComposerP
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : undefined;
const submittedBody = trimmed;
const viewportSnapshot = captureComposerViewportSnapshot(composerContainerRef.current);
setSubmitting(true);
setBody("");
try {
await api.thread().append({
const appendPromise = api.thread().append({
role: "user",
content: [{ type: "text", text: submittedBody }],
metadata: { custom: {} },
@@ -1684,6 +1696,8 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
},
},
});
queueViewportRestore(viewportSnapshot);
await appendPromise;
if (draftKey) clearDraft(draftKey);
setReopen(issueStatus === "done" || issueStatus === "cancelled");
setReassignTarget(effectiveSuggestedAssigneeValue);
@@ -1696,6 +1710,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
);
} finally {
setSubmitting(false);
queueViewportRestore(viewportSnapshot);
}
}
+73
View File
@@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import {
captureComposerViewportSnapshot,
restoreComposerViewportSnapshot,
} from "./issue-chat-scroll";
function mockTop(element: HTMLElement, top: number) {
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
top,
bottom: top + 48,
left: 0,
right: 0,
width: 0,
height: 48,
x: 0,
y: top,
toJSON: () => ({}),
} 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();
});
});
+57
View File
@@ -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" });
}