mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
fix(ui): anchor issue composer during live updates
This commit is contained in:
1 parent
f4747d3cce
commit
38c0fb4e16
4 files changed
+154
-14
No files matched your search
@@ -19,6 +19,16 @@ const { threadMessagesMock } = vi.hoisted(() => ({
|
||||
threadMessagesMock: vi.fn(() => <div data-testid="thread-messages" />),
|
||||
}));
|
||||
|
||||
const {
|
||||
captureComposerViewportSnapshotMock,
|
||||
restoreComposerViewportSnapshotMock,
|
||||
shouldPreserveComposerViewportMock,
|
||||
} = vi.hoisted(() => ({
|
||||
captureComposerViewportSnapshotMock: vi.fn(),
|
||||
restoreComposerViewportSnapshotMock: vi.fn(),
|
||||
shouldPreserveComposerViewportMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@assistant-ui/react", () => ({
|
||||
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ThreadPrimitive: {
|
||||
@@ -55,6 +65,16 @@ vi.mock("./transcript/useLiveRunTranscripts", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/issue-chat-scroll", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/issue-chat-scroll")>();
|
||||
return {
|
||||
...actual,
|
||||
captureComposerViewportSnapshot: captureComposerViewportSnapshotMock.mockImplementation(actual.captureComposerViewportSnapshot),
|
||||
restoreComposerViewportSnapshot: restoreComposerViewportSnapshotMock.mockImplementation(actual.restoreComposerViewportSnapshot),
|
||||
shouldPreserveComposerViewport: shouldPreserveComposerViewportMock.mockImplementation(actual.shouldPreserveComposerViewport),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
@@ -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(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[{
|
||||
id: "run-1",
|
||||
issueId: "issue-1",
|
||||
status: "running",
|
||||
invocationSource: "comment",
|
||||
triggerDetail: null,
|
||||
startedAt: "2026-04-06T12:00:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-04-06T12:00:00.000Z",
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent 1",
|
||||
adapterType: "codex_local",
|
||||
}]}
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
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",
|
||||
|
||||
@@ -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<HTMLDivElement | null>(null);
|
||||
const composerViewportAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerViewportSnapshotRef = useRef<ReturnType<typeof captureComposerViewportSnapshot>>(null);
|
||||
const preserveComposerViewportRef = useRef(false);
|
||||
const displayLiveRuns = useMemo(() => {
|
||||
const deduped = new Map<string, LiveRunForIssue>();
|
||||
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({
|
||||
</IssueChatErrorBoundary>
|
||||
|
||||
{showComposer ? (
|
||||
<IssueChatComposer
|
||||
ref={composerRef}
|
||||
onImageUpload={imageUploadHandler}
|
||||
onAttachImage={onAttachImage}
|
||||
draftKey={draftKey}
|
||||
enableReassign={enableReassign}
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentions}
|
||||
agentMap={agentMap}
|
||||
composerDisabledReason={composerDisabledReason}
|
||||
issueStatus={issueStatus}
|
||||
/>
|
||||
<div ref={composerViewportAnchorRef}>
|
||||
<IssueChatComposer
|
||||
ref={composerRef}
|
||||
onImageUpload={imageUploadHandler}
|
||||
onAttachImage={onAttachImage}
|
||||
draftKey={draftKey}
|
||||
enableReassign={enableReassign}
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentions}
|
||||
agentMap={agentMap}
|
||||
composerDisabledReason={composerDisabledReason}
|
||||
issueStatus={issueStatus}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</IssueChatCtx.Provider>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user