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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-07-17 13:05:43 +01:00
committed by GitHub
parent e145d48f22
commit 14d65a2abd
7 changed files with 207 additions and 38 deletions
@@ -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(
<StateProvider>
<KanbanHeaderMobile
title={title}
workspaceId={workspaceId}
workspaceLabel="/root/kandev"
showHealthIndicator={false}
onOpenHealthDialog={() => 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();
});
});
@@ -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={
<>
<TopbarMetrics size="lg" />
{workspaceId && (
<Button
variant="outline"
size="icon-lg"
onClick={handleOpenQuickChat}
className="cursor-pointer"
aria-label="Quick Chat"
data-testid="mobile-quick-chat-button"
>
<IconMessageCircle className="h-4 w-4" />
</Button>
)}
{onSearchChange && (
<Button
variant={isSearchOpen ? "secondary" : "outline"}
+24 -1
View File
@@ -5,7 +5,13 @@ import { useRouter } from "@/lib/routing/client-router";
import { Button } from "@kandev/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import { IconList, IconLayoutKanban, IconMenu2, IconTimeline } from "@tabler/icons-react";
import {
IconList,
IconLayoutKanban,
IconMenu2,
IconMessageCircle,
IconTimeline,
} from "@tabler/icons-react";
import { PageTopbar } from "@/components/page-topbar";
import { KanbanDisplayDropdown } from "../kanban-display-dropdown";
import { ReleaseNotesDialog } from "../release-notes/release-notes-dialog";
@@ -19,6 +25,7 @@ import { useAppStore } from "@/components/state-provider";
import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings";
import { useReleaseNotes } from "@/hooks/use-release-notes";
import { useSystemHealthIndicator } from "@/hooks/use-system-health-indicator";
import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher";
import { TopbarMetrics } from "@/components/system-metrics/topbar-metrics";
import type { ComponentProps, RefObject } from "react";
@@ -130,6 +137,7 @@ function useIsHeaderNarrow(ref: RefObject<HTMLElement | null>): boolean {
function TabletHeader({
title,
workspaceLabel,
workspaceId,
searchQuery,
onSearchChange,
isSearchLoading,
@@ -142,6 +150,7 @@ function TabletHeader({
}: {
title: string;
workspaceLabel: string;
workspaceId?: string;
searchQuery: string;
onSearchChange?: (query: string) => void;
isSearchLoading: boolean;
@@ -153,6 +162,7 @@ function TabletHeader({
hideTitle?: boolean;
}) {
const isHome = title === "Home";
const handleOpenQuickChat = useQuickChatLauncher(workspaceId);
return (
<PageTopbar
@@ -174,6 +184,18 @@ function TabletHeader({
/>
)}
<TopbarMetrics size="lg" />
{workspaceId && (
<Button
variant="outline"
size="icon-lg"
onClick={handleOpenQuickChat}
className="cursor-pointer"
aria-label="Quick Chat"
data-testid="tablet-quick-chat-button"
>
<IconMessageCircle className="h-4 w-4" />
</Button>
)}
<TooltipProvider>
<ViewToggleGroup toggleValue={toggleValue} onValueChange={handleViewChange} size="lg" />
</TooltipProvider>
@@ -330,6 +352,7 @@ export function KanbanHeader({
<TabletHeader
title={title}
workspaceLabel={workspaceLabel}
workspaceId={workspaceId}
hideTitle={hideTitle}
{...sharedSearch}
toggleValue={toggleValue}
@@ -4,7 +4,7 @@ import { memo, type CSSProperties } from "react";
import { useShallow } from "zustand/react/shallow";
import { Dialog, DialogContent, DialogTitle } from "@kandev/ui/dialog";
import { Button } from "@kandev/ui/button";
import { IconPlus } from "@tabler/icons-react";
import { IconPlus, IconX } from "@tabler/icons-react";
import { useAppStore } from "@/components/state-provider";
import { PassthroughTerminal } from "@/components/task/passthrough-terminal";
import { QuickChatContent } from "./quick-chat-content";
@@ -25,6 +25,7 @@ function QuickChatTabs({
onTabClose,
onNewChat,
onRename,
onCloseModal,
}: {
sessions: Array<{ sessionId: string; workspaceId: string; name?: string }>;
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({
<IconPlus className="h-3.5 w-3.5" />
</Button>
</div>
{/* Touch devices have no Escape key or visible overlay to dismiss the
full-screen dialog, so give them an explicit close control. */}
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 cursor-pointer shrink-0 sm:hidden"
onClick={onCloseModal}
aria-label="Close quick chat"
data-testid="quick-chat-close"
>
<IconX className="h-3.5 w-3.5" />
</Button>
</div>
);
}
@@ -160,6 +174,7 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic
onTabClose={handleCloseTab}
onNewChat={handleNewChat}
onRename={handleRename}
onCloseModal={() => handleOpenChange(false)}
/>
{activeSessionId && !activeSessionNeedsAgent && (
<QuickChatSessionView sessionId={activeSessionId} />
@@ -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 (
<SheetHeader className="p-4 pb-2 border-b border-border">
<div className="flex items-center justify-between">
<SheetTitle className="text-base">Tasks</SheetTitle>
<div className="flex items-center gap-2">
{workspaceId && (
<Button
size="sm"
variant="outline"
className="h-7 gap-1 cursor-pointer"
onClick={onQuickChat}
data-testid="mobile-sheet-quick-chat"
>
<IconMessageCircle className="h-4 w-4" />
Chat
</Button>
)}
<Button
size="sm"
variant="outline"
className="h-7 gap-1 cursor-pointer"
onClick={onNewTask}
>
<IconPlus className="h-4 w-4" />
New
</Button>
</div>
</div>
<div className="pt-2">
<WorkspaceSwitcher
workspaces={workspaces}
activeWorkspaceId={workspaceId}
onSelect={onWorkspaceChange}
/>
</div>
</SheetHeader>
);
}
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
@@ -149,27 +207,13 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
side="left"
className="w-[85vw] max-w-sm p-0 flex flex-col"
>
<SheetHeader className="p-4 pb-2 border-b border-border">
<div className="flex items-center justify-between">
<SheetTitle className="text-base">Tasks</SheetTitle>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 cursor-pointer"
onClick={() => setDialogOpen(true)}
>
<IconPlus className="h-4 w-4" />
New
</Button>
</div>
<div className="pt-2">
<WorkspaceSwitcher
workspaces={data.workspaces.map((w) => ({ id: w.id, name: w.name }))}
activeWorkspaceId={workspaceId}
onSelect={actions.handleWorkspaceChange}
/>
</div>
</SheetHeader>
<TaskSwitcherSheetHeader
workspaceId={workspaceId}
workspaces={data.workspaces.map((w) => ({ id: w.id, name: w.name }))}
onWorkspaceChange={actions.handleWorkspaceChange}
onQuickChat={handleQuickChat}
onNewTask={() => setDialogOpen(true)}
/>
<SidebarFilterBar />
@@ -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);
});
});
@@ -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);
});
});