feat(KB-089): add board search functionality

- Add search state and filter logic to Board component (id, title, description)

- Add search input to Header with search icon and clear button

- Wire search query state through App component

- Add CSS styles for search input and visual states

- Include changeset for patch release
This commit is contained in:
gsxdsm
2026-03-29 23:07:51 -07:00
parent 1a245b4f2b
commit 94f599a3df
5 changed files with 136 additions and 4 deletions

View File

@@ -41,6 +41,7 @@ function AppInner() {
}
return "board";
});
const [searchQuery, setSearchQuery] = useState("");
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask } = useTasks();
@@ -179,6 +180,8 @@ function AppInner() {
onToggleEnginePause={handleToggleEnginePause}
view={view}
onChangeView={handleChangeView}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
{view === "board" ? (
<Board
@@ -195,6 +198,7 @@ function AppInner() {
onUpdateTask={updateTask}
onArchiveTask={archiveTask}
onUnarchiveTask={unarchiveTask}
searchQuery={searchQuery}
/>
) : (
<ListView

View File

@@ -2,7 +2,7 @@ import type { Task, TaskDetail, TaskCreateInput, Column as ColumnType } from "@k
import { COLUMNS } from "@kb/core";
import { Column } from "./Column";
import type { ToastType } from "../hooks/useToast";
import { useState } from "react";
import { useState, useMemo } from "react";
interface BoardProps {
tasks: Task[];
@@ -21,18 +21,31 @@ interface BoardProps {
) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
searchQuery?: string;
}
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask }: BoardProps) {
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, searchQuery = "" }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
// Filter tasks based on search query (matches id, title, or description)
const filteredTasks = useMemo(() => {
if (!searchQuery.trim()) return tasks;
const query = searchQuery.toLowerCase();
return tasks.filter(
(t) =>
t.id.toLowerCase().includes(query) ||
(t.title && t.title.toLowerCase().includes(query)) ||
t.description.toLowerCase().includes(query)
);
}, [tasks, searchQuery]);
return (
<main className="board" id="board">
{COLUMNS.map((col) => (
<Column
key={col}
column={col}
tasks={tasks
tasks={filteredTasks
.filter((t) => t.column === col)
.sort((a, b) => {
// Tasks with columnMovedAt sort descending (most recent first)

View File

@@ -1,4 +1,4 @@
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal, Lightbulb } from "lucide-react";
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal, Lightbulb, Search, X } from "lucide-react";
interface HeaderProps {
onOpenSettings?: () => void;
@@ -11,6 +11,8 @@ interface HeaderProps {
onToggleEnginePause?: () => void;
view?: "board" | "list";
onChangeView?: (view: "board" | "list") => void;
searchQuery?: string;
onSearchChange?: (query: string) => void;
}
export function Header({
@@ -24,6 +26,8 @@ export function Header({
onToggleEnginePause,
view = "board",
onChangeView,
searchQuery = "",
onSearchChange,
}: HeaderProps) {
return (
<header className="header">
@@ -56,6 +60,28 @@ export function Header({
</button>
</div>
)}
{/* Search - only show in board view */}
{onSearchChange && view === "board" && (
<div className="header-search">
<Search size={14} className="header-search-icon" />
<input
type="text"
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="header-search-input"
/>
{searchQuery && (
<button
className="header-search-clear"
onClick={() => onSearchChange("")}
aria-label="Clear search"
>
<X size={14} />
</button>
)}
</div>
)}
{/* Import from GitHub */}
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
<Download size={16} />

View File

@@ -171,6 +171,90 @@ body {
color: var(--bg);
}
/* Header Search */
.header-search {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 160px;
max-width: 280px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 12px;
transition: border-color var(--transition-fast);
}
.header-search:focus-within {
border-color: var(--todo);
}
.header-search-icon {
color: var(--text-muted);
flex-shrink: 0;
}
.header-search-input {
flex: 1;
background: none;
border: none;
color: var(--text);
font-size: 13px;
font-family: inherit;
outline: none;
min-width: 0;
}
.header-search-input::placeholder {
color: var(--text-dim);
}
.header-search-clear {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 2px;
border-radius: 4px;
flex-shrink: 0;
transition: color var(--transition-fast), background var(--transition-fast);
}
.header-search-clear:hover {
color: var(--text);
background: var(--border);
}
/* Mobile responsive: hide search on very small screens */
@media (max-width: 640px) {
.header-search {
max-width: 160px;
min-width: 120px;
padding: 6px 8px;
}
.header-search-input {
font-size: 12px;
}
}
@media (max-width: 480px) {
.header-search {
order: 1;
width: 100%;
max-width: none;
margin-top: 8px;
}
.header-actions {
flex-wrap: wrap;
}
}
.logo {
font-size: 20px;
font-weight: 700;