Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
349b38a
feat(builder): declare explicit viewport with safe-area support
eduardocodes Aug 18, 2026
dd7a7be
feat(vitest-config): add a live matchMedia polyfill for jsdom suites
eduardocodes Aug 18, 2026
9eda944
test(ui): cover the useIsMobile breakpoint boundary
eduardocodes Aug 18, 2026
cffbbd8
feat(ui): add a hamburger sidebar trigger for the mobile sheet
eduardocodes Aug 18, 2026
a762ab4
feat(builder): add mobile header with sidebar trigger to workspace shell
eduardocodes Aug 18, 2026
089b078
fix(builder): close mobile sidebar sheet when a nav link is tapped
eduardocodes Aug 18, 2026
295d2fd
fix(builder): make app tab strip horizontally scrollable on narrow vi…
eduardocodes Aug 18, 2026
8dc1b7a
refactor(builder): replace inbox negative-margin hack with FullBleed …
eduardocodes Aug 18, 2026
62944ef
feat(builder): add mobile header to the manage console shell
eduardocodes Aug 18, 2026
48f4900
feat(ui): scroll DataTable by default and add an opt-in mobile card view
eduardocodes Aug 18, 2026
913d95d
feat(ui): add a generic mobile row card for data tables
eduardocodes Aug 18, 2026
8b450be
fix(ui): stack data table toolbar filters on narrow viewports
eduardocodes Aug 18, 2026
0d6615e
fix(ui): keep a viewport gutter on dialogs that override max-width
eduardocodes Aug 18, 2026
4a70107
feat(builder): render contacts, flows and broadcasts tables as cards …
eduardocodes Aug 18, 2026
76fb1f1
fix(analytics): collapse dashboard grid and nav to a single column on…
eduardocodes Aug 18, 2026
0340df1
fix(builder): stack setting rows into a single column on mobile
eduardocodes Aug 18, 2026
39b4d3d
feat(ui): expose useIsMobileState for layouts that must not guess
eduardocodes Aug 18, 2026
7fa908a
feat(vitest-config): stub ResizeObserver for jsdom suites
eduardocodes Aug 18, 2026
bb2db99
feat(builder): add mobile back and contact controls to the message he…
eduardocodes Aug 18, 2026
63e30a7
refactor(builder): extract inbox panes from the chat layout
eduardocodes Aug 18, 2026
d080daa
feat(builder): render the inbox as a single-pane master detail view o…
eduardocodes Aug 18, 2026
19ddd9c
fix(builder): size inbox media, composer and popovers to the viewport
eduardocodes Aug 18, 2026
f616c2f
fix(analytics): stop the admins card spanning a phantom second column…
eduardocodes Aug 18, 2026
df88790
fix(builder): trade the mobile shell header for a sidebar edge handle
eduardocodes Aug 20, 2026
dffed6e
refactor(builder): stop the inbox tracking the removed shell header h…
eduardocodes Aug 20, 2026
5b391ca
fix(builder): keep the mobile inbox on the list after back and on lan…
realcodesiman Sep 3, 2026
92cdbbb
refactor(builder,ui): drop dead scrollable prop, header comments and …
realcodesiman Sep 3, 2026
be30985
fix(vitest-config): stub Element.getAnimations for jsdom suites
eduardocodes Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions apps/builder/__tests__/app-tab.test.tsx
Original file line number Diff line number Diff line change
@@ -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
}) => (
<a href={href} {...rest}>
{children}
</a>
),
}))

const { AppTab } = await import("@/components/app-tab")

type Tab = ComponentProps<typeof AppTab>["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(<AppTab tabs={tabs} />)
})
}

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")
})
})
206 changes: 206 additions & 0 deletions apps/builder/__tests__/chat-layout-mobile.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
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: () => <div data-testid="realtime" />,
}))

const mockRouterReplace = vi.fn()

vi.mock("next/navigation", () => ({
usePathname: () => "/space/w1/inbox",
useRouter: () => ({ replace: mockRouterReplace }),
useSearchParams: () => new URLSearchParams("conversationId=c1"),
}))

vi.mock("@/features/chat/chat-panes", () => ({
ConversationListPane: ({
autoSelectFirstConversation,
}: {
autoSelectFirstConversation?: boolean
}) => (
<div
data-auto-select={String(autoSelectFirstConversation)}
data-testid="list-pane"
/>
),
MessageThreadPane: ({
onBack,
onOpenContact,
}: {
onBack?: () => void
onOpenContact?: () => void
}) => (
<div data-testid="thread-pane">
{onBack && (
<button data-testid="back" onClick={onBack} type="button">
back
</button>
)}
{onOpenContact && (
<button
data-testid="open-contact"
onClick={onOpenContact}
type="button"
>
contact
</button>
)}
</div>
),
ContactDetailPane: () => <div data-testid="contact-pane" />,
}))

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(<ChatLayout workspaceId="w1" />)
})
}

const find = (id: string) =>
container.querySelector<HTMLElement>(`[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)
mockRouterReplace.mockClear()
})

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("fills the viewport, with the pane owning the whole screen", () => {
setViewportWidth(375)
render()

// The mobile shell has no top bar to subtract height for.
const pane = find("list-pane")?.closest("div.flex")
expect(pane?.className).toContain("h-[100dvh]")
})

test("does not auto-select a conversation on mobile", () => {
setViewportWidth(375)
render()

expect(find("list-pane")?.getAttribute("data-auto-select")).toBe("false")
})

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("back also clears the conversationId URL param, so a remount cannot resurrect it", () => {
storeState.activeConversationId = "c1"
setViewportWidth(375)
render()

act(() => {
find("back")?.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true }),
)
})

expect(mockRouterReplace).toHaveBeenCalledWith("/space/w1/inbox")
})

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