Mobile web UI [1/7]: Make the chat page usable on a phone - #293
Mobile web UI [1/7]: Make the chat page usable on a phone#293alex-clickhouse wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
Makes the chat page responsive by replacing narrow inline columns with mobile overlays.
Changes:
- Adds viewport detection and mobile drawer state.
- Converts session sidebar and side panel into overlays.
- Simplifies and truncates the mobile header.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
web/src/pages/ChatPage.tsx |
Coordinates responsive chat layout and header. |
web/src/hooks/useMediaQuery.ts |
Adds reactive mobile viewport detection. |
web/src/components/Chat/SidePanel.tsx |
Adds full-screen mobile panel mode. |
web/src/components/Chat/SessionSidebar.tsx |
Adds off-canvas mobile drawer mode. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| {mobile && !collapsed && ( | ||
| <div | ||
| onClick={onRequestClose} | ||
| className="fixed inset-0 z-40 bg-black/60 transition-opacity duration-200" | ||
| aria-hidden="true" | ||
| /> |
There was a problem hiding this comment.
Fixed. Every conversation link now calls onSelect — SessionItem (which covers the search, running, starred and date-grouped lists), the virtual new-chat link, and the system-session links — plus the New chat button. The parent still watches activeSession as a backstop for switches that come from elsewhere (browser Back, a deep link), but the tap itself is what closes the drawer, so re-tapping the conversation already open works.
| {mobile && !collapsed && ( | ||
| <div | ||
| onClick={onRequestClose} | ||
| className="fixed inset-0 z-40 bg-black/60 transition-opacity duration-200" | ||
| aria-hidden="true" | ||
| /> |
There was a problem hiding this comment.
Fixed. New useModalSurface hook gives the drawer the full modal contract: focus moves in on open, Tab and Shift+Tab cycle inside it, Escape closes it, and focus returns to the toggle on close. Escape is claimed locally (stopPropagation before the document-level handler), so it no longer falls through to the global Escape shortcut and stops a streaming response. Instead of inert on the background I went with role="dialog" + aria-modal, which is what tells assistive technology to treat everything outside the dialog as inert, plus real Tab containment for the keyboard — same effect, without portalling the drawer out of the app tree to find something inertable.
| if (mobile) { | ||
| return ( | ||
| <div className={`side-panel fixed inset-0 z-30 flex flex-col bg-bg-sunken ${isOpen ? '' : 'hidden'}`}> |
There was a problem hiding this comment.
Partly. It shares the same useModalSurface treatment here, but note it does not stay a modal: #298 (later in this stack) repositions it to absolute inset-0 so it covers the chat column and deliberately not the bottom nav. At that point trapping focus would be wrong — it would make the nav unreachable, which is the whole point of that change — so it becomes a named region and the column it covers is marked inert instead. Net effect is the one you asked for: nothing invisible behind it is tabbable, and focus leaves the covered composer.
| collapsed={isMobile ? !drawerOpen : sidebarCollapsed} | ||
| mobile={isMobile} | ||
| onRequestClose={() => setDrawerOpen(false)} |
There was a problem hiding this comment.
Fixed. The drawer state moved into chatStore as mobileSidebarOpen, with two viewport-aware actions: toggleSessionList() (Cmd+Shift+S and the header button) and revealSessionList() (Cmd+K, before requestSearchFocus). Each picks the drawer below md and the persisted desktop column above it, so neither shortcut writes sidebarCollapsed from a phone, and Cmd+K opens the drawer before asking for focus rather than focusing into a closed inert one.
The chat page is three inline columns — session list, transcript, side panel — and below ~768px there is no width left to split. At 412px the transcript collapsed to a ~100px sliver wrapping one word per line, with the session list half off-screen. That is the screenshot in #271. Below `md`, turn the two side columns into overlays so the transcript keeps the full viewport: - session list becomes an off-canvas drawer over a tap-to-dismiss scrim, closed by default, opened from the existing header toggle - side panel covers the transcript instead of taking a 45% split - header sheds what does not fit: backend and model chips, the context bar and the Langfuse link stay desktop-only, and the title truncates Both overlays are modals, so `useModalSurface` gives them what a modal owes the keyboard: focus moves in on open, Tab cycles inside instead of walking onto the transcript and navigation behind them, Escape dismisses (claimed locally, so it no longer reaches the global Escape shortcut and stops a streaming response), and focus returns to the opener on close. `role="dialog"` + `aria-modal` tell assistive technology to treat what is behind them as inert. Every conversation link in the drawer closes it. Watching `activeSession` alone is not enough: re-tapping the conversation that is already open never changes the route, so the drawer would stay parked over the transcript it was asked to reveal. Drawer state is deliberately separate from `sidebarCollapsed`. That flag is a persisted desktop preference which defaults to expanded, so reusing it would pop the drawer open on first load and let a phone overwrite the desktop layout. It lives in the store rather than in `ChatPage` so the keyboard can reach it: Cmd+Shift+S and Cmd+K now go through `toggleSessionList` / `revealSessionList`, which pick the drawer or the desktop column by viewport. Previously both drove `sidebarCollapsed`, so on a phone Cmd+Shift+S rewrote the desktop preference while moving nothing on screen, and Cmd+K focused the search field inside a closed, inert drawer. Both resize handles are pointer-driven and mouse-only, so neither is rendered in overlay mode. Desktop layout is unchanged: every new rule is behind a media query or the `mobile` branch. Refs #271
6b9c02c to
4b38668
Compare
|
Worked through the Copilot review across the whole stack (#293 → #299) and restacked. All seven branches were force-pushed; PR bases are unchanged and each still merges cleanly. Verification: Two things worth flagging because they cross PR boundaries:
#297 and #299 had no comments. #297 lost one hunk to an upstream fix (noted on the PR); #299 was rebased unchanged — I checked its flex |
First step on #271. Scoped to the chat page, which is both the landing route and the page in the issue's screenshot.
The problem
The chat page is three inline columns — session list, transcript, side panel. Below ~768px there is no width left to split between them. On a 412px viewport:
The transcript wrapped one word per line and the session list sat half off-screen. That is exactly the screenshot in #271.
The change
Below Tailwind's
md, the two side columns stop taking inline width and become overlays:md)The transcript gets the full viewport width. Everything is opened and dismissed through controls that already existed — the header's sidebar toggle and the panel's close button — so there is nothing new to learn.
Notes for review
sidebarCollapsed. That flag is a persisted desktop preference that defaults to expanded; reusing it would pop the drawer open on first load and let a phone overwrite the desktop layout. It is separate local state, forced shut when the conversation changes or the layout crosses the breakpoint.react-hooks/set-state-in-effectquiet.useMediaQueryusesuseSyncExternalStore, so the first render already knows the viewport. The effect-based version renders the desktop shell for one frame before correcting, which on this page is a visible flash of the squeezed three-column layout.onMouseDown+col-resize), so neither is rendered in overlay mode.inertso it is out of the tab order.Verification
npm run build(tsc + vite) clean;eslintclean on all four touched filesmain(all chips, inline sidebar, both resize handles present)Not in this PR
Still to do for #271, in rough priority order: the nav rail (56px on every page), the
/crontwo-pane split and wide tables, and the/tasksstatus tab row. Happy to keep them as separate PRs.