"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePathname } from "@/lib/routing/client-router"; import { useCommands, useCommandPanelOpen } from "@/lib/commands/command-registry"; import type { CommandPanelMode, CommandItem as CommandItemType } from "@/lib/commands/types"; import { findFirstMatchingCommand, selectCommandSearchResult } from "@/lib/commands/search"; import { useCommandPanelShortcuts } from "@/hooks/use-command-panel-shortcuts"; import { useContentSearchResultOpener } from "@/hooks/use-content-search-result-opener"; import { useWorkspaceContentSearch } from "@/hooks/domains/session/use-workspace-content-search"; import { useAppStore } from "@/components/state-provider"; import { isCommandPanelScopeMode, type CommandPanelScopeMode, } from "@/components/command-panel-scope-switcher"; import { listTasksByWorkspace } from "@/lib/api"; import type { Task } from "@/lib/types/http"; import type { FileSearchResult } from "@/lib/types/backend"; import { getWebSocketClient } from "@/lib/ws/connection"; import { searchWorkspaceFiles } from "@/lib/ws/workspace-files"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { getContentSearchResultValue } from "@/components/workspace-content-search"; import { getFileName } from "@/lib/utils/file-path"; import { isTaskWorkspaceSearchAvailable } from "@/lib/commands/task-workspace-search"; import { useCommandPanelTaskNavigation } from "@/hooks/use-command-panel-task-navigation"; import { CommandPanelView, MODE_COMMANDS, MODE_SEARCH_CONTENT, MODE_SEARCH_FILES, getFileResultValue, getTaskResultValue, } from "@/components/command-panel-footer"; function useCommandPanelState(mode: CommandPanelMode, setMode: (mode: CommandPanelMode) => void) { const [search, setSearch] = useState(""); const [inputCommand, setInputCommand] = useState(null); const [taskResults, setTaskResults] = useState([]); const [isSearching, setIsSearching] = useState(false); const [fileResults, setFileResults] = useState([]); const [isSearchingFiles, setIsSearchingFiles] = useState(false); const [selectedValue, setSelectedValue] = useState(""); return { mode, setMode, search, setSearch, inputCommand, setInputCommand, taskResults, setTaskResults, isSearching, setIsSearching, fileResults, setFileResults, isSearchingFiles, setIsSearchingFiles, selectedValue, setSelectedValue, }; } type FileSearchEffectOptions = { mode: CommandPanelMode; search: string; workspaceSearchAvailable: boolean; activeSessionId: string | null; setFileResults: (files: FileSearchResult[]) => void; setIsSearchingFiles: (searching: boolean) => void; fileDebounceRef: React.RefObject | null>; }; function useFileSearchEffect(opts: FileSearchEffectOptions) { const { mode, search, workspaceSearchAvailable, activeSessionId, setFileResults, setIsSearchingFiles, fileDebounceRef, } = opts; useEffect(() => { if ( !workspaceSearchAvailable || mode !== MODE_SEARCH_FILES || !search.trim() || !activeSessionId ) { setFileResults([]); setIsSearchingFiles(false); return; } setIsSearchingFiles(true); if (fileDebounceRef.current) clearTimeout(fileDebounceRef.current); let cancelled = false; fileDebounceRef.current = setTimeout(async () => { const client = getWebSocketClient(); if (!client || cancelled) { if (!cancelled) setIsSearchingFiles(false); return; } try { const res = await searchWorkspaceFiles(client, activeSessionId, search.trim(), 10); if (!cancelled) { const results = res.results ?? (res.files ?? []).map((path) => ({ path })); setFileResults(results); } } catch { if (!cancelled) setFileResults([]); } finally { if (!cancelled) setIsSearchingFiles(false); } }, 250); return () => { cancelled = true; if (fileDebounceRef.current) clearTimeout(fileDebounceRef.current); }; }, [ activeSessionId, fileDebounceRef, mode, search, setFileResults, setIsSearchingFiles, workspaceSearchAvailable, ]); } const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]); 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 }; } function useInlineTaskSearchEffect(opts: InlineTaskSearchOptions) { const { mode, search, open, workspaceId, steps, setTaskResults, setIsSearching } = opts; const debounceRef = useRef | null>(null); const abortRef = useRef(null); const { visibleStepIds, stepPositionMap } = useStepMaps(steps); useEffect(() => { if (mode !== MODE_COMMANDS) return; if (debounceRef.current) clearTimeout(debounceRef.current); abortRef.current?.abort(); // No search: load active tasks (excluding backlog + done steps) if (!search.trim()) { if (!open || !workspaceId) { setTaskResults([]); setIsSearching(false); return; } setIsSearching(true); const controller = new AbortController(); abortRef.current = controller; listTasksByWorkspace( workspaceId, { page: 1, pageSize: 20 }, { init: { signal: controller.signal } }, ) .then((res) => { if (controller.signal.aborted) return; const tasks = (res.tasks ?? []).filter( (t) => (!visibleStepIds || visibleStepIds.has(t.workflow_step_id)) && !ARCHIVED_STATES.has(t.state), ); tasks.sort( (a, b) => (stepPositionMap.get(a.workflow_step_id) ?? 99) - (stepPositionMap.get(b.workflow_step_id) ?? 99), ); 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 res = await listTasksByWorkspace( workspaceId, { query: search.trim(), page: 1, pageSize: 5, includeArchived: true }, { init: { signal: controller.signal } }, ); if (!controller.signal.aborted) { const tasks = res.tasks ?? []; tasks.sort((a, b) => { const aArchived = ARCHIVED_STATES.has(a.state) ? 1 : 0; const bArchived = ARCHIVED_STATES.has(b.state) ? 1 : 0; return aArchived - bArchived; }); 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, ]); } type CommandPanelEffectsOptions = { open: boolean; state: ReturnType; workspaceId: string | null; activeSessionId: string | null; workspaceSearchAvailable: boolean; steps: { id: string; position: number; show_in_command_panel?: boolean }[]; modeRequestVersion: number; }; function useCommandPanelEffects(options: CommandPanelEffectsOptions) { const { open, state, workspaceId, activeSessionId, workspaceSearchAvailable, steps, modeRequestVersion, } = options; const { mode, search, setMode, setSearch, setInputCommand, setTaskResults, setIsSearching, setFileResults, setIsSearchingFiles, setSelectedValue, } = state; const fileDebounceRef = useRef | null>(null); const previousRequestVersion = useRef(modeRequestVersion); useEffect(() => { if (previousRequestVersion.current === modeRequestVersion) return; previousRequestVersion.current = modeRequestVersion; setSearch(""); setInputCommand(null); setTaskResults([]); setFileResults([]); setSelectedValue(""); }, [ modeRequestVersion, setFileResults, setInputCommand, setSearch, setSelectedValue, setTaskResults, ]); useEffect(() => { if (!open) { const t = setTimeout(() => { setMode(MODE_COMMANDS); setSearch(""); setInputCommand(null); setTaskResults([]); setFileResults([]); setSelectedValue(""); }, 200); return () => clearTimeout(t); } }, [open, setMode, setSearch, setInputCommand, setTaskResults, setFileResults, setSelectedValue]); useInlineTaskSearchEffect({ mode, search, open, workspaceId, steps, setTaskResults, setIsSearching, }); useFileSearchEffect({ mode, search, workspaceSearchAvailable, activeSessionId, setFileResults, setIsSearchingFiles, fileDebounceRef, }); } function useFirstResultSelection( open: boolean, state: ReturnType, commands: CommandItemType[], contentResults: ReturnType["results"], ) { const { mode, search, taskResults, fileResults, setSelectedValue } = state; const previousCommandsRef = useRef(commands); // `search` intentionally re-applies the first result while debounced results are loading. useEffect(() => { const commandsChanged = previousCommandsRef.current !== commands; previousCommandsRef.current = commands; if (!open) return; if (mode === MODE_COMMANDS) { const firstTask = taskResults[0]; if (commandsChanged) { const taskValues = taskResults.map(getTaskResultValue); setSelectedValue((current) => selectCommandSearchResult(commands, search, taskValues, current), ); return; } if (firstTask) { setSelectedValue(getTaskResultValue(firstTask)); return; } if (search.trim()) { setSelectedValue(findFirstMatchingCommand(commands, search)?.id ?? ""); return; } setSelectedValue((current) => (current.startsWith("__task:") ? "" : current)); return; } if (mode === MODE_SEARCH_FILES) { const firstFile = fileResults[0]; setSelectedValue(firstFile ? getFileResultValue(firstFile.path) : ""); return; } if (mode === MODE_SEARCH_CONTENT) { const firstResult = contentResults[0]; setSelectedValue(firstResult ? getContentSearchResultValue(firstResult) : ""); return; } setSelectedValue(""); }, [commands, contentResults, fileResults, mode, open, search, setSelectedValue, taskResults]); } type CommandPanelHandlerOptions = { state: ReturnType; setOpen: (open: boolean) => void; commands: CommandItemType[]; kanbanSteps: { id: string; title: string; color: string }[]; repositories: Array<{ id: string; local_path: string }>; onTaskSelect: (task: Task) => void; }; function useCommandPanelHandlers({ state, setOpen, commands, kanbanSteps, repositories, onTaskSelect, }: CommandPanelHandlerOptions) { const { mode, search, inputCommand, setMode, setSearch, setInputCommand } = state; const grouped = useMemo(() => { const map = new Map(); for (const cmd of commands) { const existing = map.get(cmd.group) ?? []; existing.push(cmd); map.set(cmd.group, existing); } return Array.from(map.entries()).sort( ([, a], [, b]) => Math.min(...a.map((c) => c.priority ?? 100)) - Math.min(...b.map((c) => c.priority ?? 100)), ); }, [commands]); const stepMap = useMemo(() => { const map = new Map(); for (const step of kanbanSteps) map.set(step.id, { name: step.title, color: step.color }); return map; }, [kanbanSteps]); const repoMap = useMemo(() => { const map = new Map(); for (const repo of repositories) map.set(repo.id, repo.local_path); return map; }, [repositories]); const handleSelect = useCallback( (cmd: CommandItemType) => { if (cmd.enterMode) { if (cmd.enterMode === "input") setInputCommand(cmd); setMode(cmd.enterMode); setSearch(""); return; } if (cmd.action) { setOpen(false); cmd.action(); } }, [setOpen, setMode, setSearch, setInputCommand], ); const handleTaskSelect = useCallback( (task: Task) => { setOpen(false); onTaskSelect(task); }, [onTaskSelect, setOpen], ); const handleFileSelect = useCallback( (filePath: string) => { setOpen(false); useDockviewStore.getState().addFileEditorPanel(filePath, getFileName(filePath)); }, [setOpen], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (mode === "input" && e.key === "Enter" && search.trim() && inputCommand?.onInputSubmit) { e.preventDefault(); setOpen(false); inputCommand.onInputSubmit(search.trim()); return; } if (!isCommandPanelScopeMode(mode) && e.key === "Backspace" && !search) { e.preventDefault(); setMode(MODE_COMMANDS); setSearch(""); setInputCommand(null); } }, [mode, search, inputCommand, setOpen, setMode, setSearch, setInputCommand], ); const onScopeChange = (nextMode: CommandPanelScopeMode) => { setMode(nextMode); setInputCommand(null); }; const goBack = useCallback(() => { setMode(MODE_COMMANDS); setSearch(""); setInputCommand(null); }, [setMode, setSearch, setInputCommand]); return { grouped, stepMap, repoMap, handleSelect, handleTaskSelect, handleFileSelect, handleKeyDown, onScopeChange, goBack, }; } export function CommandPanel() { const { open, setOpen, mode: panelMode, setMode, modeRequestVersion } = useCommandPanelOpen(); const commands = useCommands(); const pathname = usePathname(); const kanbanSteps = useAppStore((state) => state.kanban.steps); const workspaceId = useAppStore((state) => state.workspaces.activeId); const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); const workspaceSearchAvailable = useAppStore((state) => isTaskWorkspaceSearchAvailable(state, pathname), ); const worktreePath = useAppStore((s) => activeSessionId ? (s.taskSessions.items[activeSessionId]?.worktree_path ?? null) : null, ); const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); const repositories = workspaceId ? (reposByWorkspace[workspaceId] ?? []) : []; const handleTaskNavigation = useCommandPanelTaskNavigation(pathname, activeTaskId); const state = useCommandPanelState(panelMode, setMode); const { mode, search, inputCommand, taskResults, isSearching, fileResults, isSearchingFiles, selectedValue, setSelectedValue, setSearch, } = state; useCommandPanelEffects({ open, state, workspaceId, activeSessionId, workspaceSearchAvailable, steps: kanbanSteps, modeRequestVersion, }); const { results: contentResults, isSearching: isSearchingContent, error: contentSearchError, } = useWorkspaceContentSearch({ enabled: open && workspaceSearchAvailable && mode === MODE_SEARCH_CONTENT, query: search, sessionId: activeSessionId, }); useFirstResultSelection(open, state, commands, contentResults); useCommandPanelShortcuts({ open, setOpen, mode, workspaceSearchAvailable, setMode, setSearch, }); const handlers = useCommandPanelHandlers({ state, setOpen, commands, kanbanSteps, repositories, onTaskSelect: handleTaskNavigation, }); const handleContentSelect = useContentSearchResultOpener(setOpen, worktreePath, activeSessionId); return ( ); }