From 49445cc4e0c32ef0ad36367de168ad7ed56d08e9 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 13 Aug 2026 16:01:25 +0000 Subject: [PATCH 01/11] feat(site): migrate agent chat scrolling --- site/package.json | 2 +- site/pnpm-lock.yaml | 34 +- .../AgentsPage/AgentChatPage.stories.tsx | 69 +- .../pages/AgentsPage/AgentChatPage.test.ts | 39 +- site/src/pages/AgentsPage/AgentChatPage.tsx | 22 +- .../AgentsPage/AgentChatPageView.stories.tsx | 656 +++++++----------- .../pages/AgentsPage/AgentChatPageView.tsx | 64 +- site/src/pages/AgentsPage/AgentEmbedPage.tsx | 6 - .../src/pages/AgentsPage/AgentsPageLayout.tsx | 7 +- .../ConversationTimeline.stories.tsx | 126 ++-- .../ChatConversation/ConversationTimeline.tsx | 515 +++----------- .../ChatConversation/UserMessageContent.tsx | 16 - .../ChatConversation/timelineRows.test.ts | 106 ++- .../ChatConversation/timelineRows.ts | 32 +- .../components/ChatMessageScroller.tsx | 134 ++++ .../components/ChatPageContent.stories.tsx | 23 +- .../AgentsPage/components/ChatPageContent.tsx | 24 +- .../components/ChatScrollContainer.tsx | 184 ----- 18 files changed, 814 insertions(+), 1245 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/ChatMessageScroller.tsx delete mode 100644 site/src/pages/AgentsPage/components/ChatScrollContainer.tsx diff --git a/site/package.json b/site/package.json index 58d79b1e4e9..7c256e374cd 100644 --- a/site/package.json +++ b/site/package.json @@ -58,6 +58,7 @@ "@novnc/novnc": "^1.5.0", "@pierre/diffs": "1.3.3", "@pierre/trees": "1.0.0-beta.4", + "@shadcn/react": "0.3.0", "@tanstack/react-query-devtools": "5.82.0", "@xterm/addon-canvas": "0.7.0", "@xterm/addon-fit": "0.11.0", @@ -94,7 +95,6 @@ "react-confetti": "6.4.0", "react-day-picker": "9.14.0", "react-dom": "19.2.8", - "react-infinite-scroll-component": "7.2.1", "react-markdown": "9.1.0", "react-query": "npm:@tanstack/react-query@5.82.0", "react-resizable-panels": "3.0.6", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index f545d9675bc..36e1eb8bcb8 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@shadcn/react': + specifier: 0.3.0 + version: 0.3.0(@types/react@19.2.18)(react@19.2.8) '@tanstack/react-query-devtools': specifier: 5.82.0 version: 5.82.0(@tanstack/react-query@5.82.0(react@19.2.8))(react@19.2.8) @@ -203,9 +206,6 @@ importers: react-dom: specifier: 19.2.8 version: 19.2.8(react@19.2.8) - react-infinite-scroll-component: - specifier: 7.2.1 - version: 7.2.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react-markdown: specifier: 9.1.0 version: 9.1.0(@types/react@19.2.18)(react@19.2.8) @@ -2308,6 +2308,17 @@ packages: rollup: optional: true + '@shadcn/react@0.3.0': + resolution: {integrity: sha512-iKN0NuYe850VDHlxEfvppFrpa7CMV3zArTHusXlaSmeFeQhohGbIqclv6WwPTs/44I8EkXQpcuRt6sXEVKkWqQ==, tarball: https://registry.npmjs.org/@shadcn/react/-/react-0.3.0.tgz} + peerDependencies: + '@types/react': '>=19' + react: '>=19' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + '@shikijs/core@4.4.2': resolution: {integrity: sha512-StyzbAyxg2/tBGf78gwbBkGyeQ73lf8UiJArFaQhTQIDqQOCKPCQFanvrs4/Yv3Yfyc+ONInJM6K+FMIf+P+kA==, tarball: https://registry.npmjs.org/@shikijs/core/-/core-4.4.2.tgz} engines: {node: '>=20'} @@ -5234,13 +5245,6 @@ packages: react-fast-compare@2.0.4: resolution: {integrity: sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==, tarball: https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz} - react-infinite-scroll-component@7.2.1: - resolution: {integrity: sha512-yPuEf6VHBJXFt+YXDxjlnAuhorUwiA2cKURx/P6a7WW3k+TvwY/mbx7KP1rQ1hxGzDcwreczRyLIv6eSd1sFIw==, tarball: https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-7.2.1.tgz} - engines: {node: '>=20.0.0'} - peerDependencies: - react: '>=17' - react-dom: '>=17' - react-inspector@6.0.2: resolution: {integrity: sha512-x+b7LxhmHXjHoU/VrFAzw5iutsILRoYyDq97EDYdFpPLcvqtEzk4ZSZSQjnFPbr5T57tLXnHcqFYoN1pI6u8uQ==, tarball: https://registry.npmjs.org/react-inspector/-/react-inspector-6.0.2.tgz} peerDependencies: @@ -8264,6 +8268,11 @@ snapshots: estree-walker: 2.0.2 picomatch: 4.0.4 + '@shadcn/react@0.3.0(@types/react@19.2.18)(react@19.2.8)': + optionalDependencies: + '@types/react': 19.2.18 + react: 19.2.8 + '@shikijs/core@4.4.2': dependencies: '@shikijs/primitive': 4.4.2 @@ -11675,11 +11684,6 @@ snapshots: react-fast-compare@2.0.4: {} - react-infinite-scroll-component@7.2.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): - dependencies: - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - react-inspector@6.0.2(react@19.2.8): dependencies: react: 19.2.8 diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 581d9b5ba4b..b220e2ca065 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -1,6 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { FC } from "react"; -import { useRef } from "react"; import { hashKey } from "react-query"; import { Outlet, useNavigate } from "react-router"; import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; @@ -50,7 +49,6 @@ import type { AgentsPageOutletContext } from "./AgentsPageLayout"; // Layout wrapper: provides outlet context for the child route. // --------------------------------------------------------------------------- const AgentChatPageLayout: FC = () => { - const scrollContainerRef = useRef(null); return (
@@ -74,7 +72,6 @@ const AgentChatPageLayout: FC = () => { onToggleSidebarCollapsed: () => {}, onExpandSidebar: () => {}, onChatReady: () => {}, - scrollContainerRef, } satisfies AgentsPageOutletContext } /> @@ -3149,6 +3146,72 @@ export const SendResponseAfterChatSwitch: Story = { }, }; +/** + * The send flow renders the durable user row once the server accepts the + * prompt, before the assistant turn produces any output. + */ +export const SendRendersDurableUserRowBeforeAssistantOutput: Story = { + parameters: { + queries: buildQueries( + { + id: CHAT_ID, + ...baseChatFields, + title: "Durable send", + status: "waiting", + }, + { messages: [], queued_messages: [], has_more: false }, + { diffUrl: undefined }, + ), + }, + beforeEach: () => { + spyOn(API.experimental, "getUserSkills").mockResolvedValue([]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + let releaseSend: (() => void) | undefined; + const sendGate = new Promise((resolve) => { + releaseSend = resolve; + }); + const sendSpy = spyOn( + API.experimental, + "createChatMessage", + ).mockImplementation(async () => { + await sendGate; + return { + queued: false, + message: { + ...MockChatMessage, + id: 60, + chat_id: CHAT_ID, + role: "user", + content: [{ type: "text", text: "Durable prompt" }], + }, + }; + }); + + const editor = await canvas.findByTestId("chat-message-input"); + await userEvent.click(editor); + await userEvent.type(editor, "Durable prompt"); + await userEvent.keyboard("{Enter}"); + await waitFor(() => { + expect(sendSpy).toHaveBeenCalledTimes(1); + }); + + const timeline = within(await canvas.findByTestId("conversation-timeline")); + expect( + timeline.queryByTestId("chat-message-message:60"), + ).not.toBeInTheDocument(); + + releaseSend?.(); + expect( + await timeline.findByTestId("chat-message-message:60"), + ).toHaveTextContent("Durable prompt"); + // The turn is still waiting on its first chunk, so the durable row is in + // place before any assistant output exists. + expect(canvas.getByTestId("live-activity-slot")).toBeVisible(); + }, +}; + const mockErrorChat: TypesGen.Chat = { ...MockChat, id: CHAT_ID, diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index 676f315e937..46331761adc 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -28,7 +28,7 @@ import { restoreOptimisticRequestSnapshot, runPromoteQueuedMessage, settlePromotedQueueHead, - submitEditAndScroll, + submitEdit, useConversationEditingState, waitForPendingChatSettingsSyncs, } from "./AgentChatPage"; @@ -1246,63 +1246,40 @@ describe("useConversationEditingState", () => { }); }); -describe("submitEditAndScroll", () => { +describe("submitEdit", () => { const dummyArgs = { messageId: 42, req: { content: [{ type: "text" as const, text: "edited" }] }, }; - it("calls scrollToBottom after editMessage resolves", async () => { - const callOrder: string[] = []; - const editMessage = vi.fn(async () => { - callOrder.push("editMessage"); - }); - const scrollToBottom = vi.fn(() => { - callOrder.push("scrollToBottom"); - }); + it("awaits editMessage", async () => { + const editMessage = vi.fn().mockResolvedValue(undefined); - await submitEditAndScroll({ + await submitEdit({ editMessage, editArgs: dummyArgs, - scrollToBottom, onError: vi.fn(), }); - expect(callOrder).toEqual(["editMessage", "scrollToBottom"]); + expect(editMessage).toHaveBeenCalledWith(dummyArgs); }); - it("does not call scrollToBottom when editMessage throws", async () => { - const scrollToBottom = vi.fn(); + it("reports and rethrows an editMessage failure", async () => { const onError = vi.fn(); const editMessage = vi.fn().mockRejectedValue(new Error("boom")); await expect( - submitEditAndScroll({ + submitEdit({ editMessage, editArgs: dummyArgs, - scrollToBottom, onError, }), ).rejects.toThrow("boom"); - expect(scrollToBottom).not.toHaveBeenCalled(); expect(onError).toHaveBeenCalledWith( expect.objectContaining({ message: "boom" }), ); }); - - it("tolerates null scrollToBottom", async () => { - const editMessage = vi.fn().mockResolvedValue(undefined); - - await submitEditAndScroll({ - editMessage, - editArgs: dummyArgs, - scrollToBottom: null, - onError: vi.fn(), - }); - - expect(editMessage).toHaveBeenCalled(); - }); }); describe("sidebar tab persistence", () => { diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 4fd0b0d09a3..6f9f75bd15e 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -338,10 +338,9 @@ export const settlePromotedQueueHead = async ( ); }; -export async function submitEditAndScroll({ +export async function submitEdit({ editMessage, editArgs, - scrollToBottom, onError, }: { editMessage: (args: { @@ -354,7 +353,6 @@ export async function submitEditAndScroll({ optimisticMessage?: TypesGen.ChatMessage; req: TypesGen.EditChatMessageRequest; }; - scrollToBottom: (() => void) | null | undefined; onError: (error: unknown) => void; }): Promise { try { @@ -363,13 +361,6 @@ export async function submitEditAndScroll({ onError(error); throw error; } - // Scroll after the mutation resolves so the optimistic - // truncation and server reconciliation have already been - // applied to the DOM. Scrolling before this point causes - // the sticky user message to cycle through prior messages - // as the IntersectionObserver reacts to rapid layout - // shifts between the old and truncated content. - scrollToBottom?.(); } /** @internal Exported for testing. */ @@ -882,7 +873,6 @@ const AgentChatPage: FC = () => { isSidebarCollapsed, onToggleSidebarCollapsed, onChatReady, - scrollContainerRef, } = useOutletContext(); const queryClient = useQueryClient(); const { permissions, user: currentUser } = useAuthenticated(); @@ -891,7 +881,6 @@ const AgentChatPage: FC = () => { const [selectedModel, setSelectedModel] = useState(""); const [selectedReasoningEffort, setSelectedReasoningEffort] = useState(""); const isEditReasoningEffortDirtyRef = useRef(false); - const scrollToBottomRef = useRef<(() => void) | null>(null); const chatInputRef = useRef(null); const inputValueRef = useRef( agentId @@ -1662,7 +1651,6 @@ const AgentChatPage: FC = () => { clearStreamError(); store.clearStreamState(); store.setChatStatus("running"); - scrollToBottomRef.current?.(); try { await compact(); } catch (error) { @@ -1715,14 +1703,13 @@ const AgentChatPage: FC = () => { store.setChatStatus("running"); store.clearStreamState(); }); - await submitEditAndScroll({ + await submitEdit({ editMessage, editArgs: { messageId: editedMessageID, optimisticMessage, req: request, }, - scrollToBottom: scrollToBottomRef.current, onError: (error) => { restoreOptimisticRequestSnapshot(store, previousSnapshot); handleRequestError(error); @@ -1758,7 +1745,6 @@ const AgentChatPage: FC = () => { }; clearChatErrorReason(agentId); clearStreamError(); - scrollToBottomRef.current?.(); // An errored-chat send may promote the queue head that existed when the request began. const queuedMessagesBeforeSend = store.getSnapshot().queuedMessages; @@ -2035,12 +2021,10 @@ const AgentChatPage: FC = () => { isPinned={(chatRecord?.pin_order ?? 0) > 0} isChildChat={parentChatID !== undefined} urlTransform={urlTransform} - scrollContainerRef={scrollContainerRef} - scrollToBottomRef={scrollToBottomRef} hasMoreMessages={chatMessagesQuery.hasNextPage ?? false} isFetchingMoreMessages={chatMessagesQuery.isFetchingNextPage} + hasFetchMoreError={chatMessagesQuery.isFetchNextPageError} onFetchMoreMessages={chatMessagesQuery.fetchNextPage} - messageCount={storeMessageCount} desktopChatId={desktopEnabled ? agentId : undefined} mcpServers={mcpServers} selectedMCPServerIds={effectiveMCPServerIds} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index 63ff87aa831..9a5ffce7e36 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -1,6 +1,14 @@ import type { Decorator, Meta, StoryObj } from "@storybook/react-vite"; -import { type ComponentProps, type FC, useRef } from "react"; -import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { type ComponentProps, type FC, useRef, useState } from "react"; +import { + expect, + fireEvent, + fn, + spyOn, + userEvent, + waitFor, + within, +} from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { API } from "#/api/api"; import type * as TypesGen from "#/api/typesGenerated"; @@ -30,10 +38,7 @@ import { AgentChatPageView, } from "./AgentChatPageView"; import type { ChatDetailError } from "./components/ChatConversation/chatError"; -import { - createChatStore, - useChatSelector, -} from "./components/ChatConversation/chatStore"; +import { createChatStore } from "./components/ChatConversation/chatStore"; import type { ModelSelectorOption } from "./components/ChatElements"; import { lastActiveSidebarTabStorageKeyPrefix } from "./utils/sidebarTabStorage"; @@ -122,13 +127,7 @@ type StoryProps = Omit< const StoryAgentChatPageView: FC = ({ editing, ...overrides }) => { const defaultStoreRef = useRef(createChatStore()); - const defaultScrollContainerRef = useRef(null); - const defaultScrollToBottomRef = useRef<(() => void) | null>(null); const store = overrides.store ?? defaultStoreRef.current; - const messageCount = useChatSelector( - store, - (state) => state.messagesByID.size, - ); const props = { agentId: AGENT_ID, @@ -169,12 +168,10 @@ const StoryAgentChatPageView: FC = ({ editing, ...overrides }) => { handleArchiveAgentAction: fn(), handleUnarchiveAgentAction: fn(), handleArchiveAndDeleteWorkspaceAction: fn(), - scrollContainerRef: - overrides.scrollContainerRef ?? defaultScrollContainerRef, - scrollToBottomRef: overrides.scrollToBottomRef ?? defaultScrollToBottomRef, hasMoreMessages: false, isFetchingMoreMessages: false, - onFetchMoreMessages: fn(), + hasFetchMoreError: false, + onFetchMoreMessages: fn(async () => {}), mcpServers: [] as ComponentProps["mcpServers"], selectedMCPServerIds: [] as ComponentProps< typeof AgentChatPageView @@ -187,7 +184,6 @@ const StoryAgentChatPageView: FC = ({ editing, ...overrides }) => { modelCount: 1, ...overrides, store, - messageCount: overrides.messageCount ?? messageCount, editing: buildEditing(editing), }; return ; @@ -976,7 +972,7 @@ export const NotFoundSidebarCollapsed: Story = { }; // --------------------------------------------------------------------------- -// Infinite scroll stories +// Transcript scrolling stories // --------------------------------------------------------------------------- /** Generate a long conversation so the scroll container overflows. */ @@ -1009,57 +1005,17 @@ const scrollStoryDecorators: Decorator[] = [ ), ]; -const waitForScrollOverflow = async (scrollContainer: HTMLElement) => { - await waitFor(() => { - expect(scrollContainer.scrollHeight).toBeGreaterThan( - scrollContainer.clientHeight, - ); - }); -}; - -const scrollToHistoryTop = (scrollContainer: HTMLElement) => { - // In the library's documented column-reverse layout, older history is - // reached by driving the scroll offset toward the negative extreme. - scrollContainer.scrollTop = -scrollContainer.scrollHeight; - scrollContainer.dispatchEvent(new Event("scroll")); -}; - -const scrollToLatestMessages = (scrollContainer: HTMLElement) => { - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); -}; - -const waitForFetchCount = async ( - fetchSpy: ReturnType, - count: number, -) => { - await waitFor(() => { - expect(fetchSpy).toHaveBeenCalledTimes(count); - }); -}; +const getViewport = (canvas: ReturnType) => + canvas.getByRole("region", { name: "Messages" }); -const waitForVisibleText = async ( - canvas: ReturnType, - text: string, -) => { +const waitForScrollOverflow = async (viewport: HTMLElement) => { await waitFor(() => { - // The chat timeline renders hidden measurement copies for some message - // layouts, so pick any visible match instead of assuming the first node is - // the one a user sees. - const matches = canvas.queryAllByText(text); - const hasVisibleMatch = matches.some((element: Element) => { - const style = window.getComputedStyle(element); - return ( - style.display !== "none" && - style.visibility !== "hidden" && - element.getClientRects().length > 0 - ); - }); - expect(hasVisibleMatch).toBe(true); + expect(viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight); }); }; -const waitForIntersectionObserverTick = async () => { +/** The scroller commits its state on an animation frame. */ +const settleScroller = async () => { await new Promise((resolve) => { requestAnimationFrame(() => { requestAnimationFrame(() => { @@ -1069,6 +1025,11 @@ const waitForIntersectionObserverTick = async () => { }); }; +const scrollTo = (viewport: HTMLElement, scrollTop: number) => { + viewport.scrollTop = scrollTop; + fireEvent.scroll(viewport); +}; + /** Helper that extracts the current messages array from a store. */ const getStoreMessages = ( store: ReturnType, @@ -1089,8 +1050,7 @@ const prependOlderMessages = ( count: number, ) => { const existing = getStoreMessages(store); - const oldestMessage = existing[0]; - const oldestID = oldestMessage?.id ?? 1; + const oldestID = existing[0]?.id ?? 1; const olderMessages = Array.from({ length: count }, (_, index) => { const id = oldestID - count + index; const role: TypesGen.ChatMessageRole = id % 2 === 0 ? "assistant" : "user"; @@ -1103,417 +1063,309 @@ const prependOlderMessages = ( store.replaceMessages([...olderMessages, ...existing]); }; -const resetScrollStoryStore = ( - store: ReturnType, - // Default to a transcript long enough to overflow the 600px decorator so the - // inverse-scroll stories exercise the fetch threshold immediately. - count = 80, -) => { - store.replaceMessages(buildLongConversation(count)); - store.setChatStatus("waiting"); -}; - -const inverseScrollStore = buildStoreWithMessages(buildLongConversation(80)); -const inverseScrollFetchSpy = fn(() => { - prependOlderMessages(inverseScrollStore, 10); -}); +const singleRenderStore = buildStoreWithMessages([ + buildMessage(1, "user", "Only rendered once"), + buildMessage(2, "assistant", "Understood."), +]); -/** - * Scrolling upward in the library's inverse mode loads older messages into the - * top of the transcript. - */ -export const InverseScrollLoadsOlderMessages: Story = { +/** Every prompt renders exactly one row: no sticky copy trails the original. */ +export const UserPromptsRenderOnce: Story = { parameters: { pixel: { exclude: true } }, decorators: scrollStoryDecorators, - render: () => ( - - ), + render: () => , play: async ({ canvasElement }) => { - resetScrollStoryStore(inverseScrollStore); - inverseScrollFetchSpy.mockClear(); const canvas = within(canvasElement); - const scrollContainer = canvas.getByTestId("scroll-container"); - - await waitForScrollOverflow(scrollContainer); - expect(inverseScrollFetchSpy).not.toHaveBeenCalled(); - - scrollToHistoryTop(scrollContainer); - - await waitForFetchCount(inverseScrollFetchSpy, 1); - await waitForVisibleText(canvas, "Older question 9."); + expect(canvas.getAllByText("Only rendered once")).toHaveLength(1); + expect(canvas.getAllByTestId("chat-message-message:1")).toHaveLength(1); }, }; -const multiPageScrollStore = buildStoreWithMessages(buildLongConversation(80)); -const multiPageFetchSpy = fn(() => { - prependOlderMessages(multiPageScrollStore, 10); +const startEdgeStore = buildStoreWithMessages( + buildLongConversation(40).slice(1), +); + +const streamCompletionStore = buildStoreWithMessages(buildLongConversation(40)); + +let releaseStartEdgeFetch: (() => void) | undefined; +let startEdgeFetchGate: Promise; +const startEdgeFetchSpy = fn(async () => { + await startEdgeFetchGate; + prependOlderMessages(startEdgeStore, 10); }); -/** - * The library resets its one-shot load guard when dataLength changes, so a - * second upward reveal can load another page. - */ -export const InverseScrollCanLoadMultiplePages: Story = { +/** Reaching the start of the loaded history requests the previous page once. */ +export const ReachingTheStartLoadsEarlierMessages: Story = { parameters: { pixel: { exclude: true } }, decorators: scrollStoryDecorators, + beforeEach: () => { + startEdgeFetchGate = new Promise((resolve) => { + releaseStartEdgeFetch = resolve; + }); + }, render: () => ( ), play: async ({ canvasElement }) => { - resetScrollStoryStore(multiPageScrollStore); - multiPageFetchSpy.mockClear(); + startEdgeStore.replaceMessages(buildLongConversation(40).slice(1)); + startEdgeStore.setChatStatus("waiting"); + startEdgeFetchSpy.mockClear(); const canvas = within(canvasElement); - const scrollContainer = canvas.getByTestId("scroll-container"); - - await waitForScrollOverflow(scrollContainer); + const viewport = getViewport(canvas); - scrollToHistoryTop(scrollContainer); - await waitForFetchCount(multiPageFetchSpy, 1); - await waitForVisibleText(canvas, "Older question 9."); + await waitForScrollOverflow(viewport); + await settleScroller(); + expect(startEdgeFetchSpy).not.toHaveBeenCalled(); - scrollToLatestMessages(scrollContainer); + const marker = canvas.getByTestId("chat-message-message:2"); + fireEvent.wheel(viewport, { deltaY: -100 }); + scrollTo(viewport, 0); await waitFor(() => { - expect(scrollContainer.scrollTop).toBe(0); + expect(startEdgeFetchSpy).toHaveBeenCalledTimes(1); }); - await waitForIntersectionObserverTick(); - scrollToHistoryTop(scrollContainer); + const markerOffset = + marker.getBoundingClientRect().top - viewport.getBoundingClientRect().top; + releaseStartEdgeFetch?.(); - await waitForFetchCount(multiPageFetchSpy, 2); - await waitForVisibleText(canvas, "Older answer 10."); + await waitFor(() => { + expect(canvas.getByTestId("chat-message-message:1")).toBeInTheDocument(); + }); + await settleScroller(); + expect( + Math.abs( + marker.getBoundingClientRect().top - + viewport.getBoundingClientRect().top - + markerOffset, + ), + ).toBeLessThan(4); }, }; -const scrollToBottomButtonStoryStore = buildStoreWithMessages( - buildLongConversation(80), -); - /** - * The replacement container should keep the floating affordance that returns a - * user from older history to the newest messages. + * Stream completion must not yank the transcript to the oldest message. The + * live assistant row is replaced by its durable row (a remount, not an + * in-place reconciliation), and the only anchored row is the active turn's + * prompt, so the scroller has no unhandled historical anchor to jump to. */ -export const ScrollToBottomButtonWorksWithInverseScroll: Story = { +export const StreamCompletionKeepsViewportPosition: Story = { parameters: { pixel: { exclude: true } }, decorators: scrollStoryDecorators, - render: () => ( - - ), + render: () => , play: async ({ canvasElement }) => { - resetScrollStoryStore(scrollToBottomButtonStoryStore); + streamCompletionStore.replaceMessages(buildLongConversation(40)); + streamCompletionStore.setChatStatus("waiting"); const canvas = within(canvasElement); - const scrollContainer = canvas.getByTestId("scroll-container"); - - await waitForScrollOverflow(scrollContainer); - expect( - canvas.queryByRole("button", { name: /scroll to bottom/i }), - ).toBeNull(); - - scrollToHistoryTop(scrollContainer); - + const viewport = getViewport(canvas); + + await waitForScrollOverflow(viewport); + await settleScroller(); + + // Sit at the live edge; the oldest message is scrolled above the viewport. + scrollTo(viewport, viewport.scrollHeight); + await settleScroller(); + const oldest = canvas.getByTestId("chat-message-message:1"); + const oldestAboveViewport = () => + oldest.getBoundingClientRect().bottom <= + viewport.getBoundingClientRect().top; + expect(oldestAboveViewport()).toBe(true); + + // A new turn begins: the prompt is appended and the assistant starts + // streaming into the live row. + streamCompletionStore.batch(() => { + streamCompletionStore.upsertDurableMessages([ + buildMessage(41, "user", "Final question."), + ]); + streamCompletionStore.setChatStatus("running"); + streamCompletionStore.applyMessageParts([ + { type: "text", text: "Streaming the final answer." }, + ]); + }); + // The live assistant row mounts under its ephemeral turn-local key. await waitFor(() => { expect( - canvas.getByRole("button", { name: /scroll to bottom/i }), - ).toBeVisible(); + canvas.getByTestId("chat-message-message:41:assistant:0"), + ).toBeInTheDocument(); + }); + await settleScroller(); + + // The durable assistant row replaces the live row, then the turn ends. + streamCompletionStore.batch(() => { + streamCompletionStore.upsertDurableMessages([ + buildMessage(42, "assistant", "Streaming the final answer."), + ]); + streamCompletionStore.clearStreamState(); + streamCompletionStore.setChatStatus("waiting"); }); - - await userEvent.click( - canvas.getByRole("button", { name: /scroll to bottom/i }), - ); - await waitFor(() => { - expect(scrollContainer.scrollTop).toBe(0); - expect( - canvas.queryByRole("button", { name: /scroll to bottom/i }), - ).toBeNull(); + expect(canvas.getByTestId("chat-message-message:42")).toBeInTheDocument(); }); + await settleScroller(); + + // The viewport never jumped back to the oldest message. + expect(oldestAboveViewport()).toBe(true); }, }; -const scrollToBottomStoryStore = buildStoreWithMessages( - buildLongConversation(80), -); -// Story objects live at module scope, so use a ref-shaped object instead of a -// hook to capture the imperative callback across the render and play phases. -const scrollToBottomStoryRef: { current: (() => void) | null } = { - current: null, +const underflowFetchSpy = fn(); + +const UnderflowPaginationStory: FC = () => { + const store = useRef( + buildStoreWithMessages([ + buildMessage(9, "assistant", "The newest loaded message. ".repeat(6)), + ]), + ).current; + const [loadedPages, setLoadedPages] = useState(0); + const [isFetching, setIsFetching] = useState(false); + + const completePage = () => { + const nextPage = loadedPages + 1; + store.replaceMessages([ + buildMessage( + 9 - nextPage, + nextPage % 2 === 1 ? "user" : "assistant", + `Loaded underflow page ${nextPage}. `.repeat(20), + ), + ...getStoreMessages(store), + ]); + setLoadedPages(nextPage); + setIsFetching(false); + }; + + return ( +
+ { + underflowFetchSpy(); + setIsFetching(true); + }} + /> + +
+ ); }; /** - * Page-level send and edit flows still rely on an imperative scroll-to-bottom - * hook, so the replacement container must keep that contract working. + * A transcript that does not fill the viewport keeps asking for history, one + * page at a time, until it overflows or the history ends. */ -export const ScrollToBottomRefStillWorks: Story = { +export const ShortTranscriptLoadsUntilHistoryIsExhausted: Story = { parameters: { pixel: { exclude: true } }, - decorators: scrollStoryDecorators, - render: () => ( - - ), + beforeEach: () => { + underflowFetchSpy.mockClear(); + }, + render: () => , play: async ({ canvasElement }) => { - resetScrollStoryStore(scrollToBottomStoryStore); const canvas = within(canvasElement); - const scrollContainer = canvas.getByTestId("scroll-container"); - - await waitForScrollOverflow(scrollContainer); - scrollToHistoryTop(scrollContainer); + const completePage = canvas.getByRole("button", { + name: "Complete history page", + }); await waitFor(() => { - expect(scrollContainer.scrollTop).toBeLessThan(0); - expect(typeof scrollToBottomStoryRef.current).toBe("function"); + expect(underflowFetchSpy).toHaveBeenCalledTimes(1); + expect( + canvas.getByRole("status", { name: "Loading earlier messages" }), + ).toBeVisible(); }); + scrollTo(getViewport(canvas), 0); + expect(underflowFetchSpy).toHaveBeenCalledTimes(1); - const scrollToBottom = scrollToBottomStoryRef.current; - if (!scrollToBottom) { - throw new Error("Expected scrollToBottomRef to be available."); - } - scrollToBottom(); - + await userEvent.click(completePage); await waitFor(() => { - expect(scrollContainer.scrollTop).toBe(0); + expect(canvas.getByText(/Loaded underflow page 1\./)).toBeVisible(); + expect(underflowFetchSpy).toHaveBeenCalledTimes(2); }); - }, -}; - -const messageOrderStore = buildStoreWithMessages([ - buildMessage(1, "user", "Oldest message"), - buildMessage(2, "assistant", "Older response"), - buildMessage(3, "user", "Newer question"), - buildMessage(4, "assistant", "Newest reply"), -]); - -/** - * The reversed container layout must not invert the transcript's visible order. - */ -export const MessageOrderIsStillCorrect: Story = { - parameters: { pixel: { exclude: true } }, - decorators: scrollStoryDecorators, - render: () => , - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const oldest = canvas.getByText("Oldest message"); - const newer = canvas.getByText("Newest reply"); + await userEvent.click(completePage); await waitFor(() => { - expect(oldest.getBoundingClientRect().top).toBeLessThan( - newer.getBoundingClientRect().top, - ); + expect(canvas.getByText(/Loaded underflow page 2\./)).toBeVisible(); + expect( + canvas.queryByRole("status", { name: "Loading earlier messages" }), + ).not.toBeInTheDocument(); }); + expect(underflowFetchSpy).toHaveBeenCalledTimes(2); }, }; -const stickyPinningStore = buildStoreWithMessages(buildLongConversation(40)); +const retryFetchSpy = fn(); -/** - * Regression guard for the StickyUserMessage push-up logic. - * - * `react-infinite-scroll-component` renders two wrapper divs between the - * scroll container and the message tree. The library applies `overflow: - * auto` to its inner wrapper, which used to make `position: sticky` on a - * user message resolve against that wrapper instead of the actual scroller. - * The fix forces both wrappers to `display: contents` so the sticky - * container's nearest scrolling ancestor is once again the - * `.overflow-y-auto` element. - * - * This story scrolls past the most recent user message and asserts the - * message is pinned within a few pixels of the scroll container's top. - */ -export const StickyUserMessagePinsOnScroll: Story = { - parameters: { pixel: { exclude: true } }, - decorators: scrollStoryDecorators, - render: () => , - play: async ({ canvasElement }) => { - resetScrollStoryStore(stickyPinningStore, 40); - const canvas = within(canvasElement); - const scrollContainer = canvas.getByTestId("scroll-container"); - - await waitForScrollOverflow(scrollContainer); - - // Each sticky user message is the element immediately following its - // `data-user-sentinel` marker. The push-up logic depends on the - // sticky container resolving against the real scroll container, - // which is the regression this story guards against. - const sentinels = scrollContainer.querySelectorAll("[data-user-sentinel]"); - expect(sentinels.length).toBeGreaterThan(0); - for (const sentinel of Array.from(sentinels)) { - expect(sentinel.closest("[data-testid='scroll-container']")).toBe( - scrollContainer, - ); - const container = sentinel.nextElementSibling; - expect(container).not.toBeNull(); - expect(window.getComputedStyle(container as Element).position).toBe( - "sticky", - ); - } +const RetryPaginationStory: FC = () => { + const store = useRef( + buildStoreWithMessages(buildLongConversation(40)), + ).current; + const [hasError, setHasError] = useState(true); + const [isFetching, setIsFetching] = useState(false); - // At the default `scrollTop = 0`, the inverse layout shows the - // newest messages at the bottom of the viewport. Older user - // messages whose sentinels have already scrolled above the - // scroller's top edge should be pinned by `position: sticky`. Pick - // a sentinel that is comfortably above the top edge so a tiny - // scroll offset cannot flip it on or off the boundary. - const scrollerRect = scrollContainer.getBoundingClientRect(); - // Walk the sentinels in reverse DOM order so we land on the - // most recent user message whose sentinel has scrolled above - // the scroll container's top edge. That is the message the - // push-up logic actively pins at the top; earlier pinned - // messages will have been pushed out of view by it. - const pinnedSentinel = Array.from(sentinels) - .reverse() - .find( - (sentinel) => - sentinel.getBoundingClientRect().top < scrollerRect.top - 4, - ) as HTMLElement | undefined; - expect(pinnedSentinel).toBeDefined(); - if (!pinnedSentinel) { - return; - } - const pinnedContainer = pinnedSentinel.nextElementSibling as HTMLElement; - - // `position: sticky` should pin the user message container near - // the scroll container's top edge while the assistant response - // below it is on screen. Before the fix, the sticky container - // resolved against the InfiniteScroll wrapper rather than the - // real scroll container, so it scrolled out with its sentinel - // and ended up far above the viewport. - const pinnedRect = pinnedContainer.getBoundingClientRect(); - expect(window.getComputedStyle(pinnedContainer).position).toBe("sticky"); - expect(pinnedRect.top - scrollerRect.top).toBeGreaterThanOrEqual(-1); - expect(pinnedRect.top - scrollerRect.top).toBeLessThan(40); - }, -}; + const completePage = () => { + prependOlderMessages(store, 4); + setIsFetching(false); + }; -// Tall user messages interleaved with verbose assistant replies. The height -// gives the sticky clip room to shrink as the transcript grows, and the -// volume overflows the 600px scroll decorator. -const buildTallStickyConversation = (count: number): TypesGen.ChatMessage[] => { - const messages: TypesGen.ChatMessage[] = []; - for (let i = 1; i <= count; i++) { - const role: TypesGen.ChatMessageRole = i % 2 === 1 ? "user" : "assistant"; - const text = - role === "user" - ? Array.from( - { length: 6 }, - (_, line) => - `Question ${Math.ceil(i / 2)} paragraph ${line + 1}: keep this user message tall enough to clip.`, - ).join("\n\n") - : `Detailed answer ${Math.floor(i / 2)}. `.repeat(12); - messages.push(buildMessage(i, role, text)); - } - return messages; + return ( +
+ { + retryFetchSpy(); + setHasError(false); + setIsFetching(true); + }} + /> + +
+ ); }; -const stickyClipUpdateStore = buildStoreWithMessages( - buildTallStickyConversation(30), -); - /** - * Regression guard: the sticky truncation must stay in sync as the - * transcript grows while the user is pinned to the bottom. - * - * The clip height is recomputed by a scroll handler, a window-resize - * handler, and a ResizeObserver on the transcript. The observer used to - * watch `scroller.firstElementChild`, which is the aria-hidden flex spacer - * that pins content to the bottom. That spacer collapses to 0px once the - * transcript overflows and then stops emitting resize callbacks, so several - * messages arriving while pinned left the clip stale until the next manual - * scroll and the bubble overflowed. The fix observes the real content - * wrapper tagged with `data-chat-scroll-content`. - * - * This story grows the transcript while pinned and asserts the clip tracks - * the new geometry without any scroll event. + * A failed history request stops the automatic loading and offers a retry that + * keyboard users can reach. */ -export const StickyUserMessageClipUpdatesWhilePinned: Story = { +export const FailedHistoryPageOffersKeyboardRetry: Story = { parameters: { pixel: { exclude: true } }, - decorators: scrollStoryDecorators, - render: () => , + beforeEach: () => { + retryFetchSpy.mockClear(); + }, + render: () => , play: async ({ canvasElement }) => { - stickyClipUpdateStore.replaceMessages(buildTallStickyConversation(30)); - stickyClipUpdateStore.setChatStatus("waiting"); const canvas = within(canvasElement); - const scrollContainer = canvas.getByTestId("scroll-container"); + const viewport = getViewport(canvas); + const retry = canvas.getByRole("button", { + name: "Retry loading earlier messages", + }); - await waitForScrollOverflow(scrollContainer); + scrollTo(viewport, 0); + await settleScroller(); + expect(retry).toBeVisible(); + expect(retryFetchSpy).not.toHaveBeenCalled(); - // The observed transcript node must be the real content wrapper, not - // the aria-hidden flex spacer that collapses to 0px on overflow. - const contentMarker = scrollContainer.querySelector( - "[data-chat-scroll-content]", - ); - expect(contentMarker).not.toBeNull(); - const spacer = scrollContainer.firstElementChild; - expect(spacer).not.toBe(contentMarker); - expect(spacer?.getAttribute("aria-hidden")).toBe("true"); - - // Every sticky sentinel lives inside the observed content node, so a - // resize of that node reflects transcript growth. - const sentinels = scrollContainer.querySelectorAll("[data-user-sentinel]"); - expect(sentinels.length).toBeGreaterThan(0); - for (const sentinel of Array.from(sentinels)) { - expect(contentMarker?.contains(sentinel)).toBe(true); - } + retry.focus(); + expect(retry).toHaveFocus(); + await userEvent.keyboard("{Enter}"); - // At scrollTop 0 the newest message is pinned to the bottom. The most - // recent user message whose sentinel sits just above the top edge is - // the bubble pinned at the top and actively clipped. - const scrollerRect = scrollContainer.getBoundingClientRect(); - const pinnedSentinel = Array.from(sentinels) - .reverse() - .find( - (sentinel) => - sentinel.getBoundingClientRect().top < scrollerRect.top - 4, - ) as HTMLElement | undefined; - expect(pinnedSentinel).toBeDefined(); - if (!pinnedSentinel) { - return; - } - const pinnedContainer = pinnedSentinel.nextElementSibling as HTMLElement; - - const MIN_CLIP_HEIGHT = 72; - const readClip = () => - Number.parseFloat(pinnedContainer.style.getPropertyValue("--clip-h")) || - 0; - const measureScrolledPast = () => - scrollContainer.getBoundingClientRect().top - - pinnedSentinel.getBoundingClientRect().top; - const expectedClip = () => - Math.max( - pinnedContainer.offsetHeight - measureScrolledPast(), - MIN_CLIP_HEIGHT, - ); - - const scrolledPastBefore = measureScrolledPast(); - expect(scrolledPastBefore).toBeGreaterThan(4); - // Stay in the clipping regime (not a near-full-height bubble). - expect(pinnedContainer.offsetHeight).toBeLessThanOrEqual( - scrollContainer.clientHeight * 0.75, - ); - expect(scrollContainer.scrollTop).toBe(0); - - // Grow the transcript at the newest end. While pinned, scrollTop stays - // at 0 so no scroll event fires; only the content ResizeObserver can - // drive the recompute. - stickyClipUpdateStore.replaceMessages([ - ...getStoreMessages(stickyClipUpdateStore), - buildMessage(31, "assistant", "Freshly streamed reply. ".repeat(80)), - buildMessage(32, "assistant", "More freshly streamed reply. ".repeat(80)), - ]); + await waitFor(() => { + expect(retryFetchSpy).toHaveBeenCalledTimes(1); + expect( + canvas.getByRole("status", { name: "Loading earlier messages" }), + ).toBeVisible(); + }); - // The pinned bubble is now further above the top edge. Its clip must - // follow the new geometry. Before the fix it stayed stale (matching - // the pre-growth scrolledPast) until a manual scroll. + await userEvent.click( + canvas.getByRole("button", { name: "Complete retry page" }), + ); await waitFor(() => { - expect(scrollContainer.scrollTop).toBe(0); - expect(measureScrolledPast()).toBeGreaterThan(scrolledPastBefore + 10); - expect(Math.abs(readClip() - expectedClip())).toBeLessThanOrEqual(2); + expect(canvas.getByText("Older question 3.")).toBeInTheDocument(); }); }, }; diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 1ae728bbefe..1052aafcef6 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -5,7 +5,6 @@ import { type ReactNode, type RefObject, useEffect, - useRef, useState, } from "react"; import { useQueryClient } from "react-query"; @@ -43,7 +42,6 @@ import { DesktopPanelContext } from "./components/ChatElements/tools/DesktopPane import type { SkillMetadata } from "./components/ChatMessageInput/SkillsTriggerMenu"; import type { PendingAttachment } from "./components/ChatPageContent"; import { ChatPageInput, ChatPageTimeline } from "./components/ChatPageContent"; -import { ChatScrollContainer } from "./components/ChatScrollContainer"; import { ChatSharingPopoverContent } from "./components/ChatSharingPopover"; import { ChatSummaryPanel } from "./components/ChatSummaryPanel"; import { getEffectiveTabId } from "./components/ChatsSidebar/tabs/getEffectiveTabId"; @@ -205,15 +203,11 @@ interface AgentChatPageViewProps { isChildChat?: boolean; isArchivingThisChat?: boolean; - // Scroll container ref. - scrollContainerRef: RefObject; - scrollToBottomRef?: RefObject<(() => void) | null>; - // Pagination for loading older messages. hasMoreMessages: boolean; isFetchingMoreMessages: boolean; - onFetchMoreMessages: () => void; - messageCount: number; + hasFetchMoreError: boolean; + onFetchMoreMessages: () => Promise; urlTransform?: UrlTransform; @@ -379,12 +373,10 @@ export const AgentChatPageView: FC = ({ isPinned, isChildChat, isArchivingThisChat, - scrollContainerRef, - scrollToBottomRef, hasMoreMessages, isFetchingMoreMessages, + hasFetchMoreError, onFetchMoreMessages, - messageCount, urlTransform, mcpServers, selectedMCPServerIds, @@ -420,9 +412,6 @@ export const AgentChatPageView: FC = ({ null, ); const visualExpanded = dragVisualExpanded ?? isRightPanelExpanded; - const internalScrollToBottomRef = useRef<(() => void) | null>(null); - const effectiveScrollToBottomRef = - scrollToBottomRef ?? internalScrollToBottomRef; const [sidebarTabId, setSidebarTabIdState] = useState(() => getPersistedSidebarTabId(agentId), @@ -914,38 +903,27 @@ export const AgentChatPageView: FC = ({ }} />
- -
- -
-
+ onEditUserMessage={ + isOtherUserReadOnly ? undefined : editing.handleEditUserMessage + } + editingMessageId={editing.editingMessageId} + urlTransform={urlTransform} + mcpServers={mcpServers} + onImplementPlan={ + isOtherUserReadOnly ? undefined : onImplementPlan + } + onSendAskUserQuestionResponse={ + isOtherUserReadOnly ? undefined : canSendAskUserQuestionResponse + } + />
{ }; }, [searchParams]); - // Shared ref for the chat scroll container. Passed through the - // outlet context so AgentChatPage attaches it to the DOM element - // instead of creating its own. - const scrollContainerRef = useRef(null); - // Listen for parent frame commands (e.g. theme changes). useEffect(() => { const parentWindow = window.parent; @@ -236,7 +231,6 @@ const AgentEmbedPage: FC = () => { onToggleSidebarCollapsed, onExpandSidebar: () => {}, onChatReady, - scrollContainerRef, }; // When signed out and not already bootstrapping, listen for the diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index 41f79d6b280..a76dbebb88d 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -1,4 +1,4 @@ -import { type FC, type RefObject, useEffect, useRef, useState } from "react"; +import { type FC, useEffect, useRef, useState } from "react"; import { useInfiniteQuery, useMutation, @@ -119,8 +119,6 @@ export interface AgentsPageOutletContext { onToggleSidebarCollapsed: () => void; onExpandSidebar: () => void; onChatReady: () => void; - /** Ref attached to the chat scroll container by AgentChatPage. */ - scrollContainerRef: RefObject; } const FILTER_MEMBERSHIP_EVENT_KINDS = new Set([ @@ -744,8 +742,6 @@ const AgentsPageLayout: FC = () => { ]), ); - const scrollContainerRef = useRef(null); - // State for the shared rename-chat dialog. Lifted here so both the // sidebar menu and the chat top bar open the same dialog instance. const [chatPendingRename, setChatPendingRename] = @@ -768,7 +764,6 @@ const AgentsPageLayout: FC = () => { onToggleSidebarCollapsed: handleToggleSidebarCollapsed, onExpandSidebar: () => setIsSidebarCollapsed(false), onChatReady: () => {}, - scrollContainerRef, }; return ( diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index f505cb67dd8..0cff9303b94 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Decorator, Meta, StoryObj } from "@storybook/react-vite"; import { expect, fireEvent, @@ -12,10 +12,33 @@ import { import type * as TypesGen from "#/api/typesGenerated"; import { getChatFileURL } from "../../utils/chatAttachments"; import { encodeInlineTextAttachment } from "../../utils/fetchTextAttachment"; +import { ChatMessageScroller } from "../ChatMessageScroller"; import { ConversationTimeline } from "./ConversationTimeline"; import { parseMessagesWithMergedTools } from "./messageParsing"; import type { ParsedMessageEntry } from "./types"; +// The timeline renders scroller items, so every story needs the scroller +// around it. Stories that exercise scrolling set `messageScrollerHeight` to +// bound the viewport; the rest render at their natural height. +const withMessageScroller: Decorator = (Story, { parameters }) => { + const height = + typeof parameters.messageScrollerHeight === "number" + ? parameters.messageScrollerHeight + : undefined; + return ( +
+ {}} + > + + +
+ ); +}; + // 1×1 solid coral (#FF6B6B) PNG encoded as base64. const TEST_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4n539HwAHFwLVF8kc1wAAAABJRU5ErkJggg=="; @@ -366,8 +389,11 @@ const expectNoCopyMessageButtonForElement = (element: HTMLElement) => { expect(messageRow).not.toBeNull(); const messageWrapper = messageRow?.parentElement; expect(messageWrapper).not.toBeNull(); + if (!messageWrapper) { + return; + } expect( - within(messageWrapper as HTMLElement).queryByRole("button", { + within(messageWrapper).queryByRole("button", { name: "Copy message", }), ).not.toBeInTheDocument(); @@ -405,6 +431,7 @@ const defaultArgs: Omit< const meta: Meta = { title: "pages/AgentsPage/ChatConversation/ConversationTimeline", component: ConversationTimeline, + decorators: [withMessageScroller], beforeEach: () => { attachmentFetchCounts = new Map(); mockAttachmentFetch(); @@ -1491,7 +1518,7 @@ export const UserMessageWithMultipleInlineFileRefs: Story = { }, }; -export const MetadataOnlyUserMessageDoesNotLeaveStickyGap: Story = { +export const MetadataOnlyUserMessageRendersNoRow: Story = { args: { ...defaultArgs, parsedMessages: buildMessages([ @@ -1524,23 +1551,15 @@ export const MetadataOnlyUserMessageDoesNotLeaveStickyGap: Story = { const canvas = within(canvasElement); expect(canvas.getByText("Before hidden metadata.")).toBeVisible(); expect(canvas.getByText("After hidden metadata.")).toBeVisible(); - expect(canvasElement.querySelectorAll("[data-user-sentinel]")).toHaveLength( - 0, - ); + expect(canvas.queryByTestId("chat-message-message:2")).toBeNull(); }, }; /** - * Verifies the structural requirements for sticky user messages - * in the flat (section-less) message list: - * - Each user message renders a data-user-sentinel marker so - * the push-up logic can find the next user message via DOM - * traversal. - * - The user message container gets position:sticky. - * - Sentinels appear in the correct order (matching user - * message order). + * Each user prompt is a single transcript row. The scroller anchors on those + * rows, so nothing renders a second, pinned copy of the prompt. */ -export const StickyUserMessageStructure: Story = { +export const UserMessagesRenderAsSingleRows: Story = { args: { ...defaultArgs, parsedMessages: buildMessages([ @@ -1571,57 +1590,22 @@ export const StickyUserMessageStructure: Story = { ]), }, play: async ({ canvasElement }) => { - // Each user message should produce a data-user-sentinel - // marker that the push-up scroll logic relies on. - const sentinels = canvasElement.querySelectorAll("[data-user-sentinel]"); - expect(sentinels.length).toBe(2); - - // Each sentinel should be immediately followed by a sticky - // container (the user message itself). - for (const sentinel of sentinels) { - const container = sentinel.nextElementSibling; - expect(container).not.toBeNull(); - const style = window.getComputedStyle(container!); - expect(style.position).toBe("sticky"); - } - - // Sentinels must appear in DOM order matching the message - // order so nextElementSibling traversal finds the correct - // next user message. - const allElements = Array.from( - canvasElement.querySelectorAll("[data-user-sentinel], [class*='sticky']"), - ); - const sentinelIndices = Array.from(sentinels).map((s) => - allElements.indexOf(s), - ); - // Sentinels should be in ascending DOM order. - expect(sentinelIndices[0]).toBeLessThan(sentinelIndices[1]); - - // Both user messages should be visible. const canvas = within(canvasElement); - expect(canvas.getByText("First prompt")).toBeVisible(); - expect(canvas.getByText("Second prompt")).toBeVisible(); + expect(canvas.getAllByText("First prompt")).toHaveLength(1); + expect(canvas.getAllByText("Second prompt")).toHaveLength(1); + expect(canvas.getAllByTestId("chat-message-message:1")).toHaveLength(1); + expect(canvas.getAllByTestId("chat-message-message:3")).toHaveLength(1); }, }; /** - * Each user message exposes left/right chevron buttons in its - * action row so users can jump the transcript between user prompts. - * Disabled at the ends of the conversation; otherwise the click - * smooth-scrolls the bubble's `data-user-sentinel` to the top of - * the scroller. + * Each user message exposes left/right chevron buttons in its action row so + * users can jump the transcript between user prompts. They are disabled at the + * ends of the conversation; otherwise the click scrolls the neighbouring + * prompt to the top of the viewport. */ export const UserMessageJumpArrows: Story = { - decorators: [ - (Story) => ( -
- -
- ), - ], + parameters: { messageScrollerHeight: 320 }, args: { ...defaultArgs, parsedMessages: buildMessages([ @@ -1670,6 +1654,7 @@ export const UserMessageJumpArrows: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); + const viewport = canvas.getByRole("region", { name: "Messages" }); // Reveal the hover-only action rows so we can interact with // the chevron buttons without dispatching real hover events. @@ -1703,22 +1688,21 @@ export const UserMessageJumpArrows: Story = { expect(prevButtons[2]).toBeEnabled(); expect(nextButtons[2]).toBeDisabled(); - // Clicking Next on the first prompt scrolls the second user - // prompt's sentinel into view via its registered ref. - const sentinels = Array.from( - canvasElement.querySelectorAll("[data-user-sentinel]"), - ); - expect(sentinels).toHaveLength(3); - const targetSpy = spyOn(sentinels[1], "scrollIntoView"); + // Clicking Next on the first prompt brings the second prompt to the top + // of the viewport through the scroller. + const secondPrompt = canvas.getByText("Second prompt"); + const offsetInViewport = () => + secondPrompt.getBoundingClientRect().top - + viewport.getBoundingClientRect().top; + // The transcript opens at its end, so the second prompt starts above the + // visible area. + expect(offsetInViewport()).toBeLessThan(0); await userEvent.click(nextButtons[0]); await waitFor(() => { - expect(targetSpy).toHaveBeenCalledTimes(1); - }); - expect(targetSpy).toHaveBeenCalledWith({ - behavior: "smooth", - block: "start", + expect(offsetInViewport()).toBeGreaterThan(0); + expect(offsetInViewport()).toBeLessThan(160); }); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 1fa291b8403..785a01ae1d4 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -1,17 +1,14 @@ +import { + MessageScroller, + useMessageScroller, +} from "@shadcn/react/message-scroller"; import { ChevronLeftIcon, ChevronRightIcon, InfoIcon, PencilIcon, } from "lucide-react"; -import { - type FC, - memo, - type ReactNode, - useLayoutEffect, - useRef, - useState, -} from "react"; +import { type FC, memo, type ReactNode, useState } from "react"; import type { UrlTransform } from "streamdown"; import type * as TypesGen from "#/api/typesGenerated"; @@ -101,8 +98,7 @@ const LifecycleHookNotice: FC<{ const ChatMessageItem = memo<{ renderKey: string; - // Durable rows render from a message. The live assistant row renders from - // liveStatus and the stream buffers instead. + // Durable messages and live assistant output share one rendering path. message?: TypesGen.ChatMessage; parsed?: ParsedMessageContent; liveStatus?: LiveStatusModel; @@ -127,10 +123,6 @@ const ChatMessageItem = memo<{ // The last transcript message has nothing after it, so the spacer // would render as a dangling blank at the end of the chat. isLastMessage?: boolean; - // When true, renders a gradient overlay inside the bubble - // that fades text out toward the bottom. Used by the sticky - // overlay to indicate truncated content. - fadeFromBottom?: boolean; onImplementPlan?: () => Promise | void; urlTransform?: UrlTransform; mcpServers?: readonly TypesGen.MCPServerConfig[]; @@ -142,9 +134,9 @@ const ChatMessageItem = memo<{ latestAskUserQuestionToolId?: string; askUserQuestionResponseTextByToolId?: ReadonlyMap; hasUserResponseAfterAskQuestion?: boolean; - prevUserMessageId?: number; - nextUserMessageId?: number; - onJumpToUserMessage?: (messageId: number) => void; + prevUserMessageKey?: string; + nextUserMessageKey?: string; + onJumpToUserMessage?: (messageKey: string) => void; }>( ({ renderKey, @@ -161,15 +153,14 @@ const ChatMessageItem = memo<{ hasActiveStream = false, isAwaitingFirstStreamChunk = false, isLastMessage = false, - fadeFromBottom = false, onImplementPlan, onSendAskUserQuestionResponse, isChatCompleted, latestAskUserQuestionToolId, askUserQuestionResponseTextByToolId, hasUserResponseAfterAskQuestion = false, - prevUserMessageId, - nextUserMessageId, + prevUserMessageKey, + nextUserMessageKey, onJumpToUserMessage, urlTransform, @@ -230,9 +221,7 @@ const ChatMessageItem = memo<{ return (
@@ -333,8 +321,8 @@ const ChatMessageItem = memo<{ )} {isUser && onJumpToUserMessage && - (prevUserMessageId !== undefined || - nextUserMessageId !== undefined) && ( + (prevUserMessageKey !== undefined || + nextUserMessageKey !== undefined) && ( <> @@ -343,10 +331,10 @@ const ChatMessageItem = memo<{ variant="subtle" className="size-6" aria-label="Jump to previous user message" - disabled={prevUserMessageId === undefined} + disabled={prevUserMessageKey === undefined} onClick={() => { - if (prevUserMessageId !== undefined) { - onJumpToUserMessage(prevUserMessageId); + if (prevUserMessageKey !== undefined) { + onJumpToUserMessage(prevUserMessageKey); } }} > @@ -367,10 +355,10 @@ const ChatMessageItem = memo<{ variant="subtle" className="size-6" aria-label="Jump to next user message" - disabled={nextUserMessageId === undefined} + disabled={nextUserMessageKey === undefined} onClick={() => { - if (nextUserMessageId !== undefined) { - onJumpToUserMessage(nextUserMessageId); + if (nextUserMessageKey !== undefined) { + onJumpToUserMessage(nextUserMessageKey); } }} > @@ -410,340 +398,9 @@ const ChatMessageItem = memo<{ }, ); -const StickyUserMessage = memo<{ - message: TypesGen.ChatMessage; - parsed: ParsedMessageContent; - onEditUserMessage?: ( - messageId: number, - text: string, - fileBlocks?: readonly TypesGen.ChatMessagePart[], - ) => void; - editingMessageId?: number | null; - isAfterEditingMessage?: boolean; - prevUserMessageId?: number; - nextUserMessageId?: number; - onJumpToUserMessage?: (messageId: number) => void; - registerSentinel?: (messageId: number, el: HTMLDivElement | null) => void; - urlTransform?: UrlTransform; -}>( - ({ - message, - parsed, - onEditUserMessage, - editingMessageId, - isAfterEditingMessage = false, - prevUserMessageId, - nextUserMessageId, - onJumpToUserMessage, - registerSentinel, - urlTransform, - }) => { - const [isStuck, setIsStuck] = useState(false); - const [isReady, setIsReady] = useState(false); - const [isTooTall, setIsTooTall] = useState(false); - const sentinelRef = useRef(null); - const messageKey = `message:${message.id}`; - const messageId = message.id; - const setSentinelRef = (el: HTMLDivElement | null) => { - sentinelRef.current = el; - registerSentinel?.(messageId, el); - }; - const containerRef = useRef(null); - const updateFnRef = useRef<(() => void) | null>(null); - - // useLayoutEffect so isStuck and --clip-h are both resolved - // before the browser paints, avoiding a flash on load. - useLayoutEffect(() => { - const sentinel = sentinelRef.current; - if (!sentinel) return; - // Immediate check so the first paint is correct when the - // sentinel is already scrolled out of view. - const scroller = sentinel.closest(".overflow-y-auto"); - if (scroller) { - const stuck = - sentinel.getBoundingClientRect().top < - scroller.getBoundingClientRect().top; - if (stuck) { - setIsStuck(true); - } - } - setIsReady(true); - const observer = new IntersectionObserver( - ([entry]) => setIsStuck(!entry.isIntersecting), - { threshold: 0 }, - ); - observer.observe(sentinel); - return () => observer.disconnect(); - }, []); - - // Sets a single CSS custom property (--clip-h) on the sticky - // container. All visual behaviour (max-height, mask fade) is - // driven by CSS using this variable. - useLayoutEffect(() => { - const sentinel = sentinelRef.current; - const container = containerRef.current; - if (!sentinel || !container) return; - const scroller = sentinel.closest( - ".overflow-y-auto", - ) as HTMLElement | null; - if (!scroller) return; - - const MIN_HEIGHT = 72; - const STICKY_TOP = 8; - - const update = () => { - // Read the scroller geometry on each tick. Caching it goes - // stale when the scroller moves or resizes without a window - // resize (for example the composer growing), which skews the - // clip height and push-up math. - const scrollerTop = scroller.getBoundingClientRect().top; - const scrollerHeight = scroller.clientHeight; - const fullHeight = container.offsetHeight; - - // Skip sticky behavior for messages that take up - // most of the visible area — accounting for the - // chat input and some breathing room. - const tooTall = fullHeight > scrollerHeight * 0.75; - setIsTooTall(tooTall); - if (tooTall) { - container.style.setProperty("--clip-h", `${fullHeight}px`); - container.style.setProperty("--fade-opacity", "0"); - container.style.top = `${STICKY_TOP}px`; - - return; - } - const sentinelTop = sentinel.getBoundingClientRect().top; - const scrolledPast = scrollerTop - sentinelTop; - - if (scrolledPast <= 0) { - // Always set a valid value so the overlay has the - // correct height immediately when isStuck flips. - container.style.setProperty("--clip-h", `${fullHeight}px`); - container.style.setProperty("--fade-opacity", "0"); - container.style.top = `${STICKY_TOP}px`; - - return; - } - const visible = Math.max(fullHeight - scrolledPast, MIN_HEIGHT); - container.style.setProperty("--clip-h", `${visible}px`); - // Only show the blur and gradient once the message - // is near its minimum compressed height. Ramp over - // the last 40px before MIN_HEIGHT so it doesn't pop. - const FADE_RANGE = 40; - const fade = Math.max( - 0, - Math.min((MIN_HEIGHT + FADE_RANGE - visible) / FADE_RANGE, 1), - ); - container.style.setProperty("--fade-opacity", String(fade)); - // Push-up effect: when the next user message's sentinel - // approaches the bottom of this sticky container, shift - // this container upward so it slides out of view — the - // same visual as the old section-boundary behavior. - let nextSentinel: Element | null = sentinel.nextElementSibling; - while (nextSentinel) { - if (nextSentinel.hasAttribute("data-user-sentinel")) { - break; - } - nextSentinel = nextSentinel.nextElementSibling; - } - if (nextSentinel) { - const nextY = nextSentinel.getBoundingClientRect().top - scrollerTop; - container.style.top = `${Math.min(STICKY_TOP, nextY - visible + STICKY_TOP)}px`; - } else { - container.style.top = `${STICKY_TOP}px`; - } - }; - updateFnRef.current = update; - - // Throttle to one update per animation frame so we don't - // do redundant work on high-refresh-rate displays. - let rafId: number | null = null; - const onScroll = () => { - if (rafId !== null) return; - rafId = requestAnimationFrame(() => { - rafId = null; - update(); - }); - }; - - // Re-run the visual update when the transcript height changes, - // for example a streaming response or several messages arriving - // at once. In flex-col-reverse the scrollTop stays at 0 while - // pinned to the bottom, so no scroll event fires; observing the - // content wrapper catches that growth instead. - // - // The scroller's firstElementChild is the flex spacer that pins - // content to the bottom. It collapses to 0px once the transcript - // overflows and then stops emitting resize callbacks, which is - // exactly when truncation is active, so observe the real content - // node (an ancestor of the sentinel) and fall back to the spacer - // only when the marker is absent. - const contentEl = - sentinel.closest("[data-chat-scroll-content]") ?? - (scroller.firstElementChild as HTMLElement | null); - let contentRafId: number | null = null; - const contentObserver = contentEl - ? new ResizeObserver(() => { - if (contentRafId !== null) return; - contentRafId = requestAnimationFrame(() => { - contentRafId = null; - update(); - }); - }) - : null; - contentObserver?.observe(contentEl!); - - scroller.addEventListener("scroll", onScroll, { passive: true }); - window.addEventListener("resize", update); - update(); - // Set immediately — both --clip-h and --overlay-ready are - // applied before the browser paints since we're in a - // useLayoutEffect. - container.style.setProperty("--overlay-ready", "1"); - return () => { - scroller.removeEventListener("scroll", onScroll); - window.removeEventListener("resize", update); - contentObserver?.disconnect(); - container.style.removeProperty("--overlay-ready"); - if (rafId !== null) cancelAnimationFrame(rafId); - if (contentRafId !== null) cancelAnimationFrame(contentRafId); - }; - }, []); - - // Re-run the height calculation synchronously whenever - // isStuck changes so --clip-h is correct on the same frame - // the overlay appears. Without this, the async - // IntersectionObserver + RAF-throttled scroll handler can - // leave a stale --clip-h for one paint. - // biome-ignore lint/correctness/useExhaustiveDependencies: isStuck is an intentional trigger - useLayoutEffect(() => { - updateFnRef.current?.(); - }, [isStuck]); - - const handleEditUserMessage = onEditUserMessage - ? ( - messageId: number, - text: string, - fileBlocks?: readonly TypesGen.ChatMessagePart[], - ) => { - onEditUserMessage(messageId, text, fileBlocks); - requestAnimationFrame(() => { - const sentinel = sentinelRef.current; - if (!sentinel) return; - const scroller = sentinel.closest( - ".overflow-y-auto", - ) as HTMLElement | null; - if (!scroller) return; - const offset = - sentinel.getBoundingClientRect().top - - scroller.getBoundingClientRect().top; - scroller.scrollBy({ top: offset, behavior: "smooth" }); - }); - } - : undefined; - - return ( - <> -
-
- {/* Flow element: always in the DOM to preserve - scroll layout. Hidden when stuck so the - clipped overlay takes over visually. */} -
- -
- - {/* Overlay: absolutely positioned, matching the - sticky container. max-height + mask are driven - entirely by the --clip-h CSS variable which the - scroll handler sets on the container. */} - {isStuck && !isTooTall && ( -
- {/* Blur layer: extends 48px beyond the - clipped content so the frosted effect - is visible around the bubble. Promoted - to its own GPU layer via will-change. */} -
- {/* Content layer: px-3 matches the sticky - container's padding so the overlay aligns - with the flow element. will-change promotes - to GPU layer. */} -
- -
-
- )} -
- - ); - }, -); - interface ConversationTimelineProps { parsedMessages: readonly ParsedMessageEntry[]; + onVisibleRowsChange?: (hasVisibleRows: boolean) => void; streamState?: StreamState | null; streamTools?: readonly MergedTool[]; liveStatus?: LiveStatusModel; @@ -769,6 +426,7 @@ interface ConversationTimelineProps { export const ConversationTimeline = memo( ({ parsedMessages, + onVisibleRowsChange, streamState, streamTools = [], liveStatus, @@ -786,19 +444,9 @@ export const ConversationTimeline = memo( hasActiveStream, isAwaitingFirstStreamChunk, }) => { - const sentinelsRef = useRef>(new Map()); - const registerSentinel = (messageId: number, el: HTMLDivElement | null) => { - if (el) { - sentinelsRef.current.set(messageId, el); - } else { - sentinelsRef.current.delete(messageId); - } - }; - const jumpToUserMessage = (messageId: number) => { - sentinelsRef.current.get(messageId)?.scrollIntoView({ - behavior: "smooth", - block: "start", - }); + const { scrollToMessage } = useMessageScroller(); + const jumpToUserMessage = (messageKey: string) => { + scrollToMessage(messageKey, { align: "start", behavior: "smooth" }); }; const displayMessages = buildDisplayMessages(parsedMessages); @@ -806,6 +454,12 @@ export const ConversationTimeline = memo( displayMessages, Boolean(liveStatus && shouldRenderLiveAssistant(liveStatus)), ); + const hasVisibleRows = displayMessages.length > 0; + const [reportedVisibleRows, setReportedVisibleRows] = useState(); + if (hasVisibleRows !== reportedVisibleRows) { + setReportedVisibleRows(hasVisibleRows); + onVisibleRowsChange?.(hasVisibleRows); + } // A live turn only reveals its stream blocks once output has accumulated. // Before that the callout and thinking indicator stand in for the turn. @@ -835,26 +489,35 @@ export const ConversationTimeline = memo( } } - // Ordered list of visible user message IDs, used to drive the - // per-bubble prev/next arrow buttons that jump the transcript - // to the neighbouring user prompt. - const visibleUserMessageIds: number[] = []; - for (const { message } of displayMessages) { - if (message.role === "user") { - visibleUserMessageIds.push(message.id); + // Ordered list of visible user rows, used to drive the per-bubble + // prev/next arrow buttons that jump the transcript to the neighbouring + // user prompt. The row key doubles as the scroller's message ID. + const userRowKeys: string[] = []; + for (const row of renderRows) { + if (row.type === "message" && row.entry.message.role === "user") { + userRowKeys.push(row.key); } } - const userNeighborsById = new Map< - number, - { prevId?: number; nextId?: number } + // Only the latest user row anchors the scroller, and only while its turn + // is active. The scroller never marks initially rendered anchors as + // handled, and its fallback for mutations that are neither clean appends + // nor clean prepends jumps to the oldest unhandled anchor, so historical + // rows must not be anchors at all. + const hasLiveAssistant = Boolean( + liveStatus && shouldRenderLiveAssistant(liveStatus), + ); + const anchorUserRowKey = + hasLiveAssistant || isAwaitingFirstStreamChunk + ? userRowKeys[userRowKeys.length - 1] + : undefined; + const userNeighborsByKey = new Map< + string, + { prevKey?: string; nextKey?: string } >(); - for (let i = 0; i < visibleUserMessageIds.length; i++) { - userNeighborsById.set(visibleUserMessageIds[i], { - prevId: i > 0 ? visibleUserMessageIds[i - 1] : undefined, - nextId: - i < visibleUserMessageIds.length - 1 - ? visibleUserMessageIds[i + 1] - : undefined, + for (let i = 0; i < userRowKeys.length; i++) { + userNeighborsByKey.set(userRowKeys[i], { + prevKey: i > 0 ? userRowKeys[i - 1] : undefined, + nextKey: i < userRowKeys.length - 1 ? userRowKeys[i + 1] : undefined, }); } let latestAskUserQuestionToolId: string | undefined; @@ -895,16 +558,12 @@ export const ConversationTimeline = memo( return ( -
- {renderRows.map((row) => { - if (row.type === "live") { - // This row only exists when liveStatus is set. - return ( + {renderRows.map((row) => { + if (row.type === "live") { + // This row only exists when liveStatus is set. + return ( + ( urlTransform={urlTransform} mcpServers={mcpServers} /> - ); - } - const { message, parsed } = row.entry; - const neighbors = userNeighborsById.get(message.id); - const isAfterEditingMessage = afterEditingMessageIds.has( - message.id, + ); - if (message.role === "user") { - return ( - - ); - } - return ( + } + const { message, parsed } = row.entry; + const isUser = message.role === "user"; + const neighbors = userNeighborsByKey.get(row.key); + const isAfterEditingMessage = afterEditingMessageIds.has(message.id); + return ( + ( } urlTransform={urlTransform} isAfterEditingMessage={isAfterEditingMessage} - hideActions={!row.isLastInAssistantChain} + hideActions={!isUser && !row.isLastInAssistantChain} hasActiveStream={Boolean(hasActiveStream)} isAwaitingFirstStreamChunk={Boolean(isAwaitingFirstStreamChunk)} isLastMessage={row.isLastMessage} @@ -967,10 +615,13 @@ export const ConversationTimeline = memo( subagentTitles={subagentTitles} subagentVariants={subagentVariants} showDesktopPreviews={showDesktopPreviews} + prevUserMessageKey={neighbors?.prevKey} + nextUserMessageKey={neighbors?.nextKey} + onJumpToUserMessage={isUser ? jumpToUserMessage : undefined} /> - ); - })} -
+ + ); + })}
); }, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx b/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx index 7443d88b41a..8f6e14f2fca 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx @@ -61,14 +61,12 @@ export const UserMessageContent: FC<{ displayState: MessageDisplayState; markdown: string; isEditing?: boolean; - fadeFromBottom?: boolean; onImageClick?: (src: string) => void; onTextFileClick?: (attachment: PreviewTextAttachment) => void; }> = ({ displayState, markdown, isEditing = false, - fadeFromBottom = false, onImageClick, onTextFileClick, }) => { @@ -79,11 +77,7 @@ export const UserMessageContent: FC<{ "rounded-lg border border-solid border-border-default bg-surface-secondary px-3 py-2 font-sans shadow-sm transition-shadow", isEditing && "border-surface-secondary shadow-[0_0_0_2px_hsla(var(--border-warning),0.6)]", - fadeFromBottom && "relative overflow-hidden", )} - style={ - fadeFromBottom ? { maxHeight: "var(--clip-h, none)" } : undefined - } >
{(displayState.hasUserMessageBody || displayState.hasFileBlocks) && ( @@ -115,16 +109,6 @@ export const UserMessageContent: FC<{ ))}
)} - {fadeFromBottom && ( -
- )}
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.test.ts index 7ccfc6135e0..104b2be53ae 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import type * as TypesGen from "#/api/typesGenerated"; +import { buildDisplayMessages } from "./messageHelpers"; import { assignTimelineRows } from "./timelineRows"; -import type { ParsedMessageContent, ParsedMessageEntry } from "./types"; +import type { + MergedTool, + ParsedMessageContent, + ParsedMessageEntry, +} from "./types"; const emptyParsed: ParsedMessageContent = { markdown: "", @@ -41,14 +46,107 @@ const durable = ( const keys = (rows: ReturnType): string[] => rows.map((row) => row.key); +const readFileMessage = (id: number, toolID: string): ParsedMessageEntry => ({ + message: { + id, + chat_id: "chat-1", + role: "assistant", + created_at: "2026-08-12T00:00:00Z", + content: [], + }, + parsed: { + ...emptyParsed, + toolCalls: [{ id: toolID, name: "read_file", args: { path: toolID } }], + toolResults: [ + { + id: toolID, + name: "read_file", + result: { content: toolID }, + isError: false, + }, + ], + tools: [ + { + id: toolID, + name: "read_file", + args: { path: toolID }, + result: { content: toolID }, + isError: false, + status: "completed", + } satisfies MergedTool, + ], + blocks: [{ type: "tool", id: toolID }], + }, +}); + describe("assignTimelineRows", () => { - it("keys durable rows by message ID and the live row separately", () => { + it("keeps durable row keys stable when an earlier turn is prepended", () => { + const assistant = durable(2, "assistant", "answer"); + const before = assignTimelineRows([assistant], false); + const after = assignTimelineRows( + [durable(1, "user", "prompt"), assistant], + false, + ); + + expect(keys(before)).toEqual(["message:2"]); + expect(keys(after)).toEqual(["message:1", "message:2"]); + }); + + it("uses a separate slot for the live assistant", () => { + const rows = assignTimelineRows([durable(1, "user", "prompt")], true); + + expect(keys(rows)).toEqual(["message:1", "message:1:assistant:0"]); + }); + + it("advances the live assistant slot past durable assistants in the turn", () => { const rows = assignTimelineRows( - [durable(1, "user", "prompt"), durable(2, "assistant", "answer")], + [ + durable(1, "user", "prompt"), + durable(2, "assistant", "first"), + durable(3, "assistant", "second"), + ], true, ); - expect(keys(rows)).toEqual(["message:1", "message:2", "live-assistant"]); + expect(keys(rows)).toEqual([ + "message:1", + "message:2", + "message:3", + "message:1:assistant:2", + ]); + }); + + it("falls back to a live key when no durable user turn exists", () => { + expect(keys(assignTimelineRows([], true))).toEqual(["live-assistant"]); + }); + + it("keeps a merged read_file row's key stable when older reads are prepended", () => { + const prompt = durable(1, "user", "prompt"); + const loadedFirst = assignTimelineRows( + [ + prompt, + buildDisplayMessages([ + readFileMessage(50, "read-50"), + readFileMessage(51, "read-51"), + ])[0], + ], + false, + ); + const afterPrepend = assignTimelineRows( + [ + prompt, + buildDisplayMessages([ + readFileMessage(48, "read-48"), + readFileMessage(49, "read-49"), + readFileMessage(50, "read-50"), + readFileMessage(51, "read-51"), + ])[0], + ], + false, + ); + + expect(keys(loadedFirst)).toEqual(["message:1", "read-file-group:after:1"]); + expect(keys(afterPrepend)).toEqual(keys(loadedFirst)); }); it("marks only the last message of an assistant chain", () => { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts b/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts index 0888e08a3c7..02b1515c346 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts @@ -1,3 +1,4 @@ +import { getDisplayMessageKey } from "./messageHelpers"; import type { ParsedMessageEntry } from "./types"; type TimelineMessageRow = { @@ -10,17 +11,36 @@ type TimelineMessageRow = { type TimelineRow = TimelineMessageRow | { type: "live"; key: string }; +const assistantSlotKey = (turnKey: string, slot: number): string => + `${turnKey}:assistant:${slot}`; + +/** + * Durable rows keep their server IDs so prepending history never changes an + * existing Item's identity. Merged read_file groups key off the visible row + * before them for the same reason. The live assistant uses a turn-local slot + * until its durable message arrives. + */ export const assignTimelineRows = ( displayMessages: readonly ParsedMessageEntry[], hasLiveAssistant: boolean, ): readonly TimelineRow[] => { const rows: TimelineMessageRow[] = []; + let turnKey: string | undefined; + let assistantsInTurn = 0; for (const [index, entry] of displayMessages.entries()) { + const { message } = entry; + const key = getDisplayMessageKey(entry, displayMessages[index - 1]); + if (message.role === "user") { + turnKey = key; + assistantsInTurn = 0; + } else if (message.role === "assistant" && turnKey) { + assistantsInTurn += 1; + } rows.push({ type: "message", entry, - key: `message:${entry.message.id}`, + key, isLastInAssistantChain: false, isLastMessage: index === displayMessages.length - 1, }); @@ -45,5 +65,13 @@ export const assignTimelineRows = ( if (!hasLiveAssistant) { return rows; } - return [...rows, { type: "live", key: "live-assistant" }]; + return [ + ...rows, + { + type: "live", + key: turnKey + ? assistantSlotKey(turnKey, assistantsInTurn) + : "live-assistant", + }, + ]; }; diff --git a/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx b/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx new file mode 100644 index 00000000000..9823547b2c3 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx @@ -0,0 +1,134 @@ +import { + MessageScroller, + useMessageScrollerScrollable, + useMessageScrollerVisibility, +} from "@shadcn/react/message-scroller"; +import { ArrowDownIcon, RotateCcwIcon } from "lucide-react"; +import { type FC, type ReactNode, useEffect } from "react"; +import { Button } from "#/components/Button/Button"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { cn } from "#/utils/cn"; +import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth"; + +interface EarlierMessagesProps { + hasMoreMessages: boolean; + isFetchingMoreMessages: boolean; + hasFetchMoreError: boolean; + onFetchMoreMessages: () => Promise; +} + +/** + * Owns history paging for the transcript. It reads the scroller's own state + * instead of measuring the viewport, and it never moves the scroll position: + * MessageScroller keeps the reading position across a prepend on its own. + */ +const EarlierMessages: FC = ({ + hasMoreMessages, + isFetchingMoreMessages, + hasFetchMoreError, + onFetchMoreMessages, +}) => { + const { start: canScrollTowardStart } = useMessageScrollerScrollable(); + const { visibleMessageIds } = useMessageScrollerVisibility(); + + // The scroller reports that it cannot scroll toward the start both before it + // has measured anything and once the viewport sits at the start edge, so wait + // for a measured row before reading that as a request for older history. A + // transcript that does not fill the viewport stays at the start edge, which + // keeps paging until it overflows or history runs out. + const isAtHistoryStart = + !canScrollTowardStart && visibleMessageIds.length > 0; + const shouldLoadEarlierMessages = + isAtHistoryStart && + hasMoreMessages && + !isFetchingMoreMessages && + !hasFetchMoreError; + + // isFetchingMoreMessages (from React Query) is the only fence: the effect + // does not re-fire while a page request is already running. + useEffect(() => { + if (shouldLoadEarlierMessages) { + void onFetchMoreMessages(); + } + }, [shouldLoadEarlierMessages, onFetchMoreMessages]); + + if (isFetchingMoreMessages) { + return ( +
+
+ + Loading earlier messages +
+
+ ); + } + + if (!hasFetchMoreError) { + return null; + } + + return ( +
+ +
+ ); +}; + +interface ChatMessageScrollerProps extends EarlierMessagesProps { + /** One `MessageScroller.Item` per transcript row, and nothing else. */ + children: ReactNode; +} + +export const ChatMessageScroller: FC = ({ + children, + ...earlierMessages +}) => { + const [chatFullWidth] = useChatFullWidth(); + + return ( + + + + + {children} + + + + + } + > + + + + + + + ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx index 0ffe7e873c1..ba72eedcd7e 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { FC } from "react"; import { expect, within } from "storybook/test"; import type * as TypesGen from "#/api/typesGenerated"; import { ChatWorkspaceContext } from "../context/ChatWorkspaceContext"; @@ -6,6 +7,20 @@ import { createChatStore } from "./ChatConversation/chatStore"; import { FIXTURE_NOW } from "./ChatConversation/storyFixtures"; import { ChatPageTimeline } from "./ChatPageContent"; +// These stories cover transcript rendering, so history paging stays idle. +const StoryChatPageTimeline: FC<{ + store: ReturnType; +}> = ({ store }) => ( + {}} + /> +); + const meta = { title: "pages/AgentsPage/ChatPageContent", } satisfies Meta; @@ -49,7 +64,7 @@ export const SpacerVisibleWhenNotStreaming: Story = { render: () => { const store = buildThinkingSpacerStore(); - return ; + return ; }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -76,7 +91,7 @@ export const DurableUnresolvedWorkspaceToolRuns: Story = { return ( - + ); }, @@ -99,7 +114,7 @@ export const HiddenAssistantPlaceholderDoesNotRender: Story = { buildMessage(4, "user", [{ type: "text", text: "Thanks!" }]), ]); - return ; + return ; }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -137,7 +152,7 @@ export const MergedMessagesRenderInIDOrder: Story = { batched(2, "assistant", "bravo"), ]); - return ; + return ; }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index a87deb9e188..a770e195bef 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -48,6 +48,7 @@ import { buildStreamTools } from "./ChatConversation/streamState"; import { useOnRenderProfiler } from "./ChatConversation/useOnRenderProfiler"; import type { ModelSelectorOption } from "./ChatElements"; import type { SkillMetadata } from "./ChatMessageInput/SkillsTriggerMenu"; +import { ChatMessageScroller } from "./ChatMessageScroller"; type ChatStoreHandle = ReturnType["store"]; @@ -85,6 +86,10 @@ export const workspaceSkillsFromChat = ( interface ChatPageTimelineProps { store: ChatStoreHandle; persistedError: ChatDetailError | undefined; + hasMoreMessages: boolean; + isFetchingMoreMessages: boolean; + hasFetchMoreError: boolean; + onFetchMoreMessages: () => Promise; onEditUserMessage?: ( messageId: number, text: string, @@ -100,6 +105,10 @@ interface ChatPageTimelineProps { export const ChatPageTimeline: FC = ({ store, persistedError, + hasMoreMessages, + isFetchingMoreMessages, + hasFetchMoreError, + onFetchMoreMessages, onEditUserMessage, editingMessageId, onImplementPlan, @@ -162,12 +171,11 @@ export const ChatPageTimeline: FC = ({ return ( -
{/* VNC sessions for completed agents may already be terminated, so inline desktop previews are disabled @@ -193,6 +201,10 @@ export const ChatPageTimeline: FC = ({ mcpServers={mcpServers} showDesktopPreviews={false} /> + + {/* The empty state and the terminal failure callout sit outside the + scroller content, which holds transcript rows only. */} +
void; -}> = ({ scrollContainerElement, messageCount, onScrollToBottom }) => { - const [showScrollToBottomButton, setShowScrollToBottomButton] = - useState(false); - - useEffect(() => { - if (!scrollContainerElement) { - setShowScrollToBottomButton(false); - return; - } - - let frameId: number | null = null; - const updateVisibility = () => { - setShowScrollToBottomButton( - Math.abs(scrollContainerElement.scrollTop) > - SCROLL_TO_BOTTOM_BUTTON_OFFSET_PX, - ); - }; - const handleScroll = () => { - if (frameId !== null) { - return; - } - frameId = requestAnimationFrame(() => { - frameId = null; - updateVisibility(); - }); - }; - - updateVisibility(); - scrollContainerElement.addEventListener("scroll", handleScroll, { - passive: true, - }); - - return () => { - scrollContainerElement.removeEventListener("scroll", handleScroll); - if (frameId !== null) { - cancelAnimationFrame(frameId); - } - }; - }, [scrollContainerElement]); - - useEffect(() => { - if (!scrollContainerElement) { - return; - } - setShowScrollToBottomButton( - messageCount > 0 && - Math.abs(scrollContainerElement.scrollTop) > - SCROLL_TO_BOTTOM_BUTTON_OFFSET_PX, - ); - }, [messageCount, scrollContainerElement]); - - const handleScrollToBottom = () => { - onScrollToBottom(); - setShowScrollToBottomButton(false); - }; - - return ( - // Floating overlay above the scroll container. The button has its own - // fixed-size box so the wrapper does not need overflow handling. -
- -
- ); -}; - -const ChatScrollContainer: FC<{ - scrollContainerRef: RefObject; - scrollToBottomRef: RefObject<(() => void) | null>; - isFetchingMoreMessages: boolean; - hasMoreMessages: boolean; - onFetchMoreMessages: () => void; - messageCount: number; - children: ReactNode; -}> = ({ - scrollContainerRef, - scrollToBottomRef, - isFetchingMoreMessages, - hasMoreMessages, - onFetchMoreMessages, - messageCount, - children, -}) => { - const [scrollContainerElement, setScrollContainerElement] = - useState(null); - - const scrollToBottom = () => { - // Read the live ref so remounts cannot leave callers targeting a detached - // scroll node. - const scrollContainer = scrollContainerRef.current; - if (!scrollContainer) { - return; - } - // In the library's reversed layout, the newest messages sit at the visual - // bottom, which maps to a zero scroll offset. - scrollContainer.scrollTop = 0; - }; - - const setScrollContainer = (element: HTMLDivElement | null) => { - scrollContainerRef.current = element; - setScrollContainerElement(element); - scrollToBottomRef.current = element ? scrollToBottom : null; - }; - - return ( -
-
-
- 0} - loader={isFetchingMoreMessages ?
: null} - endMessage={null} - // `display: contents` removes this wrapper's box from the - // layout tree. Combined with the `outerdiv:contents` - // selector on the scroller above, the children render as - // direct flex items of the scroller so sticky messages - // can pin to its top edge. - style={{ display: "contents" }} - > - {children} - -
- -
- ); -}; - -export { ChatScrollContainer }; From 8c4b9296544145aaa5dab64e83283890970e93be Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 13 Aug 2026 16:40:10 +0000 Subject: [PATCH 02/11] fix(site): keep chat history paging stable across filtered pages and merged rows --- .../ConversationTimeline.stories.tsx | 21 +++---------------- .../ChatConversation/messageHelpers.ts | 18 ++++++++++++++++ .../components/ChatConversation/types.ts | 3 +++ .../components/ChatMessageScroller.tsx | 18 +++++++++------- .../AgentsPage/components/ChatPageContent.tsx | 12 ++++++++++- 5 files changed, 45 insertions(+), 27 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 0cff9303b94..0549d3bf279 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -31,6 +31,7 @@ const withMessageScroller: Decorator = (Story, { parameters }) => { hasMoreMessages={false} isFetchingMoreMessages={false} hasFetchMoreError={false} + hasVisibleRows={true} onFetchMoreMessages={async () => {}} > @@ -1601,8 +1602,8 @@ export const UserMessagesRenderAsSingleRows: Story = { /** * Each user message exposes left/right chevron buttons in its action row so * users can jump the transcript between user prompts. They are disabled at the - * ends of the conversation; otherwise the click scrolls the neighbouring - * prompt to the top of the viewport. + * ends of the conversation; clicking one hands the neighbouring prompt's row + * key to the scroller, which owns the scroll itself. */ export const UserMessageJumpArrows: Story = { parameters: { messageScrollerHeight: 320 }, @@ -1654,7 +1655,6 @@ export const UserMessageJumpArrows: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const viewport = canvas.getByRole("region", { name: "Messages" }); // Reveal the hover-only action rows so we can interact with // the chevron buttons without dispatching real hover events. @@ -1688,22 +1688,7 @@ export const UserMessageJumpArrows: Story = { expect(prevButtons[2]).toBeEnabled(); expect(nextButtons[2]).toBeDisabled(); - // Clicking Next on the first prompt brings the second prompt to the top - // of the viewport through the scroller. - const secondPrompt = canvas.getByText("Second prompt"); - const offsetInViewport = () => - secondPrompt.getBoundingClientRect().top - - viewport.getBoundingClientRect().top; - // The transcript opens at its end, so the second prompt starts above the - // visible area. - expect(offsetInViewport()).toBeLessThan(0); - await userEvent.click(nextButtons[0]); - - await waitFor(() => { - expect(offsetInViewport()).toBeGreaterThan(0); - expect(offsetInViewport()).toBeLessThan(160); - }); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts index 710c8ab382f..bcf67711f89 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts @@ -202,6 +202,7 @@ const mergeReadFileMessageGroup = ( const [first] = group; return { message: first.message, + mergedFrom: group.map((entry) => entry.message.id), parsed: { markdown: "", reasoning: "", @@ -215,6 +216,23 @@ const mergeReadFileMessageGroup = ( }; }; +// A merged group's row key cannot come from its members: prepending history +// changes the first member, live streaming changes the last. Key off the +// visible entry before the group instead; a group at the start of loaded +// history falls back to its newest member. +export const getDisplayMessageKey = ( + entry: ParsedMessageEntry, + previousVisible: ParsedMessageEntry | undefined, +): string => { + if (entry.mergedFrom === undefined) { + return `message:${entry.message.id}`; + } + if (previousVisible !== undefined) { + return `read-file-group:after:${previousVisible.message.id}`; + } + return `read-file-group:through:${entry.mergedFrom[entry.mergedFrom.length - 1]}`; +}; + // Real transcripts place hidden tool-result-only messages between // sequential read_file assistant messages. Those hidden entries stay // transparent so the visible timeline reflects one file-reading run instead diff --git a/site/src/pages/AgentsPage/components/ChatConversation/types.ts b/site/src/pages/AgentsPage/components/ChatConversation/types.ts index 06b1128722c..2acdbfae67d 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/types.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/types.ts @@ -67,6 +67,9 @@ export type ParsedMessageContent = { export type ParsedMessageEntry = { message: TypesGen.ChatMessage; parsed: ParsedMessageContent; + // IDs of the messages folded into this entry when consecutive read_file + // runs are merged into one row. Absent for ordinary messages. + mergedFrom?: readonly number[]; }; export type ReconnectState = ReconnectSchedule; diff --git a/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx b/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx index 9823547b2c3..37f746f8560 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx @@ -14,6 +14,7 @@ interface EarlierMessagesProps { hasMoreMessages: boolean; isFetchingMoreMessages: boolean; hasFetchMoreError: boolean; + hasVisibleRows: boolean; onFetchMoreMessages: () => Promise; } @@ -26,26 +27,27 @@ const EarlierMessages: FC = ({ hasMoreMessages, isFetchingMoreMessages, hasFetchMoreError, + hasVisibleRows, onFetchMoreMessages, }) => { const { start: canScrollTowardStart } = useMessageScrollerScrollable(); const { visibleMessageIds } = useMessageScrollerVisibility(); - // The scroller reports that it cannot scroll toward the start both before it - // has measured anything and once the viewport sits at the start edge, so wait - // for a measured row before reading that as a request for older history. A - // transcript that does not fill the viewport stays at the start edge, which - // keeps paging until it overflows or history runs out. + // "Cannot scroll toward the start" also holds before any row is measured, + // and a history page can filter down to zero rendered rows; both mean keep + // loading rather than treating the transcript as exhausted. const isAtHistoryStart = - !canScrollTowardStart && visibleMessageIds.length > 0; + !canScrollTowardStart && (visibleMessageIds.length > 0 || !hasVisibleRows); const shouldLoadEarlierMessages = isAtHistoryStart && hasMoreMessages && !isFetchingMoreMessages && !hasFetchMoreError; - // isFetchingMoreMessages (from React Query) is the only fence: the effect - // does not re-fire while a page request is already running. + // The scroller exposes position only as state, with no "reached the top" + // event, so paging is synced from that state here. Scroll events cannot + // replace this: they never fire when the transcript is shorter than the + // viewport, which is exactly when more history is needed. useEffect(() => { if (shouldLoadEarlierMessages) { void onFetchMoreMessages(); diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index a770e195bef..4fcf87e1ee5 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -1,4 +1,11 @@ -import { type FC, Profiler, type ReactNode, useEffect, useRef } from "react"; +import { + type FC, + Profiler, + type ReactNode, + useEffect, + useRef, + useState, +} from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { toast } from "sonner"; import type { UrlTransform } from "streamdown"; @@ -117,6 +124,7 @@ export const ChatPageTimeline: FC = ({ mcpServers, }) => { const [chatFullWidth] = useChatFullWidth(); + const [hasVisibleRows, setHasVisibleRows] = useState(true); const messagesByID = useChatSelector(store, selectMessagesByID); const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs); const chatStatus = useChatSelector(store, selectChatStatus); @@ -175,6 +183,7 @@ export const ChatPageTimeline: FC = ({ hasMoreMessages={hasMoreMessages} isFetchingMoreMessages={isFetchingMoreMessages} hasFetchMoreError={hasFetchMoreError} + hasVisibleRows={hasVisibleRows} onFetchMoreMessages={onFetchMoreMessages} > {/* VNC sessions for completed agents may already be @@ -184,6 +193,7 @@ export const ChatPageTimeline: FC = ({ renders correctly. */} Date: Thu, 13 Aug 2026 17:26:46 +0000 Subject: [PATCH 03/11] fix(site): stabilize merged read_file row keys and derive visible rows in the parent --- .../ChatConversation/ConversationTimeline.tsx | 8 -------- .../components/ChatConversation/messageHelpers.ts | 15 ++++----------- .../ChatConversation/timelineRows.test.ts | 5 ++++- .../components/ChatConversation/timelineRows.ts | 2 +- .../AgentsPage/components/ChatPageContent.tsx | 13 +++---------- 5 files changed, 12 insertions(+), 31 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 785a01ae1d4..0eb2223dc85 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -400,7 +400,6 @@ const ChatMessageItem = memo<{ interface ConversationTimelineProps { parsedMessages: readonly ParsedMessageEntry[]; - onVisibleRowsChange?: (hasVisibleRows: boolean) => void; streamState?: StreamState | null; streamTools?: readonly MergedTool[]; liveStatus?: LiveStatusModel; @@ -426,7 +425,6 @@ interface ConversationTimelineProps { export const ConversationTimeline = memo( ({ parsedMessages, - onVisibleRowsChange, streamState, streamTools = [], liveStatus, @@ -454,12 +452,6 @@ export const ConversationTimeline = memo( displayMessages, Boolean(liveStatus && shouldRenderLiveAssistant(liveStatus)), ); - const hasVisibleRows = displayMessages.length > 0; - const [reportedVisibleRows, setReportedVisibleRows] = useState(); - if (hasVisibleRows !== reportedVisibleRows) { - setReportedVisibleRows(hasVisibleRows); - onVisibleRowsChange?.(hasVisibleRows); - } // A live turn only reveals its stream blocks once output has accumulated. // Before that the callout and thinking indicator stand in for the turn. diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts index bcf67711f89..d1fece85e47 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts @@ -216,20 +216,13 @@ const mergeReadFileMessageGroup = ( }; }; -// A merged group's row key cannot come from its members: prepending history -// changes the first member, live streaming changes the last. Key off the -// visible entry before the group instead; a group at the start of loaded -// history falls back to its newest member. -export const getDisplayMessageKey = ( - entry: ParsedMessageEntry, - previousVisible: ParsedMessageEntry | undefined, -): string => { +// A merged group's row key cannot come from its first member: prepending +// history into the group changes it. Key off the newest member instead, which +// pagination never changes for an existing group. +export const getDisplayMessageKey = (entry: ParsedMessageEntry): string => { if (entry.mergedFrom === undefined) { return `message:${entry.message.id}`; } - if (previousVisible !== undefined) { - return `read-file-group:after:${previousVisible.message.id}`; - } return `read-file-group:through:${entry.mergedFrom[entry.mergedFrom.length - 1]}`; }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.test.ts index 104b2be53ae..e0015f0f1ee 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.test.ts @@ -145,7 +145,10 @@ describe("assignTimelineRows", () => { false, ); - expect(keys(loadedFirst)).toEqual(["message:1", "read-file-group:after:1"]); + expect(keys(loadedFirst)).toEqual([ + "message:1", + "read-file-group:through:51", + ]); expect(keys(afterPrepend)).toEqual(keys(loadedFirst)); }); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts b/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts index 02b1515c346..3f1673cdfe8 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts @@ -30,7 +30,7 @@ export const assignTimelineRows = ( for (const [index, entry] of displayMessages.entries()) { const { message } = entry; - const key = getDisplayMessageKey(entry, displayMessages[index - 1]); + const key = getDisplayMessageKey(entry); if (message.role === "user") { turnKey = key; assistantsInTurn = 0; diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 4fcf87e1ee5..94255a19a9b 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -1,11 +1,4 @@ -import { - type FC, - Profiler, - type ReactNode, - useEffect, - useRef, - useState, -} from "react"; +import { type FC, Profiler, type ReactNode, useEffect, useRef } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { toast } from "sonner"; import type { UrlTransform } from "streamdown"; @@ -46,6 +39,7 @@ import { } from "./ChatConversation/chatStore"; import { LiveStreamTailContent } from "./ChatConversation/LiveStreamTail"; import { deriveLiveStatus } from "./ChatConversation/liveStatusModel"; +import { buildDisplayMessages } from "./ChatConversation/messageHelpers"; import { buildSubagentMaps, getPendingToolCallIDs, @@ -124,7 +118,6 @@ export const ChatPageTimeline: FC = ({ mcpServers, }) => { const [chatFullWidth] = useChatFullWidth(); - const [hasVisibleRows, setHasVisibleRows] = useState(true); const messagesByID = useChatSelector(store, selectMessagesByID); const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs); const chatStatus = useChatSelector(store, selectChatStatus); @@ -175,6 +168,7 @@ export const ChatPageTimeline: FC = ({ }); const { titles: subagentTitles, variants: subagentVariants } = buildSubagentMaps(parsedMessages); + const hasVisibleRows = buildDisplayMessages(parsedMessages).length > 0; const onRenderProfiler = useOnRenderProfiler(); return ( @@ -193,7 +187,6 @@ export const ChatPageTimeline: FC = ({ renders correctly. */} Date: Thu, 13 Aug 2026 17:47:35 +0000 Subject: [PATCH 04/11] fix(site): key singleton read_file rows as groups and gate paging on filtered pages --- .../ConversationTimeline.stories.tsx | 2 +- .../ChatConversation/messageHelpers.test.ts | 10 +++++----- .../ChatConversation/messageHelpers.ts | 6 ++---- .../ChatConversation/timelineRows.test.ts | 17 +++++++++++++++++ .../components/ChatMessageScroller.tsx | 11 ++++++----- .../AgentsPage/components/ChatPageContent.tsx | 8 ++++++-- 6 files changed, 37 insertions(+), 17 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 0549d3bf279..757b670bfde 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -31,7 +31,7 @@ const withMessageScroller: Decorator = (Story, { parameters }) => { hasMoreMessages={false} isFetchingMoreMessages={false} hasFetchMoreError={false} - hasVisibleRows={true} + hasFilteredOutRows={false} onFetchMoreMessages={async () => {}} > diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts index f59384d1b45..75378609572 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts @@ -466,13 +466,13 @@ describe("buildDisplayMessages", () => { ]); }); - it("returns a single read_file-only message unchanged", () => { - const readFile = readFileMessage(1, "read-1"); - - const result = buildDisplayMessages([readFile]); + it("keys a single read_file-only message like a merged group", () => { + const result = buildDisplayMessages([readFileMessage(1, "read-1")]); expect(result).toHaveLength(1); - expect(result[0]).toBe(readFile); + expect(result[0].message.id).toBe(1); + expect(result[0].mergedFrom).toEqual([1]); + expect(result[0].parsed.blocks).toEqual([{ type: "tool", id: "read-1" }]); }); it("collapses read_file-only assistant messages across hidden tool results", () => { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts index d1fece85e47..4c75f235c0c 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts @@ -195,13 +195,11 @@ const isReadFileOnlyMessage = (entry: ParsedMessageEntry): boolean => { const mergeReadFileMessageGroup = ( group: readonly ParsedMessageEntry[], ): ParsedMessageEntry => { - if (group.length === 1) { - return group[0]; - } - const [first] = group; return { message: first.message, + // Singletons carry mergedFrom too: a prepend can extend the run, and + // the row key must not change when it does. mergedFrom: group.map((entry) => entry.message.id), parsed: { markdown: "", diff --git a/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.test.ts index e0015f0f1ee..c77d97c1d58 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.test.ts @@ -152,6 +152,23 @@ describe("assignTimelineRows", () => { expect(keys(afterPrepend)).toEqual(keys(loadedFirst)); }); + it("keeps a singleton read_file row's key stable when a prepend extends the run", () => { + const loadedFirst = assignTimelineRows( + buildDisplayMessages([readFileMessage(50, "read-50")]), + false, + ); + const afterPrepend = assignTimelineRows( + buildDisplayMessages([ + readFileMessage(49, "read-49"), + readFileMessage(50, "read-50"), + ]), + false, + ); + + expect(keys(loadedFirst)).toEqual(["read-file-group:through:50"]); + expect(keys(afterPrepend)).toEqual(keys(loadedFirst)); + }); + it("marks only the last message of an assistant chain", () => { const rows = assignTimelineRows( [ diff --git a/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx b/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx index 37f746f8560..7ca6a5fb516 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx @@ -14,7 +14,7 @@ interface EarlierMessagesProps { hasMoreMessages: boolean; isFetchingMoreMessages: boolean; hasFetchMoreError: boolean; - hasVisibleRows: boolean; + hasFilteredOutRows: boolean; onFetchMoreMessages: () => Promise; } @@ -27,17 +27,18 @@ const EarlierMessages: FC = ({ hasMoreMessages, isFetchingMoreMessages, hasFetchMoreError, - hasVisibleRows, + hasFilteredOutRows, onFetchMoreMessages, }) => { const { start: canScrollTowardStart } = useMessageScrollerScrollable(); const { visibleMessageIds } = useMessageScrollerVisibility(); // "Cannot scroll toward the start" also holds before any row is measured, - // and a history page can filter down to zero rendered rows; both mean keep - // loading rather than treating the transcript as exhausted. + // so only page once rows exist, or when a loaded page filtered down to + // zero rows and there may be visible history behind it. const isAtHistoryStart = - !canScrollTowardStart && (visibleMessageIds.length > 0 || !hasVisibleRows); + !canScrollTowardStart && + (visibleMessageIds.length > 0 || hasFilteredOutRows); const shouldLoadEarlierMessages = isAtHistoryStart && hasMoreMessages && diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 94255a19a9b..47877a4f446 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -168,7 +168,11 @@ export const ChatPageTimeline: FC = ({ }); const { titles: subagentTitles, variants: subagentVariants } = buildSubagentMaps(parsedMessages); - const hasVisibleRows = buildDisplayMessages(parsedMessages).length > 0; + // An empty store (initial hydration) must not trigger paging, but a loaded + // page whose messages all filter out of the timeline must keep paging. + const hasFilteredOutRows = + parsedMessages.length > 0 && + buildDisplayMessages(parsedMessages).length === 0; const onRenderProfiler = useOnRenderProfiler(); return ( @@ -177,7 +181,7 @@ export const ChatPageTimeline: FC = ({ hasMoreMessages={hasMoreMessages} isFetchingMoreMessages={isFetchingMoreMessages} hasFetchMoreError={hasFetchMoreError} - hasVisibleRows={hasVisibleRows} + hasFilteredOutRows={hasFilteredOutRows} onFetchMoreMessages={onFetchMoreMessages} > {/* VNC sessions for completed agents may already be From ce8f668432351edc1498030d11351b7480cbe204 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 13 Aug 2026 18:14:00 +0000 Subject: [PATCH 05/11] docs(site): describe the merged read_file row key correctly --- .../AgentsPage/components/ChatConversation/timelineRows.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts b/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts index 3f1673cdfe8..2593d8c1bdc 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts @@ -16,9 +16,9 @@ const assistantSlotKey = (turnKey: string, slot: number): string => /** * Durable rows keep their server IDs so prepending history never changes an - * existing Item's identity. Merged read_file groups key off the visible row - * before them for the same reason. The live assistant uses a turn-local slot - * until its durable message arrives. + * existing Item's identity. Merged read_file groups key off their newest + * member, which pagination never changes. The live assistant uses a + * turn-local slot until its durable message arrives. */ export const assignTimelineRows = ( displayMessages: readonly ParsedMessageEntry[], From 6e7869111434045f3f9cf93b3089d46711ef4dd2 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 13 Aug 2026 19:26:31 +0000 Subject: [PATCH 06/11] fix(site): render the terminal chat failure as a transcript row --- .../LiveStreamTail.stories.tsx | 338 +---------------- .../ChatConversation/LiveStreamTail.tsx | 41 +- .../TerminalStatusRow.stories.tsx | 353 ++++++++++++++++++ .../AgentsPage/components/ChatPageContent.tsx | 10 +- 4 files changed, 386 insertions(+), 356 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/ChatConversation/TerminalStatusRow.stories.tsx diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx index 20e1adf1f91..e5ad98d0365 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx @@ -3,22 +3,19 @@ import { expect, within } from "storybook/test"; import { LiveStreamTailContent } from "./LiveStreamTail"; import { buildLiveStatus, pinFixtureClock } from "./storyFixtures"; -const defaultArgs: React.ComponentProps = { - isTranscriptEmpty: true, - liveStatus: buildLiveStatus(), -}; - const meta: Meta = { title: "pages/AgentsPage/ChatConversation/LiveStreamTail", component: LiveStreamTailContent, beforeEach: pinFixtureClock, + args: { + isTranscriptEmpty: true, + liveStatus: buildLiveStatus(), + }, }; export default meta; type Story = StoryObj; -/** Empty transcripts show the standard prompt when there is no live tail. */ export const EmptyConversationPrompt: Story = { - args: defaultArgs, play: async ({ canvasElement }) => { const canvas = within(canvasElement); expect( @@ -26,330 +23,3 @@ export const EmptyConversationPrompt: Story = { ).toBeVisible(); }, }; - -export const UsageLimitExceeded: Story = { - args: { - ...defaultArgs, - liveStatus: buildLiveStatus({ - persistedError: { - kind: "usage_limit", - message: "Your AI spend budget has been reached.", - }, - }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect( - canvas.getByRole("heading", { name: /usage limit reached/i }), - ).toBeVisible(); - expect(canvas.getByText(/ai spend budget has been reached/i)).toBeVisible(); - expect( - canvas.queryByRole("link", { name: /view usage/i }), - ).not.toBeInTheDocument(); - }, -}; - -export const ProviderQuotaExceeded: Story = { - args: { - ...defaultArgs, - liveStatus: buildLiveStatus({ - streamError: { - kind: "usage_limit", - message: - "The usage quota for OpenAI has been exceeded. Check the billing and quota settings for the provider account.", - provider: "openai", - retryable: false, - }, - }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect( - canvas.getByText(/usage quota for openai has been exceeded/i), - ).toBeVisible(); - expect( - canvas.queryByRole("link", { name: /view usage/i }), - ).not.toBeInTheDocument(); - expect( - canvas.getByRole("heading", { name: /usage limit reached/i }), - ).toBeVisible(); - }, -}; - -/** Provider failures keep the footer-level terminal callout and status link. */ -export const TerminalOverloadedError: Story = { - args: { - ...defaultArgs, - liveStatus: buildLiveStatus({ - persistedError: { - kind: "overloaded", - message: "Anthropic is temporarily overloaded.", - provider: "anthropic", - retryable: true, - statusCode: 529, - }, - }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect( - canvas.getByRole("heading", { name: /service overloaded/i }), - ).toBeVisible(); - expect( - canvas.getByText(/anthropic is temporarily overloaded\./i), - ).toBeVisible(); - expect(canvas.getByText(/^HTTP 529$/)).toBeVisible(); - expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument(); - expect(canvas.queryByText(/^retryable$/i)).not.toBeInTheDocument(); - expect(canvas.getByRole("link", { name: /status/i })).toBeVisible(); - expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument(); - }, -}; - -/** Content-filter refusals render as terminal errors without a retry countdown or status link. */ -export const TerminalContentFilterError: Story = { - args: { - ...defaultArgs, - liveStatus: buildLiveStatus({ - persistedError: { - kind: "content_filter", - message: - "Anthropic blocked this response under its content policy (cyber).", - detail: - "This request triggered restrictions on violative cyber content and was blocked under Anthropic's Usage Policy. To learn more, see https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback.", - provider: "anthropic", - retryable: false, - }, - }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect( - canvas.getByRole("heading", { name: /response blocked/i }), - ).toBeVisible(); - expect( - canvas.getByText( - /anthropic blocked this response under its content policy \(cyber\)\./i, - ), - ).toBeVisible(); - expect( - canvas.getByText(/this request triggered restrictions/i), - ).toBeVisible(); - expect(canvas.queryByText(/retrying in/i)).not.toBeInTheDocument(); - expect( - canvas.queryByRole("link", { name: /status/i }), - ).not.toBeInTheDocument(); - }, -}; - -/** - * Transport timeouts render the per-provider "temporarily - * unavailable" copy with a "Request timed out" heading rather than - * the generic "Request failed" fallback. - */ -export const TerminalTimeoutErrorAnthropic: Story = { - args: { - ...defaultArgs, - liveStatus: buildLiveStatus({ - streamError: { - kind: "timeout", - message: "Anthropic is temporarily unavailable.", - provider: "anthropic", - retryable: false, - }, - }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect( - canvas.getByRole("heading", { name: /request timed out/i }), - ).toBeVisible(); - expect( - canvas.getByText(/anthropic is temporarily unavailable/i), - ).toBeVisible(); - // Guard against the pre-fix generic fallback. - expect( - canvas.queryByText(/the chat request failed unexpectedly/i), - ).not.toBeInTheDocument(); - }, -}; - -/** Transport timeout with an unknown provider uses the generic subject. */ -export const TerminalTimeoutErrorUnknownProvider: Story = { - args: { - ...defaultArgs, - liveStatus: buildLiveStatus({ - streamError: { - kind: "timeout", - message: "The AI provider is temporarily unavailable.", - retryable: false, - }, - }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect( - canvas.getByRole("heading", { name: /request timed out/i }), - ).toBeVisible(); - expect( - canvas.getByText(/the ai provider is temporarily unavailable/i), - ).toBeVisible(); - }, -}; - -/** Missing API key shows the "Chat interrupted" terminal error. */ -export const TerminalMissingKeyError: Story = { - args: { - ...defaultArgs, - liveStatus: buildLiveStatus({ - streamError: { - kind: "missing_key", - message: - "This conversation was started with an API key that is no longer available. Send your message again to continue.", - retryable: false, - detail: - "If this error persists after resending, please report it as a bug.", - }, - }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect( - canvas.getByRole("heading", { name: /chat interrupted/i }), - ).toBeVisible(); - expect( - canvas.getByText( - /this conversation was started with an api key that is no longer available/i, - ), - ).toBeVisible(); - expect( - canvas.getByText(/if this error persists after resending/i), - ).toBeVisible(); - // Guard against the generic fallback. - expect( - canvas.queryByText(/the chat request failed unexpectedly/i), - ).not.toBeInTheDocument(); - }, -}; - -/** Terminal stream-silence timeouts get a specific heading without provider metadata. */ -export const TerminalStreamSilenceTimeoutError: Story = { - args: { - ...defaultArgs, - liveStatus: buildLiveStatus({ - persistedError: { - kind: "stream_silence_timeout", - message: "Anthropic did not send response data in time.", - provider: "anthropic", - retryable: true, - }, - }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect( - canvas.getByRole("heading", { name: /response stalled/i }), - ).toBeVisible(); - expect( - canvas.getByText(/anthropic did not send response data in time./i), - ).toBeVisible(); - expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument(); - expect(canvas.queryByText(/^retryable$/i)).not.toBeInTheDocument(); - expect( - canvas.queryByRole("link", { name: /status/i }), - ).not.toBeInTheDocument(); - expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument(); - }, -}; - -/** Disabled provider errors render an admin-oriented message without retry. */ -export const TerminalProviderDisabledError: Story = { - args: { - ...defaultArgs, - liveStatus: buildLiveStatus({ - streamError: { - kind: "provider_disabled", - message: - "The OpenAI provider has been disabled. Contact your Coder administrator.", - provider: "openai", - retryable: false, - statusCode: 503, - }, - }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect( - canvas.getByRole("heading", { name: /provider disabled/i }), - ).toBeVisible(); - expect( - canvas.getByText( - /the openai provider has been disabled.*contact your coder administrator/i, - ), - ).toBeVisible(); - expect(canvas.getByText(/^HTTP 503$/)).toBeVisible(); - // No retry or status link for administrative disablement. - expect(canvas.queryByText(/retrying/i)).not.toBeInTheDocument(); - expect( - canvas.queryByRole("link", { name: /status/i }), - ).not.toBeInTheDocument(); - }, -}; - -/** Generic failures do not show usage or provider CTAs. */ -export const GenericErrorDoesNotShowUsageAction: Story = { - args: { - ...defaultArgs, - liveStatus: buildLiveStatus({ - persistedError: { - kind: "generic", - message: "Provider request failed.", - }, - }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect( - canvas.getByRole("heading", { name: /request failed/i }), - ).toBeVisible(); - expect(canvas.getByText(/provider request failed/i)).toBeVisible(); - expect( - canvas.queryByText(/start a conversation with your agent/i), - ).not.toBeInTheDocument(); - expect( - canvas.queryByRole("link", { name: /view usage/i }), - ).not.toBeInTheDocument(); - expect( - canvas.queryByRole("link", { name: /status/i }), - ).not.toBeInTheDocument(); - }, -}; - -/** Provider detail renders in a monospace block for generic errors. */ -export const GenericErrorShowsProviderDetail: Story = { - args: { - ...defaultArgs, - liveStatus: buildLiveStatus({ - streamError: { - kind: "generic", - message: "Anthropic returned an unexpected error.", - detail: - "messages.0.content.1.image.source.base64: image exceeds 5 MB maximum.", - provider: "anthropic", - statusCode: 400, - retryable: false, - }, - }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect( - canvas.getByRole("heading", { name: /request failed/i }), - ).toBeVisible(); - expect( - canvas.getByText(/anthropic returned an unexpected error\./i), - ).toBeVisible(); - expect(canvas.getByText(/^HTTP 400$/)).toBeVisible(); - expect(canvas.getByText(/image exceeds 5 mb maximum/i)).toBeVisible(); - }, -}; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx index 1bcc0ab7325..1f3a414c8b7 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx @@ -1,3 +1,4 @@ +import { MessageScroller } from "@shadcn/react/message-scroller"; import { ChatStatusCallout } from "./ChatStatusCallout"; import type { LiveStatusModel } from "./liveStatusModel"; @@ -7,33 +8,35 @@ interface LiveStreamTailContentProps { } // The live assistant turn renders as a timeline row, so the tail below the -// transcript only carries the empty state and the terminal failure callout. +// transcript only carries the empty state. export const LiveStreamTailContent = ({ isTranscriptEmpty, liveStatus, }: LiveStreamTailContentProps) => { - const terminalStatus = liveStatus.phase === "failed" ? liveStatus : null; - const shouldRenderEmptyState = - isTranscriptEmpty && liveStatus.phase === "idle"; - - if (!shouldRenderEmptyState && !terminalStatus) { + if (!isTranscriptEmpty || liveStatus.phase !== "idle") { return null; } return ( -
- {shouldRenderEmptyState && ( -
-

Start a conversation with your agent.

-
- )} - {terminalStatus && } +
+

Start a conversation with your agent.

); }; + +// Terminal failures render as a transcript row so a long error scrolls with +// the conversation instead of squeezing the composer out of the panel. +export const TerminalStatusRow = ({ + liveStatus, +}: { + liveStatus: LiveStatusModel; +}) => { + if (liveStatus.phase !== "failed") { + return null; + } + return ( + + + + ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/TerminalStatusRow.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/TerminalStatusRow.stories.tsx new file mode 100644 index 00000000000..d9a16d9c3c5 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatConversation/TerminalStatusRow.stories.tsx @@ -0,0 +1,353 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { ChatMessageScroller } from "../ChatMessageScroller"; +import { TerminalStatusRow } from "./LiveStreamTail"; +import { buildLiveStatus, pinFixtureClock } from "./storyFixtures"; + +const meta: Meta = { + title: "pages/AgentsPage/ChatConversation/TerminalStatusRow", + component: TerminalStatusRow, + beforeEach: pinFixtureClock, + decorators: [ + (Story) => ( +
+ {}} + > + + +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const UsageLimitExceeded: Story = { + args: { + liveStatus: buildLiveStatus({ + persistedError: { + kind: "usage_limit", + message: "Your AI spend budget has been reached.", + }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /usage limit reached/i }), + ).toBeVisible(); + expect(canvas.getByText(/ai spend budget has been reached/i)).toBeVisible(); + expect( + canvas.queryByRole("link", { name: /view usage/i }), + ).not.toBeInTheDocument(); + }, +}; + +export const ProviderQuotaExceeded: Story = { + args: { + liveStatus: buildLiveStatus({ + streamError: { + kind: "usage_limit", + message: + "The usage quota for OpenAI has been exceeded. Check the billing and quota settings for the provider account.", + provider: "openai", + retryable: false, + }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByText(/usage quota for openai has been exceeded/i), + ).toBeVisible(); + expect( + canvas.queryByRole("link", { name: /view usage/i }), + ).not.toBeInTheDocument(); + expect( + canvas.getByRole("heading", { name: /usage limit reached/i }), + ).toBeVisible(); + }, +}; + +/** Provider failures keep the footer-level terminal callout and status link. */ + +export const TerminalOverloadedError: Story = { + args: { + liveStatus: buildLiveStatus({ + persistedError: { + kind: "overloaded", + message: "Anthropic is temporarily overloaded.", + provider: "anthropic", + retryable: true, + statusCode: 529, + }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /service overloaded/i }), + ).toBeVisible(); + expect( + canvas.getByText(/anthropic is temporarily overloaded\./i), + ).toBeVisible(); + expect(canvas.getByText(/^HTTP 529$/)).toBeVisible(); + expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument(); + expect(canvas.queryByText(/^retryable$/i)).not.toBeInTheDocument(); + expect(canvas.getByRole("link", { name: /status/i })).toBeVisible(); + expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument(); + }, +}; + +/** Content-filter refusals render as terminal errors without a retry countdown or status link. */ + +export const TerminalContentFilterError: Story = { + args: { + liveStatus: buildLiveStatus({ + persistedError: { + kind: "content_filter", + message: + "Anthropic blocked this response under its content policy (cyber).", + detail: + "This request triggered restrictions on violative cyber content and was blocked under Anthropic's Usage Policy. To learn more, see https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback.", + provider: "anthropic", + retryable: false, + }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /response blocked/i }), + ).toBeVisible(); + expect( + canvas.getByText( + /anthropic blocked this response under its content policy \(cyber\)\./i, + ), + ).toBeVisible(); + expect( + canvas.getByText(/this request triggered restrictions/i), + ).toBeVisible(); + expect(canvas.queryByText(/retrying in/i)).not.toBeInTheDocument(); + expect( + canvas.queryByRole("link", { name: /status/i }), + ).not.toBeInTheDocument(); + }, +}; + +/** + * Transport timeouts render the per-provider "temporarily + * unavailable" copy with a "Request timed out" heading rather than + * the generic "Request failed" fallback. + */ + +export const TerminalTimeoutErrorAnthropic: Story = { + args: { + liveStatus: buildLiveStatus({ + streamError: { + kind: "timeout", + message: "Anthropic is temporarily unavailable.", + provider: "anthropic", + retryable: false, + }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /request timed out/i }), + ).toBeVisible(); + expect( + canvas.getByText(/anthropic is temporarily unavailable/i), + ).toBeVisible(); + // Guard against the pre-fix generic fallback. + expect( + canvas.queryByText(/the chat request failed unexpectedly/i), + ).not.toBeInTheDocument(); + }, +}; + +/** Transport timeout with an unknown provider uses the generic subject. */ + +export const TerminalTimeoutErrorUnknownProvider: Story = { + args: { + liveStatus: buildLiveStatus({ + streamError: { + kind: "timeout", + message: "The AI provider is temporarily unavailable.", + retryable: false, + }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /request timed out/i }), + ).toBeVisible(); + expect( + canvas.getByText(/the ai provider is temporarily unavailable/i), + ).toBeVisible(); + }, +}; + +/** Missing API key shows the "Chat interrupted" terminal error. */ + +export const TerminalMissingKeyError: Story = { + args: { + liveStatus: buildLiveStatus({ + streamError: { + kind: "missing_key", + message: + "This conversation was started with an API key that is no longer available. Send your message again to continue.", + retryable: false, + detail: + "If this error persists after resending, please report it as a bug.", + }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /chat interrupted/i }), + ).toBeVisible(); + expect( + canvas.getByText( + /this conversation was started with an api key that is no longer available/i, + ), + ).toBeVisible(); + expect( + canvas.getByText(/if this error persists after resending/i), + ).toBeVisible(); + // Guard against the generic fallback. + expect( + canvas.queryByText(/the chat request failed unexpectedly/i), + ).not.toBeInTheDocument(); + }, +}; + +/** Terminal stream-silence timeouts get a specific heading without provider metadata. */ + +export const TerminalStreamSilenceTimeoutError: Story = { + args: { + liveStatus: buildLiveStatus({ + persistedError: { + kind: "stream_silence_timeout", + message: "Anthropic did not send response data in time.", + provider: "anthropic", + retryable: true, + }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /response stalled/i }), + ).toBeVisible(); + expect( + canvas.getByText(/anthropic did not send response data in time./i), + ).toBeVisible(); + expect(canvas.queryByText(/please try again/i)).not.toBeInTheDocument(); + expect(canvas.queryByText(/^retryable$/i)).not.toBeInTheDocument(); + expect( + canvas.queryByRole("link", { name: /status/i }), + ).not.toBeInTheDocument(); + expect(canvas.queryByText(/provider anthropic/i)).not.toBeInTheDocument(); + }, +}; + +/** Disabled provider errors render an admin-oriented message without retry. */ + +export const TerminalProviderDisabledError: Story = { + args: { + liveStatus: buildLiveStatus({ + streamError: { + kind: "provider_disabled", + message: + "The OpenAI provider has been disabled. Contact your Coder administrator.", + provider: "openai", + retryable: false, + statusCode: 503, + }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /provider disabled/i }), + ).toBeVisible(); + expect( + canvas.getByText( + /the openai provider has been disabled.*contact your coder administrator/i, + ), + ).toBeVisible(); + expect(canvas.getByText(/^HTTP 503$/)).toBeVisible(); + // No retry or status link for administrative disablement. + expect(canvas.queryByText(/retrying/i)).not.toBeInTheDocument(); + expect( + canvas.queryByRole("link", { name: /status/i }), + ).not.toBeInTheDocument(); + }, +}; + +/** Generic failures do not show usage or provider CTAs. */ + +export const GenericErrorDoesNotShowUsageAction: Story = { + args: { + liveStatus: buildLiveStatus({ + persistedError: { + kind: "generic", + message: "Provider request failed.", + }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /request failed/i }), + ).toBeVisible(); + expect(canvas.getByText(/provider request failed/i)).toBeVisible(); + expect( + canvas.queryByText(/start a conversation with your agent/i), + ).not.toBeInTheDocument(); + expect( + canvas.queryByRole("link", { name: /view usage/i }), + ).not.toBeInTheDocument(); + expect( + canvas.queryByRole("link", { name: /status/i }), + ).not.toBeInTheDocument(); + }, +}; + +/** Provider detail renders in a monospace block for generic errors. */ + +export const GenericErrorShowsProviderDetail: Story = { + args: { + liveStatus: buildLiveStatus({ + streamError: { + kind: "generic", + message: "Anthropic returned an unexpected error.", + detail: + "messages.0.content.1.image.source.base64: image exceeds 5 MB maximum.", + provider: "anthropic", + statusCode: 400, + retryable: false, + }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /request failed/i }), + ).toBeVisible(); + expect( + canvas.getByText(/anthropic returned an unexpected error\./i), + ).toBeVisible(); + expect(canvas.getByText(/^HTTP 400$/)).toBeVisible(); + expect(canvas.getByText(/image exceeds 5 mb maximum/i)).toBeVisible(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 47877a4f446..f0490810c86 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -37,7 +37,10 @@ import { useChatSelector, type useChatStore, } from "./ChatConversation/chatStore"; -import { LiveStreamTailContent } from "./ChatConversation/LiveStreamTail"; +import { + LiveStreamTailContent, + TerminalStatusRow, +} from "./ChatConversation/LiveStreamTail"; import { deriveLiveStatus } from "./ChatConversation/liveStatusModel"; import { buildDisplayMessages } from "./ChatConversation/messageHelpers"; import { @@ -208,9 +211,10 @@ export const ChatPageTimeline: FC = ({ mcpServers={mcpServers} showDesktopPreviews={false} /> + - {/* The empty state and the terminal failure callout sit outside the - scroller content, which holds transcript rows only. */} + {/* The empty state sits outside the scroller content, which holds + transcript rows only. */}
Date: Thu, 13 Aug 2026 19:55:06 +0000 Subject: [PATCH 07/11] docs(site): document the merged read_file key append tradeoff --- .../components/ChatConversation/messageHelpers.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts index 4c75f235c0c..d45ee594060 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts @@ -216,7 +216,11 @@ const mergeReadFileMessageGroup = ( // A merged group's row key cannot come from its first member: prepending // history into the group changes it. Key off the newest member instead, which -// pagination never changes for an existing group. +// pagination never changes for an existing group. The tradeoff: a live turn +// that keeps appending reads to the tail group changes the key and remounts +// the row, collapsing its expansion state. No client-side key is stable in +// both directions; prepend stability wins because scroll preservation +// depends on it. export const getDisplayMessageKey = (entry: ParsedMessageEntry): string => { if (entry.mergedFrom === undefined) { return `message:${entry.message.id}`; From 0d030d3245c66daa80430d5391728eabc0c1331a Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 13 Aug 2026 20:20:31 +0000 Subject: [PATCH 08/11] fix(site): return to the live edge on submit and stabilize the live row key --- site/src/pages/AgentsPage/AgentChatPage.tsx | 5 ++++ .../AgentsPage/AgentChatPageView.stories.tsx | 4 +-- .../pages/AgentsPage/AgentChatPageView.tsx | 4 +++ .../ChatConversation/timelineRows.test.ts | 17 +++-------- .../ChatConversation/timelineRows.ts | 29 +++---------------- .../components/ChatMessageScroller.tsx | 21 +++++++++++++- .../AgentsPage/components/ChatPageContent.tsx | 3 ++ 7 files changed, 42 insertions(+), 41 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 6f9f75bd15e..066c187bf93 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1229,6 +1229,7 @@ const AgentChatPage: FC = () => { }; const aiGatewayDisabled = !useAIGatewayEnabled(); + const [liveEdgeSignal, setLiveEdgeSignal] = useState(0); const { store, acceptServerChatStatus, @@ -1616,6 +1617,9 @@ const AgentChatPage: FC = () => { if (!hasContent || isSubmissionPending || !agentId || !hasModelOptions) { return; } + // Every accepted submission (send, edit, /compact) is an explicit ask + // to be at the live edge, even one that appends no visible prompt. + setLiveEdgeSignal((signal) => signal + 1); // Wait for chat-setting mutations to settle before sending so the // message observes the workspace and plan-mode choices the user just made. await waitForPendingChatSettingsSyncs([ @@ -1959,6 +1963,7 @@ const AgentChatPage: FC = () => { workspaceAgent={workspaceAgent} chatBuildId={chatQuery.data?.build_id} store={store} + liveEdgeSignal={liveEdgeSignal} editing={{ ...editing, handleEditUserMessage }} effectiveSelectedModel={effectiveSelectedModel} setSelectedModel={setSelectedModel} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index 9a5ffce7e36..5a2c5a0e363 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -1183,10 +1183,10 @@ export const StreamCompletionKeepsViewportPosition: Story = { { type: "text", text: "Streaming the final answer." }, ]); }); - // The live assistant row mounts under its ephemeral turn-local key. + // The live assistant row mounts under its ephemeral key. await waitFor(() => { expect( - canvas.getByTestId("chat-message-message:41:assistant:0"), + canvas.getByTestId("chat-message-live-assistant"), ).toBeInTheDocument(); }); await settleScroller(); diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 1052aafcef6..f017f6d5797 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -129,6 +129,8 @@ interface AgentChatPageViewProps { // Store handle. store: ChatStoreHandle; + /** Incremented on each accepted submission to return to the live edge. */ + liveEdgeSignal?: number; // Editing state. editing: EditingState; @@ -324,6 +326,7 @@ export const AgentChatPageView: FC = ({ workspace, chatBuildId, store, + liveEdgeSignal, editing, effectiveSelectedModel, setSelectedModel, @@ -906,6 +909,7 @@ export const AgentChatPageView: FC = ({ { expect(keys(after)).toEqual(["message:1", "message:2"]); }); - it("uses a separate slot for the live assistant", () => { - const rows = assignTimelineRows([durable(1, "user", "prompt")], true); - - expect(keys(rows)).toEqual(["message:1", "message:1:assistant:0"]); - }); - - it("advances the live assistant slot past durable assistants in the turn", () => { - const rows = assignTimelineRows( + it("keys the live row independently of the loaded history", () => { + const withPrompt = assignTimelineRows( [ durable(1, "user", "prompt"), durable(2, "assistant", "first"), @@ -108,15 +102,12 @@ describe("assignTimelineRows", () => { true, ); - expect(keys(rows)).toEqual([ + expect(keys(withPrompt)).toEqual([ "message:1", "message:2", "message:3", - "message:1:assistant:2", + "live-assistant", ]); - }); - - it("falls back to a live key when no durable user turn exists", () => { expect(keys(assignTimelineRows([], true))).toEqual(["live-assistant"]); }); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts b/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts index 2593d8c1bdc..5a701122dd2 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts @@ -11,36 +11,23 @@ type TimelineMessageRow = { type TimelineRow = TimelineMessageRow | { type: "live"; key: string }; -const assistantSlotKey = (turnKey: string, slot: number): string => - `${turnKey}:assistant:${slot}`; - /** * Durable rows keep their server IDs so prepending history never changes an * existing Item's identity. Merged read_file groups key off their newest - * member, which pagination never changes. The live assistant uses a - * turn-local slot until its durable message arrives. + * member, which pagination never changes. The live row is keyed independently + * of which history pages are loaded. */ export const assignTimelineRows = ( displayMessages: readonly ParsedMessageEntry[], hasLiveAssistant: boolean, ): readonly TimelineRow[] => { const rows: TimelineMessageRow[] = []; - let turnKey: string | undefined; - let assistantsInTurn = 0; for (const [index, entry] of displayMessages.entries()) { - const { message } = entry; - const key = getDisplayMessageKey(entry); - if (message.role === "user") { - turnKey = key; - assistantsInTurn = 0; - } else if (message.role === "assistant" && turnKey) { - assistantsInTurn += 1; - } rows.push({ type: "message", entry, - key, + key: getDisplayMessageKey(entry), isLastInAssistantChain: false, isLastMessage: index === displayMessages.length - 1, }); @@ -65,13 +52,5 @@ export const assignTimelineRows = ( if (!hasLiveAssistant) { return rows; } - return [ - ...rows, - { - type: "live", - key: turnKey - ? assistantSlotKey(turnKey, assistantsInTurn) - : "live-assistant", - }, - ]; + return [...rows, { type: "live", key: "live-assistant" }]; }; diff --git a/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx b/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx index 7ca6a5fb516..0c6c3b5ee74 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx @@ -1,10 +1,11 @@ import { MessageScroller, + useMessageScroller, useMessageScrollerScrollable, useMessageScrollerVisibility, } from "@shadcn/react/message-scroller"; import { ArrowDownIcon, RotateCcwIcon } from "lucide-react"; -import { type FC, type ReactNode, useEffect } from "react"; +import { type FC, type ReactNode, useEffect, useState } from "react"; import { Button } from "#/components/Button/Button"; import { Spinner } from "#/components/Spinner/Spinner"; import { cn } from "#/utils/cn"; @@ -92,10 +93,27 @@ const EarlierMessages: FC = ({ interface ChatMessageScrollerProps extends EarlierMessagesProps { /** One `MessageScroller.Item` per transcript row, and nothing else. */ children: ReactNode; + /** Increment to return the reader to the live edge. */ + liveEdgeSignal?: number; } +// Submitting a turn is an explicit ask to be at the live edge, so this calls +// the scroller's own command when the signal changes. useState with a +// render-time adjustment, per React's pattern for reacting to prop changes; +// not an effect. +const LiveEdgeFollower: FC<{ signal?: number }> = ({ signal }) => { + const { scrollToEnd } = useMessageScroller(); + const [followed, setFollowed] = useState(signal); + if (signal !== followed) { + setFollowed(signal); + scrollToEnd({ behavior: "smooth" }); + } + return null; +}; + export const ChatMessageScroller: FC = ({ children, + liveEdgeSignal, ...earlierMessages }) => { const [chatFullWidth] = useChatFullWidth(); @@ -131,6 +149,7 @@ export const ChatMessageScroller: FC = ({ + ); diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index f0490810c86..225c0091107 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -90,6 +90,7 @@ export const workspaceSkillsFromChat = ( interface ChatPageTimelineProps { store: ChatStoreHandle; persistedError: ChatDetailError | undefined; + liveEdgeSignal?: number; hasMoreMessages: boolean; isFetchingMoreMessages: boolean; hasFetchMoreError: boolean; @@ -109,6 +110,7 @@ interface ChatPageTimelineProps { export const ChatPageTimeline: FC = ({ store, persistedError, + liveEdgeSignal, hasMoreMessages, isFetchingMoreMessages, hasFetchMoreError, @@ -186,6 +188,7 @@ export const ChatPageTimeline: FC = ({ hasFetchMoreError={hasFetchMoreError} hasFilteredOutRows={hasFilteredOutRows} onFetchMoreMessages={onFetchMoreMessages} + liveEdgeSignal={liveEdgeSignal} > {/* VNC sessions for completed agents may already be terminated, so inline desktop previews are disabled From 2d716ead36c556b524f6b9a2dd426e5924edc2ff Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 13 Aug 2026 20:43:34 +0000 Subject: [PATCH 09/11] fix(site): hoist the scroller provider and scroll to the live edge on submit --- site/src/pages/AgentsPage/AgentChatPage.tsx | 20 +++-- .../AgentsPage/AgentChatPageView.stories.tsx | 12 ++- .../pages/AgentsPage/AgentChatPageView.tsx | 4 - .../ConversationTimeline.stories.tsx | 21 ++--- .../TerminalStatusRow.stories.tsx | 21 ++--- .../components/ChatMessageScroller.tsx | 79 +++++++------------ .../components/ChatPageContent.stories.tsx | 19 +++-- .../AgentsPage/components/ChatPageContent.tsx | 3 - 8 files changed, 89 insertions(+), 90 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 066c187bf93..842ed7f3ec9 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1,3 +1,7 @@ +import { + MessageScroller, + useMessageScroller, +} from "@shadcn/react/message-scroller"; import { type FC, useEffect, @@ -1229,7 +1233,7 @@ const AgentChatPage: FC = () => { }; const aiGatewayDisabled = !useAIGatewayEnabled(); - const [liveEdgeSignal, setLiveEdgeSignal] = useState(0); + const { scrollToEnd } = useMessageScroller(); const { store, acceptServerChatStatus, @@ -1617,9 +1621,6 @@ const AgentChatPage: FC = () => { if (!hasContent || isSubmissionPending || !agentId || !hasModelOptions) { return; } - // Every accepted submission (send, edit, /compact) is an explicit ask - // to be at the live edge, even one that appends no visible prompt. - setLiveEdgeSignal((signal) => signal + 1); // Wait for chat-setting mutations to settle before sending so the // message observes the workspace and plan-mode choices the user just made. await waitForPendingChatSettingsSyncs([ @@ -1627,6 +1628,10 @@ const AgentChatPage: FC = () => { pendingWorkspaceSyncRef.current, ]); + // Every accepted submission (send, edit, /compact) is an explicit ask + // to be at the live edge, even one that appends no visible prompt. + scrollToEnd({ behavior: "smooth" }); + // "/compact" on its own (no attachments or file references) // requests a manual context compaction instead of sending a // message. Only new sends are intercepted; history and queued @@ -1963,7 +1968,6 @@ const AgentChatPage: FC = () => { workspaceAgent={workspaceAgent} chatBuildId={chatQuery.data?.build_id} store={store} - liveEdgeSignal={liveEdgeSignal} editing={{ ...editing, handleEditUserMessage }} effectiveSelectedModel={effectiveSelectedModel} setSelectedModel={setSelectedModel} @@ -2046,7 +2050,11 @@ const AgentChatPage: FC = () => { // internal state (drafts, editing, queries) cleanly. const KeyedAgentChatPage: FC = () => { const { agentId } = useParams<{ agentId: string }>(); - return ; + return ( + + + + ); }; export default KeyedAgentChatPage; diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index 5a2c5a0e363..996ca0976dc 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -1,3 +1,4 @@ +import { MessageScroller } from "@shadcn/react/message-scroller"; import type { Decorator, Meta, StoryObj } from "@storybook/react-vite"; import { type ComponentProps, type FC, useRef, useState } from "react"; import { @@ -201,7 +202,16 @@ const meta: Meta = { beforeEach: () => { spyOn(API.experimental, "getChat").mockResolvedValue(buildChat()); }, - decorators: [withAuthProvider, withDashboardProvider, withProxyProvider()], + decorators: [ + (Story) => ( + + + + ), + withAuthProvider, + withDashboardProvider, + withProxyProvider(), + ], parameters: { layout: "fullscreen", user: MockUserOwner, diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index f017f6d5797..1052aafcef6 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -129,8 +129,6 @@ interface AgentChatPageViewProps { // Store handle. store: ChatStoreHandle; - /** Incremented on each accepted submission to return to the live edge. */ - liveEdgeSignal?: number; // Editing state. editing: EditingState; @@ -326,7 +324,6 @@ export const AgentChatPageView: FC = ({ workspace, chatBuildId, store, - liveEdgeSignal, editing, effectiveSelectedModel, setSelectedModel, @@ -909,7 +906,6 @@ export const AgentChatPageView: FC = ({ { : undefined; return (
- {}} - > - - + + {}} + > + + +
); }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/TerminalStatusRow.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/TerminalStatusRow.stories.tsx index d9a16d9c3c5..90310758692 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/TerminalStatusRow.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/TerminalStatusRow.stories.tsx @@ -1,3 +1,4 @@ +import { MessageScroller } from "@shadcn/react/message-scroller"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, within } from "storybook/test"; import { ChatMessageScroller } from "../ChatMessageScroller"; @@ -11,15 +12,17 @@ const meta: Meta = { decorators: [ (Story) => (
- {}} - > - - + + {}} + > + + +
), ], diff --git a/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx b/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx index 0c6c3b5ee74..48449fc6064 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageScroller.tsx @@ -1,11 +1,10 @@ import { MessageScroller, - useMessageScroller, useMessageScrollerScrollable, useMessageScrollerVisibility, } from "@shadcn/react/message-scroller"; import { ArrowDownIcon, RotateCcwIcon } from "lucide-react"; -import { type FC, type ReactNode, useEffect, useState } from "react"; +import { type FC, type ReactNode, useEffect } from "react"; import { Button } from "#/components/Button/Button"; import { Spinner } from "#/components/Spinner/Spinner"; import { cn } from "#/utils/cn"; @@ -93,64 +92,44 @@ const EarlierMessages: FC = ({ interface ChatMessageScrollerProps extends EarlierMessagesProps { /** One `MessageScroller.Item` per transcript row, and nothing else. */ children: ReactNode; - /** Increment to return the reader to the live edge. */ - liveEdgeSignal?: number; } -// Submitting a turn is an explicit ask to be at the live edge, so this calls -// the scroller's own command when the signal changes. useState with a -// render-time adjustment, per React's pattern for reacting to prop changes; -// not an effect. -const LiveEdgeFollower: FC<{ signal?: number }> = ({ signal }) => { - const { scrollToEnd } = useMessageScroller(); - const [followed, setFollowed] = useState(signal); - if (signal !== followed) { - setFollowed(signal); - scrollToEnd({ behavior: "smooth" }); - } - return null; -}; - export const ChatMessageScroller: FC = ({ children, - liveEdgeSignal, ...earlierMessages }) => { const [chatFullWidth] = useChatFullWidth(); return ( - - - - - {children} - - - - - } + + + - - + {children} + + + + + } + > + + - - - - + + ); }; diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx index ba72eedcd7e..6bd7bd6c92a 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -1,3 +1,4 @@ +import { MessageScroller } from "@shadcn/react/message-scroller"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { FC } from "react"; import { expect, within } from "storybook/test"; @@ -11,14 +12,16 @@ import { ChatPageTimeline } from "./ChatPageContent"; const StoryChatPageTimeline: FC<{ store: ReturnType; }> = ({ store }) => ( - {}} - /> + + {}} + /> + ); const meta = { diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 225c0091107..f0490810c86 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -90,7 +90,6 @@ export const workspaceSkillsFromChat = ( interface ChatPageTimelineProps { store: ChatStoreHandle; persistedError: ChatDetailError | undefined; - liveEdgeSignal?: number; hasMoreMessages: boolean; isFetchingMoreMessages: boolean; hasFetchMoreError: boolean; @@ -110,7 +109,6 @@ interface ChatPageTimelineProps { export const ChatPageTimeline: FC = ({ store, persistedError, - liveEdgeSignal, hasMoreMessages, isFetchingMoreMessages, hasFetchMoreError, @@ -188,7 +186,6 @@ export const ChatPageTimeline: FC = ({ hasFetchMoreError={hasFetchMoreError} hasFilteredOutRows={hasFilteredOutRows} onFetchMoreMessages={onFetchMoreMessages} - liveEdgeSignal={liveEdgeSignal} > {/* VNC sessions for completed agents may already be terminated, so inline desktop previews are disabled From e6971ccd2e938ddae9264061193d470c11fda1ec Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 13 Aug 2026 20:56:34 +0000 Subject: [PATCH 10/11] fix(site): key the scroller provider by chat and scroll after compact acceptance --- site/src/pages/AgentsPage/AgentChatPage.tsx | 23 +++++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 842ed7f3ec9..3ab72d4f52d 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1628,10 +1628,6 @@ const AgentChatPage: FC = () => { pendingWorkspaceSyncRef.current, ]); - // Every accepted submission (send, edit, /compact) is an explicit ask - // to be at the live edge, even one that appends no visible prompt. - scrollToEnd({ behavior: "smooth" }); - // "/compact" on its own (no attachments or file references) // requests a manual context compaction instead of sending a // message. Only new sends are intercepted; history and queued @@ -1650,6 +1646,11 @@ const AgentChatPage: FC = () => { ); throw new CompactCommandPendingError(); } + + // Every accepted submission (send, edit, /compact) is an explicit ask + // to be at the live edge, even one that appends no visible prompt. + scrollToEnd({ behavior: "smooth" }); + if (isExactCompactSubmission && compactCommandResolution === "available") { // Optimistically show the running state before awaiting so // a fast compaction cannot race this write: the worker's @@ -2045,14 +2046,18 @@ const AgentChatPage: FC = () => { ); }; -// Keyed wrapper so that navigating between agents (changing the -// :agentId param) fully remounts the component, resetting all -// internal state (drafts, editing, queries) cleanly. +// Keyed so that navigating between agents (changing the :agentId param) +// fully remounts the component, resetting all internal state (drafts, +// editing, queries, scroller) cleanly. const KeyedAgentChatPage: FC = () => { const { agentId } = useParams<{ agentId: string }>(); return ( - - + + ); }; From 2cd3df491f4b6eeac0e274c7c411b6890daaad54 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 13 Aug 2026 21:14:38 +0000 Subject: [PATCH 11/11] fix(site): scroll to the live edge only after a history edit succeeds --- site/src/pages/AgentsPage/AgentChatPage.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 3ab72d4f52d..30fca63909d 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1647,9 +1647,13 @@ const AgentChatPage: FC = () => { throw new CompactCommandPendingError(); } - // Every accepted submission (send, edit, /compact) is an explicit ask - // to be at the live edge, even one that appends no visible prompt. - scrollToEnd({ behavior: "smooth" }); + // Sends and /compact are an explicit ask to be at the live edge, + // even one that appends no visible prompt. History edits scroll + // only after the mutation succeeds, so a rejected edit cannot + // pull a reader of older history to the live edge. + if (editedMessageID === undefined) { + scrollToEnd({ behavior: "smooth" }); + } if (isExactCompactSubmission && compactCommandResolution === "available") { // Optimistically show the running state before awaiting so @@ -1728,6 +1732,7 @@ const AgentChatPage: FC = () => { void invalidateChatEntity(queryClient, agentId); }, }); + scrollToEnd({ behavior: "smooth" }); if (editSelectedModelConfigID) { localStorage.setItem( lastModelConfigIDStorageKey,