fix(chat): keep sent messages in view

This commit is contained in:
2026-07-15 19:14:36 +08:00
parent 47876484fc
commit cf239a51de
8 changed files with 122 additions and 1 deletions
+1
View File
@@ -185,6 +185,7 @@ export function ChatScreen() {
<ChatArea <ChatArea
messages={visibleMessages} messages={visibleMessages}
isReplyingAI={state.isReplyingAI} isReplyingAI={state.isReplyingAI}
scrollToBottomSignal={state.outgoingMessageRevision}
initialScrollReady={ initialScrollReady={
state.historyLoaded && isPromotionBootstrapReady state.historyLoaded && isPromotionBootstrapReady
} }
@@ -145,6 +145,51 @@ describe("ChatArea scrolling", () => {
expect(scrollNode.scrollTop).toBe(100); 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", () => { it("preserves the visible message after older history is prepended", () => {
scrollHeight = 1_000; scrollHeight = 1_000;
clientHeight = 300; clientHeight = 300;
@@ -184,6 +229,7 @@ function renderChatArea(
canLoadMoreHistory?: boolean; canLoadMoreHistory?: boolean;
isLoadingMoreHistory?: boolean; isLoadingMoreHistory?: boolean;
onLoadMoreHistory?: () => void; onLoadMoreHistory?: () => void;
scrollToBottomSignal?: number;
} = {}, } = {},
): void { ): void {
act(() => { 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 { function getScrollNode(container: HTMLElement): HTMLElement {
const node = container.querySelector<HTMLElement>( const node = container.querySelector<HTMLElement>(
'[aria-label="Chat messages"]', '[aria-label="Chat messages"]',
+31 -1
View File
@@ -47,6 +47,7 @@ const ReplyingAnimation = lazy(() =>
export interface ChatAreaProps { export interface ChatAreaProps {
messages: readonly UiMessage[]; messages: readonly UiMessage[];
isReplyingAI: boolean; isReplyingAI: boolean;
scrollToBottomSignal?: number;
initialScrollReady?: boolean; initialScrollReady?: boolean;
canLoadMoreHistory?: boolean; canLoadMoreHistory?: boolean;
isLoadingMoreHistory?: boolean; isLoadingMoreHistory?: boolean;
@@ -62,6 +63,7 @@ export interface ChatAreaProps {
export function ChatArea({ export function ChatArea({
messages, messages,
isReplyingAI, isReplyingAI,
scrollToBottomSignal = 0,
initialScrollReady = true, initialScrollReady = true,
canLoadMoreHistory = false, canLoadMoreHistory = false,
isLoadingMoreHistory = false, isLoadingMoreHistory = false,
@@ -77,6 +79,7 @@ export function ChatArea({
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const initialScrollSettledRef = useRef(false); const initialScrollSettledRef = useRef(false);
const shouldStickToBottomRef = useRef(true); const shouldStickToBottomRef = useRef(true);
const previousScrollToBottomSignalRef = useRef(scrollToBottomSignal);
const loadMoreAnchorRef = useRef<{ const loadMoreAnchorRef = useRef<{
scrollHeight: number; scrollHeight: number;
scrollTop: number; scrollTop: number;
@@ -115,12 +118,33 @@ export function ChatArea({
useLayoutEffect(() => { useLayoutEffect(() => {
if (!initialScrollReady) { if (!initialScrollReady) {
initialScrollSettledRef.current = false; initialScrollSettledRef.current = false;
previousScrollToBottomSignalRef.current = scrollToBottomSignal;
return; return;
} }
const scrollNode = scrollRef.current; const scrollNode = scrollRef.current;
if (!scrollNode) return; 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) { if (loadMoreAnchorRef.current && !isLoadingMoreHistory) {
const anchor = loadMoreAnchorRef.current; const anchor = loadMoreAnchorRef.current;
loadMoreAnchorRef.current = null; loadMoreAnchorRef.current = null;
@@ -149,7 +173,13 @@ export function ChatArea({
}); });
return () => cancelAnimationFrame(frameId); return () => cancelAnimationFrame(frameId);
}, [initialScrollReady, isLoadingMoreHistory, isReplyingAI, messages]); }, [
initialScrollReady,
isLoadingMoreHistory,
isReplyingAI,
messages,
scrollToBottomSignal,
]);
useEffect(() => { useEffect(() => {
const scrollNode = scrollRef.current; const scrollNode = scrollRef.current;
@@ -31,6 +31,7 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
return { return {
messages: [], messages: [],
promotion: null, promotion: null,
outgoingMessageRevision: 0,
isReplyingAI: true, isReplyingAI: true,
pendingReplyCount: 1, pendingReplyCount: 1,
upgradePromptVisible: false, upgradePromptVisible: false,
@@ -29,6 +29,10 @@ describe("chat send flow", () => {
{ content: "hello", isFromAI: false }, { content: "hello", isFromAI: false },
{ content: "still there?", 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( await waitFor(
actor, actor,
@@ -60,10 +64,32 @@ describe("chat send flow", () => {
expect(actor.getSnapshot().context.messages).toMatchObject([ expect(actor.getSnapshot().context.messages).toMatchObject([
{ content: "[Image]", isFromAI: false }, { content: "[Image]", isFromAI: false },
]); ]);
expect(actor.getSnapshot().context.outgoingMessageRevision).toBe(1);
actor.stop(); 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 () => { it("restores ready state when the direct HTTP actor fails", async () => {
const actor = createActor( const actor = createActor(
createTestChatMachine({ createTestChatMachine({
+2
View File
@@ -18,6 +18,7 @@ interface ChatState {
messages: MachineContext["messages"]; messages: MachineContext["messages"];
historyMessages: MachineContext["messages"]; historyMessages: MachineContext["messages"];
promotion: MachineContext["promotion"]; promotion: MachineContext["promotion"];
outgoingMessageRevision: number;
isReplyingAI: boolean; isReplyingAI: boolean;
upgradePromptVisible: boolean; upgradePromptVisible: boolean;
upgradeReason: MachineContext["upgradeReason"]; upgradeReason: MachineContext["upgradeReason"];
@@ -74,6 +75,7 @@ function selectChatState(state: ChatSnapshot): SelectedChatState {
return { return {
historyMessages: state.context.messages, historyMessages: state.context.messages,
promotion: state.context.promotion, promotion: state.context.promotion,
outgoingMessageRevision: state.context.outgoingMessageRevision,
isReplyingAI: state.context.isReplyingAI, isReplyingAI: state.context.isReplyingAI,
upgradePromptVisible: state.context.upgradePromptVisible, upgradePromptVisible: state.context.upgradePromptVisible,
upgradeReason: state.context.upgradeReason, upgradeReason: state.context.upgradeReason,
+2
View File
@@ -22,6 +22,7 @@ export interface ChatUnlockPaywallRequest {
export interface ChatState { export interface ChatState {
messages: UiMessage[]; messages: UiMessage[];
promotion: ChatPromotionState | null; promotion: ChatPromotionState | null;
outgoingMessageRevision: number;
isReplyingAI: boolean; isReplyingAI: boolean;
pendingReplyCount: number; pendingReplyCount: number;
upgradePromptVisible: boolean; upgradePromptVisible: boolean;
@@ -55,6 +56,7 @@ export interface ChatState {
export const initialState: ChatState = { export const initialState: ChatState = {
messages: [], messages: [],
promotion: null, promotion: null,
outgoingMessageRevision: 0,
isReplyingAI: false, isReplyingAI: false,
pendingReplyCount: 0, pendingReplyCount: 0,
upgradePromptVisible: false, upgradePromptVisible: false,
+4
View File
@@ -41,6 +41,7 @@ const appendGuestUserMessageAction = historyMachineSetup.assign(
date: today, date: today,
}, },
], ],
outgoingMessageRevision: context.outgoingMessageRevision + 1,
upgradePromptVisible: false, upgradePromptVisible: false,
upgradeReason: null, upgradeReason: null,
}; };
@@ -67,6 +68,7 @@ const appendUserMessageAction = historyMachineSetup.assign(
date: today, date: today,
}, },
], ],
outgoingMessageRevision: context.outgoingMessageRevision + 1,
upgradePromptVisible: false, upgradePromptVisible: false,
upgradeReason: null, upgradeReason: null,
}; };
@@ -125,6 +127,7 @@ const appendGuestUserImageAction = historyMachineSetup.assign(
imageUrl: event.imageBase64, imageUrl: event.imageBase64,
}, },
], ],
outgoingMessageRevision: context.outgoingMessageRevision + 1,
...beginPendingReply(context), ...beginPendingReply(context),
upgradePromptVisible: false, upgradePromptVisible: false,
upgradeReason: null, upgradeReason: null,
@@ -151,6 +154,7 @@ const appendUserImageAction = historyMachineSetup.assign(
imageUrl: event.imageBase64, imageUrl: event.imageBase64,
}, },
], ],
outgoingMessageRevision: context.outgoingMessageRevision + 1,
...beginPendingReply(context), ...beginPendingReply(context),
upgradePromptVisible: false, upgradePromptVisible: false,
upgradeReason: null, upgradeReason: null,