fix(ui): anchor issue composer during live updates

This commit is contained in:
Dotta committed 2026-04-14 11:46:39 -05:00
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",
+34 -14
View File
@@ -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>
+25
View File
@@ -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();
});
});
+16
View File
@@ -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,