Compare commits

...

7 Commits

Author SHA1 Message Date
Jiayuan Zhang
a2dbf03785 fix(desktop): center dock icon within canvas
The bundled dock icon used by `pnpm dev:desktop` had its squircle
touching the top of the 1024×1024 canvas (T=0, B=11), making the
shape look shifted up in the macOS dock. Shift content down 6px
to balance margins (T=6, B=5).
2026-04-17 09:25:47 +08:00
Jiayuan Zhang
3b7abae5b4 refactor(search): collapse cmd+k empty-state commands to primary action (#1225)
Previously every registered Command (New Issue, New Project, three theme
switches, plus contextual Copy actions on issue pages) surfaced on empty
query, leaving only 3–5 rows for Recent in a 400px panel. Low-frequency
commands (theme, copy, New Project) are now revealed by typing, matching
the progressive-disclosure pattern already used for Pages and Switch
Workspace. Refs MUL-991.
2026-04-17 09:09:55 +08:00
Jiayuan Zhang
7843da0315 refactor(issues): lighten board card styling (#1217)
Slimmer 0.5px border, 12/10 asymmetric padding, and a two-layer soft
drop shadow give the kanban card a more weightless look on the board.
2026-04-17 02:15:24 +08:00
Jiayuan Zhang
caa18a6983 feat(search): extend cmd+k palette (theme toggle, new issue/project, copy link, switch workspace) (#1208)
* 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.
2026-04-17 02:03:03 +08:00
Jiayuan Zhang
6e980925cf chore(desktop): DESKTOP_APP_SUFFIX env for parallel-worktree dev (#1215)
Dev Electron uses a single userData path ("Multica Canary") derived from
the app name, which also locates the single-instance lock. Two worktrees
running dev simultaneously fight for that lock — the second `app.quit()`s
silently before opening a window.

DESKTOP_APP_SUFFIX appends to the app name + userData path so each
worktree can claim its own lock:

  DESKTOP_APP_SUFFIX=foo  → "Multica Canary foo"

Default (no env var) keeps behavior unchanged.

Complements the existing DESKTOP_RENDERER_PORT env from #1210 so a full
"run a second dev Electron" setup looks like:

  DESKTOP_RENDERER_PORT=15173 DESKTOP_APP_SUFFIX=foo pnpm dev:desktop
2026-04-17 01:55:30 +08:00
Jiayuan Zhang
8bc20ce161 feat(issues): add newly created issue to cmd+k Recent list (#1213)
Hooks recordVisit into useCreateIssue onSuccess so issues the user just
created appear in cmd+k's Recent section without requiring them to open
the issue first.
2026-04-17 01:45:19 +08:00
Jiayuan Zhang
8816e1669c feat(desktop): brand dev build as Multica Canary with bundled icon (#1210)
* feat(desktop): brand dev build as Multica Canary with bundled icon

pnpm dev:desktop ran under the stock Electron name and default icon,
making it indistinguishable from any other Electron dev app in the dock.
Set a Canary app name + userData path and point the macOS dock icon and
BrowserWindow icon at the bundled resources/icon.png so the dev build is
visually branded.

* feat(desktop): allow overriding renderer port via DESKTOP_RENDERER_PORT

Lets a second worktree run `pnpm dev:desktop` while a primary checkout
already holds the default Vite dev port 5173 — required to actually
exercise the "Multica Canary" branding in isolation.

* feat(desktop): rebrand Electron.app Info.plist so dev shows Multica Canary

app.setName() can't override the macOS menu bar title or Cmd+Tab label
— those come from CFBundleName baked into the running bundle's
Info.plist. Patch the bundled Electron.app's plist during `pnpm
dev:desktop` so dev launches read "Multica Canary" everywhere, not
"Electron". Idempotent; unlinks before rewriting so we don't mutate a
pnpm-store inode shared with other projects.
2026-04-17 01:21:53 +08:00
14 changed files with 992 additions and 347 deletions

View File

@@ -12,7 +12,10 @@ export default defineConfig({
},
renderer: {
server: {
port: 5173,
// Allow parallel worktrees to run `pnpm dev:desktop` side-by-side
// (e.g. Multica Canary alongside a primary checkout) by overriding
// the renderer port via env. Falls back to 5173 for the common case.
port: Number(process.env.DESKTOP_RENDERER_PORT) || 5173,
strictPort: true,
},
plugins: [react(), tailwindcss()],

View File

@@ -5,7 +5,8 @@
"main": "./out/main/index.js",
"scripts": {
"bundle-cli": "node scripts/bundle-cli.mjs",
"dev": "pnpm run bundle-cli && electron-vite dev",
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
"build": "pnpm run bundle-cli && electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 735 KiB

After

Width:  |  Height:  |  Size: 534 KiB

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env node
// Rebrand the bundled Electron.app's Info.plist so `pnpm dev:desktop`
// shows "Multica Canary" in the menu bar, Cmd+Tab switcher, and
// Activity Monitor. On macOS these titles come from CFBundleName at
// launch time — `app.setName()` cannot override them at runtime, so
// patching the plist in node_modules is the only working fix.
//
// Idempotent: runs on every dev launch and no-ops once the plist already
// matches. The patch is isolated to this worktree's node_modules — we
// unlink the file before rewriting so we never mutate a pnpm-store inode
// shared with another project.
import { createRequire } from "node:module";
import { execFileSync } from "node:child_process";
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
if (process.platform !== "darwin") process.exit(0);
const DESIRED_NAME = "Multica Canary";
const require = createRequire(import.meta.url);
// `require('electron')` returns the path to the executable
// (.../Electron.app/Contents/MacOS/Electron). Walk up to Contents/Info.plist.
const electronBin = require("electron");
const plistPath = resolve(electronBin, "../../Info.plist");
function plistGet(key) {
try {
return execFileSync(
"/usr/libexec/PlistBuddy",
["-c", `Print :${key}`, plistPath],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
).trim();
} catch {
return "";
}
}
function plistSet(key, value) {
try {
execFileSync("/usr/libexec/PlistBuddy", [
"-c",
`Set :${key} ${value}`,
plistPath,
]);
} catch {
execFileSync("/usr/libexec/PlistBuddy", [
"-c",
`Add :${key} string ${value}`,
plistPath,
]);
}
}
if (
plistGet("CFBundleName") === DESIRED_NAME &&
plistGet("CFBundleDisplayName") === DESIRED_NAME
) {
process.exit(0);
}
// Break any pnpm hardlink to the global store: read, unlink, rewrite.
// PlistBuddy would otherwise write through the hardlink and mutate the
// shared store file (and every other project's Electron.app with it).
const original = readFileSync(plistPath);
unlinkSync(plistPath);
writeFileSync(plistPath, original);
plistSet("CFBundleName", DESIRED_NAME);
plistSet("CFBundleDisplayName", DESIRED_NAME);
console.log(`[brand-dev-electron] ${plistPath} → CFBundleName="${DESIRED_NAME}"`);

View File

@@ -1,4 +1,4 @@
import { app, shell, BrowserWindow, ipcMain } from "electron";
import { app, shell, BrowserWindow, ipcMain, nativeImage } from "electron";
import { homedir } from "os";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
@@ -6,6 +6,11 @@ import fixPath from "fix-path";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
// Bundled icon used for dev-mode dock/taskbar branding. In production the
// app bundle icon (from electron-builder) wins; this path is only consumed
// by the `is.dev` branch below.
const DEV_ICON_PATH = join(__dirname, "../../resources/icon.png");
// macOS/Linux GUI launches inherit a minimal PATH from launchd that omits
// the user's shell config (~/.zshrc, Homebrew, nvm, ~/.local/bin, etc.).
// Run the user's login shell once to recover the real PATH so the bundled
@@ -61,6 +66,9 @@ function createWindow(): void {
trafficLightPosition: { x: 16, y: 13 },
show: false,
autoHideMenuBar: true,
// Windows/Linux pick up the window/taskbar icon from this option in
// dev — on macOS it's ignored (dock comes from app.dock.setIcon below).
...(is.dev ? { icon: DEV_ICON_PATH } : {}),
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
@@ -101,9 +109,18 @@ function createWindow(): void {
// is derived from the userData path. (Same approach VS Code uses for
// Stable / Insiders coexistence.)
// DESKTOP_APP_SUFFIX lets parallel worktrees run dev Electron side-by-side
// without fighting for the shared single-instance lock. The suffix is
// appended to the app name + userData path, so each worktree gets its own
// lock file. Default (no env var) keeps behavior unchanged — the common
// single-worktree case still lands at "Multica Canary".
const DEV_APP_NAME = process.env.DESKTOP_APP_SUFFIX
? `Multica Canary ${process.env.DESKTOP_APP_SUFFIX}`
: "Multica Canary";
if (is.dev) {
app.setName("Multica Dev");
app.setPath("userData", join(app.getPath("appData"), "Multica Dev"));
app.setName(DEV_APP_NAME);
app.setPath("userData", join(app.getPath("appData"), DEV_APP_NAME));
}
// --- Protocol registration -----------------------------------------------
@@ -141,6 +158,14 @@ if (!gotTheLock) {
is.dev ? "ai.multica.desktop.dev" : "ai.multica.desktop",
);
// macOS: replace the default Electron dock icon with the bundled logo
// so the Canary dev build is visually distinct from a stock Electron
// run. `app.dock` is macOS-only — guard the call.
if (is.dev && process.platform === "darwin" && app.dock) {
const icon = nativeImage.createFromPath(DEV_ICON_PATH);
if (!icon.isEmpty()) app.dock.setIcon(icon);
}
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});

View File

@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { issueKeys, CLOSED_PAGE_SIZE, type MyIssuesFilter } from "./queries";
import { useWorkspaceId } from "../hooks";
import { useRecentIssuesStore } from "./stores";
import type { Issue, IssueReaction } from "../types";
import type {
CreateIssueRequest,
@@ -94,6 +95,9 @@ export function useCreateIssue() {
}
: old,
);
// Surface the just-created issue in cmd+k's Recent list without
// requiring the user to open it first.
useRecentIssuesStore.getState().recordVisit(newIssue.id);
// Invalidate parent's children query so sub-issues list updates immediately
if (newIssue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newIssue.parent_issue_id) });

View File

@@ -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;

View File

@@ -67,7 +67,7 @@ export const BoardCardContent = memo(function BoardCardContent({
const showDueDate = storeProperties.dueDate && issue.due_date;
return (
<div className="rounded-lg border bg-card p-3.5 shadow-[0_1px_2px_0_rgba(0,0,0,0.03)] transition-shadow group-hover:shadow-sm">
<div className="rounded-lg border-[0.5px] bg-card py-3 px-2.5 shadow-[0_3px_6px_-2px_rgba(0,0,0,0.02),0_1px_1px_0_rgba(0,0,0,0.04)] transition-shadow group-hover:shadow-sm">
{/* Row 1: Identifier */}
<p className="text-xs text-muted-foreground">{issue.identifier}</p>

View File

@@ -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<HTMLButtonElement>) {
return (
<button
type="button"
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
"hover:bg-accent/60 transition-colors cursor-pointer",
className,
)}
{...props}
>
{children}
</button>
);
}
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<ContentEditorRef>(null);
const [status, setStatus] = useState<ProjectStatus>("planned");
const [priority, setPriority] = useState<ProjectPriority>("none");
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>();
const [leadId, setLeadId] = useState<string | undefined>();
const [icon, setIcon] = useState<string | undefined>();
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 (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent
showCloseButton={false}
className={cn(
"p-0 gap-0 flex flex-col overflow-hidden",
"!top-1/2 !left-1/2 !-translate-x-1/2",
"!transition-all !duration-300 !ease-out",
isExpanded
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
: "!max-w-2xl !w-full !h-96 !-translate-y-1/2",
)}
>
<DialogTitle className="sr-only">New Project</DialogTitle>
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">{workspaceName}</span>
<ChevronRight className="size-3 text-muted-foreground/50" />
<span className="font-medium">New project</span>
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger
render={
<button
onClick={() => setIsExpanded(!isExpanded)}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</button>
}
/>
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<button
onClick={onClose}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
<XIcon className="size-4" />
</button>
}
/>
<TooltipContent side="bottom">Close</TooltipContent>
</Tooltip>
</div>
</div>
<div className="px-5 pb-2 shrink-0">
<Popover open={iconPickerOpen} onOpenChange={setIconPickerOpen}>
<PopoverTrigger
render={
<button
type="button"
className="text-2xl cursor-pointer rounded-lg p-1 -ml-1 hover:bg-accent/60 transition-colors"
title="Choose icon"
>
{icon || "📁"}
</button>
}
/>
<PopoverContent align="start" className="w-auto p-0">
<EmojiPicker
onSelect={(emoji) => {
setIcon(emoji);
setIconPickerOpen(false);
}}
/>
</PopoverContent>
</Popover>
<TitleEditor
autoFocus
defaultValue=""
placeholder="Project title"
className="text-lg font-semibold"
onChange={(v) => setTitle(v)}
onSubmit={handleSubmit}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-5">
<ContentEditor
ref={descEditorRef}
defaultValue=""
placeholder="Add description..."
debounceMs={500}
/>
</div>
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton>
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[status].dotColor)} />
<span>{PROJECT_STATUS_CONFIG[status].label}</span>
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_STATUS_ORDER.map((s) => (
<DropdownMenuItem key={s} onClick={() => setStatus(s)}>
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton>
<PriorityIcon priority={priority} />
<span>{PROJECT_PRIORITY_CONFIG[priority].label}</span>
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_PRIORITY_ORDER.map((pr) => (
<DropdownMenuItem key={pr} onClick={() => setPriority(pr)}>
<PriorityIcon priority={pr} />
<span>{PROJECT_PRIORITY_CONFIG[pr].label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Popover
open={leadOpen}
onOpenChange={(v) => {
setLeadOpen(v);
if (!v) setLeadFilter("");
}}
>
<PopoverTrigger
render={
<PillButton>
{leadType && leadId ? (
<>
<ActorAvatar actorType={leadType} actorId={leadId} size={16} />
<span>{leadLabel}</span>
</>
) : (
<span className="text-muted-foreground">Lead</span>
)}
</PillButton>
}
/>
<PopoverContent align="start" className="w-52 p-0">
<div className="px-2 py-1.5 border-b">
<input
type="text"
value={leadFilter}
onChange={(e) => setLeadFilter(e.target.value)}
placeholder="Assign lead..."
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="p-1 max-h-60 overflow-y-auto">
<button
type="button"
onClick={() => {
setLeadType(undefined);
setLeadId(undefined);
setLeadOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">No lead</span>
</button>
{filteredMembers.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Members
</div>
{filteredMembers.map((m) => (
<button
type="button"
key={m.user_id}
onClick={() => {
setLeadType("member");
setLeadId(m.user_id);
setLeadOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
<span>{m.name}</span>
</button>
))}
</>
)}
{filteredAgents.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Agents
</div>
{filteredAgents.map((a) => (
<button
type="button"
key={a.id}
onClick={() => {
setLeadType("agent");
setLeadId(a.id);
setLeadOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
<span>{a.name}</span>
</button>
))}
</>
)}
{filteredMembers.length === 0 &&
filteredAgents.length === 0 &&
leadFilter && (
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
No results
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Project"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 <CreateWorkspaceModal onClose={close} />;
case "create-issue":
return <CreateIssueModal onClose={close} data={data} />;
case "create-project":
return <CreateProjectModal onClose={close} />;
default:
return null;
}

View File

@@ -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<HTMLButtonElement>) {
return (
<button
type="button"
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
"hover:bg-accent/60 transition-colors cursor-pointer",
className,
)}
{...props}
>
{children}
</button>
);
}
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<ContentEditorRef>(null);
const [status, setStatus] = useState<ProjectStatus>("planned");
const [priority, setPriority] = useState<ProjectPriority>("none");
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>();
const [leadId, setLeadId] = useState<string | undefined>();
const [icon, setIcon] = useState<string | undefined>();
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className={cn(
"p-0 gap-0 flex flex-col overflow-hidden",
"!top-1/2 !left-1/2 !-translate-x-1/2",
"!transition-all !duration-300 !ease-out",
isExpanded
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
: "!max-w-2xl !w-full !h-96 !-translate-y-1/2",
)}
>
<DialogTitle className="sr-only">New Project</DialogTitle>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">{workspaceName}</span>
<ChevronRight className="size-3 text-muted-foreground/50" />
<span className="font-medium">New project</span>
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger
render={
<button
onClick={() => setIsExpanded(!isExpanded)}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</button>
}
/>
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<button
onClick={() => onOpenChange(false)}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
<XIcon className="size-4" />
</button>
}
/>
<TooltipContent side="bottom">Close</TooltipContent>
</Tooltip>
</div>
</div>
{/* Icon + Title */}
<div className="px-5 pb-2 shrink-0">
<Popover open={iconPickerOpen} onOpenChange={setIconPickerOpen}>
<PopoverTrigger
render={
<button
type="button"
className="text-2xl cursor-pointer rounded-lg p-1 -ml-1 hover:bg-accent/60 transition-colors"
title="Choose icon"
>
{icon || "📁"}
</button>
}
/>
<PopoverContent align="start" className="w-auto p-0">
<EmojiPicker
onSelect={(emoji) => {
setIcon(emoji);
setIconPickerOpen(false);
}}
/>
</PopoverContent>
</Popover>
<TitleEditor
autoFocus
defaultValue=""
placeholder="Project title"
className="text-lg font-semibold"
onChange={(v) => setTitle(v)}
onSubmit={handleSubmit}
/>
</div>
{/* Description */}
<div className="flex-1 min-h-0 overflow-y-auto px-5">
<ContentEditor
ref={descEditorRef}
defaultValue=""
placeholder="Add description..."
debounceMs={500}
/>
</div>
{/* Property toolbar */}
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
{/* Status */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton>
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[status].dotColor)} />
<span>{PROJECT_STATUS_CONFIG[status].label}</span>
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_STATUS_ORDER.map((s) => (
<DropdownMenuItem key={s} onClick={() => setStatus(s)}>
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Priority */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton>
<PriorityIcon priority={priority} />
<span>{PROJECT_PRIORITY_CONFIG[priority].label}</span>
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_PRIORITY_ORDER.map((p) => (
<DropdownMenuItem key={p} onClick={() => setPriority(p)}>
<PriorityIcon priority={p} />
<span>{PROJECT_PRIORITY_CONFIG[p].label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Lead */}
<Popover open={leadOpen} onOpenChange={(v) => { setLeadOpen(v); if (!v) setLeadFilter(""); }}>
<PopoverTrigger
render={
<PillButton>
{leadType && leadId ? (
<>
<ActorAvatar actorType={leadType} actorId={leadId} size={16} />
<span>{leadLabel}</span>
</>
) : (
<span className="text-muted-foreground">Lead</span>
)}
</PillButton>
}
/>
<PopoverContent align="start" className="w-52 p-0">
<div className="px-2 py-1.5 border-b">
<input
type="text"
value={leadFilter}
onChange={(e) => setLeadFilter(e.target.value)}
placeholder="Assign lead..."
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="p-1 max-h-60 overflow-y-auto">
<button
type="button"
onClick={() => { setLeadType(undefined); setLeadId(undefined); setLeadOpen(false); }}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">No lead</span>
</button>
{filteredMembers.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Members</div>
{filteredMembers.map((m) => (
<button
type="button"
key={m.user_id}
onClick={() => { setLeadType("member"); setLeadId(m.user_id); setLeadOpen(false); }}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
<span>{m.name}</span>
</button>
))}
</>
)}
{filteredAgents.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Agents</div>
{filteredAgents.map((a) => (
<button
type="button"
key={a.id}
onClick={() => { setLeadType("agent"); setLeadId(a.id); setLeadOpen(false); }}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
<span>{a.name}</span>
</button>
))}
</>
)}
{filteredMembers.length === 0 && filteredAgents.length === 0 && leadFilter && (
<div className="px-2 py-3 text-center text-sm text-muted-foreground">No results</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
{/* Footer */}
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Project"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
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 (
<div className="flex h-full flex-col">
@@ -551,7 +243,7 @@ export function ProjectsPage() {
<span className="text-xs text-muted-foreground tabular-nums">{projects.length}</span>
)}
</div>
<Button size="sm" variant="outline" onClick={() => setCreateOpen(true)}>
<Button size="sm" variant="outline" onClick={openCreateProject}>
<Plus className="h-3.5 w-3.5 mr-1" />
New project
</Button>
@@ -569,7 +261,7 @@ export function ProjectsPage() {
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
<FolderKanban className="h-10 w-10 mb-3 opacity-30" />
<p className="text-sm">No projects yet</p>
<Button size="sm" variant="outline" className="mt-3" onClick={() => setCreateOpen(true)}>
<Button size="sm" variant="outline" className="mt-3" onClick={openCreateProject}>
Create your first project
</Button>
</div>
@@ -593,8 +285,6 @@ export function ProjectsPage() {
</>
)}
</div>
<CreateProjectDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}

View File

@@ -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<Record<string, unknown>> },
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,21 @@ describe("SearchCommand", () => {
expect(screen.queryByPlaceholderText("Type a command or search...")).not.toBeInTheDocument();
});
it("does not show pages when no query is entered", () => {
it("shows only New Issue by default and hides Pages / Switch Workspace / low-frequency commands until query", () => {
render(<SearchCommand />);
expect(screen.queryByText("Pages")).not.toBeInTheDocument();
expect(screen.queryByText("Switch Workspace")).not.toBeInTheDocument();
// Only the primary creation action surfaces on empty query; everything
// else (theme, copy, New Project) must be revealed by typing.
expect(screen.getByText("Commands")).toBeInTheDocument();
expect(
screen.getByText((_, el) => el?.textContent === "New Issue" && el?.tagName === "SPAN"),
).toBeInTheDocument();
expect(screen.queryByText("New Project")).not.toBeInTheDocument();
expect(screen.queryByText("Switch to Light Theme")).not.toBeInTheDocument();
expect(screen.queryByText("Switch to Dark Theme")).not.toBeInTheDocument();
expect(screen.queryByText("Use System Theme")).not.toBeInTheDocument();
});
it("filters navigation pages by query", async () => {
@@ -112,7 +190,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 +225,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(<SearchCommand />);
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(<SearchCommand />);
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(<SearchCommand />);
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(<SearchCommand />);
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(<SearchCommand />);
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(<SearchCommand />);
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(<SearchCommand />);
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(<SearchCommand />);
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 },

View File

@@ -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,145 @@ 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<CommandItem[]>(() => {
const activeThemeCheck = (value: ThemeValue) =>
theme === value ? (
<Check
aria-label="Current theme"
className="ml-auto size-4 shrink-0 text-muted-foreground"
/>
) : 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: only surface the primary creation action. Other commands
// (theme switches, copy actions, New Project) are revealed as the user
// types, leaving the empty-state space to Recent.
if (!q) return commands.filter((c) => c.key === "new-issue");
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 +426,14 @@ export function SearchCommand() {
[push, setOpen, p],
);
const handleSwitchWorkspace = useCallback(
(slug: string) => {
push(paths.workspace(slug).issues());
setOpen(false);
},
[push, setOpen],
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
@@ -317,17 +489,70 @@ export function SearchCommand() {
</CommandPrimitive.Group>
)}
{/* Commands section — New Issue / New Project / Copy link / Theme, only shown when query matches */}
{filteredCommands.length > 0 && (
<CommandPrimitive.Group className="p-2">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Commands
</div>
{filteredCommands.map((cmd) => (
<CommandPrimitive.Item
key={cmd.key}
value={`command:${cmd.key}`}
onSelect={cmd.onSelect}
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"
>
<cmd.icon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">
<HighlightText text={cmd.label} query={query} />
</span>
{cmd.trailing}
</CommandPrimitive.Item>
))}
</CommandPrimitive.Group>
)}
{/* Workspaces section — switch to a different workspace, only shown when query matches */}
{filteredWorkspaces.length > 0 && (
<CommandPrimitive.Group className="p-2">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Switch Workspace
</div>
{filteredWorkspaces.map((ws) => (
<CommandPrimitive.Item
key={ws.id}
value={`workspace:${ws.id}`}
onSelect={() => 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"
>
<Building2 className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">
<HighlightText text={ws.name} query={query} />
</span>
<span className="ml-auto text-xs text-muted-foreground truncate">
{ws.slug}
</span>
</CommandPrimitive.Item>
))}
</CommandPrimitive.Group>
)}
{isLoading && (
<div className="flex items-center justify-center py-10">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading && query.trim() && !hasResults && filteredPages.length === 0 && (
<CommandPrimitive.Empty className="py-10 text-center text-sm text-muted-foreground">
No results found.
</CommandPrimitive.Empty>
)}
{!isLoading &&
query.trim() &&
!hasResults &&
filteredPages.length === 0 &&
filteredCommands.length === 0 &&
filteredWorkspaces.length === 0 && (
<CommandPrimitive.Empty className="py-10 text-center text-sm text-muted-foreground">
No results found.
</CommandPrimitive.Empty>
)}
{!isLoading && results.projects.length > 0 && (
<CommandPrimitive.Group
@@ -448,9 +673,8 @@ export function SearchCommand() {
)}
{!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 and projects...</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>
<div className="px-5 py-4 text-center text-xs text-muted-foreground">
Type to search issues and projects
</div>
)}
</CommandPrimitive.List>

View File

@@ -10,7 +10,8 @@
"MULTICA_SERVER_URL",
"COMPOSE_PROJECT_NAME",
"POSTGRES_DB",
"POSTGRES_PORT"
"POSTGRES_PORT",
"DESKTOP_RENDERER_PORT"
],
"tasks": {
"build": {