From caa18a69831783a40c29b2f581d1f40ed999945d Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Fri, 17 Apr 2026 02:03:03 +0800 Subject: [PATCH] feat(search): extend cmd+k palette (theme toggle, new issue/project, copy link, switch workspace) (#1208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(search): add light/dark/system theme toggle actions to cmd+k The command palette now surfaces an "Actions" section with theme toggle items (Light / Dark / System), searchable via keywords like "theme", "light", "dark", "appearance", or "mode". The active theme is marked with a check icon. * feat(search): add quick-win commands to cmd+k palette Extends the command palette with a "Commands" group that consolidates theme toggles plus four new actions: - New Issue / New Project — trigger the global create modals - Copy Issue Link / Copy Identifier (MUL-xxx) — only when the current route is an issue detail page; mirrors the copy-link dropdown logic from issue-detail Adds a "Switch Workspace" group that lists the user's other workspaces (filtered by name/slug, or by typing "workspace"/"switch") and navigates to the selected workspace's issues page. To make "New Project" work from anywhere, the inline CreateProjectDialog on ProjectsPage is extracted into a global CreateProjectModal mounted via the existing ModalRegistry + modal store (same pattern as create-issue / create-workspace). The modal store type gains a "create-project" variant. * feat(search): show Commands by default so they're discoverable Before, cmd+k actions (New Issue / New Project / Copy link / Copy ID / theme toggles) only appeared when the user typed a matching keyword, leaving them invisible unless the user already knew they existed. Now the Commands group renders as soon as the palette opens (no query), with the whole command list shown; typing narrows it down as before. Also trims the redundant "⌘K to open this anytime" hint from the empty state — the palette is already open. --- packages/core/modals/store.ts | 2 +- packages/views/modals/create-project.tsx | 352 ++++++++++++++++++ packages/views/modals/registry.tsx | 3 + .../projects/components/projects-page.tsx | 340 +---------------- packages/views/search/search-command.test.tsx | 275 +++++++++++++- packages/views/search/search-command.tsx | 243 +++++++++++- 6 files changed, 875 insertions(+), 340 deletions(-) create mode 100644 packages/views/modals/create-project.tsx diff --git a/packages/core/modals/store.ts b/packages/core/modals/store.ts index f720be433..81031245d 100644 --- a/packages/core/modals/store.ts +++ b/packages/core/modals/store.ts @@ -2,7 +2,7 @@ import { create } from "zustand"; -type ModalType = "create-workspace" | "create-issue" | null; +type ModalType = "create-workspace" | "create-issue" | "create-project" | null; interface ModalStore { modal: ModalType; diff --git a/packages/views/modals/create-project.tsx b/packages/views/modals/create-project.tsx new file mode 100644 index 000000000..2179455d5 --- /dev/null +++ b/packages/views/modals/create-project.tsx @@ -0,0 +1,352 @@ +"use client"; + +import { useState, useRef } from "react"; +import { ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { useCreateProject } from "@multica/core/projects/mutations"; +import { + PROJECT_STATUS_CONFIG, + PROJECT_STATUS_ORDER, + PROJECT_PRIORITY_CONFIG, + PROJECT_PRIORITY_ORDER, +} from "@multica/core/projects/config"; +import { useWorkspaceId } from "@multica/core/hooks"; +import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths"; +import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries"; +import { useActorName } from "@multica/core/workspace/hooks"; +import type { ProjectStatus, ProjectPriority } from "@multica/core/types"; +import { cn } from "@multica/ui/lib/utils"; +import { toast } from "sonner"; +import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@multica/ui/components/ui/dropdown-menu"; +import { Popover, PopoverTrigger, PopoverContent } from "@multica/ui/components/ui/popover"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"; +import { Button } from "@multica/ui/components/ui/button"; +import { EmojiPicker } from "@multica/ui/components/common/emoji-picker"; +import { ContentEditor, type ContentEditorRef, TitleEditor } from "../editor"; +import { PriorityIcon } from "../issues/components/priority-icon"; +import { ActorAvatar } from "../common/actor-avatar"; +import { useNavigation } from "../navigation"; + +function PillButton({ + children, + className, + ...props +}: React.ButtonHTMLAttributes) { + return ( + + ); +} + +export function CreateProjectModal({ onClose }: { onClose: () => void }) { + const router = useNavigation(); + const workspace = useCurrentWorkspace(); + const workspaceName = workspace?.name; + const wsPaths = useWorkspacePaths(); + const wsId = useWorkspaceId(); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + const { data: agents = [] } = useQuery(agentListOptions(wsId)); + const { getActorName } = useActorName(); + + const [title, setTitle] = useState(""); + const descEditorRef = useRef(null); + const [status, setStatus] = useState("planned"); + const [priority, setPriority] = useState("none"); + const [leadType, setLeadType] = useState<"member" | "agent" | undefined>(); + const [leadId, setLeadId] = useState(); + const [icon, setIcon] = useState(); + const [iconPickerOpen, setIconPickerOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + + const [leadOpen, setLeadOpen] = useState(false); + const [leadFilter, setLeadFilter] = useState(""); + + const leadQuery = leadFilter.toLowerCase(); + const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery)); + const filteredAgents = agents.filter( + (a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery), + ); + + const leadLabel = leadType && leadId ? getActorName(leadType, leadId) : "Lead"; + + const createProject = useCreateProject(); + + const handleSubmit = async () => { + if (!title.trim() || submitting) return; + setSubmitting(true); + try { + const project = await createProject.mutateAsync({ + title: title.trim(), + description: descEditorRef.current?.getMarkdown()?.trim() || undefined, + icon, + status, + priority, + lead_type: leadType, + lead_id: leadId, + }); + onClose(); + toast.success("Project created"); + router.push(wsPaths.projectDetail(project.id)); + } catch { + toast.error("Failed to create project"); + } finally { + setSubmitting(false); + } + }; + + return ( + { if (!v) onClose(); }}> + + New Project + +
+
+ {workspaceName} + + New project +
+
+ + setIsExpanded(!isExpanded)} + className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer" + > + {isExpanded ? : } + + } + /> + {isExpanded ? "Collapse" : "Expand"} + + + + + + } + /> + Close + +
+
+ +
+ + + {icon || "📁"} + + } + /> + + { + setIcon(emoji); + setIconPickerOpen(false); + }} + /> + + + setTitle(v)} + onSubmit={handleSubmit} + /> +
+ +
+ +
+ +
+ + + + {PROJECT_STATUS_CONFIG[status].label} + + } + /> + + {PROJECT_STATUS_ORDER.map((s) => ( + setStatus(s)}> + + {PROJECT_STATUS_CONFIG[s].label} + + ))} + + + + + + + {PROJECT_PRIORITY_CONFIG[priority].label} + + } + /> + + {PROJECT_PRIORITY_ORDER.map((pr) => ( + setPriority(pr)}> + + {PROJECT_PRIORITY_CONFIG[pr].label} + + ))} + + + + { + setLeadOpen(v); + if (!v) setLeadFilter(""); + }} + > + + {leadType && leadId ? ( + <> + + {leadLabel} + + ) : ( + Lead + )} + + } + /> + +
+ setLeadFilter(e.target.value)} + placeholder="Assign lead..." + className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none" + /> +
+
+ + {filteredMembers.length > 0 && ( + <> +
+ Members +
+ {filteredMembers.map((m) => ( + + ))} + + )} + {filteredAgents.length > 0 && ( + <> +
+ Agents +
+ {filteredAgents.map((a) => ( + + ))} + + )} + {filteredMembers.length === 0 && + filteredAgents.length === 0 && + leadFilter && ( +
+ No results +
+ )} +
+
+
+
+ +
+ +
+
+
+ ); +} diff --git a/packages/views/modals/registry.tsx b/packages/views/modals/registry.tsx index d97709bbc..6f0bd6a98 100644 --- a/packages/views/modals/registry.tsx +++ b/packages/views/modals/registry.tsx @@ -3,6 +3,7 @@ import { useModalStore } from "@multica/core/modals"; import { CreateWorkspaceModal } from "./create-workspace"; import { CreateIssueModal } from "./create-issue"; +import { CreateProjectModal } from "./create-project"; export function ModalRegistry() { const modal = useModalStore((s) => s.modal); @@ -14,6 +15,8 @@ export function ModalRegistry() { return ; case "create-issue": return ; + case "create-project": + return ; default: return null; } diff --git a/packages/views/projects/components/projects-page.tsx b/packages/views/projects/components/projects-page.tsx index 7725c880c..95bf2a868 100644 --- a/packages/views/projects/components/projects-page.tsx +++ b/packages/views/projects/components/projects-page.tsx @@ -1,26 +1,26 @@ "use client"; -import { useState, useRef, useCallback } from "react"; -import { Plus, FolderKanban, ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus, Check } from "lucide-react"; +import { useState, useCallback } from "react"; +import { Plus, FolderKanban, UserMinus, Check } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { projectListOptions } from "@multica/core/projects/queries"; -import { useCreateProject, useUpdateProject } from "@multica/core/projects/mutations"; -import { PROJECT_STATUS_CONFIG, PROJECT_STATUS_ORDER, PROJECT_PRIORITY_CONFIG, PROJECT_PRIORITY_ORDER } from "@multica/core/projects/config"; +import { useUpdateProject } from "@multica/core/projects/mutations"; +import { + PROJECT_STATUS_CONFIG, + PROJECT_STATUS_ORDER, + PROJECT_PRIORITY_CONFIG, + PROJECT_PRIORITY_ORDER, +} from "@multica/core/projects/config"; import { useWorkspaceId } from "@multica/core/hooks"; -import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths"; +import { useWorkspacePaths } from "@multica/core/paths"; import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries"; -import { AppLink, useNavigation } from "../../navigation"; +import { useModalStore } from "@multica/core/modals"; +import { AppLink } from "../../navigation"; import { ActorAvatar } from "../../common/actor-avatar"; import { useActorName } from "@multica/core/workspace/hooks"; import { Skeleton } from "@multica/ui/components/ui/skeleton"; import { Button } from "@multica/ui/components/ui/button"; import { cn } from "@multica/ui/lib/utils"; -import { toast } from "sonner"; -import { - Dialog, - DialogContent, - DialogTitle, -} from "@multica/ui/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -33,9 +33,6 @@ import { PopoverContent, } from "@multica/ui/components/ui/popover"; import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"; -import { ContentEditor, type ContentEditorRef } from "../../editor"; -import { TitleEditor } from "../../editor"; -import { EmojiPicker } from "@multica/ui/components/common/emoji-picker"; import type { Project, ProjectStatus, ProjectPriority, UpdateProjectRequest } from "@multica/core/types"; import { PageHeader } from "../../layout/page-header"; import { PriorityIcon } from "../../issues/components/priority-icon"; @@ -229,316 +226,11 @@ function ProjectRow({ project }: { project: Project }) { ); } -function PillButton({ - children, - className, - ...props -}: React.ButtonHTMLAttributes) { - return ( - - ); -} - -function CreateProjectDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { - const router = useNavigation(); - const workspace = useCurrentWorkspace(); - const workspaceName = workspace?.name; - const wsPaths = useWorkspacePaths(); - const wsId = useWorkspaceId(); - const { data: members = [] } = useQuery(memberListOptions(wsId)); - const { data: agents = [] } = useQuery(agentListOptions(wsId)); - const { getActorName } = useActorName(); - - const [title, setTitle] = useState(""); - const descEditorRef = useRef(null); - const [status, setStatus] = useState("planned"); - const [priority, setPriority] = useState("none"); - const [leadType, setLeadType] = useState<"member" | "agent" | undefined>(); - const [leadId, setLeadId] = useState(); - const [icon, setIcon] = useState(); - const [iconPickerOpen, setIconPickerOpen] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [isExpanded, setIsExpanded] = useState(false); - - // Lead popover - const [leadOpen, setLeadOpen] = useState(false); - const [leadFilter, setLeadFilter] = useState(""); - - const leadQuery = leadFilter.toLowerCase(); - const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery)); - const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery)); - - const leadLabel = - leadType && leadId ? getActorName(leadType, leadId) : "Lead"; - - const createProject = useCreateProject(); - - const handleSubmit = async () => { - if (!title.trim() || submitting) return; - setSubmitting(true); - try { - const project = await createProject.mutateAsync({ - title: title.trim(), - description: descEditorRef.current?.getMarkdown()?.trim() || undefined, - icon, - status, - priority, - lead_type: leadType, - lead_id: leadId, - }); - onOpenChange(false); - setTitle(""); - setIcon(undefined); - setStatus("planned"); - setPriority("none"); - setLeadType(undefined); - setLeadId(undefined); - toast.success("Project created"); - router.push(wsPaths.projectDetail(project.id)); - } catch { - toast.error("Failed to create project"); - } finally { - setSubmitting(false); - } - }; - - return ( - - - New Project - - {/* Header */} -
-
- {workspaceName} - - New project -
-
- - setIsExpanded(!isExpanded)} - className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer" - > - {isExpanded ? : } - - } - /> - {isExpanded ? "Collapse" : "Expand"} - - - onOpenChange(false)} - className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer" - > - - - } - /> - Close - -
-
- - {/* Icon + Title */} -
- - - {icon || "📁"} - - } - /> - - { - setIcon(emoji); - setIconPickerOpen(false); - }} - /> - - - setTitle(v)} - onSubmit={handleSubmit} - /> -
- - {/* Description */} -
- -
- - {/* Property toolbar */} -
- {/* Status */} - - - - {PROJECT_STATUS_CONFIG[status].label} - - } - /> - - {PROJECT_STATUS_ORDER.map((s) => ( - setStatus(s)}> - - {PROJECT_STATUS_CONFIG[s].label} - - ))} - - - - {/* Priority */} - - - - {PROJECT_PRIORITY_CONFIG[priority].label} - - } - /> - - {PROJECT_PRIORITY_ORDER.map((p) => ( - setPriority(p)}> - - {PROJECT_PRIORITY_CONFIG[p].label} - - ))} - - - - {/* Lead */} - { setLeadOpen(v); if (!v) setLeadFilter(""); }}> - - {leadType && leadId ? ( - <> - - {leadLabel} - - ) : ( - Lead - )} - - } - /> - -
- setLeadFilter(e.target.value)} - placeholder="Assign lead..." - className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none" - /> -
-
- - {filteredMembers.length > 0 && ( - <> -
Members
- {filteredMembers.map((m) => ( - - ))} - - )} - {filteredAgents.length > 0 && ( - <> -
Agents
- {filteredAgents.map((a) => ( - - ))} - - )} - {filteredMembers.length === 0 && filteredAgents.length === 0 && leadFilter && ( -
No results
- )} -
-
-
-
- - {/* Footer */} -
- -
-
-
- ); -} export function ProjectsPage() { const wsId = useWorkspaceId(); const { data: projects = [], isLoading } = useQuery(projectListOptions(wsId)); - const [createOpen, setCreateOpen] = useState(false); + const openCreateProject = () => useModalStore.getState().open("create-project"); return (
@@ -551,7 +243,7 @@ export function ProjectsPage() { {projects.length} )}
- @@ -569,7 +261,7 @@ export function ProjectsPage() {

No projects yet

-
@@ -593,8 +285,6 @@ export function ProjectsPage() { )} - - ); } diff --git a/packages/views/search/search-command.test.tsx b/packages/views/search/search-command.test.tsx index 23fcb2105..6fdb1b65e 100644 --- a/packages/views/search/search-command.test.tsx +++ b/packages/views/search/search-command.test.tsx @@ -5,12 +5,40 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { SearchCommand } from "./search-command"; import { useSearchStore } from "./search-store"; -const { mockPush, mockSearchIssues, mockSearchProjects, mockRecentItems, mockAllIssues } = vi.hoisted(() => ({ +const { + mockPush, + mockSearchIssues, + mockSearchProjects, + mockRecentItems, + mockAllIssues, + mockSetTheme, + mockTheme, + mockPathname, + mockGetShareableUrl, + mockWorkspaces, + mockCurrentWorkspace, + mockOpenModal, + mockToastSuccess, + mockClipboardWrite, +} = vi.hoisted(() => ({ mockPush: vi.fn(), mockSearchIssues: vi.fn(), mockSearchProjects: vi.fn(), mockRecentItems: { current: [] as Array<{ id: string; visitedAt: number }> }, mockAllIssues: { current: [] as Array> }, + mockSetTheme: vi.fn(), + mockTheme: { current: "system" as "light" | "dark" | "system" }, + mockPathname: { current: "/ws-test/issues" as string }, + mockGetShareableUrl: vi.fn((p: string) => `https://app.multica/${p}`), + mockWorkspaces: { + current: [] as Array<{ id: string; name: string; slug: string }>, + }, + mockCurrentWorkspace: { + current: null as { id: string; name: string; slug: string } | null, + }, + mockOpenModal: vi.fn(), + mockToastSuccess: vi.fn(), + mockClipboardWrite: vi.fn(() => Promise.resolve()), })); vi.mock("@multica/core/api", () => ({ @@ -32,6 +60,12 @@ vi.mock("@multica/core", () => ({ })); vi.mock("@multica/core/paths", () => ({ + paths: { + workspace: (slug: string) => ({ + issues: () => `/${slug}/issues`, + }), + }, + useCurrentWorkspace: () => mockCurrentWorkspace.current, useWorkspacePaths: () => ({ inbox: () => "/ws-test/inbox", myIssues: () => "/ws-test/my-issues", @@ -50,16 +84,40 @@ vi.mock("@multica/core/issues/queries", () => ({ issueListOptions: () => ({ queryKey: ["issues", "ws-test", "list"], enabled: false }), })); +vi.mock("@multica/core/workspace/queries", () => ({ + workspaceListOptions: () => ({ queryKey: ["workspaces", "list"], enabled: false }), +})); + +vi.mock("@multica/core/modals", () => ({ + useModalStore: Object.assign(vi.fn(), { + getState: () => ({ open: mockOpenModal }), + }), +})); + vi.mock("@tanstack/react-query", () => ({ - useQuery: () => ({ data: mockAllIssues.current }), + useQuery: (opts: { queryKey: readonly unknown[] }) => { + const key = opts.queryKey; + if (key[0] === "workspaces") return { data: mockWorkspaces.current }; + return { data: mockAllIssues.current }; + }, })); vi.mock("../navigation", () => ({ useNavigation: () => ({ push: mockPush, + pathname: mockPathname.current, + getShareableUrl: mockGetShareableUrl, }), })); +vi.mock("@multica/ui/components/common/theme-provider", () => ({ + useTheme: () => ({ theme: mockTheme.current, setTheme: mockSetTheme }), +})); + +vi.mock("sonner", () => ({ + toast: { success: mockToastSuccess, error: vi.fn() }, +})); + describe("SearchCommand", () => { beforeEach(() => { mockPush.mockReset(); @@ -67,6 +125,15 @@ describe("SearchCommand", () => { mockSearchProjects.mockReset().mockResolvedValue({ projects: [] }); mockRecentItems.current = []; mockAllIssues.current = []; + mockSetTheme.mockReset(); + mockTheme.current = "system"; + mockPathname.current = "/ws-test/issues"; + mockGetShareableUrl.mockReset().mockImplementation((p: string) => `https://app.multica/${p}`); + mockWorkspaces.current = []; + mockCurrentWorkspace.current = null; + mockOpenModal.mockReset(); + mockToastSuccess.mockReset(); + mockClipboardWrite.mockReset().mockResolvedValue(undefined); // cmdk calls scrollIntoView on the first selected item, which jsdom doesn't implement Element.prototype.scrollIntoView = vi.fn(); @@ -94,10 +161,19 @@ describe("SearchCommand", () => { expect(screen.queryByPlaceholderText("Type a command or search...")).not.toBeInTheDocument(); }); - it("does not show pages when no query is entered", () => { + it("shows Commands by default but hides Pages and Switch Workspace until query", () => { render(); expect(screen.queryByText("Pages")).not.toBeInTheDocument(); + expect(screen.queryByText("Switch Workspace")).not.toBeInTheDocument(); + // Commands surface by default for discoverability. + expect(screen.getByText("Commands")).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "New Issue" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "New Project" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); }); it("filters navigation pages by query", async () => { @@ -112,7 +188,6 @@ describe("SearchCommand", () => { 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 () => { @@ -148,6 +223,198 @@ describe("SearchCommand", () => { expect(screen.getByText("MUL-2")).toBeInTheDocument(); }); + it("shows New Issue / New Project under Commands and triggers the modal store", async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText("Type a command or search..."); + await user.type(input, "new"); + + await waitFor(() => { + expect(screen.getByText("Commands")).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "New Issue" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "New Project" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + }); + + const newIssue = await screen.findByText( + (_, el) => el?.textContent === "New Issue" && el?.tagName === "SPAN", + ); + await user.click(newIssue); + + expect(mockOpenModal).toHaveBeenCalledWith("create-issue"); + expect(useSearchStore.getState().open).toBe(false); + }); + + it("hides copy-link commands when not on an issue detail route", async () => { + const user = userEvent.setup(); + mockPathname.current = "/ws-test/projects"; + render(); + + const input = screen.getByPlaceholderText("Type a command or search..."); + await user.type(input, "copy"); + + // Commands section may still be empty / absent. + expect(screen.queryByText("Copy Issue Link")).not.toBeInTheDocument(); + }); + + it("copies issue link and identifier when on an issue detail route", async () => { + const user = userEvent.setup(); + // userEvent.setup() installs its own navigator.clipboard; spy on it so we + // intercept the writeText call without clobbering userEvent's internals. + const writeSpy = vi + .spyOn(navigator.clipboard, "writeText") + .mockImplementation(mockClipboardWrite); + mockPathname.current = "/ws-test/issues/issue-1"; + mockAllIssues.current = [ + { id: "issue-1", identifier: "MUL-42", title: "Demo", status: "todo" }, + ]; + render(); + + const input = screen.getByPlaceholderText("Type a command or search..."); + await user.type(input, "copy"); + + const linkItem = await screen.findByText( + (_, el) => el?.textContent === "Copy Issue Link" && el?.tagName === "SPAN", + ); + await user.click(linkItem); + + expect(mockGetShareableUrl).toHaveBeenCalledWith("/ws-test/issues/issue-1"); + expect(mockClipboardWrite).toHaveBeenCalledWith("https://app.multica//ws-test/issues/issue-1"); + expect(mockToastSuccess).toHaveBeenCalledWith("Link copied"); + + // Reopen palette and test identifier copy + act(() => { + useSearchStore.setState({ open: true }); + }); + const input2 = screen.getByPlaceholderText("Type a command or search..."); + await user.type(input2, "copy"); + const idItem = await screen.findByText( + (_, el) => + el?.textContent === "Copy Identifier (MUL-42)" && el?.tagName === "SPAN", + ); + await user.click(idItem); + expect(mockClipboardWrite).toHaveBeenCalledWith("MUL-42"); + expect(mockToastSuccess).toHaveBeenCalledWith("Copied MUL-42"); + + writeSpy.mockRestore(); + }); + + it("filters theme commands by query keywords", async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText("Type a command or search..."); + await user.type(input, "dark"); + + await waitFor(() => { + expect(screen.getByText("Commands")).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "Switch to Dark Theme" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + }); + expect(screen.queryByText("Switch to Light Theme")).not.toBeInTheDocument(); + expect(screen.queryByText("Use System Theme")).not.toBeInTheDocument(); + }); + + it("applies the selected theme and closes the palette", async () => { + const user = userEvent.setup(); + mockTheme.current = "light"; + render(); + + const input = screen.getByPlaceholderText("Type a command or search..."); + await user.type(input, "dark"); + + const darkItem = await screen.findByText( + (_, el) => el?.textContent === "Switch to Dark Theme" && el?.tagName === "SPAN", + ); + await user.click(darkItem); + + expect(mockSetTheme).toHaveBeenCalledWith("dark"); + expect(useSearchStore.getState().open).toBe(false); + }); + + it("matches theme action via generic 'theme' keyword and marks current theme", async () => { + const user = userEvent.setup(); + mockTheme.current = "dark"; + render(); + + const input = screen.getByPlaceholderText("Type a command or search..."); + await user.type(input, "theme"); + + await waitFor(() => { + expect( + screen.getByText((_, el) => el?.textContent === "Switch to Light Theme" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "Switch to Dark Theme" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "Use System Theme" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + }); + expect(screen.getByLabelText("Current theme")).toBeInTheDocument(); + }); + + it("lists other workspaces under Switch Workspace and navigates on select", async () => { + const user = userEvent.setup(); + mockCurrentWorkspace.current = { id: "ws-current", name: "Current", slug: "current" }; + mockWorkspaces.current = [ + { id: "ws-current", name: "Current", slug: "current" }, + { id: "ws-alpha", name: "Alpha Co", slug: "alpha" }, + { id: "ws-beta", name: "Beta Co", slug: "beta" }, + ]; + render(); + + const input = screen.getByPlaceholderText("Type a command or search..."); + await user.type(input, "alpha"); + + await waitFor(() => { + expect(screen.getByText("Switch Workspace")).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "Alpha Co" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + }); + expect(screen.queryByText("Beta Co")).not.toBeInTheDocument(); + expect(screen.queryByText("Current")).not.toBeInTheDocument(); + + const alphaItem = await screen.findByText( + (_, el) => el?.textContent === "Alpha Co" && el?.tagName === "SPAN", + ); + await user.click(alphaItem); + + expect(mockPush).toHaveBeenCalledWith("/alpha/issues"); + expect(useSearchStore.getState().open).toBe(false); + }); + + it("shows all other workspaces when typing 'workspace'", async () => { + const user = userEvent.setup(); + mockCurrentWorkspace.current = { id: "ws-current", name: "Current", slug: "current" }; + mockWorkspaces.current = [ + { id: "ws-current", name: "Current", slug: "current" }, + { id: "ws-alpha", name: "Alpha Co", slug: "alpha" }, + { id: "ws-beta", name: "Beta Co", slug: "beta" }, + ]; + render(); + + const input = screen.getByPlaceholderText("Type a command or search..."); + await user.type(input, "workspace"); + + await waitFor(() => { + expect(screen.getByText("Switch Workspace")).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "Alpha Co" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + expect( + screen.getByText((_, el) => el?.textContent === "Beta Co" && el?.tagName === "SPAN"), + ).toBeInTheDocument(); + }); + expect(screen.queryByText("Current")).not.toBeInTheDocument(); + }); + it("filters out recent items not present in query cache", () => { mockRecentItems.current = [ { id: "issue-1", visitedAt: 1000 }, diff --git a/packages/views/search/search-command.tsx b/packages/views/search/search-command.tsx index 98d1db136..b064fdb4f 100644 --- a/packages/views/search/search-command.tsx +++ b/packages/views/search/search-command.tsx @@ -2,9 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + Check, Clock, + Copy, + Link2, Loader2, MessageSquare, + Plus, SearchIcon, Inbox, CircleUser, @@ -12,19 +16,25 @@ import { FolderKanban, Bot, Monitor, + Moon, + Sun, BookOpenText, Settings, + Building2, type LucideIcon, } from "lucide-react"; import { Command as CommandPrimitive } from "cmdk"; import { useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; 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 { useWorkspacePaths } from "@multica/core/paths"; +import { paths, useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths"; import type { WorkspacePaths } from "@multica/core/paths"; +import { useModalStore } from "@multica/core/modals"; +import { workspaceListOptions } from "@multica/core/workspace/queries"; import { StatusIcon } from "../issues/components"; import { STATUS_CONFIG } from "@multica/core/issues/config"; import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config"; @@ -36,6 +46,7 @@ import { DialogTitle, DialogDescription, } from "@multica/ui/components/ui/dialog"; +import { useTheme } from "@multica/ui/components/common/theme-provider"; import { useNavigation } from "../navigation"; import { useSearchStore } from "./search-store"; @@ -106,19 +117,33 @@ const navPages: NavPage[] = [ { key: "settings", label: "Settings", icon: Settings, keywords: ["settings", "config", "preferences"] }, ]; +type ThemeValue = "light" | "dark" | "system"; + +interface CommandItem { + key: string; + label: string; + icon: LucideIcon; + keywords: string[]; + trailing?: React.ReactNode; + onSelect: () => void; +} + interface SearchResults { issues: SearchIssueResult[]; projects: SearchProjectResult[]; } export function SearchCommand() { - const { push } = useNavigation(); + const { push, pathname, getShareableUrl } = useNavigation(); const open = useSearchStore((s) => s.open); const setOpen = useSearchStore((s) => s.setOpen); const recentItems = useRecentIssuesStore((s) => s.items); const wsId = useWorkspaceId(); const p: WorkspacePaths = useWorkspacePaths(); const { data: allIssues = [] } = useQuery(issueListOptions(wsId)); + const { theme, setTheme } = useTheme(); + const currentWorkspace = useCurrentWorkspace(); + const { data: workspaces = [] } = useQuery(workspaceListOptions()); const recentIssues = useMemo(() => { const issueMap = new Map(allIssues.map((i) => [i.id, i])); @@ -144,6 +169,144 @@ export function SearchCommand() { ); }, [query]); + // Detect if current route is an issue detail page — /{slug}/issues/{id}. + // Falls back to null on any other route; used to gate issue-specific commands. + const currentIssue = useMemo(() => { + const match = pathname.match(/\/issues\/([^/]+)$/); + const raw = match?.[1]; + if (!raw) return null; + const id = decodeURIComponent(raw); + return allIssues.find((i) => i.id === id) ?? null; + }, [pathname, allIssues]); + + const commands = useMemo(() => { + const activeThemeCheck = (value: ThemeValue) => + theme === value ? ( + + ) : undefined; + + const items: CommandItem[] = [ + { + key: "new-issue", + label: "New Issue", + icon: Plus, + keywords: ["new", "issue", "create", "add"], + onSelect: () => { + useModalStore.getState().open("create-issue"); + setOpen(false); + }, + }, + { + key: "new-project", + label: "New Project", + icon: Plus, + keywords: ["new", "project", "create", "add"], + onSelect: () => { + useModalStore.getState().open("create-project"); + setOpen(false); + }, + }, + ]; + + if (currentIssue) { + const identifier = currentIssue.identifier; + items.push( + { + key: "copy-issue-link", + label: "Copy Issue Link", + icon: Link2, + keywords: ["copy", "link", "share", "url", identifier.toLowerCase()], + onSelect: () => { + const url = getShareableUrl ? getShareableUrl(pathname) : window.location.href; + void navigator.clipboard.writeText(url); + toast.success("Link copied"); + setOpen(false); + }, + }, + { + key: "copy-issue-identifier", + label: `Copy Identifier (${identifier})`, + icon: Copy, + keywords: ["copy", "id", "identifier", identifier.toLowerCase()], + onSelect: () => { + void navigator.clipboard.writeText(identifier); + toast.success(`Copied ${identifier}`); + setOpen(false); + }, + }, + ); + } + + items.push( + { + key: "theme-light", + label: "Switch to Light Theme", + icon: Sun, + keywords: ["light", "theme", "appearance", "mode", "bright"], + trailing: activeThemeCheck("light"), + onSelect: () => { + setTheme("light"); + setOpen(false); + }, + }, + { + key: "theme-dark", + label: "Switch to Dark Theme", + icon: Moon, + keywords: ["dark", "theme", "appearance", "mode", "night"], + trailing: activeThemeCheck("dark"), + onSelect: () => { + setTheme("dark"); + setOpen(false); + }, + }, + { + key: "theme-system", + label: "Use System Theme", + icon: Monitor, + keywords: ["system", "theme", "appearance", "mode", "auto"], + trailing: activeThemeCheck("system"), + onSelect: () => { + setTheme("system"); + setOpen(false); + }, + }, + ); + + return items; + }, [currentIssue, getShareableUrl, pathname, setOpen, setTheme, theme]); + + const filteredCommands = useMemo(() => { + const q = query.trim().toLowerCase(); + // No query: surface the whole Commands list so users can discover what's + // available without having to guess keywords (Linear/Raycast pattern). + if (!q) return commands; + return commands.filter( + (c) => + c.label.toLowerCase().includes(q) || + c.keywords.some((kw) => kw.includes(q)), + ); + }, [commands, query]); + + // Only show workspaces different from the current one, and only after the + // user types >=2 chars — one char would match everything (e.g. "w"). + const filteredWorkspaces = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return []; + const others = workspaces.filter((w) => w.id !== currentWorkspace?.id); + const wantsAll = + q.length >= 2 && ("workspace".startsWith(q) || "switch".startsWith(q)); + return others.filter( + (w) => + wantsAll || + w.name.toLowerCase().includes(q) || + w.slug.toLowerCase().includes(q), + ); + }, [workspaces, currentWorkspace?.id, query]); + const hasResults = results.issues.length > 0 || results.projects.length > 0; // Global Cmd+K / Ctrl+K shortcut @@ -262,6 +425,14 @@ export function SearchCommand() { [push, setOpen, p], ); + const handleSwitchWorkspace = useCallback( + (slug: string) => { + push(paths.workspace(slug).issues()); + setOpen(false); + }, + [push, setOpen], + ); + return ( )} + {/* Commands section — New Issue / New Project / Copy link / Theme, only shown when query matches */} + {filteredCommands.length > 0 && ( + +
+ Commands +
+ {filteredCommands.map((cmd) => ( + + + + + + {cmd.trailing} + + ))} +
+ )} + + {/* Workspaces section — switch to a different workspace, only shown when query matches */} + {filteredWorkspaces.length > 0 && ( + +
+ Switch Workspace +
+ {filteredWorkspaces.map((ws) => ( + handleSwitchWorkspace(ws.slug)} + 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" + > + + + + + + {ws.slug} + + + ))} +
+ )} + {isLoading && (
)} - {!isLoading && query.trim() && !hasResults && filteredPages.length === 0 && ( - - No results found. - - )} + {!isLoading && + query.trim() && + !hasResults && + filteredPages.length === 0 && + filteredCommands.length === 0 && + filteredWorkspaces.length === 0 && ( + + No results found. + + )} {!isLoading && results.projects.length > 0 && ( - Type to search issues and projects... - Press ⌘K to open this anytime +
+ Type to search issues and projects
)}