mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
fix(ui): preserve issue thread scroll on comment submit
This commit is contained in:
1 parent
093691b683
commit
71e9aeb06d
3 files changed
+146
-1
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
Reference in new issue
Block a user