Files
kandev/apps/web/hooks/use-command-panel-task-results.ts
T
2026-09-03 08:08:12 +00:00

197 lines
6.5 KiB
TypeScript

"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<string, number>();
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<string> | null;
stepPositionMap: Map<string, number>;
resultLimit: number;
};
/** Unarchived task preview for the idle palette, ordered by workflow step. */
async function fetchActiveTasks(query: ActiveTaskQuery): Promise<Task[]> {
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<Task[]> {
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<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(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,
]);
}