From 7728ddb3bc5b6c7a815e1dd8dd3843e601f00fe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 27 Jun 2026 16:11:39 +0100 Subject: [PATCH] fix: improve mobile chat input controls (#1477) --- .../components/github/pr-status-chip.test.tsx | 76 ++- apps/web/components/github/pr-status-chip.tsx | 10 +- .../components/github/pr-topbar-button.tsx | 18 +- .../kanban/kanban-header-mobile.test.tsx | 69 +++ .../kanban/kanban-header-mobile.tsx | 20 +- .../components/model-config-selector.test.tsx | 25 + apps/web/components/model-config-selector.tsx | 6 +- .../settings/system-metrics-settings-card.tsx | 4 +- .../system-metrics/topbar-metrics.test.tsx | 144 +++++ .../system-metrics/topbar-metrics.tsx | 46 +- .../task/chat/chat-input-area.test.tsx | 8 +- .../components/task/chat/chat-input-area.tsx | 2 +- .../task/chat/chat-input-body.test.tsx | 100 ++++ .../components/task/chat/chat-input-body.tsx | 11 +- .../task/chat/chat-input-toolbar-desktop.tsx | 263 +++++++++ .../task/chat/chat-input-toolbar-mobile.tsx | 188 +++++++ .../chat/chat-input-toolbar-primitives.tsx | 244 +++++++++ .../task/chat/chat-input-toolbar.test.tsx | 158 +++++- .../task/chat/chat-input-toolbar.tsx | 517 ++---------------- .../components/task/chat/todo-indicator.tsx | 134 +++-- .../chat/use-chat-input-container.test.ts | 34 +- .../task/chat/use-chat-input-container.ts | 23 +- .../task/chat/use-tiptap-editor.test.ts | 10 +- .../components/task/chat/use-tiptap-editor.ts | 3 +- .../session-task-switcher-sheet.test.tsx | 92 ++++ .../mobile/session-task-switcher-sheet.tsx | 2 +- apps/web/components/task/mode-selector.tsx | 14 +- apps/web/components/task/model-selector.tsx | 7 +- .../task/selector-trigger-class.test.tsx | 89 +++ .../task/task-switcher-subtask-dnd.test.ts | 24 + .../task/task-switcher-subtask-dnd.tsx | 28 +- apps/web/e2e/pages/session-page.ts | 2 +- .../tests/chat/mobile-model-selector.spec.ts | 52 ++ .../mobile-passthrough-composer.spec.ts | 5 +- .../e2e/tests/pr/mobile-pr-ci-chip.spec.ts | 43 +- .../web/hooks/use-compact-task-chrome.test.ts | 53 ++ apps/web/hooks/use-compact-task-chrome.ts | 20 + 37 files changed, 1969 insertions(+), 575 deletions(-) create mode 100644 apps/web/components/kanban/kanban-header-mobile.test.tsx create mode 100644 apps/web/components/model-config-selector.test.tsx create mode 100644 apps/web/components/system-metrics/topbar-metrics.test.tsx create mode 100644 apps/web/components/task/chat/chat-input-body.test.tsx create mode 100644 apps/web/components/task/chat/chat-input-toolbar-desktop.tsx create mode 100644 apps/web/components/task/chat/chat-input-toolbar-mobile.tsx create mode 100644 apps/web/components/task/chat/chat-input-toolbar-primitives.tsx create mode 100644 apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx create mode 100644 apps/web/components/task/selector-trigger-class.test.tsx create mode 100644 apps/web/components/task/task-switcher-subtask-dnd.test.ts create mode 100644 apps/web/e2e/tests/chat/mobile-model-selector.spec.ts create mode 100644 apps/web/hooks/use-compact-task-chrome.test.ts create mode 100644 apps/web/hooks/use-compact-task-chrome.ts diff --git a/apps/web/components/github/pr-status-chip.test.tsx b/apps/web/components/github/pr-status-chip.test.tsx index 17072858a4..51545ce08d 100644 --- a/apps/web/components/github/pr-status-chip.test.tsx +++ b/apps/web/components/github/pr-status-chip.test.tsx @@ -9,9 +9,23 @@ import { PR_CI_DESKTOP_POPOVER_SCROLL_CLASS } from "./pr-ci-popover"; import type { AppState } from "@/lib/state/store"; import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github"; -const isMobileMock = vi.fn(() => false); -vi.mock("@/hooks/use-mobile", () => ({ - useIsMobile: () => isMobileMock(), +const responsiveMock = vi.hoisted(() => ({ + breakpoint: "desktop" as "mobile" | "tablet" | "compactDesktop" | "desktop", + isFinePointer: true, +})); +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ + breakpoint: responsiveMock.breakpoint, + isMobile: responsiveMock.breakpoint === "mobile", + isTablet: responsiveMock.breakpoint === "tablet", + isDesktop: + responsiveMock.breakpoint === "compactDesktop" || responsiveMock.breakpoint === "desktop", + isCompactDesktop: responsiveMock.breakpoint === "compactDesktop", + isFullDesktop: responsiveMock.breakpoint === "desktop", + isFinePointer: responsiveMock.isFinePointer, + usesDesktopWorkbench: + responsiveMock.breakpoint === "compactDesktop" || responsiveMock.breakpoint === "desktop", + }), })); vi.mock("@/lib/api/domains/github-api", async (importOriginal) => { @@ -95,12 +109,12 @@ function makeCIOptions(overrides: Partial = {}): TaskCI } beforeEach(() => { - isMobileMock.mockReturnValue(false); + responsiveMock.breakpoint = "desktop"; + responsiveMock.isFinePointer = true; }); afterEach(() => { cleanup(); - isMobileMock.mockReset(); }); const CHIP_TESTID = "pr-status-chip"; @@ -156,7 +170,10 @@ describe("PRStatusChip", () => { }); describe("PRStatusChip desktop branch", () => { - beforeEach(() => isMobileMock.mockReturnValue(false)); + beforeEach(() => { + responsiveMock.breakpoint = "desktop"; + responsiveMock.isFinePointer = true; + }); it("renders the chip button without a Drawer", () => { renderWithStore(seededState, ); @@ -170,6 +187,18 @@ describe("PRStatusChip desktop branch", () => { expect(document.querySelector(DRAWER_SELECTOR)).toBeNull(); }); + it("keeps the hovercard path on fine-pointer tablets", () => { + responsiveMock.breakpoint = "tablet"; + responsiveMock.isFinePointer = true; + + renderWithStore(seededState, ); + const chip = screen.getByTestId(CHIP_TESTID); + act(() => { + fireEvent.click(chip); + }); + expect(document.querySelector(DRAWER_SELECTOR)).toBeNull(); + }); + it("constrains the hover popover to the available viewport height", async () => { renderWithStore(seededState, ); await expectDesktopHoverPopoverConstrained(); @@ -208,7 +237,10 @@ describe("PRStatusChip desktop branch", () => { }); describe("PRStatusChip mobile branch", () => { - beforeEach(() => isMobileMock.mockReturnValue(true)); + beforeEach(() => { + responsiveMock.breakpoint = "mobile"; + responsiveMock.isFinePointer = false; + }); it("renders the chip closed and opens the drawer on click", () => { renderWithStore(seededState, ); @@ -298,6 +330,26 @@ describe("PRStatusChip mobile branch", () => { }); }); +describe("PRStatusChip touch tablet branch", () => { + beforeEach(() => { + responsiveMock.breakpoint = "tablet"; + responsiveMock.isFinePointer = false; + }); + + it("opens the drawer instead of the hovercard on coarse-pointer tablets", () => { + renderWithStore(seededState, ); + expect(document.querySelector(DRAWER_SELECTOR)).toBeNull(); + + act(() => { + fireEvent.click(screen.getByTestId(CHIP_TESTID)); + }); + + expect(document.querySelector(DRAWER_SELECTOR)).not.toBeNull(); + expect(document.querySelector("[data-testid='pr-topbar-popover-inner']")).not.toBeNull(); + expect(screen.getByTestId("pr-popover-title").textContent).toBe("#42 Test PR"); + }); +}); + describe("PRStatusChip — mergeability", () => { it("is 'conflict' (not 'passed') for a dirty PR even with green checks + approval", () => { // Regression: the chip read mergeable_state-blind and showed the green @@ -379,7 +431,10 @@ describe("PRStatusChip — mergeability", () => { }); describe("PRStatusChip CI automation mobile parity", () => { - beforeEach(() => isMobileMock.mockReturnValue(true)); + beforeEach(() => { + responsiveMock.breakpoint = "mobile"; + responsiveMock.isFinePointer = false; + }); it("renders controls and prompt editing inside the drawer", async () => { renderWithStore(seededState, ); @@ -477,7 +532,10 @@ describe("PRStatusChip — multiple PRs", () => { }); describe("mobile drawer", () => { - beforeEach(() => isMobileMock.mockReturnValue(true)); + beforeEach(() => { + responsiveMock.breakpoint = "mobile"; + responsiveMock.isFinePointer = false; + }); it("opens a drawer with the tabbed multi-PR popover", () => { renderWithStore(multiState(TWO_OPEN), ); diff --git a/apps/web/components/github/pr-status-chip.tsx b/apps/web/components/github/pr-status-chip.tsx index dd96c2a277..b75bfd475c 100644 --- a/apps/web/components/github/pr-status-chip.tsx +++ b/apps/web/components/github/pr-status-chip.tsx @@ -34,7 +34,7 @@ import { isPRWaitingOnBranchProtection, pickDefaultPR, } from "@/components/github/pr-task-icon"; -import { useIsMobile } from "@/hooks/use-mobile"; +import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import type { TaskPR } from "@/lib/types/github"; const HOVER_OPEN_DELAY_MS = 150; @@ -248,8 +248,8 @@ function AutomationFlagBadges({ automation }: { automation: AutomationFlags }) { } function PRStatusChipInner({ pr, automation }: { pr: TaskPR; automation: AutomationFlags }) { - const isMobile = useIsMobile(); - if (isMobile) return ; + const usesMobileDrawer = useTouchDrawer(); + if (usesMobileDrawer) return ; return ; } @@ -305,8 +305,8 @@ function PRStatusChipMultiInner({ prs: TaskPR[]; automation: AutomationFlags; }) { - const isMobile = useIsMobile(); - if (isMobile) return ; + const usesMobileDrawer = useTouchDrawer(); + if (usesMobileDrawer) return ; return ; } diff --git a/apps/web/components/github/pr-topbar-button.tsx b/apps/web/components/github/pr-topbar-button.tsx index 11f37b5101..c54531acd7 100644 --- a/apps/web/components/github/pr-topbar-button.tsx +++ b/apps/web/components/github/pr-topbar-button.tsx @@ -23,7 +23,7 @@ import { import { useDockviewStore } from "@/lib/state/dockview-store"; import { useTaskPR } from "@/hooks/domains/github/use-task-pr"; import { useHoverPopover } from "@/hooks/domains/github/use-hover-popover"; -import { useIsMobile } from "@/hooks/use-mobile"; +import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import { aggregatePRStatusColor, getPRStatusColor, @@ -113,13 +113,13 @@ export const PRTopbarButton = memo(function PRTopbarButton() { * {@link useHoverPopover} hook so the chip and this button stay in sync. */ function usePopoverInteractions() { - const isMobile = useIsMobile(); + const usesTouchDrawer = useTouchDrawer(); const hover = useHoverPopover({ openDelayMs: POPOVER_OPEN_DELAY_MS, closeDelayMs: POPOVER_CLOSE_DELAY_MS, - disabled: isMobile, + disabled: usesTouchDrawer, }); - return { isMobile, ...hover }; + return { usesTouchDrawer, ...hover }; } function PRSingleButton({ pr }: { pr: TaskPR }) { @@ -127,7 +127,7 @@ function PRSingleButton({ pr }: { pr: TaskPR }) { const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); const tooltip = `${pr.owner}/${pr.repo} #${pr.pr_number} — ${pr.pr_title}`; const { - isMobile, + usesTouchDrawer, open, onOpenChange, onTriggerEnter, @@ -169,7 +169,7 @@ function PRSingleButton({ pr }: { pr: TaskPR }) { ); - if (isMobile) { + if (usesTouchDrawer) { return ( {button} @@ -205,7 +205,7 @@ function PRMultiButton({ prs }: { prs: TaskPR[] }) { const addPRPanel = useDockviewStore((s) => s.addPRPanel); const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); const { - isMobile, + usesTouchDrawer, open, onOpenChange, onTriggerEnter, @@ -257,7 +257,7 @@ function PRMultiButton({ prs }: { prs: TaskPR[] }) { > - {isMobile ? triggerButton : {triggerButton}} + {usesTouchDrawer ? triggerButton : {triggerButton}} {prs.length} pull requests linked to this task — open one @@ -265,7 +265,7 @@ function PRMultiButton({ prs }: { prs: TaskPR[] }) { ); - if (isMobile) return dropdown; + if (usesTouchDrawer) return dropdown; return ( diff --git a/apps/web/components/kanban/kanban-header-mobile.test.tsx b/apps/web/components/kanban/kanban-header-mobile.test.tsx new file mode 100644 index 0000000000..8ebdc26d64 --- /dev/null +++ b/apps/web/components/kanban/kanban-header-mobile.test.tsx @@ -0,0 +1,69 @@ +import { cleanup, 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"; +import { KanbanHeaderMobile } from "./kanban-header-mobile"; + +vi.mock("@/components/page-topbar", () => ({ + PageTopbar: ({ + backLabel, + leftActions, + actions, + }: { + backLabel?: string; + leftActions?: ReactNode; + actions?: ReactNode; + }) => ( +
+ {backLabel} +
{leftActions}
+
{actions}
+
+ ), +})); + +vi.mock("@/components/system-metrics/topbar-metrics", () => ({ + TopbarMetrics: () =>
, +})); + +vi.mock("./mobile-menu-sheet", () => ({ + MobileMenuSheet: () => null, +})); + +const LEFT_ACTIONS_TEST_ID = "topbar-left-actions"; + +afterEach(() => { + cleanup(); +}); + +function renderHeader(title: string) { + return render( + + undefined} + /> + , + ); +} + +describe("KanbanHeaderMobile", () => { + it("renders the Home title in compact root chrome", () => { + renderHeader("Home"); + + expect(screen.getByText("Kandev")).toBeTruthy(); + expect(screen.getByTestId(LEFT_ACTIONS_TEST_ID).textContent).toContain("Home"); + expect(screen.getByTestId(LEFT_ACTIONS_TEST_ID).textContent).not.toContain("/root/kandev"); + }); + + it("renders page title and workspace label for non-Home pages", () => { + renderHeader("Tasks"); + + const leftActions = screen.getByTestId(LEFT_ACTIONS_TEST_ID); + expect(leftActions.textContent).toContain("Tasks"); + expect(leftActions.textContent).toContain("/root/kandev"); + expect(leftActions.firstElementChild?.className).toContain("max-w-[38vw]"); + }); +}); diff --git a/apps/web/components/kanban/kanban-header-mobile.tsx b/apps/web/components/kanban/kanban-header-mobile.tsx index 71db494153..dc7c0822de 100644 --- a/apps/web/components/kanban/kanban-header-mobile.tsx +++ b/apps/web/components/kanban/kanban-header-mobile.tsx @@ -3,6 +3,7 @@ import { Button } from "@kandev/ui/button"; import { IconMenu2, 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"; @@ -33,6 +34,7 @@ export function KanbanHeaderMobile({ const setMenuOpen = useAppStore((state) => state.setMobileKanbanMenuOpen); const isSearchOpen = useAppStore((state) => state.mobileKanban.isSearchOpen); const setSearchOpen = useAppStore((state) => state.setMobileKanbanSearchOpen); + const isHome = title === "Home"; const toggleSearch = () => { const next = !isSearchOpen; @@ -43,14 +45,26 @@ export function KanbanHeaderMobile({ return ( <> + {/* Keep mobile root chrome compact so metrics and actions stay visible. */} + {title} + {!isHome && ( + + {workspaceLabel} + + )} + + } + actionsClassName="gap-2" actions={ <> + {onSearchChange && ( + + {props.isExpanded ? "Collapse" : "More actions"} + + ); +} + +function CollapsibleItems(props: { items: ToolbarItemConfig[]; testIdPrefix: string }) { + return props.items.map((i) => ( +
+ {i.render()} +
+ )); +} + +function buildCollapsibleItems(props: DesktopToolbarProps): ToolbarItemConfig[] { + if (props.hideAgentControls) return []; + return [ + { + id: "mcp", + section: "left", + render: () => , + }, + { + id: "mode", + section: "left", + render: () => , + }, + { + id: "reset-context", + section: "right", + visible: !!props.sessionId && !props.isAgentBusy, + render: () => , + }, + { + id: "sessions", + section: "right", + visible: !props.hideSessionsDropdown, + render: () => ( + + ), + }, + { + id: "model", + section: "right", + render: () => , + }, + { + id: "enhance", + section: "right", + visible: !props.isAgentBusy, + render: () => ( + {})} + isLoading={props.isEnhancingPrompt} + isConfigured={props.isUtilityConfigured} + /> + ), + }, + ]; +} + +function DesktopRightSection(props: { + showCollapsed: boolean; + rightItems: ToolbarItemConfig[]; + sessionId: string | null; + planModeEnabled: boolean; + isAgentBusy: boolean; + hasContent: boolean; + onImplementPlan?: (fresh: boolean) => void; + isDisabled: boolean; + submitDisabledReason?: string; + isSending: boolean; + onCancel: () => void | Promise; + onSubmit: () => void; + submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS]; + onVoiceTranscript?: (text: string) => void; + onVoiceAutoSend?: () => void; +}) { + return ( +
+ {!props.showCollapsed && ( + + )} + + {props.planModeEnabled && !props.isAgentBusy && props.onImplementPlan && ( + + )} +
+ {props.onVoiceTranscript && ( + + )} + +
+
+ ); +} + +export function DesktopChatInputToolbar(props: DesktopToolbarProps) { + const toolbarRef = useRef(null); + const isCollapsed = useToolbarCollapsed(toolbarRef); + const [isExpanded, setIsExpanded] = useState(false); + const showCollapsed = isCollapsed && !isExpanded; + const items = buildCollapsibleItems(props); + const leftItems = items.filter((i) => i.section === "left" && i.visible !== false); + const rightItems = items.filter((i) => i.section === "right" && i.visible !== false); + + return ( +
+
+ {!props.hidePlanMode && ( + + )} + {!showCollapsed && } + {props.onAttachFiles && } + {})} + trigger={ + + } + sessionId={props.sessionId} + planContextEnabled={props.planContextEnabled} + contextFiles={props.contextFiles} + onToggleFile={props.onToggleFile ?? (() => {})} + /> + {isCollapsed && ( + setIsExpanded((v) => !v)} /> + )} +
+ +
+ + +
+ ); +} diff --git a/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx b/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx new file mode 100644 index 0000000000..a7ab61f408 --- /dev/null +++ b/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { IconAt } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { ModelSelector } from "@/components/task/model-selector"; +import { ModeSelector } from "@/components/task/mode-selector"; +import { SessionsDropdown } from "@/components/task/sessions-dropdown"; +import { TokenUsageDisplay } from "@/components/task/chat/token-usage-display"; +import { EnhancePromptButton } from "@/components/enhance-prompt-button"; +import { VoiceInputButton } from "./voice-input-button"; +import { ContextPopover } from "./context-popover"; +import { ImplementPlanButton } from "./implement-plan-button"; +import { ResetContextButton } from "./reset-context-button"; +import { + AttachFilesButton, + McpIndicator, + PlanToggleButton, + SubmitButton, +} from "./chat-input-toolbar-primitives"; +import type { ContextFile } from "@/lib/state/context-files-store"; +import type { SHORTCUTS } from "@/lib/keyboard/constants"; + +type MobileToolbarProps = { + planModeEnabled: boolean; + planModeAvailable: boolean; + onPlanModeChange: (enabled: boolean) => void; + hidePlanMode: boolean; + hideAgentControls: boolean; + hideSessionsDropdown: boolean; + mcpServers: string[]; + sessionId: string | null; + taskId: string | null; + taskTitle?: string; + onAttachFiles?: () => void; + contextPopoverOpen: boolean; + onContextPopoverOpenChange: (open: boolean) => void; + contextCount: number; + planContextEnabled: boolean; + contextFiles: ContextFile[]; + onToggleFile: (file: ContextFile) => void; + isAgentBusy: boolean; + hasContent: boolean; + onImplementPlan?: (fresh: boolean) => void; + onEnhancePrompt?: () => void; + isEnhancingPrompt: boolean; + isUtilityConfigured: boolean; + isDisabled: boolean; + submitDisabledReason?: string; + isSending: boolean; + onCancel: () => void | Promise; + onSubmit: () => void; + submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS]; + onVoiceTranscript?: (text: string) => void; + onVoiceAutoSend?: () => void; +}; + +function mobileContextButton(contextCount: number) { + return ( + + ); +} + +function MobileLeftActions(props: MobileToolbarProps) { + return ( +
+
+
+ {!props.hidePlanMode && ( + + )} + {!props.hideAgentControls && ( + <> +
+ +
+
+ +
+
+ +
+ {!props.hideSessionsDropdown && ( +
+ +
+ )} + + )} + {props.onAttachFiles && } +
+ +
+ {!props.hideAgentControls && props.sessionId && !props.isAgentBusy && ( +
+ +
+ )} + {!props.hideAgentControls && !props.isAgentBusy && ( +
+ {})} + isLoading={props.isEnhancingPrompt} + isConfigured={props.isUtilityConfigured} + /> +
+ )} +
+
+ + ); +} + +export function MobileChatInputToolbar(props: MobileToolbarProps) { + return ( +
+ +
+ + {props.planModeEnabled && !props.isAgentBusy && props.onImplementPlan && ( + + )} + {props.onVoiceTranscript && ( + + )} + +
+
+ ); +} diff --git a/apps/web/components/task/chat/chat-input-toolbar-primitives.tsx b/apps/web/components/task/chat/chat-input-toolbar-primitives.tsx new file mode 100644 index 0000000000..f7e64afdbe --- /dev/null +++ b/apps/web/components/task/chat/chat-input-toolbar-primitives.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { + IconArrowUp, + IconFileTextSpark, + IconPaperclip, + IconPlayerPauseFilled, + IconPlugConnected, + IconPlugConnectedX, +} from "@tabler/icons-react"; + +import { GridSpinner } from "@/components/grid-spinner"; +import { KeyboardShortcutTooltip } from "@/components/keyboard-shortcut-tooltip"; +import { useAppStore } from "@/components/state-provider"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; +import { SHORTCUTS } from "@/lib/keyboard/constants"; +import { formatShortcut } from "@/lib/keyboard/utils"; +import { cn } from "@/lib/utils"; + +type SubmitButtonProps = { + isAgentBusy: boolean; + hasContent: boolean; + isDisabled: boolean; + submitDisabledReason?: string; + isSending: boolean; + planModeEnabled: boolean; + onCancel: () => void | Promise; + onSubmit: () => void; + submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS]; +}; + +type SendSubmitButtonProps = Pick< + SubmitButtonProps, + "isDisabled" | "isSending" | "planModeEnabled" | "onSubmit" | "submitShortcut" +> & { + tooltipDescription?: string; +}; + +function submitTooltipDescription( + isAgentBusy: boolean, + planModeEnabled: boolean, + submitDisabledReason?: string, +) { + if (submitDisabledReason) return submitDisabledReason; + if (isAgentBusy) return "Queue message"; + if (planModeEnabled) return "Request plan changes"; + return undefined; +} + +function SendSubmitButton({ + isDisabled, + isSending, + planModeEnabled, + onSubmit, + submitShortcut, + tooltipDescription, +}: SendSubmitButtonProps) { + return ( + + + + + + ); +} + +export function SubmitButton({ + isAgentBusy, + hasContent, + isDisabled, + submitDisabledReason, + isSending, + planModeEnabled, + onCancel, + onSubmit, + submitShortcut, +}: SubmitButtonProps) { + const showSendButton = !isAgentBusy || hasContent; + const [isCancelling, setIsCancelling] = useState(false); + const tooltipDescription = submitTooltipDescription( + isAgentBusy, + planModeEnabled, + submitDisabledReason, + ); + const cancellingRef = useRef(false); + const handleCancelClick = useCallback(async () => { + if (cancellingRef.current) return; + cancellingRef.current = true; + setIsCancelling(true); + try { + await onCancel(); + } catch (error) { + console.error("Failed to cancel agent turn:", error); + } finally { + cancellingRef.current = false; + setIsCancelling(false); + } + }, [onCancel]); + + return ( +
+ {isAgentBusy && ( + + + + + {isCancelling ? "Cancelling..." : "Cancel agent"} + + )} + {showSendButton && ( + + )} +
+ ); +} + +export function PlanToggleButton({ + planModeEnabled, + planModeAvailable, + onPlanModeChange, +}: { + planModeEnabled: boolean; + planModeAvailable: boolean; + onPlanModeChange: (enabled: boolean) => void; +}) { + const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); + const planModeShortcutLabel = formatShortcut(getShortcut("TOGGLE_PLAN_MODE", keyboardShortcuts)); + const tooltip = planModeAvailable + ? `Toggle plan mode (${planModeShortcutLabel}) — Agent collaborates on the plan without implementing changes` + : `Toggle plan layout (${planModeShortcutLabel}) — View and edit the plan (agent cannot read/write it without MCP)`; + + return ( + + + + + {tooltip} + + ); +} + +export function McpIndicator({ mcpServers }: { mcpServers: string[] }) { + const hasMcp = mcpServers.length > 0; + const tooltipText = hasMcp + ? `MCP Servers: ${mcpServers.join(", ")}` + : "Agent does not support MCP"; + const Icon = hasMcp ? IconPlugConnected : IconPlugConnectedX; + return ( + + +
+ +
+
+ {tooltipText} +
+ ); +} + +export function AttachFilesButton({ onClick }: { onClick: () => void }) { + return ( + + + + + Attach files + + ); +} diff --git a/apps/web/components/task/chat/chat-input-toolbar.test.tsx b/apps/web/components/task/chat/chat-input-toolbar.test.tsx index 61564f2f85..4b6709f520 100644 --- a/apps/web/components/task/chat/chat-input-toolbar.test.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar.test.tsx @@ -1,10 +1,33 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import { render, screen, fireEvent, act, cleanup } from "@testing-library/react"; +const responsiveMock = vi.hoisted(() => ({ + breakpoint: "desktop" as "mobile" | "tablet" | "compactDesktop" | "desktop", +})); + afterEach(() => { cleanup(); }); +beforeEach(() => { + responsiveMock.breakpoint = "desktop"; +}); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ + breakpoint: responsiveMock.breakpoint, + isMobile: responsiveMock.breakpoint === "mobile", + isTablet: responsiveMock.breakpoint === "tablet", + isDesktop: + responsiveMock.breakpoint === "compactDesktop" || responsiveMock.breakpoint === "desktop", + isCompactDesktop: responsiveMock.breakpoint === "compactDesktop", + isFullDesktop: responsiveMock.breakpoint === "desktop", + isFinePointer: true, + usesDesktopWorkbench: + responsiveMock.breakpoint === "compactDesktop" || responsiveMock.breakpoint === "desktop", + }), +})); + vi.mock("@kandev/ui/tooltip", () => ({ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, @@ -27,7 +50,58 @@ vi.mock("@/components/keyboard-shortcut-tooltip", () => ({ ), })); +vi.mock("@/components/task/model-selector", () => ({ + ModelSelector: ({ triggerClassName }: { triggerClassName?: string }) => ( + + ), +})); + +vi.mock("@/components/task/mode-selector", () => ({ + ModeSelector: ({ triggerClassName }: { triggerClassName?: string }) => ( + + ), +})); + +vi.mock("@/components/task/sessions-dropdown", () => ({ + SessionsDropdown: () => ( + + ), +})); + +vi.mock("@/components/task/chat/token-usage-display", () => ({ + TokenUsageDisplay: () => , +})); + +vi.mock("@/components/enhance-prompt-button", () => ({ + EnhancePromptButton: () => , +})); + +vi.mock("./context-popover", () => ({ + ContextPopover: ({ trigger }: { trigger: React.ReactNode }) => <>{trigger}, +})); + +vi.mock("./implement-plan-button", () => ({ + ImplementPlanButton: () => , +})); + +vi.mock("./reset-context-button", () => ({ + ResetContextButton: () => , +})); + +vi.mock("./voice-input-button", () => ({ + VoiceInputButton: () => , +})); + import { ChatInputToolbar } from "./chat-input-toolbar"; +import type { ChatInputToolbarProps } from "./chat-input-toolbar"; + +const MOBILE_TOOLBAR_TEST_ID = "mobile-chat-input-toolbar"; function deferred() { let resolve!: (v: T) => void; @@ -55,6 +129,31 @@ function renderToolbar(onCancel: () => void | Promise) { ); } +function renderFullToolbar(overrides: Partial = {}) { + return render( + {}} + sessionId="s1" + taskId="t1" + taskDescription="" + isAgentBusy={false} + hasContent + isDisabled={false} + isSending={false} + onCancel={() => {}} + onSubmit={() => {}} + hidePlanMode + mcpServers={["filesystem"]} + contextCount={2} + onAttachFiles={() => {}} + onEnhancePrompt={() => {}} + isUtilityConfigured + {...overrides} + />, + ); +} + // The cancel button must disable itself while a cancel request is in flight. // Without this guard, an impatient user clicking it repeatedly while the agent // tears down a long-running tool (Claude Monitor, etc.) sends N cancel requests @@ -135,3 +234,60 @@ describe("ChatInputToolbar submit button", () => { expect(screen.getByText("The agent is still being set up.")).toBeTruthy(); }); }); + +describe("ChatInputToolbar responsive wrapper", () => { + it("routes mobile breakpoints to the compact toolbar without a duplicate sessions control", () => { + responsiveMock.breakpoint = "mobile"; + renderFullToolbar(); + + const mobileToolbar = screen.getByTestId(MOBILE_TOOLBAR_TEST_ID); + expect(mobileToolbar).toBeTruthy(); + expect(mobileToolbar.getAttribute("data-legacy-testid")).toBe("chat-input-toolbar"); + expect(screen.getByTestId("mobile-chat-toolbar-left-actions")).toBeTruthy(); + expect(screen.getByTestId("mobile-chat-toolbar-left-actions").className).toContain("pr-8"); + expect(screen.getByTestId("mobile-chat-toolbar-scroll-fade").className).toContain( + "bg-gradient-to-l", + ); + expect(screen.getByTestId("toolbar-item-mcp")).toBeTruthy(); + expect(screen.getByTestId("toolbar-item-mode")).toBeTruthy(); + expect(screen.getByTestId("toolbar-item-model")).toBeTruthy(); + expect(screen.queryByTestId("toolbar-item-sessions")).toBeNull(); + expect(screen.queryByTestId("mock-sessions-dropdown")).toBeNull(); + expect(screen.getByTestId("toolbar-item-context")).toBeTruthy(); + expect(screen.getByTestId("toolbar-item-reset-context")).toBeTruthy(); + expect(screen.getByTestId("toolbar-item-enhance")).toBeTruthy(); + expect(screen.getByTestId("mock-mode-selector").className).toContain("max-w-[46vw]"); + expect(screen.getByTestId("mock-model-selector").className).toContain("max-w-[56vw]"); + expect(screen.getByTestId("mock-model-selector").className).toContain("min-w-0"); + expect(screen.getByTestId("mock-model-selector").className).toContain("overflow-hidden"); + }); + + it("keeps the compact sessions control on tablet layouts", () => { + responsiveMock.breakpoint = "tablet"; + renderFullToolbar(); + + expect(screen.getByTestId(MOBILE_TOOLBAR_TEST_ID)).toBeTruthy(); + expect(screen.getByTestId("toolbar-item-sessions")).toBeTruthy(); + expect(screen.getByTestId("mock-sessions-dropdown")).toBeTruthy(); + }); + + it("hides the compact sessions control when requested", () => { + responsiveMock.breakpoint = "tablet"; + renderFullToolbar({ hideSessionsDropdown: true }); + + expect(screen.getByTestId(MOBILE_TOOLBAR_TEST_ID)).toBeTruthy(); + expect(screen.queryByTestId("toolbar-item-sessions")).toBeNull(); + expect(screen.queryByTestId("mock-sessions-dropdown")).toBeNull(); + }); + + it.each(["compactDesktop", "desktop"] as const)( + "routes %s breakpoints to the desktop toolbar", + (breakpoint) => { + responsiveMock.breakpoint = breakpoint; + renderFullToolbar(); + + expect(screen.getByTestId("chat-input-toolbar")).toBeTruthy(); + expect(screen.queryByTestId(MOBILE_TOOLBAR_TEST_ID)).toBeNull(); + }, + ); +}); diff --git a/apps/web/components/task/chat/chat-input-toolbar.tsx b/apps/web/components/task/chat/chat-input-toolbar.tsx index 28337d1073..e2c2cfbdfc 100644 --- a/apps/web/components/task/chat/chat-input-toolbar.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar.tsx @@ -1,37 +1,13 @@ "use client"; -import { memo, useCallback, useRef, useState, type ReactNode } from "react"; -import { - IconArrowUp, - IconChevronsLeft, - IconDots, - IconFileTextSpark, - IconPlayerPauseFilled, - IconAt, - IconPlugConnected, - IconPlugConnectedX, - IconPaperclip, -} from "@tabler/icons-react"; +import { memo } from "react"; -import { EnhancePromptButton } from "@/components/enhance-prompt-button"; -import { GridSpinner } from "@/components/grid-spinner"; -import { Button } from "@kandev/ui/button"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { cn } from "@/lib/utils"; -import { useToolbarCollapsed } from "@/hooks/use-toolbar-collapsed"; import { SHORTCUTS } from "@/lib/keyboard/constants"; -import { KeyboardShortcutTooltip } from "@/components/keyboard-shortcut-tooltip"; -import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; -import { formatShortcut } from "@/lib/keyboard/utils"; -import { useAppStore } from "@/components/state-provider"; -import { TokenUsageDisplay } from "@/components/task/chat/token-usage-display"; -import { SessionsDropdown } from "@/components/task/sessions-dropdown"; -import { ModelSelector } from "@/components/task/model-selector"; -import { ModeSelector } from "@/components/task/mode-selector"; -import { ContextPopover } from "./context-popover"; -import { ResetContextButton } from "./reset-context-button"; -import { ImplementPlanButton } from "./implement-plan-button"; -import { VoiceInputButton } from "./voice-input-button"; +import { DesktopChatInputToolbar } from "./chat-input-toolbar-desktop"; +import { MobileChatInputToolbar } from "./chat-input-toolbar-mobile"; +import { SubmitButton } from "./chat-input-toolbar-primitives"; +import { shouldUseCompactTaskChrome } from "@/hooks/use-compact-task-chrome"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import type { ContextFile } from "@/lib/state/context-files-store"; export type ChatInputToolbarProps = { @@ -84,392 +60,6 @@ export type ChatInputToolbarProps = { hidePlanMode?: boolean; }; -type SubmitButtonProps = { - isAgentBusy: boolean; - hasContent: boolean; - isDisabled: boolean; - submitDisabledReason?: string; - isSending: boolean; - planModeEnabled: boolean; - onCancel: () => void | Promise; - onSubmit: () => void; - submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS]; -}; - -function submitTooltipDescription( - isAgentBusy: boolean, - planModeEnabled: boolean, - submitDisabledReason?: string, -) { - if (submitDisabledReason) return submitDisabledReason; - if (isAgentBusy) return "Queue message"; - if (planModeEnabled) return "Request plan changes"; - return undefined; -} - -function SubmitButton({ - isAgentBusy, - hasContent, - isDisabled, - submitDisabledReason, - isSending, - planModeEnabled, - onCancel, - onSubmit, - submitShortcut, -}: SubmitButtonProps) { - // When agent is busy and there's nothing to send, show only the cancel button. - // When there's content to queue, show both cancel and send buttons side-by-side. - const showSendButton = !isAgentBusy || hasContent; - // Track cancel-in-flight locally so impatient retries are blocked at the - // button itself: cancelling a long-running tool (e.g. Claude Monitor) can - // take several seconds, and without this the user clicks repeatedly and - // each click hits the backend. - const [isCancelling, setIsCancelling] = useState(false); - const tooltipDescription = submitTooltipDescription( - isAgentBusy, - planModeEnabled, - submitDisabledReason, - ); - // Re-entrancy guard via ref: `disabled={isCancelling}` already blocks DOM - // clicks, but the ref makes the guard effective for any programmatic caller - // and keeps `isCancelling` out of the useCallback deps so the handler - // identity is stable across the false→true→false flips. - const cancellingRef = useRef(false); - const handleCancelClick = useCallback(async () => { - if (cancellingRef.current) return; - cancellingRef.current = true; - setIsCancelling(true); - try { - await onCancel(); - } catch (error) { - // Parent handlers normally swallow errors, but a rejected onCancel - // must not propagate as an unhandled rejection — and the button must - // re-enable so the user can retry. - console.error("Failed to cancel agent turn:", error); - } finally { - cancellingRef.current = false; - setIsCancelling(false); - } - }, [onCancel]); - return ( -
- {isAgentBusy && ( - - - - - {isCancelling ? "Cancelling..." : "Cancel agent"} - - )} - {showSendButton && ( - - - - - - )} -
- ); -} - -function PlanToggleButton({ - planModeEnabled, - planModeAvailable, - onPlanModeChange, -}: { - planModeEnabled: boolean; - planModeAvailable: boolean; - onPlanModeChange: (enabled: boolean) => void; -}) { - const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); - const planModeShortcutLabel = formatShortcut(getShortcut("TOGGLE_PLAN_MODE", keyboardShortcuts)); - const tooltip = planModeAvailable - ? `Toggle plan mode (${planModeShortcutLabel}) — Agent collaborates on the plan without implementing changes` - : `Toggle plan layout (${planModeShortcutLabel}) — View and edit the plan (agent cannot read/write it without MCP)`; - - return ( - - - - - {tooltip} - - ); -} - -function McpIndicator({ mcpServers }: { mcpServers: string[] }) { - const hasMcp = mcpServers.length > 0; - const tooltipText = hasMcp - ? `MCP Servers: ${mcpServers.join(", ")}` - : "Agent does not support MCP"; - const Icon = hasMcp ? IconPlugConnected : IconPlugConnectedX; - return ( - - -
- -
-
- {tooltipText} -
- ); -} - -type ToolbarItemConfig = { - id: string; - collapsible: boolean; - section: "left" | "right"; - render: () => ReactNode; - visible?: boolean; -}; - -function ToolbarExpandToggle({ - isExpanded, - onToggle, -}: { - isExpanded: boolean; - onToggle: () => void; -}) { - return ( - - - - - {isExpanded ? "Collapse" : "More actions"} - - ); -} - -function ToolbarRightSection({ - showCollapsed, - rightItems, - sessionId, - planModeEnabled, - isAgentBusy, - hasContent, - onImplementPlan, - isDisabled, - submitDisabledReason, - isSending, - onCancel, - onSubmit, - submitShortcut, - onVoiceTranscript, - onVoiceAutoSend, -}: { - showCollapsed: boolean; - rightItems: ToolbarItemConfig[]; - sessionId: string | null; - planModeEnabled: boolean; - isAgentBusy: boolean; - hasContent: boolean; - onImplementPlan?: (fresh: boolean) => void; - isDisabled: boolean; - submitDisabledReason?: string; - isSending: boolean; - onCancel: () => void; - onSubmit: () => void; - submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS]; - onVoiceTranscript?: (text: string) => void; - onVoiceAutoSend?: () => void; -}) { - return ( -
- {!showCollapsed && } - - {planModeEnabled && !isAgentBusy && onImplementPlan && ( - - )} -
- {onVoiceTranscript && ( - - )} - -
-
- ); -} - -function buildCollapsibleItems(props: { - mcpServers: string[]; - sessionId: string | null; - taskId: string | null; - taskTitle?: string; - taskDescription: string; - hideSessionsDropdown?: boolean; - isAgentBusy: boolean; - onEnhancePrompt?: () => void; - isEnhancingPrompt: boolean; - isUtilityConfigured: boolean; - hideAgentControls?: boolean; -}): ToolbarItemConfig[] { - if (props.hideAgentControls) return []; - return [ - { - id: "mcp", - section: "left", - collapsible: true, - render: () => , - }, - { - id: "mode", - section: "left", - collapsible: true, - render: () => , - }, - { - id: "reset-context", - section: "right", - collapsible: true, - visible: !!props.sessionId && !props.isAgentBusy, - render: () => , - }, - { - id: "sessions", - section: "right", - collapsible: true, - visible: !props.hideSessionsDropdown, - render: () => ( - - ), - }, - { - id: "model", - section: "right", - collapsible: true, - render: () => , - }, - { - id: "enhance", - section: "right", - collapsible: true, - visible: !props.isAgentBusy, - render: () => ( - {})} - isLoading={props.isEnhancingPrompt} - isConfigured={props.isUtilityConfigured} - /> - ), - }, - ]; -} - -function CollapsibleItems({ - items, - testIdPrefix, -}: { - items: ToolbarItemConfig[]; - testIdPrefix: string; -}) { - return items.map((i) => ( -
- {i.render()} -
- )); -} - -function AttachFilesButton({ onClick }: { onClick: () => void }) { - return ( - - - - - Attach files - - ); -} - function MinimalToolbar({ isAgentBusy, hasContent, @@ -525,10 +115,8 @@ const toolbarDefaults = { export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInputToolbarProps) { const props = { ...toolbarDefaults, ...rawProps }; const submitShortcut = props.submitKey === "enter" ? SHORTCUTS.SUBMIT_ENTER : SHORTCUTS.SUBMIT; - const toolbarRef = useRef(null); - const isCollapsed = useToolbarCollapsed(toolbarRef); - const [isExpanded, setIsExpanded] = useState(false); - const showCollapsed = isCollapsed && !isExpanded; + const responsiveBreakpoint = useResponsiveBreakpoint(); + const usesCompactTaskChrome = shouldUseCompactTaskChrome(responsiveBreakpoint); if (props.minimalToolbar) { return ( @@ -545,68 +133,34 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp ); } - const items = buildCollapsibleItems(props); - const leftItems = items.filter((i) => i.section === "left" && i.visible !== false); - const rightItems = items.filter((i) => i.section === "right" && i.visible !== false); - - return ( -
-
- {!props.hidePlanMode && ( - - )} - {!showCollapsed && } - {props.onAttachFiles && } - {})} - trigger={ - - } - sessionId={props.sessionId} - planContextEnabled={props.planContextEnabled} - contextFiles={props.contextFiles} - onToggleFile={props.onToggleFile ?? (() => {})} - /> - {isCollapsed && ( - setIsExpanded((v) => !v)} /> - )} -
- -
- - {})} + contextCount={props.contextCount} + planContextEnabled={props.planContextEnabled} + contextFiles={props.contextFiles} + onToggleFile={props.onToggleFile ?? (() => {})} isAgentBusy={props.isAgentBusy} hasContent={props.hasContent ?? false} onImplementPlan={props.onImplementPlan} + onEnhancePrompt={props.onEnhancePrompt} + isEnhancingPrompt={props.isEnhancingPrompt} + isUtilityConfigured={props.isUtilityConfigured} isDisabled={props.isDisabled} submitDisabledReason={props.submitDisabledReason} isSending={props.isSending} @@ -616,6 +170,9 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp onVoiceTranscript={props.onVoiceTranscript} onVoiceAutoSend={props.onVoiceAutoSend} /> -
- ); + ); + } + + const { submitKey: _submitKey, ...desktopProps } = props; + return ; }); diff --git a/apps/web/components/task/chat/todo-indicator.tsx b/apps/web/components/task/chat/todo-indicator.tsx index e8f465f5c0..5417537da9 100644 --- a/apps/web/components/task/chat/todo-indicator.tsx +++ b/apps/web/components/task/chat/todo-indicator.tsx @@ -1,8 +1,10 @@ "use client"; -import { useEffect, useRef } from "react"; +import { forwardRef, useEffect, useRef, useState, type ComponentPropsWithoutRef } from "react"; import { IconCheck, IconCircle, IconCircleFilled, IconListCheck, IconX } from "@tabler/icons-react"; -import { HoverCard, HoverCardTrigger, HoverCardContent } from "@kandev/ui/hover-card"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card"; +import { Popover, PopoverTrigger, PopoverContent } from "@kandev/ui/popover"; +import { useCompactTaskChrome } from "@/hooks/use-compact-task-chrome"; import { cn } from "@/lib/utils"; type TodoDisplayItem = { @@ -60,48 +62,108 @@ function TodoList({ todos }: { todos: TodoDisplayItem[] }) { ); } +function TodoIndicatorContent({ + todos, + completed, + progress, +}: { + todos: TodoDisplayItem[]; + completed: number; + progress: number; +}) { + return ( +
+
+ Todos + + {completed}/{todos.length} completed + +
+
+
+
+ +
+ ); +} + +type TodoIndicatorButtonProps = { + allComplete: boolean; + completed: number; + total: number; + open?: boolean; +} & ComponentPropsWithoutRef<"button">; + +const TodoIndicatorTrigger = forwardRef( + function TodoIndicatorTrigger({ allComplete, completed, total, open, ...buttonProps }, ref) { + return ( + + ); + }, +); + export function TodoIndicator({ todos }: TodoIndicatorProps) { + const usesCompactTaskChrome = useCompactTaskChrome(); + const [open, setOpen] = useState(false); + if (!todos.length) return null; const completed = todos.filter((t) => resolveStatus(t) === "completed").length; const allComplete = completed === todos.length; const progress = Math.round((completed / todos.length) * 100); + const content = ; + + if (!usesCompactTaskChrome) { + return ( + + + + + + {content} + + + ); + } return ( - - - - - -
- Todos - - {completed}/{todos.length} completed - -
-
-
-
- - - + + + + + + {content} + + ); } diff --git a/apps/web/components/task/chat/use-chat-input-container.test.ts b/apps/web/components/task/chat/use-chat-input-container.test.ts index 62b2d36092..bfd5c26773 100644 --- a/apps/web/components/task/chat/use-chat-input-container.test.ts +++ b/apps/web/components/task/chat/use-chat-input-container.test.ts @@ -1,7 +1,7 @@ import { createRef } from "react"; import { renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { useChatInputContainer } from "./use-chat-input-container"; +import { shouldShowChatFocusHint, useChatInputContainer } from "./use-chat-input-container"; import type { ChatInputContainerHandle } from "./chat-input-container"; function renderInputState(overrides: Partial[0]> = {}) { @@ -55,3 +55,35 @@ describe("useChatInputContainer", () => { expect(result.current.submitDisabledReason).toBe("The agent is still being set up."); }); }); + +describe("shouldShowChatFocusHint", () => { + it("hides the hint when a blurred editor has draft text", () => { + expect( + shouldShowChatFocusHint({ + isInputFocused: false, + value: "halle from chat input", + hasClarification: false, + hasPendingComments: false, + }), + ).toBe(false); + }); + + it("shows the hint only for an empty blurred editor without blocking overlays", () => { + expect( + shouldShowChatFocusHint({ + isInputFocused: false, + value: " ", + hasClarification: false, + hasPendingComments: false, + }), + ).toBe(true); + expect( + shouldShowChatFocusHint({ + isInputFocused: true, + value: "", + hasClarification: false, + hasPendingComments: false, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/components/task/chat/use-chat-input-container.ts b/apps/web/components/task/chat/use-chat-input-container.ts index daa7be20fc..21e0194836 100644 --- a/apps/web/components/task/chat/use-chat-input-container.ts +++ b/apps/web/components/task/chat/use-chat-input-container.ts @@ -95,6 +95,20 @@ function getInputPlaceholder( return "Ask to make changes, @mention files"; } +export function shouldShowChatFocusHint(args: { + isInputFocused: boolean; + value: string; + hasClarification: boolean; + hasPendingComments: boolean; +}) { + return ( + !args.isInputFocused && + args.value.trim().length === 0 && + !args.hasClarification && + !args.hasPendingComments + ); +} + function computeDerivedState(params: { isStarting: boolean; isPreparingEnvironment: boolean; @@ -108,6 +122,7 @@ function computeDerivedState(params: { pendingCommentsByFile: Record | undefined; allItemsLength: number; isInputFocused: boolean; + value: string; placeholder: string | undefined; isAgentBusy: boolean; hasAgentCommands: boolean; @@ -137,7 +152,12 @@ function computeDerivedState(params: { params.pendingCommentsByFile && Object.keys(params.pendingCommentsByFile).length > 0 ); const hasContextZone = params.allItemsLength > 0; - const showFocusHint = !params.isInputFocused && !hasClarification && !hasPendingComments; + const showFocusHint = shouldShowChatFocusHint({ + isInputFocused: params.isInputFocused, + value: params.value, + hasClarification, + hasPendingComments, + }); const inputPlaceholder = getInputPlaceholder( params.placeholder, params.isAgentBusy, @@ -222,6 +242,7 @@ export function useChatInputContainer(params: UseChatInputContainerParams) { pendingCommentsByFile, allItemsLength: allItems.length, isInputFocused, + value, placeholder, isAgentBusy, hasAgentCommands, diff --git a/apps/web/components/task/chat/use-tiptap-editor.test.ts b/apps/web/components/task/chat/use-tiptap-editor.test.ts index 8a0539baae..02d9253789 100644 --- a/apps/web/components/task/chat/use-tiptap-editor.test.ts +++ b/apps/web/components/task/chat/use-tiptap-editor.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from "vitest"; -import { decideSubmitShortcut } from "./use-tiptap-editor"; +import { TIPTAP_EDITOR_TEXT_SIZE_CLASS, decideSubmitShortcut } from "./use-tiptap-editor"; import { decideHistoryNav } from "./tiptap-editor-history"; +describe("TIPTAP_EDITOR_TEXT_SIZE_CLASS", () => { + it("keeps 16px text until the lg breakpoint", () => { + expect(TIPTAP_EDITOR_TEXT_SIZE_CLASS).toContain("text-base"); + expect(TIPTAP_EDITOR_TEXT_SIZE_CLASS).toContain("lg:text-sm"); + expect(TIPTAP_EDITOR_TEXT_SIZE_CLASS).not.toContain("md:text-sm"); + }); +}); + describe("decideSubmitShortcut", () => { describe("submitKey=enter", () => { it("submits on Enter when no suggestion menu is open", () => { diff --git a/apps/web/components/task/chat/use-tiptap-editor.ts b/apps/web/components/task/chat/use-tiptap-editor.ts index 9e3436c518..9643ffcae3 100644 --- a/apps/web/components/task/chat/use-tiptap-editor.ts +++ b/apps/web/components/task/chat/use-tiptap-editor.ts @@ -40,6 +40,7 @@ export type TipTapInputHandle = { }; const lowlightInstance = createLowlight(common); +export const TIPTAP_EDITOR_TEXT_SIZE_CLASS = "text-base leading-relaxed lg:text-sm"; /** * Custom Placeholder extension that reads the current placeholder from @@ -224,7 +225,7 @@ function buildEditorProps(args: { attributes: { class: cn( "w-full h-full resize-none bg-transparent px-2 py-2 overflow-y-auto", - "text-sm leading-relaxed", + TIPTAP_EDITOR_TEXT_SIZE_CLASS, "placeholder:text-muted-foreground", "focus:outline-none", "disabled:cursor-not-allowed disabled:opacity-50", diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx new file mode 100644 index 0000000000..dafd1389d8 --- /dev/null +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ToastProvider } from "@/components/toast-provider"; +import { MobileTaskList } from "@/components/task/mobile/session-task-switcher-sheet"; +import type { TaskSwitcherItem } from "@/components/task/task-switcher"; + +const mocks = vi.hoisted(() => ({ + toggleSidebarGroupCollapsed: vi.fn(), + appState: { + taskPRs: { byTaskId: {} }, + comments: { byTaskId: {} }, + }, +})); + +type MockAppState = typeof mocks.appState & { + toggleSidebarGroupCollapsed: typeof mocks.toggleSidebarGroupCollapsed; +}; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: MockAppState) => unknown) => + selector({ + ...mocks.appState, + toggleSidebarGroupCollapsed: mocks.toggleSidebarGroupCollapsed, + }), +})); + +vi.mock("@/hooks/domains/sidebar/use-effective-sidebar-view", () => ({ + useEffectiveSidebarView: () => ({ + id: "default", + name: "Default", + filters: [], + sort: { key: "updatedAt", direction: "desc" }, + group: "workflowStep", + collapsedGroups: [], + }), +})); + +vi.mock("@/hooks/domains/sidebar/use-sidebar-task-prefs", () => ({ + useSidebarTaskPrefs: () => ({ + pinnedTaskIds: [], + orderedTaskIds: [], + subtaskOrderByParentId: {}, + togglePinnedTask: vi.fn(), + handleReorderGroup: vi.fn(), + handleReorderSubtasks: vi.fn(), + }), +})); + +function task(id: string): TaskSwitcherItem { + return { + id, + title: `Task ${id}`, + state: "TODO", + workflowId: "workflow-1", + workflowName: "Workflow", + workflowStepId: "step-1", + workflowStepTitle: "Build", + }; +} + +describe("MobileTaskList", () => { + beforeEach(() => { + mocks.toggleSidebarGroupCollapsed.mockClear(); + }); + + it("toggles workflow-step groups from the mobile sidebar", () => { + render( + + + , + ); + + const header = screen.getByTestId("sidebar-group-header"); + expect(header.textContent).toContain("Build"); + expect(header.textContent).toContain("5"); + + fireEvent.click(header); + + expect(mocks.toggleSidebarGroupCollapsed).toHaveBeenCalledWith("default", "step-1"); + }); +}); 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 d67b847546..2933469ad1 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -26,7 +26,7 @@ type SessionTaskSwitcherSheetProps = { workflowId: string | null; }; -function MobileTaskList({ +export function MobileTaskList({ tasks, workflows, stepsByWorkflowId, diff --git a/apps/web/components/task/mode-selector.tsx b/apps/web/components/task/mode-selector.tsx index ac3fcc7225..3910277312 100644 --- a/apps/web/components/task/mode-selector.tsx +++ b/apps/web/components/task/mode-selector.tsx @@ -15,6 +15,7 @@ import { useAppStore } from "@/components/state-provider"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { setSessionMode } from "@/lib/api/domains/session-api"; +import { cn } from "@/lib/utils"; import type { Agent, AgentProfile, AvailableAgent } from "@/lib/types/http"; type ModeOption = { @@ -25,6 +26,7 @@ type ModeOption = { type ModeSelectorProps = { sessionId: string | null; + triggerClassName?: string; }; function resolveSnapshotMode(snapshot: unknown): string | null { @@ -121,7 +123,10 @@ function useModeSelectorState(sessionId: string | null) { ); } -export const ModeSelector = memo(function ModeSelector({ sessionId }: ModeSelectorProps) { +export const ModeSelector = memo(function ModeSelector({ + sessionId, + triggerClassName, +}: ModeSelectorProps) { const modeState = useModeSelectorState(sessionId); const [dropdownOpen, setDropdownOpen] = useState(false); const [tooltipOpen, setTooltipOpen] = useState(false); @@ -174,9 +179,12 @@ export const ModeSelector = memo(function ModeSelector({ sessionId }: ModeSelect variant="ghost" size="sm" data-testid="session-mode-selector" - className="h-7 gap-1 px-2 cursor-pointer hover:bg-muted/40 whitespace-nowrap" + className={cn( + "h-7 min-w-0 gap-1 overflow-hidden px-2 cursor-pointer hover:bg-muted/40 whitespace-nowrap", + triggerClassName, + )} > - {displayName} + {displayName} diff --git a/apps/web/components/task/model-selector.tsx b/apps/web/components/task/model-selector.tsx index 35772f47f6..43685b2821 100644 --- a/apps/web/components/task/model-selector.tsx +++ b/apps/web/components/task/model-selector.tsx @@ -29,6 +29,7 @@ type SessionModelsEntry = { type ModelSelectorProps = { sessionId: string | null; + triggerClassName?: string; }; function resolveSnapshotModel(snapshot: unknown): string | null { @@ -276,7 +277,10 @@ function useModelSelectorState(sessionId: string | null) { return { currentModel, modelOptions, configOptions, handleModelChange, handleConfigChange }; } -export const ModelSelector = memo(function ModelSelector({ sessionId }: ModelSelectorProps) { +export const ModelSelector = memo(function ModelSelector({ + sessionId, + triggerClassName, +}: ModelSelectorProps) { const { currentModel, modelOptions, configOptions, handleModelChange, handleConfigChange } = useModelSelectorState(sessionId); const modelConfig = configOptions.find(isModelConfigOption); @@ -310,6 +314,7 @@ export const ModelSelector = memo(function ModelSelector({ sessionId }: ModelSel ariaLabel="Session model settings" variant="compact" popoverSide="top" + triggerClassName={triggerClassName} /> ); }); diff --git a/apps/web/components/task/selector-trigger-class.test.tsx b/apps/web/components/task/selector-trigger-class.test.tsx new file mode 100644 index 0000000000..c3af19b027 --- /dev/null +++ b/apps/web/components/task/selector-trigger-class.test.tsx @@ -0,0 +1,89 @@ +import { render, screen } from "@testing-library/react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { describe, expect, it, vi } from "vitest"; + +import { ModelSelector } from "@/components/task/model-selector"; +import { ModeSelector } from "@/components/task/mode-selector"; + +const mocks = vi.hoisted(() => { + const appState = { + activeModel: { bySessionId: {} }, + sessionModels: { + bySessionId: { + "session-1": { + currentModelId: "gpt-5.5", + models: [{ modelId: "gpt-5.5", name: "GPT-5.5" }], + configOptions: [], + }, + }, + }, + sessionMode: { + bySessionId: { + "session-1": { + currentModeId: "full-access", + availableModes: [ + { id: "full-access", name: "Full access" }, + { id: "read-only", name: "Read only" }, + ], + }, + }, + }, + settingsAgents: { items: [] }, + taskSessions: { + items: { + "session-1": { + agent_profile_id: "profile-1", + agent_profile_snapshot: {}, + }, + }, + }, + setActiveModel: vi.fn(), + setSessionModels: vi.fn(), + }; + + return { + appState, + }; +}); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: typeof mocks.appState) => unknown) => selector(mocks.appState), +})); + +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: vi.fn() }), +})); + +vi.mock("@/hooks/domains/settings/use-available-agents", () => ({ + useAvailableAgents: () => ({ items: [] }), +})); + +vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ + useSettingsData: vi.fn(), +})); + +vi.mock("@/lib/api/domains/session-api", () => ({ + setSessionConfigOption: vi.fn(), + setSessionMode: vi.fn(), + setSessionModel: vi.fn(), +})); + +describe("task selector trigger styling", () => { + it("forwards custom trigger classes to the model selector trigger", () => { + render(); + + expect(screen.getByRole("button", { name: "Session model settings" }).className).toContain( + "max-w-model", + ); + }); + + it("forwards custom trigger classes to the mode selector trigger", () => { + render( + + + , + ); + + expect(screen.getByTestId("session-mode-selector").className).toContain("max-w-mode"); + }); +}); diff --git a/apps/web/components/task/task-switcher-subtask-dnd.test.ts b/apps/web/components/task/task-switcher-subtask-dnd.test.ts new file mode 100644 index 0000000000..b2bf16bb89 --- /dev/null +++ b/apps/web/components/task/task-switcher-subtask-dnd.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { + DRAG_ACTIVATION_DISTANCE, + TOUCH_DRAG_ACTIVATION_DELAY_MS, + TOUCH_DRAG_ACTIVATION_TOLERANCE_PX, + taskSwitcherDragActivationConstraints, +} from "@/components/task/task-switcher-subtask-dnd"; + +describe("task switcher subtask DnD activation", () => { + it("keeps pointer dragging responsive but delays touch dragging for scrollable mobile lists", () => { + expect(taskSwitcherDragActivationConstraints()).toEqual({ + pointer: { distance: DRAG_ACTIVATION_DISTANCE }, + touch: { + delay: TOUCH_DRAG_ACTIVATION_DELAY_MS, + tolerance: TOUCH_DRAG_ACTIVATION_TOLERANCE_PX, + }, + }); + }); + + it("reuses stable activation constraint references across renders", () => { + expect(taskSwitcherDragActivationConstraints()).toBe(taskSwitcherDragActivationConstraints()); + }); +}); diff --git a/apps/web/components/task/task-switcher-subtask-dnd.tsx b/apps/web/components/task/task-switcher-subtask-dnd.tsx index 59538fd4b1..baf9904238 100644 --- a/apps/web/components/task/task-switcher-subtask-dnd.tsx +++ b/apps/web/components/task/task-switcher-subtask-dnd.tsx @@ -3,7 +3,8 @@ import { useCallback, useMemo } from "react"; import { DndContext, - PointerSensor, + MouseSensor, + TouchSensor, closestCenter, type DragEndEvent, useSensor, @@ -20,6 +21,20 @@ import { cn } from "@/lib/utils"; import type { TaskSwitcherItem } from "./task-switcher"; export const DRAG_ACTIVATION_DISTANCE = 8; +export const TOUCH_DRAG_ACTIVATION_DELAY_MS = 250; +export const TOUCH_DRAG_ACTIVATION_TOLERANCE_PX = 5; + +const TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS = { + pointer: { distance: DRAG_ACTIVATION_DISTANCE }, + touch: { + delay: TOUCH_DRAG_ACTIVATION_DELAY_MS, + tolerance: TOUCH_DRAG_ACTIVATION_TOLERANCE_PX, + }, +}; + +export function taskSwitcherDragActivationConstraints() { + return TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS; +} // Skip layout/paint for rows outside the sidebar scrollport so long task lists // stay cheap to render and scroll. `auto 52px` seeds the height estimate before @@ -65,8 +80,8 @@ function DraggableSortableTaskNode({
void) { const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE } }), + useSensor(MouseSensor, { + activationConstraint: TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS.pointer, + }), + useSensor(TouchSensor, { + activationConstraint: TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS.touch, + }), ); const handleDragEnd = useCallback( (event: DragEndEvent) => { diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index ecc67a70a7..ff2968b2f2 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -74,7 +74,7 @@ export class SessionPage { return this.activeChat().getByTestId("chat-status-bar").getByTestId("pr-status-chip"); } todoIndicator() { - return this.page.getByTestId("todo-indicator"); + return this.activeChat().getByTestId("todo-indicator"); } /** Span wrapper around the resume button — used to trigger tooltip on disabled state. */ failedSessionResumeWrapper(): Locator { diff --git a/apps/web/e2e/tests/chat/mobile-model-selector.spec.ts b/apps/web/e2e/tests/chat/mobile-model-selector.spec.ts new file mode 100644 index 0000000000..9c1431b5f0 --- /dev/null +++ b/apps/web/e2e/tests/chat/mobile-model-selector.spec.ts @@ -0,0 +1,52 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import type { SeedData } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; + +async function seedAndOpenTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile Model Selector", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + return session; +} + +test.describe("Mobile chat model selector", () => { + test.describe.configure({ retries: 1, timeout: 60_000 }); + + test("shows mode and model in the swipeable composer actions", async ({ + testPage, + apiClient, + seedData, + }) => { + await seedAndOpenTask(testPage, apiClient, seedData); + + const leftActions = testPage.getByTestId("mobile-chat-toolbar-left-actions"); + await expect(leftActions).toBeVisible({ timeout: 15_000 }); + await expect(testPage.getByTestId("toolbar-overflow-menu")).not.toBeVisible(); + await expect(leftActions.getByTestId("toolbar-item-sessions")).toHaveCount(0); + + await expect(leftActions.getByTestId("session-mode-selector")).toBeVisible(); + + const trigger = leftActions.getByRole("button", { name: "Session model settings" }); + await expect(trigger).toContainText("Mock Fast", { timeout: 15_000 }); + + await trigger.tap(); + await expect(testPage.getByRole("option", { name: /Mock Smart/ })).toBeVisible({ + timeout: 5_000, + }); + }); +}); diff --git a/apps/web/e2e/tests/cli-mode/mobile-passthrough-composer.spec.ts b/apps/web/e2e/tests/cli-mode/mobile-passthrough-composer.spec.ts index a3dd046479..262468a18f 100644 --- a/apps/web/e2e/tests/cli-mode/mobile-passthrough-composer.spec.ts +++ b/apps/web/e2e/tests/cli-mode/mobile-passthrough-composer.spec.ts @@ -118,7 +118,10 @@ test.describe("mobile CLI mode: passthrough composer", () => { const editor = composer.locator(".tiptap.ProseMirror"); await expect(editor).toBeVisible({ timeout: 5_000 }); - await testPage.getByTestId("chat-context-button").tap(); + await composer + .getByTestId("mobile-chat-toolbar-left-actions") + .getByTestId("chat-context-button") + .tap(); const searchInput = testPage.getByPlaceholder("Search files and prompts..."); await expect(searchInput).toBeVisible({ timeout: 5_000 }); await searchInput.fill(promptName); diff --git a/apps/web/e2e/tests/pr/mobile-pr-ci-chip.spec.ts b/apps/web/e2e/tests/pr/mobile-pr-ci-chip.spec.ts index b76d9cf12b..aa68ef46ad 100644 --- a/apps/web/e2e/tests/pr/mobile-pr-ci-chip.spec.ts +++ b/apps/web/e2e/tests/pr/mobile-pr-ci-chip.spec.ts @@ -21,6 +21,7 @@ type SeedTaskArgs = { apiClient: ApiClient; seedData: SeedData; title: string; + description?: string; prOverrides?: Partial[0]>; }; @@ -28,6 +29,7 @@ async function seedTaskWithPR({ apiClient, seedData, title, + description = "/e2e:simple-message", prOverrides = {}, }: SeedTaskArgs): Promise { await apiClient.mockGitHubReset(); @@ -37,7 +39,7 @@ async function seedTaskWithPR({ title, seedData.agentProfileId, { - description: "/e2e:simple-message", + description, workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], @@ -61,6 +63,22 @@ async function seedTaskWithPR({ return task.id; } +async function seedTaskWithPRAndTodos(args: SeedTaskArgs): Promise { + return seedTaskWithPR({ + ...args, + description: [ + 'e2e:plan([{"content":"Review mobile toolbar","status":"completed"},{"content":"Verify todo tap","status":"in_progress"}])', + 'e2e:message("mobile todo and ci response")', + ].join("\n"), + prOverrides: { + checks_state: "success", + checks_total: 4, + checks_passing: 4, + ...args.prOverrides, + }, + }); +} + async function openTask(testPage: import("@playwright/test").Page, taskId: string) { await testPage.goto(`/t/${taskId}`); const session = new SessionPage(testPage); @@ -69,6 +87,29 @@ async function openTask(testPage: import("@playwright/test").Page, taskId: strin } test.describe("mobile PR CI chip drawer", () => { + test("tapping the todo indicator beside the CI chip opens the todo list", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const taskId = await seedTaskWithPRAndTodos({ + apiClient, + seedData, + title: "Mobile todo tap-open", + }); + const session = await openTask(testPage, taskId); + + await expect(session.prStatusChip()).toBeVisible({ timeout: 15_000 }); + await expect(session.todoIndicator()).toBeVisible({ timeout: 15_000 }); + + await session.todoIndicator().tap(); + + const todoPopover = testPage.getByTestId("todo-indicator-popover"); + await expect(todoPopover.getByText("Todos", { exact: true })).toBeVisible(); + await expect(todoPopover.getByText("Verify todo tap", { exact: true })).toBeVisible(); + }); + test("tapping the chip opens the drawer with PR CI content", async ({ testPage, apiClient, diff --git a/apps/web/hooks/use-compact-task-chrome.test.ts b/apps/web/hooks/use-compact-task-chrome.test.ts new file mode 100644 index 0000000000..ba3ca3efe4 --- /dev/null +++ b/apps/web/hooks/use-compact-task-chrome.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import type { ResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { shouldUseCompactTaskChrome, shouldUseTouchDrawer } from "@/hooks/use-compact-task-chrome"; + +function breakpoint(overrides: Partial): ResponsiveBreakpoint { + return { + breakpoint: "desktop", + isMobile: false, + isTablet: false, + isDesktop: true, + isCompactDesktop: false, + isFullDesktop: true, + isFinePointer: true, + usesDesktopWorkbench: true, + ...overrides, + }; +} + +describe("compact task chrome breakpoint", () => { + it("uses compact task chrome on mobile and tablet layouts", () => { + expect(shouldUseCompactTaskChrome(breakpoint({ breakpoint: "mobile", isMobile: true }))).toBe( + true, + ); + expect(shouldUseCompactTaskChrome(breakpoint({ breakpoint: "tablet", isTablet: true }))).toBe( + true, + ); + }); + + it("keeps full task chrome on compact and full desktop layouts", () => { + expect( + shouldUseCompactTaskChrome( + breakpoint({ + breakpoint: "compactDesktop", + isDesktop: true, + isCompactDesktop: true, + isFullDesktop: false, + }), + ), + ).toBe(false); + expect(shouldUseCompactTaskChrome(breakpoint({ breakpoint: "desktop" }))).toBe(false); + }); +}); + +describe("touch drawer breakpoint", () => { + it("uses drawers on coarse-pointer layouts", () => { + expect(shouldUseTouchDrawer(breakpoint({ isFinePointer: false }))).toBe(true); + }); + + it("keeps hover surfaces on fine-pointer layouts", () => { + expect(shouldUseTouchDrawer(breakpoint({ breakpoint: "tablet", isTablet: true }))).toBe(false); + }); +}); diff --git a/apps/web/hooks/use-compact-task-chrome.ts b/apps/web/hooks/use-compact-task-chrome.ts new file mode 100644 index 0000000000..ff476d8b7d --- /dev/null +++ b/apps/web/hooks/use-compact-task-chrome.ts @@ -0,0 +1,20 @@ +"use client"; + +import type { ResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; + +export function shouldUseCompactTaskChrome(breakpoint: ResponsiveBreakpoint): boolean { + return breakpoint.isMobile || breakpoint.isTablet; +} + +export function useCompactTaskChrome(): boolean { + return shouldUseCompactTaskChrome(useResponsiveBreakpoint()); +} + +export function shouldUseTouchDrawer(breakpoint: ResponsiveBreakpoint): boolean { + return !breakpoint.isFinePointer; +} + +export function useTouchDrawer(): boolean { + return shouldUseTouchDrawer(useResponsiveBreakpoint()); +}