fix(chat): keep sent messages in view
This commit is contained in:
@@ -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"]',
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user