From 40d06b93a3de63538e0324f10905d78a8b912e27 Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:48:51 +0000 Subject: [PATCH] Keep the nav reachable while the side panel is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The side panel — sub-agents, plans, modified files, workflow tabs — was made a full-screen overlay for phones in the first PR of this series, and that was as far as it got tested. Opening it on a phone shows three problems. `fixed inset-0` covered the bottom nav as well as the chat column, so the close button was the only way out of the panel: Android's back button does not apply either. It is now `absolute inset-0` against ChatPage's root, covering the chat column only, and the nav stays visible and usable underneath. That also stops it being a modal, so it stops claiming to be one: it is a named region rather than a dialog, it no longer traps Tab or takes Escape, and the column it covers is marked `inert` instead. Nothing behind it is tabbable — the transcript and the composer are invisible under it — while the nav outside it stays reachable, which was the point. `inert` also moves focus off the covered composer, so keystrokes stop landing in a box that is no longer on screen. It also dropped the `overflow-hidden` the desktop panel carries. Flex items default to min-height:auto, so the `flex-1 overflow-y-auto` content region grew past the panel and spilled its last rows over the nav bar rather than scrolling inside itself. Third, and not specific to the panel: `viewport-fit=cover` lets the layout reach under the notch and the rounded corners, which is what makes the background continuous, but it also puts *content* there unless something pays the inset back. The shell now applies the top and side insets once, covering every page laid out inside it; the bottom stays with the nav, the element actually against that edge. The shell cannot cover the drawers, though. A `position: fixed` element is laid out against the viewport, not against the shell's padding box, so its insets never reach one — opening the session drawer still put its search field under the status bar. Each fixed surface pays its own, via a shared `safeAreaInsets()` so there is one place to be wrong: the session drawer had none at all, and the shared `Drawer` was missing the side inset for the edge it is anchored to. Refs #271 --- web/src/components/Chat/SessionSidebar.tsx | 5 ++++- web/src/components/Chat/SidePanel.tsx | 24 ++++++++++++---------- web/src/components/Layout/AppShell.tsx | 18 +++++++++++++++- web/src/components/ui/Drawer.tsx | 9 ++++---- web/src/pages/ChatPage.tsx | 21 +++++++++++++++---- web/src/utils/safeArea.ts | 24 ++++++++++++++++++++++ 6 files changed, 79 insertions(+), 22 deletions(-) create mode 100644 web/src/utils/safeArea.ts diff --git a/web/src/components/Chat/SessionSidebar.tsx b/web/src/components/Chat/SessionSidebar.tsx index 7cd0680c..d4cbf3ee 100644 --- a/web/src/components/Chat/SessionSidebar.tsx +++ b/web/src/components/Chat/SessionSidebar.tsx @@ -5,6 +5,7 @@ import type { Session, AgentStatus } from '../../types/chat'; import { groupByDate, parseTimestamp } from '../../utils/dateGroups'; import { useChatStore } from '../../stores/chatStore'; import { useModalSurface } from '../../hooks/useModalSurface'; +import { safeAreaInsets } from '../../utils/safeArea'; /** Strip leading '#' and 'Implement: ' prefixes from generated titles. */ function cleanTitle(session: Session): string { @@ -314,7 +315,9 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, className={mobile ? `bg-surface border-r border-border-subtle flex flex-col overflow-hidden fixed inset-y-0 left-0 z-50 w-[85vw] max-w-[320px] transition-transform duration-200 outline-none ${collapsed ? '-translate-x-full' : 'translate-x-0'}` : `bg-surface border-r border-border-subtle flex flex-col shrink-0 overflow-hidden relative ${collapsed ? 'border-r-0' : ''} ${isDragging ? '' : 'transition-all duration-200'}`} - style={mobile ? undefined : { width: collapsed ? 0 : sidebarWidth }} + // Fixed in drawer mode, so the shell's safe-area padding does not reach + // it: without this its first controls sit under the status bar. + style={mobile ? safeAreaInsets('left') : { width: collapsed ? 0 : sidebarWidth }} // Keep the closed drawer out of the tab order: it stays mounted so the // slide transition has something to animate, but it is off-canvas. inert={mobile && collapsed ? true : undefined} diff --git a/web/src/components/Chat/SidePanel.tsx b/web/src/components/Chat/SidePanel.tsx index 15dee6e9..5ae00308 100644 --- a/web/src/components/Chat/SidePanel.tsx +++ b/web/src/components/Chat/SidePanel.tsx @@ -2,7 +2,6 @@ import { useRef, useEffect, useState, useCallback } from 'react'; import { X, Lightbulb, Bot, Search, Wrench, Files, Loader2, Check, Ban, Workflow as WorkflowIcon } from 'lucide-react'; import { useChatStore } from '../../stores/chatStore'; import { useIsMobile } from '../../hooks/useMediaQuery'; -import { useModalSurface } from '../../hooks/useModalSurface'; import { MarkdownContent } from './MarkdownContent'; import { SelectionToolbar } from './SelectionToolbar'; import { BlockRenderer } from './BlockRenderer'; @@ -273,14 +272,6 @@ export function SidePanel() { 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) => { @@ -322,10 +313,21 @@ export function SidePanel() { // as on desktop — via the tab header's close button. if (mobile) { return ( + // absolute, not fixed: this covers the chat column (ChatPage's root is + // the positioned ancestor), so the bottom nav stays visible and usable + // underneath it. Fixed inset-0 covered the nav too, leaving the close + // button as the only way out of the panel. + // overflow-hidden matters as much as the positioning: without it the + // `flex-1 overflow-y-auto` content region grows past the panel (flex + // items default to min-height:auto) and spills over the bottom nav + // instead of scrolling inside. + // It stops being a modal here — the nav outside it is meant to stay + // reachable — so it is a named region rather than a dialog, and what it + // covers is made inert by ChatPage instead of trapped by this panel.
{showTabs && ( +
{isMobile ? : }
diff --git a/web/src/components/ui/Drawer.tsx b/web/src/components/ui/Drawer.tsx index 4c88d14b..b4303fc5 100644 --- a/web/src/components/ui/Drawer.tsx +++ b/web/src/components/ui/Drawer.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { useModalSurface } from '../../hooks/useModalSurface'; +import { safeAreaInsets } from '../../utils/safeArea'; /** * Off-canvas panel over a tap-to-dismiss scrim. @@ -46,11 +47,9 @@ export function Drawer({ open, onClose, side = 'left', label, children }: { className={`fixed inset-y-0 z-50 flex w-[85vw] max-w-[320px] flex-col overflow-hidden bg-surface outline-none transition-transform duration-200 ${side === 'left' ? 'left-0 border-r' : 'right-0 border-l'} border-border-subtle ${open ? 'translate-x-0' : closedTransform}`} - // The panel spans the full height, so it owns both insets itself. - style={{ - paddingTop: 'env(safe-area-inset-top)', - paddingBottom: 'env(safe-area-inset-bottom)', - }} + // Fixed, so the shell's safe-area padding does not reach it — including + // the side inset for the edge it is anchored to. + style={safeAreaInsets(side)} > {children}
diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 05f151b4..a84ffca3 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -43,7 +43,7 @@ export function ChatPage() { sessions, activeSession, virtualSession, messages, streamingBlocks, isStreaming, loading, agentStatus, contextUsage, backendStatus, currentTodos, currentCCTasks, - sidebarCollapsed, mobileSidebarOpen, panels, + sidebarCollapsed, mobileSidebarOpen, panels, panelVisible, modifiedFiles, modifiedFilesCount, backendDefault, newChatBackend, loadSessions, switchSession, createSession, deleteSession, @@ -207,9 +207,14 @@ export function ChatPage() { const fileCount = modifiedFiles.length || modifiedFilesCount; const filesPanelActive = panels.some(p => p.id === 'files-panel'); + // SidePanel renders nothing without a tab, so it only covers the column when + // there is one. + const panelCoversColumn = isMobile && panelVisible && panels.length > 0; return ( -
+ // `relative` anchors the mobile side panel, which covers this column but + // deliberately not the bottom nav below it. +
- {/* Chat column */} -
+ {/* Chat column. On a phone the side panel covers it completely, so it + goes inert while that is open: the panel is not a modal — the nav + below it stays reachable on purpose — and without this, Tab would + walk through a transcript and a composer nobody can see. Marking it + inert also moves focus off the covered composer, so keystrokes stop + landing in a box that is no longer on screen. */} +
{/* Header */}
diff --git a/web/src/utils/safeArea.ts b/web/src/utils/safeArea.ts new file mode 100644 index 00000000..a74807a2 --- /dev/null +++ b/web/src/utils/safeArea.ts @@ -0,0 +1,24 @@ +import type { CSSProperties } from 'react'; + +/** + * Safe-area padding for an overlay that is positioned against the viewport. + * + * `viewport-fit=cover` lets the layout run under the notch, the home indicator + * and the rounded corners — which is what makes the background continuous — + * but it puts *content* there unless something pays the inset back. AppShell + * pays it for everything laid out inside it, and a `position: fixed` element is + * not: it is laid out against the viewport, so the shell's padding box never + * reaches it and it has to pay its own. + * + * `anchor` names the vertical edge the surface is pinned to, since only that + * one can collide with a corner: `left` for a left drawer, `right` for a right + * drawer, `both` for a surface spanning the full width. + */ +export function safeAreaInsets(anchor: 'left' | 'right' | 'both' = 'both'): CSSProperties { + return { + paddingTop: 'env(safe-area-inset-top)', + paddingBottom: 'env(safe-area-inset-bottom)', + ...(anchor !== 'right' && { paddingLeft: 'env(safe-area-inset-left)' }), + ...(anchor !== 'left' && { paddingRight: 'env(safe-area-inset-right)' }), + }; +}