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 messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const mobileSessionMenuRef = useRef<HTMLDivElement>(null);
|
const mobileSessionMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const isUserScrollingRef = useRef(false);
|
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 hideSkillMenuTimeoutRef = useRef<number | null>(null);
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -1077,25 +1077,24 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
}, [anchorToBottom]);
|
}, [anchorToBottom]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const sessionId = activeSession?.id ?? null;
|
const threadId = roomThreadActive ? (rooms.activeRoom?.id ?? null) : (activeSession?.id ?? null);
|
||||||
if (!sessionId) {
|
if (!threadId) {
|
||||||
lastAnchoredSessionStateRef.current = null;
|
lastAnchoredThreadStateRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextState = {
|
const nextState = {
|
||||||
sessionId,
|
threadId,
|
||||||
loaded: !messagesLoading,
|
loaded: roomThreadActive ? !rooms.messagesLoading : !messagesLoading,
|
||||||
hasMessages: messages.length > 0,
|
hasMessages: roomThreadActive ? rooms.messages.length > 0 : messages.length > 0,
|
||||||
};
|
};
|
||||||
const previousState = lastAnchoredSessionStateRef.current;
|
const previousState = lastAnchoredThreadStateRef.current;
|
||||||
const isSessionChanged = previousState?.sessionId !== sessionId;
|
const isThreadChanged = previousState?.threadId !== threadId;
|
||||||
const finishedLoading =
|
const finishedLoading = previousState?.threadId === threadId && !previousState.loaded && nextState.loaded;
|
||||||
previousState?.sessionId === sessionId && !previousState.loaded && nextState.loaded;
|
|
||||||
const firstMessagesArrived =
|
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) {
|
if (!shouldAnchor) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1106,8 +1105,19 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
}
|
}
|
||||||
|
|
||||||
anchorToBottom(messagesContainer);
|
anchorToBottom(messagesContainer);
|
||||||
lastAnchoredSessionStateRef.current = nextState;
|
lastAnchoredThreadStateRef.current = nextState;
|
||||||
}, [activeSession?.id, messages.length, messagesLoading, anchorToBottom]);
|
}, [
|
||||||
|
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.
|
// 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
|
// Avoid Element.scrollIntoView() here because on mobile Safari it can
|
||||||
@@ -1116,7 +1126,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
if (!isUserScrollingRef.current) {
|
if (!isUserScrollingRef.current) {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
}, [messages, streamingText, streamingThinking, isStreaming, scrollToBottom]);
|
}, [activeThreadMessages, streamingText, streamingThinking, isStreaming, scrollToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (keyboardOverlap <= 0) {
|
if (keyboardOverlap <= 0) {
|
||||||
@@ -1194,7 +1204,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
}, [isMobile, activeSession]);
|
}, [isMobile, activeSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMobile || !activeSession) {
|
if (!isMobile || (!activeSession && !roomThreadActive)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1220,7 +1230,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||||
window.removeEventListener("pageshow", reAnchorToLatest);
|
window.removeEventListener("pageshow", reAnchorToLatest);
|
||||||
};
|
};
|
||||||
}, [isMobile, activeSession, anchorToBottom]);
|
}, [isMobile, activeSession, roomThreadActive, anchorToBottom]);
|
||||||
|
|
||||||
// Fetch agents on mount for name resolution (project-scoped with stale-request protection)
|
// Fetch agents on mount for name resolution (project-scoped with stale-request protection)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -131,6 +131,78 @@ function mockMobileVisualViewport({ innerHeight, vvHeight }: { innerHeight: numb
|
|||||||
return { mockVV, listeners: { resize: resizeListeners, scroll: scrollListeners } };
|
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)", () => {
|
describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetInitialViewportHeight();
|
_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 () => {
|
it("keeps direct mode behavior unchanged when rooms are enabled", async () => {
|
||||||
localStorage.setItem("fusion:chat-scope", "direct");
|
localStorage.setItem("fusion:chat-scope", "direct");
|
||||||
const addToast = vi.fn();
|
const addToast = vi.fn();
|
||||||
|
|||||||
Reference in New Issue
Block a user