import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { WorkspaceContentSearchResult } from "@/lib/types/backend";
import type { Task } from "@/lib/types/http";
import { taskId, workflowId, workspaceId } from "@/lib/types/ids";
vi.mock("@kandev/ui/command", () => ({
Command: ({ children, shouldFilter }: { children: ReactNode; shouldFilter?: boolean }) => (
{children}
),
CommandDialog: ({
children,
open,
onOpenChange,
}: {
children: ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
}) =>
open ? (
onOpenChange(false)}>
Dismiss test dialog
{children}
) : null,
CommandEmpty: ({ children }: { children: ReactNode }) => {children}
,
CommandGroup: ({
children,
heading,
"data-testid": testId,
"data-repository": repository,
}: {
children: ReactNode;
heading?: ReactNode;
"data-testid"?: string;
"data-repository"?: string;
}) => (
{heading && (
{heading}
)}
{children}
),
CommandInput: ({
placeholder,
value,
onKeyDown,
}: {
placeholder: string;
value: string;
onKeyDown: (event: React.KeyboardEvent) => void;
}) => (
),
// Forwards onSelect: without it the mock silently swallows every selection
// handler and any assertion about picking a row would prove nothing.
CommandItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => (
{children}
),
CommandList: ({ children }: { children: ReactNode }) => {children}
,
CommandShortcut: ({ children }: { children: ReactNode }) => {children} ,
}));
vi.mock("@kandev/ui/kbd", () => ({
Kbd: ({ children }: { children: ReactNode }) => {children} ,
KbdGroup: ({ children }: { children: ReactNode }) => {children} ,
}));
vi.mock("@kandev/ui/badge", () => ({
Badge: ({ children }: { children: ReactNode }) => {children} ,
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
userSettings: { keyboardShortcuts: {} },
taskSessions: { items: {} },
kanban: { tasks: [] },
repositories: { itemsByWorkspaceId: {} },
}),
}));
type MockContentSearchProps = {
onSelect: (result: WorkspaceContentSearchResult) => void;
results: WorkspaceContentSearchResult[];
};
const mockContentSearch = vi.fn(({ onSelect, results }: MockContentSearchProps) => (
onSelect(results[0])}>
Content results
));
vi.mock("./workspace-content-search", () => ({
WorkspaceContentSearch: (props: MockContentSearchProps) => mockContentSearch(props),
}));
import {
CommandPanelView,
MODE_COMMANDS,
MODE_SEARCH_CONTENT,
MODE_SEARCH_TASKS,
type CommandPanelViewProps,
} from "./command-panel-footer";
const result: WorkspaceContentSearchResult = {
repository_name: "web",
path: "src/app.tsx",
line: 5,
column: 3,
preview: "needle",
match_ranges: [{ start: 0, end: 6 }],
};
const ARIA_SELECTED_ATTRIBUTE = "aria-selected";
const CMDK_GROUP_HEADING_SELECTOR = "[cmdk-group-heading]";
const ACTIONS_GROUP = "Actions";
const CONFIRMATION_LABEL = "Remove selected item";
function viewProps(overrides: Partial = {}): CommandPanelViewProps {
return {
open: true,
setOpen: vi.fn(),
mode: MODE_SEARCH_CONTENT,
inputCommand: null,
selectedValue: "",
setSelectedValue: vi.fn(),
search: "needle",
setSearch: vi.fn(),
handleKeyDown: vi.fn(),
onScopeChange: vi.fn(),
goBack: vi.fn(),
fileResults: [],
isSearchingFiles: false,
handleFileSelect: vi.fn(),
contentResults: [result],
isSearchingContent: false,
contentSearchError: null,
activeSessionId: "session-1",
workspaceSearchAvailable: true,
handleContentSelect: vi.fn(),
commands: [],
grouped: [],
handleSelect: vi.fn(),
isSearching: false,
taskResults: [],
stepMap: new Map(),
repoMap: new Map(),
liveTasksById: new Map(),
lastStepIdByWorkflowId: new Map(),
handleTaskSelect: vi.fn(),
...overrides,
};
}
afterEach(() => {
cleanup();
mockContentSearch.mockClear();
});
describe("CommandPanelView task content search mode", () => {
it("renders a dedicated input and forwards repository results", () => {
const props = viewProps();
render( );
expect(screen.getByPlaceholderText("Search task contents…")).toBeTruthy();
expect(screen.getByText("Contents")).toBeTruthy();
expect(mockContentSearch).toHaveBeenCalledWith(
expect.objectContaining({
results: [result],
isSearching: false,
error: null,
search: "needle",
sessionId: "session-1",
}),
);
fireEvent.click(screen.getByTestId("mock-content-search"));
expect(props.handleContentSelect).toHaveBeenCalledWith(result);
});
});
describe("CommandPanelView scope switcher", () => {
it("makes all palette scopes visible and switches without clearing the query", () => {
const onScopeChange = vi.fn();
const props = viewProps({ onScopeChange });
render( );
const tabs = screen.getAllByRole("tab");
expect(tabs.map((tab) => tab.getAttribute("aria-label"))).toEqual([
"Commands",
"Tasks",
"Files",
"Contents",
]);
expect(
screen.getByRole("tab", { name: "Commands" }).getAttribute(ARIA_SELECTED_ATTRIBUTE),
).toBe("false");
expect(screen.getByRole("tab", { name: "Files" }).getAttribute(ARIA_SELECTED_ATTRIBUTE)).toBe(
"false",
);
expect(
screen.getByRole("tab", { name: "Contents" }).getAttribute(ARIA_SELECTED_ATTRIBUTE),
).toBe("true");
fireEvent.click(screen.getByRole("tab", { name: "Files" }));
expect(onScopeChange).toHaveBeenCalledWith("search-files");
expect(props.setSearch).not.toHaveBeenCalled();
expect((screen.getByRole("combobox") as HTMLInputElement).value).toBe("needle");
});
it("keeps the mode selector inline with the search input", () => {
render( );
const inputWrapper = screen.getByRole("combobox").closest("[data-slot=command-input-wrapper]");
const switcher = screen.getByRole("tablist", { name: "Command palette mode" });
expect(switcher.parentElement).toBe(inputWrapper?.parentElement);
});
it("keeps balanced breathing room between the input and header divider", () => {
render( );
const inputWrapper = screen.getByRole("combobox").closest("[data-slot=command-input-wrapper]");
expect(inputWrapper?.parentElement?.className).toContain(
"[&>[data-slot=command-input-wrapper]]:pb-1",
);
});
it("uses a low-chrome text selector with an active underline", () => {
render( );
const switcher = screen.getByRole("tablist", { name: "Command palette mode" });
expect(switcher.querySelector("kbd")).toBeNull();
expect(switcher.querySelectorAll("svg")).toHaveLength(0);
expect(screen.queryByTestId("command-panel-scope-indicator")).toBeNull();
for (const tab of screen.getAllByRole("tab")) {
expect(tab.getAttribute("tabindex")).toBe("-1");
expect(tab.className).toContain(
tab.getAttribute(ARIA_SELECTED_ATTRIBUTE) === "true"
? "after:opacity-100"
: "after:opacity-0",
);
}
});
it("cycles palette scopes with Tab and Shift+Tab", () => {
const onScopeChange = vi.fn();
const props = viewProps({ onScopeChange });
render( );
const input = screen.getByRole("combobox");
input.focus();
expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(false);
expect(onScopeChange).toHaveBeenLastCalledWith("commands");
expect(document.activeElement).toBe(input);
expect(fireEvent.keyDown(input, { key: "Tab", shiftKey: true })).toBe(false);
expect(onScopeChange).toHaveBeenLastCalledWith("search-files");
expect(document.activeElement).toBe(input);
});
it("hides workspace modes but keeps commands and tasks outside a task workbench", () => {
const onScopeChange = vi.fn();
render(
,
);
const input = screen.getByRole("combobox");
expect(screen.getAllByRole("tab").map((tab) => tab.getAttribute("aria-label"))).toEqual([
"Commands",
"Tasks",
]);
// Tab still cycles, but only between the two scopes that need no worktree.
expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(false);
expect(onScopeChange).toHaveBeenLastCalledWith("search-tasks");
expect(fireEvent.keyDown(input, { key: "Tab", shiftKey: true })).toBe(false);
expect(onScopeChange).toHaveBeenLastCalledWith("search-tasks");
});
});
describe("CommandPanelView search-only commands", () => {
const FONT_SIZE_LABEL = "Terminal Font Size";
const goToSettings = {
id: "nav-settings",
label: "Go to Settings",
group: "Navigation",
action: vi.fn(),
};
const fontSize = {
id: "setting:terminal-font-size",
label: FONT_SIZE_LABEL,
group: "Settings",
context: "Settings › Terminal & Editors",
searchOnly: true,
action: vi.fn(),
};
it("keeps granular settings hidden before typing", () => {
render(
,
);
expect(screen.getByText("Go to Settings")).toBeTruthy();
expect(screen.queryByText(FONT_SIZE_LABEL)).toBeNull();
});
it("shows a matching granular setting with owning context after typing", () => {
render(
,
);
expect(screen.getByText(FONT_SIZE_LABEL)).toBeTruthy();
expect(screen.getByText("Settings › Terminal & Editors")).toBeTruthy();
expect(screen.getByText("Settings", { selector: CMDK_GROUP_HEADING_SELECTOR })).toBeTruthy();
expect(screen.queryByText("Commands", { selector: CMDK_GROUP_HEADING_SELECTOR })).toBeNull();
});
it("separates regular and granular matches into Commands and Settings", () => {
const fontSizeGuide = {
id: "help:terminal-font-size",
label: "Terminal Font Size Guide",
group: "Help",
action: vi.fn(),
};
render(
,
);
expect(
screen
.getByText("Terminal Font Size Guide")
.closest("[cmdk-group]")
?.querySelector(CMDK_GROUP_HEADING_SELECTOR)?.textContent,
).toBe("Commands");
expect(
screen
.getByText(FONT_SIZE_LABEL, { exact: true })
.closest("[cmdk-group]")
?.querySelector(CMDK_GROUP_HEADING_SELECTOR)?.textContent,
).toBe("Settings");
});
});
describe("CommandPanelView mode result safety", () => {
it("does not delegate filtering to cmdk while palette mode groups swap", () => {
render( );
expect(screen.getByTestId("command-root").getAttribute("data-should-filter")).toBe("false");
});
it("filters command results before rendering them", () => {
render(
,
);
expect(screen.getByText("Needle command")).toBeTruthy();
expect(screen.queryByText("Unrelated action")).toBeNull();
});
it("returns a stale workspace-search mode to commands when context disappears", () => {
const onScopeChange = vi.fn();
render(
,
);
expect(onScopeChange).toHaveBeenCalledWith("commands");
});
it("groups file matches by repository", () => {
render(
,
);
const groups = screen.getAllByTestId("file-search-repo-group");
expect(groups).toHaveLength(2);
expect(groups[0].getAttribute("data-repository")).toBe("backend");
expect(groups[1].getAttribute("data-repository")).toBe("frontend");
});
});
describe("CommandPanelView confirmations", () => {
it("labels generic confirmations from their command and hides the command row", () => {
const confirmationCommand = {
id: "destructive-command",
label: CONFIRMATION_LABEL,
group: ACTIONS_GROUP,
confirmation: Confirm removal
,
};
const regularCommand = {
id: "regular-command",
label: "Keep working",
group: ACTIONS_GROUP,
};
render(
,
);
expect(screen.getByRole("alertdialog", { name: CONFIRMATION_LABEL })).toBeTruthy();
expect(screen.getByText("Confirm removal")).toBeTruthy();
expect(screen.queryByText(CONFIRMATION_LABEL)).toBeNull();
expect(screen.getByText("Keep working")).toBeTruthy();
});
it("dismisses an owned confirmation when the palette closes", () => {
const onConfirmationDismiss = vi.fn();
const setOpen = vi.fn();
const confirmationCommand = {
id: "destructive-command",
label: CONFIRMATION_LABEL,
group: ACTIONS_GROUP,
confirmation: Confirm removal
,
onConfirmationDismiss,
};
render(
,
);
fireEvent.click(screen.getByRole("button", { name: "Dismiss test dialog" }));
expect(onConfirmationDismiss).toHaveBeenCalledOnce();
expect(setOpen).toHaveBeenCalledWith(false);
});
});
function task(id: string, title: string): Task {
return {
id: taskId(id),
workspace_id: workspaceId("workspace-1"),
workflow_id: workflowId("workflow-1"),
workflow_step_id: "step-1",
position: 0,
title,
description: "",
state: "IN_PROGRESS",
priority: "medium",
created_at: "2026-08-24T09:00:00Z",
updated_at: "2026-08-24T09:00:00Z",
};
}
describe("CommandPanelView commands scope ordering", () => {
const archiveCommand = {
id: "task-archive",
label: "Archive task",
group: "Task",
action: vi.fn(),
};
function groupHeadings() {
return Array.from(document.querySelectorAll(CMDK_GROUP_HEADING_SELECTOR)).map(
(heading) => heading.textContent,
);
}
it("puts matching commands above the task preview once the user types", () => {
render(
,
);
expect(groupHeadings()).toEqual(["Commands", "Tasks"]);
});
it("keeps the active task list on top while nothing is typed", () => {
render(
,
);
expect(groupHeadings()).toEqual(["Active Tasks", "Task"]);
});
});
describe("CommandPanelView tasks scope", () => {
it("lists task results and opens the one that is picked", () => {
const target = task("task-1", "Palette task");
const props = viewProps({
mode: MODE_SEARCH_TASKS,
search: "palette",
taskResults: [target],
});
render( );
expect(screen.getByPlaceholderText("Search for tasks...")).toBeTruthy();
expect(screen.getByText("Palette task")).toBeTruthy();
expect(screen.getByRole("tab", { name: "Tasks" }).getAttribute(ARIA_SELECTED_ATTRIBUTE)).toBe(
"true",
);
fireEvent.click(screen.getByText("Palette task"));
expect(props.handleTaskSelect).toHaveBeenCalledWith(target);
});
it("reports an empty task search instead of falling back to commands", () => {
render(
,
);
expect(screen.getByText("No tasks found.")).toBeTruthy();
expect(screen.queryByText("Go Home")).toBeNull();
});
});