From 351702d0555c9d902fb4b95f60c52a768b1ab814 Mon Sep 17 00:00:00 2001 From: Fusion Date: Mon, 11 May 2026 16:19:12 -0700 Subject: [PATCH] feat(FN-4042): add mobile chat quick switcher to thread header Added a mobile chat quick switcher accessible from the thread header, with styling for the session dropdown and test coverage. Documentation for the feature was added to MOBILE.md and the dashboard guide. Fusion-Task-Id: FN-4042 --- MOBILE.md | 1 + docs/dashboard-guide.md | 1 + .../dashboard/app/components/ChatView.css | 101 +++++++++++++++++- .../dashboard/app/components/ChatView.tsx | 71 +++++++++++- .../components/__tests__/ChatView.test.tsx | 85 +++++++++++++++ 5 files changed, 255 insertions(+), 4 deletions(-) diff --git a/MOBILE.md b/MOBILE.md index d9875def7..831916a68 100644 --- a/MOBILE.md +++ b/MOBILE.md @@ -96,6 +96,7 @@ Planning Mode opens directly into the composer pane on mobile when no planning s ### 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. +- Full Chat direct-thread mobile headers include a title-triggered quick session switcher; preserve one-pane behavior (back-to-list still works) and keep the switcher scoped to direct sessions only (room threads keep existing room header/back behavior). - 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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 9815d0fc9..781606716 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -92,6 +92,7 @@ Chat view provides project-scoped conversations with agents. - If a regular Chat stream drops with a hidden-tab/browser-suspension error (for example `Load failed`) while the server is still generating, Chat suppresses the false error banner, re-attaches to the in-progress stream using the durable replay state, and reconciles the final assistant reply when generation completes. - 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 direct-chat threads, entering a thread and restoring Chat after tab/page visibility returns re-anchors to the newest message (`scrollTop = scrollHeight`) so the view always opens at the live tail. +- On mobile direct-chat threads, tapping the active title/identity in the thread header opens a lightweight conversation dropdown so you can switch to another direct session without backing out to the sidebar list first. - On mobile (`max-width: 768px`), chat bubbles are slightly wider in full Chat for improved readability while preserving header/composer gutters. - Full Chat tool-call summaries now use a denser mobile layout: grouped and single-call collapsed rows keep icon + label + status on one line (Quick Chat-style scanability) while expanded details remain unchanged. - The desktop Chat view toggle and mobile Chat tab now show an unread-response indicator when a live assistant reply arrives for your active chat thread after you leave Chat; opening Chat clears it immediately. diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 3c8a8d5ed..1f78b9b52 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -377,6 +377,101 @@ min-width: 0; } +.chat-mobile-session-menu { + position: relative; + min-width: 0; + max-width: 100%; +} + +.chat-mobile-session-trigger { + width: 100%; + max-width: 100%; + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: var(--space-sm); + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--text); + padding: var(--space-xs) var(--space-sm); + min-height: calc(var(--space-lg) * 2); +} + +.chat-mobile-session-trigger .chat-thread-header-title, +.chat-mobile-session-trigger .chat-model-tag { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-mobile-session-trigger svg:last-child { + margin-left: auto; + color: var(--text-muted); + flex-shrink: 0; +} + +.chat-mobile-session-trigger:hover { + background: var(--card-hover); +} + +.chat-mobile-session-trigger:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); +} + +.chat-mobile-session-dropdown { + position: absolute; + top: calc(100% + var(--space-xs)); + left: 0; + right: 0; + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-xs); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + box-shadow: var(--shadow-lg); + z-index: 4; + max-height: calc(var(--space-xl) * 10); + overflow-y: auto; +} + +.chat-mobile-session-option { + width: 100%; + display: flex; + align-items: center; + min-height: calc(var(--space-lg) * 2); + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text); + cursor: pointer; + text-align: left; + padding: var(--space-sm) var(--space-md); +} + +.chat-mobile-session-option:hover { + background: var(--card-hover); +} + +.chat-mobile-session-option:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); +} + +.chat-mobile-session-option--active { + background: color-mix(in srgb, var(--todo) 12%, transparent); +} + +.chat-mobile-session-option-title { + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .chat-thread-header-new-chat { flex-shrink: 0; } @@ -1343,7 +1438,11 @@ flex: 1 1 auto; min-width: 0; white-space: nowrap; - overflow: hidden; + overflow: visible; + } + + .chat-mobile-session-menu { + width: 100%; } .chat-thread-header-identity .chat-thread-header-title, diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 190f9d56f..08d1dd40f 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -781,6 +781,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView const [isDragOver, setIsDragOver] = useState(false); const [isUserScrolling, setIsUserScrolling] = useState(false); const [copyFeedbackByMessageId, setCopyFeedbackByMessageId] = useState>({}); + const [mobileSessionMenuOpen, setMobileSessionMenuOpen] = useState(false); // File mention state and hook const [, setFileMentionPopupVisible] = useState(false); @@ -804,6 +805,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView }, [fileMention.mentionActive]); const messagesEndRef = useRef(null); + const mobileSessionMenuRef = useRef(null); const isUserScrollingRef = useRef(false); const lastAnchoredSessionStateRef = useRef<{ sessionId: string; loaded: boolean; hasMessages: boolean } | null>(null); const hideSkillMenuTimeoutRef = useRef(null); @@ -1704,6 +1706,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView const handleSessionClick = useCallback( (id: string) => { selectSession(id); + setMobileSessionMenuOpen(false); if (isMobile) setSidebarVisible(false); }, [selectSession, isMobile], @@ -1713,6 +1716,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView const handleBack = useCallback(() => { selectSession(""); setSidebarVisible(true); + setMobileSessionMenuOpen(false); }, [selectSession]); // Render empty state (no active session) @@ -1738,6 +1742,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView : activeSession?.title || agentsMap.get(activeSession?.agentId ?? "")?.name || activeSession?.agentId || "Chat"; const showThreadHeaderModelTag = Boolean(activeModelTag && activeModelTag !== threadHeaderTitle); + const showMobileSessionSwitcher = isMobile && chatScope === "direct" && !!activeSession; const agentName = agentsMap.get(activeSession?.agentId ?? "")?.name || @@ -1766,6 +1771,30 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView setShowAllAsPlain((value) => !value); }, []); + useEffect(() => { + if (!mobileSessionMenuOpen) { + return; + } + + const handlePointerDown = (event: MouseEvent) => { + if (mobileSessionMenuRef.current?.contains(event.target as Node)) { + return; + } + setMobileSessionMenuOpen(false); + }; + + document.addEventListener("mousedown", handlePointerDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + }; + }, [mobileSessionMenuOpen]); + + useEffect(() => { + if (!isMobile || chatScope !== "direct" || sidebarVisible) { + setMobileSessionMenuOpen(false); + } + }, [isMobile, chatScope, sidebarVisible]); + const setCopyFeedback = useCallback((messageId: string, feedback: CopyFeedbackState) => { const existingTimeout = copyFeedbackTimeoutsRef.current.get(messageId); if (existingTimeout) { @@ -2245,9 +2274,45 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView )}
- {activeModelProvider ? : } - {threadHeaderTitle} - {showThreadHeaderModelTag && {activeModelTag}} + {showMobileSessionSwitcher ? ( +
+ + {mobileSessionMenuOpen && ( +
+ {filteredSessions.map((session) => ( + + ))} +
+ )} +
+ ) : ( + <> + {activeModelProvider ? : } + {threadHeaderTitle} + {showThreadHeaderModelTag && {activeModelTag}} + + )}
{hasThreadInView && (