Merge pull request #990 from multica-ai/NevilleQingNY/fix-cmdk-stale-status

fix(views): resolve stale status in cmd+k recent issues list
This commit is contained in:
Naiyuan Qing
2026-04-14 18:26:28 +08:00
committed by GitHub
4 changed files with 74 additions and 18 deletions

View File

@@ -2,7 +2,6 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { IssueStatus } from "../../types";
import {
createWorkspaceAwareStorage,
registerForWorkspaceRehydration,
@@ -13,25 +12,22 @@ 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;
recordVisit: (id: string) => void;
}
export const useRecentIssuesStore = create<RecentIssuesState>()(
persist(
(set) => ({
items: [],
recordVisit: (entry) =>
recordVisit: (id) =>
set((state) => {
const filtered = state.items.filter((i) => i.id !== entry.id);
const updated: RecentIssueEntry = { ...entry, visitedAt: Date.now() };
const filtered = state.items.filter((i) => i.id !== id);
const updated: RecentIssueEntry = { id, visitedAt: Date.now() };
return {
items: [updated, ...filtered].slice(0, MAX_RECENT_ISSUES),
};

View File

@@ -367,12 +367,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const recordVisit = useRecentIssuesStore((s) => s.recordVisit);
useEffect(() => {
if (issue) {
recordVisit({
id: issue.id,
identifier: issue.identifier,
title: issue.title,
status: issue.status,
});
recordVisit(issue.id);
}
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps

View File

@@ -5,10 +5,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { SearchCommand } from "./search-command";
import { useSearchStore } from "./search-store";
const { mockPush, mockSearchIssues, mockSearchProjects } = vi.hoisted(() => ({
const { mockPush, mockSearchIssues, mockSearchProjects, mockRecentItems, mockAllIssues } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockSearchIssues: vi.fn(),
mockSearchProjects: vi.fn(),
mockRecentItems: { current: [] as Array<{ id: string; visitedAt: number }> },
mockAllIssues: { current: [] as Array<Record<string, unknown>> },
}));
vi.mock("@multica/core/api", () => ({
@@ -19,12 +21,24 @@ vi.mock("@multica/core/api", () => ({
}));
vi.mock("@multica/core/issues/stores", () => ({
useRecentIssuesStore: (selector?: (state: { items: [] }) => unknown) => {
const state = { items: [] as [] };
useRecentIssuesStore: (selector?: (state: { items: typeof mockRecentItems.current }) => unknown) => {
const state = { items: mockRecentItems.current };
return selector ? selector(state) : state;
},
}));
vi.mock("@multica/core", () => ({
useWorkspaceId: () => "ws-test",
}));
vi.mock("@multica/core/issues/queries", () => ({
issueListOptions: () => ({ queryKey: ["issues", "ws-test", "list"], enabled: false }),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: mockAllIssues.current }),
}));
vi.mock("../navigation", () => ({
useNavigation: () => ({
push: mockPush,
@@ -36,6 +50,8 @@ describe("SearchCommand", () => {
mockPush.mockReset();
mockSearchIssues.mockReset().mockResolvedValue({ issues: [] });
mockSearchProjects.mockReset().mockResolvedValue({ projects: [] });
mockRecentItems.current = [];
mockAllIssues.current = [];
// cmdk calls scrollIntoView on the first selected item, which jsdom doesn't implement
Element.prototype.scrollIntoView = vi.fn();
@@ -97,4 +113,39 @@ describe("SearchCommand", () => {
expect(mockPush).toHaveBeenCalledWith("/settings");
expect(useSearchStore.getState().open).toBe(false);
});
it("renders recent issues from query cache joined with store visit records", () => {
mockRecentItems.current = [
{ id: "issue-1", visitedAt: 1000 },
{ id: "issue-2", visitedAt: 900 },
];
mockAllIssues.current = [
{ id: "issue-1", identifier: "MUL-1", title: "First issue", status: "todo" },
{ id: "issue-2", identifier: "MUL-2", title: "Second issue", status: "done" },
];
render(<SearchCommand />);
expect(screen.getByText("Recent")).toBeInTheDocument();
expect(screen.getByText("First issue")).toBeInTheDocument();
expect(screen.getByText("MUL-1")).toBeInTheDocument();
expect(screen.getByText("Second issue")).toBeInTheDocument();
expect(screen.getByText("MUL-2")).toBeInTheDocument();
});
it("filters out recent items not present in query cache", () => {
mockRecentItems.current = [
{ id: "issue-1", visitedAt: 1000 },
{ id: "deleted-issue", visitedAt: 900 },
];
mockAllIssues.current = [
{ id: "issue-1", identifier: "MUL-1", title: "Existing issue", status: "in_progress" },
];
render(<SearchCommand />);
expect(screen.getByText("Recent")).toBeInTheDocument();
expect(screen.getByText("Existing issue")).toBeInTheDocument();
expect(screen.queryByText("deleted-issue")).not.toBeInTheDocument();
});
});

View File

@@ -17,9 +17,12 @@ import {
type LucideIcon,
} from "lucide-react";
import { Command as CommandPrimitive } from "cmdk";
import { useQuery } from "@tanstack/react-query";
import type { SearchIssueResult, SearchProjectResult } from "@multica/core/types";
import { api } from "@multica/core/api";
import { useRecentIssuesStore } from "@multica/core/issues/stores";
import { issueListOptions } from "@multica/core/issues/queries";
import { useWorkspaceId } from "@multica/core";
import { StatusIcon } from "../issues/components";
import { STATUS_CONFIG } from "@multica/core/issues/config";
import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
@@ -97,7 +100,18 @@ 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 recentItems = useRecentIssuesStore((s) => s.items);
const wsId = useWorkspaceId();
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
const recentIssues = useMemo(() => {
const issueMap = new Map(allIssues.map((i) => [i.id, i]));
return recentItems.flatMap((item) => {
const issue = issueMap.get(item.id);
return issue ? [issue] : [];
});
}, [recentItems, allIssues]);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResults>({ issues: [], projects: [] });
const [isLoading, setIsLoading] = useState(false);