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:
Fusion
2026-05-06 13:30:04 -07:00
committed by gsxdsm
parent 8df5d2617c
commit 91f187f9ff
10 changed files with 220 additions and 25 deletions

View File

@@ -88,6 +88,12 @@ Install from browser:
Planning Mode opens directly into the composer pane on mobile when no planning sessions exist, avoiding an empty-sidebar dead end. On desktop/tablet the split view is unaffected. Once sessions are saved, mobile shows the session list as usual and the user can navigate between list and detail panes.
### Chat and Quick Chat mobile scroll/readability behavior
- Chat and Quick Chat must keep scrolling container-scoped (`.chat-messages` / `.quick-chat-panel-messages`) and must not switch to page-level scroll APIs (including `scrollIntoView()`) to avoid mobile Safari viewport drift.
- Both surfaces now pause live-tail autoscroll when the user scrolls away from bottom, show a temporary **Latest** jump control, and resume tail-follow only after jumping back.
- Mobile bubble widths are intentionally slightly wider for readability, but safe-area padding, full-screen Quick Chat bounds, and compact mobile tool-call summaries must remain intact.
## CI/CD Pipeline
Mobile CI is defined in `.github/workflows/mobile.yml`.

View File

@@ -47,6 +47,8 @@ Chat view provides project-scoped conversations with agents.
- Entering `/clear` (exact match after trimming) in the composer starts a fresh thread for the current chat target instead of sending the literal command to the model
- On mobile, the New Chat and Delete Conversation dialogs use a compact inset treatment (centered, viewport-bounded, internally scrollable) instead of the app's default full-height mobile modal chrome.
- Full Chat and Quick Chat both consume the same streamed `/api/chat/sessions/:id/messages` response contract, and both now prefer the authoritative assistant `message` snapshot on `done` while still accumulating `text` chunks when present (so providers without incremental text streaming still render output immediately)
- Chat message lists now track near-bottom scroll state: while you are reading older messages, live streaming/new replies do not force-scroll; a **Latest** jump control appears until you return to the tail.
- On mobile (`max-width: 768px`), chat bubbles are slightly wider in full Chat for improved readability while preserving header/composer gutters.
![Chat view](./screenshots/chat-view.png)
@@ -67,6 +69,8 @@ Quick Chat is an optional floating panel for fast, project-scoped assistant conv
- Tool-call summaries in the floating quick-chat panel are intentionally condensed into a single-line header row (especially on small screens) so tool name + status stay scannable without multi-line wrapping
- On mobile viewports, opening Quick Chat auto-focuses the composer as soon as it is ready so the keyboard opens immediately
- FAB dragging uses pointer events with document-level move/up tracking and a 5px drag threshold so Android touch drags reposition reliably while short taps still open Quick Chat
- Quick Chat now mirrors full Chat tail behavior: if you scroll up, live updates stop auto-following and a **Latest** jump control appears until you jump back down.
- On mobile, Quick Chat bubbles are slightly wider while keeping compact tool-call summary layout and full-screen/safe-area behavior intact.
## Mailbox View

View File

@@ -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\)/);
});
});

View File

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

View File

@@ -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 && (

View File

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

View File

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

View File

@@ -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%/);
});
});

View File

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

View File

@@ -196,21 +196,27 @@ function checkAgainstBaseline() {
const protectedViolations = [];
if (candidateViolations.length > 0) {
sleepMs(1250);
const resampledProtected = snapshotProtectedFusion();
const resampledByDir = new Map(resampledProtected.map((entry) => [entry.dir, entry]));
// A live local app can write in bursts (e.g. heartbeat every few seconds),
// so do a short mutability probe before blaming tests.
const postSamples = [currentProtected];
for (let i = 0; i < 4; i++) {
sleepMs(500);
postSamples.push(snapshotProtectedFusion());
}
for (const dir of candidateViolations) {
const first = currentByDir.get(dir);
const second = resampledByDir.get(dir);
if (!first || !second) {
protectedViolations.push(dir);
continue;
let externallyActive = false;
for (let i = 1; i < postSamples.length; i++) {
const prev = postSamples[i - 1].find((entry) => entry.dir === dir);
const next = postSamples[i].find((entry) => entry.dir === dir);
if (!prev || !next) continue;
if (JSON.stringify(prev.entries) !== JSON.stringify(next.entries)) {
externallyActive = true;
break;
}
}
// If the directory is still mutating across back-to-back samples,
// treat it as externally active noise (same as baseline unstable dirs).
if (JSON.stringify(first.entries) === JSON.stringify(second.entries)) {
if (!externallyActive) {
protectedViolations.push(dir);
}
}