Compare commits

..

12 Commits

Author SHA1 Message Date
Jiayuan Zhang
d47be55372 fix(autopilot): address code review — restrict run_only, validate workspace on update
Code review caught two issues with the initial CLI PR:

1. run_only mode is broken end-to-end. The daemon-side
   resolveTaskWorkspaceID() in internal/handler/daemon.go only resolves
   workspace from issue/chat, so run_only tasks (which have neither)
   return 404 from /start. BuildPrompt() would also emit an empty issue
   ID. The service-level resolver in internal/service/task.go already
   handles AutopilotRunID, but the daemon endpoint uses the handler
   copy. Fixing that path is out of scope for the CLI PR; drop
   run_only from the CLI and docs so we don't recommend a mode that
   cannot complete. Server continues to accept it for the existing UI.

2. UpdateAutopilot did not verify that a new assignee_id belongs to
   the workspace, unlike CreateAutopilot. This let a PATCH swap in an
   agent from a different workspace. Mirror the same
   GetAgentInWorkspace check.
2026-04-17 10:36:09 +08:00
Jiayuan Zhang
63daa7a112 feat(cli): add autopilot commands
Expose the existing autopilot REST API through the multica CLI so
users and agents can list, get, create, update, delete, trigger, and
inspect autopilots, plus manage their triggers (schedule/webhook/api).

Also surface the read + core write commands in the agent meta skill
prompt so agents discover them without needing --help.

- new cmd_autopilot.go (+ test) wiring /api/autopilots endpoints
- add APIClient.PatchJSON (autopilot update uses PATCH)
- expose autopilot in CORE COMMANDS group
- extend runtime_config.go meta skill with autopilot entries
- document autopilot command group in CLI_AND_DAEMON.md
2026-04-17 10:21:41 +08:00
Jiayuan Zhang
7dc37e87df fix(autopilot): subscribe creator to autopilot-created issues (#1229)
The issue:created subscriber listener type-asserted payload["issue"] to
handler.IssueResponse, but autopilot publishes the issue as
map[string]any (via service.issueToMap). The assertion failed silently,
so no subscribers (including the creator) were ever added to autopilot
issues — meaning creators received no notifications when their
autopilot run produced comments or status changes.

Add an extractIssueFields helper that accepts either format and use it
in both the issue:created and issue:updated listeners. Mirrors the
dual-format pattern already used by the comment:created listener.
2026-04-17 10:05:43 +08:00
Jiayuan Zhang
cf8a9647bb refactor(autopilot): make status a toggle instead of a pause/activate button (#1228)
Replace the Pause/Activate button on the detail page with a Switch next
to the title, showing a colored status label. Flipping it toggles
between active and paused via the existing updateAutopilot mutation.
2026-04-17 10:05:10 +08:00
Jiayuan Zhang
d7a8e9041e refactor(landing): tighten hero — CTAs, install copy, works-with wrap, LCP priority (#1227)
* refactor(landing): tighten hero — CTAs, install copy, works-with wrap, LCP priority

- Drop GitHub button from hero CTAs (already in header) so the primary
  Start / Download Desktop pair is the clear path.
- Split InstallCommand: outer is no longer a <button>, so text selection
  no longer fights with copy. Mobile gets full-width with break-all;
  desktop keeps the compact pill. Copy button has aria-label.
- Fix invalid `hover:bg-white/8` opacity to `hover:bg-white/[0.08]` so
  the install pill's hover background actually renders.
- Add `flex-wrap` and gap-y to the "Works with" row so the label + 5
  logos can stack on small screens instead of overflowing horizontally.
- Move `priority` from the decorative backdrop image onto the product
  hero image (the actual LCP candidate) to stop background bytes from
  starving the foreground.

* refactor(landing): remove install command from hero

Per design feedback, the install command pill is removed from the hero.
The download path now flows through the Download Desktop CTA only;
install instructions remain available in the docs and README.
2026-04-17 09:37:43 +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
Bohan Jiang
209300c86f fix(server): trigger agent on comments regardless of issue status (#1209)
Previously shouldEnqueueOnComment suppressed agent triggers on done/
cancelled issues, requiring an explicit @mention to resume the
conversation. The gate was non-obvious and confused users who expected
a regular reply to wake the agent up.

Drop the status check — comments are conversational and should wake
the agent up at any status. @mention already bypasses all gates, so
behavior for mentions is unchanged.

Refs multica-ai/multica#1205
2026-04-17 00:57:02 +08:00
25 changed files with 1923 additions and 451 deletions

View File

@@ -385,6 +385,62 @@ multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```
## Autopilot Commands
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).
### List Autopilots
```bash
multica autopilot list
multica autopilot list --status active --output json
```
### Get Autopilot Details
```bash
multica autopilot get <id>
multica autopilot get <id> --output json # includes triggers
```
### Create / Update / Delete
```bash
multica autopilot create \
--title "Nightly bug triage" \
--description "Scan todo issues and prioritize." \
--agent "Lambda" \
--mode create_issue
multica autopilot update <id> --status paused
multica autopilot update <id> --description "New prompt"
multica autopilot delete <id>
```
`--mode` currently only accepts `create_issue` (creates a new issue on each run and assigns it to the agent). The server data model also defines `run_only`, but the daemon task path doesn't yet resolve a workspace for runs without an issue, so it's not exposed by the CLI. `--agent` accepts either a name or UUID.
### Manual Trigger
```bash
multica autopilot trigger <id> # Fires the autopilot once, returns the run
```
### Run History
```bash
multica autopilot runs <id>
multica autopilot runs <id> --limit 50 --output json
```
### Triggers (Schedule / Webhook / API)
```bash
multica autopilot trigger-add <autopilot-id> --kind schedule --cron "0 9 * * 1-5" --timezone "America/New_York"
multica autopilot trigger-add <autopilot-id> --kind webhook
multica autopilot trigger-update <autopilot-id> <trigger-id> --enabled=false
multica autopilot trigger-delete <autopilot-id> <trigger-id>
```
## Other Commands
```bash

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",

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

@@ -1,6 +1,5 @@
"use client";
import { useCallback, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { useAuthStore } from "@multica/core/auth";
@@ -11,8 +10,6 @@ import {
GeminiCliLogo,
OpenClawLogo,
OpenCodeLogo,
GitHubMark,
githubUrl,
heroButtonClassName,
} from "./shared";
@@ -66,25 +63,14 @@ export function LandingHero() {
</svg>
{t.hero.downloadDesktop}
</Link>
<Link
href={githubUrl}
target="_blank"
rel="noreferrer"
className={heroButtonClassName("ghost")}
>
<GitHubMark className="size-4" />
GitHub
</Link>
</div>
<InstallCommand />
</div>
<div className="mt-10 flex items-center justify-center gap-8">
<div className="mt-10 flex flex-wrap items-center justify-center gap-x-6 gap-y-3">
<span className="text-[15px] text-white/50">
{t.hero.worksWith}
</span>
<div className="flex items-center gap-6">
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-3">
<div className="flex items-center gap-2.5 text-white/80">
<ClaudeCodeLogo className="size-5" />
<span className="text-[15px] font-medium">Claude Code</span>
@@ -117,64 +103,6 @@ export function LandingHero() {
);
}
const INSTALL_COMMAND =
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
function InstallCommand() {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(INSTALL_COMMAND);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
}, []);
return (
<div className="mx-auto mt-6 max-w-fit">
<button
type="button"
onClick={handleCopy}
className="group flex items-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 font-mono text-[13px] text-white/70 backdrop-blur-sm transition-colors hover:border-white/20 hover:bg-white/8 hover:text-white/90"
>
<span className="text-white/40">$</span>
<span className="select-all">{INSTALL_COMMAND}</span>
<span className="ml-1 flex size-5 shrink-0 items-center justify-center text-white/40 transition-colors group-hover:text-white/70">
{copied ? (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 text-green-400"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</span>
</button>
</div>
);
}
function LandingBackdrop() {
return (
<div className="pointer-events-none absolute inset-0">
@@ -182,7 +110,6 @@ function LandingBackdrop() {
src="/images/landing-bg.jpg"
alt=""
fill
priority
className="object-cover object-center"
/>
</div>
@@ -198,6 +125,7 @@ function ProductImage({ alt }: { alt: string }) {
alt={alt}
width={3532}
height={2382}
priority
className="block h-auto w-full"
sizes="(max-width: 1320px) 100vw, 1320px"
quality={85}

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

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Zap, Play, Pause, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil } from "lucide-react";
import { Zap, Play, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { autopilotDetailOptions, autopilotRunsOptions } from "@multica/core/autopilots/queries";
import {
@@ -20,6 +20,7 @@ import { PageHeader } from "../../layout/page-header";
import { ActorAvatar } from "../../common/actor-avatar";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import { Switch } from "@multica/ui/components/ui/switch";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import {
@@ -420,9 +421,8 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
}
};
const handleToggleStatus = () => {
const newStatus = autopilot.status === "active" ? "paused" : "active";
updateAutopilot.mutate({ id: autopilotId, status: newStatus });
const handleToggleStatus = (checked: boolean) => {
updateAutopilot.mutate({ id: autopilotId, status: checked ? "active" : "paused" });
};
return (
@@ -435,27 +435,29 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
</AppLink>
<span className="text-muted-foreground">/</span>
<h1 className="text-sm font-medium truncate">{autopilot.title}</h1>
<span className={cn(
"ml-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium",
autopilot.status === "active" ? "bg-emerald-500/10 text-emerald-500" :
autopilot.status === "paused" ? "bg-amber-500/10 text-amber-500" :
"bg-muted text-muted-foreground",
)}>
{autopilot.status}
</span>
<div className="ml-1 flex items-center gap-1.5">
<Switch
size="sm"
checked={autopilot.status === "active"}
onCheckedChange={handleToggleStatus}
disabled={autopilot.status === "archived"}
aria-label={autopilot.status === "active" ? "Pause autopilot" : "Activate autopilot"}
/>
<span className={cn(
"text-xs font-medium capitalize",
autopilot.status === "active" ? "text-emerald-500" :
autopilot.status === "paused" ? "text-amber-500" :
"text-muted-foreground",
)}>
{autopilot.status}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setEditDialogOpen(true)}>
<Pencil className="h-3.5 w-3.5 mr-1" />
Edit
</Button>
<Button size="sm" variant="outline" onClick={handleToggleStatus}>
{autopilot.status === "active" ? (
<><Pause className="h-3.5 w-3.5 mr-1" /> Pause</>
) : (
<><Play className="h-3.5 w-3.5 mr-1" /> Activate</>
)}
</Button>
<Button size="sm" onClick={handleRunNow} disabled={autopilot.status !== "active" || triggerAutopilot.isPending}>
<Play className="h-3.5 w-3.5 mr-1" />
{triggerAutopilot.isPending ? "Running..." : "Run now"}

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

@@ -0,0 +1,618 @@
package main
import (
"context"
"fmt"
"net/url"
"os"
"regexp"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
var autopilotCmd = &cobra.Command{
Use: "autopilot",
Short: "Manage autopilots (scheduled/triggered agent automations)",
}
var autopilotListCmd = &cobra.Command{
Use: "list",
Short: "List autopilots in the workspace",
RunE: runAutopilotList,
}
var autopilotGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get autopilot details (includes triggers)",
Args: exactArgs(1),
RunE: runAutopilotGet,
}
var autopilotCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new autopilot",
RunE: runAutopilotCreate,
}
var autopilotUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update an autopilot",
Args: exactArgs(1),
RunE: runAutopilotUpdate,
}
var autopilotDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete an autopilot",
Args: exactArgs(1),
RunE: runAutopilotDelete,
}
var autopilotTriggerCmd = &cobra.Command{
Use: "trigger <id>",
Short: "Manually trigger an autopilot to run once",
Args: exactArgs(1),
RunE: runAutopilotTrigger,
}
var autopilotRunsCmd = &cobra.Command{
Use: "runs <id>",
Short: "List execution history for an autopilot",
Args: exactArgs(1),
RunE: runAutopilotRuns,
}
var autopilotTriggerAddCmd = &cobra.Command{
Use: "trigger-add <autopilot-id>",
Short: "Add a trigger (schedule/webhook/api) to an autopilot",
Args: exactArgs(1),
RunE: runAutopilotTriggerAdd,
}
var autopilotTriggerUpdateCmd = &cobra.Command{
Use: "trigger-update <autopilot-id> <trigger-id>",
Short: "Update an existing trigger",
Args: exactArgs(2),
RunE: runAutopilotTriggerUpdate,
}
var autopilotTriggerDeleteCmd = &cobra.Command{
Use: "trigger-delete <autopilot-id> <trigger-id>",
Short: "Delete a trigger",
Args: exactArgs(2),
RunE: runAutopilotTriggerDelete,
}
func init() {
autopilotCmd.AddCommand(autopilotListCmd)
autopilotCmd.AddCommand(autopilotGetCmd)
autopilotCmd.AddCommand(autopilotCreateCmd)
autopilotCmd.AddCommand(autopilotUpdateCmd)
autopilotCmd.AddCommand(autopilotDeleteCmd)
autopilotCmd.AddCommand(autopilotTriggerCmd)
autopilotCmd.AddCommand(autopilotRunsCmd)
autopilotCmd.AddCommand(autopilotTriggerAddCmd)
autopilotCmd.AddCommand(autopilotTriggerUpdateCmd)
autopilotCmd.AddCommand(autopilotTriggerDeleteCmd)
// list
autopilotListCmd.Flags().String("status", "", "Filter by status (active, paused)")
autopilotListCmd.Flags().String("output", "table", "Output format: table or json")
// get
autopilotGetCmd.Flags().String("output", "json", "Output format: table or json")
// create
autopilotCreateCmd.Flags().String("title", "", "Autopilot title (required)")
autopilotCreateCmd.Flags().String("description", "", "Autopilot description (used as task prompt)")
autopilotCreateCmd.Flags().String("agent", "", "Assignee agent (name or ID) — required")
autopilotCreateCmd.Flags().String("mode", "", "Execution mode: create_issue (required). run_only is not yet supported end-to-end.")
autopilotCreateCmd.Flags().String("priority", "none", "Priority for created issues (none, low, medium, high, urgent)")
autopilotCreateCmd.Flags().String("project", "", "Project ID (optional)")
autopilotCreateCmd.Flags().String("issue-title-template", "", "Template for issue titles (create_issue mode)")
autopilotCreateCmd.Flags().String("output", "json", "Output format: table or json")
// update
autopilotUpdateCmd.Flags().String("title", "", "New title")
autopilotUpdateCmd.Flags().String("description", "", "New description")
autopilotUpdateCmd.Flags().String("agent", "", "New assignee agent (name or ID)")
autopilotUpdateCmd.Flags().String("project", "", "New project ID (use empty string to clear)")
autopilotUpdateCmd.Flags().String("priority", "", "New priority")
autopilotUpdateCmd.Flags().String("status", "", "New status (active, paused)")
autopilotUpdateCmd.Flags().String("mode", "", "New execution mode (create_issue)")
autopilotUpdateCmd.Flags().String("issue-title-template", "", "New issue title template")
autopilotUpdateCmd.Flags().String("output", "json", "Output format: table or json")
// delete
// (no flags)
// trigger (manual run)
autopilotTriggerCmd.Flags().String("output", "json", "Output format: table or json")
// runs
autopilotRunsCmd.Flags().Int("limit", 20, "Max number of runs to return")
autopilotRunsCmd.Flags().Int("offset", 0, "Pagination offset")
autopilotRunsCmd.Flags().String("output", "table", "Output format: table or json")
// trigger-add
autopilotTriggerAddCmd.Flags().String("kind", "", "Trigger kind: schedule, webhook, or api (required)")
autopilotTriggerAddCmd.Flags().String("cron", "", "Cron expression (required for kind=schedule)")
autopilotTriggerAddCmd.Flags().String("timezone", "", "IANA timezone for schedule (default UTC)")
autopilotTriggerAddCmd.Flags().String("label", "", "Optional human-readable label")
autopilotTriggerAddCmd.Flags().String("output", "json", "Output format: table or json")
// trigger-update
autopilotTriggerUpdateCmd.Flags().Bool("enabled", true, "Enable or disable the trigger")
autopilotTriggerUpdateCmd.Flags().String("cron", "", "New cron expression")
autopilotTriggerUpdateCmd.Flags().String("timezone", "", "New IANA timezone")
autopilotTriggerUpdateCmd.Flags().String("label", "", "New label")
autopilotTriggerUpdateCmd.Flags().String("output", "json", "Output format: table or json")
}
// ---------------------------------------------------------------------------
// Autopilot commands
// ---------------------------------------------------------------------------
func runAutopilotList(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
if _, err := requireWorkspaceID(cmd); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
path := "/api/autopilots"
if status, _ := cmd.Flags().GetString("status"); status != "" {
path += "?" + url.Values{"status": {status}}.Encode()
}
var resp struct {
Autopilots []map[string]any `json:"autopilots"`
Total int `json:"total"`
}
if err := client.GetJSON(ctx, path, &resp); err != nil {
return fmt.Errorf("list autopilots: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, resp)
}
headers := []string{"ID", "TITLE", "STATUS", "MODE", "ASSIGNEE", "LAST_RUN"}
rows := make([][]string, 0, len(resp.Autopilots))
for _, a := range resp.Autopilots {
rows = append(rows, []string{
truncateID(strVal(a, "id")),
strVal(a, "title"),
strVal(a, "status"),
strVal(a, "execution_mode"),
truncateID(strVal(a, "assignee_id")),
strVal(a, "last_run_at"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runAutopilotGet(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var resp map[string]any
if err := client.GetJSON(ctx, "/api/autopilots/"+args[0], &resp); err != nil {
return fmt.Errorf("get autopilot: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, resp)
}
ap, _ := resp["autopilot"].(map[string]any)
headers := []string{"ID", "TITLE", "STATUS", "MODE", "ASSIGNEE", "LAST_RUN"}
rows := [][]string{{
truncateID(strVal(ap, "id")),
strVal(ap, "title"),
strVal(ap, "status"),
strVal(ap, "execution_mode"),
truncateID(strVal(ap, "assignee_id")),
strVal(ap, "last_run_at"),
}}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runAutopilotCreate(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
if _, err := requireWorkspaceID(cmd); err != nil {
return err
}
title, _ := cmd.Flags().GetString("title")
if title == "" {
return fmt.Errorf("--title is required")
}
agent, _ := cmd.Flags().GetString("agent")
if agent == "" {
return fmt.Errorf("--agent is required (agent name or ID)")
}
mode, _ := cmd.Flags().GetString("mode")
if mode == "" {
return fmt.Errorf("--mode is required (create_issue)")
}
// run_only is a valid value server-side but the dispatch path is not wired
// end-to-end (daemon /start resolves workspace only via issue/chat, and the
// agent prompt expects an issue ID). Keep the CLI to create_issue until the
// server path is fixed to avoid shipping a mode that returns 404 on start.
if mode != "create_issue" {
return fmt.Errorf("--mode must be create_issue (run_only is not yet supported end-to-end)")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
agentID, err := resolveAgent(ctx, client, agent)
if err != nil {
return fmt.Errorf("resolve agent: %w", err)
}
body := map[string]any{
"title": title,
"assignee_id": agentID,
"execution_mode": mode,
}
if v, _ := cmd.Flags().GetString("description"); v != "" {
body["description"] = v
}
if cmd.Flags().Changed("priority") {
v, _ := cmd.Flags().GetString("priority")
body["priority"] = v
}
if v, _ := cmd.Flags().GetString("project"); v != "" {
body["project_id"] = v
}
if v, _ := cmd.Flags().GetString("issue-title-template"); v != "" {
body["issue_title_template"] = v
}
var result map[string]any
if err := client.PostJSON(ctx, "/api/autopilots", body, &result); err != nil {
return fmt.Errorf("create autopilot: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Autopilot created: %s (%s)\n", strVal(result, "title"), strVal(result, "id"))
return nil
}
func runAutopilotUpdate(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
body := map[string]any{}
if cmd.Flags().Changed("title") {
v, _ := cmd.Flags().GetString("title")
body["title"] = v
}
if cmd.Flags().Changed("description") {
v, _ := cmd.Flags().GetString("description")
body["description"] = v
}
if cmd.Flags().Changed("agent") {
v, _ := cmd.Flags().GetString("agent")
agentID, resolveErr := resolveAgent(ctx, client, v)
if resolveErr != nil {
return fmt.Errorf("resolve agent: %w", resolveErr)
}
body["assignee_id"] = agentID
}
if cmd.Flags().Changed("project") {
v, _ := cmd.Flags().GetString("project")
if v == "" {
body["project_id"] = nil
} else {
body["project_id"] = v
}
}
if cmd.Flags().Changed("priority") {
v, _ := cmd.Flags().GetString("priority")
body["priority"] = v
}
if cmd.Flags().Changed("status") {
v, _ := cmd.Flags().GetString("status")
body["status"] = v
}
if cmd.Flags().Changed("mode") {
v, _ := cmd.Flags().GetString("mode")
if v != "create_issue" {
return fmt.Errorf("--mode must be create_issue (run_only is not yet supported end-to-end)")
}
body["execution_mode"] = v
}
if cmd.Flags().Changed("issue-title-template") {
v, _ := cmd.Flags().GetString("issue-title-template")
body["issue_title_template"] = v
}
if len(body) == 0 {
return fmt.Errorf("no fields to update; use flags like --title, --description, --agent, --status, --mode, etc.")
}
var result map[string]any
if err := client.PatchJSON(ctx, "/api/autopilots/"+args[0], body, &result); err != nil {
return fmt.Errorf("update autopilot: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Autopilot updated: %s (%s)\n", strVal(result, "title"), strVal(result, "id"))
return nil
}
func runAutopilotDelete(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := client.DeleteJSON(ctx, "/api/autopilots/"+args[0]); err != nil {
return fmt.Errorf("delete autopilot: %w", err)
}
fmt.Printf("Autopilot %s deleted.\n", args[0])
return nil
}
func runAutopilotTrigger(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var run map[string]any
if err := client.PostJSON(ctx, "/api/autopilots/"+args[0]+"/trigger", nil, &run); err != nil {
return fmt.Errorf("trigger autopilot: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, run)
}
fmt.Printf("Autopilot triggered: run %s (status: %s)\n", strVal(run, "id"), strVal(run, "status"))
return nil
}
func runAutopilotRuns(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
params := url.Values{}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
params.Set("limit", fmt.Sprintf("%d", v))
}
if v, _ := cmd.Flags().GetInt("offset"); v > 0 {
params.Set("offset", fmt.Sprintf("%d", v))
}
path := "/api/autopilots/" + args[0] + "/runs"
if len(params) > 0 {
path += "?" + params.Encode()
}
var resp struct {
Runs []map[string]any `json:"runs"`
Total int `json:"total"`
}
if err := client.GetJSON(ctx, path, &resp); err != nil {
return fmt.Errorf("list runs: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, resp)
}
headers := []string{"ID", "SOURCE", "STATUS", "ISSUE", "TRIGGERED_AT", "COMPLETED_AT"}
rows := make([][]string, 0, len(resp.Runs))
for _, r := range resp.Runs {
rows = append(rows, []string{
truncateID(strVal(r, "id")),
strVal(r, "source"),
strVal(r, "status"),
truncateID(strVal(r, "issue_id")),
strVal(r, "triggered_at"),
strVal(r, "completed_at"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runAutopilotTriggerAdd(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
kind, _ := cmd.Flags().GetString("kind")
if kind == "" {
return fmt.Errorf("--kind is required (schedule, webhook, or api)")
}
if kind != "schedule" && kind != "webhook" && kind != "api" {
return fmt.Errorf("--kind must be schedule, webhook, or api")
}
cron, _ := cmd.Flags().GetString("cron")
if kind == "schedule" && cron == "" {
return fmt.Errorf("--cron is required for kind=schedule")
}
body := map[string]any{"kind": kind}
if cron != "" {
body["cron_expression"] = cron
}
if v, _ := cmd.Flags().GetString("timezone"); v != "" {
body["timezone"] = v
}
if v, _ := cmd.Flags().GetString("label"); v != "" {
body["label"] = v
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
if err := client.PostJSON(ctx, "/api/autopilots/"+args[0]+"/triggers", body, &result); err != nil {
return fmt.Errorf("create trigger: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Trigger created: %s (kind=%s)\n", strVal(result, "id"), strVal(result, "kind"))
return nil
}
func runAutopilotTriggerUpdate(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
body := map[string]any{}
if cmd.Flags().Changed("enabled") {
v, _ := cmd.Flags().GetBool("enabled")
body["enabled"] = v
}
if cmd.Flags().Changed("cron") {
v, _ := cmd.Flags().GetString("cron")
body["cron_expression"] = v
}
if cmd.Flags().Changed("timezone") {
v, _ := cmd.Flags().GetString("timezone")
body["timezone"] = v
}
if cmd.Flags().Changed("label") {
v, _ := cmd.Flags().GetString("label")
body["label"] = v
}
if len(body) == 0 {
return fmt.Errorf("no fields to update; use --enabled, --cron, --timezone, or --label")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result map[string]any
path := "/api/autopilots/" + args[0] + "/triggers/" + args[1]
if err := client.PatchJSON(ctx, path, body, &result); err != nil {
return fmt.Errorf("update trigger: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Trigger updated: %s\n", strVal(result, "id"))
return nil
}
func runAutopilotTriggerDelete(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
path := "/api/autopilots/" + args[0] + "/triggers/" + args[1]
if err := client.DeleteJSON(ctx, path); err != nil {
return fmt.Errorf("delete trigger: %w", err)
}
fmt.Printf("Trigger %s deleted.\n", args[1])
return nil
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// uuidRegexp matches a canonical UUID (8-4-4-4-12 hex).
var uuidRegexp = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
// resolveAgent accepts either a UUID or an agent name (case-insensitive substring)
// and returns the agent's UUID. Errors on no match or ambiguous match.
func resolveAgent(ctx context.Context, client *cli.APIClient, nameOrID string) (string, error) {
if uuidRegexp.MatchString(nameOrID) {
return nameOrID, nil
}
if client.WorkspaceID == "" {
return "", fmt.Errorf("workspace ID is required to resolve agents; use --workspace-id or set MULTICA_WORKSPACE_ID")
}
var agents []map[string]any
agentPath := "/api/agents?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
if err := client.GetJSON(ctx, agentPath, &agents); err != nil {
return "", fmt.Errorf("fetch agents: %w", err)
}
nameLower := strings.ToLower(nameOrID)
type match struct{ ID, Name string }
var matches []match
for _, a := range agents {
aName := strVal(a, "name")
if strings.Contains(strings.ToLower(aName), nameLower) {
matches = append(matches, match{ID: strVal(a, "id"), Name: aName})
}
}
switch len(matches) {
case 0:
return "", fmt.Errorf("no agent found matching %q", nameOrID)
case 1:
return matches[0].ID, nil
default:
var parts []string
for _, m := range matches {
parts = append(parts, fmt.Sprintf(" %q (%s)", m.Name, truncateID(m.ID)))
}
return "", fmt.Errorf("ambiguous agent %q; matches:\n%s", nameOrID, strings.Join(parts, "\n"))
}
}

View File

@@ -0,0 +1,120 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/multica-ai/multica/server/internal/cli"
)
func TestResolveAgent(t *testing.T) {
agentsResp := []map[string]any{
{"id": "11111111-1111-1111-1111-111111111111", "name": "Lambda"},
{"id": "22222222-2222-2222-2222-222222222222", "name": "Codex Agent"},
{"id": "33333333-3333-3333-3333-333333333333", "name": "Claude Reviewer"},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/agents" {
json.NewEncoder(w).Encode(agentsResp)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
ctx := context.Background()
t.Run("passes through a UUID without lookup", func(t *testing.T) {
id := "44444444-4444-4444-4444-444444444444"
got, err := resolveAgent(ctx, client, id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != id {
t.Errorf("got %q, want %q", got, id)
}
})
t.Run("exact name match", func(t *testing.T) {
got, err := resolveAgent(ctx, client, "Lambda")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "11111111-1111-1111-1111-111111111111" {
t.Errorf("got %q, want Lambda's UUID", got)
}
})
t.Run("case-insensitive substring", func(t *testing.T) {
got, err := resolveAgent(ctx, client, "codex")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "22222222-2222-2222-2222-222222222222" {
t.Errorf("got %q, want Codex Agent's UUID", got)
}
})
t.Run("no match", func(t *testing.T) {
_, err := resolveAgent(ctx, client, "nobody")
if err == nil {
t.Fatal("expected error for no match")
}
})
t.Run("ambiguous match", func(t *testing.T) {
_, err := resolveAgent(ctx, client, "a") // matches Lambda, Codex Agent, Claude Reviewer
if err == nil {
t.Fatal("expected error for ambiguous match")
}
if !strings.Contains(err.Error(), "ambiguous") {
t.Errorf("expected ambiguous error, got: %v", err)
}
})
t.Run("missing workspace ID for name lookup", func(t *testing.T) {
noWSClient := cli.NewAPIClient(srv.URL, "", "test-token")
_, err := resolveAgent(ctx, noWSClient, "Lambda")
if err == nil {
t.Fatal("expected error when workspace ID is missing")
}
})
t.Run("UUID works without workspace ID", func(t *testing.T) {
noWSClient := cli.NewAPIClient(srv.URL, "", "test-token")
id := "55555555-5555-5555-5555-555555555555"
got, err := resolveAgent(ctx, noWSClient, id)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != id {
t.Errorf("got %q, want %q", got, id)
}
})
}
func TestUUIDRegexp(t *testing.T) {
tests := []struct {
in string
want bool
}{
{"11111111-1111-1111-1111-111111111111", true},
{"A1B2C3D4-1111-1111-1111-111111111111", true},
{"not-a-uuid", false},
{"11111111-1111-1111-1111-11111111111", false}, // too short
{"11111111111111111111111111111111", false}, // missing dashes
{"11111111-1111-1111-1111-1111111111111", false}, // too long
{"", false},
}
for _, tt := range tests {
if got := uuidRegexp.MatchString(tt.in); got != tt.want {
t.Errorf("uuidRegexp.MatchString(%q) = %v, want %v", tt.in, got, tt.want)
}
}
}

View File

@@ -34,6 +34,7 @@ func init() {
issueCmd.GroupID = groupCore
projectCmd.GroupID = groupCore
agentCmd.GroupID = groupCore
autopilotCmd.GroupID = groupCore
workspaceCmd.GroupID = groupCore
repoCmd.GroupID = groupCore
skillCmd.GroupID = groupCore
@@ -54,6 +55,7 @@ func init() {
rootCmd.AddCommand(issueCmd)
rootCmd.AddCommand(projectCmd)
rootCmd.AddCommand(agentCmd)
rootCmd.AddCommand(autopilotCmd)
rootCmd.AddCommand(workspaceCmd)
rootCmd.AddCommand(repoCmd)
rootCmd.AddCommand(skillCmd)

View File

@@ -20,7 +20,9 @@ func registerSubscriberListeners(bus *events.Bus, queries *db.Queries) {
if !ok {
return
}
issue, ok := payload["issue"].(handler.IssueResponse)
// Issues created via handler use IssueResponse; autopilot-created issues
// use map[string]any (see service/autopilot.go → issueToMap).
issue, ok := extractIssueFields(payload["issue"])
if !ok {
return
}
@@ -48,7 +50,7 @@ func registerSubscriberListeners(bus *events.Bus, queries *db.Queries) {
if !ok {
return
}
issue, ok := payload["issue"].(handler.IssueResponse)
issue, ok := extractIssueFields(payload["issue"])
if !ok {
return
}
@@ -107,6 +109,31 @@ func registerSubscriberListeners(bus *events.Bus, queries *db.Queries) {
})
}
// extractIssueFields normalizes an issue payload that may be either a
// handler.IssueResponse struct (HTTP handler path) or a map[string]any
// (autopilot service path) into a common shape.
func extractIssueFields(v any) (handler.IssueResponse, bool) {
if issue, ok := v.(handler.IssueResponse); ok {
return issue, true
}
m, ok := v.(map[string]any)
if !ok {
return handler.IssueResponse{}, false
}
issue := handler.IssueResponse{}
issue.ID, _ = m["id"].(string)
issue.WorkspaceID, _ = m["workspace_id"].(string)
issue.CreatorType, _ = m["creator_type"].(string)
issue.CreatorID, _ = m["creator_id"].(string)
issue.AssigneeType, _ = m["assignee_type"].(*string)
issue.AssigneeID, _ = m["assignee_id"].(*string)
issue.Description, _ = m["description"].(*string)
if issue.ID == "" || issue.CreatorID == "" {
return handler.IssueResponse{}, false
}
return issue, true
}
// addSubscriber adds a user as an issue subscriber and publishes a
// subscriber:added event for real-time frontend sync.
func addSubscriber(bus *events.Bus, queries *db.Queries, workspaceID, issueID, userType, userID, reason string) {

View File

@@ -357,6 +357,39 @@ func TestSubscriberAddedEventPublished(t *testing.T) {
}
}
// Autopilot publishes EventIssueCreated with a map[string]any payload (not handler.IssueResponse).
// The listener must still subscribe the creator.
func TestSubscriberIssueCreated_AutopilotMapPayload(t *testing.T) {
queries := db.New(testPool)
bus := events.New()
registerSubscriberListeners(bus, queries)
issueID := createTestIssue(t, testWorkspaceID, testUserID)
t.Cleanup(func() { cleanupTestIssue(t, issueID) })
bus.Publish(events.Event{
Type: protocol.EventIssueCreated,
WorkspaceID: testWorkspaceID,
ActorType: "member",
ActorID: testUserID,
Payload: map[string]any{
"issue": map[string]any{
"id": issueID,
"workspace_id": testWorkspaceID,
"title": "autopilot test issue",
"status": "todo",
"priority": "medium",
"creator_type": "member",
"creator_id": testUserID,
},
},
})
if !isSubscribed(t, queries, issueID, "member", testUserID) {
t.Fatal("expected creator to be subscribed when autopilot publishes map payload")
}
}
// Verify parseUUID is consistent — pgtype.UUID from our local helper should match util.ParseUUID
func TestParseUUIDConsistency(t *testing.T) {
uuid := "550e8400-e29b-41d4-a716-446655440000"

View File

@@ -183,6 +183,36 @@ func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any)
return json.NewDecoder(resp.Body).Decode(out)
}
// PatchJSON performs a PATCH request with a JSON body.
func (c *APIClient) PatchJSON(ctx context.Context, path string, body any, out any) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.BaseURL+path, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("PATCH %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(respData)))
}
if out == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
// UploadFile uploads a file via multipart form to /api/upload-file.
// It returns the attachment ID from the server response.
func (c *APIClient) UploadFile(ctx context.Context, fileData []byte, filename string, issueID string) (string, error) {

View File

@@ -75,7 +75,10 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("- `multica repo checkout <url>` — Check out a repository into the working directory (creates a git worktree with a dedicated branch)\n")
b.WriteString("- `multica issue runs <issue-id> --output json` — List all execution runs for an issue (status, timestamps, errors)\n")
b.WriteString("- `multica issue run-messages <task-id> [--since <seq>] --output json` — List messages for a specific execution run (supports incremental fetch)\n")
b.WriteString("- `multica attachment download <id> [-o <dir>]` — Download an attachment file locally by ID\n\n")
b.WriteString("- `multica attachment download <id> [-o <dir>]` — Download an attachment file locally by ID\n")
b.WriteString("- `multica autopilot list [--status X] --output json` — List autopilots (scheduled/triggered agent automations) in the workspace\n")
b.WriteString("- `multica autopilot get <id> --output json` — Get autopilot details including triggers\n")
b.WriteString("- `multica autopilot runs <id> [--limit N] --output json` — List execution history for an autopilot\n\n")
b.WriteString("### Write\n")
b.WriteString("- `multica issue create --title \"...\" [--description \"...\"] [--priority X] [--assignee X] [--parent <issue-id>] [--status X]` — Create a new issue\n")
@@ -84,7 +87,11 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString(" - For content with special characters (backticks, quotes), pipe via stdin: `cat <<'COMMENT' | multica issue comment add <issue-id> --content-stdin`\n")
b.WriteString("- `multica issue comment delete <comment-id>` — Delete a comment\n")
b.WriteString("- `multica issue status <id> <status>` — Update issue status (todo, in_progress, in_review, done, blocked)\n")
b.WriteString("- `multica issue update <id> [--title X] [--description X] [--priority X]` — Update issue fields\n\n")
b.WriteString("- `multica issue update <id> [--title X] [--description X] [--priority X]` — Update issue fields\n")
b.WriteString("- `multica autopilot create --title \"...\" --agent <name> --mode create_issue [--description \"...\"]` — Create an autopilot\n")
b.WriteString("- `multica autopilot update <id> [--title X] [--description X] [--status active|paused]` — Update an autopilot\n")
b.WriteString("- `multica autopilot trigger <id>` — Manually trigger an autopilot to run once\n")
b.WriteString("- `multica autopilot delete <id>` — Delete an autopilot\n\n")
// Inject available repositories section.
if len(ctx.Repos) > 0 {

View File

@@ -359,6 +359,13 @@ func (h *Handler) UpdateAutopilot(w http.ResponseWriter, r *http.Request) {
}
if _, ok := rawFields["assignee_id"]; ok {
if req.AssigneeID != nil {
if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
ID: parseUUID(*req.AssigneeID),
WorkspaceID: parseUUID(workspaceID),
}); err != nil {
writeError(w, http.StatusBadRequest, "assignee must be a valid agent in this workspace")
return
}
params.AssigneeID = parseUUID(*req.AssigneeID)
}
}

View File

@@ -1185,13 +1185,10 @@ func (h *Handler) shouldEnqueueAgentTask(ctx context.Context, issue db.Issue) bo
}
// shouldEnqueueOnComment returns true if a member comment on this issue should
// trigger the assigned agent. Fires for any non-terminal status — comments are
// conversational and can happen at any stage of active work.
// trigger the assigned agent. Fires for any status — comments are
// conversational and can happen at any stage, including after completion
// (e.g. follow-up questions on a done issue).
func (h *Handler) shouldEnqueueOnComment(ctx context.Context, issue db.Issue) bool {
// Don't trigger on terminal statuses (done, cancelled).
if issue.Status == "done" || issue.Status == "cancelled" {
return false
}
if !h.isAgentAssigneeReady(ctx, issue) {
return false
}

View File

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