mission-control/hooks/useSearch.ts
OpenClaw Bot 0092b318c2 feat(search): Enhance Mission Control search functionality
- Add search result highlighting with match positions
- Implement advanced filters (type, status, date range)
- Add relevance scoring algorithm with recency/status boosts
- Add search history with localStorage persistence
- Create useSearch and useSearchHistory hooks
- Add filter UI with popover component
- Improve visual feedback and status icons

Task: 56ae2be4-fcf1-403a-87fb-ea9de966f456
2026-02-25 15:28:47 -06:00

303 lines
8.0 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef } from "react";
export type SearchResultType = "task" | "project" | "document" | "sprint";
export interface SearchResultHighlight {
field: string;
text: string;
matches: Array<{ start: number; end: number }>;
}
export interface SearchFilters {
types?: SearchResultType[];
status?: string[];
dateFrom?: string;
dateTo?: string;
}
export interface SearchResult {
id: string;
type: SearchResultType;
title: string;
snippet?: string;
url: string;
icon: string;
status?: string;
color?: string;
highlights?: SearchResultHighlight[];
score: number;
updatedAt?: string;
createdAt?: string;
}
export interface SearchState {
query: string;
results: SearchResult[];
loading: boolean;
error: string | null;
filters: SearchFilters;
executionTimeMs?: number;
}
export interface UseSearchOptions {
debounceMs?: number;
minQueryLength?: number;
onResults?: (results: SearchResult[]) => void;
onError?: (error: string) => void;
}
export function useSearch(options: UseSearchOptions = {}) {
const { debounceMs = 150, minQueryLength = 2, onResults, onError } = options;
const [state, setState] = useState<SearchState>({
query: "",
results: [],
loading: false,
error: null,
filters: {},
});
const abortControllerRef = useRef<AbortController | null>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const setQuery = useCallback((query: string) => {
setState(prev => ({ ...prev, query }));
}, []);
const setFilters = useCallback((filters: SearchFilters) => {
setState(prev => ({ ...prev, filters }));
}, []);
const clearFilters = useCallback(() => {
setState(prev => ({ ...prev, filters: {} }));
}, []);
const search = useCallback(async () => {
const { query, filters } = state;
if (!query || query.length < minQueryLength) {
setState(prev => ({ ...prev, results: [], loading: false, error: null }));
return;
}
// Cancel any pending request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const params = new URLSearchParams();
params.set("q", query);
if (filters.types?.length) params.set("types", filters.types.join(","));
if (filters.status?.length) params.set("status", filters.status.join(","));
if (filters.dateFrom) params.set("dateFrom", filters.dateFrom);
if (filters.dateTo) params.set("dateTo", filters.dateTo);
const response = await fetch(`/api/search?${params.toString()}`, {
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
throw new Error(`Search failed with status ${response.status}`);
}
const data = await response.json();
const newState: SearchState = {
query,
results: data.results || [],
loading: false,
error: null,
filters,
executionTimeMs: data.executionTimeMs,
};
setState(newState);
onResults?.(data.results || []);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
return; // Request was cancelled, ignore
}
const errorMessage = err instanceof Error ? err.message : "Search failed";
setState(prev => ({ ...prev, loading: false, error: errorMessage }));
onError?.(errorMessage);
}
}, [state.query, state.filters, minQueryLength, onResults, onError]);
// Debounced search effect
useEffect(() => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
search();
}, debounceMs);
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, [state.query, state.filters, search, debounceMs]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
return {
...state,
setQuery,
setFilters,
clearFilters,
refresh: search,
};
}
// Hook for search history management
const SEARCH_HISTORY_KEY = "mission-control-search-history";
const MAX_HISTORY_ITEMS = 10;
export interface SearchHistoryItem {
query: string;
filters?: SearchFilters;
timestamp: number;
resultCount?: number;
}
export function useSearchHistory() {
const [history, setHistory] = useState<SearchHistoryItem[]>([]);
const [isLoaded, setIsLoaded] = useState(false);
// Load history from localStorage on mount
useEffect(() => {
try {
const saved = localStorage.getItem(SEARCH_HISTORY_KEY);
if (saved) {
const parsed = JSON.parse(saved);
// Filter out items older than 30 days
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
const filtered = parsed.filter((item: SearchHistoryItem) => item.timestamp > thirtyDaysAgo);
setHistory(filtered);
}
} catch (e) {
console.error("Failed to load search history:", e);
}
setIsLoaded(true);
}, []);
// Save history to localStorage whenever it changes
useEffect(() => {
if (isLoaded) {
try {
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
} catch (e) {
console.error("Failed to save search history:", e);
}
}
}, [history, isLoaded]);
const addToHistory = useCallback((item: Omit<SearchHistoryItem, "timestamp">) => {
if (!item.query.trim()) return;
setHistory(prev => {
// Remove duplicate queries
const filtered = prev.filter(h => h.query.toLowerCase() !== item.query.toLowerCase());
// Add new item at the beginning
const newItem: SearchHistoryItem = {
...item,
timestamp: Date.now(),
};
return [newItem, ...filtered].slice(0, MAX_HISTORY_ITEMS);
});
}, []);
const removeFromHistory = useCallback((index: number) => {
setHistory(prev => prev.filter((_, i) => i !== index));
}, []);
const clearHistory = useCallback(() => {
setHistory([]);
}, []);
return {
history,
isLoaded,
addToHistory,
removeFromHistory,
clearHistory,
};
}
// Utility function to highlight search matches
export function createHighlightedText(
text: string,
query: string,
maxLength: number = 150
): { text: string; matches: Array<{ start: number; end: number }> } {
if (!text || !query) {
return {
text: text?.length > maxLength ? text.substring(0, maxLength) + "..." : text || "",
matches: [],
};
}
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
const matches: Array<{ start: number; end: number }> = [];
let index = 0;
while ((index = lowerText.indexOf(lowerQuery, index)) !== -1) {
matches.push({ start: index, end: index + query.length });
index += query.length;
}
if (matches.length === 0) {
return {
text: text.length > maxLength ? text.substring(0, maxLength) + "..." : text,
matches: [],
};
}
// Center snippet around first match
const firstMatch = matches[0];
const contextSize = Math.floor((maxLength - query.length) / 2);
let start = Math.max(0, firstMatch.start - contextSize);
let end = Math.min(text.length, firstMatch.end + contextSize);
if (start === 0) {
end = Math.min(text.length, maxLength);
} else if (end === text.length) {
start = Math.max(0, text.length - maxLength);
}
const prefix = start > 0 ? "..." : "";
const suffix = end < text.length ? "..." : "";
const adjustedMatches = matches
.filter(m => m.start >= start && m.end <= end)
.map(m => ({
start: m.start - start + prefix.length,
end: m.end - start + prefix.length,
}));
return {
text: prefix + text.substring(start, end) + suffix,
matches: adjustedMatches,
};
}