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:
Fusion
2026-05-12 09:17:17 -07:00
committed by gsxdsm
parent 87377794e5
commit 24b839bb1b
3 changed files with 257 additions and 18 deletions

View 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.

View File

@@ -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(() => {

View File

@@ -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();