Files
kandev/apps/web/components/command-panel-footer.tsx
T
2026-08-23 14:56:11 +00:00

643 lines
20 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 { useEffect, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { formatTimeDistance, useDateLocale } from "@/lib/i18n/date-locale";
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";
import {
CommandPanelConfirmation,
dismissCommandConfirmation,
getCommandConfirmationState,
} from "@/components/command-panel-confirmation";
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<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={
cmd.context ? "self-start pt-0.5 text-muted-foreground" : "text-muted-foreground"
}
>
{cmd.icon}
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate">{cmd.label}</span>
{cmd.context && (
<span className="block truncate text-[0.625rem] leading-4 text-muted-foreground">
{cmd.context}
</span>
)}
</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 locale = useDateLocale();
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(formatTimeDistance(task.updated_at, locale));
}
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 SearchCommandGroups({
commands,
search,
onSelect,
}: Pick<CommandsListContentProps, "commands" | "search" | "onSelect">) {
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<string, CommandItemType[]>();
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 && (
<CommandGroup heading={t("common:commandGroupCommands")}>
{regularMatches.map((cmd) => (
<CommandItemRow key={cmd.id} cmd={cmd} onSelect={onSelect} />
))}
</CommandGroup>
)}
{[...searchOnlyGroups].map(([group, items]) => (
<CommandGroup key={group} heading={group}>
{items.map((cmd) => (
<CommandItemRow key={cmd.id} cmd={cmd} onSelect={onSelect} />
))}
</CommandGroup>
))}
</>
);
}
function CommandsListContent({
commands,
grouped,
search,
onSelect,
taskResults,
isSearching,
stepMap,
repoMap,
onTaskSelect,
}: CommandsListContentProps) {
const { t } = useTranslation();
const hasInlineResults = taskResults.length > 0 || isSearching;
return (
<>
{!hasInlineResults && !isSearching && (
<CommandEmpty>{t("common:noCommandsFound")}</CommandEmpty>
)}
{isSearching && taskResults.length === 0 && (
<CommandGroup heading={t("common:activeTasks")} 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() ? t("common:tasks") : t("common:activeTasks")}
forceMount
>
{taskResults.map((task) => (
<TaskResultItem
key={task.id}
task={task}
stepMap={stepMap}
repoMap={repoMap}
onSelect={onTaskSelect}
/>
))}
</CommandGroup>
)}
{search.trim() ? (
<SearchCommandGroups commands={commands} search={search} onSelect={onSelect} />
) : (
grouped.map(([group, items]) => {
const visibleItems = items.filter((cmd) => !cmd.searchOnly);
if (visibleItems.length === 0) return null;
return (
<CommandGroup key={group} heading={group}>
{visibleItems.map((cmd) => (
<CommandItemRow key={cmd.id} cmd={cmd} onSelect={onSelect} />
))}
</CommandGroup>
);
})
)}
</>
);
}
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 (
<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>{t("common:noFilesFound")}</CommandEmpty>;
if (!search.trim()) return <CommandEmpty>{t("common:typeToSearchFiles")}</CommandEmpty>;
const groups = groupByRepositoryName(files, (file) => file.repository_name);
const singleRepo = isSingleRepoGroup(groups);
return groups.map((group) => (
<CommandGroup
key={group.repositoryName || "workspace"}
heading={
singleRepo
? t("common:files")
: (getRepoDisplayName(group.repositoryName) ??
group.repositoryName ??
t("common:workspace"))
}
forceMount
data-testid="file-search-repo-group"
data-repository={group.repositoryName}
>
{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 (
<CommandItem
key={file.path}
value={getFileResultValue(file.path)}
onSelect={() => onSelect(file.path)}
forceMount
>
<FileIcon fileName={fileName} className="shrink-0" />
<span className="truncate font-medium">{fileName}</span>
{dir && <span className="ml-1 truncate text-xs text-muted-foreground">{dir}</span>}
</CommandItem>
);
})}
</CommandGroup>
));
}
// `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 (
<div className="border-t border-border px-3 py-1.5 flex items-center gap-3 text-[0.6rem] text-muted-foreground">
{isScopeMode && (
<>
<KbdGroup>
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
<span>{t("common:navigate")}</span>
</KbdGroup>
{canSwitchScope && (
<KbdGroup>
<Kbd>Tab</Kbd>
<span>{t("common:switchMode")}</span>
</KbdGroup>
)}
</>
)}
<KbdGroup>
<Kbd>↵</Kbd>
<span>{getEnterLabel(t, mode)}</span>
</KbdGroup>
{!isScopeMode && (
<KbdGroup>
<Kbd>⌫</Kbd>
<span>{t("common:back")}</span>
</KbdGroup>
)}
<KbdGroup>
{/* A key name, not copy — it labels the physical key. */}
<Kbd>esc</Kbd>
<span>{t("common: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;
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<string, { name: string; color: string }>;
repoMap: Map<string, string>;
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 (
<div className="flex min-h-10 items-center border-b border-border [&>[data-slot=command-input-wrapper]]:min-w-0 [&>[data-slot=command-input-wrapper]]:flex-1 [&>[data-slot=command-input-wrapper]]:pb-1">
{!isTopLevelMode && (
<button
onClick={goBack}
tabIndex={-1}
className="shrink-0 pl-2 flex min-h-10 cursor-pointer items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<span>←</span>
<span>{modeLabel}</span>
<span className="text-muted-foreground/50">›</span>
</button>
)}
<CommandInput
placeholder={getInputPlaceholder(t, mode, inputCommand)}
value={search}
onValueChange={setSearch}
onKeyDown={onInputKeyDown}
/>
{canSwitchScope && <CommandPanelScopeSwitcher mode={mode} onScopeChange={onScopeChange} />}
</div>
);
}
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;
const { confirmationCommand, visibleCommands, visibleGroups } = getCommandConfirmationState(
commands,
grouped,
);
return (
<CommandList>
{confirmationCommand && <CommandPanelConfirmation command={confirmationCommand} />}
{mode === MODE_COMMANDS && (
<CommandsListContent
commands={visibleCommands}
grouped={visibleGroups}
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}
sessionId={activeSessionId}
onSelect={handleFileSelect}
/>
)}
{mode === MODE_SEARCH_CONTENT && (
<WorkspaceContentSearch
results={contentResults}
isSearching={isSearchingContent}
error={contentSearchError}
search={search}
sessionId={activeSessionId}
onSelect={handleContentSelect}
/>
)}
{mode === "input" &&
(!search.trim() ? (
<CommandEmpty>{inputCommand?.inputPlaceholder ?? t("common:enterAValue")}</CommandEmpty>
) : (
<CommandEmpty>{t("common:pressEnterToConfirm")}</CommandEmpty>
))}
</CommandList>
);
}
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]);
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) dismissCommandConfirmation(props.commands);
setOpen(nextOpen);
};
return (
<CommandDialog
open={open}
onOpenChange={handleOpenChange}
overlayClassName="supports-backdrop-filter:backdrop-blur-none!"
>
<Command
// Mode changes replace whole group trees. Filtering before render avoids
// cmdk's deferred sorter retaining a group after that group unmounts.
shouldFilter={false}
loop
// cmdk's built-in vim bindings intercept Ctrl/Cmd+K, +P, +J, +N as
// list-navigation shortcuts and call `event.preventDefault()` on the
// React (bubble) dispatch, which runs before this event reaches our
// `window`-level `useKeyboardShortcut` listeners. That collided with
// the app's own COMMAND_PANEL-open (Ctrl/Cmd+K) and COMMAND_PANEL
// (Ctrl/Cmd+P) shortcuts: once `useKeyboardShortcut` started bailing
// on `event.defaultPrevented` (see that hook's core-vs-plugin
// precedence guard), cmdk's vim-binding preventDefault silently
// suppressed the panel's own close/reopen toggle. Disable vim
// bindings here so this palette's Ctrl/Cmd+K and +P always reach the
// app-level toggle instead of cmdk's internal navigation.
vimBindings={false}
value={selectedValue}
onValueChange={setSelectedValue}
>
<CommandPanelInputHeader {...renderedProps} />
<CommandPanelResultList {...renderedProps} />
<CommandPanelFooter
mode={renderedProps.mode}
workspaceSearchAvailable={workspaceSearchAvailable}
/>
</Command>
</CommandDialog>
);
}