From 1998d92e0595127a9dc5655fecfb19a8b91ccac7 Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:35:32 +0000 Subject: [PATCH] Make the chat page usable on a phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat page is three inline columns — session list, transcript, side panel — and below ~768px there is no width left to split. At 412px the transcript collapsed to a ~100px sliver wrapping one word per line, with the session list half off-screen. That is the screenshot in #271. Below `md`, turn the two side columns into overlays so the transcript keeps the full viewport: - session list becomes an off-canvas drawer over a tap-to-dismiss scrim, closed by default, opened from the existing header toggle - side panel covers the transcript instead of taking a 45% split - header sheds what does not fit: backend and model chips, the context bar and the Langfuse link stay desktop-only, and the title truncates Both overlays are modals, so `useModalSurface` gives them what a modal owes the keyboard: focus moves in on open, Tab cycles inside instead of walking onto the transcript and navigation behind them, Escape dismisses (claimed locally, so it no longer reaches the global Escape shortcut and stops a streaming response), and focus returns to the opener on close. `role="dialog"` + `aria-modal` tell assistive technology to treat what is behind them as inert. Every conversation link in the drawer closes it. Watching `activeSession` alone is not enough: re-tapping the conversation that is already open never changes the route, so the drawer would stay parked over the transcript it was asked to reveal. Drawer state is deliberately separate from `sidebarCollapsed`. That flag is a persisted desktop preference which defaults to expanded, so reusing it would pop the drawer open on first load and let a phone overwrite the desktop layout. It lives in the store rather than in `ChatPage` so the keyboard can reach it: Cmd+Shift+S and Cmd+K now go through `toggleSessionList` / `revealSessionList`, which pick the drawer or the desktop column by viewport. Previously both drove `sidebarCollapsed`, so on a phone Cmd+Shift+S rewrote the desktop preference while moving nothing on screen, and Cmd+K focused the search field inside a closed, inert drawer. Both resize handles are pointer-driven and mouse-only, so neither is rendered in overlay mode. Desktop layout is unchanged: every new rule is behind a media query or the `mobile` branch. Refs #271 --- web/src/App.tsx | 5 +- web/src/components/Chat/SessionSidebar.tsx | 61 +++++++++-- web/src/components/Chat/SidePanel.tsx | 41 +++++++ web/src/hooks/useMediaQuery.ts | 44 ++++++++ web/src/hooks/useModalSurface.ts | 120 +++++++++++++++++++++ web/src/pages/ChatPage.tsx | 79 ++++++++++---- web/src/stores/chatStore.ts | 32 +++++- 7 files changed, 353 insertions(+), 29 deletions(-) create mode 100644 web/src/hooks/useMediaQuery.ts create mode 100644 web/src/hooks/useModalSurface.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index e5a2cf33..ea1da8f0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -132,7 +132,10 @@ function GlobalShortcuts() { action: () => { const focusNow = () => { const store = useChatStore.getState(); - if (store.sidebarCollapsed) store.toggleSidebar(); + // On a phone the list is an off-canvas drawer, on desktop a collapsible + // column; revealSessionList opens whichever one this viewport uses, so + // the search field is never focused inside a closed, inert drawer. + store.revealSessionList(); // The sidebar search input is unmounted until something asks for it. // requestSearchFocus bumps a nonce the sidebar subscribes to. store.requestSearchFocus(); diff --git a/web/src/components/Chat/SessionSidebar.tsx b/web/src/components/Chat/SessionSidebar.tsx index 60da4210..7cd0680c 100644 --- a/web/src/components/Chat/SessionSidebar.tsx +++ b/web/src/components/Chat/SessionSidebar.tsx @@ -4,6 +4,7 @@ import { Plus, X, MessageSquare, ChevronRight, ChevronDown, Bot, Loader2, Search import type { Session, AgentStatus } from '../../types/chat'; import { groupByDate, parseTimestamp } from '../../utils/dateGroups'; import { useChatStore } from '../../stores/chatStore'; +import { useModalSurface } from '../../hooks/useModalSurface'; /** Strip leading '#' and 'Implement: ' prefixes from generated titles. */ function cleanTitle(session: Session): string { @@ -54,13 +55,17 @@ function saveCollapsedGroups(groups: Set): void { } catch { /* quota exceeded / disabled — keep the in-memory state only */ } } -export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, onDelete, collapsed }: { +export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, onDelete, collapsed, mobile = false, onRequestClose }: { sessions: Session[]; activeSession: string; agentStatus: AgentStatus; onCreate: () => void; onDelete: (id: string) => void; collapsed?: boolean; + /** Render as an off-canvas drawer instead of an inline column. */ + mobile?: boolean; + /** Drawer mode only — tapping the scrim asks the parent to close. */ + onRequestClose?: () => void; }) { const [systemExpanded, setSystemExpanded] = useState(false); const [collapsedGroups, setCollapsedGroups] = useState>(loadCollapsedGroups); @@ -79,6 +84,19 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, const { searchResults, searchLoading, searchSessions, clearSearch, renameSession, toggleStar, archiveSession, virtualSession, discardVirtualSession, sidebarWidth, setSidebarWidth } = useChatStore(); const searchFocusNonce = useChatStore(s => s.searchFocusNonce); + // In drawer mode the list is a modal overlay: it needs focus, Tab + // containment, Escape, and focus restoration. Declared before the search + // effects below so that when Cmd+K opens the drawer and asks for search + // focus in the same tick, the search input wins the race. + const drawerOpen = mobile && !collapsed; + const { dialogProps } = useModalSurface(drawerOpen, onRequestClose); + + // Opening a conversation should reveal it, so every row dismisses the + // drawer. Leaving this to the parent's `activeSession` watcher isn't enough: + // re-tapping the conversation that is already open never changes the route, + // and the drawer would stay parked over the transcript. + const handleSelect = mobile ? onRequestClose : undefined; + // Drag-to-resize the session list. It is left-anchored against the nav rail, // so the width tracks the cursor 1:1. The width transition is disabled while // dragging so it stays responsive. @@ -279,12 +297,31 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, }, [runningSystemCount]); // eslint-disable-line react-hooks/exhaustive-deps return ( + <> + {/* Drawer scrim. Only in mobile mode, and only while open — a phone has + no room for a persistent column, so the list sits above the + transcript and the scrim is what dismisses it. */} + {drawerOpen && ( + @@ -525,6 +567,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, + ); } @@ -662,7 +706,7 @@ function StatusIndicator({ session, isActive, isRunning }: { } -function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggleStar, onArchive, showDate }: { +function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggleStar, onArchive, onSelect, showDate }: { session: Session; isActive: boolean; isRunning: boolean; @@ -670,6 +714,8 @@ function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggl onRename: (id: string, title: string) => Promise; onToggleStar: (id: string) => Promise; onArchive: (id: string) => Promise; + /** Fired when the row itself is opened (not its menu) — drawer mode uses it to close. */ + onSelect?: () => void; showDate?: boolean; }) { const [menuOpen, setMenuOpen] = useState(false); @@ -727,6 +773,7 @@ function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggl return ( s.focusPanelTab); const closePanelTab = useChatStore(s => s.closePanelTab); const setPanelWidth = useChatStore(s => s.setPanelWidth); + const mobile = useIsMobile(); const activeTab = panels.find(p => p.id === activePanelId) || panels[0] || null; const containerRef = useRef(null); + // On a phone this panel covers the whole viewport, which makes it a modal: + // without this, the transcript and the navigation underneath stay in the tab + // order and Tab lands on controls nobody can see. + const { dialogProps } = useModalSurface( + mobile && panelVisible && panels.length > 0, + togglePanel, + ); + // Drag-to-resize (disable transition during drag for responsiveness) const [isDragging, setIsDragging] = useState(false); const handleResizeStart = useCallback((e: React.MouseEvent) => { @@ -306,6 +317,36 @@ export function SidePanel() { const isOpen = panelVisible; const showTabs = panels.length > 1; + // A phone has no room to split the viewport: at 412px a 45% panel leaves the + // transcript an unreadable sliver. Cover it instead, and dismiss the same way + // as on desktop — via the tab header's close button. + if (mobile) { + return ( +
+ {showTabs && ( + + )} + + {activeTab.type === 'files' + ? + : activeTab.type === 'workflow' + ? + : + } + {activeTab.type === 'plan' && } +
+ ); + } + return (
void) => { + const mql = window.matchMedia(query); + mql.addEventListener('change', onStoreChange); + return () => mql.removeEventListener('change', onStoreChange); + }, [query]); + + return useSyncExternalStore( + subscribe, + () => window.matchMedia(query).matches, + // Server snapshot: assume desktop, matching the pre-JS markup. + () => false, + ); +} + +/** True on phone-sized viewports (below Tailwind's `md`). */ +export function useIsMobile(): boolean { + return useMediaQuery(MOBILE_QUERY); +} + +/** + * One-shot check for code that runs outside React — store actions and keyboard + * shortcut handlers, which need the current layout but cannot call hooks. + */ +export function isMobileViewport(): boolean { + return window.matchMedia(MOBILE_QUERY).matches; +} diff --git a/web/src/hooks/useModalSurface.ts b/web/src/hooks/useModalSurface.ts new file mode 100644 index 00000000..32a7b88e --- /dev/null +++ b/web/src/hooks/useModalSurface.ts @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useRef } from 'react'; + +/** + * Controls that can hold keyboard focus. Excludes what the browser has already + * taken out of the tab order (`disabled`, `tabindex="-1"`). + */ +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +function focusableWithin(root: HTMLElement): HTMLElement[] { + return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)) + // `offsetParent === null` drops `display:none` subtrees (e.g. a panel + // hidden with Tailwind's `hidden`): the selector still matches them, but + // they cannot take focus. + .filter(el => el.offsetParent !== null && !el.closest('[inert]')); +} + +/** + * Modal plumbing for an overlay that covers the page on a phone — a drawer or + * a full-screen panel. + * + * Covering the page visually is not enough. Left alone, the transcript and the + * navigation underneath stay in the tab order, so Tab walks focus onto controls + * the user cannot see. This gives the surface the four things a modal owes the + * keyboard: + * + * 1. focus moves into it when it opens, + * 2. Tab cycles inside it instead of escaping behind it, + * 3. Escape dismisses it — and is claimed here, so it does not *also* reach the + * global Escape shortcut and stop a streaming response, + * 4. focus returns to whatever opened it when it closes. + * + * `role="dialog"` + `aria-modal` cover the same ground for assistive + * technology, which treats everything outside an aria-modal dialog as inert. + * + * Spread `dialogProps` onto the surface element; add an `aria-label` there so + * the dialog has a name. + */ +export function useModalSurface(active: boolean, onClose?: () => void) { + const ref = useRef(null); + // Held in a ref so a new `onClose` identity cannot re-run the focus effect, + // which would yank focus back to the top of the surface mid-interaction. + const onCloseRef = useRef(onClose); + useEffect(() => { onCloseRef.current = onClose; }, [onClose]); + + useEffect(() => { + const surface = ref.current; + if (!active || !surface) return; + + const restoreTo = document.activeElement as HTMLElement | null; + // Focus the surface itself rather than its first control: that control is + // usually a search box or a close button, and starting there skips the + // surface's own heading for a screen reader. + surface.focus(); + + return () => { + if (!restoreTo?.isConnected) return; + const current = document.activeElement; + // Hand focus back only if it is still inside the surface, or adrift on + // because the surface was dismissed by tapping the scrim. If the + // user has already moved focus elsewhere, leave it there. + const adrift = !current || current === document.body; + if (adrift || surface.contains(current)) restoreTo.focus(); + }; + }, [active]); + + const onKeyDown = useCallback((e: React.KeyboardEvent) => { + const surface = ref.current; + if (!surface) return; + + if (e.key === 'Escape') { + const close = onCloseRef.current; + if (!close) return; + // Claim it before it bubbles to the document-level shortcut handler, + // where Escape means "stop the agent". + e.preventDefault(); + e.stopPropagation(); + close(); + return; + } + + if (e.key !== 'Tab') return; + const focusables = focusableWithin(surface); + if (focusables.length === 0) { + // Nothing to move to — better to hold focus on the surface than to let + // it land on something hidden behind the overlay. + e.preventDefault(); + return; + } + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const current = document.activeElement; + // Shift+Tab off the top would leave through the start of the surface; + // Tab off the bottom would leave through the end. Wrap both. + if (e.shiftKey && (current === first || current === surface)) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && current === last) { + e.preventDefault(); + first.focus(); + } + }, []); + + return { + dialogProps: { + ref, + role: 'dialog' as const, + 'aria-modal': active, + // Lets the surface hold focus without joining the tab order itself. + tabIndex: -1, + onKeyDown, + }, + }; +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index a7769bd6..67a57936 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useChatStore } from '../stores/chatStore'; import { SessionSidebar } from '../components/Chat/SessionSidebar'; @@ -15,6 +15,7 @@ import { ReviewLoopCard } from '../components/Chat/ReviewLoopCard'; import { Loader2, PanelLeftOpen, PanelLeftClose, Files, ExternalLink } from 'lucide-react'; import { api } from '../api/client'; import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; +import { useIsMobile } from '../hooks/useMediaQuery'; import type { ShortcutDef } from '../utils/keyboard'; import { copyToClipboard } from '../utils/clipboard'; import type { ChatMessage, TextBlockData } from '../types/chat'; @@ -42,13 +43,42 @@ export function ChatPage() { sessions, activeSession, virtualSession, messages, streamingBlocks, isStreaming, loading, agentStatus, contextUsage, backendStatus, currentTodos, currentCCTasks, - sidebarCollapsed, panels, + sidebarCollapsed, mobileSidebarOpen, panels, modifiedFiles, modifiedFilesCount, backendDefault, newChatBackend, loadSessions, switchSession, createSession, deleteSession, - sendMessage, stopSession, toggleSidebar, openFilesPanel, + sendMessage, stopSession, toggleSessionList, setMobileSidebarOpen, openFilesPanel, } = useChatStore(); + // Below `md` the session list becomes an off-canvas drawer. Its open state is + // deliberately NOT `sidebarCollapsed`: that one is a persisted desktop + // preference (default: expanded), and reusing it would both pop the drawer + // open on first load and let a phone overwrite the desktop layout. It lives + // in the store rather than here so the global Cmd+K shortcut can open it. + const isMobile = useIsMobile(); + const sessionListOpen = isMobile ? mobileSidebarOpen : !sidebarCollapsed; + + // Retire the drawer on the way out of the phone layout, so a later resize + // back down doesn't arrive with an overlay already on screen. Nothing + // flashes on the way out: above `md` the drawer state is not read at all. + useEffect(() => { + if (!isMobile) setMobileSidebarOpen(false); + }, [isMobile, setMobileSidebarOpen]); + + // Picking a conversation should reveal it, not leave the list covering it. + // The drawer closes itself the moment one of its links is tapped — including + // a tap on the conversation that is already open, which never reaches here + // because `activeSession` doesn't change. This is the backstop for switches + // from anywhere else (browser Back, a deep link). The first resolution of + // `activeSession` out of '' is skipped: it lands after the initial load and + // would otherwise slam the drawer shut right after Cmd+K opened it. + const previousSession = useRef(activeSession); + useEffect(() => { + const switched = previousSession.current && previousSession.current !== activeSession; + previousSession.current = activeSession; + if (switched) setMobileSidebarOpen(false); + }, [activeSession, setMobileSidebarOpen]); + // Chat-scoped keyboard shortcuts. Global ones (new chat, search, modal, // Esc cascade) live in in App.tsx. const chatShortcuts = useMemo(() => [ @@ -64,7 +94,9 @@ export function ChatPage() { combo: { mod: true, shift: true, key: 's' }, description: 'Toggle session sidebar', section: 'chat', - action: () => useChatStore.getState().toggleSidebar(), + // Not `toggleSidebar`: below `md` that would silently rewrite the + // persisted desktop preference and move nothing on screen. + action: () => useChatStore.getState().toggleSessionList(), }, { id: 'chat-focus-input', @@ -189,7 +221,9 @@ export function ChatPage() { agentStatus={agentStatus} onCreate={handleCreateSession} onDelete={handleDeleteSession} - collapsed={sidebarCollapsed} + collapsed={!sessionListOpen} + mobile={isMobile} + onRequestClose={() => setMobileSidebarOpen(false)} /> {/* Main content area: chat column + optional plan panel */} @@ -197,16 +231,17 @@ export function ChatPage() { {/* Chat column */}
{/* Header */} -
-
+
+
- + {virtualSession?.id === activeSession ? 'New chat' : (sessions.find(s => s.id === activeSession)?.title || activeSession)} @@ -218,7 +253,7 @@ export function ChatPage() { return ( { const model = sessions.find(s => s.id === activeSession)?.model; return model ? ( - + {formatModelLabel(model)} ) : null; @@ -266,9 +301,9 @@ export function ChatPage() { ); })()} {statusLabel && ( -
- - {statusLabel} +
+ + {statusLabel}
)} {backendStatus?.subtype === 'codex_rate_limits' && (() => { @@ -286,7 +321,7 @@ export function ChatPage() { ); })()}
-
+
{fileCount} )} - {contextUsage && s.id === activeSession)?.total_cost_usd} />} + {contextUsage && ( +
+ s.id === activeSession)?.total_cost_usd} /> +
+ )} {langfuse?.enabled && langfuse.host && activeSession && ( diff --git a/web/src/stores/chatStore.ts b/web/src/stores/chatStore.ts index 61890102..0614f984 100644 --- a/web/src/stores/chatStore.ts +++ b/web/src/stores/chatStore.ts @@ -4,6 +4,7 @@ import { ws } from '../api/websocket'; import type { WSMessage } from '../api/websocket'; import type { ChatMessage, MessageBlock, Session, AgentStatus, PanelTab, ModifiedFileSummary } from '../types/chat'; import { hydrateMessage } from '../utils/hydrateMessage'; +import { isMobileViewport } from '../hooks/useMediaQuery'; import { randomUUID } from '../utils/uuid'; // Helpers import { cancelAutoClose, clearAllAutoCloseTimers, MAX_COMPLETED_TABS } from './helpers/blockHelpers'; @@ -152,8 +153,16 @@ interface ChatState { toolInput: Record; } | null; - // Sidebar collapse + // Sidebar collapse (desktop column — persisted) sidebarCollapsed: boolean; + /** + * Whether the phone-sized off-canvas session drawer is showing. Deliberately + * separate from `sidebarCollapsed`: that one is a persisted *desktop* + * preference, so driving the drawer with it would both pop the drawer open on + * first load and let a phone overwrite the desktop layout. Lives in the store + * rather than in ChatPage so the global Cmd+K shortcut can open it. + */ + mobileSidebarOpen: boolean; // Modified files tracking modifiedFiles: ModifiedFileSummary[]; @@ -240,6 +249,11 @@ interface ChatState { answerInteraction: (result: Record | null) => void; denyInteraction: (message?: string) => void; toggleSidebar: () => void; + setMobileSidebarOpen: (open: boolean) => void; + /** Show/hide the session list, whichever form it takes on this viewport. */ + toggleSessionList: () => void; + /** Make sure the session list is on screen (Cmd+K, before focusing search). */ + revealSessionList: () => void; // Modified files fetchModifiedFiles: (sessionId: string) => Promise; openFilesPanel: () => void; @@ -291,6 +305,7 @@ export const useChatStore = create((set, get) => ({ sidebarWidth: parseFloat(localStorage.getItem('nerve_sidebar_width') || '240'), pendingInteraction: null, sidebarCollapsed: localStorage.getItem('nerve_sidebar_collapsed') === 'true', + mobileSidebarOpen: false, modifiedFiles: [], modifiedFilesCount: 0, backgroundTasks: [], @@ -431,6 +446,21 @@ export const useChatStore = create((set, get) => ({ set({ sidebarCollapsed: next }); }, + setMobileSidebarOpen: (open: boolean) => set({ mobileSidebarOpen: open }), + + // Both entry points below branch on the viewport so that the header button + // and the keyboard shortcuts stay in agreement — and so neither writes the + // persisted desktop preference from a phone. + toggleSessionList: () => { + if (isMobileViewport()) set({ mobileSidebarOpen: !get().mobileSidebarOpen }); + else get().toggleSidebar(); + }, + + revealSessionList: () => { + if (isMobileViewport()) set({ mobileSidebarOpen: true }); + else if (get().sidebarCollapsed) get().toggleSidebar(); + }, + // ------------------------------------------------------------------ // // Modified files // // ------------------------------------------------------------------ //