Files
kandev/apps/web/components/command-panel.tsx
T
2026-02-18 18:24:41 +00:00

261 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { formatDistanceToNow } from 'date-fns';
import {
IconArrowRight,
IconLoader2,
IconSearch,
} from '@tabler/icons-react';
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from '@kandev/ui/command';
import { Kbd, KbdGroup } from '@kandev/ui/kbd';
import { Badge } from '@kandev/ui/badge';
import { useCommands, useCommandPanelOpen } from '@/lib/commands/command-registry';
import type { CommandPanelMode, CommandItem as CommandItemType } from '@/lib/commands/types';
import { SHORTCUTS } from '@/lib/keyboard/constants';
import { useKeyboardShortcut } from '@/hooks/use-keyboard-shortcut';
import { formatShortcut } from '@/lib/keyboard/utils';
import { useAppStore } from '@/components/state-provider';
import { useToast } from '@/components/toast-provider';
import { listTasksByWorkspace } from '@/lib/api';
import { linkToSession } from '@/lib/links';
import type { Task } from '@/lib/types/http';
const ARCHIVED_STATES = new Set(['COMPLETED', 'CANCELLED', 'FAILED']);
const MODE_SEARCH_TASKS: CommandPanelMode = 'search-tasks';
function CommandItemRow({ cmd, onSelect }: { cmd: CommandItemType; onSelect: (cmd: CommandItemType) => void }) {
return (
<CommandItem
key={cmd.id}
value={cmd.id + ' ' + cmd.label + ' ' + (cmd.keywords?.join(' ') ?? '')}
onSelect={() => onSelect(cmd)}
>
{cmd.icon && <span className="text-muted-foreground">{cmd.icon}</span>}
<span>{cmd.label}</span>
{cmd.shortcut && <CommandShortcut>{formatShortcut(cmd.shortcut)}</CommandShortcut>}
{cmd.enterMode && (
<span className="ml-auto text-muted-foreground">
<IconArrowRight className="size-3" />
</span>
)}
</CommandItem>
);
}
type TaskResultItemProps = { task: Task; stepMap: Map<string, { name: string; color: string }>; onSelect: (task: Task) => void };
function TaskResultItem({ task, stepMap, onSelect }: TaskResultItemProps) {
const isArchived = ARCHIVED_STATES.has(task.state);
const step = stepMap.get(task.workflow_step_id);
return (
<CommandItem key={task.id} value={task.id} onSelect={() => onSelect(task)} className={isArchived ? 'opacity-60' : ''}>
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-2">
<IconSearch className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">{task.title}</span>
{step && <Badge variant="secondary" className="text-[0.6rem] shrink-0">{step.name}</Badge>}
{isArchived && <Badge variant="outline" className="text-[0.6rem] shrink-0 opacity-70">{task.state}</Badge>}
</div>
{task.updated_at && (
<div className="flex items-center gap-1.5 text-[0.6rem] text-muted-foreground pl-5">
<span>{formatDistanceToNow(new Date(task.updated_at), { addSuffix: true })}</span>
</div>
)}
</div>
</CommandItem>
);
}
type CommandsListContentProps = {
commands: CommandItemType[];
grouped: [string, CommandItemType[]][];
search: string;
onSelect: (cmd: CommandItemType) => void;
};
function CommandsListContent({ commands, grouped, search, onSelect }: CommandsListContentProps) {
return (
<>
<CommandEmpty>No commands found.</CommandEmpty>
{search.trim() ? (
<CommandGroup>
{commands.map((cmd) => <CommandItemRow key={cmd.id} cmd={cmd} onSelect={onSelect} />)}
</CommandGroup>
) : (
grouped.map(([group, items]) => (
<CommandGroup key={group} heading={group}>
{items.map((cmd) => <CommandItemRow key={cmd.id} cmd={cmd} onSelect={onSelect} />)}
</CommandGroup>
))
)}
</>
);
}
type SearchTasksContentProps = { isSearching: boolean; search: string; taskResults: Task[]; stepMap: Map<string, { name: string; color: string }>; onTaskSelect: (task: Task) => void };
function SearchTasksContent({ isSearching, search, taskResults, stepMap, onTaskSelect }: SearchTasksContentProps) {
if (isSearching) return <div className="flex items-center justify-center py-6"><IconLoader2 className="size-4 animate-spin text-muted-foreground" /></div>;
if (search.trim() && taskResults.length === 0) return <CommandEmpty>No tasks found.</CommandEmpty>;
if (!search.trim()) return <CommandEmpty>Type to search tasks...</CommandEmpty>;
return (
<CommandGroup heading="Results">
{taskResults.map((task) => <TaskResultItem key={task.id} task={task} stepMap={stepMap} onSelect={onTaskSelect} />)}
</CommandGroup>
);
}
function getInputPlaceholder(mode: CommandPanelMode, inputCommand: CommandItemType | null) {
if (mode === 'input') return inputCommand?.inputPlaceholder ?? 'Enter value...';
if (mode === MODE_SEARCH_TASKS) return 'Search for tasks...';
return 'Type a command...';
}
function getEnterLabel(mode: CommandPanelMode) {
if (mode === 'input') return 'Confirm';
if (mode === MODE_SEARCH_TASKS) return 'Open';
return 'Select';
}
function useCommandPanelState() {
const [mode, setMode] = useState<CommandPanelMode>('commands');
const [search, setSearch] = useState('');
const [inputCommand, setInputCommand] = useState<CommandItemType | null>(null);
const [taskResults, setTaskResults] = useState<Task[]>([]);
const [isSearching, setIsSearching] = useState(false);
return { mode, setMode, search, setSearch, inputCommand, setInputCommand, taskResults, setTaskResults, isSearching, setIsSearching };
}
function useCommandPanelEffects(
open: boolean,
state: ReturnType<typeof useCommandPanelState>,
workspaceId: string | null,
) {
const { mode, search, setMode, setSearch, setInputCommand, setTaskResults, setIsSearching } = state;
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
if (!open) {
const t = setTimeout(() => { setMode('commands'); setSearch(''); setInputCommand(null); setTaskResults([]); }, 200);
return () => clearTimeout(t);
}
}, [open, setMode, setSearch, setInputCommand, setTaskResults]);
useEffect(() => {
if (mode !== MODE_SEARCH_TASKS) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
abortRef.current?.abort();
if (!search.trim()) { setTaskResults([]); setIsSearching(false); return; }
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: 10 }, { init: { signal: controller.signal } });
if (!controller.signal.aborted) setTaskResults(res.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(); };
}, [search, mode, workspaceId, setTaskResults, setIsSearching]);
}
export function CommandPanel() {
const { open, setOpen } = useCommandPanelOpen();
const commands = useCommands();
const router = useRouter();
const { toast } = useToast();
const workspaceId = useAppStore((state) => state.workspaces.activeId);
const kanbanSteps = useAppStore((state) => state.kanban.steps);
const state = useCommandPanelState();
const { mode, setMode, search, setSearch, inputCommand, setInputCommand, taskResults, isSearching } = state;
useCommandPanelEffects(open, state, workspaceId);
const stepMap = useMemo(() => {
const map = new Map<string, { name: string; color: string }>();
for (const step of kanbanSteps) map.set(step.id, { name: step.title, color: step.color });
return map;
}, [kanbanSteps]);
const openRef = useRef(open);
useEffect(() => { openRef.current = open; }, [open]);
const toggle = useCallback(() => setOpen(!openRef.current), [setOpen]);
useKeyboardShortcut(SHORTCUTS.SEARCH, toggle);
useKeyboardShortcut(SHORTCUTS.COMMAND_PANEL, toggle);
useKeyboardShortcut(SHORTCUTS.COMMAND_PANEL_SHIFT, toggle);
const grouped = useMemo(() => {
const map = new Map<string, CommandItemType[]>();
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 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);
if (task.primary_session_id) { router.push(linkToSession(task.primary_session_id)); }
else { toast({ title: 'This task has no active session', variant: 'default' }); }
}, [setOpen, router, toast]);
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 (mode !== 'commands' && e.key === 'Backspace' && !search) {
e.preventDefault(); setMode('commands'); setSearch(''); setInputCommand(null);
}
}, [mode, search, inputCommand, setOpen, setMode, setSearch, setInputCommand]);
const goBack = useCallback(() => { setMode('commands'); setSearch(''); setInputCommand(null); }, [setMode, setSearch, setInputCommand]);
return (
<CommandDialog open={open} onOpenChange={setOpen} overlayClassName="supports-backdrop-filter:backdrop-blur-none!">
<Command shouldFilter={mode === 'commands'} loop>
<div className="flex items-center border-b border-border [&>[data-slot=command-input-wrapper]]:flex-1">
{mode !== 'commands' && (
<button onClick={goBack} className="shrink-0 pl-2 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
<span>←</span>
<span>{mode === 'input' ? inputCommand?.label : 'Tasks'}</span>
<span className="text-muted-foreground/50">›</span>
</button>
)}
<CommandInput placeholder={getInputPlaceholder(mode, inputCommand)} value={search} onValueChange={setSearch} onKeyDown={handleKeyDown} />
</div>
<CommandList>
{mode === 'commands' && <CommandsListContent commands={commands} grouped={grouped} search={search} onSelect={handleSelect} />}
{mode === MODE_SEARCH_TASKS && <SearchTasksContent isSearching={isSearching} search={search} taskResults={taskResults} stepMap={stepMap} onTaskSelect={handleTaskSelect} />}
{mode === 'input' && (!search.trim() ? <CommandEmpty>{inputCommand?.inputPlaceholder ?? 'Enter a value...'}</CommandEmpty> : <CommandEmpty>Press Enter to confirm</CommandEmpty>)}
</CommandList>
<div className="border-t border-border px-3 py-1.5 flex items-center gap-3 text-[0.6rem] text-muted-foreground">
{mode === 'commands' && <KbdGroup><Kbd>↑</Kbd><Kbd>↓</Kbd><span>Navigate</span></KbdGroup>}
<KbdGroup><Kbd>↵</Kbd><span>{getEnterLabel(mode)}</span></KbdGroup>
{mode !== 'commands' && <KbdGroup><Kbd>⌫</Kbd><span>Back</span></KbdGroup>}
<KbdGroup><Kbd>esc</Kbd><span>Close</span></KbdGroup>
</div>
</Command>
</CommandDialog>
);
}