From d8b69a80c9935f5e8c0d45ee254205f84b4e46b2 Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:57:35 +0000 Subject: [PATCH] Replace the nav rail with a bottom bar on phones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 56px rail is 14% of a 412px viewport, spent on chrome, on every page — and it is exactly the width the transcript and the wider tables need. Below `md` it is replaced by a bottom bar carrying the four destinations you open to decide something (Chat, Tasks, Notifs, Plans) plus More for the rest. Bottom rather than top because of the badge: it counts questions the agent is *blocked on*, which is the reason to open the panel on a phone at all, and it is worth nothing hidden behind a menu. The remaining eight destinations, the theme toggle, the connection dot and logout move into a right-anchored drawer. Anchoring is what keeps the three edges legible: bottom is which section of the app, the left drawer is which item within this section, and the top stays what you are looking at now. Exactly one of NavRail/BottomNav is mounted rather than one being CSS-hidden, so the notification poll and feature-flag fetch they share cannot run twice. Both now read one NAV_ITEMS list so they cannot drift. One shell tree serves both layouts, with `` in a fixed position among its siblings. Two trees would swap the wrapper that owns the outlet, so React would unmount and remount the entire active route on every crossing of `md` — a rotation would throw away page state such as an open dialog, a set of filters or half-typed form input. The nav stays first in the DOM, where the desktop rail already was, and the bar paints itself last with `order-last`, so reading and tab order match on both. The More drawer is a modal and now behaves like one: `useModalSurface` moves focus in, cycles Tab inside it, restores focus to the More button on close, and claims Escape — which previously fell through to the global Escape shortcut and stopped a streaming response instead of closing the drawer. It also gained a visible close button, since Escape alone is not discoverable. More itself carries `aria-current` while an overflow destination is active: the real current item is inside a closed, inert drawer, so the state was otherwise conveyed by colour alone. Also does the mobile viewport groundwork the bar depends on: - interactive-widget=resizes-content, so the on-screen keyboard shrinks the layout instead of floating over the bar and the composer - viewport-fit=cover plus env(safe-area-inset-*) on the bar and drawer, so neither sits under the home indicator - h-dvh on the mobile shell, since 100vh does not track the keyboard - the composer's control row wraps: with up to six buttons and the model picker it had squeezed the textarea to a ~90px stub, so the textarea now takes a full-width line of its own beneath them Desktop is unchanged — the rail, the single-row composer and every control are exactly as before. Refs #271 --- web/index.html | 11 +- web/src/components/Chat/ChatInput.tsx | 15 ++- web/src/components/Layout/AppShell.tsx | 26 +++- web/src/components/Layout/BottomNav.tsx | 156 ++++++++++++++++++++++++ web/src/components/Layout/NavRail.tsx | 19 +-- web/src/components/Layout/navItems.ts | 42 +++++++ web/src/components/ui/Drawer.tsx | 59 +++++++++ 7 files changed, 304 insertions(+), 24 deletions(-) create mode 100644 web/src/components/Layout/BottomNav.tsx create mode 100644 web/src/components/Layout/navItems.ts create mode 100644 web/src/components/ui/Drawer.tsx diff --git a/web/index.html b/web/index.html index 954271f9..0211fed6 100644 --- a/web/index.html +++ b/web/index.html @@ -2,7 +2,16 @@ - + + diff --git a/web/src/components/Chat/ChatInput.tsx b/web/src/components/Chat/ChatInput.tsx index 93a8c248..c4d3bffa 100644 --- a/web/src/components/Chat/ChatInput.tsx +++ b/web/src/components/Chat/ChatInput.tsx @@ -561,9 +561,15 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { )} - {/* Main input */} + {/* Main input. + + One row on desktop. On a phone the controls alone can run to six + buttons plus the model picker, which left the textarea a ~90px + stub, so the row wraps instead: controls stay on the first line and + the textarea takes a full-width line of its own below them (see the + `basis-full`/`order-1` pair on it). */}
-
+
{/* File attach button */} + ); + })} + + + + + {/* Right-anchored so it reads as "which section of the app", distinct + from the left drawer's "which item within this section". */} + setMoreOpen(false)} side="right" label="More destinations"> +
+ More +
+ + + {/* Escape closes it too, but only this is discoverable. */} + +
+
+ +
+ {overflow.map(({ path, icon: Icon, label }) => { + const active = location.pathname.startsWith(path); + return ( + + ); + })} +
+ + +
+ + ); +} diff --git a/web/src/components/Layout/NavRail.tsx b/web/src/components/Layout/NavRail.tsx index 6007d9a6..fe52d8cb 100644 --- a/web/src/components/Layout/NavRail.tsx +++ b/web/src/components/Layout/NavRail.tsx @@ -1,27 +1,12 @@ import { useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { MessageSquare, FolderOpen, CheckSquare, Inbox, Activity, Brain, LogOut, Clock, Lightbulb, Sparkles, Bell, Plug, Workflow, Rocket } from 'lucide-react'; +import { LogOut } from 'lucide-react'; import { useAuthStore } from '../../stores/authStore'; import { useNotificationStore } from '../../stores/notificationStore'; import { ws } from '../../api/websocket'; import { api } from '../../api/client'; import { ThemeToggle } from './ThemeToggle'; - -const NAV_ITEMS = [ - { path: '/chat', icon: MessageSquare, label: 'Chat' }, - { path: '/notifications', icon: Bell, label: 'Notifs' }, - { path: '/files', icon: FolderOpen, label: 'Files' }, - { path: '/tasks', icon: CheckSquare, label: 'Tasks' }, - { path: '/plans', icon: Lightbulb, label: 'Plans' }, - { path: '/skills', icon: Sparkles, label: 'Skills' }, - { path: '/mcp', icon: Plug, label: 'MCP' }, - { path: '/ultracode', icon: Workflow, label: 'Ultra', feature: 'ultracode' as const }, - { path: '/workflow-runs', icon: Rocket, label: 'Runs' }, - { path: '/sources', icon: Inbox, label: 'Sources' }, - { path: '/cron', icon: Clock, label: 'Cron' }, - { path: '/memory', icon: Brain, label: 'Memory' }, - { path: '/diagnostics', icon: Activity, label: 'Diag' }, -]; +import { NAV_ITEMS } from './navItems'; export function NavRail() { const location = useLocation(); diff --git a/web/src/components/Layout/navItems.ts b/web/src/components/Layout/navItems.ts new file mode 100644 index 00000000..0ae0683e --- /dev/null +++ b/web/src/components/Layout/navItems.ts @@ -0,0 +1,42 @@ +import { + MessageSquare, FolderOpen, CheckSquare, Inbox, Activity, Brain, Clock, + Lightbulb, Sparkles, Bell, Plug, Workflow, Rocket, +} from 'lucide-react'; + +export type NavItem = { + path: string; + icon: typeof MessageSquare; + label: string; + /** Hidden unless the named feature is enabled. */ + feature?: 'ultracode'; +}; + +export const NAV_ITEMS: NavItem[] = [ + { path: '/chat', icon: MessageSquare, label: 'Chat' }, + { path: '/notifications', icon: Bell, label: 'Notifs' }, + { path: '/files', icon: FolderOpen, label: 'Files' }, + { path: '/tasks', icon: CheckSquare, label: 'Tasks' }, + { path: '/plans', icon: Lightbulb, label: 'Plans' }, + { path: '/skills', icon: Sparkles, label: 'Skills' }, + { path: '/mcp', icon: Plug, label: 'MCP' }, + { path: '/ultracode', icon: Workflow, label: 'Ultra', feature: 'ultracode' }, + { path: '/workflow-runs', icon: Rocket, label: 'Runs' }, + { path: '/sources', icon: Inbox, label: 'Sources' }, + { path: '/cron', icon: Clock, label: 'Cron' }, + { path: '/memory', icon: Brain, label: 'Memory' }, + { path: '/diagnostics', icon: Activity, label: 'Diag' }, +]; + +/** + * The four destinations that keep a permanent slot in the phone's bottom bar; + * everything else lives behind "More". + * + * These are the ones you open to *decide* something — read what the agent + * said, check a task, approve a plan, answer a blocking question. Notifs + * earns its slot by carrying the pending-question badge, which is the whole + * reason to open the panel on a phone and is useless if it is hidden behind + * a menu. The rest (files, skills, MCP, cron, memory, diagnostics, sources) + * are configuration and inspection surfaces — reached deliberately, rarely + * in a hurry. + */ +export const PRIMARY_PATHS = ['/chat', '/tasks', '/notifications', '/plans']; diff --git a/web/src/components/ui/Drawer.tsx b/web/src/components/ui/Drawer.tsx new file mode 100644 index 00000000..4c88d14b --- /dev/null +++ b/web/src/components/ui/Drawer.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from 'react'; +import { useModalSurface } from '../../hooks/useModalSurface'; + +/** + * Off-canvas panel over a tap-to-dismiss scrim. + * + * Stays mounted while closed so the slide has something to animate, and + * carries `inert` in that state so a panel parked off-screen cannot be + * reached by tab or read by a screen reader. + * + * While open it is a modal, and `useModalSurface` gives it the matching + * keyboard contract: focus moves in, Tab cycles inside instead of walking onto + * the page behind it, Escape closes it — claimed here, so it stops short of the + * global Escape shortcut that halts a streaming response — and focus goes back + * to whatever opened it. Callers should still put a visible close control in + * their header; Escape alone is not discoverable. + * + * `side` distinguishes the two jobs navigation does on a phone: `left` for + * "which item within this section" (the chat session list), `right` for + * "which section of the app" (the nav overflow behind More). + */ +export function Drawer({ open, onClose, side = 'left', label, children }: { + open: boolean; + onClose: () => void; + side?: 'left' | 'right'; + /** Accessible name for the panel — it is a dialog with no visible title. */ + label: string; + children: ReactNode; +}) { + const closedTransform = side === 'left' ? '-translate-x-full' : 'translate-x-full'; + const { dialogProps } = useModalSurface(open, onClose); + + return ( + <> + {open && ( +