"use client"; import { useEffect, useMemo, useRef } from "react"; import { listTasksByWorkspace } from "@/lib/api"; import type { CommandPanelMode } from "@/lib/commands/types"; import type { Task } from "@/lib/types/http"; import { MODE_COMMANDS, MODE_SEARCH_TASKS } from "@/components/command-panel-results"; /** Task rows previewed alongside the commands in the commands scope. */ const TASK_PREVIEW_SIZE = 5; /** Task rows listed in the dedicated tasks scope. */ const TASK_SCOPE_PAGE_SIZE = 20; function resolveVisibleStepIds(steps: { id: string; show_in_command_panel?: boolean }[]) { if (steps.length === 0) return null; // no steps loaded yet — don't filter return new Set(steps.filter((s) => s.show_in_command_panel !== false).map((s) => s.id)); } type InlineTaskSearchOptions = { mode: CommandPanelMode; search: string; open: boolean; workspaceId: string | null; steps: { id: string; position: number; show_in_command_panel?: boolean }[]; setTaskResults: (tasks: Task[]) => void; setIsSearching: (searching: boolean) => void; }; function useStepMaps(steps: InlineTaskSearchOptions["steps"]) { const visibleStepIds = useMemo(() => resolveVisibleStepIds(steps), [steps]); const stepPositionMap = useMemo(() => { const map = new Map(); for (const step of steps) map.set(step.id, step.position); return map; }, [steps]); return { visibleStepIds, stepPositionMap }; } type ActiveTaskQuery = { workspaceId: string; signal: AbortSignal; visibleStepIds: Set | null; stepPositionMap: Map; resultLimit: number; }; /** Unarchived task preview for the idle palette, ordered by workflow step. */ async function fetchActiveTasks(query: ActiveTaskQuery): Promise { const { workspaceId, signal, visibleStepIds, stepPositionMap, resultLimit } = query; // Deliberately over-fetched: the step and archived filters below run on the // response, so requesting only `resultLimit` rows would show fewer active // tasks than exist whenever the first page is mostly backlog or done. const res = await listTasksByWorkspace( workspaceId, { page: 1, pageSize: TASK_SCOPE_PAGE_SIZE }, { init: { signal } }, ); const tasks = (res.tasks ?? []).filter( (t) => (!visibleStepIds || visibleStepIds.has(t.workflow_step_id)) && t.archived_at == null, ); tasks.sort( (a, b) => (stepPositionMap.get(a.workflow_step_id) ?? 99) - (stepPositionMap.get(b.workflow_step_id) ?? 99), ); return tasks.slice(0, resultLimit); } /** Task search results, with archived matches fetched only as a fallback. */ async function fetchMatchingTasks( workspaceId: string, query: string, resultLimit: number, signal: AbortSignal, ): Promise { const activeResponse = await listTasksByWorkspace( workspaceId, { query, page: 1, pageSize: resultLimit }, { init: { signal } }, ); const activeTasks = (activeResponse.tasks ?? []).filter((task) => task.archived_at == null); if (activeTasks.length >= resultLimit) return activeTasks.slice(0, resultLimit); const archivedResponse = await listTasksByWorkspace( workspaceId, { query, page: 1, pageSize: resultLimit - activeTasks.length, onlyArchived: true, }, { init: { signal } }, ); const archivedTasks = (archivedResponse.tasks ?? []).filter((task) => task.archived_at != null); return [...activeTasks, ...archivedTasks].slice(0, resultLimit); } export function useInlineTaskSearchEffect(opts: InlineTaskSearchOptions) { const { mode, search, open, workspaceId, steps, setTaskResults, setIsSearching } = opts; const debounceRef = useRef | null>(null); const abortRef = useRef(null); const taskScopeRef = useRef(mode); const { visibleStepIds, stepPositionMap } = useStepMaps(steps); useEffect(() => { if (mode !== MODE_COMMANDS && mode !== MODE_SEARCH_TASKS) return; // The commands scope only previews tasks alongside the commands; the tasks // scope is the one that lists the full result set. const resultLimit = mode === MODE_SEARCH_TASKS ? TASK_SCOPE_PAGE_SIZE : TASK_PREVIEW_SIZE; // The two scopes size their result sets differently, so carrying one over // would present a five-row preview as the tasks scope's full result set // (and suppress its loading state) until the wider request lands. if (taskScopeRef.current !== mode) { taskScopeRef.current = mode; setTaskResults([]); } if (debounceRef.current) clearTimeout(debounceRef.current); abortRef.current?.abort(); // No search: load unarchived tasks in the visible workflow steps. if (!search.trim()) { if (!open || !workspaceId) { setTaskResults([]); setIsSearching(false); return; } setIsSearching(true); const controller = new AbortController(); abortRef.current = controller; fetchActiveTasks({ workspaceId, signal: controller.signal, visibleStepIds, stepPositionMap, resultLimit, }) .then((tasks) => { if (!controller.signal.aborted) setTaskResults(tasks); }) .catch(() => { if (!controller.signal.aborted) setTaskResults([]); }) .finally(() => { if (!controller.signal.aborted) setIsSearching(false); }); return () => { controller.abort(); }; } // Search with < 2 chars: clear results if (search.trim().length < 2) { setTaskResults([]); setIsSearching(false); return; } // Search: query API including archived setIsSearching(true); debounceRef.current = setTimeout(async () => { if (!workspaceId) { setIsSearching(false); return; } const controller = new AbortController(); abortRef.current = controller; try { const tasks = await fetchMatchingTasks( workspaceId, search.trim(), resultLimit, controller.signal, ); if (!controller.signal.aborted) setTaskResults(tasks); } catch { if (!controller.signal.aborted) setTaskResults([]); } finally { if (!controller.signal.aborted) setIsSearching(false); } }, 300); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); abortRef.current?.abort(); }; }, [ mode, search, open, workspaceId, visibleStepIds, stepPositionMap, setTaskResults, setIsSearching, ]); }