diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx
index 4c9869f18ed..890a9405170 100644
--- a/ui/src/components/IssueChatThread.test.tsx
+++ b/ui/src/components/IssueChatThread.test.tsx
@@ -19,6 +19,16 @@ const { threadMessagesMock } = vi.hoisted(() => ({
threadMessagesMock: vi.fn(() =>
),
}));
+const {
+ captureComposerViewportSnapshotMock,
+ restoreComposerViewportSnapshotMock,
+ shouldPreserveComposerViewportMock,
+} = vi.hoisted(() => ({
+ captureComposerViewportSnapshotMock: vi.fn(),
+ restoreComposerViewportSnapshotMock: vi.fn(),
+ shouldPreserveComposerViewportMock: vi.fn(),
+}));
+
vi.mock("@assistant-ui/react", () => ({
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => {children}
,
ThreadPrimitive: {
@@ -55,6 +65,16 @@ vi.mock("./transcript/useLiveRunTranscripts", () => ({
}),
}));
+vi.mock("../lib/issue-chat-scroll", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ captureComposerViewportSnapshot: captureComposerViewportSnapshotMock.mockImplementation(actual.captureComposerViewportSnapshot),
+ restoreComposerViewportSnapshot: restoreComposerViewportSnapshotMock.mockImplementation(actual.restoreComposerViewportSnapshot),
+ shouldPreserveComposerViewport: shouldPreserveComposerViewportMock.mockImplementation(actual.shouldPreserveComposerViewport),
+ };
+});
+
vi.mock("./MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: ReactNode }) => {children}
,
}));
@@ -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(
+
+ {}}
+ enableLiveTranscriptPolling={false}
+ />
+ ,
+ );
+ });
+
+ act(() => {
+ root.render(
+
+ {}}
+ enableLiveTranscriptPolling={false}
+ />
+ ,
+ );
+ });
+
+ 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",
diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx
index d0b64e61a5e..a8f48cd9aec 100644
--- a/ui/src/components/IssueChatThread.tsx
+++ b/ui/src/components/IssueChatThread.tsx
@@ -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(null);
+ const composerViewportAnchorRef = useRef(null);
+ const composerViewportSnapshotRef = useRef>(null);
+ const preserveComposerViewportRef = useRef(false);
const displayLiveRuns = useMemo(() => {
const deduped = new Map();
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({
{showComposer ? (
-
+
+
+
) : null}
diff --git a/ui/src/lib/issue-chat-scroll.test.ts b/ui/src/lib/issue-chat-scroll.test.ts
index d3f3411f007..3621aaec754 100644
--- a/ui/src/lib/issue-chat-scroll.test.ts
+++ b/ui/src/lib/issue-chat-scroll.test.ts
@@ -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();
+ });
});
diff --git a/ui/src/lib/issue-chat-scroll.ts b/ui/src/lib/issue-chat-scroll.ts
index edc7a58e1c8..1e7e5da135a 100644
--- a/ui/src/lib/issue-chat-scroll.ts
+++ b/ui/src/lib/issue-chat-scroll.ts
@@ -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,