From 85393f5c0ca5790f191b6aac24d15090d4ba015a Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 7 Nov 2026 19:30:12 +0000 Subject: [PATCH 1/4] test(web): drop the one-frame scroll test that the test policy rules out --- .../chat/MessagesTimeline.browser.test.tsx | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 6cb79011a3809..12ca835dc14f7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -723,34 +723,6 @@ describe("MessagesTimeline messages", () => { expect(maxNestedCommits).toBeLessThan(20); }); - it("stays at the end in the frame the viewport shrinks", async () => { - setup(); - const props = baseProps(); - const entries = Array.from({ length: 40 }, (_, index) => - message(`Turn ${index}: ${"x ".repeat(200)}`, {}, `turn-${index}`), - ); - await using screen = await renderElement( - , - ); - const viewport = props.listRef.current!.getScrollableNode()!; - const distanceFromEnd = () => - viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop; - await vi.waitFor(() => { - expect(screen.text()).toContain("Turn 39:"); - expect(distanceFromEnd()).toBeLessThan(2); - expect(props.listRef.current!.getState().isWithinMaintainScrollAtEndThreshold).toBe(true); - }); - const distances: number[] = []; - const resizes = new ResizeObserver(() => distances.push(distanceFromEnd())); - resizes.observe(viewport); - using _resizes = { [Symbol.dispose]: () => resizes.disconnect() }; - await vi.waitFor(() => expect(distances).toHaveLength(1)); - - screen.container.style.height = "880px"; - await vi.waitFor(() => expect(distances).toHaveLength(2)); - expect(distances[1]).toBeLessThan(2); - }); - it("keeps the first and last message headings reachable from either end", async () => { setup(); const props = baseProps(); From dd330d970696680f7a93bd9186a2da31dbdbbab4 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 7 Nov 2026 19:04:13 +0000 Subject: [PATCH 2/4] test(web): drop the copy text and base behavior from the busy send test --- .../chat/composerSubmission.test.ts | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/chat/composerSubmission.test.ts b/apps/web/src/components/chat/composerSubmission.test.ts index 4297f143f7b2c..96ea034f2b3e8 100644 --- a/apps/web/src/components/chat/composerSubmission.test.ts +++ b/apps/web/src/components/chat/composerSubmission.test.ts @@ -60,27 +60,18 @@ describe("submitComposerDraft", () => { expect(validationMessage).toBeNull(); }); - it("keeps the next message and explains that the previous send is still saving", () => { + it("keeps the next message while the previous send is still saving", () => { const onSend = vi.fn(); - const preventDefault = vi.fn(); - const input = { + const result = submitComposerDraft({ prompt: "Message B", - submissionTarget: "provider-turn" as const, - event: { preventDefault }, + isSendBusy: true, + submissionTarget: "provider-turn", + event: { preventDefault: () => {} }, onSend, - }; - expect(submitComposerDraft({ ...input, isSendBusy: true })).toEqual({ - validationMessage: - "Your previous message is still saving. Wait for it to finish, then send again.", - didDispatch: false, }); + expect(result.didDispatch).toBe(false); + expect(result.validationMessage).not.toBeNull(); expect(onSend).not.toHaveBeenCalled(); - expect(preventDefault).toHaveBeenCalledOnce(); - expect(submitComposerDraft({ ...input, isSendBusy: false })).toEqual({ - validationMessage: null, - didDispatch: true, - }); - expect(onSend).toHaveBeenCalledOnce(); }); it("blocks when appended context pushes the provider input over the shared limit", () => { From 20734c7256a8635c5474ef690f03306ca82ba2e4 Mon Sep 17 00:00:00 2001 From: flamboh Date: Thu, 5 Nov 2026 15:37:23 +0000 Subject: [PATCH 3/4] fix(web): position opened threads and sent messages before paint --- .../chat/MessagesTimeline.browser.test.tsx | 117 ++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 43 +++++++ .../chat/useTimelinePositionRestore.ts | 13 ++ 3 files changed, 173 insertions(+) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 12ca835dc14f7..e980a1e38fac4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -1,3 +1,4 @@ +import { useTimelineFollow } from "./useTimelineFollow"; import { flushSync } from "react-dom"; import { AudioPreview } from "../files/AudioPreview"; import { cdp, page, userEvent } from "vite-plus/test/browser"; @@ -1519,6 +1520,122 @@ describe("MessagesTimeline thread find", () => { expect(navigations).toEqual([released]); }); + it.each([false, true])( + "shows a newly sent message before its content paints (anchor %s)", + async (anchored) => { + setup(); + const base = baseProps(); + const entries = Array.from({ length: 25 }, (_, index) => + message( + `Reply ${index}\n\n${"line\n\n".repeat(8)}`, + { role: "assistant" }, + `send-${index}`, + ), + ); + function FollowingTimeline({ sent = false }) { + const [anchor, setAnchor] = useState({ + threadKey: base.routeThreadKey as string | null, + messageId: null as MessageId | null, + }); + const [wasSent, setWasSent] = useState(false); + if (sent !== wasSent) { + setWasSent(sent); + setAnchor({ + threadKey: base.routeThreadKey, + messageId: anchored ? MessageId.make("new-prompt") : null, + }); + } + const timelineEntries = sent + ? [...entries, message("New prompt", {}, "new-prompt")] + : entries; + const follow = useTimelineFollow({ + legendListRef: base.listRef, + composerTimelineInset: 170, + routeThreadKey: base.routeThreadKey, + activeThreadKey: base.routeThreadKey, + isServerThread: false, + serverProjection: null, + threadStatus: "live", + activeActivityRun: null, + activeRunningTurnId: null, + timelineEntries, + timelineAnchorMessageId: anchor.messageId, + setTimelineAnchor: setAnchor, + }); + return ( + + ); + } + await using screen = await renderElement(); + const element = base.listRef.current!.getScrollableNode()!; + await vi.waitFor(() => + expect(element.scrollHeight - element.clientHeight - element.scrollTop).toBeLessThanOrEqual( + 1, + ), + ); + const positions: number[] = []; + const observer = new ResizeObserver(() => { + const row = screen.container.querySelector('[data-message-id="new-prompt"]'); + if (row && row.getBoundingClientRect().height > 0) { + positions.push( + row.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom + 170, + ); + } + }); + using _observer = { [Symbol.dispose]: () => observer.disconnect() }; + observer.observe(screen.container.querySelector(".legend-list-content-container")!); + await screen.rerender(); + await vi.waitFor(() => expect(positions.length).toBeGreaterThan(0)); + expect(positions[0]).toBeLessThanOrEqual(0); + }, + ); + + it("opens a thread at its end before its measured content paints", async () => { + setup(); + const base = baseProps(); + const replies = Array.from({ length: 25 }, (_, index) => + message( + `Reply ${index}\n\n${"line\n\n".repeat(8)}`, + { role: "assistant" }, + `landing-${index}`, + ), + ); + await using screen = await renderElement( + , + ); + const gaps: number[] = []; + const observer = new ResizeObserver(() => { + const list = base.listRef.current; + const element = list?.getScrollableNode(); + if (!element || list?.getState().data.length !== 25) return; + const tail = screen.container.querySelector('[data-timeline-row-id="entry-landing-24"]'); + if (tail && tail.getBoundingClientRect().height > 0) { + gaps.push(element.scrollHeight - element.clientHeight - element.scrollTop); + } + }); + using _observer = { [Symbol.dispose]: () => observer.disconnect() }; + observer.observe(screen.container.querySelector(".legend-list-content-container")!); + await screen.rerender( + , + ); + await vi.waitFor(() => expect(gaps.length).toBeGreaterThan(0)); + expect(gaps[0]).toBeLessThanOrEqual(1); + }); + it("still restores the position of a thread it switches to whose find is open", async () => { setup(); const base = baseProps(); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a8a3982f3d114..29cf5d7ef09c0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1275,6 +1275,42 @@ const ConversationTimeline = memo(function ConversationTimeline({ scrollNode.scrollTop = scrollNode.scrollHeight - scrollNode.clientHeight; }); + const lastUserRowId = rows.findLast( + (row) => row.kind === "message" && row.message.role === "user", + )?.id; + const revealNewUserMessage = useStableCallback( + (previous: { identity: string; rowId: string | undefined }, list: LegendListRef) => { + if (previous.identity !== listIdentityKey || !liveFollowEnabled) { + previous.identity = listIdentityKey; + previous.rowId = lastUserRowId; + return; + } + if (previous.rowId === lastUserRowId || !lastUserRowId) return; + const state = list.getState(); + if (state.data !== rows) return; + const index = state.indexByKey(lastUserRowId); + const row = index === undefined ? undefined : state.elementAtIndex(index); + const element = list.getScrollableNode(); + if (!row || !element || row.getBoundingClientRect().height === 0) return; + previous.rowId = lastUserRowId; + const end = Math.max(0, element.scrollHeight - element.clientHeight); + const offset = anchoredEndSpace + ? Math.max( + 0, + Math.min( + end, + element.scrollTop + + row.getBoundingClientRect().top - + element.getBoundingClientRect().top - + CHAT_TIMELINE_ANCHOR_OFFSET, + ), + ) + : end; + element.scrollTop = offset; + void list.scrollToOffset({ offset, animated: false }); + }, + ); + const registerTimeline = useRegisterChatCanvasTimeline(); // Stable so the list attaches once per mount, not on every render. const setTimelineList = useStableCallback((list: LegendListRef | null) => { @@ -1290,6 +1326,12 @@ const ConversationTimeline = memo(function ConversationTimeline({ viewportRef.current = viewport; const detachViewport = viewport ? onViewportAttach?.(viewport) : undefined; const stopRememberingRelease = viewport?.onFollowReleased(rememberReleasedPosition); + const previousUserMessage = { identity: listIdentityKey, rowId: lastUserRowId }; + const content = scrollNode?.firstElementChild; + const stopRevealingNewMessages = + content && list + ? observeResize(content, () => revealNewUserMessage(previousUserMessage, list)) + : undefined; let contentSizeFrame: number | null = null; // LegendList lays rows out from layout effects; the next frame sees them settled. let scheduledData: unknown = null; @@ -1314,6 +1356,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ rememberPositionOnUnmount(); stopKeepingEndOnResize?.(); stopListeningToContentSize?.(); + stopRevealingNewMessages?.(); stopRememberingRelease?.(); if (contentSizeFrame !== null) cancelAnimationFrame(contentSizeFrame); detachViewport?.(); diff --git a/apps/web/src/components/chat/useTimelinePositionRestore.ts b/apps/web/src/components/chat/useTimelinePositionRestore.ts index 97374d7c19f69..ea7b0bce24997 100644 --- a/apps/web/src/components/chat/useTimelinePositionRestore.ts +++ b/apps/web/src/components/chat/useTimelinePositionRestore.ts @@ -1,3 +1,4 @@ +import { observeResize } from "~/lib/observeResize"; import type { LegendListRef } from "@legendapp/list/react"; import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; import type { RememberedTimelinePosition } from "./timelineScrollAnchoring"; @@ -87,8 +88,20 @@ export function startTimelinePositionRestore({ // Reading history or finding: follow lets go before the restore claims the viewport. if (position?.atEnd === false || releaseForFind) viewport.navigate({ releasesFollow: true }); const releaseScrollHold = viewport.holdProgrammaticScroll(); + const element = list.getScrollableNode(); + const content = element?.firstElementChild; + const stopObserving = + position?.atEnd !== false && content && element + ? observeResize(content, () => { + const offset = measuredEndOffset(element); + if (Math.abs(element.scrollTop - offset) <= 1) return; + element.scrollTop = offset; + void list.scrollToOffset({ offset, animated: false }); + }) + : undefined; const stop = () => { cancelled = true; + stopObserving?.(); releaseScrollHold(); unsubscribe(); }; From 997b78ca2907fe550f4dfd4a9e2d0c429ebec417 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 7 Nov 2026 19:03:38 +0000 Subject: [PATCH 4/4] fix(web): keep timeline positioning compatible with L6 --- .../chat/MessagesTimeline.browser.test.tsx | 117 ------------------ .../src/components/chat/MessagesTimeline.tsx | 9 +- 2 files changed, 2 insertions(+), 124 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index e980a1e38fac4..12ca835dc14f7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -1,4 +1,3 @@ -import { useTimelineFollow } from "./useTimelineFollow"; import { flushSync } from "react-dom"; import { AudioPreview } from "../files/AudioPreview"; import { cdp, page, userEvent } from "vite-plus/test/browser"; @@ -1520,122 +1519,6 @@ describe("MessagesTimeline thread find", () => { expect(navigations).toEqual([released]); }); - it.each([false, true])( - "shows a newly sent message before its content paints (anchor %s)", - async (anchored) => { - setup(); - const base = baseProps(); - const entries = Array.from({ length: 25 }, (_, index) => - message( - `Reply ${index}\n\n${"line\n\n".repeat(8)}`, - { role: "assistant" }, - `send-${index}`, - ), - ); - function FollowingTimeline({ sent = false }) { - const [anchor, setAnchor] = useState({ - threadKey: base.routeThreadKey as string | null, - messageId: null as MessageId | null, - }); - const [wasSent, setWasSent] = useState(false); - if (sent !== wasSent) { - setWasSent(sent); - setAnchor({ - threadKey: base.routeThreadKey, - messageId: anchored ? MessageId.make("new-prompt") : null, - }); - } - const timelineEntries = sent - ? [...entries, message("New prompt", {}, "new-prompt")] - : entries; - const follow = useTimelineFollow({ - legendListRef: base.listRef, - composerTimelineInset: 170, - routeThreadKey: base.routeThreadKey, - activeThreadKey: base.routeThreadKey, - isServerThread: false, - serverProjection: null, - threadStatus: "live", - activeActivityRun: null, - activeRunningTurnId: null, - timelineEntries, - timelineAnchorMessageId: anchor.messageId, - setTimelineAnchor: setAnchor, - }); - return ( - - ); - } - await using screen = await renderElement(); - const element = base.listRef.current!.getScrollableNode()!; - await vi.waitFor(() => - expect(element.scrollHeight - element.clientHeight - element.scrollTop).toBeLessThanOrEqual( - 1, - ), - ); - const positions: number[] = []; - const observer = new ResizeObserver(() => { - const row = screen.container.querySelector('[data-message-id="new-prompt"]'); - if (row && row.getBoundingClientRect().height > 0) { - positions.push( - row.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom + 170, - ); - } - }); - using _observer = { [Symbol.dispose]: () => observer.disconnect() }; - observer.observe(screen.container.querySelector(".legend-list-content-container")!); - await screen.rerender(); - await vi.waitFor(() => expect(positions.length).toBeGreaterThan(0)); - expect(positions[0]).toBeLessThanOrEqual(0); - }, - ); - - it("opens a thread at its end before its measured content paints", async () => { - setup(); - const base = baseProps(); - const replies = Array.from({ length: 25 }, (_, index) => - message( - `Reply ${index}\n\n${"line\n\n".repeat(8)}`, - { role: "assistant" }, - `landing-${index}`, - ), - ); - await using screen = await renderElement( - , - ); - const gaps: number[] = []; - const observer = new ResizeObserver(() => { - const list = base.listRef.current; - const element = list?.getScrollableNode(); - if (!element || list?.getState().data.length !== 25) return; - const tail = screen.container.querySelector('[data-timeline-row-id="entry-landing-24"]'); - if (tail && tail.getBoundingClientRect().height > 0) { - gaps.push(element.scrollHeight - element.clientHeight - element.scrollTop); - } - }); - using _observer = { [Symbol.dispose]: () => observer.disconnect() }; - observer.observe(screen.container.querySelector(".legend-list-content-container")!); - await screen.rerender( - , - ); - await vi.waitFor(() => expect(gaps.length).toBeGreaterThan(0)); - expect(gaps[0]).toBeLessThanOrEqual(1); - }); - it("still restores the position of a thread it switches to whose find is open", async () => { setup(); const base = baseProps(); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 29cf5d7ef09c0..ba951ab4765d1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1275,7 +1275,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ scrollNode.scrollTop = scrollNode.scrollHeight - scrollNode.clientHeight; }); - const lastUserRowId = rows.findLast( + const lastUserRowId = timelineEntries.findLast( (row) => row.kind === "message" && row.message.role === "user", )?.id; const revealNewUserMessage = useStableCallback( @@ -1327,16 +1327,12 @@ const ConversationTimeline = memo(function ConversationTimeline({ const detachViewport = viewport ? onViewportAttach?.(viewport) : undefined; const stopRememberingRelease = viewport?.onFollowReleased(rememberReleasedPosition); const previousUserMessage = { identity: listIdentityKey, rowId: lastUserRowId }; - const content = scrollNode?.firstElementChild; - const stopRevealingNewMessages = - content && list - ? observeResize(content, () => revealNewUserMessage(previousUserMessage, list)) - : undefined; let contentSizeFrame: number | null = null; // LegendList lays rows out from layout effects; the next frame sees them settled. let scheduledData: unknown = null; const scheduleContentSizeChanged = () => { if (!list) return; + revealNewUserMessage(previousUserMessage, list); const laidOutData = list.getState().data; if (contentSizeFrame !== null) { if (scheduledData === laidOutData) return; @@ -1356,7 +1352,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ rememberPositionOnUnmount(); stopKeepingEndOnResize?.(); stopListeningToContentSize?.(); - stopRevealingNewMessages?.(); stopRememberingRelease?.(); if (contentSizeFrame !== null) cancelAnimationFrame(contentSizeFrame); detachViewport?.();