feat(FN-3603): improve mobile chat with jump-to-latest and bubble width fix
This merge implements FN-3603, adding jump-to-latest controls for the chat view and improving mobile bubble width layout, with corresponding CSS updates in ChatView and QuickChatFAB components. It also includes documentation for the new mobile chat controls, a fix for workspace lint regex escaping, Fusion-Task-Id: FN-3603
This commit is contained in:
@@ -43,4 +43,9 @@ describe("quick-chat tool-call mobile layout css", () => {
|
||||
expect(scopedSummaryRules.length).toBeGreaterThan(0);
|
||||
expect(scopedSummaryRules.every((rule) => !/flex-direction:\s*column/.test(rule))).toBe(true);
|
||||
});
|
||||
|
||||
it("widens quick-chat message bubbles on mobile while keeping jump control above safe area", () => {
|
||||
expect(mobileCss).toMatch(/\.quick-chat-panel-message\s*\{[^}]*max-width:\s*90%/);
|
||||
expect(mobileCss).toMatch(/\.quick-chat-jump-to-latest\s*\{[^}]*env\(safe-area-inset-bottom,\s*0px\)/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,6 +214,7 @@
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-thread-header {
|
||||
@@ -284,6 +285,14 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-jump-to-latest {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(var(--space-xl) * 3);
|
||||
transform: translateX(-50%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.chat-message--user {
|
||||
align-self: flex-end;
|
||||
background: var(--accent);
|
||||
@@ -1122,4 +1131,12 @@
|
||||
width: calc(var(--space-lg) * 2.25);
|
||||
height: calc(var(--space-lg) * 2.25);
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
max-width: 82%;
|
||||
}
|
||||
|
||||
.chat-jump-to-latest {
|
||||
bottom: calc(var(--space-xl) * 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Paperclip,
|
||||
File,
|
||||
Wrench,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { useChat, type ChatMessageInfo, type ToolCallInfo } from "../hooks/useChat";
|
||||
import { useViewportMode } from "./Header";
|
||||
@@ -743,6 +744,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
// Attachment state mirrors QuickEntryBox: pending files selected before send.
|
||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||
|
||||
// File mention state and hook
|
||||
const [, setFileMentionPopupVisible] = useState(false);
|
||||
@@ -766,6 +768,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
}, [fileMention.mentionActive]);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const isUserScrollingRef = useRef(false);
|
||||
const hideSkillMenuTimeoutRef = useRef<number | null>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -845,14 +848,32 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Scroll thread container to bottom on new messages or streaming.
|
||||
// Avoid Element.scrollIntoView() here because on mobile Safari it can
|
||||
// scroll the page viewport instead of only the chat thread.
|
||||
useEffect(() => {
|
||||
const updateScrollState = useCallback(() => {
|
||||
const messagesContainer = messagesContainerRef.current;
|
||||
if (!messagesContainer) return;
|
||||
|
||||
const threshold = 50;
|
||||
const atBottom = messagesContainer.scrollTop + messagesContainer.clientHeight >= messagesContainer.scrollHeight - threshold;
|
||||
setIsUserScrolling(!atBottom);
|
||||
isUserScrollingRef.current = !atBottom;
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const messagesContainer = messagesContainerRef.current;
|
||||
if (!messagesContainer) return;
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}, [messages, streamingText, streamingThinking, isStreaming]);
|
||||
setIsUserScrolling(false);
|
||||
isUserScrollingRef.current = false;
|
||||
}, []);
|
||||
|
||||
// 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
|
||||
// scroll the page viewport instead of only the chat thread.
|
||||
useEffect(() => {
|
||||
if (!isUserScrollingRef.current) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [messages, streamingText, streamingThinking, isStreaming, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyboardOverlap <= 0) {
|
||||
@@ -864,8 +885,8 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}, [keyboardOverlap]);
|
||||
scrollToBottom();
|
||||
}, [keyboardOverlap, scrollToBottom]);
|
||||
|
||||
// Lock body scroll on mobile while the keyboard is up so iOS can't shift
|
||||
// the visual viewport (offsetTop > 0). Shared hook also restores
|
||||
@@ -1721,7 +1742,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div className="chat-messages" ref={messagesContainerRef}>
|
||||
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
|
||||
{isStreaming ? (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
@@ -1795,6 +1816,17 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
{isUserScrolling && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm chat-jump-to-latest"
|
||||
data-testid="chat-jump-to-latest"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
Latest
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
{activeSession && (
|
||||
|
||||
@@ -410,6 +410,14 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.quick-chat-jump-to-latest {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(var(--space-xl) * 3);
|
||||
transform: translateX(-50%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.quick-chat-panel-message p {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
@@ -880,6 +888,14 @@
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.quick-chat-panel-message {
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.quick-chat-jump-to-latest {
|
||||
bottom: calc(var(--space-xl) * 4 + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
import { Eye, EyeOff, MessageSquare, Paperclip, Plus, Send, Square, Wrench, X } from "lucide-react";
|
||||
import { ChevronDown, Eye, EyeOff, MessageSquare, Paperclip, Plus, Send, Square, Wrench, X } from "lucide-react";
|
||||
import { fetchDiscoveredSkills, fetchModels, type Agent, type ModelInfo } from "../api";
|
||||
import type { DiscoveredSkill } from "@fusion/dashboard";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
@@ -892,6 +892,7 @@ export function QuickChatFAB({
|
||||
/** Pending attachments staged in the composer before being sent. */
|
||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [isAttachmentDragOver, setIsAttachmentDragOver] = useState(false);
|
||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||
|
||||
// File mention state and hook
|
||||
const [, setFileMentionPopupVisible] = useState(false);
|
||||
@@ -978,6 +979,7 @@ export function QuickChatFAB({
|
||||
// visually grows to full height immediately on blur and the keyboard
|
||||
// slides down on top of it.
|
||||
const suppressVvShrinkRef = useRef(false);
|
||||
const isUserScrollingRef = useRef(false);
|
||||
|
||||
// Pin the document at the top while the panel is open on mobile.
|
||||
// Otherwise iOS can leave window.scrollY > 0 (e.g. after the keyboard
|
||||
@@ -1407,13 +1409,31 @@ export function QuickChatFAB({
|
||||
};
|
||||
}, [isOpen, setIsOpen]);
|
||||
|
||||
// Auto-scroll messages
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const updateScrollState = useCallback(() => {
|
||||
const messagesEl = messagesRef.current;
|
||||
if (!messagesEl) return;
|
||||
|
||||
const threshold = 50;
|
||||
const atBottom = messagesEl.scrollTop + messagesEl.clientHeight >= messagesEl.scrollHeight - threshold;
|
||||
setIsUserScrolling(!atBottom);
|
||||
isUserScrollingRef.current = !atBottom;
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const messagesEl = messagesRef.current;
|
||||
if (!messagesEl) return;
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}, [messages, streamingText, streamingThinking, isStreaming, isOpen]);
|
||||
setIsUserScrolling(false);
|
||||
isUserScrollingRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Auto-scroll messages when user is near the live tail.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!isUserScrollingRef.current) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [messages, streamingText, streamingThinking, isStreaming, isOpen, scrollToBottom]);
|
||||
|
||||
const sessionOptions = useMemo(() => {
|
||||
const agentNameById = new Map(agents.map((agent) => [agent.id, agent.name?.trim() || agent.id]));
|
||||
@@ -2269,7 +2289,7 @@ export function QuickChatFAB({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="quick-chat-panel-messages" ref={messagesRef} data-testid="quick-chat-messages">
|
||||
<div className="quick-chat-panel-messages" ref={messagesRef} data-testid="quick-chat-messages" onScroll={updateScrollState}>
|
||||
{sessionsLoading ? (
|
||||
<div className="quick-chat-panel-empty">Loading conversation…</div>
|
||||
) : isStreaming ? (
|
||||
@@ -2345,6 +2365,18 @@ export function QuickChatFAB({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isUserScrolling && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm quick-chat-jump-to-latest"
|
||||
data-testid="quick-chat-jump-to-latest"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
Latest
|
||||
</button>
|
||||
)}
|
||||
|
||||
{pendingAttachments.length > 0 && (
|
||||
<div className="quick-chat-attachment-previews" data-testid="quick-chat-attachment-previews">
|
||||
{pendingAttachments.map((attachment, index) => (
|
||||
|
||||
@@ -2926,6 +2926,42 @@ describe("ChatView mobile behavior", () => {
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows jump-to-latest only after scrolling away from bottom and jumps back on click", async () => {
|
||||
const restoreMatchMedia = mockDesktopViewport();
|
||||
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: () => 1000 });
|
||||
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, get: () => 200 });
|
||||
Object.defineProperty(messagesContainer, "scrollTop", {
|
||||
configurable: true,
|
||||
get: () => scrollTopValue,
|
||||
set: (value: number) => {
|
||||
scrollTopValue = value;
|
||||
},
|
||||
});
|
||||
|
||||
scrollTopValue = 600;
|
||||
fireEvent.scroll(messagesContainer);
|
||||
expect(screen.getByTestId("chat-jump-to-latest")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByTestId("chat-jump-to-latest"));
|
||||
expect(scrollTopValue).toBe(1000);
|
||||
expect(screen.queryByTestId("chat-jump-to-latest")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView mobile CSS contract", () => {
|
||||
@@ -3023,4 +3059,8 @@ describe("ChatView mobile CSS contract", () => {
|
||||
it("mobile includes keyboard-aware chat-thread height rule", () => {
|
||||
expect(css).toMatch(/\.chat-thread\[style\*=\"--keyboard-overlap\"\]\s*\{[^}]*--vv-height/);
|
||||
});
|
||||
|
||||
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%/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -435,4 +435,41 @@ describe("QuickChatFAB session-first UX", () => {
|
||||
expect(saved).toContain("\"x\"");
|
||||
expect(saved).toContain("\"y\"");
|
||||
});
|
||||
|
||||
it("shows jump-to-latest only after leaving live tail and scrolls back on click", async () => {
|
||||
mockFetchChatMessages.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
id: "msg-1",
|
||||
sessionId: "session-model",
|
||||
role: "assistant",
|
||||
content: "First",
|
||||
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 = 0;
|
||||
Object.defineProperty(messages, "scrollHeight", { configurable: true, get: () => 1200 });
|
||||
Object.defineProperty(messages, "clientHeight", { configurable: true, get: () => 240 });
|
||||
Object.defineProperty(messages, "scrollTop", {
|
||||
configurable: true,
|
||||
get: () => scrollTopValue,
|
||||
set: (value: number) => {
|
||||
scrollTopValue = value;
|
||||
},
|
||||
});
|
||||
|
||||
scrollTopValue = 700;
|
||||
fireEvent.scroll(messages);
|
||||
expect(screen.getByTestId("quick-chat-jump-to-latest")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-chat-jump-to-latest"));
|
||||
expect(scrollTopValue).toBe(1200);
|
||||
expect(screen.queryByTestId("quick-chat-jump-to-latest")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user