Skip to content

Mobile web UI [1/7]: Make the chat page usable on a phone - #293

Open
alex-clickhouse wants to merge 1 commit into
mainfrom
alex-clickhouse/mobile-responsive
Open

Mobile web UI [1/7]: Make the chat page usable on a phone#293
alex-clickhouse wants to merge 1 commit into
mainfrom
alex-clickhouse/mobile-responsive

Conversation

@alex-clickhouse

Copy link
Copy Markdown
Collaborator

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:

nav rail 56 + session list 240 = 296px of chrome,  leaving ~116px for the transcript

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:

before after (< md)
session list inline column, 240px, always present off-canvas drawer over a tap-to-dismiss scrim, closed by default
side panel inline, 45% split covers the transcript
header 6 chips + title title truncates; backend/model chips, context bar and Langfuse link are desktop-only

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

  • Drawer state is deliberately not 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.
  • That reset is done during render, not in an effect. An effect paints the stale state for a frame first — here a full-screen overlay flashing over the transcript. This is the "adjusting state when a prop changes" pattern from the React docs, and it keeps react-hooks/set-state-in-effect quiet.
  • useMediaQuery uses useSyncExternalStore, 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.
  • Both resize handles are mouse-only (onMouseDown + col-resize), so neither is rendered in overlay mode.
  • The closed drawer stays mounted so the slide transition has something to animate, and carries inert so it is out of the tab order.

Verification

  • npm run build (tsc + vite) clean; eslint clean on all four touched files
  • Checked at 412×915 and 1280×900 in Chromium via Playwright: drawer opens/closes, tapping a conversation reveals it, side panel covers and closes, and the desktop layout is pixel-identical to main (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 /cron two-pane split and wide tables, and the /tasks status tab row. Happy to keep them as separate PRs.

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

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.

Comment on lines +290 to +295
{mobile && !collapsed && (
<div
onClick={onRequestClose}
className="fixed inset-0 z-40 bg-black/60 transition-opacity duration-200"
aria-hidden="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. Every conversation link now calls onSelectSessionItem (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.

Comment on lines +290 to +295
{mobile && !collapsed && (
<div
onClick={onRequestClose}
className="fixed inset-0 z-40 bg-black/60 transition-opacity duration-200"
aria-hidden="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. 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.

Comment thread web/src/components/Chat/SidePanel.tsx Outdated
Comment on lines +314 to +316
if (mobile) {
return (
<div className={`side-panel fixed inset-0 z-30 flex flex-col bg-bg-sunken ${isOpen ? '' : 'hidden'}`}>

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.

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.

Comment thread web/src/pages/ChatPage.tsx Outdated
Comment on lines +208 to +210
collapsed={isMobile ? !drawerOpen : sidebarCollapsed}
mobile={isMobile}
onRequestClose={() => setDrawerOpen(false)}

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. 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
@alex-clickhouse
alex-clickhouse force-pushed the alex-clickhouse/mobile-responsive branch from 6b9c02c to 4b38668 Compare August 9, 2026 18:32
@alex-clickhouse

Copy link
Copy Markdown
Collaborator Author

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: tsc -b clean on all seven commits individually (not just the tip), npm run build green at the tip, pytest 3113 passed. The stack is frontend-only — no Python touched.

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 order usage against the same DOM-vs-tab-order point raised on #295, and its reordered block contains only spans, so there is no tab-order mismatch.

@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