feat(web): collapse mobile task search into a topbar icon (#1157)

This commit is contained in:
Carlos Florêncio
2026-05-30 12:54:05 +01:00
committed by GitHub
parent 5cab91238b
commit 956e0a551d
13 changed files with 183 additions and 24 deletions
+1
View File
@@ -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
+13
View File
@@ -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 (
<div className="h-screen w-full flex flex-col bg-background">
<KanbanHeader
@@ -420,6 +430,9 @@ export function TasksPageClient(props: TasksPageClientProps) {
onSearchChange={s.setSearchQuery}
isSearchLoading={s.isLoading && !!s.debouncedQuery}
/>
{isMobile && isMobileSearchOpen && (
<MobileSearchBar searchQuery={s.searchQuery} onSearchChange={s.setSearchQuery} />
)}
<TasksPageBody
showArchived={s.showArchived}
setShowArchived={s.setShowArchived}
+7 -1
View File
@@ -327,6 +327,12 @@ function useKanbanBoardSetup(
export function KanbanBoard({ onPreviewTask, onOpenTask, onBeforeEdit }: KanbanBoardProps = {}) {
const s = useKanbanBoardSetup(onPreviewTask, onOpenTask, onBeforeEdit);
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]);
if (!s.isMounted) {
return <div className="h-dvh w-full bg-background" />;
@@ -347,7 +353,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, onBeforeEdit }: KanbanB
searchQuery={s.searchQuery}
onSearchChange={s.setSearchQuery}
/>
{s.isMobile && (
{s.isMobile && isMobileSearchOpen && (
<MobileSearchBar searchQuery={s.searchQuery} onSearchChange={s.setSearchQuery} />
)}
<KanbanBoardDialogs
@@ -1,7 +1,7 @@
"use client";
import { Button } from "@kandev/ui/button";
import { IconMenu2 } from "@tabler/icons-react";
import { IconMenu2, IconSearch } from "@tabler/icons-react";
import { PageTopbar } from "@/components/page-topbar";
import { MobileMenuSheet } from "./mobile-menu-sheet";
import { useAppStore } from "@/components/state-provider";
@@ -11,9 +11,7 @@ type KanbanHeaderMobileProps = {
currentPage?: "kanban" | "tasks";
title: string;
workspaceLabel: string;
searchQuery?: string;
onSearchChange?: (query: string) => 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={
<Button
variant="outline"
size="icon-lg"
onClick={() => setMenuOpen(true)}
className="cursor-pointer"
>
<IconMenu2 className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
<>
{onSearchChange && (
<Button
variant={isSearchOpen ? "secondary" : "outline"}
size="icon-lg"
onClick={toggleSearch}
className="cursor-pointer"
aria-pressed={isSearchOpen}
aria-label="Search tasks"
data-testid="mobile-search-toggle"
>
<IconSearch className="h-4 w-4" />
</Button>
)}
<Button
variant="outline"
size="icon-lg"
onClick={() => setMenuOpen(true)}
className="cursor-pointer"
>
<IconMenu2 className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</>
}
/>
<MobileMenuSheet
@@ -60,9 +80,6 @@ export function KanbanHeaderMobile({
onOpenChange={setMenuOpen}
workspaceId={workspaceId}
currentPage={currentPage}
searchQuery={searchQuery}
onSearchChange={onSearchChange}
isSearchLoading={isSearchLoading}
showReleaseNotesButton={showReleaseNotesButton}
onOpenReleaseNotes={onOpenReleaseNotes}
showHealthIndicator={showHealthIndicator}
+1 -1
View File
@@ -510,7 +510,7 @@ export function KanbanHeader({
currentPage={currentPage}
title={title}
workspaceLabel={workspaceLabel}
{...sharedSearch}
onSearchChange={onSearchChange}
{...indicatorProps}
/>
);
@@ -9,12 +9,13 @@ type MobileSearchBarProps = {
export function MobileSearchBar({ searchQuery, onSearchChange }: MobileSearchBarProps) {
return (
<div className="px-4 pb-2" data-testid="mobile-search-bar">
<div className="border-b border-border px-4 py-2" data-testid="mobile-search-bar">
<TaskSearchInput
value={searchQuery}
onChange={onSearchChange}
placeholder="Search tasks..."
className="w-full"
autoFocus
/>
</div>
);
@@ -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<NodeJS.Timeout | null>(null);
const inputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>) => {
const newValue = e.target.value;
@@ -73,6 +83,7 @@ export function TaskSearchInput({
<IconSearch className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
)}
<Input
ref={inputRef}
type="text"
value={localValue}
onChange={handleChange}
+7
View File
@@ -4,6 +4,7 @@ export class MobileKanbanPage {
readonly board: Locator;
readonly mobileFab: Locator;
readonly mobileSearchBar: Locator;
readonly mobileSearchToggle: Locator;
readonly mobileMenuButton: Locator;
readonly mobileTaskSheet: Locator;
readonly swimlaneContainer: Locator;
@@ -12,6 +13,7 @@ export class MobileKanbanPage {
this.board = page.getByTestId("kanban-board");
this.mobileFab = page.getByTestId("mobile-fab");
this.mobileSearchBar = page.getByTestId("mobile-search-bar");
this.mobileSearchToggle = page.getByTestId("mobile-search-toggle");
this.mobileMenuButton = page.getByRole("button", { name: "Open menu" });
this.mobileTaskSheet = page.getByTestId("mobile-task-sheet");
this.swimlaneContainer = page.getByTestId("swimlane-container");
@@ -46,6 +48,11 @@ export class MobileKanbanPage {
return this.mobileSearchBar.getByPlaceholder("Search tasks...");
}
async openSearch() {
await this.mobileSearchToggle.click();
await this.mobileSearchBar.waitFor({ state: "visible" });
}
sheetGoToSession(): Locator {
return this.mobileTaskSheet.getByRole("button", { name: /Open Session/ });
}
@@ -19,12 +19,62 @@ test.describe("Mobile kanban view", () => {
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
@@ -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 });
});
});
+2
View File
@@ -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;
+5 -1
View File
@@ -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],
+1
View File
@@ -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;