From 14d65a2abd8120634bebd411dd5b856417a61346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Fri, 17 Jul 2026 13:05:43 +0100 Subject: [PATCH] feat(web): add quick chat entry points on mobile (#1751) * feat(web): add quick chat entry points on mobile Quick chat was unreachable on touch devices: every trigger was desktop-only (keyboard shortcut, command palette, app sidebar) and the full-screen dialog had no touch dismissal. - add quick chat button to the mobile kanban/tasks header - add quick chat button to the tablet header (640-767px has no sidebar) - add chat button to the mobile session task-switcher sheet - add a mobile-only close control to the quick chat tab bar - cover the touch entry points with a mobile Playwright spec Co-Authored-By: Claude Fable 5 * test(web): address review feedback on mobile quick chat spec - reuse the shared assertNoDocumentHorizontalOverflow layout helper in both quick chat mobile specs instead of local duplicates - assert the task switcher sheet is dismissed after opening quick chat Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Kandev Agent Co-authored-by: Claude Fable 5 --- .../kanban/kanban-header-mobile.test.tsx | 26 +++++- .../kanban/kanban-header-mobile.tsx | 16 +++- apps/web/components/kanban/kanban-header.tsx | 25 +++++- .../quick-chat/quick-chat-modal.tsx | 17 +++- .../mobile/session-task-switcher-sheet.tsx | 88 ++++++++++++++----- .../chat/mobile-quick-chat-entry.spec.ts | 58 ++++++++++++ .../chat/mobile-quick-chat-repository.spec.ts | 15 +--- 7 files changed, 207 insertions(+), 38 deletions(-) create mode 100644 apps/web/e2e/tests/chat/mobile-quick-chat-entry.spec.ts diff --git a/apps/web/components/kanban/kanban-header-mobile.test.tsx b/apps/web/components/kanban/kanban-header-mobile.test.tsx index 8ebdc26d64..fd478c872e 100644 --- a/apps/web/components/kanban/kanban-header-mobile.test.tsx +++ b/apps/web/components/kanban/kanban-header-mobile.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { StateProvider } from "@/components/state-provider"; @@ -30,17 +30,26 @@ vi.mock("./mobile-menu-sheet", () => ({ MobileMenuSheet: () => null, })); +const quickChatMocks = vi.hoisted(() => ({ openQuickChat: vi.fn() })); + +vi.mock("@/hooks/use-quick-chat-launcher", () => ({ + useQuickChatLauncher: () => quickChatMocks.openQuickChat, +})); + const LEFT_ACTIONS_TEST_ID = "topbar-left-actions"; +const QUICK_CHAT_TEST_ID = "mobile-quick-chat-button"; afterEach(() => { cleanup(); + quickChatMocks.openQuickChat.mockClear(); }); -function renderHeader(title: string) { +function renderHeader(title: string, workspaceId?: string) { return render( undefined} @@ -66,4 +75,17 @@ describe("KanbanHeaderMobile", () => { expect(leftActions.textContent).toContain("/root/kandev"); expect(leftActions.firstElementChild?.className).toContain("max-w-[38vw]"); }); + + it("opens quick chat from the header action when a workspace is active", () => { + renderHeader("Home", "workspace-1"); + + fireEvent.click(screen.getByTestId(QUICK_CHAT_TEST_ID)); + expect(quickChatMocks.openQuickChat).toHaveBeenCalledTimes(1); + }); + + it("hides the quick chat action without an active workspace", () => { + renderHeader("Home"); + + expect(screen.queryByTestId(QUICK_CHAT_TEST_ID)).toBeNull(); + }); }); diff --git a/apps/web/components/kanban/kanban-header-mobile.tsx b/apps/web/components/kanban/kanban-header-mobile.tsx index 56223fcb92..e6cafa4ace 100644 --- a/apps/web/components/kanban/kanban-header-mobile.tsx +++ b/apps/web/components/kanban/kanban-header-mobile.tsx @@ -1,11 +1,12 @@ "use client"; import { Button } from "@kandev/ui/button"; -import { IconMenu2, IconSearch } from "@tabler/icons-react"; +import { IconMenu2, IconMessageCircle, IconSearch } from "@tabler/icons-react"; import { PageTopbar } from "@/components/page-topbar"; import { TopbarMetrics } from "@/components/system-metrics/topbar-metrics"; import { MobileMenuSheet } from "./mobile-menu-sheet"; import { useAppStore } from "@/components/state-provider"; +import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; type KanbanHeaderMobileProps = { workspaceId?: string; @@ -36,6 +37,7 @@ export function KanbanHeaderMobile({ const setMenuOpen = useAppStore((state) => state.setMobileKanbanMenuOpen); const isSearchOpen = useAppStore((state) => state.mobileKanban.isSearchOpen); const setSearchOpen = useAppStore((state) => state.setMobileKanbanSearchOpen); + const handleOpenQuickChat = useQuickChatLauncher(workspaceId); const isHome = title === "Home"; const toggleSearch = () => { @@ -69,6 +71,18 @@ export function KanbanHeaderMobile({ actions={ <> + {workspaceId && ( + + )} {onSearchChange && ( + )} @@ -330,6 +352,7 @@ export function KanbanHeader({ ; activeSessionId: string; @@ -32,6 +33,7 @@ function QuickChatTabs({ onTabClose: (sessionId: string) => void; onNewChat: () => void; onRename: (sessionId: string, name: string) => void; + onCloseModal: () => void; }) { if (sessions.length === 0) return null; @@ -63,6 +65,18 @@ function QuickChatTabs({ + {/* Touch devices have no Escape key or visible overlay to dismiss the + full-screen dialog, so give them an explicit close control. */} + ); } @@ -160,6 +174,7 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic onTabClose={handleCloseTab} onNewChat={handleNewChat} onRename={handleRename} + onCloseModal={() => handleOpenChange(false)} /> {activeSessionId && !activeSessionNeedsAgent && ( diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index 9f21f46b93..f735c1f3ea 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useMemo, useState, memo } from "react"; -import { IconPlus } from "@tabler/icons-react"; +import { IconMessageCircle, IconPlus } from "@tabler/icons-react"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet"; import { Button } from "@kandev/ui/button"; import { TaskSwitcher } from "../task-switcher"; @@ -22,6 +22,7 @@ import { SidebarLinkDialogs } from "../task-session-sidebar-dialogs"; import { useSidebarLinkActions } from "../task-session-sidebar-link-actions"; import { useSidebarTaskLinking } from "../task-session-sidebar-task-linking"; import { useSheetData, useSheetActions } from "./session-task-switcher-sheet-hooks"; +import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; type SessionTaskSwitcherSheetProps = { open: boolean; @@ -131,6 +132,58 @@ export function MobileTaskList({ ); } +function TaskSwitcherSheetHeader({ + workspaceId, + workspaces, + onWorkspaceChange, + onQuickChat, + onNewTask, +}: { + workspaceId: string | null; + workspaces: Array<{ id: string; name: string }>; + onWorkspaceChange: (workspaceId: string) => void; + onQuickChat: () => void; + onNewTask: () => void; +}) { + return ( + +
+ Tasks +
+ {workspaceId && ( + + )} + +
+
+
+ +
+
+ ); +} + export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ open, onOpenChange, @@ -141,6 +194,11 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ const data = useSheetData(workspaceId); const actions = useSheetActions(workspaceId, onOpenChange); const linking = useMobileTaskLinking(workspaceId); + const openQuickChat = useQuickChatLauncher(workspaceId); + const handleQuickChat = useCallback(() => { + onOpenChange(false); + openQuickChat(); + }, [onOpenChange, openQuickChat]); return ( @@ -149,27 +207,13 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ side="left" className="w-[85vw] max-w-sm p-0 flex flex-col" > - -
- Tasks - -
-
- ({ id: w.id, name: w.name }))} - activeWorkspaceId={workspaceId} - onSelect={actions.handleWorkspaceChange} - /> -
-
+ ({ id: w.id, name: w.name }))} + onWorkspaceChange={actions.handleWorkspaceChange} + onQuickChat={handleQuickChat} + onNewTask={() => setDialogOpen(true)} + /> diff --git a/apps/web/e2e/tests/chat/mobile-quick-chat-entry.spec.ts b/apps/web/e2e/tests/chat/mobile-quick-chat-entry.spec.ts new file mode 100644 index 0000000000..88cbc69ca7 --- /dev/null +++ b/apps/web/e2e/tests/chat/mobile-quick-chat-entry.spec.ts @@ -0,0 +1,58 @@ +/** + * Mobile entry points for Quick Chat. + * + * Desktop opens Quick Chat via keyboard shortcut, command palette, or the app + * sidebar — none of which exist on a touch viewport. These tests cover the + * touch affordances: the kanban header button on Home, the task-switcher sheet + * button on a session page, and the explicit close control (touch devices have + * no Escape key and the full-screen dialog leaves no overlay to tap). + * + * Lives in `mobile-*.spec.ts` so the `mobile-chrome` Playwright project applies + * the mobile device automatically. + */ +import { test, expect } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; + +test.describe("Quick Chat entry points on mobile", () => { + test("opens from the home header and closes with the touch control", async ({ testPage }) => { + await testPage.goto("/"); + await testPage.waitForLoadState("networkidle"); + await assertNoDocumentHorizontalOverflow(testPage); + + await testPage.getByTestId("mobile-quick-chat-button").click(); + + const dialog = testPage.getByRole("dialog", { name: "Quick Chat" }); + await expect(dialog.getByTestId("quick-chat-setup")).toBeVisible({ timeout: 10_000 }); + await assertNoDocumentHorizontalOverflow(testPage); + + await dialog.getByTestId("quick-chat-close").click(); + await expect(dialog).not.toBeVisible(); + }); + + test("opens from the task switcher sheet on a session page", async ({ + testPage, + apiClient, + seedData, + }) => { + const seeded = await apiClient.seedTask(seedData.workspaceId, "Mobile Quick Chat Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + await testPage.goto(`/t/${seeded.task_id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + await testPage.getByTestId("mobile-session-menu").click(); + const sheet = testPage.getByRole("dialog", { name: "Tasks" }); + await expect(sheet).toBeVisible(); + await testPage.getByTestId("mobile-sheet-quick-chat").click(); + + const dialog = testPage.getByRole("dialog", { name: "Quick Chat" }); + await expect(dialog.getByTestId("quick-chat-setup")).toBeVisible({ timeout: 10_000 }); + // Opening quick chat dismisses the task switcher sheet. + await expect(sheet).not.toBeVisible(); + await assertNoDocumentHorizontalOverflow(testPage); + }); +}); diff --git a/apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts b/apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts index cc2b062e17..ea356663c2 100644 --- a/apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-quick-chat-repository.spec.ts @@ -1,12 +1,5 @@ import { test, expect } from "../../fixtures/test-base"; - -async function assertNoHorizontalOverflow(page: import("@playwright/test").Page) { - const dimensions = await page.evaluate(() => ({ - scrollWidth: document.documentElement.scrollWidth, - clientWidth: document.documentElement.clientWidth, - })); - expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth + 1); -} +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; test.describe("Quick Chat repository context on mobile", () => { test("selects an agent and repository branch without hidden actions", async ({ testPage }) => { @@ -35,11 +28,11 @@ test.describe("Quick Chat repository context on mobile", () => { await expect(dialog.getByTestId("branch-chip-trigger")).toContainText("main", { timeout: 10_000, }); - await assertNoHorizontalOverflow(testPage); + await assertNoDocumentHorizontalOverflow(testPage); await dialog.getByTestId("quick-chat-start").click(); await expect(dialog.locator(".tiptap.ProseMirror")).toBeVisible({ timeout: 30_000 }); - await assertNoHorizontalOverflow(testPage); + await assertNoDocumentHorizontalOverflow(testPage); await dialog.getByLabel("Start new chat").click(); await expect(dialog.getByTestId("quick-chat-setup")).toBeVisible({ timeout: 5_000 }); @@ -64,6 +57,6 @@ test.describe("Quick Chat repository context on mobile", () => { await expect(restoredTabs).toHaveCount(2); await expect(restoredTabs.locator("span")).toHaveText(originalNames); await expect(restoredDialog.getByTestId("quick-chat-setup")).not.toBeVisible(); - await assertNoHorizontalOverflow(testPage); + await assertNoDocumentHorizontalOverflow(testPage); }); });