feat(search): show recent issues in cmd+k dialog (#656)

* feat(search): show recent issues list when cmd+k opens

When opening the cmd+k search dialog, display a list of recently visited
issues instead of the empty placeholder. Visits are tracked via a
workspace-scoped persisted Zustand store (max 20 items).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(search): close cmd+k dialog on single ESC press

cmdk was consuming the first ESC to clear internal state, requiring a
second press to close the dialog. Intercept ESC on the CommandPrimitive
and close the dialog directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(search): move ESC handler to input to prevent double-ESC

The previous handler on CommandPrimitive didn't fire because cmdk
intercepts ESC at the input level. Moving the onKeyDown to
CommandPrimitive.Input ensures it fires before cmdk processes it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(search): use capture-phase ESC listener to close dialog reliably

The previous onKeyDown approach on the Input didn't work because
base-ui Dialog's internal focus management handled ESC before the
React synthetic event. Use a document-level capture-phase listener
that fires before all other handlers and stops propagation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(search): cover single-escape command palette close

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jiayuan Zhang
2026-04-10 20:48:43 +08:00
committed by GitHub
parent 9b62485a86
commit 54d452e20d
7 changed files with 200 additions and 2 deletions

View File

@@ -1,5 +1,6 @@
export { useIssueSelectionStore } from "./selection-store";
export { useIssueDraftStore } from "./draft-store";
export { useRecentIssuesStore, type RecentIssueEntry } from "./recent-issues-store";
export {
ViewStoreProvider,
useViewStore,

View File

@@ -0,0 +1,52 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { IssueStatus } from "../../types";
import {
createWorkspaceAwareStorage,
registerForWorkspaceRehydration,
} from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
const MAX_RECENT_ISSUES = 20;
export interface RecentIssueEntry {
id: string;
identifier: string;
title: string;
status: IssueStatus;
visitedAt: number;
}
interface RecentIssuesState {
items: RecentIssueEntry[];
recordVisit: (entry: Omit<RecentIssueEntry, "visitedAt">) => void;
}
export const useRecentIssuesStore = create<RecentIssuesState>()(
persist(
(set) => ({
items: [],
recordVisit: (entry) =>
set((state) => {
const filtered = state.items.filter((i) => i.id !== entry.id);
const updated: RecentIssueEntry = { ...entry, visitedAt: Date.now() };
return {
items: [updated, ...filtered].slice(0, MAX_RECENT_ISSUES),
};
}),
}),
{
name: "multica_recent_issues",
storage: createJSONStorage(() =>
createWorkspaceAwareStorage(defaultStorage),
),
partialize: (state) => ({ items: state.items }),
},
),
);
registerForWorkspaceRehydration(() =>
useRecentIssuesStore.persist.rehydrate(),
);

View File

@@ -210,6 +210,18 @@ vi.mock("@multica/core/issues/config", () => ({
},
}));
// Mock recent issues store
const mockRecordVisit = vi.fn();
vi.mock("@multica/core/issues/stores", () => ({
useRecentIssuesStore: Object.assign(
(selector?: any) => {
const state = { items: [], recordVisit: mockRecordVisit };
return selector ? selector(state) : state;
},
{ getState: () => ({ items: [], recordVisit: mockRecordVisit }) },
),
}));
// Mock modals
vi.mock("@multica/core/modals", () => ({
useModalStore: Object.assign(

View File

@@ -70,6 +70,7 @@ import { useWorkspaceId } from "@multica/core/hooks";
import { issueListOptions, issueDetailOptions, childIssuesOptions, issueUsageOptions } from "@multica/core/issues/queries";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
import { useUpdateIssue, useDeleteIssue } from "@multica/core/issues/mutations";
import { useRecentIssuesStore } from "@multica/core/issues/stores";
import { useIssueTimeline } from "../hooks/use-issue-timeline";
import { useIssueReactions } from "../hooks/use-issue-reactions";
import { useIssueSubscribers } from "../hooks/use-issue-subscribers";
@@ -231,6 +232,19 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
},
});
// Record recent visit
const recordVisit = useRecentIssuesStore((s) => s.recordVisit);
useEffect(() => {
if (issue) {
recordVisit({
id: issue.id,
identifier: issue.identifier,
title: issue.title,
status: issue.status,
});
}
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Custom hooks — encapsulate timeline, reactions, subscribers
const {
timeline, loading: timelineLoading, submitComment, submitReply,

View File

@@ -131,6 +131,9 @@ const mockViewState = {
vi.mock("@multica/core/issues/stores/view-store", () => ({
initFilterWorkspaceSync: vi.fn(),
registerViewStoreForWorkspaceSync: vi.fn(),
viewStorePersistOptions: () => ({ name: "test", storage: undefined, partialize: (s: any) => s }),
viewStoreSlice: vi.fn(),
useIssueViewStore: Object.assign(
(selector?: any) => (selector ? selector(mockViewState) : mockViewState),
{ getState: () => mockViewState, setState: vi.fn() },
@@ -181,6 +184,16 @@ vi.mock("@multica/core/issues/stores/selection-store", () => ({
),
}));
vi.mock("@multica/core/issues/stores/recent-issues-store", () => ({
useRecentIssuesStore: Object.assign(
(selector?: any) => {
const state = { items: [], recordVisit: vi.fn() };
return selector ? selector(state) : state;
},
{ getState: () => ({ items: [], recordVisit: vi.fn() }) },
),
}));
vi.mock("@multica/core/modals", () => ({
useModalStore: Object.assign(
() => ({ open: vi.fn() }),

View File

@@ -0,0 +1,59 @@
import { act } from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SearchCommand } from "./search-command";
import { useSearchStore } from "./search-store";
const { mockPush, mockSearchIssues } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockSearchIssues: vi.fn(),
}));
vi.mock("@multica/core/api", () => ({
api: {
searchIssues: mockSearchIssues,
},
}));
vi.mock("@multica/core/issues/stores", () => ({
useRecentIssuesStore: (selector?: (state: { items: [] }) => unknown) => {
const state = { items: [] as [] };
return selector ? selector(state) : state;
},
}));
vi.mock("../navigation", () => ({
useNavigation: () => ({
push: mockPush,
}),
}));
describe("SearchCommand", () => {
beforeEach(() => {
mockPush.mockReset();
mockSearchIssues.mockReset().mockResolvedValue({ issues: [] });
act(() => {
useSearchStore.setState({ open: true });
});
});
it("closes on a single Escape press from the search input", async () => {
const user = userEvent.setup();
render(<SearchCommand />);
const input = screen.getByPlaceholderText("Type a command or search...");
await user.click(input);
expect(useSearchStore.getState().open).toBe(true);
await user.keyboard("{Escape}");
await waitFor(() => {
expect(useSearchStore.getState().open).toBe(false);
});
expect(screen.queryByPlaceholderText("Type a command or search...")).not.toBeInTheDocument();
});
});

View File

@@ -1,10 +1,11 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Loader2, MessageSquare, SearchIcon } from "lucide-react";
import { Clock, Loader2, MessageSquare, SearchIcon } from "lucide-react";
import { Command as CommandPrimitive } from "cmdk";
import type { SearchIssueResult } from "@multica/core/types";
import { api } from "@multica/core/api";
import { useRecentIssuesStore } from "@multica/core/issues/stores";
import { StatusIcon } from "../issues/components";
import { STATUS_CONFIG } from "@multica/core/issues/config";
import {
@@ -57,6 +58,7 @@ export function SearchCommand() {
const { push } = useNavigation();
const open = useSearchStore((s) => s.open);
const setOpen = useSearchStore((s) => s.setOpen);
const recentIssues = useRecentIssuesStore((s) => s.items);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchIssueResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -75,6 +77,20 @@ export function SearchCommand() {
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
// Close on single ESC — capture phase fires before base-ui Dialog's handlers
useEffect(() => {
if (!open) return;
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setOpen(false);
}
};
document.addEventListener("keydown", handleEsc, true);
return () => document.removeEventListener("keydown", handleEsc, true);
}, [open, setOpen]);
// Cleanup debounce/abort on unmount
useEffect(() => {
return () => {
@@ -228,7 +244,38 @@ export function SearchCommand() {
</CommandPrimitive.Group>
)}
{!isLoading && !query.trim() && (
{!isLoading && !query.trim() && recentIssues.length > 0 && (
<CommandPrimitive.Group className="p-2">
<div className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium text-muted-foreground">
<Clock className="size-3" />
<span>Recent</span>
</div>
{recentIssues.map((item) => (
<CommandPrimitive.Item
key={item.id}
value={item.id}
onSelect={handleSelect}
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"
>
<StatusIcon
status={item.status}
className="size-4 shrink-0"
/>
<span className="text-xs text-muted-foreground shrink-0">
{item.identifier}
</span>
<span className="truncate">{item.title}</span>
<span
className={`ml-auto text-xs shrink-0 ${STATUS_CONFIG[item.status]?.iconColor ?? ""}`}
>
{STATUS_CONFIG[item.status]?.label ?? ""}
</span>
</CommandPrimitive.Item>
))}
</CommandPrimitive.Group>
)}
{!isLoading && !query.trim() && recentIssues.length === 0 && (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-muted-foreground">
<span>Type to search issues...</span>
<span className="text-xs">Press <kbd className="rounded bg-muted px-1.5 py-0.5 font-medium">K</kbd> to open this anytime</span>