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 9d12e849..ac658f22 100644 --- a/web/src/components/Chat/ChatInput.tsx +++ b/web/src/components/Chat/ChatInput.tsx @@ -555,9 +555,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 && ( +