Skip to content

feat(builder): make the app usable on mobile viewports - #970

Merged
realcodesiman merged 28 commits into
ChatbotXIO:mainfrom
eduardocodes:feat/responsive-mobile-ui
Sep 3, 2026
Merged

feat(builder): make the app usable on mobile viewports#970
realcodesiman merged 28 commits into
ChatbotXIO:mainfrom
eduardocodes:feat/responsive-mobile-ui

Conversation

@eduardocodes

@eduardocodes eduardocodes commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

What

Makes the builder usable on a phone. Before this, the app was desktop-only in
practice: of 1137 .tsx files, 99 contained any responsive prefix, and of 44
layout files exactly one did. The shadcn Sidebar already collapsed into a
Sheet below md, but nothing in the app could open it — the only trigger is
absolutely positioned off the inset's inline edge — and no nav link closed it.

Scope is the app shell, the inbox, and the table/list surfaces. The flow
builder is deliberately out of scope
and untouched; it needs its own pass.

Shell and navigation

  • Explicit viewport export with viewportFit: "cover", so env(safe-area-inset-*)
    reports real values.
  • New SidebarMobileHandle in packages/ui — a drawer handle pinned to the
    screen's inline-start edge, as opposed to SidebarTrigger, which is a
    rail-collapse chevron and reads as nothing below md. Both shells (workspace
    and manage console) render it unconditionally; it hides itself from md up.
    No shell header was added — the whole viewport stays page content.
  • NavMain now closes the mobile sheet when a link is tapped; it used to stay
    open on top of the destination page.
  • AppTab scrolls horizontally instead of overflowing. One file, and it unblocks
    the 15 surfaces that render 4–6 tabs.
  • The inbox's -m-6 negative margin — which silently depended on the shell's
    p-6 — is replaced by a documented FullBleed component, so changing the
    shell's padding can no longer break the page from a distance.

Tables and lists

  • DataTable now scrolls horizontally. The default was overflow-hidden
    and only 1 of its 36 consumers opted out of it, so 35 tables silently clipped
    their rightmost columns with no way to reach them.
  • New opt-in mobileCard render mode plus a generic DataTableRowCard that
    renders a row through the column's own flexRender — no duplicated cell
    logic, and field labels come from meta.label, so no new translation keys.
    Applied to contacts (bespoke card), flows and broadcasts (generic).
  • The switch between card and table is CSS, not a media-query hook, so the right
    layout is in the first paint rather than flipping after hydration.
  • Toolbar filters stack on narrow viewports; analytics dashboards collapse to one
    column; SettingRow stacks (its grid-cols-4 reached every settings form).

Inbox

Below md the inbox is a single-pane master/detail view: the conversation list,
then the thread with a back control, with the contact panel behind a button in a
Sheet. From md up the three-column ResizablePanelGroup and its layout cookie
are unchanged.

This is the one place the layout is chosen in JS rather than CSS: the three panes
are heavy and self-fetching, so rendering both arrangements would mount and fetch
everything twice. useIsMobileState was added for it — it returns undefined
until the first measurement so the layout waits instead of guessing desktop and
remounting a frame later.

Bugs fixed along the way:

  • <ChatRealtime /> lived inside the message pane. In the mobile single-pane view
    that pane unmounts when returning to the list, which would have taken the
    realtime socket down with it. It now sits at the layout root.
  • The single-pane view unmounts and remounts ConversationList every time the
    user goes back from a thread. Its mount effects re-read ?conversationId= from
    the URL and re-select it, and re-ran the auto-select-first-conversation logic —
    so tapping back reopened the same thread, and landing on /inbox on a phone
    could skip the list entirely. The back control now also clears the URL param,
    and the mobile list passes autoSelectFirstConversation={false}.

Dialogs

DialogContent's base width goes from w-full to w-[calc(100%-2rem)].
tailwind-merge resolves max-w-* by group, so the ~60 dialogs passing an
unprefixed max-w-* were replacing the max-w-[calc(100%-2rem)] guard and
rendering edge-to-edge on a phone. Width is a separate group, so the gutter now
survives. One line, no consumer changes.

Verification

  • pnpm lint clean; check-types clean for builder, ui, analytics-nextjs and
    vitest-config; pnpm build passes.
  • Builder, ui and analytics test suites passing, including new coverage for the
    mobile inbox's back-button/URL/auto-select fix.
  • Driven in a real browser at 390×844 across inbox, contacts, flows, broadcasts,
    settings and both analytics dashboards: every route measures
    scrollWidth == clientWidth, with no element outside the viewport. Re-checked
    at 1440×900 to confirm desktop is unchanged.
  • That sweep caught a real defect: admins-analysis.tsx carried an unprefixed
    col-span-2, which against the new one-column mobile grid created an implicit
    second column and pushed the analytics page to 471px in a 375px viewport. It
    was invisible before because the grid was always two columns. Fixed here.

packages/vitest-config gains matchMedia and ResizeObserver stubs for jsdom;
without them useIsMobile, the sidebar's mobile branch, and anything using a
resizable panel throw on mount and could not be tested at all.

Not included

max-h-screen appears on 101 dialog lines. On iOS Safari 100vh overshoots the
visible area, so a dialog's footer can sit under the browser chrome. Left alone
because the sweep would cross into the flow builder.


🤖 Generated with Claude Code

@github-actions github-actions Bot added the feature New feature or request label Aug 18, 2026
*/}
<header className="sticky top-0 z-20 flex h-12 shrink-0 items-center gap-2 border-b bg-background px-2 pt-[env(safe-area-inset-top)] md:hidden">
<SidebarMobileTrigger />
<span className="truncate font-medium text-sm">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the contribution.
We want to dedicate the entire screen to the main content,
so we intentionally chose not to display the header.
Please remove it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — the header is gone from both shells (the workspace one and the manage/admin console), so the page now starts at the top of the viewport.

One thing worth flagging: that bar was the only way to open the sidebar below md. SidebarTrigger is hidden md:inline-flex, AppSidebar doesn't render SidebarRail, and the Base UI Sheet only does swipe-to-dismiss, not swipe-to-open — so removing it outright left phones with no navigation at all.

Rather than put another bar back, the control moved to a fixed drawer handle on the inline-start edge (24×64, md:hidden). It reserves no height, so the whole viewport still belongs to the page content.

Removing the header also let the inbox drop its copy of the shell's h-12: the mobile pane was sized calc(100dvh - 3rem) and is now simply h-[100dvh].

Verified at 390×844 — no <header> in either shell, main starts at y=0, the handle opens the Sheet, tapping a nav link closes it, and the inbox pane is exactly the viewport height. Desktop is unchanged: the handle is display:none, SidebarTrigger sits where it always did, and the inbox keeps its three panels.

Happy to drop the handle as well if you'd rather have no mobile chrome at all — it's isolated in its own commit.

eduardocodes added a commit to eduardocodes/ChatbotX that referenced this pull request Aug 20, 2026
The review on ChatbotXIO#970 asked for the whole screen to go to page content, so
neither shell renders a top bar below md any more. That bar was the only
way into the sidebar on a phone — SidebarTrigger is hidden md:inline-flex
and the Sheet has no swipe-to-open — so the control moves to the screen's
inline-start edge as a fixed drawer handle, which reserves no height.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eduardocodes and others added 25 commits September 3, 2026 11:55
The review on ChatbotXIO#970 asked for the whole screen to go to page content, so
neither shell renders a top bar below md any more. That bar was the only
way into the sidebar on a phone — SidebarTrigger is hidden md:inline-flex
and the Sheet has no swipe-to-open — so the control moves to the screen's
inline-start edge as a fixed drawer handle, which reserves no height.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eight

The mobile pane sized itself with calc(100dvh - 3rem), a hand-copy of the
shell's h-12 header. With no header, the pane is simply h-[100dvh] and the
cross-module value is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@realcodesiman
realcodesiman force-pushed the feat/responsive-mobile-ui branch from 6546021 to dffed6e Compare September 3, 2026 07:04
…ding

The mobile single-pane inbox unmounts ConversationList whenever the user
goes back from a thread. Its mount effects re-read `?conversationId=` from
the URL and re-select it, and re-run the auto-select-first-conversation
logic on every remount, so tapping back reopened the same thread and a
fresh phone visit to /inbox could skip the list and land in a thread.

Add an `autoSelectFirst` option to the chat store's `loadMoreConversations`,
a `useConversationIdParam` hook to read/write the URL param from one place,
and thread an `autoSelectFirstConversation` prop through ConversationList
and ConversationListPane. The mobile layout now passes `false` and clears
the URL param in its back handler; desktop is unaffected. Also reset the
mobile contact sheet when the active conversation changes, so it can't
reopen bound to a previous conversation.
realcodesiman and others added 2 commits September 3, 2026 15:55
…scrollbar triad

- Remove DataTable's `scrollable` prop: the default flipped to `true` in
  this PR and no caller passes `false` anymore, so the prop, its ternary,
  and the redundant `scrollable` on workspace-members-table were dead.
- Drop the now-duplicate `data-state` on DataTableRowCard — the DataTable
  wrapper already sets it on the card container, and nothing styles it on
  cards.
- Drop the unreachable `= {}` default on MessageHead's props; its one
  caller always passes an object.
- Add a shared `scrollbar-hide` Tailwind utility in packages/ui and use it
  in place of the `[-ms-overflow-style:none] [scrollbar-width:none]
  [&::-webkit-scrollbar]:hidden` triad duplicated across app-tab,
  analytics-nav and message-input.
- Simplify setup-dom's media-query evaluator with `matchAll` instead of a
  manual `exec` loop with a `lastIndex` reset.
- Fix comments left over from removing the mobile shell header: the
  viewport comment named a header that no longer exists, the vitest-config
  preset comment didn't mention the ResizeObserver stub, and two test
  comments narrated PR review history instead of stating the invariant.
The ResizeObserver stub let Base UI's ScrollAreaViewport past the guard at
the top of its layout effect, and the effect then schedules
`viewport.getAnimations({ subtree: true })` on a 0ms timeout. jsdom
implements no Web Animations API, so any suite driving fake timers fires
that timeout and throws `getAnimations is not a function` outside an act
boundary, taking the whole file down through an unhandled rejection.

`__tests__/category-resource-list-selection.test.tsx` is the file that hit
it: it advances timers by 500ms to drive the search debounce.

Return an empty list — truthful under jsdom, which runs no animations, and
the branch Base UI short-circuits on, so no Animation object has to be
faked. Guarded like the matchMedia and ResizeObserver stubs beside it, so a
real implementation (jsdom gaining one) is left untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnHNPtTMsp9aeUogicybch
@realcodesiman

Copy link
Copy Markdown
Contributor

Thanks for the PR! Nice change.
Hope we'll see more from you 🥰

@realcodesiman
realcodesiman merged commit 88051c1 into ChatbotXIO:main Sep 3, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants