From e729684bcb1c2a93302acdb4c17633d4222c6d38 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 18 Aug 2026 15:20:14 -0300 Subject: [PATCH 01/23] feat(builder): declare explicit viewport with safe-area support --- apps/builder/src/app/layout.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/builder/src/app/layout.tsx b/apps/builder/src/app/layout.tsx index b5fa321f6..ce534a794 100644 --- a/apps/builder/src/app/layout.tsx +++ b/apps/builder/src/app/layout.tsx @@ -1,5 +1,5 @@ import { UiProvider } from "@chatbotx.io/ui" -import type { Metadata } from "next" +import type { Metadata, Viewport } from "next" import { NextIntlClientProvider } from "next-intl" import { getLocale } from "next-intl/server" import type { ReactNode } from "react" @@ -14,6 +14,15 @@ import "./globals.css" import "./themes.css" import { DirectionProvider } from "@chatbotx.io/ui/components/ui/direction" +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + // `cover` lets the page paint under the notch/home indicator so + // `env(safe-area-inset-*)` reports real values. Sticky mobile chrome + // (the workspace header, the inbox composer) relies on those insets. + viewportFit: "cover", +} + export async function generateMetadata(): Promise { const { name, faviconUrl } = await getTenantSettings() From e2bedb05961c7ebf63a7fd27f0ba80b1a5254ee9 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 18 Aug 2026 15:20:34 -0300 Subject: [PATCH 02/23] feat(vitest-config): add a live matchMedia polyfill for jsdom suites --- packages/vitest-config/package.json | 1 + packages/vitest-config/src/react.ts | 8 ++ packages/vitest-config/src/setup-dom.ts | 165 ++++++++++++++++++++++++ packages/vitest-config/tsconfig.json | 6 + 4 files changed, 180 insertions(+) create mode 100644 packages/vitest-config/src/setup-dom.ts diff --git a/packages/vitest-config/package.json b/packages/vitest-config/package.json index 6a8c765e3..1a5f2d1cb 100644 --- a/packages/vitest-config/package.json +++ b/packages/vitest-config/package.json @@ -10,6 +10,7 @@ "./nextjs": "./src/nextjs.ts", "./msw": "./src/msw.ts", "./setup-env": "./src/setup-env.ts", + "./setup-dom": "./src/setup-dom.ts", "./setup-msw": "./src/setup-msw.ts" }, "scripts": { diff --git a/packages/vitest-config/src/react.ts b/packages/vitest-config/src/react.ts index e2a8e03d8..c6942999b 100644 --- a/packages/vitest-config/src/react.ts +++ b/packages/vitest-config/src/react.ts @@ -1,17 +1,25 @@ +import { fileURLToPath } from "node:url" import nodeConfig from "@chatbotx.io/vitest-config/node" import react from "@vitejs/plugin-react" import { mergeConfig, type ViteUserConfig } from "vitest/config" +const setupDomPath = fileURLToPath(new URL("./setup-dom.ts", import.meta.url)) + /** * Vitest preset for React libraries. * * Switches the environment to `jsdom` so React Testing Library and DOM APIs * work, and adds `@vitejs/plugin-react` for JSX transform. + * + * `setupFiles` is appended to the base preset's list (mergeConfig concatenates + * arrays), so the node setup still runs first and `setup-dom` only fills the + * DOM gaps jsdom leaves — today, `window.matchMedia`. */ const config: ViteUserConfig = mergeConfig(nodeConfig, { plugins: [react()], test: { environment: "jsdom", + setupFiles: [setupDomPath], }, }) diff --git a/packages/vitest-config/src/setup-dom.ts b/packages/vitest-config/src/setup-dom.ts new file mode 100644 index 000000000..bfa8b5fb8 --- /dev/null +++ b/packages/vitest-config/src/setup-dom.ts @@ -0,0 +1,165 @@ +import { afterEach } from "vitest" + +/** + * jsdom ships no `window.matchMedia`, so anything that reads a media query — + * `useIsMobile`, the shadcn `Sidebar` mobile branch, and every responsive + * component built on them — throws on first render under the `react` and + * `nextjs` presets. + * + * This installs a minimal but *live* implementation. Queries are evaluated + * against `window.innerWidth`, and a `resize` event re-evaluates every + * outstanding query, firing `change` on the ones whose result flipped. Tests + * can therefore drive a breakpoint the way a real browser would: + * + * setViewportWidth(375) + * + * Only width features are understood (`min-width` / `max-width`), which is the + * complete set this repo queries today. A query with no width feature — or one + * this parser does not recognise — evaluates to `false` rather than throwing, + * matching the "no match" behaviour a browser would report for an unsupported + * feature. + * + * A real `window.matchMedia` (jsdom gaining one, or a workspace providing its + * own) is left untouched. + */ + +type ChangeListener = (event: MediaQueryListEvent) => void + +type TrackedQuery = { + readonly media: string + readonly listeners: Set + matches: boolean +} + +const WIDTH_FEATURE = /\(\s*(min|max)-width\s*:\s*([\d.]+)px\s*\)/gi + +const DEFAULT_VIEWPORT_WIDTH = 1024 + +const tracked = new Set() + +/** + * Evaluate every width feature in `media` against the current viewport. All + * features must hold (queries in this repo join them with `and`), and a query + * carrying no width feature at all never matches. + */ +function evaluateQuery(media: string): boolean { + const width = window.innerWidth + let sawFeature = false + + WIDTH_FEATURE.lastIndex = 0 + let match = WIDTH_FEATURE.exec(media) + + while (match !== null) { + sawFeature = true + const isMin = match[1]?.toLowerCase() === "min" + const bound = Number.parseFloat(match[2] ?? "") + + if (Number.isNaN(bound)) { + return false + } + if (isMin ? width < bound : width > bound) { + return false + } + + match = WIDTH_FEATURE.exec(media) + } + + return sawFeature +} + +function createMediaQueryList(media: string): MediaQueryList { + const entry: TrackedQuery = { + media, + listeners: new Set(), + matches: evaluateQuery(media), + } + tracked.add(entry) + + const list: MediaQueryList = { + get matches() { + return entry.matches + }, + get media() { + return entry.media + }, + onchange: null, + addEventListener: ( + type: string, + listener: EventListenerOrEventListenerObject, + ) => { + if (type === "change" && typeof listener === "function") { + entry.listeners.add(listener as ChangeListener) + } + }, + removeEventListener: ( + type: string, + listener: EventListenerOrEventListenerObject, + ) => { + if (type === "change" && typeof listener === "function") { + entry.listeners.delete(listener as ChangeListener) + } + }, + // Deprecated Safari-era API, still called by some libraries. + addListener: (listener: ChangeListener | null) => { + if (listener) { + entry.listeners.add(listener) + } + }, + removeListener: (listener: ChangeListener | null) => { + if (listener) { + entry.listeners.delete(listener) + } + }, + dispatchEvent: () => true, + } as MediaQueryList + + return list +} + +/** Re-evaluate every live query and notify the ones that changed. */ +function refreshTrackedQueries(): void { + for (const entry of tracked) { + const next = evaluateQuery(entry.media) + if (next === entry.matches) { + continue + } + entry.matches = next + + const event = { matches: next, media: entry.media } as MediaQueryListEvent + for (const listener of entry.listeners) { + listener(event) + } + } +} + +/** + * Set the viewport width and let every outstanding media query react, exactly + * as a browser resize would. Returns nothing; read the effect through the + * component under test. + */ +export function setViewportWidth(width: number): void { + Object.defineProperty(window, "innerWidth", { + configurable: true, + writable: true, + value: width, + }) + refreshTrackedQueries() + window.dispatchEvent(new Event("resize")) +} + +if (typeof window !== "undefined" && typeof window.matchMedia !== "function") { + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: (media: string) => createMediaQueryList(media), + }) + + window.addEventListener("resize", refreshTrackedQueries) + + afterEach(() => { + // Queries registered by a finished test can never fire again; dropping them + // keeps the registry from growing across a long suite. + tracked.clear() + setViewportWidth(DEFAULT_VIEWPORT_WIDTH) + }) +} diff --git a/packages/vitest-config/tsconfig.json b/packages/vitest-config/tsconfig.json index c0a90ef19..3eca1d21e 100644 --- a/packages/vitest-config/tsconfig.json +++ b/packages/vitest-config/tsconfig.json @@ -1,5 +1,11 @@ { "extends": "@chatbotx.io/typescript-config/base.json", + "compilerOptions": { + // `setup-dom.ts` runs inside the jsdom environment this package configures, + // so it needs the DOM lib the base config leaves out. Same override as + // `@chatbotx.io/typescript-config/react-library.json`. + "lib": ["ESNext", "DOM"] + }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"] } From fc479b556db847c9095a7eb13eb2c102db620f51 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 18 Aug 2026 15:20:34 -0300 Subject: [PATCH 03/23] test(ui): cover the useIsMobile breakpoint boundary --- packages/ui/__tests__/use-mobile.test.tsx | 87 +++++++++++++++++++++++ packages/ui/src/hooks/use-mobile.ts | 14 +++- 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 packages/ui/__tests__/use-mobile.test.tsx diff --git a/packages/ui/__tests__/use-mobile.test.tsx b/packages/ui/__tests__/use-mobile.test.tsx new file mode 100644 index 000000000..0312ec4fd --- /dev/null +++ b/packages/ui/__tests__/use-mobile.test.tsx @@ -0,0 +1,87 @@ +import { setViewportWidth } from "@chatbotx.io/vitest-config/setup-dom" +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test } from "vitest" +import { MOBILE_BREAKPOINT, useIsMobile } from "../src/hooks/use-mobile" + +function Probe({ onRead }: { onRead: (value: boolean) => void }) { + onRead(useIsMobile()) + return null +} + +describe("useIsMobile", () => { + let container: HTMLDivElement + let root: Root + let latest: boolean | undefined + + const render = () => { + act(() => { + root.render( + { + latest = value + }} + />, + ) + }) + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + latest = undefined + container = document.createElement("div") + document.body.append(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + test("reports mobile one pixel below the breakpoint", () => { + setViewportWidth(MOBILE_BREAKPOINT - 1) + render() + + expect(latest).toBe(true) + }) + + test("reports desktop exactly at the breakpoint", () => { + setViewportWidth(MOBILE_BREAKPOINT) + render() + + expect(latest).toBe(false) + }) + + test("reports desktop well above the breakpoint", () => { + setViewportWidth(1440) + render() + + expect(latest).toBe(false) + }) + + test("follows the viewport when it crosses the breakpoint", () => { + setViewportWidth(1440) + render() + expect(latest).toBe(false) + + act(() => { + setViewportWidth(375) + }) + expect(latest).toBe(true) + + act(() => { + setViewportWidth(1024) + }) + expect(latest).toBe(false) + }) + + test("stays paired with Tailwind's md breakpoint", () => { + // The hook's JS branch and every `md:` CSS branch must agree about which + // layout is showing. Tailwind v4's stock `md` is 48rem = 768px and this + // repo defines no `--breakpoint-*` override. + expect(MOBILE_BREAKPOINT).toBe(768) + }) +}) diff --git a/packages/ui/src/hooks/use-mobile.ts b/packages/ui/src/hooks/use-mobile.ts index 2b0fe1dfe..2ba9772f5 100644 --- a/packages/ui/src/hooks/use-mobile.ts +++ b/packages/ui/src/hooks/use-mobile.ts @@ -1,6 +1,18 @@ import * as React from "react" -const MOBILE_BREAKPOINT = 768 +/** + * Viewport width, in px, below which the UI switches to its mobile layout. + * + * This is deliberately the same value as Tailwind's `md` breakpoint (48rem). + * The project has no `tailwind.config.*` — it is Tailwind v4 CSS-first, themed + * from `packages/ui/src/styles/default.css`, with no `--breakpoint-*` override + * — so `md` is the stock 768px. Components pair a CSS `md:` branch with this + * hook, and the two must agree or the JS and CSS halves of a responsive + * component disagree about which layout is showing. + * + * Change one, change the other. + */ +export const MOBILE_BREAKPOINT = 768 export function useIsMobile() { const [isMobile, setIsMobile] = React.useState(undefined) From 871efba3cb5a8de9d2c5de0ad6f7dd3d2682e892 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 18 Aug 2026 15:26:41 -0300 Subject: [PATCH 04/23] feat(ui): add a hamburger sidebar trigger for the mobile sheet --- .../__tests__/sidebar-mobile-trigger.test.tsx | 117 ++++++++++++++++++ packages/ui/src/components/ui/sidebar.tsx | 39 ++++++ 2 files changed, 156 insertions(+) create mode 100644 packages/ui/__tests__/sidebar-mobile-trigger.test.tsx diff --git a/packages/ui/__tests__/sidebar-mobile-trigger.test.tsx b/packages/ui/__tests__/sidebar-mobile-trigger.test.tsx new file mode 100644 index 000000000..9e00c650d --- /dev/null +++ b/packages/ui/__tests__/sidebar-mobile-trigger.test.tsx @@ -0,0 +1,117 @@ +import { setViewportWidth } from "@chatbotx.io/vitest-config/setup-dom" +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test } from "vitest" +import { + SidebarMobileTrigger, + SidebarProvider, + useSidebar, +} from "../src/components/ui/sidebar" + +function SidebarStateProbe({ + onRead, +}: { + onRead: (state: { openMobile: boolean; isMobile: boolean }) => void +}) { + const { openMobile, isMobile } = useSidebar() + onRead({ openMobile, isMobile }) + return null +} + +describe("SidebarMobileTrigger", () => { + let container: HTMLDivElement + let root: Root + let state: { openMobile: boolean; isMobile: boolean } | undefined + + const renderShell = () => { + act(() => { + root.render( + + + { + state = next + }} + /> + , + ) + }) + } + + const trigger = () => + container.querySelector( + '[data-slot="sidebar-mobile-trigger"]', + ) + + const click = (element: HTMLElement) => { + act(() => { + element.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ) + }) + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + state = undefined + container = document.createElement("div") + document.body.append(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + test("renders a labelled button", () => { + setViewportWidth(375) + renderShell() + + const button = trigger() + expect(button).not.toBeNull() + expect(button?.textContent).toContain("Toggle Sidebar") + }) + + test("opens the sidebar sheet on a mobile viewport", () => { + setViewportWidth(375) + renderShell() + expect(state?.isMobile).toBe(true) + expect(state?.openMobile).toBe(false) + + const button = trigger() + if (!button) { + throw new Error("mobile trigger did not render") + } + click(button) + + expect(state?.openMobile).toBe(true) + }) + + test("closes the sheet when tapped again", () => { + setViewportWidth(375) + renderShell() + + const button = trigger() + if (!button) { + throw new Error("mobile trigger did not render") + } + click(button) + expect(state?.openMobile).toBe(true) + + click(button) + expect(state?.openMobile).toBe(false) + }) + + test("still renders above the breakpoint so callers control visibility", () => { + // The button hides itself nowhere — the shell wraps it in an `md:hidden` + // container. A caller with an always-mobile shell can render it unwrapped. + setViewportWidth(1440) + renderShell() + + expect(trigger()).not.toBeNull() + expect(state?.isMobile).toBe(false) + }) +}) diff --git a/packages/ui/src/components/ui/sidebar.tsx b/packages/ui/src/components/ui/sidebar.tsx index 4bc23bad4..3e21ecb7d 100644 --- a/packages/ui/src/components/ui/sidebar.tsx +++ b/packages/ui/src/components/ui/sidebar.tsx @@ -8,6 +8,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { ChevronLeftIcon, ChevronRightIcon, + MenuIcon, } from "lucide-react" import { useIsMobile } from "@chatbotx.io/ui/hooks/use-mobile" @@ -292,6 +293,43 @@ function SidebarTrigger({ ) } +/** + * Menu button for the mobile layout, where `Sidebar` renders as a `Sheet`. + * + * `SidebarTrigger` is a rail-collapse affordance: a small chevron that only + * reads as "collapse this column" next to a visible sidebar. Below the mobile + * breakpoint there is no column to collapse, so the same control needs the + * conventional hamburger shape and a full touch target. + * + * Render this inside a container that is itself hidden from `md` up; the button + * does not hide itself, so a caller can also use it in an always-mobile shell. + */ +function SidebarMobileTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { const { toggleSidebar } = useSidebar() @@ -739,6 +777,7 @@ export { SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, + SidebarMobileTrigger, SidebarProvider, SidebarRail, SidebarSeparator, From 755dff7c5220e2978f51699522bf0e6ef9b14829 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 18 Aug 2026 15:26:41 -0300 Subject: [PATCH 05/23] feat(builder): add mobile header with sidebar trigger to workspace shell --- .../src/app/space/[workspaceId]/layout.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/builder/src/app/space/[workspaceId]/layout.tsx b/apps/builder/src/app/space/[workspaceId]/layout.tsx index e65f214b4..fbfb76823 100644 --- a/apps/builder/src/app/space/[workspaceId]/layout.tsx +++ b/apps/builder/src/app/space/[workspaceId]/layout.tsx @@ -8,6 +8,7 @@ import { } from "@chatbotx.io/business" import { SidebarInset, + SidebarMobileTrigger, SidebarProvider, SidebarTrigger, } from "@chatbotx.io/ui/components/ui/sidebar" @@ -121,7 +122,20 @@ export default async function WorkspaceLayout({ workspaceId={workspaceId} /> -
+ {/* + Below `md` the sidebar collapses into a Sheet, and `SidebarTrigger` + below is positioned off the inset's inline edge — where nothing can + reach it. This bar is the only way in on a phone, so it stays pinned + while the page scrolls (the body is the scroll container: the sidebar + wrapper is `min-h-svh` with no overflow of its own). + */} +
+ + + {targetWorkspaceMember.workspace.name} + +
+
- + ) From 51c9fb4c0b1f9f94d931e735f6b2ae6637c072c8 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 18 Aug 2026 15:26:41 -0300 Subject: [PATCH 06/23] fix(builder): close mobile sidebar sheet when a nav link is tapped --- .../__tests__/nav-main-mobile.test.tsx | 145 ++++++++++++++++++ apps/builder/src/components/nav-main.tsx | 13 +- 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 apps/builder/__tests__/nav-main-mobile.test.tsx diff --git a/apps/builder/__tests__/nav-main-mobile.test.tsx b/apps/builder/__tests__/nav-main-mobile.test.tsx new file mode 100644 index 000000000..3e245e2b6 --- /dev/null +++ b/apps/builder/__tests__/nav-main-mobile.test.tsx @@ -0,0 +1,145 @@ +import { + SidebarProvider, + useSidebar, +} from "@chatbotx.io/ui/components/ui/sidebar" +import { setViewportWidth } from "@chatbotx.io/vitest-config/setup-dom" +import type { ComponentProps, ReactNode } from "react" +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" + +vi.mock("next/navigation", () => ({ + usePathname: () => "/space/w1/inbox", +})) + +vi.mock("next/link", () => ({ + default: ({ children, href, ...rest }: ComponentProps<"a">) => ( + + {children} + + ), +})) + +const { NavMain } = await import("@/components/nav-main") + +type SidebarState = { openMobile: boolean; isMobile: boolean } + +function SidebarStateProbe({ + onReady, + onRead, +}: { + onReady: (open: (value: boolean) => void) => void + onRead: (state: SidebarState) => void +}) { + const { openMobile, isMobile, setOpenMobile } = useSidebar() + onRead({ openMobile, isMobile }) + onReady(setOpenMobile) + return null +} + +const ITEMS = [ + { title: "Inbox", url: "/space/w1/inbox" }, + { title: "Contacts", url: "/space/w1/contacts" }, +] + +describe("NavMain on a mobile viewport", () => { + let container: HTMLDivElement + let root: Root + let state: SidebarState | undefined + let setOpenMobile: ((value: boolean) => void) | undefined + + const swallowNavigation = (event: Event) => event.preventDefault() + + const renderNav = (children: ReactNode) => { + act(() => { + root.render( + + {children} + { + state = next + }} + onReady={(setter) => { + setOpenMobile = setter + }} + /> + , + ) + }) + } + + const clickLink = (label: string) => { + const link = Array.from(container.querySelectorAll("a")).find((anchor) => + anchor.textContent?.includes(label), + ) + if (!link) { + throw new Error(`no nav link labelled ${label}`) + } + act(() => { + link.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ) + }) + } + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + state = undefined + setOpenMobile = undefined + container = document.createElement("div") + document.body.append(container) + root = createRoot(container) + // jsdom cannot navigate; without this every link click logs a "Not + // implemented" error that has nothing to do with the behaviour under test. + document.addEventListener("click", swallowNavigation) + }) + + afterEach(() => { + document.removeEventListener("click", swallowNavigation) + act(() => { + root.unmount() + }) + container.remove() + }) + + test("closes the sidebar sheet when a nav link is tapped", () => { + setViewportWidth(375) + renderNav() + expect(state?.isMobile).toBe(true) + + act(() => { + setOpenMobile?.(true) + }) + expect(state?.openMobile).toBe(true) + + clickLink("Contacts") + + expect(state?.openMobile).toBe(false) + }) + + test("closes the sheet for cross-zone links too", () => { + setViewportWidth(375) + renderNav() + + act(() => { + setOpenMobile?.(true) + }) + expect(state?.openMobile).toBe(true) + + clickLink("Inbox") + + expect(state?.openMobile).toBe(false) + }) + + test("leaves the desktop sidebar untouched", () => { + setViewportWidth(1440) + renderNav() + expect(state?.isMobile).toBe(false) + + clickLink("Contacts") + + // `openMobile` is not read on desktop; the important part is that the click + // handler runs without disturbing the expanded/collapsed rail state. + expect(state?.openMobile).toBe(false) + }) +}) diff --git a/apps/builder/src/components/nav-main.tsx b/apps/builder/src/components/nav-main.tsx index 895aa9b52..a7258217b 100644 --- a/apps/builder/src/components/nav-main.tsx +++ b/apps/builder/src/components/nav-main.tsx @@ -6,6 +6,7 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, + useSidebar, } from "@chatbotx.io/ui/components/ui/sidebar" import type { LucideIcon } from "lucide-react" import Link from "next/link" @@ -26,11 +27,13 @@ function NavItemContent({ className, isDisabled, isCrossZone, + onNavigate, }: { item: NavItem className: string isDisabled: boolean isCrossZone: boolean + onNavigate: () => void }) { const label = ( <> @@ -49,6 +52,7 @@ function NavItemContent({ @@ -58,7 +62,7 @@ function NavItemContent({ } return ( - + {label} ) @@ -76,6 +80,12 @@ export function NavMain({ disabledTooltip?: string }) { const pathname = usePathname() + const { setOpenMobile } = useSidebar() + + // On mobile the sidebar is a Sheet rendered over the page. Navigating leaves + // it open on top of the destination, so close it as the link is followed. + // No-op on desktop, where `openMobile` is not read. + const closeMobileSidebar = () => setOpenMobile(false) return ( @@ -98,6 +108,7 @@ export function NavMain({ isCrossZone={crossZone || Boolean(item.crossZone)} isDisabled={isDisabled} item={item} + onNavigate={closeMobileSidebar} /> From 3b3a39813907d8cfe698290841192951c6343a37 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 18 Aug 2026 15:26:41 -0300 Subject: [PATCH 07/23] fix(builder): make app tab strip horizontally scrollable on narrow viewports --- apps/builder/__tests__/app-tab.test.tsx | 131 ++++++++++++++++++++++++ apps/builder/src/components/app-tab.tsx | 13 ++- 2 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 apps/builder/__tests__/app-tab.test.tsx diff --git a/apps/builder/__tests__/app-tab.test.tsx b/apps/builder/__tests__/app-tab.test.tsx new file mode 100644 index 000000000..9b75eb347 --- /dev/null +++ b/apps/builder/__tests__/app-tab.test.tsx @@ -0,0 +1,131 @@ +import type { ComponentProps, ReactNode } from "react" +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + ...rest + }: { + children: ReactNode + href: string + className?: string + }) => ( + + {children} + + ), +})) + +const { AppTab } = await import("@/components/app-tab") + +type Tab = ComponentProps["tabs"][number] + +const TABS: Tab[] = [ + { label: "General", href: "/settings/general", isActive: true }, + { label: "Channels", href: "/settings/channels", isActive: false }, + { label: "Integrations", href: "/settings/integrations", isActive: false }, + { label: "Admins", href: "/settings/admins", isActive: false }, + { label: "Inbox teams", href: "/settings/inbox-teams", isActive: false }, +] + +describe("AppTab", () => { + let container: HTMLDivElement + let root: Root + + const render = (tabs: Tab[]) => { + act(() => { + root.render() + }) + } + + const strip = () => { + const anchor = container.querySelector("a") + return anchor?.parentElement ?? null + } + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + container = document.createElement("div") + document.body.append(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + test("renders every tab", () => { + render(TABS) + + const labels = Array.from(container.querySelectorAll("a")).map( + (anchor) => anchor.textContent, + ) + expect(labels).toEqual([ + "General", + "Channels", + "Integrations", + "Admins", + "Inbox teams", + ]) + }) + + test("scrolls the strip instead of overflowing the page", () => { + render(TABS) + + const className = strip()?.className ?? "" + expect(className).toContain("overflow-x-auto") + expect(className).toContain("flex-nowrap") + }) + + test("keeps each tab at its natural width so labels never squeeze", () => { + render(TABS) + + for (const anchor of Array.from(container.querySelectorAll("a"))) { + expect(anchor.className).toContain("shrink-0") + expect(anchor.className).toContain("whitespace-nowrap") + } + }) + + test("tightens padding on small screens and restores it from md up", () => { + render(TABS) + + const className = strip()?.className ?? "" + expect(className).toContain("px-4") + expect(className).toContain("md:px-8") + expect(className).toContain("gap-4") + expect(className).toContain("md:gap-8") + }) + + test("marks the active tab", () => { + render(TABS) + + const active = Array.from(container.querySelectorAll("a")).find((anchor) => + anchor.className.includes("border-neutral-700"), + ) + expect(active?.textContent).toBe("General") + }) + + test("renders a disabled tab as a non-link", () => { + render([ + { label: "General", href: "/settings/general", isActive: true }, + { + label: "Locked", + href: "/settings/locked", + isActive: false, + disabled: true, + }, + ]) + + const anchors = Array.from(container.querySelectorAll("a")).map( + (anchor) => anchor.textContent, + ) + expect(anchors).toEqual(["General"]) + expect(container.textContent).toContain("Locked") + }) +}) diff --git a/apps/builder/src/components/app-tab.tsx b/apps/builder/src/components/app-tab.tsx index d91bf481b..cfb00e167 100644 --- a/apps/builder/src/components/app-tab.tsx +++ b/apps/builder/src/components/app-tab.tsx @@ -20,7 +20,9 @@ type AppTabProps = { } function getTabClassName(tab: AppTabProps["tabs"][number]) { - const base = "border-b-2 py-6 text-sm" + // `shrink-0` + `whitespace-nowrap` keep each tab at its natural width so + // the strip overflows (and scrolls) instead of squeezing labels. + const base = "shrink-0 whitespace-nowrap border-b-2 py-4 text-sm md:py-6" if (tab.disabled) { const disabledPresentation = tab.disabledPresentation === "normal" @@ -37,7 +39,14 @@ function getTabClassName(tab: AppTabProps["tabs"][number]) { export function AppTab({ tabs }: AppTabProps) { return ( - + {/* + Several surfaces render 5-6 tabs, which cannot fit a phone. Scrolling + the strip keeps every tab reachable without pushing the page itself + into horizontal overflow. The scrollbar is hidden because the strip + sits directly under a card edge, where a persistent bar reads as a + rendering artefact; touch scrolling needs no visible track. + */} + {tabs.map((tab) => tab.disabled ? ( From 1830f30c52604661b353c49baf384b5c4438f203 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 18 Aug 2026 15:26:41 -0300 Subject: [PATCH 08/23] refactor(builder): replace inbox negative-margin hack with FullBleed component --- .../app/space/[workspaceId]/inbox/page.tsx | 5 ++-- apps/builder/src/components/full-bleed.tsx | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 apps/builder/src/components/full-bleed.tsx diff --git a/apps/builder/src/app/space/[workspaceId]/inbox/page.tsx b/apps/builder/src/app/space/[workspaceId]/inbox/page.tsx index ec4d40865..d52c5ec62 100644 --- a/apps/builder/src/app/space/[workspaceId]/inbox/page.tsx +++ b/apps/builder/src/app/space/[workspaceId]/inbox/page.tsx @@ -1,6 +1,7 @@ import { getIdFromParams } from "@chatbotx.io/utils" import { cookies } from "next/headers" import { notFound } from "next/navigation" +import { FullBleed } from "@/components/full-bleed" import { ChatLayout } from "@/features/chat/chat-layout" import { ChatStoreProvider } from "@/features/chat/store/chat-store-provider" import { canViewContactEmailAndPhone } from "@/features/contacts/permissions" @@ -34,7 +35,7 @@ export default async function InboxPage({ params }: InboxPageProps) { ) return ( -
+ @@ -59,6 +60,6 @@ export default async function InboxPage({ params }: InboxPageProps) { -
+ ) } diff --git a/apps/builder/src/components/full-bleed.tsx b/apps/builder/src/components/full-bleed.tsx new file mode 100644 index 000000000..1805dd9c3 --- /dev/null +++ b/apps/builder/src/components/full-bleed.tsx @@ -0,0 +1,23 @@ +import { cn } from "@chatbotx.io/ui/lib/utils" +import type { ReactNode } from "react" + +type FullBleedProps = { + children: ReactNode + className?: string +} + +/** + * Cancels the workspace shell's content padding so a page can run edge to edge. + * + * The shell (`app/space/[workspaceId]/layout.tsx`) pads its `
` with + * `p-4 md:p-6`. Surfaces that own the whole viewport — the inbox, primarily — + * need that padding gone. Doing it inline with a bare negative margin couples + * the page to a spacing value it cannot see: change the shell's padding and the + * page silently gains or loses a gutter, with nothing to grep for. + * + * Keeping the mirror here makes the pair greppable and gives it one place to + * change. **The margins below must stay the negation of the shell's padding.** + */ +export function FullBleed({ children, className }: FullBleedProps) { + return
{children}
+} From 47b45546f80108f9057023b91ec2d92bcec1e20b Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 18 Aug 2026 15:26:41 -0300 Subject: [PATCH 09/23] feat(builder): add mobile header to the manage console shell --- apps/builder/src/features/manage/manage-layout.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/builder/src/features/manage/manage-layout.tsx b/apps/builder/src/features/manage/manage-layout.tsx index 8a77bc95a..f93100295 100644 --- a/apps/builder/src/features/manage/manage-layout.tsx +++ b/apps/builder/src/features/manage/manage-layout.tsx @@ -2,6 +2,7 @@ import { SidebarInset, + SidebarMobileTrigger, SidebarProvider, SidebarTrigger, } from "@chatbotx.io/ui/components/ui/sidebar" @@ -49,7 +50,15 @@ export function ManageLayout({ children, sidebar }: ManageLayoutProps) { > {sidebar} - + {/* + Mirrors the workspace shell: below `md` the sidebar is a Sheet and the + absolutely positioned trigger below sits off the inset's inline edge, + out of reach. See `app/space/[workspaceId]/layout.tsx`. + */} +
+ +
+
{children}
From 9fc2820de683e1180bb2e9e8848fe8a538480eed Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 18 Aug 2026 15:35:14 -0300 Subject: [PATCH 10/23] feat(ui): scroll DataTable by default and add an opt-in mobile card view --- packages/ui/__tests__/data-table.test.tsx | 134 ++++++++++++++++++ .../src/components/data-table/data-table.tsx | 66 +++++++-- 2 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 packages/ui/__tests__/data-table.test.tsx diff --git a/packages/ui/__tests__/data-table.test.tsx b/packages/ui/__tests__/data-table.test.tsx new file mode 100644 index 000000000..dc0069749 --- /dev/null +++ b/packages/ui/__tests__/data-table.test.tsx @@ -0,0 +1,134 @@ +import { + createColumnHelper, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table" +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test } from "vitest" +import { DataTable } from "../src/components/data-table/data-table" + +type Contact = { id: string; name: string; email: string } + +const ROWS: Contact[] = [ + { id: "1", name: "Ada", email: "ada@example.com" }, + { id: "2", name: "Grace", email: "grace@example.com" }, +] + +const helper = createColumnHelper() +const COLUMNS = [ + helper.accessor("name", { header: "Name" }), + helper.accessor("email", { header: "Email" }), +] + +function Harness({ + data = ROWS, + scrollable, + withCards = false, +}: { + data?: Contact[] + scrollable?: boolean + withCards?: boolean +}) { + const table = useReactTable({ + data, + columns: COLUMNS, + getCoreRowModel: getCoreRowModel(), + }) + + return ( + {row.original.name} + : undefined + } + scrollable={scrollable} + table={table} + /> + ) +} + +describe("DataTable", () => { + let container: HTMLDivElement + let root: Root + + const render = (props: Parameters[0] = {}) => { + act(() => { + root.render() + }) + } + + const tableRegion = () => + container.querySelector("div.rounded-md.border") + + const cardList = () => + container.querySelector('[data-slot="data-table-cards"]') + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + container = document.createElement("div") + document.body.append(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + test("scrolls horizontally by default instead of clipping columns", () => { + render() + + expect(tableRegion()?.className).toContain("overflow-x-auto") + expect(tableRegion()?.className).not.toContain("overflow-hidden") + }) + + test("still allows a caller to opt into clipping", () => { + render({ scrollable: false }) + + expect(tableRegion()?.className).toContain("overflow-hidden") + expect(tableRegion()?.className).not.toContain("overflow-x-auto") + }) + + test("renders only the table when no mobileCard is supplied", () => { + render() + + expect(cardList()).toBeNull() + expect(container.querySelector("table")).not.toBeNull() + // Nothing hides the table, so existing consumers are untouched. + expect(tableRegion()?.className).not.toContain("hidden") + }) + + test("renders a card per row and hides the table below md when supplied", () => { + render({ withCards: true }) + + const cards = Array.from( + container.querySelectorAll('[data-testid="card"]'), + ).map((node) => node.textContent) + expect(cards).toEqual(["Ada", "Grace"]) + + expect(cardList()?.className).toContain("md:hidden") + expect(tableRegion()?.className).toContain("hidden") + expect(tableRegion()?.className).toContain("md:block") + }) + + test("keeps the table rendered so it takes over from md up", () => { + render({ withCards: true }) + + const cells = Array.from(container.querySelectorAll("td")).map( + (node) => node.textContent, + ) + expect(cells).toContain("Ada") + }) + + test("shows the empty label in both views", () => { + render({ data: [], withCards: true }) + + expect(cardList()?.textContent).toBe("Nothing here") + expect(container.querySelector("tbody")?.textContent).toBe("Nothing here") + }) +}) diff --git a/packages/ui/src/components/data-table/data-table.tsx b/packages/ui/src/components/data-table/data-table.tsx index 12d6eba88..c44a8d3f8 100644 --- a/packages/ui/src/components/data-table/data-table.tsx +++ b/packages/ui/src/components/data-table/data-table.tsx @@ -1,4 +1,8 @@ -import { flexRender, type Table as TanstackTable } from "@tanstack/react-table" +import { + flexRender, + type Row, + type Table as TanstackTable, +} from "@tanstack/react-table" import type * as React from "react" import { DataTablePagination } from "@chatbotx.io/ui/components/data-table/data-table-pagination" @@ -20,28 +24,75 @@ interface DataTableProps extends React.ComponentProps<"div"> { labels?: DataTablePaginationLabels & { noResults?: string } + /** + * Whether the bordered table region scrolls horizontally when its columns + * exceed the available width. + * + * Defaults to `true`. Clipping is only ever right for a table whose columns + * are guaranteed to fit; every other table loses its rightmost columns with + * no way to reach them, which on a phone is most of the row. + */ scrollable?: boolean + /** + * Renders one row as a card for narrow viewports. + * + * When supplied, the card list replaces the table below `md` and the table + * takes over from `md` up — a wide table reduced to horizontal scrolling is + * readable but miserable to work through on a phone. Toolbar and pagination + * are shared by both. + * + * The switch is CSS, not a media-query hook, so the correct layout is present + * in the first paint instead of flipping after hydration. Both trees are in + * the DOM, which is why this is opt-in: pay the duplication only where the + * card view earns it. + */ + mobileCard?: (row: Row) => React.ReactNode } export function DataTable({ table, actionBar, labels, - scrollable = false, + scrollable = true, + mobileCard, children, className, ...props }: DataTableProps) { + const rows = table.getRowModel().rows + const noResults = labels?.noResults ?? "No results." + return (
{children} + {mobileCard && ( +
+ {rows.length ? ( + rows.map((row) => ( +
+ {mobileCard(row)} +
+ )) + ) : ( +
{noResults}
+ )} +
+ )}
@@ -72,8 +123,8 @@ export function DataTable({ ))} - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( + {rows.length ? ( + rows.map((row) => ( ({ : cell.column.getSize(), }} > - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} @@ -103,7 +151,7 @@ export function DataTable({ colSpan={table.getAllColumns().length} className="h-24 text-center" > - {labels?.noResults ?? "No results."} + {noResults} )} From 9e3217dddd88f08d77b52e17842e133755434ee6 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 18 Aug 2026 15:35:14 -0300 Subject: [PATCH 11/23] feat(ui): add a generic mobile row card for data tables --- .../ui/__tests__/data-table-row-card.test.tsx | 94 +++++++++++++++++++ .../data-table/data-table-row-card.tsx | 69 ++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 packages/ui/__tests__/data-table-row-card.test.tsx create mode 100644 packages/ui/src/components/data-table/data-table-row-card.tsx diff --git a/packages/ui/__tests__/data-table-row-card.test.tsx b/packages/ui/__tests__/data-table-row-card.test.tsx new file mode 100644 index 000000000..1f6b54164 --- /dev/null +++ b/packages/ui/__tests__/data-table-row-card.test.tsx @@ -0,0 +1,94 @@ +import { + createColumnHelper, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table" +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test } from "vitest" +import { DataTableRowCard } from "../src/components/data-table/data-table-row-card" + +type Flow = { id: string; name: string; status: string } + +const helper = createColumnHelper() +const COLUMNS = [ + helper.display({ + id: "select", + cell: () => , + }), + helper.accessor("name", { + header: "Name", + cell: (info) => {info.getValue()}, + meta: { label: "Name" }, + }), + helper.accessor("status", { + header: "Status", + cell: (info) => info.getValue(), + meta: { label: "Status" }, + }), + helper.display({ + id: "actions", + cell: () => , + }), +] + +function Harness() { + const table = useReactTable({ + data: [{ id: "1", name: "Welcome flow", status: "active" }], + columns: COLUMNS, + getCoreRowModel: getCoreRowModel(), + }) + const row = table.getRowModel().rows[0] + if (!row) { + throw new Error("fixture produced no row") + } + return +} + +describe("DataTableRowCard", () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + container = document.createElement("div") + document.body.append(container) + root = createRoot(container) + act(() => { + root.render() + }) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + test("reuses each column's own cell renderer", () => { + // The card must not carry a second copy of a cell that could drift from + // the table's. + expect( + container.querySelector('[data-testid="name-cell"]')?.textContent, + ).toBe("Welcome flow") + }) + + test("labels fields from column meta", () => { + const labels = Array.from(container.querySelectorAll("dt")).map( + (node) => node.textContent, + ) + expect(labels).toEqual(["Name", "Status"]) + }) + + test("lifts select and actions out of the field list", () => { + const values = Array.from(container.querySelectorAll("dd")).map( + (node) => node.textContent, + ) + expect(values).toEqual(["Welcome flow", "active"]) + + // Both still render, just as chrome above the fields. + expect(container.querySelector('input[type="checkbox"]')).not.toBeNull() + expect(container.querySelector("button")?.textContent).toBe("Menu") + }) +}) diff --git a/packages/ui/src/components/data-table/data-table-row-card.tsx b/packages/ui/src/components/data-table/data-table-row-card.tsx new file mode 100644 index 000000000..fb469928f --- /dev/null +++ b/packages/ui/src/components/data-table/data-table-row-card.tsx @@ -0,0 +1,69 @@ +import { cn } from "@chatbotx.io/ui/lib/utils" +import { flexRender, type Row } from "@tanstack/react-table" + +/** + * Generic mobile card for one table row, for use as `DataTable`'s `mobileCard`. + * + * It renders the row's own cells through `flexRender`, so every cell keeps the + * renderer, formatting, and interactivity the table column already defines — + * there is no second copy of a cell to drift, and no new translation keys: the + * field names come from each column's `meta.label`. + * + * The `select` and `actions` columns are lifted into a header strip, since a + * checkbox and a row menu read as chrome rather than as fields. Any column + * without a `meta.label` still renders, just without a label. + * + * Tables whose card view should show *fewer* fields, or a bespoke arrangement, + * should pass their own `mobileCard` instead of this. + */ + +const CHROME_COLUMN_IDS = new Set(["select", "actions"]) + +export function DataTableRowCard({ + row, + className, +}: { + row: Row + className?: string +}) { + const cells = row.getVisibleCells() + const chrome = cells.filter((cell) => CHROME_COLUMN_IDS.has(cell.column.id)) + const fields = cells.filter((cell) => !CHROME_COLUMN_IDS.has(cell.column.id)) + + return ( +
+ {chrome.length > 0 && ( +
+ {chrome.map((cell) => ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ))} +
+ )} +
+ {fields.map((cell) => { + const label = cell.column.columnDef.meta?.label + return ( +
+ {label ? ( +
+ {label} +
+ ) : null} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ) + })} +
+
+ ) +} From 90755df0c5bbe3ed065ea77ee4978cff2c7ca3ce Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 18 Aug 2026 15:35:14 -0300 Subject: [PATCH 12/23] fix(ui): stack data table toolbar filters on narrow viewports --- .../data-table/data-table-toolbar.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/data-table/data-table-toolbar.tsx b/packages/ui/src/components/data-table/data-table-toolbar.tsx index 51903c92b..f4af9bde3 100644 --- a/packages/ui/src/components/data-table/data-table-toolbar.tsx +++ b/packages/ui/src/components/data-table/data-table-toolbar.tsx @@ -41,7 +41,9 @@ export function DataTableToolbar({ role="toolbar" aria-orientation="horizontal" className={cn( - "flex w-full items-start justify-between gap-2 p-1", + // Stacked on a phone: the filter row and the action row cannot share a + // line once a filter grows to the full width. + "flex w-full flex-col items-stretch justify-between gap-2 p-1 sm:flex-row sm:items-start", className, )} {...props} @@ -67,9 +69,7 @@ export function DataTableToolbar({ )} -
- {children} -
+
{children}
) } @@ -95,20 +95,23 @@ function DataTableToolbarFilter({ placeholder={columnMeta.placeholder ?? columnMeta.label} value={(column.getFilterValue() as string) ?? ""} onChange={(event) => column.setFilterValue(event.target.value)} - className="h-8 w-40 lg:w-56" + className="h-8 w-full sm:w-40 lg:w-56" /> ) case "number": return ( -
+
column.setFilterValue(event.target.value)} - className={cn("h-8 w-[120px]", columnMeta.unit && "pe-8")} + className={cn( + "h-8 w-full sm:w-[120px]", + columnMeta.unit && "pe-8", + )} /> {columnMeta.unit && ( From 0624ba725e37fdae89003be5e9be220e669d5426 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 18 Aug 2026 15:35:15 -0300 Subject: [PATCH 13/23] fix(ui): keep a viewport gutter on dialogs that override max-width --- .../ui/__tests__/dialog-mobile-width.test.tsx | 78 +++++++++++++++++++ packages/ui/src/components/ui/dialog.tsx | 10 ++- 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 packages/ui/__tests__/dialog-mobile-width.test.tsx diff --git a/packages/ui/__tests__/dialog-mobile-width.test.tsx b/packages/ui/__tests__/dialog-mobile-width.test.tsx new file mode 100644 index 000000000..66d279490 --- /dev/null +++ b/packages/ui/__tests__/dialog-mobile-width.test.tsx @@ -0,0 +1,78 @@ +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test } from "vitest" +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "../src/components/ui/dialog" + +/** + * ~60 dialogs across the app pass an unprefixed `max-w-*`. tailwind-merge + * resolves `max-w` by group, so those replace the base `max-w-[calc(100%-2rem)]` + * guard and used to leave the dialog edge-to-edge on a phone. The width is a + * separate group, so it survives — that is what these tests pin. + */ +const FULL_WIDTH_CLASS = /(^|\s)w-full(\s|$)/ + +describe("DialogContent mobile width", () => { + let container: HTMLDivElement + let root: Root + + const render = (className?: string) => { + act(() => { + root.render( + + + Title + Description + + , + ) + }) + } + + const popup = () => + document.querySelector('[data-slot="dialog-content"]') + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + container = document.createElement("div") + document.body.append(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + test("keeps a viewport gutter by default", () => { + render() + + expect(popup()?.className).toContain("w-[calc(100%-2rem)]") + expect(popup()?.className).toContain("max-w-[calc(100%-2rem)]") + }) + + test("keeps the gutter when a caller overrides max-width", () => { + render("max-w-lg") + + const className = popup()?.className ?? "" + // The caller's max-width wins, as intended for wide viewports... + expect(className).toContain("max-w-lg") + expect(className).not.toContain("max-w-[calc(100%-2rem)]") + // ...but the width still caps the dialog short of the screen edges. + expect(className).toContain("w-[calc(100%-2rem)]") + }) + + test("never falls back to a full-bleed w-full", () => { + render("max-h-screen max-w-5xl overflow-y-scroll") + + const className = popup()?.className ?? "" + expect(className).toContain("w-[calc(100%-2rem)]") + expect(className).not.toMatch(FULL_WIDTH_CLASS) + }) +}) diff --git a/packages/ui/src/components/ui/dialog.tsx b/packages/ui/src/components/ui/dialog.tsx index 0957ddd37..98d80b29e 100644 --- a/packages/ui/src/components/ui/dialog.tsx +++ b/packages/ui/src/components/ui/dialog.tsx @@ -52,10 +52,18 @@ function DialogContent({ return ( + {/* + Width is `w-[calc(100%-2rem)]`, not `w-full`, so the 1rem gutter + survives a caller's `className`. tailwind-merge resolves `max-w-*` by + group, so a consumer passing an unprefixed `max-w-lg` replaces the + `max-w-[calc(100%-2rem)]` guard below — on a phone that produced an + edge-to-edge dialog. The width caps it independently. On any viewport + wide enough for the `max-w` to bite, this is a no-op. + */} Date: Tue, 18 Aug 2026 15:35:15 -0300 Subject: [PATCH 14/23] feat(builder): render contacts, flows and broadcasts tables as cards on mobile --- .../features/broadcasts/broadcasts-table.tsx | 6 ++- .../src/features/contacts/contacts-table.tsx | 53 ++++++++++++++++++- .../src/features/flows/flows-table.tsx | 6 ++- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/apps/builder/src/features/broadcasts/broadcasts-table.tsx b/apps/builder/src/features/broadcasts/broadcasts-table.tsx index 5600df5b3..0e7535a80 100644 --- a/apps/builder/src/features/broadcasts/broadcasts-table.tsx +++ b/apps/builder/src/features/broadcasts/broadcasts-table.tsx @@ -2,6 +2,7 @@ import { DataTable } from "@chatbotx.io/ui/components/data-table/data-table" import { DataTableColumnHeader } from "@chatbotx.io/ui/components/data-table/data-table-column-header" +import { DataTableRowCard } from "@chatbotx.io/ui/components/data-table/data-table-row-card" import { DataTableToolbar } from "@chatbotx.io/ui/components/data-table/data-table-toolbar" import { Badge } from "@chatbotx.io/ui/components/ui/badge" import { Button, buttonVariants } from "@chatbotx.io/ui/components/ui/button" @@ -347,7 +348,10 @@ export function BroadcastsTable({ promises }: BroadcastsTableProps) { broadcastIds={broadcastIds} workspaceId={workspaceId} > - + } + table={table} + >
+ workspaceId: string +}) { + const t = useTranslations() + const contact = row.original + + return ( +
+ row.toggleSelected(Boolean(value))} + /> +
+ +
+
+
{t("fields.assignee.label")}
+
+ {getUserName( + contact.conversation?.assignedUser, + t("assignAdmin.unAssigned"), + )} +
+
+
+
{t("fields.createdAt.label")}
+
+ {format(contact.createdAt, "yyyy/MM/dd")} +
+
+
+
+
+ ) +} + const parseSortParam = (value: string | null) => { if (!value) { return [] @@ -448,6 +498,7 @@ export function ContactsTable({ return ( } table={table} > {showContactFilterPanel && ( diff --git a/apps/builder/src/features/flows/flows-table.tsx b/apps/builder/src/features/flows/flows-table.tsx index cd82fbfbc..00c4f7f3a 100644 --- a/apps/builder/src/features/flows/flows-table.tsx +++ b/apps/builder/src/features/flows/flows-table.tsx @@ -1,6 +1,7 @@ "use client" import { DataTable } from "@chatbotx.io/ui/components/data-table/data-table" +import { DataTableRowCard } from "@chatbotx.io/ui/components/data-table/data-table-row-card" import { DataTableToolbar } from "@chatbotx.io/ui/components/data-table/data-table-toolbar" import { buttonVariants } from "@chatbotx.io/ui/components/ui/button" import { @@ -70,7 +71,10 @@ export function FlowsTable({ {t("flows.title")} - + } + table={table} + > Date: Tue, 18 Aug 2026 15:35:15 -0300 Subject: [PATCH 15/23] fix(analytics): collapse dashboard grid and nav to a single column on mobile --- .../features/analytics/components/analytics-nav.tsx | 13 +++++++++---- .../src/components/contacts-dashboard.tsx | 4 ++-- .../src/components/conversations-dashboard.tsx | 4 ++-- .../analytics-nextjs/src/components/filter-form.tsx | 2 +- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/builder/src/features/analytics/components/analytics-nav.tsx b/apps/builder/src/features/analytics/components/analytics-nav.tsx index da176cf7f..baa3da96e 100644 --- a/apps/builder/src/features/analytics/components/analytics-nav.tsx +++ b/apps/builder/src/features/analytics/components/analytics-nav.tsx @@ -29,16 +29,21 @@ export function AnalyticsNav({ showAds }: { showAds: boolean }) { ] return ( -