diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 01831dfd..32da0f6d 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -185,6 +185,7 @@ export function ChatScreen() { { expect(scrollNode.scrollTop).toBe(100); }); + it("forces the latest user message into view after the keyboard resizes", () => { + scrollHeight = 1_000; + clientHeight = 300; + const history = [createMessage("history-1")]; + renderChatArea(root, history, true, { scrollToBottomSignal: 0 }); + const scrollNode = getScrollNode(container); + + clientHeight = 200; + scrollNode.scrollTop = 700; + act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true }))); + + scrollHeight = 1_200; + renderChatArea( + root, + [...history, createUserMessage("sent-1")], + true, + { scrollToBottomSignal: 1 }, + ); + + expect(scrollNode.scrollTop).toBe(1_000); + }); + + it("scrolls below a promotion after the user sends a message", () => { + scrollHeight = 1_000; + clientHeight = 300; + const history = [createMessage("history-1")]; + const promotion = createMessage("promotion-1"); + renderChatArea(root, [...history, promotion], true, { + scrollToBottomSignal: 0, + }); + const scrollNode = getScrollNode(container); + scrollNode.scrollTop = 100; + act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true }))); + + scrollHeight = 1_500; + renderChatArea( + root, + [...history, createUserMessage("sent-1"), promotion], + true, + { scrollToBottomSignal: 1 }, + ); + + expect(scrollNode.scrollTop).toBe(1_200); + }); + it("preserves the visible message after older history is prepended", () => { scrollHeight = 1_000; clientHeight = 300; @@ -184,6 +229,7 @@ function renderChatArea( canLoadMoreHistory?: boolean; isLoadingMoreHistory?: boolean; onLoadMoreHistory?: () => void; + scrollToBottomSignal?: number; } = {}, ): void { act(() => { @@ -227,6 +273,15 @@ function createMessage(id: string): UiMessage { }; } +function createUserMessage(id: string): UiMessage { + return { + id, + content: `Message ${id}`, + isFromAI: false, + date: "2026-07-15", + }; +} + function getScrollNode(container: HTMLElement): HTMLElement { const node = container.querySelector( '[aria-label="Chat messages"]', diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index 20d0395a..0265aab5 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -47,6 +47,7 @@ const ReplyingAnimation = lazy(() => export interface ChatAreaProps { messages: readonly UiMessage[]; isReplyingAI: boolean; + scrollToBottomSignal?: number; initialScrollReady?: boolean; canLoadMoreHistory?: boolean; isLoadingMoreHistory?: boolean; @@ -62,6 +63,7 @@ export interface ChatAreaProps { export function ChatArea({ messages, isReplyingAI, + scrollToBottomSignal = 0, initialScrollReady = true, canLoadMoreHistory = false, isLoadingMoreHistory = false, @@ -77,6 +79,7 @@ export function ChatArea({ const contentRef = useRef(null); const initialScrollSettledRef = useRef(false); const shouldStickToBottomRef = useRef(true); + const previousScrollToBottomSignalRef = useRef(scrollToBottomSignal); const loadMoreAnchorRef = useRef<{ scrollHeight: number; scrollTop: number; @@ -115,12 +118,33 @@ export function ChatArea({ useLayoutEffect(() => { if (!initialScrollReady) { initialScrollSettledRef.current = false; + previousScrollToBottomSignalRef.current = scrollToBottomSignal; return; } const scrollNode = scrollRef.current; if (!scrollNode) return; + const shouldForceScrollToBottom = + previousScrollToBottomSignalRef.current !== scrollToBottomSignal; + previousScrollToBottomSignalRef.current = scrollToBottomSignal; + + if (shouldForceScrollToBottom) { + loadMoreAnchorRef.current = null; + initialScrollSettledRef.current = true; + shouldStickToBottomRef.current = true; + scrollToBottom(scrollNode); + const frameId = requestAnimationFrame(() => { + if ( + scrollRef.current === scrollNode && + shouldStickToBottomRef.current + ) { + scrollToBottom(scrollNode); + } + }); + return () => cancelAnimationFrame(frameId); + } + if (loadMoreAnchorRef.current && !isLoadingMoreHistory) { const anchor = loadMoreAnchorRef.current; loadMoreAnchorRef.current = null; @@ -149,7 +173,13 @@ export function ChatArea({ }); return () => cancelAnimationFrame(frameId); - }, [initialScrollReady, isLoadingMoreHistory, isReplyingAI, messages]); + }, [ + initialScrollReady, + isLoadingMoreHistory, + isReplyingAI, + messages, + scrollToBottomSignal, + ]); useEffect(() => { const scrollNode = scrollRef.current; diff --git a/src/stores/chat/__tests__/chat-helpers.test.ts b/src/stores/chat/__tests__/chat-helpers.test.ts index 4ffa44c7..ae40fc79 100644 --- a/src/stores/chat/__tests__/chat-helpers.test.ts +++ b/src/stores/chat/__tests__/chat-helpers.test.ts @@ -31,6 +31,7 @@ function makeChatState(overrides: Partial = {}): ChatState { return { messages: [], promotion: null, + outgoingMessageRevision: 0, isReplyingAI: true, pendingReplyCount: 1, upgradePromptVisible: false, diff --git a/src/stores/chat/__tests__/chat-send-flow.test.ts b/src/stores/chat/__tests__/chat-send-flow.test.ts index ae97f222..5065d973 100644 --- a/src/stores/chat/__tests__/chat-send-flow.test.ts +++ b/src/stores/chat/__tests__/chat-send-flow.test.ts @@ -29,6 +29,10 @@ describe("chat send flow", () => { { content: "hello", isFromAI: false }, { content: "still there?", isFromAI: false }, ]); + expect(actor.getSnapshot().context.outgoingMessageRevision).toBe(2); + + actor.send({ type: "ChatSendMessage", content: " " }); + expect(actor.getSnapshot().context.outgoingMessageRevision).toBe(2); await waitFor( actor, @@ -60,10 +64,32 @@ describe("chat send flow", () => { expect(actor.getSnapshot().context.messages).toMatchObject([ { content: "[Image]", isFromAI: false }, ]); + expect(actor.getSnapshot().context.outgoingMessageRevision).toBe(1); actor.stop(); }); + it("increments the outgoing revision for a user image", async () => { + const actor = createActor(createTestChatMachine()).start(); + + actor.send({ type: "ChatUserLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + + actor.send({ + type: "ChatSendImage", + imageBase64: "data:image/png;base64,user-image", + }); + + expect(actor.getSnapshot().context.outgoingMessageRevision).toBe(1); + expect(actor.getSnapshot().context.messages.at(-1)).toMatchObject({ + content: "[Image]", + isFromAI: false, + }); + actor.stop(); + }); + it("restores ready state when the direct HTTP actor fails", async () => { const actor = createActor( createTestChatMachine({ diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index fc888e59..d28f3c7c 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -18,6 +18,7 @@ interface ChatState { messages: MachineContext["messages"]; historyMessages: MachineContext["messages"]; promotion: MachineContext["promotion"]; + outgoingMessageRevision: number; isReplyingAI: boolean; upgradePromptVisible: boolean; upgradeReason: MachineContext["upgradeReason"]; @@ -74,6 +75,7 @@ function selectChatState(state: ChatSnapshot): SelectedChatState { return { historyMessages: state.context.messages, promotion: state.context.promotion, + outgoingMessageRevision: state.context.outgoingMessageRevision, isReplyingAI: state.context.isReplyingAI, upgradePromptVisible: state.context.upgradePromptVisible, upgradeReason: state.context.upgradeReason, diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index 94462b82..02baa616 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -22,6 +22,7 @@ export interface ChatUnlockPaywallRequest { export interface ChatState { messages: UiMessage[]; promotion: ChatPromotionState | null; + outgoingMessageRevision: number; isReplyingAI: boolean; pendingReplyCount: number; upgradePromptVisible: boolean; @@ -55,6 +56,7 @@ export interface ChatState { export const initialState: ChatState = { messages: [], promotion: null, + outgoingMessageRevision: 0, isReplyingAI: false, pendingReplyCount: 0, upgradePromptVisible: false, diff --git a/src/stores/chat/machine/send-flow.ts b/src/stores/chat/machine/send-flow.ts index be28ceca..8d2bb458 100644 --- a/src/stores/chat/machine/send-flow.ts +++ b/src/stores/chat/machine/send-flow.ts @@ -41,6 +41,7 @@ const appendGuestUserMessageAction = historyMachineSetup.assign( date: today, }, ], + outgoingMessageRevision: context.outgoingMessageRevision + 1, upgradePromptVisible: false, upgradeReason: null, }; @@ -67,6 +68,7 @@ const appendUserMessageAction = historyMachineSetup.assign( date: today, }, ], + outgoingMessageRevision: context.outgoingMessageRevision + 1, upgradePromptVisible: false, upgradeReason: null, }; @@ -125,6 +127,7 @@ const appendGuestUserImageAction = historyMachineSetup.assign( imageUrl: event.imageBase64, }, ], + outgoingMessageRevision: context.outgoingMessageRevision + 1, ...beginPendingReply(context), upgradePromptVisible: false, upgradeReason: null, @@ -151,6 +154,7 @@ const appendUserImageAction = historyMachineSetup.assign( imageUrl: event.imageBase64, }, ], + outgoingMessageRevision: context.outgoingMessageRevision + 1, ...beginPendingReply(context), upgradePromptVisible: false, upgradeReason: null,