diff --git a/web/src/App.tsx b/web/src/App.tsx index 3c37a15e..94d9ec8a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -108,7 +108,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 71d37951..05f151b4 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', @@ -184,7 +216,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 */} @@ -192,16 +226,17 @@ export function ChatPage() { {/* Chat column */}
{/* Header */} -
-
+
+
- + {virtualSession?.id === activeSession ? 'New chat' : (sessions.find(s => s.id === activeSession)?.title || activeSession)} @@ -213,7 +248,7 @@ export function ChatPage() { return ( { const model = sessions.find(s => s.id === activeSession)?.model; return model ? ( - + {formatModelLabel(model)} ) : null; @@ -261,9 +296,9 @@ export function ChatPage() { ); })()} {statusLabel && ( -
- - {statusLabel} +
+ + {statusLabel}
)} {backendStatus?.subtype === 'codex_rate_limits' && (() => { @@ -281,7 +316,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 7de9f8b1..68d98934 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'; @@ -151,8 +152,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[]; @@ -231,6 +240,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; @@ -260,6 +274,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: [], @@ -404,6 +419,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 // // ------------------------------------------------------------------ //