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;