diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 51520b62..2787f948 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -5,7 +5,10 @@ import Image from "next/image"; import { useRouter, useSearchParams } from "next/navigation"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; -import { useChatState } from "@/stores/chat/chat-context"; +import { + useChatDispatch, + useChatState, +} from "@/stores/chat/chat-context"; import { ROUTES } from "@/router/routes"; import { MobileShell } from "@/app/_components/core"; @@ -45,6 +48,7 @@ export function ChatScreen() { const router = useRouter(); const searchParams = useSearchParams(); const state = useChatState(); + const chatDispatch = useChatDispatch(); const authState = useAuthState(); const authDispatch = useAuthDispatch(); useSplashLatestMessageSync({ @@ -232,12 +236,17 @@ export function ChatScreen() { initialScrollReady={ state.historyLoaded && isPromotionBootstrapReady } + canLoadMoreHistory={state.hasMoreHistory} + isLoadingMoreHistory={state.isLoadingMoreHistory} isUnlockingMessage={state.isUnlockingMessage} unlockingMessageId={state.unlockingMessageId} onUnlockPrivateMessage={handleUnlockPrivateMessage} onUnlockVoiceMessage={handleUnlockVoiceMessage} onUnlockImageMessage={handleUnlockImageMessage} onOpenImage={handleOpenImage} + onLoadMoreHistory={() => { + chatDispatch({ type: "ChatLoadMoreHistoryRequested" }); + }} /> {messageLimitBanner.visible ? ( diff --git a/src/app/chat/components/__tests__/chat-area-scroll.test.tsx b/src/app/chat/components/__tests__/chat-area-scroll.test.tsx index 14a2656b..72e31ebf 100644 --- a/src/app/chat/components/__tests__/chat-area-scroll.test.tsx +++ b/src/app/chat/components/__tests__/chat-area-scroll.test.tsx @@ -144,12 +144,47 @@ describe("ChatArea scrolling", () => { expect(scrollNode.scrollTop).toBe(100); }); + + it("preserves the visible message after older history is prepended", () => { + scrollHeight = 1_000; + clientHeight = 300; + const onLoadMoreHistory = vi.fn(); + const history = [createMessage("history-1")]; + renderChatArea(root, history, true, { + canLoadMoreHistory: true, + onLoadMoreHistory, + }); + const scrollNode = getScrollNode(container); + scrollNode.scrollTop = 0; + act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true }))); + + pull(scrollNode, 180); + expect(onLoadMoreHistory).toHaveBeenCalledOnce(); + + scrollHeight = 1_400; + renderChatArea( + root, + [createMessage("older-1"), ...history], + true, + { + canLoadMoreHistory: true, + onLoadMoreHistory, + }, + ); + + expect(scrollNode.scrollTop).toBe(400); + }); }); function renderChatArea( root: Root, messages: readonly UiMessage[], initialScrollReady: boolean, + historyOptions: { + canLoadMoreHistory?: boolean; + isLoadingMoreHistory?: boolean; + onLoadMoreHistory?: () => void; + } = {}, ): void { act(() => { root.render( @@ -157,11 +192,32 @@ function renderChatArea( messages={messages} isReplyingAI={false} initialScrollReady={initialScrollReady} + {...historyOptions} />, ); }); } +function pull(target: HTMLElement, distance: number): void { + act(() => { + target.dispatchEvent(createTouchEvent("touchstart", 0)); + target.dispatchEvent(createTouchEvent("touchmove", distance)); + target.dispatchEvent(createTouchEvent("touchend", distance, true)); + }); +} + +function createTouchEvent( + type: string, + clientY: number, + ended = false, +): Event { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "touches", { + value: ended ? [] : [{ identifier: 1, clientY }], + }); + return event; +} + function createMessage(id: string): UiMessage { return { id, diff --git a/src/app/chat/components/browser-hint-overlay.tsx b/src/app/chat/components/browser-hint-overlay.tsx index cc5fdf09..b3545653 100644 --- a/src/app/chat/components/browser-hint-overlay.tsx +++ b/src/app/chat/components/browser-hint-overlay.tsx @@ -45,7 +45,7 @@ export function BrowserHintOverlay({ onClick={handleClick} >