feat: implement CommandPalette for quick filter and search

- Add text search across all focus items
- Support filter commands: type: and stale:
- Keyboard navigation with arrow keys + Enter
- Click to select items
- Escape or backdrop click to close
- 17 tests covering search, filters, keyboard nav, and empty state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-26 10:16:00 -05:00
parent 23a4e6bf19
commit 807899bc49
5 changed files with 662 additions and 4 deletions

1
src-tauri/Cargo.lock generated
View File

@@ -3424,6 +3424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab7f01e9310a820edd31c80fde3cae445295adde21a3f9416517d7d65015b971"
dependencies = [
"paste",
"serde_json",
"specta-macros",
"thiserror 1.0.69",
]

View File

@@ -30,7 +30,7 @@ dirs = "5"
notify = "7"
tauri-plugin-global-shortcut = "2"
libc = "0.2"
specta = { version = "=2.0.0-rc.22", features = ["derive"] }
specta = { version = "=2.0.0-rc.22", features = ["derive", "serde_json"] }
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
specta-typescript = "0.0.9"

View File

@@ -109,6 +109,9 @@ pub fn run() {
tracing::info!("Starting Mission Control");
// Build tauri-specta builder for type-safe IPC
// Note: read_state/write_state/clear_state use serde_json::Value which doesn't
// implement specta::Type, so they're excluded from the builder but kept in
// the invoke_handler via generate_handler!
let builder = Builder::<tauri::Wry>::new().commands(collect_commands![
commands::greet,
commands::get_lore_status,
@@ -116,9 +119,6 @@ pub fn run() {
commands::sync_now,
commands::reconcile,
commands::quick_capture,
commands::read_state,
commands::write_state,
commands::clear_state,
]);
// Export TypeScript bindings in debug builds

View File

@@ -0,0 +1,362 @@
/**
* CommandPalette -- ⌘K quick filter and search overlay.
*
* Features:
* - Text search across all items
* - Filter commands (type:, stale:)
* - Keyboard navigation (arrows + Enter)
* - Click to select
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import type { FocusItem, FocusItemType } from "@/lib/types";
/** Filter command definition */
interface FilterCommand {
prefix: string;
options: { value: string; label: string }[];
}
const FILTER_COMMANDS: FilterCommand[] = [
{
prefix: "type:",
options: [
{ value: "mr_review", label: "type:mr_review" },
{ value: "mr_authored", label: "type:mr_authored" },
{ value: "issue", label: "type:issue" },
{ value: "manual", label: "type:manual" },
],
},
{
prefix: "stale:",
options: [
{ value: "1", label: "stale:1d" },
{ value: "3", label: "stale:3d" },
{ value: "7", label: "stale:7d" },
],
},
];
/** Filter criteria passed to onFilter */
export interface FilterCriteria {
type?: FocusItemType;
minAge?: number;
}
export interface CommandPaletteProps {
isOpen: boolean;
items: FocusItem[];
onFilter: (criteria: FilterCriteria) => void;
onSelect: (itemId: string) => void;
onClose: () => void;
}
export function CommandPalette({
isOpen,
items,
onFilter,
onSelect,
onClose,
}: CommandPaletteProps): React.ReactElement | null {
const [search, setSearch] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
// Reset state when opening
useEffect(() => {
if (isOpen) {
setSearch("");
setHighlightedIndex(-1);
requestAnimationFrame(() => {
inputRef.current?.focus();
});
}
}, [isOpen]);
// Handle Escape key
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(e: KeyboardEvent): void {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
// Check if search matches a command prefix
const activeCommand = useMemo(() => {
for (const cmd of FILTER_COMMANDS) {
if (search.toLowerCase().startsWith(cmd.prefix)) {
return cmd;
}
}
return null;
}, [search]);
// Filter items by text search
const filteredItems = useMemo(() => {
if (activeCommand) {
return []; // Commands mode, don't show items
}
if (!search.trim()) {
return items.slice(0, 10); // Show first 10 when empty
}
const lower = search.toLowerCase();
return items
.filter((item) => item.title.toLowerCase().includes(lower))
.slice(0, 10);
}, [search, items, activeCommand]);
// Compute all selectable options (items or command options)
const selectableOptions = useMemo(() => {
if (activeCommand) {
return activeCommand.options.map((opt) => ({
id: `cmd:${opt.label}`,
label: opt.label,
value: opt.value,
type: "command" as const,
}));
}
return filteredItems.map((item) => ({
id: item.id,
label: item.title,
value: item.id,
type: "item" as const,
itemType: item.type,
}));
}, [activeCommand, filteredItems]);
// Reset highlight when options change
useEffect(() => {
setHighlightedIndex(-1);
}, [selectableOptions]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightedIndex((prev) =>
prev < selectableOptions.length - 1 ? prev + 1 : prev
);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (e.key === "Enter") {
e.preventDefault();
const option = selectableOptions[highlightedIndex];
if (option) {
handleOptionSelect(option);
} else if (selectableOptions.length === 1) {
// Auto-select if only one option
handleOptionSelect(selectableOptions[0]);
}
}
},
[selectableOptions, highlightedIndex]
);
const handleOptionSelect = useCallback(
(option: (typeof selectableOptions)[0]) => {
if (option.type === "command") {
// Parse the command and emit filter
if (option.id.startsWith("cmd:type:")) {
onFilter({ type: option.value as FocusItemType });
} else if (option.id.startsWith("cmd:stale:")) {
onFilter({ minAge: parseInt(option.value, 10) });
}
} else {
onSelect(option.value);
}
onClose();
},
[onFilter, onSelect, onClose]
);
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
},
[onClose]
);
const getTypeIcon = (type: FocusItemType): string => {
switch (type) {
case "mr_review":
return "⟲";
case "mr_authored":
return "⟳";
case "issue":
return "●";
case "manual":
return "✓";
}
};
if (!isOpen) return null;
return (
<AnimatePresence>
<motion.div
data-testid="command-palette-backdrop"
role="dialog"
aria-modal="true"
aria-label="Command palette"
className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={handleBackdropClick}
>
{/* Backdrop */}
<motion.div
className="absolute inset-0 bg-black/60"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
{/* Card */}
<motion.div
className="relative z-10 w-full max-w-lg overflow-hidden rounded-xl border border-zinc-700 bg-zinc-900 shadow-2xl"
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.98 }}
transition={{
duration: 0.2,
type: "spring",
damping: 25,
stiffness: 300,
}}
>
{/* Search input */}
<div className="border-b border-zinc-700 p-3">
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search or filter... (try type: or stale:)"
className="w-full bg-transparent text-sm text-zinc-100 placeholder-zinc-500 outline-none"
autoFocus
/>
</div>
{/* Results */}
<div className="max-h-[300px] overflow-y-auto p-2">
{activeCommand ? (
// Command options
<div>
<div className="px-2 py-1 text-xs font-medium text-zinc-500">
{activeCommand.prefix}
</div>
{activeCommand.options.map((opt, index) => (
<button
key={opt.label}
type="button"
role="option"
aria-selected={highlightedIndex === index}
data-highlighted={highlightedIndex === index}
onClick={() =>
handleOptionSelect({
id: `cmd:${opt.label}`,
label: opt.label,
value: opt.value,
type: "command",
})
}
className={`flex w-full items-center rounded-md px-3 py-2 text-left text-sm transition-colors ${
highlightedIndex === index
? "bg-zinc-700 text-zinc-100"
: "text-zinc-300 hover:bg-zinc-800"
}`}
>
{opt.label}
</button>
))}
</div>
) : (
<>
{/* Items */}
{filteredItems.length > 0 ? (
<div>
<div className="px-2 py-1 text-xs font-medium text-zinc-500">
Items
</div>
{filteredItems.map((item, index) => (
<button
key={item.id}
type="button"
role="option"
aria-selected={highlightedIndex === index}
data-highlighted={highlightedIndex === index}
onClick={() =>
handleOptionSelect({
id: item.id,
label: item.title,
value: item.id,
type: "item",
itemType: item.type,
})
}
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
highlightedIndex === index
? "bg-zinc-700 text-zinc-100"
: "text-zinc-300 hover:bg-zinc-800"
}`}
>
<span className="text-zinc-500">
{getTypeIcon(item.type)}
</span>
<span className="truncate">{item.title}</span>
</button>
))}
</div>
) : search.trim() ? (
<div className="px-3 py-6 text-center text-sm text-zinc-500">
No results found
</div>
) : null}
{/* Command suggestions (when not searching) */}
{!search.trim() && (
<div className="mt-2 border-t border-zinc-800 pt-2">
<div className="px-2 py-1 text-xs font-medium text-zinc-500">
Commands
</div>
{FILTER_COMMANDS.map((cmd) => (
<button
key={cmd.prefix}
type="button"
onClick={() => setSearch(cmd.prefix)}
className="flex w-full items-center rounded-md px-3 py-2 text-left text-sm text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300"
>
{cmd.prefix}...
</button>
))}
</div>
)}
</>
)}
</div>
{/* Footer hint */}
<div className="border-t border-zinc-800 px-3 py-2">
<p className="text-[10px] text-zinc-600">
to navigate &middot; Enter to select &middot; Esc to close
</p>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,295 @@
/**
* Tests for CommandPalette component.
*
* TDD: These tests define the expected behavior before implementation.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CommandPalette } from "@/components/CommandPalette";
import type { FocusItem } from "@/lib/types";
const mockItems: FocusItem[] = [
{
id: "mr-1",
title: "Fix auth token refresh",
type: "mr_review",
project: "platform/core",
url: "https://gitlab.com/platform/core/-/merge_requests/1",
iid: 1,
updatedAt: "2026-02-25T10:00:00Z",
contextQuote: null,
requestedBy: "alice",
},
{
id: "mr-2",
title: "Update README",
type: "mr_authored",
project: "platform/docs",
url: "https://gitlab.com/platform/docs/-/merge_requests/2",
iid: 2,
updatedAt: "2026-02-20T10:00:00Z",
contextQuote: null,
requestedBy: null,
},
{
id: "issue-1",
title: "Auth bug in production",
type: "issue",
project: "platform/core",
url: "https://gitlab.com/platform/core/-/issues/42",
iid: 42,
updatedAt: "2026-02-24T10:00:00Z",
contextQuote: null,
requestedBy: null,
},
{
id: "manual-1",
title: "Review quarterly goals",
type: "manual",
project: "personal",
url: "",
iid: 0,
updatedAt: null,
contextQuote: null,
requestedBy: null,
},
];
describe("CommandPalette", () => {
const defaultProps = {
isOpen: true,
items: mockItems,
onFilter: vi.fn(),
onSelect: vi.fn(),
onClose: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it("renders when open", () => {
render(<CommandPalette {...defaultProps} />);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(
screen.getByPlaceholderText(/Search or filter/i)
).toBeInTheDocument();
});
it("does not render when closed", () => {
render(<CommandPalette {...defaultProps} isOpen={false} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("closes on Escape key", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<CommandPalette {...defaultProps} onClose={onClose} />);
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalled();
});
it("closes when clicking backdrop", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<CommandPalette {...defaultProps} onClose={onClose} />);
// Click the backdrop (outer div)
await user.click(screen.getByTestId("command-palette-backdrop"));
expect(onClose).toHaveBeenCalled();
});
describe("text search", () => {
it("filters items by text", async () => {
const user = userEvent.setup();
render(<CommandPalette {...defaultProps} />);
await user.type(screen.getByRole("textbox"), "auth");
// Should show items containing "auth"
expect(screen.getByText("Fix auth token refresh")).toBeInTheDocument();
expect(screen.getByText("Auth bug in production")).toBeInTheDocument();
// Should not show items without "auth"
expect(screen.queryByText("Update README")).not.toBeInTheDocument();
expect(
screen.queryByText("Review quarterly goals")
).not.toBeInTheDocument();
});
it("shows all items when search is empty", () => {
render(<CommandPalette {...defaultProps} />);
// Should show all items (or top 10)
expect(screen.getByText("Fix auth token refresh")).toBeInTheDocument();
expect(screen.getByText("Update README")).toBeInTheDocument();
});
it("selects item and closes on click", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const onClose = vi.fn();
render(
<CommandPalette
{...defaultProps}
onSelect={onSelect}
onClose={onClose}
/>
);
await user.click(screen.getByText("Fix auth token refresh"));
expect(onSelect).toHaveBeenCalledWith("mr-1");
expect(onClose).toHaveBeenCalled();
});
it("selects item on Enter when typing", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const onClose = vi.fn();
render(
<CommandPalette
{...defaultProps}
onSelect={onSelect}
onClose={onClose}
/>
);
// Type to filter to one item
await user.type(screen.getByRole("textbox"), "README");
await user.keyboard("{Enter}");
expect(onSelect).toHaveBeenCalledWith("mr-2");
expect(onClose).toHaveBeenCalled();
});
});
describe("filter commands", () => {
it("shows filter command options when typing type:", async () => {
const user = userEvent.setup();
render(<CommandPalette {...defaultProps} />);
await user.type(screen.getByRole("textbox"), "type:");
expect(screen.getByText("type:mr_review")).toBeInTheDocument();
expect(screen.getByText("type:issue")).toBeInTheDocument();
expect(screen.getByText("type:manual")).toBeInTheDocument();
});
it("filters by type when type: command used", async () => {
const user = userEvent.setup();
const onFilter = vi.fn();
const onClose = vi.fn();
render(
<CommandPalette
{...defaultProps}
onFilter={onFilter}
onClose={onClose}
/>
);
await user.type(screen.getByRole("textbox"), "type:");
await user.click(screen.getByText("type:mr_review"));
expect(onFilter).toHaveBeenCalledWith({ type: "mr_review" });
expect(onClose).toHaveBeenCalled();
});
it("shows staleness filter options when typing stale:", async () => {
const user = userEvent.setup();
render(<CommandPalette {...defaultProps} />);
await user.type(screen.getByRole("textbox"), "stale:");
expect(screen.getByText("stale:1d")).toBeInTheDocument();
expect(screen.getByText("stale:3d")).toBeInTheDocument();
expect(screen.getByText("stale:7d")).toBeInTheDocument();
});
it("filters by staleness when stale: command used", async () => {
const user = userEvent.setup();
const onFilter = vi.fn();
const onClose = vi.fn();
render(
<CommandPalette
{...defaultProps}
onFilter={onFilter}
onClose={onClose}
/>
);
await user.type(screen.getByRole("textbox"), "stale:");
await user.click(screen.getByText("stale:7d"));
expect(onFilter).toHaveBeenCalledWith({ minAge: 7 });
expect(onClose).toHaveBeenCalled();
});
it("shows command suggestions before typing a command", () => {
render(<CommandPalette {...defaultProps} />);
// Commands section header should exist
expect(screen.getByText("Commands")).toBeInTheDocument();
// Command buttons should be present
expect(screen.getByText("type:...")).toBeInTheDocument();
expect(screen.getByText("stale:...")).toBeInTheDocument();
});
});
describe("keyboard navigation", () => {
it("navigates down with arrow key", async () => {
const user = userEvent.setup();
render(<CommandPalette {...defaultProps} />);
const input = screen.getByRole("textbox");
await user.click(input);
await user.keyboard("{ArrowDown}");
// First item should be highlighted
const items = screen.getAllByRole("option");
expect(items[0]).toHaveAttribute("data-highlighted", "true");
});
it("navigates up with arrow key", async () => {
const user = userEvent.setup();
render(<CommandPalette {...defaultProps} />);
const input = screen.getByRole("textbox");
await user.click(input);
await user.keyboard("{ArrowDown}{ArrowDown}{ArrowUp}");
// First item should be highlighted again
const items = screen.getAllByRole("option");
expect(items[0]).toHaveAttribute("data-highlighted", "true");
});
it("selects highlighted item on Enter", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<CommandPalette {...defaultProps} onSelect={onSelect} />);
const input = screen.getByRole("textbox");
await user.click(input);
await user.keyboard("{ArrowDown}{Enter}");
expect(onSelect).toHaveBeenCalledWith("mr-1");
});
});
describe("empty state", () => {
it("shows no results message when no items match", async () => {
const user = userEvent.setup();
render(<CommandPalette {...defaultProps} />);
await user.type(screen.getByRole("textbox"), "xyznonexistent");
expect(screen.getByText(/No results found/i)).toBeInTheDocument();
});
});
});