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