From a77ee000d1fae2b735e0e3c35dffd3173bd5d098 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 8 Sep 2026 16:53:44 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20fix:=20prevent=20transcript?= =?UTF-8?q?=20loading=20indicator=20layout=20flash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep transient loading feedback out of the in-flow composer dock; add desktop and phone replay geometry regression coverage. --- _Generated with `xum` • Model: `openai:gpt-6-astra` • Thinking: `medium` • Cost: `$9.15`_ --- src/browser/components/ChatPane/ChatPane.tsx | 29 ++++--- .../stories/App.chatLoading.stories.tsx | 81 ++++++++++++++++--- 2 files changed, 88 insertions(+), 22 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 3eab5164e0..e2754f9835 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -1689,18 +1689,23 @@ const ChatPaneContent: React.FC = (props) => { )} - {/* Read-only transcripts need replay feedback without an editable composer. */} - {isHydratingTranscript && !shouldMountStreamingBarrier && ( - -
-
-
+ {/* Replay feedback must not resize the in-flow dock: even a brief + catch-up would otherwise shift cached transcript rows on workspace switches. + Keep it above the dock for both editable and read-only transcripts, + yielding to Jump to bottom while scrolled up so they cannot overlap on phones. */} + {isHydratingTranscript && !shouldMountStreamingBarrier && autoScroll && ( +
+ +
+
+
+
)} {transcriptOnly ? ( // Transcript-only workspaces keep their historical transcript, but the whole diff --git a/src/browser/stories/App.chatLoading.stories.tsx b/src/browser/stories/App.chatLoading.stories.tsx index 5a11f678be..7edde634ad 100644 --- a/src/browser/stories/App.chatLoading.stories.tsx +++ b/src/browser/stories/App.chatLoading.stories.tsx @@ -54,6 +54,35 @@ async function checkLoadingLayout(canvasElement: HTMLElement) { }); } +// Catch-up must not change dock height or move already-visible rows, including +// bottom-pinned transcripts whose scroll position follows content size changes. +async function finishReplayWithoutLayoutShift( + canvasElement: HTMLElement, + finishReplay: () => void +) { + const canvas = within(canvasElement); + const dock = canvas.getByTestId("chat-composer-dock"); + const message = canvas.getByText("Previously loaded response."); + const scrollport = canvas.getByTestId("message-window"); + const before = { + dockHeight: dock.getBoundingClientRect().height, + messageTop: message.getBoundingClientRect().top, + scrollHeight: scrollport.scrollHeight, + }; + finishReplay(); + await waitFor(() => expect(getLoadingStatus(canvasElement)).toBeNull()); + // Let layout and the native scroll/resize observers process the removal. + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ); + await expect(dock.getBoundingClientRect().height).toBe(before.dockHeight); + const replayedMessage = await canvas.findByText("Previously loaded response."); + await expect(replayedMessage.getBoundingClientRect().top, "cached message position").toBe( + before.messageTop + ); + await expect(scrollport.scrollHeight).toBe(before.scrollHeight); +} + function createHydrationStory(workspaceId: string): AppStory { const workspace = createWorkspace({ id: workspaceId, @@ -77,9 +106,14 @@ function createHydrationStory(workspaceId: string): AppStory { transcriptOnly: true, }); const workspaces = [workspace, otherWorkspace, monitorWorkspace, transcriptWorkspace]; - const history = createAssistantMessage("history", "Previously loaded response.", { - historySequence: 1, - }); + // Exercise real bottom-pinning, not only a short transcript with spare space. + const history = createAssistantMessage( + "history", + Array.from({ length: 20 }, (_, index) => `Earlier response paragraph ${index + 1}.`).join( + "\n\n" + ) + "\n\nPreviously loaded response.", + { historySequence: 1 } + ); let emitChat: (event: WorkspaceChatMessage) => void; let subscriptions = 0; let transcriptSubscriptions = 0; @@ -184,6 +218,31 @@ function createHydrationStory(workspaceId: string): AppStory { await expect(canvas.queryByTestId("transcript-hydration-placeholder")).toBeNull(); await expect(exposedStatuses()).toHaveLength(1); await expect(exposedStatuses()[0]).toBe(getLoadingStatus(canvasElement)); + // The loading badge must yield to navigation instead of overlapping it on phones. + const scrollport = canvas.getByTestId("message-window"); + await expect(scrollport.scrollHeight).toBeGreaterThan(scrollport.clientHeight); + scrollport.dispatchEvent(new WheelEvent("wheel", { deltaY: -100, bubbles: true })); + scrollport.scrollTop = 0; + scrollport.dispatchEvent(new Event("scroll")); + const jumpToBottom = await canvas.findByRole("button", { name: /Jump to bottom/ }); + await expect(getLoadingStatus(canvasElement)).toBeNull(); + await userEvent.click(jumpToBottom); + await checkLoadingLayout(canvasElement); + await finishReplayWithoutLayoutShift(canvasElement, () => { + emitChat(history); + emitChat({ + type: "caught-up", + replay: "since", + hasOlderHistory: false, + cursor: { history: { messageId: history.id, historySequence: 1 } }, + }); + }); + // Re-enter catch-up for the competing progress-state checks below. + await switchWorkspace(canvasElement, otherWorkspace.id); + await expect(await canvas.findByText("Another workspace response.")).toBeVisible(); + await switchWorkspace(canvasElement, workspace.id); + await waitFor(() => expect(subscriptions).toBe(3)); + await checkLoadingLayout(canvasElement); } ); @@ -275,12 +334,14 @@ function createHydrationStory(workspaceId: string): AppStory { await expect(canvas.getByText("Previously loaded response.")).toBeVisible(); await expect(canvas.queryByTestId("transcript-hydration-placeholder")).toBeNull(); await expect(canvas.queryByRole("textbox")).toBeNull(); - emitTranscript(history); - emitTranscript({ - type: "caught-up", - replay: "since", - hasOlderHistory: false, - cursor: { history: { messageId: history.id, historySequence: 1 } }, + await finishReplayWithoutLayoutShift(canvasElement, () => { + emitTranscript(history); + emitTranscript({ + type: "caught-up", + replay: "since", + hasOlderHistory: false, + cursor: { history: { messageId: history.id, historySequence: 1 } }, + }); }); await waitFor(() => expect(getLoadingStatus(canvasElement)).toBeNull()); await expect(canvas.getByText("Previously loaded response.")).toBeVisible(); @@ -294,7 +355,7 @@ function createHydrationStory(workspaceId: string): AppStory { await canvas.findByText("Another workspace response.", {}, { timeout: 5000 }) ).toBeVisible(); await switchWorkspace(canvasElement, workspace.id); - await waitFor(() => expect(subscriptions).toBe(3)); + await waitFor(() => expect(subscriptions).toBe(4)); await checkLoadingLayout(canvasElement); await expect(canvas.getByText("Previously loaded response.")).toBeVisible(); } From 096c620fb357b3537c1016913250d1d580ef3b33 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 8 Sep 2026 17:03:31 -0500 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A4=96=20fix:=20reserve=20stable=20cl?= =?UTF-8?q?earance=20for=20transcript=20loading=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _Generated with `xum` • Model: `openai:gpt-6-astra` • Thinking: `medium` • Cost: `$9.15`_ --- src/browser/components/ChatPane/ChatPane.tsx | 6 +++--- src/browser/stories/App.chatLoading.stories.tsx | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index e2754f9835..17e3f24c77 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -1440,9 +1440,9 @@ const ChatPaneContent: React.FC = (props) => { // margins disable flex-item stretch. chatTranscriptFullWidth ? "w-full" : "plan-toc-aware max-w-4xl mx-auto w-full", // `flex-1` pushes the dock to the scrollport bottom for short - // transcripts; `pb-[15px]` keeps the original gap between the last - // message and the composer. - "flex-1 pb-[15px]", + // transcripts. Keep a permanent gutter for the loading overlay so + // even compact tail rows remain unobscured without resizing on catch-up. + "flex-1 pb-8", // Only the empty/centered placeholder fills height (as a flex column // so the placeholder's flex-1 centering works). The hydration // skeleton renders in normal top-aligned transcript flow so it sits diff --git a/src/browser/stories/App.chatLoading.stories.tsx b/src/browser/stories/App.chatLoading.stories.tsx index 7edde634ad..2e7d441669 100644 --- a/src/browser/stories/App.chatLoading.stories.tsx +++ b/src/browser/stories/App.chatLoading.stories.tsx @@ -44,6 +44,13 @@ async function checkLoadingLayout(canvasElement: HTMLElement) { const statusRect = status!.getBoundingClientRect(); const dockRect = dock.getBoundingClientRect(); const composerRect = composer.getBoundingClientRect(); + const transcript = within(canvasElement).getByRole("log"); + const transcriptContentBottom = + transcript.getBoundingClientRect().bottom - + Number.parseFloat(getComputedStyle(transcript).paddingBottom); + // The badge must stay inside the permanent gutter, even when the final row + // has no extra margin (e.g. a compact tool or reasoning row). + await expect(statusRect.top).toBeGreaterThanOrEqual(transcriptContentBottom); await expect(statusRect.bottom).toBeLessThanOrEqual(composerRect.top); await expect(Math.abs(dockRect.left - composerRect.left)).toBeLessThan(1); await expect(Math.abs(dockRect.right - composerRect.right)).toBeLessThan(1);