From e66f552bb938ce8313a1ec34b6b6f3ca0975e2c7 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 6 Apr 2026 16:50:12 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20fix:=20prevent=20open-time=20tra?= =?UTF-8?q?nscript=20layout=20flash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep ChatPane's synchronous transcript bottom-pinning effect in sync when hydration-gated transcript chrome appears without changing the latest message. Add a regression test covering the older-history button path on cached transcript open. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$6.19`_ --- src/browser/components/ChatPane/ChatPane.tsx | 12 ++- tests/ui/chat/bottomLayoutShift.test.ts | 97 +++++++++++++++++++- 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index b36f0b1661..bf088620cf 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -648,10 +648,12 @@ export const ChatPane: React.FC = (props) => { const shouldMountRetryBarrier = !suppressRetryBarrier; const showRetryBarrierUI = showRetryBarrier && !suppressRetryBarrier; - // Keep the transcript bottom pinned before paint when the tail changes. - // Sending a message can append a new user row and mount footer UI (streaming/retry/TODO) - // in the same turn; synchronizing here avoids a visible jump before the async - // ResizeObserver / streaming auto-scroll path runs. + // Keep the transcript bottom pinned before paint when visible transcript chrome changes. + // Sending a message can append a new user row and mount footer UI (streaming/retry/TODO), + // and opening a cached non-streaming transcript can reveal hydration-gated UI like the + // interrupted marker or the older-history button without changing latestMessageId. + // Synchronizing here avoids a visible jump before the async ResizeObserver / auto-scroll + // path runs. useLayoutEffect(() => { if (!autoScroll || !contentRef.current) { return; @@ -667,6 +669,8 @@ export const ChatPane: React.FC = (props) => { workspaceState?.isStreamStarting, workspaceState?.canInterrupt, shouldShowQueuedAgentTaskPrompt, + shouldRenderLoadOlderMessagesButton, + isHydratingTranscript, ]); const handleLoadOlderHistory = useCallback(() => { diff --git a/tests/ui/chat/bottomLayoutShift.test.ts b/tests/ui/chat/bottomLayoutShift.test.ts index bda9700257..dcd353dc91 100644 --- a/tests/ui/chat/bottomLayoutShift.test.ts +++ b/tests/ui/chat/bottomLayoutShift.test.ts @@ -1,6 +1,6 @@ import "../dom"; -import { fireEvent, waitFor } from "@testing-library/react"; +import { act, fireEvent, waitFor } from "@testing-library/react"; // App-level UI tests render the loader shell first, so stub Lottie before importing the // harness to keep happy-dom from tripping over lottie-web's canvas bootstrap. @@ -11,7 +11,7 @@ jest.mock("lottie-react", () => ({ import { preloadTestModules } from "../../ipc/setup"; import { createAppHarness } from "../harness"; -import { workspaceStore } from "@/browser/stores/WorkspaceStore"; +import { useWorkspaceStoreRaw, workspaceStore } from "@/browser/stores/WorkspaceStore"; function getMessageWindow(container: HTMLElement): HTMLDivElement { const element = container.querySelector('[data-testid="message-window"]'); @@ -249,6 +249,99 @@ describe("Chat bottom layout stability", () => { } }, 60_000); + test("keeps the transcript pinned when opening cached history reveals older-history chrome", async () => { + const app = await createAppHarness({ branchPrefix: "open-layout-shift" }); + + const originalRequestAnimationFrame = globalThis.requestAnimationFrame; + const originalWindowRequestAnimationFrame = window.requestAnimationFrame; + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; + const originalWindowCancelAnimationFrame = window.cancelAnimationFrame; + const queuedAnimationFrames: FrameRequestCallback[] = []; + + try { + await app.chat.send("Seed transcript before testing workspace-open pinning"); + await app.chat.expectStreamComplete(); + await app.chat.expectTranscriptContains( + "Mock response: Seed transcript before testing workspace-open pinning" + ); + + // Let the previous turn's queued auto-scroll frames settle before we freeze the async path. + await new Promise((resolve) => originalRequestAnimationFrame(() => resolve())); + await new Promise((resolve) => originalRequestAnimationFrame(() => resolve())); + + const messageWindow = getMessageWindow(app.view.container); + let scrollTop = 1000; + let scrollHeight = 1000; + + Object.defineProperty(messageWindow, "scrollTop", { + configurable: true, + get: () => scrollTop, + set: (nextValue: number) => { + scrollTop = nextValue; + }, + }); + Object.defineProperty(messageWindow, "scrollHeight", { + configurable: true, + get: () => scrollHeight, + }); + Object.defineProperty(messageWindow, "clientHeight", { + configurable: true, + get: () => 400, + }); + + const requestAnimationFrameMock: typeof requestAnimationFrame = (callback) => { + queuedAnimationFrames.push(callback); + return queuedAnimationFrames.length; + }; + const cancelAnimationFrameMock: typeof cancelAnimationFrame = () => undefined; + + globalThis.requestAnimationFrame = requestAnimationFrameMock; + window.requestAnimationFrame = requestAnimationFrameMock; + globalThis.cancelAnimationFrame = cancelAnimationFrameMock; + window.cancelAnimationFrame = cancelAnimationFrameMock; + + // eslint-disable-next-line react-hooks/rules-of-hooks -- plain singleton accessor, no React state. + const storeRaw = useWorkspaceStoreRaw() as unknown as { + historyPagination: Map; + bumpState(workspaceId: string): void; + }; + const paginationState = storeRaw.historyPagination.get(app.workspaceId); + expect(paginationState).toBeDefined(); + + await waitFor( + () => { + expect(app.view.container.textContent ?? "").not.toContain("Load older messages"); + }, + { timeout: 10_000 } + ); + + // Workspace-open catch-up can reveal the older-history button above cached rows without + // changing the latest message, so bottom pinning must happen synchronously. + scrollTop = scrollHeight; + scrollHeight = 1120; + act(() => { + paginationState!.hasOlder = true; + paginationState!.loading = false; + storeRaw.bumpState(app.workspaceId); + }); + + await waitFor( + () => { + expect(app.view.container.textContent ?? "").toContain("Load older messages"); + }, + { timeout: 10_000 } + ); + + expect(scrollTop).toBe(scrollHeight); + } finally { + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + window.requestAnimationFrame = originalWindowRequestAnimationFrame; + globalThis.cancelAnimationFrame = originalCancelAnimationFrame; + window.cancelAnimationFrame = originalWindowCancelAnimationFrame; + await app.dispose(); + } + }, 60_000); + test("keeps the transcript pinned when send-time footer UI appears", async () => { const app = await createAppHarness({ branchPrefix: "bottom-layout-shift" });