From 8eab2a520f137dfb3f478a406fc444887d07ac5b Mon Sep 17 00:00:00 2001 From: Sullaimon Date: Sat, 4 Jul 2026 08:05:49 -0400 Subject: [PATCH] Stabilize chat scroll anchoring after send --- apps/web/src/components/ChatView.tsx | 52 ++++++++++++++++--- .../chat/MessagesTimeline.browser.tsx | 50 +++++++++++++++++- .../components/chat/MessagesTimeline.test.tsx | 1 + .../src/components/chat/MessagesTimeline.tsx | 52 ++++++++++++++++++- packages/shared/package.json | 4 ++ packages/shared/src/chatList.test.ts | 33 ++++++++++++ packages/shared/src/chatList.ts | 33 ++++++++++++ 7 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 packages/shared/src/chatList.test.ts create mode 100644 packages/shared/src/chatList.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0da130dd18b..4a629d19e37 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1026,6 +1026,20 @@ export default function ChatView(props: ChatViewProps) { [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const [timelineAnchor, setTimelineAnchor] = useState<{ + readonly threadKey: string | null; + readonly messageId: MessageId | null; + }>({ threadKey: activeThreadKey, messageId: null }); + const timelineAnchorMessageId = + timelineAnchor.threadKey === activeThreadKey ? timelineAnchor.messageId : null; + + useEffect(() => { + setTimelineAnchor((existing) => + existing.threadKey === activeThreadKey + ? existing + : { threadKey: activeThreadKey, messageId: null }, + ); + }, [activeThreadKey]); useEffect(() => { if (!activeThreadRef) { @@ -1593,6 +1607,25 @@ export default function ChatView(props: ChatViewProps) { ); const isSendBusy = isLocalDispatchBusy || isWorkspaceSwitching; const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + useEffect(() => { + if (!timelineAnchorMessageId) { + return; + } + if (isWorking || !latestTurnSettled || optimisticUserMessages.length > 0) { + return; + } + setTimelineAnchor((existing) => + existing.threadKey === activeThreadKey && existing.messageId === timelineAnchorMessageId + ? { threadKey: activeThreadKey, messageId: null } + : existing, + ); + }, [ + activeThreadKey, + isWorking, + latestTurnSettled, + optimisticUserMessages.length, + timelineAnchorMessageId, + ]); const canQueueMessages = isServerThread && !isConnecting && !activeEnvironmentUnavailable && !activePendingProgress; @@ -3088,13 +3121,16 @@ export default function ChatView(props: ChatViewProps) { sizeBytes: file.sizeBytes, })), ]; - // Scroll to the current end *before* adding the optimistic message. - // This sets LegendList's internal isAtEnd=true so maintainScrollAtEnd - // automatically pins to the new item when the data changes. + // Sending always returns to the live edge. Once the optimistic row is + // rendered, MessagesTimeline positions that sent message near the top and + // leaves room for the response to stream underneath it. isAtEndRef.current = true; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - await legendListRef.current?.scrollToEnd?.({ animated: false }); + setTimelineAnchor({ + threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), + messageId: messageIdForSend, + }); setOptimisticUserMessages((existing) => [ ...existing, @@ -3542,11 +3578,14 @@ export default function ChatView(props: ChatViewProps) { beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); - // Scroll to the current end *before* adding the optimistic message. + // Position the sent row once the optimistic message has rendered. isAtEndRef.current = true; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - await legendListRef.current?.scrollToEnd?.({ animated: false }); + setTimelineAnchor({ + threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), + messageId: messageIdForSend, + }); setOptimisticUserMessages((existing) => [ ...existing, @@ -4017,6 +4056,7 @@ export default function ChatView(props: ChatViewProps) { timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} + anchorMessageId={timelineAnchorMessageId} onIsAtEndChange={onIsAtEndChange} /> diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx index c7aa3ef1f1d..56a310852ce 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.tsx @@ -1,6 +1,6 @@ import "../../index.css"; -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId } from "@t3tools/contracts"; import { createRef } from "react"; import type { LegendListRef } from "@legendapp/list/react"; import { page } from "vite-plus/test/browser"; @@ -8,7 +8,9 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { render } from "vitest-browser-react"; const scrollToEndSpy = vi.fn(); +const scrollToIndexSpy = vi.fn(); const getStateSpy = vi.fn(() => ({ isAtEnd: true })); +let lastLegendListProps: { maintainScrollAtEnd?: boolean } | null = null; vi.mock("@legendapp/list/react", async () => { const React = await import("react"); @@ -19,13 +21,16 @@ vi.mock("@legendapp/list/react", async () => { renderItem: (args: { item: { id: string } }) => React.ReactNode; ListHeaderComponent?: React.ReactNode; ListFooterComponent?: React.ReactNode; + maintainScrollAtEnd?: boolean; ref?: React.Ref; }) { + lastLegendListProps = props; React.useImperativeHandle( props.ref, () => ({ scrollToEnd: scrollToEndSpy, + scrollToIndex: scrollToIndexSpy, getState: getStateSpy, }) as unknown as LegendListRef, ); @@ -69,6 +74,7 @@ function buildProps() { resolvedTheme: "dark" as const, timestampFormat: "24-hour" as const, workspaceRoot: undefined, + anchorMessageId: null, onIsAtEndChange: vi.fn(), }; } @@ -97,7 +103,9 @@ function buildUserTimelineEntry(text: string) { describe("MessagesTimeline", () => { afterEach(() => { scrollToEndSpy.mockReset(); + scrollToIndexSpy.mockReset(); getStateSpy.mockClear(); + lastLegendListProps = null; vi.restoreAllMocks(); document.body.innerHTML = ""; }); @@ -179,6 +187,46 @@ describe("MessagesTimeline", () => { } }); + it("positions an anchored sent message without maintaining the live edge", async () => { + vi.spyOn(window, "requestAnimationFrame").mockImplementation( + (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + ); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); + + const anchoredMessageId = MessageId.make("message-1"); + const screen = await render( + , + ); + + try { + expect(lastLegendListProps?.maintainScrollAtEnd).toBe(false); + expect(scrollToIndexSpy).toHaveBeenCalledWith({ + index: 0, + animated: true, + viewPosition: 0, + viewOffset: 16, + }); + } finally { + await screen.unmount(); + } + }); + it("starts long user messages collapsed by default", async () => { const screen = await render( {}, }; } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1a49db12fdc..8b4181f1840 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -4,6 +4,7 @@ import { type ServerProviderSkill, type TurnId, } from "@t3tools/contracts"; +import { resolveChatListAnchorPosition } from "@t3tools/shared/chatList"; import { createContext, memo, @@ -108,6 +109,7 @@ const TimelineRowCtx = createContext(null!); const TimelineRowActivityCtx = createContext(null!); const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FOOTER =
; +const TIMELINE_ANCHORED_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; // --------------------------------------------------------------------------- @@ -136,6 +138,7 @@ interface MessagesTimelineProps { timestampFormat: TimestampFormat; workspaceRoot: string | undefined; skills?: ReadonlyArray>; + anchorMessageId: MessageId | null; onIsAtEndChange: (isAtEnd: boolean) => void; } @@ -165,6 +168,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timestampFormat, workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, + anchorMessageId, onIsAtEndChange, }: MessagesTimelineProps) { const rawRows = useMemo( @@ -193,6 +197,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ], ); const rows = useStableRows(rawRows); + const anchorPosition = useMemo( + () => + resolveChatListAnchorPosition(rows, anchorMessageId, (row) => + row.kind === "message" ? row.message.id : null, + ), + [anchorMessageId, rows], + ); + const anchorScrollKey = anchorPosition + ? `${anchorMessageId}:${anchorPosition.anchorIndex}:${anchorPosition.anchorOffset}` + : null; const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); @@ -219,6 +233,36 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }; }, [listRef, onIsAtEndChange, rows.length]); + useEffect(() => { + if (!anchorPosition || !anchorMessageId) { + return; + } + + let cancelled = false; + let secondFrameId: number | null = null; + const firstFrameId = window.requestAnimationFrame(() => { + secondFrameId = window.requestAnimationFrame(() => { + if (cancelled) { + return; + } + void listRef.current?.scrollToIndex?.({ + index: anchorPosition.anchorIndex, + animated: true, + viewPosition: 0, + viewOffset: anchorPosition.anchorOffset, + }); + }); + }); + + return () => { + cancelled = true; + window.cancelAnimationFrame(firstFrameId); + if (secondFrameId !== null) { + window.cancelAnimationFrame(secondFrameId); + } + }; + }, [anchorMessageId, anchorPosition, anchorScrollKey, listRef]); + const sharedState = useMemo( () => ({ timestampFormat, @@ -284,13 +328,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ renderItem={renderItem} estimatedItemSize={90} initialScrollAtEnd - maintainScrollAtEnd + maintainScrollAtEnd={!anchorMessageId} maintainScrollAtEndThreshold={0.1} maintainVisibleContentPosition onScroll={handleScroll} className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5" ListHeaderComponent={TIMELINE_LIST_HEADER} - ListFooterComponent={TIMELINE_LIST_FOOTER} + ListFooterComponent={ + anchorMessageId && (activeTurnInProgress || anchorPosition) + ? TIMELINE_ANCHORED_LIST_FOOTER + : TIMELINE_LIST_FOOTER + } /> diff --git a/packages/shared/package.json b/packages/shared/package.json index b3683b31a93..ccb9bb2b6e1 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -143,6 +143,10 @@ "types": "./src/composerTrigger.ts", "import": "./src/composerTrigger.ts" }, + "./chatList": { + "types": "./src/chatList.ts", + "import": "./src/chatList.ts" + }, "./terminalLabels": { "types": "./src/terminalLabels.ts", "import": "./src/terminalLabels.ts" diff --git a/packages/shared/src/chatList.test.ts b/packages/shared/src/chatList.test.ts new file mode 100644 index 00000000000..af7f2f55883 --- /dev/null +++ b/packages/shared/src/chatList.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchorPosition } from "./chatList.ts"; + +describe("resolveChatListAnchorPosition", () => { + it("returns undefined when no anchor is requested", () => { + expect(resolveChatListAnchorPosition([{ id: "a" }], null, (item) => item.id)).toBeUndefined(); + }); + + it("finds the last matching anchor item", () => { + expect( + resolveChatListAnchorPosition( + [{ id: "sent" }, { id: "assistant" }, { id: "sent" }], + "sent", + (item) => item.id, + ), + ).toEqual({ + anchorIndex: 2, + anchorOffset: CHAT_LIST_ANCHOR_OFFSET, + }); + }); + + it("uses a custom anchor offset", () => { + expect( + resolveChatListAnchorPosition([{ id: "sent" }], "sent", (item) => item.id, { + anchorOffset: 24, + }), + ).toEqual({ + anchorIndex: 0, + anchorOffset: 24, + }); + }); +}); diff --git a/packages/shared/src/chatList.ts b/packages/shared/src/chatList.ts new file mode 100644 index 00000000000..48fe062f88f --- /dev/null +++ b/packages/shared/src/chatList.ts @@ -0,0 +1,33 @@ +export const CHAT_LIST_ANCHOR_OFFSET = 16; + +export interface ChatListAnchorPosition { + readonly anchorIndex: number; + readonly anchorOffset: number; +} + +export interface ChatListAnchorOptions { + readonly anchorOffset?: number; +} + +export function resolveChatListAnchorPosition( + items: ReadonlyArray, + anchorId: AnchorId | null, + getAnchorId: (item: Item) => AnchorId | null, + options: ChatListAnchorOptions = {}, +): ChatListAnchorPosition | undefined { + if (anchorId === null) { + return undefined; + } + + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (item !== undefined && getAnchorId(item) === anchorId) { + return { + anchorIndex: index, + anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET, + }; + } + } + + return undefined; +}