diff --git a/packages/core/issues/stores/index.ts b/packages/core/issues/stores/index.ts index 2e0f3ff49..19af5fc55 100644 --- a/packages/core/issues/stores/index.ts +++ b/packages/core/issues/stores/index.ts @@ -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, diff --git a/packages/core/issues/stores/recent-issues-store.ts b/packages/core/issues/stores/recent-issues-store.ts new file mode 100644 index 000000000..d8c5f6740 --- /dev/null +++ b/packages/core/issues/stores/recent-issues-store.ts @@ -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) => void; +} + +export const useRecentIssuesStore = create()( + 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(), +); diff --git a/packages/views/issues/components/issue-detail.test.tsx b/packages/views/issues/components/issue-detail.test.tsx index 58d49a48c..224ec3510 100644 --- a/packages/views/issues/components/issue-detail.test.tsx +++ b/packages/views/issues/components/issue-detail.test.tsx @@ -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( diff --git a/packages/views/issues/components/issue-detail.tsx b/packages/views/issues/components/issue-detail.tsx index 7eef59ea4..a6b893a83 100644 --- a/packages/views/issues/components/issue-detail.tsx +++ b/packages/views/issues/components/issue-detail.tsx @@ -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, diff --git a/packages/views/issues/components/issues-page.test.tsx b/packages/views/issues/components/issues-page.test.tsx index 1f97aa769..de30fdfa4 100644 --- a/packages/views/issues/components/issues-page.test.tsx +++ b/packages/views/issues/components/issues-page.test.tsx @@ -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() }), diff --git a/packages/views/search/search-command.test.tsx b/packages/views/search/search-command.test.tsx new file mode 100644 index 000000000..5c6f39aab --- /dev/null +++ b/packages/views/search/search-command.test.tsx @@ -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(); + + 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(); + }); +}); diff --git a/packages/views/search/search-command.tsx b/packages/views/search/search-command.tsx index 3052f5608..54729c460 100644 --- a/packages/views/search/search-command.tsx +++ b/packages/views/search/search-command.tsx @@ -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([]); 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() { )} - {!isLoading && !query.trim() && ( + {!isLoading && !query.trim() && recentIssues.length > 0 && ( + +
+ + Recent +
+ {recentIssues.map((item) => ( + + + + {item.identifier} + + {item.title} + + {STATUS_CONFIG[item.status]?.label ?? ""} + + + ))} +
+ )} + + {!isLoading && !query.trim() && recentIssues.length === 0 && (
Type to search issues... Press ⌘K to open this anytime