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:
@@ -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"}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user