+ // 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.
+
diff --git a/web/src/components/Layout/BottomNav.tsx b/web/src/components/Layout/BottomNav.tsx
new file mode 100644
index 00000000..c29ec762
--- /dev/null
+++ b/web/src/components/Layout/BottomNav.tsx
@@ -0,0 +1,156 @@
+import { useEffect, useState } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+import { MoreHorizontal, LogOut, X } from 'lucide-react';
+import { useAuthStore } from '../../stores/authStore';
+import { useNotificationStore } from '../../stores/notificationStore';
+import { ws } from '../../api/websocket';
+import { api } from '../../api/client';
+import { Drawer } from '../ui/Drawer';
+import { ThemeToggle } from './ThemeToggle';
+import { NAV_ITEMS, PRIMARY_PATHS, type NavItem } from './navItems';
+
+/**
+ * Phone navigation: four primary destinations plus More, pinned to the bottom.
+ *
+ * Bottom rather than top because of the notification badge — it counts
+ * questions the agent is *blocked on*, which is the reason to open the panel
+ * at all, so it has to be visible without going looking for it. Thumb reach
+ * is the bonus.
+ *
+ * Replaces the 56px nav rail below `md`; the two are never mounted together
+ * (AppShell picks one), so the notification poll and feature-flag fetch below
+ * do not run twice.
+ */
+export function BottomNav() {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const { logout } = useAuthStore();
+ const pendingCount = useNotificationStore(s => s.pendingCount);
+ const loadNotifications = useNotificationStore(s => s.loadNotifications);
+ const [ultracodeEnabled, setUltracodeEnabled] = useState(false);
+ const [moreOpen, setMoreOpen] = useState(false);
+
+ useEffect(() => {
+ loadNotifications();
+ // A 404 is expected until the dashboard backend is loaded after restart.
+ api.getUltracodeDashboardStatus().then(s => setUltracodeEnabled(s.enabled)).catch(() => {});
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const available = NAV_ITEMS.filter(i => !(i.feature === 'ultracode' && !ultracodeEnabled));
+ const primary = PRIMARY_PATHS
+ .map(p => available.find(i => i.path === p))
+ .filter((i): i is NavItem => Boolean(i));
+ const overflow = available.filter(i => !PRIMARY_PATHS.includes(i.path));
+
+ // "More" counts as selected whenever the current route is one of the
+ // destinations it hides, so the bar always shows where you are.
+ const inOverflow = overflow.some(i => location.pathname.startsWith(i.path));
+
+ const go = (path: string) => {
+ navigate(path);
+ setMoreOpen(false);
+ };
+
+ return (
+ <>
+ {/* `order-last` paints the bar at the bottom while leaving it first in
+ the DOM, which is where the desktop nav rail already sits — so the
+ reading and tab order is the same on both layouts. */}
+
+ {primary.map(({ path, icon: Icon, label }) => {
+ const active = location.pathname.startsWith(path);
+ const isNotifs = path === '/notifications';
+ return (
+ go(path)}
+ aria-current={active ? 'page' : undefined}
+ // min-h-14 keeps every target at/above the ~44px touch minimum.
+ className={`relative flex min-h-14 flex-1 cursor-pointer flex-col items-center justify-center gap-0.5 transition-colors ${
+ active ? 'text-accent' : 'text-text-dim'
+ }`}
+ >
+
+ {label}
+ {isNotifs && pendingCount > 0 && (
+
+ {pendingCount > 9 ? '9+' : pendingCount}
+
+ )}
+
+ );
+ })}
+
+ setMoreOpen(true)}
+ aria-expanded={moreOpen}
+ aria-haspopup="dialog"
+ // While an overflow destination is active, the real current item is
+ // inside a closed, inert drawer — so without this the main navigation
+ // exposes no current item at all and the state is colour-only.
+ aria-current={inOverflow ? true : undefined}
+ className={`flex min-h-14 flex-1 cursor-pointer flex-col items-center justify-center gap-0.5 transition-colors ${
+ inOverflow ? 'text-accent' : 'text-text-dim'
+ }`}
+ >
+
+ More
+
+
+
+ {/* 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. */}
+ setMoreOpen(false)}
+ aria-label="Close menu"
+ className="cursor-pointer text-text-faint transition-colors hover:text-text-muted"
+ >
+
+
+
+
+
+
+ {overflow.map(({ path, icon: Icon, label }) => {
+ const active = location.pathname.startsWith(path);
+ return (
+ go(path)}
+ aria-current={active ? 'page' : undefined}
+ className={`flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left text-sm transition-colors ${
+ active ? 'bg-accent/10 text-accent' : 'text-text-muted hover:bg-surface-hover'
+ }`}
+ >
+
+ {label}
+
+ );
+ })}
+
+
+
+
+ Log out
+
+
+ >
+ );
+}
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 && (
+
+ )}
+
+ {children}
+
+ >
+ );
+}