"use client"; import { useEffect, type Dispatch, type SetStateAction } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; 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 type { Task } from "@/lib/types/http"; import type { FileSearchResult } from "@/lib/types/backend"; import { FileIcon } from "@/components/ui/file-icon"; import { WorkspaceContentSearch } from "@/components/workspace-content-search"; import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name"; import { groupByRepositoryName, isSingleRepoGroup } from "@/lib/group-by-repo"; import { CommandPanelScopeSwitcher, getAdjacentCommandPanelScope, isCommandPanelScopeMode, type CommandPanelScopeMode, } from "@/components/command-panel-scope-switcher"; import type { WorkspaceContentSearchError } from "@/hooks/domains/session/use-workspace-content-search"; import type { WorkspaceContentSearchResult } from "@/lib/types/backend"; const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]); export const MODE_COMMANDS: CommandPanelMode = "commands"; export const MODE_SEARCH_FILES: CommandPanelMode = "search-files"; export const MODE_SEARCH_CONTENT: CommandPanelMode = "search-content"; const STEP_COLOR_MAP: Record = { "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 ( onSelect(cmd)} > {cmd.icon && ( {cmd.icon} )} {cmd.label} {cmd.context && ( {cmd.context} )} {cmd.shortcut && {formatShortcut(cmd.shortcut)}} {cmd.enterMode && ( )} ); } type TaskResultItemProps = { task: Task; stepMap: Map; repoMap: Map; 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 ( onSelect(task)} className={isArchived ? "opacity-60" : ""} forceMount >
{isArchived ? ( ) : ( )} {task.title} {step && ( {step.name} )} {details.length > 0 && ( {details.join(" · ")} )}
); } type CommandsListContentProps = { commands: CommandItemType[]; grouped: [string, CommandItemType[]][]; search: string; onSelect: (cmd: CommandItemType) => void; taskResults: Task[]; isSearching: boolean; stepMap: Map; repoMap: Map; onTaskSelect: (task: Task) => void; }; function SearchCommandGroups({ commands, search, onSelect, }: Pick) { const { t } = useTranslation(); const matches = sortCommandsForSearch(commands, search).filter( (cmd) => scoreCommandSearch(cmd.id, search, getCommandSearchTerms(cmd)) > 0, ); const regularMatches = matches.filter((cmd) => !cmd.searchOnly); const searchOnlyGroups = new Map(); for (const cmd of matches) { if (!cmd.searchOnly) continue; const groupItems = searchOnlyGroups.get(cmd.group) ?? []; groupItems.push(cmd); searchOnlyGroups.set(cmd.group, groupItems); } return ( <> {regularMatches.length > 0 && ( {regularMatches.map((cmd) => ( ))} )} {[...searchOnlyGroups].map(([group, items]) => ( {items.map((cmd) => ( ))} ))} ); } function CommandsListContent({ commands, grouped, search, onSelect, taskResults, isSearching, stepMap, repoMap, onTaskSelect, }: CommandsListContentProps) { const { t } = useTranslation(); const hasInlineResults = taskResults.length > 0 || isSearching; return ( <> {!hasInlineResults && !isSearching && ( {t("common:noCommandsFound")} )} {isSearching && taskResults.length === 0 && (
)} {taskResults.length > 0 && ( {taskResults.map((task) => ( ))} )} {search.trim() ? ( ) : ( grouped.map(([group, items]) => { const visibleItems = items.filter((cmd) => !cmd.searchOnly); if (visibleItems.length === 0) return null; return ( {visibleItems.map((cmd) => ( ))} ); }) )} ); } type FileSearchContentProps = { files: FileSearchResult[]; isSearching: boolean; search: string; sessionId: string | null; onSelect: (path: string) => void; }; function FileSearchContent({ files, isSearching, search, sessionId, onSelect, }: FileSearchContentProps) { const { t } = useTranslation(); const getRepoDisplayName = useRepoDisplayName(sessionId); if (isSearching && files.length === 0) { return (
); } if (search.trim() && files.length === 0) return {t("common:noFilesFound")}; if (!search.trim()) return {t("common:typeToSearchFiles")}; const groups = groupByRepositoryName(files, (file) => file.repository_name); const singleRepo = isSingleRepoGroup(groups); return groups.map((group) => ( {group.items.map((file) => { const repositoryPrefix = file.repository_name ? `${file.repository_name}/` : ""; const displayPath = file.path.startsWith(repositoryPrefix) ? file.path.slice(repositoryPrefix.length) : file.path; const fileName = getFileName(displayPath); const lastSlash = displayPath.lastIndexOf("/"); const dir = lastSlash > 0 ? displayPath.slice(0, lastSlash) : ""; return ( onSelect(file.path)} forceMount > {fileName} {dir && {dir}} ); })} )); } // `mode` stays an untranslated discriminant — it is compared with `===` // throughout. Only the labels it selects are copy. function getInputPlaceholder( t: TFunction, mode: CommandPanelMode, inputCommand: CommandItemType | null, ) { if (mode === "input") return inputCommand?.inputPlaceholder ?? t("common:enterValue"); if (mode === "search-tasks") return t("common:searchForTasks"); if (mode === MODE_SEARCH_FILES) return t("common:searchForFiles"); if (mode === MODE_SEARCH_CONTENT) return t("common:searchTaskContents"); return t("common:typeACommand"); } function getEnterLabel(t: TFunction, mode: CommandPanelMode) { if (mode === "input") return t("common:confirm"); if (mode === "search-tasks" || mode === MODE_SEARCH_FILES || mode === MODE_SEARCH_CONTENT) { return t("common:open"); } return t("common:select"); } function getModeLabel(t: TFunction, mode: CommandPanelMode, inputCommand: CommandItemType | null) { if (mode === "input") return inputCommand?.label; if (mode === "search-tasks") return t("common:tasks"); if (mode === MODE_SEARCH_FILES) return t("common:files"); if (mode === MODE_SEARCH_CONTENT) return t("common:contents"); return null; } function CommandPanelFooter({ mode, workspaceSearchAvailable, }: { mode: CommandPanelMode; workspaceSearchAvailable: boolean; }) { const { t } = useTranslation(); const isScopeMode = isCommandPanelScopeMode(mode); const canSwitchScope = workspaceSearchAvailable && isScopeMode; return (
{isScopeMode && ( <> ↑ ↓ {t("common:navigate")} {canSwitchScope && ( Tab {t("common:switchMode")} )} )} ↵ {getEnterLabel(t, mode)} {!isScopeMode && ( ⌫ {t("common:back")} )} {/* A key name, not copy — it labels the physical key. */} esc {t("common:close")}
); } export type CommandPanelViewProps = { open: boolean; setOpen: (open: boolean) => void; mode: CommandPanelMode; inputCommand: CommandItemType | null; selectedValue: string; setSelectedValue: Dispatch>; search: string; setSearch: (value: string) => void; handleKeyDown: (e: React.KeyboardEvent) => void; onScopeChange: (mode: CommandPanelScopeMode) => void; goBack: () => void; fileResults: FileSearchResult[]; isSearchingFiles: boolean; handleFileSelect: (filePath: string) => void; contentResults: WorkspaceContentSearchResult[]; isSearchingContent: boolean; contentSearchError: WorkspaceContentSearchError | null; activeSessionId: string | null; workspaceSearchAvailable: boolean; handleContentSelect: (result: WorkspaceContentSearchResult) => void; commands: CommandItemType[]; grouped: Array<[string, CommandItemType[]]>; handleSelect: (cmd: CommandItemType) => void; isSearching: boolean; taskResults: Task[]; stepMap: Map; repoMap: Map; handleTaskSelect: (task: Task) => void; }; function CommandPanelInputHeader({ mode, inputCommand, search, setSearch, handleKeyDown, onScopeChange, goBack, workspaceSearchAvailable, }: CommandPanelViewProps) { const { t } = useTranslation(); const isTopLevelMode = isCommandPanelScopeMode(mode); const canSwitchScope = workspaceSearchAvailable && isTopLevelMode; const modeLabel = getModeLabel(t, mode, inputCommand); const onInputKeyDown = (event: React.KeyboardEvent) => { if ( canSwitchScope && event.key === "Tab" && !event.altKey && !event.ctrlKey && !event.metaKey ) { event.preventDefault(); onScopeChange(getAdjacentCommandPanelScope(mode, event.shiftKey)); return; } handleKeyDown(event); }; return (
{!isTopLevelMode && ( )} {canSwitchScope && }
); } function CommandPanelResultList(props: CommandPanelViewProps) { const { t } = useTranslation(); const { mode, inputCommand, search, fileResults, isSearchingFiles, handleFileSelect, contentResults, isSearchingContent, contentSearchError, activeSessionId, handleContentSelect, commands, grouped, handleSelect, isSearching, taskResults, stepMap, repoMap, handleTaskSelect, } = props; return ( {mode === MODE_COMMANDS && ( )} {mode === MODE_SEARCH_FILES && ( )} {mode === MODE_SEARCH_CONTENT && ( )} {mode === "input" && (!search.trim() ? ( {inputCommand?.inputPlaceholder ?? t("common:enterAValue")} ) : ( {t("common:pressEnterToConfirm")} ))} ); } export function CommandPanelView(props: CommandPanelViewProps) { const { open, setOpen, mode, selectedValue, setSelectedValue, workspaceSearchAvailable, onScopeChange, } = props; const workspaceModeUnavailable = !workspaceSearchAvailable && (mode === MODE_SEARCH_FILES || mode === MODE_SEARCH_CONTENT); const renderedProps = workspaceModeUnavailable ? { ...props, mode: MODE_COMMANDS } : props; useEffect(() => { if (workspaceModeUnavailable) onScopeChange("commands"); }, [onScopeChange, workspaceModeUnavailable]); return ( ); }