mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
7 Commits
agent/lamb
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2dbf03785 | ||
|
|
3b7abae5b4 | ||
|
|
7843da0315 | ||
|
|
caa18a6983 | ||
|
|
6e980925cf | ||
|
|
8bc20ce161 | ||
|
|
8816e1669c |
@@ -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()],
|
||||
|
||||
@@ -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 |
73
apps/desktop/scripts/brand-dev-electron.mjs
Normal file
73
apps/desktop/scripts/brand-dev-electron.mjs
Normal 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}"`);
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
352
packages/views/modals/create-project.tsx
Normal file
352
packages/views/modals/create-project.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"MULTICA_SERVER_URL",
|
||||
"COMPOSE_PROJECT_NAME",
|
||||
"POSTGRES_DB",
|
||||
"POSTGRES_PORT"
|
||||
"POSTGRES_PORT",
|
||||
"DESKTOP_RENDERER_PORT"
|
||||
],
|
||||
"tasks": {
|
||||
"build": {
|
||||
|
||||
Reference in New Issue
Block a user