Mobile web UI [2/7]: Replace the nav rail with a bottom bar on phones - #294
Mobile web UI [2/7]: Replace the nav rail with a bottom bar on phones#294alex-clickhouse wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
Replaces the desktop navigation rail with phone-friendly bottom navigation and responsive viewport behavior.
Changes:
- Adds bottom navigation with a More drawer.
- Centralizes navigation items and responsive shell selection.
- Adds safe-area/keyboard viewport handling and mobile composer reflow.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
web/index.html |
Configures mobile viewport behavior. |
web/src/components/Chat/ChatInput.tsx |
Reflows the mobile composer. |
web/src/components/Layout/AppShell.tsx |
Selects mobile or desktop navigation. |
web/src/components/Layout/BottomNav.tsx |
Implements bottom navigation and overflow. |
web/src/components/Layout/NavRail.tsx |
Uses shared navigation definitions. |
web/src/components/Layout/navItems.ts |
Centralizes navigation metadata. |
web/src/components/ui/Drawer.tsx |
Adds the reusable off-canvas drawer. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if (isMobile) { | ||
| return ( | ||
| // h-dvh, 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 h-dvh flex-col bg-bg"> |
There was a problem hiding this comment.
Fixed. AppShell is now one tree for both layouts with <Outlet> in a fixed position among its siblings, so crossing md swaps NavRail/BottomNav but never remounts the route. The nav stays first in the DOM in both — which is where the desktop rail already was — and BottomNav paints itself last with order-last, so reading and tab order match on both layouts rather than being reordered per breakpoint.
| role="dialog" | ||
| aria-modal="true" | ||
| aria-label={label} | ||
| inert={open ? undefined : true} |
There was a problem hiding this comment.
Fixed. Drawer now uses the shared useModalSurface hook added in #293: initial focus, Tab containment, Escape (claimed with stopPropagation, so it no longer reaches the global Escape shortcut and stops generation), and focus restoration to the More button. Also added a visible close button in the More header — Escape alone is not discoverable, and on a phone there is no key to press.
| <button | ||
| onClick={() => setMoreOpen(true)} | ||
| aria-expanded={moreOpen} | ||
| aria-haspopup="dialog" | ||
| className={`flex min-h-14 flex-1 cursor-pointer flex-col items-center justify-center gap-0.5 transition-colors ${ |
There was a problem hiding this comment.
Fixed — aria-current is now set on the More button whenever inOverflow is true.
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 `<Outlet>` 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
991c7f6 to
234e3da
Compare
Second step on #271, stacked on #293 — review that one first; this PR's diff is only meaningful on top of it.
The problem
The nav rail is 56px on every page. On a 412px viewport that is 14% of the width spent permanently on chrome, and it is exactly the width the transcript and the wider tables are short of.
It also does not shrink well: 13 destinations, each with an icon and a label, is a lot of rail to justify when four of them cover almost everything you do from a phone.
The change
Below
md, the rail is replaced by a bottom bar: Chat, Tasks, Notifs, Plans, More.Why bottom, not top. The Notifs badge counts questions the agent is blocked on —
pendingCountis pending interactive questions, not unread items. That is the reason to open the panel on a phone at all, so it needs to be visible without going looking for it. A hamburger would hide the one number that decides whether you need to act. Thumb reach is a bonus on top of that.Why those four. They are the destinations you open to decide something — read what the agent said, check a task, approve a plan, answer a blocking question. The other eight (files, skills, MCP, runs, sources, cron, memory, diagnostics) are configuration and inspection: reached deliberately, rarely in a hurry.
Anchoring. Each edge now means exactly one thing:
That is why More opens from the right — it is app-level, so it must not read as another session list.
Viewport groundwork
A bottom-anchored bar does not work on a phone without these, so they are here rather than in a follow-up:
interactive-widget=resizes-content— the on-screen keyboard shrinks the layout instead of floating over the bar and the composerviewport-fit=cover+env(safe-area-inset-*)on the bar and drawer, so neither ends up under the home indicatorh-dvhon the mobile shell —100vhdoes not track the keyboard, so the bar would be pushed off-screenbasis-full+order-1, both undone atmd).Notes for review
NAV_ITEMSlist (newnavItems.ts) so the rail and the bar cannot drift apart when a destination is added — including theultracodefeature gate.ui/Drawer.tsxholds the scrim + slide +inertpattern.SessionSidebarfrom Mobile web UI [1/7]: Make the chat page usable on a phone #293 still has its own copy — its drawer and inline modes share a root element, so extracting it is a real refactor and I did not want it riding along in this PR. Happy to do it as a follow-up.min-h-14, above the ~44px minimum.Verification
npm run buildclean;eslintclean on every touched file (the one remaining warning inNavRail.tsxis pre-existing)mainStill to do for #271
/cron's two-pane split and wide table,/diagnosticstables, and the/tasksstatus tab row that runs off the right edge.