feat(FN-4118): restore last viewed room thread on chat load
Adds chat room scroll anchoring so that when returning to a room, the view stays positioned at the previously visited thread rather than resetting to the top, with test coverage in the rooms suite and a patch changeset for the published CLI. Fusion-Task-Id: FN-4118
This commit is contained in:
5
.changeset/fn-4118-chat-room-scroll-on-return.md
Normal file
5
.changeset/fn-4118-chat-room-scroll-on-return.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Keep chat room threads anchored to the latest message when returning to a room, switching rooms, or resuming the mobile view.
|
||||
@@ -897,7 +897,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const mobileSessionMenuRef = useRef<HTMLDivElement>(null);
|
||||
const isUserScrollingRef = useRef(false);
|
||||
const lastAnchoredSessionStateRef = useRef<{ sessionId: string; loaded: boolean; hasMessages: boolean } | null>(null);
|
||||
const lastAnchoredThreadStateRef = useRef<{ threadId: string; loaded: boolean; hasMessages: boolean } | null>(null);
|
||||
const hideSkillMenuTimeoutRef = useRef<number | null>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -1077,25 +1077,24 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
}, [anchorToBottom]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const sessionId = activeSession?.id ?? null;
|
||||
if (!sessionId) {
|
||||
lastAnchoredSessionStateRef.current = null;
|
||||
const threadId = roomThreadActive ? (rooms.activeRoom?.id ?? null) : (activeSession?.id ?? null);
|
||||
if (!threadId) {
|
||||
lastAnchoredThreadStateRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextState = {
|
||||
sessionId,
|
||||
loaded: !messagesLoading,
|
||||
hasMessages: messages.length > 0,
|
||||
threadId,
|
||||
loaded: roomThreadActive ? !rooms.messagesLoading : !messagesLoading,
|
||||
hasMessages: roomThreadActive ? rooms.messages.length > 0 : messages.length > 0,
|
||||
};
|
||||
const previousState = lastAnchoredSessionStateRef.current;
|
||||
const isSessionChanged = previousState?.sessionId !== sessionId;
|
||||
const finishedLoading =
|
||||
previousState?.sessionId === sessionId && !previousState.loaded && nextState.loaded;
|
||||
const previousState = lastAnchoredThreadStateRef.current;
|
||||
const isThreadChanged = previousState?.threadId !== threadId;
|
||||
const finishedLoading = previousState?.threadId === threadId && !previousState.loaded && nextState.loaded;
|
||||
const firstMessagesArrived =
|
||||
previousState?.sessionId === sessionId && !previousState.hasMessages && nextState.hasMessages;
|
||||
previousState?.threadId === threadId && !previousState.hasMessages && nextState.hasMessages;
|
||||
|
||||
const shouldAnchor = previousState === null || isSessionChanged || finishedLoading || firstMessagesArrived;
|
||||
const shouldAnchor = previousState === null || isThreadChanged || finishedLoading || firstMessagesArrived;
|
||||
if (!shouldAnchor) {
|
||||
return;
|
||||
}
|
||||
@@ -1106,8 +1105,19 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
}
|
||||
|
||||
anchorToBottom(messagesContainer);
|
||||
lastAnchoredSessionStateRef.current = nextState;
|
||||
}, [activeSession?.id, messages.length, messagesLoading, anchorToBottom]);
|
||||
lastAnchoredThreadStateRef.current = nextState;
|
||||
}, [
|
||||
roomThreadActive,
|
||||
rooms.activeRoom?.id,
|
||||
rooms.messages.length,
|
||||
rooms.messagesLoading,
|
||||
activeSession?.id,
|
||||
messages.length,
|
||||
messagesLoading,
|
||||
anchorToBottom,
|
||||
]);
|
||||
|
||||
const activeThreadMessages = roomThreadActive ? rooms.messages : messages;
|
||||
|
||||
// Scroll thread container to bottom on new messages or streaming when user is near live tail.
|
||||
// Avoid Element.scrollIntoView() here because on mobile Safari it can
|
||||
@@ -1116,7 +1126,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
if (!isUserScrollingRef.current) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [messages, streamingText, streamingThinking, isStreaming, scrollToBottom]);
|
||||
}, [activeThreadMessages, streamingText, streamingThinking, isStreaming, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyboardOverlap <= 0) {
|
||||
@@ -1194,7 +1204,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
}, [isMobile, activeSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile || !activeSession) {
|
||||
if (!isMobile || (!activeSession && !roomThreadActive)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1220,7 +1230,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
window.removeEventListener("pageshow", reAnchorToLatest);
|
||||
};
|
||||
}, [isMobile, activeSession, anchorToBottom]);
|
||||
}, [isMobile, activeSession, roomThreadActive, anchorToBottom]);
|
||||
|
||||
// Fetch agents on mount for name resolution (project-scoped with stale-request protection)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -131,6 +131,78 @@ function mockMobileVisualViewport({ innerHeight, vvHeight }: { innerHeight: numb
|
||||
return { mockVV, listeners: { resize: resizeListeners, scroll: scrollListeners } };
|
||||
}
|
||||
|
||||
function mockDesktopViewport() {
|
||||
if (!window.matchMedia) {
|
||||
Object.defineProperty(window, "matchMedia", { value: vi.fn(), configurable: true, writable: true });
|
||||
}
|
||||
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
|
||||
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
function mockMessagesContainerMetrics({
|
||||
scrollHeight,
|
||||
clientHeight = 200,
|
||||
initialScrollTop = 0,
|
||||
}: {
|
||||
scrollHeight: number;
|
||||
clientHeight?: number;
|
||||
initialScrollTop?: number;
|
||||
}) {
|
||||
const scrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, "scrollHeight");
|
||||
const clientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, "clientHeight");
|
||||
const scrollTopDescriptor = Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, "scrollTop");
|
||||
let scrollTopValue = initialScrollTop;
|
||||
|
||||
Object.defineProperty(HTMLDivElement.prototype, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => scrollHeight,
|
||||
});
|
||||
Object.defineProperty(HTMLDivElement.prototype, "clientHeight", {
|
||||
configurable: true,
|
||||
get: () => clientHeight,
|
||||
});
|
||||
Object.defineProperty(HTMLDivElement.prototype, "scrollTop", {
|
||||
configurable: true,
|
||||
get: () => scrollTopValue,
|
||||
set: (value: number) => {
|
||||
scrollTopValue = value;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
getScrollTop: () => scrollTopValue,
|
||||
setScrollTop: (value: number) => {
|
||||
scrollTopValue = value;
|
||||
},
|
||||
restore: () => {
|
||||
if (scrollHeightDescriptor) {
|
||||
Object.defineProperty(HTMLDivElement.prototype, "scrollHeight", scrollHeightDescriptor);
|
||||
} else {
|
||||
delete (HTMLDivElement.prototype as Partial<HTMLDivElement>).scrollHeight;
|
||||
}
|
||||
if (clientHeightDescriptor) {
|
||||
Object.defineProperty(HTMLDivElement.prototype, "clientHeight", clientHeightDescriptor);
|
||||
} else {
|
||||
delete (HTMLDivElement.prototype as Partial<HTMLDivElement>).clientHeight;
|
||||
}
|
||||
if (scrollTopDescriptor) {
|
||||
Object.defineProperty(HTMLDivElement.prototype, "scrollTop", scrollTopDescriptor);
|
||||
} else {
|
||||
delete (HTMLDivElement.prototype as Partial<HTMLDivElement>).scrollTop;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
beforeEach(() => {
|
||||
_resetInitialViewportHeight();
|
||||
@@ -349,6 +421,158 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("FN-4118: anchors an already-loaded active room to the live tail on mount and remount", async () => {
|
||||
const restoreMatchMedia = mockDesktopViewport();
|
||||
const metrics = mockMessagesContainerMetrics({ scrollHeight: 960, clientHeight: 240 });
|
||||
|
||||
try {
|
||||
setup({}, {
|
||||
activeRoom: roomA,
|
||||
messagesLoading: false,
|
||||
messages: [
|
||||
{ id: "rmsg-1", roomId: roomA.id, role: "user", content: "Room hello", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: null, mentions: [] },
|
||||
{ id: "rmsg-2", roomId: roomA.id, role: "assistant", content: "Latest room reply", createdAt: "2026-04-08T00:00:10.000Z", senderAgentId: "agent-1", mentions: [] },
|
||||
],
|
||||
});
|
||||
|
||||
const { unmount } = render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(metrics.getScrollTop()).toBe(960);
|
||||
});
|
||||
|
||||
metrics.setScrollTop(0);
|
||||
unmount();
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(metrics.getScrollTop()).toBe(960);
|
||||
});
|
||||
} finally {
|
||||
metrics.restore();
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("FN-4118: anchors to the live tail when a new room message arrives", async () => {
|
||||
const restoreMatchMedia = mockDesktopViewport();
|
||||
const metrics = mockMessagesContainerMetrics({ scrollHeight: 980, clientHeight: 240 });
|
||||
|
||||
try {
|
||||
setup({}, {
|
||||
activeRoom: roomA,
|
||||
messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }],
|
||||
});
|
||||
const { rerender } = render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
||||
metrics.setScrollTop(980);
|
||||
fireEvent.scroll(messagesContainer);
|
||||
setup({}, {
|
||||
activeRoom: roomA,
|
||||
messages: [
|
||||
{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] },
|
||||
{ id: "rmsg-2", roomId: roomA.id, role: "assistant", content: "Two", createdAt: "2026-04-08T00:00:10.000Z", senderAgentId: "agent-1", mentions: [] },
|
||||
],
|
||||
});
|
||||
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(metrics.getScrollTop()).toBe(980);
|
||||
});
|
||||
} finally {
|
||||
metrics.restore();
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("FN-4118: does not yank room scrollback readers when new messages arrive", async () => {
|
||||
const restoreMatchMedia = mockDesktopViewport();
|
||||
const metrics = mockMessagesContainerMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 720 });
|
||||
|
||||
try {
|
||||
setup({}, {
|
||||
activeRoom: roomA,
|
||||
messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }],
|
||||
});
|
||||
const { rerender } = render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
||||
metrics.setScrollTop(720);
|
||||
fireEvent.scroll(messagesContainer);
|
||||
|
||||
setup({}, {
|
||||
activeRoom: roomA,
|
||||
messages: [
|
||||
{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] },
|
||||
{ id: "rmsg-2", roomId: roomA.id, role: "assistant", content: "Two", createdAt: "2026-04-08T00:00:10.000Z", senderAgentId: "agent-1", mentions: [] },
|
||||
],
|
||||
});
|
||||
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(metrics.getScrollTop()).toBe(720);
|
||||
});
|
||||
} finally {
|
||||
metrics.restore();
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("FN-4118: mobile visibility restore re-anchors an active room thread", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const metrics = mockMessagesContainerMetrics({ scrollHeight: 1180, clientHeight: 240, initialScrollTop: 250 });
|
||||
|
||||
try {
|
||||
setup({}, {
|
||||
activeRoom: roomA,
|
||||
messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
|
||||
fireEvent(document, new Event("visibilitychange"));
|
||||
metrics.setScrollTop(300);
|
||||
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
||||
fireEvent(document, new Event("visibilitychange"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(metrics.getScrollTop()).toBe(1180);
|
||||
});
|
||||
} finally {
|
||||
metrics.restore();
|
||||
restoreMatchMedia.mockRestore();
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
||||
}
|
||||
});
|
||||
|
||||
it("FN-4118: mobile pageshow restore re-anchors an active room thread", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const metrics = mockMessagesContainerMetrics({ scrollHeight: 1180, clientHeight: 240, initialScrollTop: 250 });
|
||||
|
||||
try {
|
||||
setup({}, {
|
||||
activeRoom: roomA,
|
||||
messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
metrics.setScrollTop(300);
|
||||
fireEvent(window, new Event("pageshow"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(metrics.getScrollTop()).toBe(1180);
|
||||
});
|
||||
} finally {
|
||||
metrics.restore();
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps direct mode behavior unchanged when rooms are enabled", async () => {
|
||||
localStorage.setItem("fusion:chat-scope", "direct");
|
||||
const addToast = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user