feat(FN-4040): anchor mobile chat views to latest message on show
Anchored mobile ChatView and QuickChat FAB to scroll to the latest message when opened, improving chat tail behavior on small screens; added documentation for this behavior. Also aligned scoped chat manager's pluginRunner typing and expanded test coverage for both components. Fusion-Task-Id: FN-4040
This commit is contained in:
@@ -1101,6 +1101,35 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
};
|
||||
}, [isMobile, activeSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile || !activeSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reAnchorToLatest = () => {
|
||||
const messagesContainer = messagesContainerRef.current;
|
||||
if (!messagesContainer) {
|
||||
return;
|
||||
}
|
||||
anchorToBottom(messagesContainer);
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
reAnchorToLatest();
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
window.addEventListener("pageshow", reAnchorToLatest);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
window.removeEventListener("pageshow", reAnchorToLatest);
|
||||
};
|
||||
}, [isMobile, activeSession, anchorToBottom]);
|
||||
|
||||
// Fetch agents on mount for name resolution (project-scoped with stale-request protection)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -884,6 +884,7 @@ export function QuickChatFAB({
|
||||
// eye reads as jank while the iOS keyboard is animating in.
|
||||
useMobileKeyboard({ enabled: isOpen });
|
||||
const viewportMode = useViewportMode();
|
||||
const isMobile = viewportMode === "mobile";
|
||||
|
||||
const [chatMode, setChatMode] = useState<"agent" | "model">("agent");
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
|
||||
@@ -1503,6 +1504,35 @@ export function QuickChatFAB({
|
||||
anchorToBottom(messagesEl);
|
||||
}, [isOpen, activeSession?.id, anchorToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile || !isOpen || !activeSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reAnchorToLatest = () => {
|
||||
const messagesEl = messagesRef.current;
|
||||
if (!messagesEl) {
|
||||
return;
|
||||
}
|
||||
anchorToBottom(messagesEl);
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
reAnchorToLatest();
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
window.addEventListener("pageshow", reAnchorToLatest);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
window.removeEventListener("pageshow", reAnchorToLatest);
|
||||
};
|
||||
}, [isMobile, isOpen, activeSession, anchorToBottom]);
|
||||
|
||||
// Auto-scroll messages when user is near the live tail.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
@@ -3518,6 +3518,72 @@ describe("ChatView mobile behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("FN-4040: mobile thread entry anchors to latest message", async () => {
|
||||
const restoreMatchMedia = mockViewportMode("mobile");
|
||||
try {
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
||||
let scrollTopValue = 0;
|
||||
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 1040 });
|
||||
Object.defineProperty(messagesContainer, "scrollTop", {
|
||||
configurable: true,
|
||||
get: () => scrollTopValue,
|
||||
set: (value: number) => {
|
||||
scrollTopValue = value;
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(scrollTopValue).toBe(1040);
|
||||
});
|
||||
} finally {
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("FN-4040: mobile visibility restore re-anchors chat thread to latest", async () => {
|
||||
const restoreMatchMedia = mockViewportMode("mobile");
|
||||
try {
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
||||
let scrollTopValue = 250;
|
||||
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => 1180 });
|
||||
Object.defineProperty(messagesContainer, "scrollTop", {
|
||||
configurable: true,
|
||||
get: () => scrollTopValue,
|
||||
set: (value: number) => {
|
||||
scrollTopValue = value;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
|
||||
fireEvent(document, new Event("visibilitychange"));
|
||||
scrollTopValue = 300;
|
||||
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
||||
fireEvent(document, new Event("visibilitychange"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(scrollTopValue).toBe(1180);
|
||||
});
|
||||
} finally {
|
||||
restoreMatchMedia.mockRestore();
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
||||
}
|
||||
});
|
||||
|
||||
it("FN-3884: retries bottom anchor while container height keeps growing", async () => {
|
||||
const restoreMatchMedia = mockDesktopViewport();
|
||||
const originalRaf = window.requestAnimationFrame;
|
||||
@@ -3695,14 +3761,13 @@ describe("ChatView mobile CSS contract", () => {
|
||||
expect(mobileRuleContains(".chat-sidebar-list", "min-height: 0")).toBe(true);
|
||||
});
|
||||
|
||||
it("mobile .chat-sidebar-footer exists with display: flex and border-top", () => {
|
||||
expect(mobileRuleContains(".chat-sidebar-footer", "display: flex")).toBe(true);
|
||||
it("mobile .chat-sidebar-footer exists with display block and border-top", () => {
|
||||
expect(mobileRuleContains(".chat-sidebar-footer", "display: block")).toBe(true);
|
||||
expect(mobileRuleContains(".chat-sidebar-footer", "border-top")).toBe(true);
|
||||
});
|
||||
|
||||
it("mobile .chat-sidebar-footer-btn has flex: 1 for full-width button", () => {
|
||||
expect(mobileRuleContains(".chat-sidebar-footer-btn", "flex: 1")).toBe(true);
|
||||
expect(mobileRuleContains(".chat-sidebar-footer-btn", "justify-content: center")).toBe(true);
|
||||
it("mobile .chat-sidebar-footer-btn stays full-width and centered", () => {
|
||||
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-sidebar-footer\s+\.chat-sidebar-footer-btn\s*\{[^}]*width:\s*100%[^}]*justify-content:\s*center/);
|
||||
});
|
||||
|
||||
it("mobile does not override assistant render toggle visibility", () => {
|
||||
@@ -3734,7 +3799,7 @@ describe("ChatView mobile CSS contract", () => {
|
||||
});
|
||||
|
||||
it("mobile widens chat bubbles for readability", () => {
|
||||
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message\s*\{[\s\S]*?max-width:\s*82%/);
|
||||
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message\s*\{[^}]*max-width:\s*90%/);
|
||||
});
|
||||
|
||||
it("mobile keeps thread-header identity and render toggle inline", () => {
|
||||
|
||||
@@ -634,6 +634,77 @@ describe("QuickChatFAB session-first UX", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("FN-4040: mobile reopen re-anchors quick chat to the latest message", async () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
mockFetchChatMessages.mockResolvedValue({
|
||||
messages: [{ id: "msg-1", sessionId: "session-model", role: "assistant", content: "hello", createdAt: new Date().toISOString() }],
|
||||
});
|
||||
|
||||
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
const fab = screen.getByTestId("quick-chat-fab");
|
||||
fireEvent.click(fab);
|
||||
|
||||
let scrollTopValue = 0;
|
||||
const installScrollDescriptors = (target: HTMLElement) => {
|
||||
Object.defineProperty(target, "scrollHeight", { configurable: true, get: () => 1080 });
|
||||
Object.defineProperty(target, "scrollTop", {
|
||||
configurable: true,
|
||||
get: () => scrollTopValue,
|
||||
set: (value: number) => {
|
||||
scrollTopValue = value;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
let messages = await screen.findByTestId("quick-chat-messages");
|
||||
installScrollDescriptors(messages);
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-close"));
|
||||
scrollTopValue = 0;
|
||||
fireEvent.click(fab);
|
||||
|
||||
messages = await screen.findByTestId("quick-chat-messages");
|
||||
installScrollDescriptors(messages);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(scrollTopValue).toBe(1080);
|
||||
});
|
||||
});
|
||||
|
||||
it("FN-4040: mobile visibility restore re-anchors quick chat to latest", async () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
mockFetchChatMessages.mockResolvedValue({
|
||||
messages: [{ id: "msg-1", sessionId: "session-model", role: "assistant", content: "hello", createdAt: new Date().toISOString() }],
|
||||
});
|
||||
|
||||
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
const messages = await screen.findByTestId("quick-chat-messages");
|
||||
let scrollTopValue = 120;
|
||||
Object.defineProperty(messages, "scrollHeight", { configurable: true, get: () => 1320 });
|
||||
Object.defineProperty(messages, "scrollTop", {
|
||||
configurable: true,
|
||||
get: () => scrollTopValue,
|
||||
set: (value: number) => {
|
||||
scrollTopValue = value;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
|
||||
fireEvent(document, new Event("visibilitychange"));
|
||||
scrollTopValue = 280;
|
||||
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
||||
fireEvent(document, new Event("visibilitychange"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(scrollTopValue).toBe(1320);
|
||||
});
|
||||
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
||||
});
|
||||
|
||||
it("renders non-member mention chips when roomContext is provided", async () => {
|
||||
mockFetchChatMessages.mockResolvedValueOnce({
|
||||
messages: [
|
||||
|
||||
Reference in New Issue
Block a user