feat(FN-1260): add full-text search for tasks and comments

- Add FTS5 virtual table with v21 database migration for task search
- Add searchTasks() method to TaskStore with FTS5 query support
- Add q= search parameter to GET /api/tasks route for server-side search
- Update useTasks hook and frontend API to support searchQuery prop
- Update Board.tsx and App.tsx to pass searchQuery through component hierarchy
- Add comprehensive tests for FTS5 index and searchTasks functionality
This commit is contained in:
gsxdsm
2026-04-09 10:17:02 -07:00
parent 6232b38937
commit dcbad95fd6
13 changed files with 571 additions and 53 deletions

View File

@@ -41,9 +41,12 @@ function AppInner() {
const { nodes } = useNodes();
const { currentProject, setCurrentProject, clearCurrentProject, loading: currentProjectLoading } = useCurrentProject(projects);
// Tasks hook with project context
// Search query state - must be defined before useTasks
const [searchQuery, setSearchQuery] = useState("");
// Tasks hook with project context and search query
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone } = useTasks(
currentProject ? { projectId: currentProject.id } : undefined
currentProject ? { projectId: currentProject.id, searchQuery: searchQuery || undefined } : { searchQuery: searchQuery || undefined }
);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
@@ -106,7 +109,6 @@ function AppInner() {
toggleGlobalPause,
toggleEnginePause,
} = useAppSettings(currentProject?.id);
const [searchQuery, setSearchQuery] = useState("");
const {
availableModels,
favoriteProviders,

View File

@@ -93,11 +93,12 @@ async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T
return data as T;
}
export function fetchTasks(limit?: number, offset?: number, projectId?: string): Promise<Task[]> {
export function fetchTasks(limit?: number, offset?: number, projectId?: string, q?: string): Promise<Task[]> {
const search = new URLSearchParams();
if (limit !== undefined) search.set("limit", String(limit));
if (offset !== undefined) search.set("offset", String(offset));
if (projectId) search.set("projectId", projectId);
if (q) search.set("q", q);
const suffix = search.size > 0 ? `?${search.toString()}` : "";
return api<Task[]>(`/tasks${suffix}`);
}

View File

@@ -81,18 +81,8 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
setArchivedCollapsed((current) => !current);
}, []);
// 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]);
// Tasks are already server-filtered when searchQuery is active (via useTasks hook).
// Client-side filtering is removed - tasks prop is used directly.
// Keep per-column array identities stable for unchanged columns so React.memo(Column)
// can skip sibling rerenders during unrelated task updates.
const tasksByColumn = useMemo(() => {
@@ -100,7 +90,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
COLUMNS.map((column) => [column, [] as Task[]]),
) as Record<ColumnType, Task[]>;
for (const task of filteredTasks) {
for (const task of tasks) {
nextGrouped[task.column].push(task);
}
@@ -116,14 +106,14 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
tasksByColumnCacheRef.current = stableGrouped;
return stableGrouped;
}, [filteredTasks]);
}, [tasks]);
// Collect task IDs with GitHub badge info for batch fetching
const taskIdsWithBadges = useMemo(() => {
return filteredTasks
return tasks
.filter((t) => t.prInfo || t.issueInfo)
.map((t) => t.id);
}, [filteredTasks]);
}, [tasks]);
// Batch fetch badge statuses on mount and when visible tasks change
useEffect(() => {
@@ -171,7 +161,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
onUpdateTask={onUpdateTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
allTasks={filteredTasks}
allTasks={tasks}
availableModels={availableModels}
onOpenDetailWithTab={onOpenDetailWithTab}
favoriteProviders={favoriteProviders}

View File

@@ -59,6 +59,11 @@ const mockUseTasks = vi.fn(() => ({
archiveAllDone: vi.fn(),
}));
// Accept both old and new hook signatures
vi.mock("../../hooks/useTasks", () => ({
useTasks: (options?: { projectId?: string; searchQuery?: string }) => mockUseTasks(options),
}));
vi.mock("../../hooks/useTasks", () => ({
useTasks: () => mockUseTasks(),
}));

View File

@@ -107,14 +107,17 @@ describe("Board", () => {
...overrides,
});
it("filters tasks by ID when search query is provided", () => {
it("renders server-filtered tasks by ID when search query is provided", () => {
const tasks: Task[] = [
createTask({ id: "FN-001", description: "First task", column: "todo" }),
createTask({ id: "FN-002", description: "Second task", column: "todo" }),
createTask({ id: "FN-003", description: "Third task", column: "in-progress" }),
];
renderBoard({ tasks, searchQuery: "FN-002" });
// Pre-filtered tasks - only FN-002 matches the search
const filteredTasks = [tasks[1]];
renderBoard({ tasks: filteredTasks, searchQuery: "FN-002" });
const todoColumn = screen.getByTestId("column-todo");
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
@@ -126,14 +129,17 @@ describe("Board", () => {
expect(inProgressTasks).toHaveLength(0);
});
it("filters tasks by title when search query is provided", () => {
it("renders server-filtered tasks by title when search query is provided", () => {
const tasks: Task[] = [
createTask({ id: "FN-001", title: "Fix login bug", description: "First task", column: "todo" }),
createTask({ id: "FN-002", title: "Add dashboard feature", description: "Second task", column: "todo" }),
createTask({ id: "FN-003", title: "Update documentation", description: "Third task", column: "todo" }),
];
renderBoard({ tasks, searchQuery: "dashboard" });
// Pre-filtered tasks - only dashboard matches
const filteredTasks = [tasks[1]];
renderBoard({ tasks: filteredTasks, searchQuery: "dashboard" });
const todoColumn = screen.getByTestId("column-todo");
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
@@ -141,14 +147,17 @@ describe("Board", () => {
expect(todoTasks[0].id).toBe("FN-002");
});
it("filters tasks by description when search query is provided", () => {
it("renders server-filtered tasks by description when search query is provided", () => {
const tasks: Task[] = [
createTask({ id: "FN-001", description: "Implement user authentication", column: "todo" }),
createTask({ id: "FN-002", description: "Fix database connection issue", column: "todo" }),
createTask({ id: "FN-003", description: "Add caching layer", column: "todo" }),
];
renderBoard({ tasks, searchQuery: "database" });
// Pre-filtered tasks - only database matches
const filteredTasks = [tasks[1]];
renderBoard({ tasks: filteredTasks, searchQuery: "database" });
const todoColumn = screen.getByTestId("column-todo");
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
@@ -156,13 +165,16 @@ describe("Board", () => {
expect(todoTasks[0].id).toBe("FN-002");
});
it("search is case-insensitive", () => {
it("search is case-insensitive (server handles this)", () => {
const tasks: Task[] = [
createTask({ id: "FN-001", title: "Fix Login Bug", description: "First task", column: "todo" }),
createTask({ id: "FN-002", title: "Add Dashboard Feature", description: "Second task", column: "todo" }),
];
renderBoard({ tasks, searchQuery: "login" });
// Pre-filtered tasks - only FN-001 matches
const filteredTasks = [tasks[0]];
renderBoard({ tasks: filteredTasks, searchQuery: "login" });
const todoColumn = screen.getByTestId("column-todo");
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
@@ -170,12 +182,15 @@ describe("Board", () => {
expect(todoTasks[0].id).toBe("FN-001");
});
it("search is case-insensitive for lowercase query matching uppercase content", () => {
it("search is case-insensitive for lowercase query matching uppercase content (server handles this)", () => {
const tasks: Task[] = [
createTask({ id: "FN-UPPER", title: "UPPERCASE TITLE", description: "DESC", column: "todo" }),
];
renderBoard({ tasks, searchQuery: "upper" });
// Pre-filtered tasks - FN-UPPER matches
const filteredTasks = [tasks[0]];
renderBoard({ tasks: filteredTasks, searchQuery: "upper" });
const todoColumn = screen.getByTestId("column-todo");
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
@@ -201,13 +216,16 @@ describe("Board", () => {
expect(inProgressTasks).toHaveLength(1);
});
it("shows no tasks when search query matches nothing", () => {
it("shows no tasks when search query matches nothing (server returns empty)", () => {
const tasks: Task[] = [
createTask({ id: "FN-001", description: "First task", column: "todo" }),
createTask({ id: "FN-002", description: "Second task", column: "todo" }),
];
renderBoard({ tasks, searchQuery: "nonexistent" });
// Pre-filtered tasks - empty array because server found no matches
const filteredTasks: Task[] = [];
renderBoard({ tasks: filteredTasks, searchQuery: "nonexistent" });
const todoColumn = screen.getByTestId("column-todo");
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
@@ -299,24 +317,27 @@ describe("Board", () => {
expect(todoTasks[2].id).toBe("FN-003");
});
it("matches tasks across multiple fields simultaneously", () => {
it("renders server-filtered tasks matching across multiple fields simultaneously", () => {
const tasks: Task[] = [
createTask({ id: "SEARCH-123", title: "Searchable title", description: "Normal description", column: "todo" }),
createTask({ id: "FN-999", title: "Other task", description: "This has searchable content", column: "todo" }),
createTask({ id: "FN-888", title: "Unrelated", description: "No match here", column: "todo" }),
];
renderBoard({ tasks, searchQuery: "search" });
// Pre-filtered tasks - only the two matching tasks
const filteredTasks = [tasks[0], tasks[1]];
renderBoard({ tasks: filteredTasks, searchQuery: "search" });
const todoColumn = screen.getByTestId("column-todo");
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
// Should match both tasks with "search" in ID, title, or description
// Should have both matching tasks
expect(todoTasks).toHaveLength(2);
expect(todoTasks.map((t: Task) => t.id).sort()).toEqual(["FN-999", "SEARCH-123"]);
});
it("trims whitespace from search query", () => {
it("shows all tasks for whitespace-only search query (server treats as empty)", () => {
const tasks: Task[] = [
createTask({ id: "FN-001", description: "First task", column: "todo" }),
];

View File

@@ -34,10 +34,16 @@ export interface UseTasksOptions {
* Note: SSE updates are not filtered by project in current implementation.
*/
projectId?: string;
/**
* When provided, fetches tasks matching this search query.
* Server-side full-text search across title, ID, description, and comments.
*/
searchQuery?: string;
}
export function useTasks(options?: UseTasksOptions) {
const projectId = options?.projectId;
const searchQuery = options?.searchQuery;
const [tasks, setTasks] = useState<Task[]>([]);
const [connectionNonce, setConnectionNonce] = useState(0);
const tasksRef = useRef(tasks);
@@ -47,11 +53,12 @@ export function useTasks(options?: UseTasksOptions) {
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
const refreshTasks = useCallback(async (options?: { clearOnError?: boolean }) => {
const refreshTasks = useCallback(async (options?: { clearOnError?: boolean; searchQueryOverride?: string }) => {
const requestVersion = ++fetchVersionRef.current;
const query = options?.searchQueryOverride ?? searchQuery;
try {
const fetchedTasks = await api.fetchTasks(undefined, undefined, projectId);
const fetchedTasks = await api.fetchTasks(undefined, undefined, projectId, query);
if (fetchVersionRef.current !== requestVersion) {
return;
}
@@ -66,7 +73,16 @@ export function useTasks(options?: UseTasksOptions) {
}
setTasks((current) => current);
}
}, [projectId]);
}, [projectId, searchQuery]);
// Debounced search effect - separate from refreshTasks to avoid dependency cycle
useEffect(() => {
if (searchQuery === undefined) return;
const timer = setTimeout(() => {
void refreshTasks({ searchQueryOverride: searchQuery });
}, 300);
return () => clearTimeout(timer);
}, [searchQuery]); // intentionally NOT including refreshTasks in deps
// Fetch initial tasks and recover when the tab becomes visible again.
useEffect(() => {
@@ -124,6 +140,11 @@ export function useTasks(options?: UseTasksOptions) {
const handleCreated = (e: MessageEvent) => {
resetHeartbeat();
const task = normalizeTask(JSON.parse(e.data) as Task);
// When search is active, re-fetch to get server-filtered results
if (searchQuery) {
void refreshTasks({ searchQueryOverride: searchQuery });
return;
}
// In project mode, only add if this task belongs to our project
// Since we can't determine project from event, we add and let subsequent
// fetches correct the state, or filter by checking if task exists in our set
@@ -136,6 +157,11 @@ export function useTasks(options?: UseTasksOptions) {
const handleMoved = (e: MessageEvent) => {
resetHeartbeat();
// When search is active, re-fetch to get server-filtered results
if (searchQuery) {
void refreshTasks({ searchQueryOverride: searchQuery });
return;
}
const { task, to }: { task: Task; from: Column; to: Column } = JSON.parse(e.data);
const normalizedTask = normalizeTask(task);
setTasks((prev) =>
@@ -147,6 +173,11 @@ export function useTasks(options?: UseTasksOptions) {
const handleUpdated = (e: MessageEvent) => {
resetHeartbeat();
// When search is active, re-fetch to get server-filtered results
if (searchQuery) {
void refreshTasks({ searchQueryOverride: searchQuery });
return;
}
const incoming = normalizeTask(JSON.parse(e.data) as Task);
setTasks((prev) =>
prev.map((t) => {
@@ -177,12 +208,22 @@ export function useTasks(options?: UseTasksOptions) {
const handleDeleted = (e: MessageEvent) => {
resetHeartbeat();
// When search is active, re-fetch to get server-filtered results
if (searchQuery) {
void refreshTasks({ searchQueryOverride: searchQuery });
return;
}
const task = normalizeTask(JSON.parse(e.data) as Task);
setTasks((prev) => prev.filter((t) => t.id !== task.id));
};
const handleMerged = (e: MessageEvent) => {
resetHeartbeat();
// When search is active, re-fetch to get server-filtered results
if (searchQuery) {
void refreshTasks({ searchQueryOverride: searchQuery });
return;
}
const { task }: { task: Task } = JSON.parse(e.data);
const normalizedTask = normalizeTask(task);
setTasks((prev) =>
@@ -237,7 +278,7 @@ export function useTasks(options?: UseTasksOptions) {
closedByCleanup = true;
cleanup();
};
}, [connectionNonce, projectId, refreshTasks]);
}, [connectionNonce, projectId, searchQuery, refreshTasks]);
const createTask = useCallback(async (input: TaskCreateInput): Promise<Task> => {
const task = normalizeTask(await api.createTask(input, projectId));