Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion web/src/components/Chat/SessionSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}
Expand Down
24 changes: 13 additions & 11 deletions web/src/components/Chat/SidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -273,14 +272,6 @@ export function SidePanel() {
const activeTab = panels.find(p => p.id === activePanelId) || panels[0] || null;
const containerRef = useRef<HTMLDivElement>(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<HTMLDivElement>(
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) => {
Expand Down Expand Up @@ -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.
<div
{...dialogProps}
role="region"
aria-label={activeTab.label || 'Panel'}
className={`side-panel fixed inset-0 z-30 flex flex-col bg-bg-sunken outline-none ${isOpen ? '' : 'hidden'}`}
className={`side-panel absolute inset-0 z-30 flex flex-col overflow-hidden bg-bg-sunken ${isOpen ? '' : 'hidden'}`}
>
{showTabs && (
<TabBar
Expand Down
18 changes: 17 additions & 1 deletion web/src/components/Layout/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,23 @@ export function AppShell() {
return (
// h-dvh on a phone, not h-screen: the dynamic unit tracks the on-screen
// keyboard, so the bar stays put instead of being pushed off the bottom.
<div className={`flex bg-bg ${isMobile ? 'h-dvh flex-col' : 'h-screen'}`}>
<div
className={`flex bg-bg ${isMobile ? 'h-dvh flex-col' : 'h-screen'}`}
// `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. Doing it
// once here covers every page laid out in this box; the bottom is left to
// BottomNav, the element actually sitting against that edge.
//
// It does not reach the drawers: a `position: fixed` element is laid out
// against the viewport, not against this padding box, so each of those
// pays its own insets.
style={isMobile ? {
paddingTop: 'env(safe-area-inset-top)',
paddingLeft: 'env(safe-area-inset-left)',
paddingRight: 'env(safe-area-inset-right)',
} : undefined}
>
{isMobile ? <BottomNav /> : <NavRail />}
<div className="min-h-0 min-w-0 flex-1">
<Outlet />
Expand Down
9 changes: 4 additions & 5 deletions web/src/components/ui/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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}
</div>
Expand Down
21 changes: 17 additions & 4 deletions web/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<div className="h-full flex">
// `relative` anchors the mobile side panel, which covers this column but
// deliberately not the bottom nav below it.
<div className="h-full flex relative">
<SessionSidebar
sessions={sessions}
activeSession={activeSession}
Expand All @@ -223,8 +228,16 @@ export function ChatPage() {

{/* Main content area: chat column + optional plan panel */}
<div className="flex-1 flex min-w-0">
{/* Chat column */}
<div className="flex-1 flex flex-col min-w-0">
{/* 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. */}
<div
className="flex-1 flex flex-col min-w-0"
inert={panelCoversColumn ? true : undefined}
>
{/* Header */}
<div className="border-b border-border-subtle px-3 md:px-5 py-2.5 flex items-center justify-between gap-2 bg-bg shrink-0">
<div className="flex items-center gap-2 min-w-0">
Expand Down
24 changes: 24 additions & 0 deletions web/src/utils/safeArea.ts
Original file line number Diff line number Diff line change
@@ -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)' }),
};
}
Loading