mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
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:
@@ -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,
|
||||
|
||||
52
packages/core/issues/stores/recent-issues-store.ts
Normal file
52
packages/core/issues/stores/recent-issues-store.ts
Normal 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(),
|
||||
);
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }),
|
||||
|
||||
59
packages/views/search/search-command.test.tsx
Normal file
59
packages/views/search/search-command.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user