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(); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a8a3982f3d114..ba951ab4765d1 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 = timelineEntries.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,11 +1326,13 @@ 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 }; 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; 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", () => { 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(); };