Compare commits

...

3 Commits

Author SHA1 Message Date
Jiayuan Zhang
8208bfe814 merge: resolve conflicts with main (project search feature)
Integrate page navigation with the new project search feature from main.
The cmd+k dialog now supports pages, projects, and issues search.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:15:04 +08:00
Jiayuan Zhang
2a1b269051 fix(search): only show pages when query is entered
Pages section was pushing down the Recent Issues list when the dialog
first opens. Now pages only appear when the user types a matching query.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:08:26 +08:00
Jiayuan Zhang
f33cae878a feat(search): add page navigation to cmd+k command palette
Users can now search and navigate to sidebar pages (Inbox, My Issues,
Issues, Projects, Agents, Runtimes, Skills, Settings) directly from
the cmd+k dialog. Pages are shown in a dedicated "Pages" group and
filtered by query with keyword matching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:03:30 +08:00
5 changed files with 120 additions and 4 deletions

1
.worktree-backend.pid Normal file
View File

@@ -0,0 +1 @@
10462

1
.worktree-daemon.pid Normal file
View File

@@ -0,0 +1 @@
10589

1
.worktree-frontend.pid Normal file
View File

@@ -0,0 +1 @@
10473

View File

@@ -5,14 +5,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { SearchCommand } from "./search-command";
import { useSearchStore } from "./search-store";
const { mockPush, mockSearchIssues } = vi.hoisted(() => ({
const { mockPush, mockSearchIssues, mockSearchProjects } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockSearchIssues: vi.fn(),
mockSearchProjects: vi.fn(),
}));
vi.mock("@multica/core/api", () => ({
api: {
searchIssues: mockSearchIssues,
searchProjects: mockSearchProjects,
},
}));
@@ -33,6 +35,10 @@ describe("SearchCommand", () => {
beforeEach(() => {
mockPush.mockReset();
mockSearchIssues.mockReset().mockResolvedValue({ issues: [] });
mockSearchProjects.mockReset().mockResolvedValue({ projects: [] });
// cmdk calls scrollIntoView on the first selected item, which jsdom doesn't implement
Element.prototype.scrollIntoView = vi.fn();
act(() => {
useSearchStore.setState({ open: true });
@@ -56,4 +62,39 @@ describe("SearchCommand", () => {
});
expect(screen.queryByPlaceholderText("Type a command or search...")).not.toBeInTheDocument();
});
it("does not show pages when no query is entered", () => {
render(<SearchCommand />);
expect(screen.queryByText("Pages")).not.toBeInTheDocument();
});
it("filters navigation pages by query", async () => {
const user = userEvent.setup();
render(<SearchCommand />);
const input = screen.getByPlaceholderText("Type a command or search...");
await user.type(input, "set");
await waitFor(() => {
// HighlightText splits text, so use a function matcher
expect(screen.getByText((_, el) => el?.textContent === "Settings" && el?.tagName === "SPAN")).toBeInTheDocument();
});
expect(screen.queryByText("Inbox")).not.toBeInTheDocument();
expect(screen.queryByText("Projects")).not.toBeInTheDocument();
});
it("navigates to page on selection", async () => {
const user = userEvent.setup();
render(<SearchCommand />);
const input = screen.getByPlaceholderText("Type a command or search...");
await user.type(input, "settings");
const settingsItem = await screen.findByText("Settings");
await user.click(settingsItem);
expect(mockPush).toHaveBeenCalledWith("/settings");
expect(useSearchStore.getState().open).toBe(false);
});
});

View File

@@ -1,7 +1,21 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Clock, FolderKanban, Loader2, MessageSquare, SearchIcon } from "lucide-react";
import {
Clock,
Loader2,
MessageSquare,
SearchIcon,
Inbox,
CircleUser,
ListTodo,
FolderKanban,
Bot,
Monitor,
BookOpenText,
Settings,
type LucideIcon,
} from "lucide-react";
import { Command as CommandPrimitive } from "cmdk";
import type { SearchIssueResult, SearchProjectResult } from "@multica/core/types";
import { api } from "@multica/core/api";
@@ -56,6 +70,24 @@ function HighlightText({ text, query }: { text: string; query: string }) {
);
}
interface NavPage {
href: string;
label: string;
icon: LucideIcon;
keywords: string[];
}
const navPages: NavPage[] = [
{ href: "/inbox", label: "Inbox", icon: Inbox, keywords: ["inbox", "notifications"] },
{ href: "/my-issues", label: "My Issues", icon: CircleUser, keywords: ["my", "issues", "assigned"] },
{ href: "/issues", label: "Issues", icon: ListTodo, keywords: ["issues", "tasks", "bugs"] },
{ href: "/projects", label: "Projects", icon: FolderKanban, keywords: ["projects", "kanban"] },
{ href: "/agents", label: "Agents", icon: Bot, keywords: ["agents", "bots", "ai"] },
{ href: "/runtimes", label: "Runtimes", icon: Monitor, keywords: ["runtimes", "environments"] },
{ href: "/skills", label: "Skills", icon: BookOpenText, keywords: ["skills", "library"] },
{ href: "/settings", label: "Settings", icon: Settings, keywords: ["settings", "config", "preferences"] },
];
interface SearchResults {
issues: SearchIssueResult[];
projects: SearchProjectResult[];
@@ -72,6 +104,16 @@ export function SearchCommand() {
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const filteredPages = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return navPages.filter(
(page) =>
page.label.toLowerCase().includes(q) ||
page.keywords.some((kw) => kw.includes(q)),
);
}, [query]);
const hasResults = results.issues.length > 0 || results.projects.length > 0;
// Global Cmd+K / Ctrl+K shortcut
@@ -181,6 +223,14 @@ export function SearchCommand() {
[push, setOpen],
);
const handlePageSelect = useCallback(
(href: string) => {
setOpen(false);
push(href);
},
[push],
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
@@ -190,7 +240,7 @@ export function SearchCommand() {
<DialogHeader className="sr-only">
<DialogTitle>Search</DialogTitle>
<DialogDescription>
Search issues and projects by title or description
Search pages, issues, and projects
</DialogDescription>
</DialogHeader>
<CommandPrimitive
@@ -213,13 +263,35 @@ export function SearchCommand() {
{/* Results list */}
<CommandPrimitive.List className="max-h-[min(400px,50vh)] overflow-y-auto overflow-x-hidden">
{/* Pages section — only shown when query matches */}
{filteredPages.length > 0 && (
<CommandPrimitive.Group className="p-2">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Pages
</div>
{filteredPages.map((page) => (
<CommandPrimitive.Item
key={page.href}
value={`page:${page.href}`}
onSelect={() => handlePageSelect(page.href)}
className="flex cursor-default select-none items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
>
<page.icon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">
<HighlightText text={page.label} query={query} />
</span>
</CommandPrimitive.Item>
))}
</CommandPrimitive.Group>
)}
{isLoading && (
<div className="flex items-center justify-center py-10">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading && query.trim() && !hasResults && (
{!isLoading && query.trim() && !hasResults && filteredPages.length === 0 && (
<CommandPrimitive.Empty className="py-10 text-center text-sm text-muted-foreground">
No results found.
</CommandPrimitive.Empty>