diff --git a/.agents/skills/mobile-parity/SKILL.md b/.agents/skills/mobile-parity/SKILL.md index 8b37475f5e..c1ad37c328 100644 --- a/.agents/skills/mobile-parity/SKILL.md +++ b/.agents/skills/mobile-parity/SKILL.md @@ -49,6 +49,7 @@ If task has no UI surface, say why this skill does not apply and continue. - Run the narrowest relevant viewport locally or with screenshots when possible. - Check that text does not overlap, controls remain clickable, focus/keyboard flows still work, and no unintended horizontal scroll appears. - Run the focused Playwright tests. If full E2E cannot run, report the command and blocker. + - E2E runs against the standalone build, not a dev server, so rebuild after frontend changes: `make build-web` (and `make build-backend` for Go), or use `make test-e2e` which rebuilds both. Skipping this silently tests stale code. See `/e2e`. ## Mobile E2E Expectations diff --git a/apps/web/app/tasks/tasks-page-client.tsx b/apps/web/app/tasks/tasks-page-client.tsx index 121d7f8445..fe02b910e1 100644 --- a/apps/web/app/tasks/tasks-page-client.tsx +++ b/apps/web/app/tasks/tasks-page-client.tsx @@ -8,10 +8,13 @@ import { getColumns } from "./columns"; import { archiveTask, deleteTask, listTasksByWorkspace } from "@/lib/api"; import { TaskCreateDialog } from "@/components/task-create-dialog"; import { KanbanHeader } from "@/components/kanban/kanban-header"; +import { MobileSearchBar } from "@/components/kanban/mobile-search-bar"; import { Checkbox } from "@kandev/ui/checkbox"; import { Label } from "@kandev/ui/label"; import type { Task, Workspace, Workflow, WorkflowStep, Repository } from "@/lib/types/http"; import { useToast } from "@/components/toast-provider"; +import { useAppStore } from "@/components/state-provider"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; import { useDebounce } from "@/hooks/use-debounce"; @@ -410,6 +413,13 @@ function useTasksPageSetup(props: TasksPageClientProps) { export function TasksPageClient(props: TasksPageClientProps) { const s = useTasksPageSetup(props); + const { isMobile } = useResponsiveBreakpoint(); + const isMobileSearchOpen = useAppStore((state) => state.mobileKanban.isSearchOpen); + const setMobileSearchOpen = useAppStore((state) => state.setMobileKanbanSearchOpen); + + // Collapse search on unmount so the global flag doesn't auto-open (and focus) + // the search bar after navigating to another route. + useEffect(() => () => setMobileSearchOpen(false), [setMobileSearchOpen]); return (
+ {isMobile && isMobileSearchOpen && ( + + )} state.mobileKanban.isSearchOpen); + const setMobileSearchOpen = useAppStore((state) => state.setMobileKanbanSearchOpen); + + // Collapse search on unmount so the global flag doesn't auto-open (and focus) + // the search bar after navigating to another route. + useEffect(() => () => setMobileSearchOpen(false), [setMobileSearchOpen]); if (!s.isMounted) { return
; @@ -347,7 +353,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, onBeforeEdit }: KanbanB searchQuery={s.searchQuery} onSearchChange={s.setSearchQuery} /> - {s.isMobile && ( + {s.isMobile && isMobileSearchOpen && ( )} void; - isSearchLoading?: boolean; showReleaseNotesButton: boolean; onOpenReleaseNotes: () => void; showHealthIndicator: boolean; @@ -25,9 +23,7 @@ export function KanbanHeaderMobile({ currentPage = "kanban", title, workspaceLabel, - searchQuery = "", onSearchChange, - isSearchLoading = false, showReleaseNotesButton, onOpenReleaseNotes, showHealthIndicator, @@ -35,6 +31,15 @@ export function KanbanHeaderMobile({ }: KanbanHeaderMobileProps) { const isMenuOpen = useAppStore((state) => state.mobileKanban.isMenuOpen); const setMenuOpen = useAppStore((state) => state.setMobileKanbanMenuOpen); + const isSearchOpen = useAppStore((state) => state.mobileKanban.isSearchOpen); + const setSearchOpen = useAppStore((state) => state.setMobileKanbanSearchOpen); + + const toggleSearch = () => { + const next = !isSearchOpen; + setSearchOpen(next); + // Clear any active query when collapsing so results aren't filtered by a hidden search. + if (!next) onSearchChange?.(""); + }; return ( <> @@ -44,15 +49,30 @@ export function KanbanHeaderMobile({ className="h-10 px-3 py-1" variant={title === "Home" ? "root" : "breadcrumb"} actions={ - + <> + {onSearchChange && ( + + )} + + } /> ); diff --git a/apps/web/components/kanban/mobile-search-bar.tsx b/apps/web/components/kanban/mobile-search-bar.tsx index 0e5c3a2328..225aac229a 100644 --- a/apps/web/components/kanban/mobile-search-bar.tsx +++ b/apps/web/components/kanban/mobile-search-bar.tsx @@ -9,12 +9,13 @@ type MobileSearchBarProps = { export function MobileSearchBar({ searchQuery, onSearchChange }: MobileSearchBarProps) { return ( -
+
); diff --git a/apps/web/components/kanban/task-search-input.tsx b/apps/web/components/kanban/task-search-input.tsx index 271bdd4362..19c4d4305a 100644 --- a/apps/web/components/kanban/task-search-input.tsx +++ b/apps/web/components/kanban/task-search-input.tsx @@ -12,6 +12,7 @@ interface TaskSearchInputProps { debounceMs?: number; isLoading?: boolean; className?: string; + autoFocus?: boolean; } export function TaskSearchInput({ @@ -21,15 +22,24 @@ export function TaskSearchInput({ debounceMs = 300, isLoading = false, className, + autoFocus = false, }: TaskSearchInputProps) { const [localValue, setLocalValue] = useState(value); const timeoutRef = useRef(null); + const inputRef = useRef(null); // Sync local value when external value changes useEffect(() => { setLocalValue(value); }, [value]); + // Focus on mount when requested (e.g. when the mobile search bar expands) + useEffect(() => { + if (autoFocus) { + inputRef.current?.focus(); + } + }, [autoFocus]); + const handleChange = useCallback( (e: React.ChangeEvent) => { const newValue = e.target.value; @@ -73,6 +83,7 @@ export function TaskSearchInput({ )} { await expect(mobile.mobileKanbanLayout()).toBeVisible(); // FAB should be visible for creating tasks await expect(mobile.mobileFab).toBeVisible(); - // Search bar should be visible on mobile - await expect(mobile.mobileSearchBar).toBeVisible(); + // Search is collapsed behind a topbar icon by default + await expect(mobile.mobileSearchToggle).toBeVisible(); + await expect(mobile.mobileSearchBar).not.toBeVisible(); // Task card should be visible await expect(mobile.taskCardByTitle("Mobile Layout Task")).toBeVisible(); }); + test("search toggle reveals and hides the search input", async ({ + testPage, + apiClient, + seedData, + }) => { + await apiClient.createTask(seedData.workspaceId, "Toggle Visible Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + const mobile = new MobileKanbanPage(testPage); + await mobile.goto(); + + // Hidden by default, revealed when the topbar search icon is tapped + await expect(mobile.mobileSearchBar).not.toBeVisible(); + await mobile.openSearch(); + await expect(mobile.mobileSearchBar).toBeVisible(); + // Input is focused on reveal so the keyboard opens immediately + await expect(mobile.searchInput()).toBeFocused(); + + // Tapping the icon again collapses the search bar + await mobile.mobileSearchToggle.click(); + await expect(mobile.mobileSearchBar).not.toBeVisible(); + }); + + test("collapsing search clears an active query", async ({ testPage, apiClient, seedData }) => { + await apiClient.createTask(seedData.workspaceId, "Clearable Alpha", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await apiClient.createTask(seedData.workspaceId, "Other Beta", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + const mobile = new MobileKanbanPage(testPage); + await mobile.goto(); + + await mobile.openSearch(); + await mobile.searchInput().fill("Alpha"); + await expect(mobile.taskCardByTitle("Other Beta")).not.toBeVisible({ timeout: 5000 }); + + // Collapsing clears the query so the full list is shown again + await mobile.mobileSearchToggle.click(); + await expect(mobile.mobileSearchBar).not.toBeVisible(); + await expect(mobile.taskCardByTitle("Clearable Alpha")).toBeVisible({ timeout: 5000 }); + await expect(mobile.taskCardByTitle("Other Beta")).toBeVisible({ timeout: 5000 }); + }); + test("shows mobile menu via hamburger button", async ({ testPage }) => { const mobile = new MobileKanbanPage(testPage); await mobile.goto(); @@ -100,7 +150,8 @@ test.describe("Mobile kanban view", () => { await expect(mobile.taskCardByTitle("Searchable Alpha")).toBeVisible(); await expect(mobile.taskCardByTitle("Hidden Beta")).toBeVisible(); - // Type in search + // Reveal the search input from the topbar, then type in it + await mobile.openSearch(); await mobile.searchInput().fill("Alpha"); // Only matching task should remain visible diff --git a/apps/web/e2e/tests/task/mobile-task-list-search.spec.ts b/apps/web/e2e/tests/task/mobile-task-list-search.spec.ts new file mode 100644 index 0000000000..bdb64a1ca0 --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-task-list-search.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from "../../fixtures/test-base"; + +test.describe("Mobile task list search", () => { + test("topbar search icon reveals, filters, and clears on collapse", async ({ + testPage, + apiClient, + seedData, + }) => { + await apiClient.createTask(seedData.workspaceId, "List Alpha Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await apiClient.createTask(seedData.workspaceId, "List Beta Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + await testPage.goto("/tasks"); + await testPage.waitForLoadState("networkidle"); + + const searchBar = testPage.getByTestId("mobile-search-bar"); + const searchToggle = testPage.getByTestId("mobile-search-toggle"); + + // Both tasks are listed and search is collapsed behind the topbar icon + await expect(testPage.getByText("List Alpha Task")).toBeVisible(); + await expect(testPage.getByText("List Beta Task")).toBeVisible(); + await expect(searchBar).not.toBeVisible(); + + // Tapping the icon reveals the focused search input + await searchToggle.click(); + await expect(searchBar).toBeVisible(); + await expect(searchBar.getByPlaceholder("Search tasks...")).toBeFocused(); + + // Typing filters the list down to the match + await searchBar.getByPlaceholder("Search tasks...").fill("Alpha"); + await expect(testPage.getByText("List Alpha Task")).toBeVisible({ timeout: 5000 }); + await expect(testPage.getByText("List Beta Task")).not.toBeVisible({ timeout: 5000 }); + + // Collapsing clears the query so the full list returns + await searchToggle.click(); + await expect(searchBar).not.toBeVisible(); + await expect(testPage.getByText("List Alpha Task")).toBeVisible({ timeout: 5000 }); + await expect(testPage.getByText("List Beta Task")).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index 9a74e8ea8f..16921e2211 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -38,6 +38,7 @@ export type ConnectionState = { export type MobileKanbanState = { activeColumnIndex: number; isMenuOpen: boolean; + isSearchOpen: boolean; }; export type MobileSessionPanel = "chat" | "plan" | "changes" | "files" | "terminal"; @@ -150,6 +151,7 @@ export type UISliceActions = { setConnectionStatus: (status: ConnectionState["status"], error?: string | null) => void; setMobileKanbanColumnIndex: (index: number) => void; setMobileKanbanMenuOpen: (open: boolean) => void; + setMobileKanbanSearchOpen: (open: boolean) => void; setMobileSessionPanel: (sessionId: string, panel: MobileSessionPanel) => void; setMobileSessionTaskSwitcherOpen: (open: boolean) => void; setPlanMode: (sessionId: string, enabled: boolean) => void; diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index a4bb476215..6f8c3a2c49 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -109,7 +109,7 @@ export const defaultUIState: UISliceState = { rightPanel: { activeTabBySessionId: {} }, diffs: { files: [] }, connection: { status: "disconnected", error: null }, - mobileKanban: { activeColumnIndex: 0, isMenuOpen: false }, + mobileKanban: { activeColumnIndex: 0, isMenuOpen: false, isSearchOpen: false }, mobileSession: { activePanelBySessionId: {}, isTaskSwitcherOpen: false }, chatInput: { planModeBySessionId: {} }, documentPanel: { activeDocumentBySessionId: {} }, @@ -183,6 +183,10 @@ function buildMobileActions(set: ImmerSet) { set((draft) => { draft.mobileKanban.isMenuOpen = open; }), + setMobileKanbanSearchOpen: (open: boolean) => + set((draft) => { + draft.mobileKanban.isSearchOpen = open; + }), setMobileSessionPanel: ( sessionId: string, panel: UISliceState["mobileSession"]["activePanelBySessionId"][string], diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index b03d9d1e12..d7620cbb46 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -330,6 +330,7 @@ export type AppState = { setConnectionStatus: (status: ConnectionState["status"], error?: string | null) => void; setMobileKanbanColumnIndex: (index: number) => void; setMobileKanbanMenuOpen: (open: boolean) => void; + setMobileKanbanSearchOpen: (open: boolean) => void; setMobileSessionPanel: (sessionId: string, panel: UISliceTypes.MobileSessionPanel) => void; setMobileSessionTaskSwitcherOpen: (open: boolean) => void; setPlanMode: (sessionId: string, enabled: boolean) => void;