Skip to content

Mobile web UI [2/7]: Replace the nav rail with a bottom bar on phones - #294

Open
alex-clickhouse wants to merge 1 commit into
alex-clickhouse/mobile-responsivefrom
alex-clickhouse/mobile-bottom-nav
Open

Mobile web UI [2/7]: Replace the nav rail with a bottom bar on phones#294
alex-clickhouse wants to merge 1 commit into
alex-clickhouse/mobile-responsivefrom
alex-clickhouse/mobile-bottom-nav

Conversation

@alex-clickhouse

Copy link
Copy Markdown
Collaborator

Second step on #271, stacked on #293review 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 onpendingCount is 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:

edge question it answers
bottom which section of the app
left drawer which item within this section (the session list, #293)
top what am I looking at right now

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 composer
  • viewport-fit=cover + env(safe-area-inset-*) on the bar and drawer, so neither ends up under the home indicator
  • h-dvh on the mobile shell — 100vh does not track the keyboard, so the bar would be pushed off-screen
  • composer reflow — with up to six controls plus the model picker, the textarea was squeezed to a ~90px stub. It now takes a full-width line of its own beneath the controls (basis-full + order-1, both undone at md).

Notes for review

  • Exactly one of NavRail/BottomNav is mounted, rather than one being CSS-hidden. They share a notification poll and a feature-flag fetch on mount, so rendering both would double every request.
  • Both read one NAV_ITEMS list (new navItems.ts) so the rail and the bar cannot drift apart when a destination is added — including the ultracode feature gate.
  • More counts as selected whenever the route is one of the destinations it hides, so the bar always shows where you are.
  • New ui/Drawer.tsx holds the scrim + slide + inert pattern. SessionSidebar from 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.
  • Touch targets are min-h-14, above the ~44px minimum.

Verification

  • npm run build clean; eslint clean on every touched file (the one remaining warning in NavRail.tsx is pre-existing)
  • Checked at 412×915 and 1280×900 in Chromium: bar switches correctly, badge renders, More opens/closes, every overflow destination navigates and closes the drawer, and the desktop layout is unchanged — rail, sidebar, single-row composer and all header chips exactly as on main

Still to do for #271

/cron's two-pane split and wide table, /diagnostics tables, and the /tasks status tab row that runs off the right edge.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread web/src/components/Layout/AppShell.tsx Outdated
Comment on lines +13 to +17
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">

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread web/src/components/ui/Drawer.tsx Outdated
Comment on lines +34 to +37
role="dialog"
aria-modal="true"
aria-label={label}
inert={open ? undefined : true}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +85 to +89
<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 ${

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@alex-clickhouse
alex-clickhouse force-pushed the alex-clickhouse/mobile-bottom-nav branch from 991c7f6 to 234e3da Compare August 9, 2026 18:32
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review August 9, 2026 18:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants