Files
kandev/apps/web/components/command-panel-footer.tsx
T
José Almeida 80b5516199 fix(web): improve command palette alias matching (#1718)
* fix(web): improve command palette alias matching

* docs(web): explain command priority tie-breaker

* fix(web): preserve command palette selection
2026-07-16 00:22:31 +01:00

415 lines
13 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 type { Dispatch, SetStateAction } from "react";
import { formatDistanceToNow } from "date-fns";
import { IconArchive, IconArrowRight, IconHammer, IconLoader2 } 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 type { CommandPanelMode, CommandItem as CommandItemType } from "@/lib/commands/types";
import {
getCommandSearchTerms,
scoreCommandSearch,
sortCommandsForSearch,
} from "@/lib/commands/search";
import { formatShortcut } from "@/lib/keyboard/utils";
import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
import { useAppStore } from "@/components/state-provider";
import type { Task } from "@/lib/types/http";
import { FileIcon } from "@/components/ui/file-icon";
const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]);
export const MODE_COMMANDS: CommandPanelMode = "commands";
export const MODE_SEARCH_FILES: CommandPanelMode = "search-files";
const STEP_COLOR_MAP: Record<string, string> = {
"bg-slate-500": "#64748b",
"bg-red-500": "#ef4444",
"bg-orange-500": "#f97316",
"bg-yellow-500": "#eab308",
"bg-green-500": "#22c55e",
"bg-cyan-500": "#06b6d4",
"bg-blue-500": "#3b82f6",
"bg-indigo-500": "#6366f1",
"bg-purple-500": "#a855f7",
};
function getFileName(filePath: string) {
return filePath.split("/").pop() ?? filePath;
}
export function getTaskResultValue(task: Task) {
return `__task:${task.id} ${task.title}`;
}
export function getFileResultValue(filePath: string) {
return `__file:${filePath}`;
}
function CommandItemRow({
cmd,
onSelect,
}: {
cmd: CommandItemType;
onSelect: (cmd: CommandItemType) => void;
}) {
return (
<CommandItem
key={cmd.id}
value={cmd.id}
keywords={getCommandSearchTerms(cmd)}
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 }>;
repoMap: Map<string, string>;
onSelect: (task: Task) => void;
};
function TaskResultItem({ task, stepMap, repoMap, onSelect }: TaskResultItemProps) {
const isArchived = ARCHIVED_STATES.has(task.state);
const step = stepMap.get(task.workflow_step_id);
const stepHex = step ? STEP_COLOR_MAP[step.color] : undefined;
const rawPath =
task.primary_working_directory ??
(task.repositories?.[0] ? repoMap.get(task.repositories[0].repository_id) : undefined);
const workDir = rawPath ? getFileName(rawPath) : undefined;
const details: string[] = [];
if (workDir) details.push(workDir);
if (task.primary_agent_name) details.push(task.primary_agent_name);
if (task.updated_at) {
details.push(formatDistanceToNow(new Date(task.updated_at), { addSuffix: true }));
}
return (
<CommandItem
key={task.id}
value={getTaskResultValue(task)}
onSelect={() => onSelect(task)}
className={isArchived ? "opacity-60" : ""}
forceMount
>
<div className="flex items-center gap-2 min-w-0 w-full">
{isArchived ? (
<IconArchive className="size-3 shrink-0 text-muted-foreground" />
) : (
<IconHammer 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"
style={stepHex ? { backgroundColor: stepHex + "22", color: stepHex } : undefined}
>
{step.name}
</Badge>
)}
{details.length > 0 && (
<span className="ml-auto text-[0.6rem] text-muted-foreground truncate shrink-0">
{details.join(" · ")}
</span>
)}
</div>
</CommandItem>
);
}
type CommandsListContentProps = {
commands: CommandItemType[];
grouped: [string, CommandItemType[]][];
search: string;
onSelect: (cmd: CommandItemType) => void;
taskResults: Task[];
isSearching: boolean;
stepMap: Map<string, { name: string; color: string }>;
repoMap: Map<string, string>;
onTaskSelect: (task: Task) => void;
};
function CommandsListContent({
commands,
grouped,
search,
onSelect,
taskResults,
isSearching,
stepMap,
repoMap,
onTaskSelect,
}: CommandsListContentProps) {
const hasInlineResults = taskResults.length > 0 || isSearching;
return (
<>
{!hasInlineResults && !isSearching && <CommandEmpty>No commands found.</CommandEmpty>}
{isSearching && taskResults.length === 0 && (
<CommandGroup heading="Active Tasks" forceMount>
<div className="flex items-center justify-center py-3">
<IconLoader2 className="size-3.5 animate-spin text-muted-foreground" />
</div>
</CommandGroup>
)}
{taskResults.length > 0 && (
<CommandGroup heading={search.trim() ? "Tasks" : "Active Tasks"} forceMount>
{taskResults.map((task) => (
<TaskResultItem
key={task.id}
task={task}
stepMap={stepMap}
repoMap={repoMap}
onSelect={onTaskSelect}
/>
))}
</CommandGroup>
)}
{search.trim() ? (
<CommandGroup heading="Commands">
{/* cmdk preserves this priority pre-sort when filter scores tie. */}
{sortCommandsForSearch(commands, search).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 FileSearchContentProps = {
files: string[];
isSearching: boolean;
search: string;
onSelect: (path: string) => void;
};
function FileSearchContent({ files, isSearching, search, onSelect }: FileSearchContentProps) {
if (isSearching && files.length === 0) {
return (
<div className="flex items-center justify-center py-6">
<IconLoader2 className="size-4 animate-spin text-muted-foreground" />
</div>
);
}
if (search.trim() && files.length === 0) return <CommandEmpty>No files found.</CommandEmpty>;
if (!search.trim()) return <CommandEmpty>Type to search files...</CommandEmpty>;
return (
<CommandGroup heading="Files" forceMount>
{files.map((filePath) => {
const fileName = getFileName(filePath);
const lastSlash = filePath.lastIndexOf("/");
const dir = lastSlash > 0 ? filePath.slice(0, lastSlash) : "";
return (
<CommandItem
key={filePath}
value={getFileResultValue(filePath)}
onSelect={() => onSelect(filePath)}
forceMount
>
<FileIcon fileName={fileName} className="shrink-0" />
<span className="font-medium truncate">{fileName}</span>
{dir && <span className="text-muted-foreground text-xs truncate ml-1">{dir}</span>}
</CommandItem>
);
})}
</CommandGroup>
);
}
function getInputPlaceholder(mode: CommandPanelMode, inputCommand: CommandItemType | null) {
if (mode === "input") return inputCommand?.inputPlaceholder ?? "Enter value...";
if (mode === "search-tasks") return "Search for tasks...";
if (mode === MODE_SEARCH_FILES) return "Search for files...";
return "Type a command...";
}
function getEnterLabel(mode: CommandPanelMode) {
if (mode === "input") return "Confirm";
if (mode === "search-tasks" || mode === MODE_SEARCH_FILES) return "Open";
return "Select";
}
function getModeLabel(mode: CommandPanelMode, inputCommand: CommandItemType | null) {
if (mode === "input") return inputCommand?.label;
if (mode === "search-tasks") return "Tasks";
if (mode === MODE_SEARCH_FILES) return "Files";
return null;
}
function CommandPanelFooter({ mode }: { mode: CommandPanelMode }) {
const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts);
return (
<div className="border-t border-border px-3 py-1.5 flex items-center gap-3 text-[0.6rem] text-muted-foreground">
{mode === MODE_COMMANDS && (
<>
<KbdGroup>
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
<span>Navigate</span>
</KbdGroup>
<KbdGroup>
<Kbd>{formatShortcut(getShortcut("FILE_SEARCH", keyboardShortcuts))}</Kbd>
<span>File Search</span>
</KbdGroup>
</>
)}
<KbdGroup>
<Kbd>↵</Kbd>
<span>{getEnterLabel(mode)}</span>
</KbdGroup>
{mode !== MODE_COMMANDS && (
<KbdGroup>
<Kbd>⌫</Kbd>
<span>Back</span>
</KbdGroup>
)}
<KbdGroup>
<Kbd>esc</Kbd>
<span>Close</span>
</KbdGroup>
</div>
);
}
export type CommandPanelViewProps = {
open: boolean;
setOpen: (open: boolean) => void;
mode: CommandPanelMode;
inputCommand: CommandItemType | null;
selectedValue: string;
setSelectedValue: Dispatch<SetStateAction<string>>;
search: string;
setSearch: (value: string) => void;
handleKeyDown: (e: React.KeyboardEvent) => void;
goBack: () => void;
fileResults: string[];
isSearchingFiles: boolean;
handleFileSelect: (filePath: string) => void;
commands: CommandItemType[];
grouped: Array<[string, CommandItemType[]]>;
handleSelect: (cmd: CommandItemType) => void;
isSearching: boolean;
taskResults: Task[];
stepMap: Map<string, { name: string; color: string }>;
repoMap: Map<string, string>;
handleTaskSelect: (task: Task) => void;
};
export function CommandPanelView({
open,
setOpen,
mode,
inputCommand,
selectedValue,
setSelectedValue,
search,
setSearch,
handleKeyDown,
goBack,
fileResults,
isSearchingFiles,
handleFileSelect,
commands,
grouped,
handleSelect,
isSearching,
taskResults,
stepMap,
repoMap,
handleTaskSelect,
}: CommandPanelViewProps) {
const modeLabel = getModeLabel(mode, inputCommand);
return (
<CommandDialog
open={open}
onOpenChange={setOpen}
overlayClassName="supports-backdrop-filter:backdrop-blur-none!"
>
<Command
filter={scoreCommandSearch}
shouldFilter={mode === MODE_COMMANDS}
loop
value={selectedValue}
onValueChange={setSelectedValue}
>
<div className="flex items-center border-b border-border [&>[data-slot=command-input-wrapper]]:flex-1">
{mode !== MODE_COMMANDS && (
<button
onClick={goBack}
tabIndex={-1}
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>{modeLabel}</span>
<span className="text-muted-foreground/50">›</span>
</button>
)}
<CommandInput
placeholder={getInputPlaceholder(mode, inputCommand)}
value={search}
onValueChange={setSearch}
onKeyDown={handleKeyDown}
/>
</div>
<CommandList>
{mode === MODE_COMMANDS && (
<CommandsListContent
commands={commands}
grouped={grouped}
search={search}
onSelect={handleSelect}
taskResults={taskResults}
isSearching={isSearching}
stepMap={stepMap}
repoMap={repoMap}
onTaskSelect={handleTaskSelect}
/>
)}
{mode === MODE_SEARCH_FILES && (
<FileSearchContent
files={fileResults}
isSearching={isSearchingFiles}
search={search}
onSelect={handleFileSelect}
/>
)}
{mode === "input" &&
(!search.trim() ? (
<CommandEmpty>{inputCommand?.inputPlaceholder ?? "Enter a value..."}</CommandEmpty>
) : (
<CommandEmpty>Press Enter to confirm</CommandEmpty>
))}
</CommandList>
<CommandPanelFooter mode={mode} />
</Command>
</CommandDialog>
);
}