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/__tests__/chat-layout-mobile.test.tsx b/apps/builder/__tests__/chat-layout-mobile.test.tsx new file mode 100644 index 000000000..d379a4802 --- /dev/null +++ b/apps/builder/__tests__/chat-layout-mobile.test.tsx @@ -0,0 +1,158 @@ +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, vi } from "vitest" + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})) + +vi.mock("@/features/chat/chat-realtime", () => ({ + ChatRealtime: () =>
, +})) + +vi.mock("@/features/chat/chat-panes", () => ({ + ConversationListPane: () =>
, + MessageThreadPane: ({ + onBack, + onOpenContact, + }: { + onBack?: () => void + onOpenContact?: () => void + }) => ( +
+ {onBack && ( + + )} + {onOpenContact && ( + + )} +
+ ), + ContactDetailPane: () =>
, +})) + +const storeState = { + conversations: [] as unknown[], + isFirstLoadConversation: false, + isLoadingConversation: false, + isBootstrappingUrlConversation: false, + activeConversationId: null as string | null, + setActiveConversationId: vi.fn((id: string | null) => { + storeState.activeConversationId = id + }), +} + +vi.mock("@/features/chat/store/chat-store-provider", () => ({ + useChatStore: (selector: (state: typeof storeState) => unknown) => + selector(storeState), +})) + +const { ChatLayout } = await import("@/features/chat/chat-layout") + +describe("ChatLayout", () => { + let container: HTMLDivElement + let root: Root + + const render = () => { + act(() => { + root.render() + }) + } + + const find = (id: string) => + container.querySelector(`[data-testid="${id}"]`) + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + storeState.activeConversationId = null + storeState.setActiveConversationId.mockClear() + container = document.createElement("div") + document.body.append(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + setViewportWidth(1024) + }) + + test("shows only the conversation list on mobile with nothing selected", () => { + setViewportWidth(375) + render() + + expect(find("list-pane")).not.toBeNull() + expect(find("thread-pane")).toBeNull() + // The three-column group must not mount on a phone. + expect(container.querySelector("[data-panel-group]")).toBeNull() + }) + + test("shows the thread with a back control once a conversation is active", () => { + storeState.activeConversationId = "c1" + setViewportWidth(375) + render() + + expect(find("thread-pane")).not.toBeNull() + expect(find("list-pane")).toBeNull() + expect(find("back")).not.toBeNull() + }) + + test("back clears the active conversation, returning to the list", () => { + storeState.activeConversationId = "c1" + setViewportWidth(375) + render() + + act(() => { + find("back")?.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ) + }) + + expect(storeState.setActiveConversationId).toHaveBeenCalledWith(null) + }) + + test("offers the contact panel behind a control instead of a third column", () => { + storeState.activeConversationId = "c1" + setViewportWidth(375) + render() + + expect(find("open-contact")).not.toBeNull() + // The sheet is closed until asked for, so the panel is not mounted yet. + expect(find("contact-pane")).toBeNull() + }) + + test("renders all three panes side by side from md up", () => { + storeState.activeConversationId = "c1" + setViewportWidth(1440) + render() + + expect(find("list-pane")).not.toBeNull() + expect(find("thread-pane")).not.toBeNull() + expect(find("contact-pane")).not.toBeNull() + // No mobile-only affordances leak into the desktop layout. + expect(find("back")).toBeNull() + expect(find("open-contact")).toBeNull() + }) + + test("keeps the realtime socket mounted in every layout", () => { + setViewportWidth(375) + render() + expect(find("realtime")).not.toBeNull() + + act(() => { + setViewportWidth(1440) + }) + expect(find("realtime")).not.toBeNull() + }) +}) 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/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() 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/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} + +
+
- + ) 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 ? ( 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}
+} 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} /> diff --git a/apps/builder/src/components/setting-row.tsx b/apps/builder/src/components/setting-row.tsx index 98c3f9872..7fed14a54 100644 --- a/apps/builder/src/components/setting-row.tsx +++ b/apps/builder/src/components/setting-row.tsx @@ -11,13 +11,16 @@ type SettingRowProps = { export const SettingRow = (props: SettingRowProps) => { const { label, description, children } = props return ( -
+ // A four-column split is unreadable on a phone: label, control and + // description each end up a sliver wide. Stack them instead, and restore + // the desktop split from `md` up. +
{children}
{description && ( -

+

{description}

)} 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 ( -