Compare commits

...

13 Commits

Author SHA1 Message Date
Jiayuan Zhang
a2dbf03785 fix(desktop): center dock icon within canvas
The bundled dock icon used by `pnpm dev:desktop` had its squircle
touching the top of the 1024×1024 canvas (T=0, B=11), making the
shape look shifted up in the macOS dock. Shift content down 6px
to balance margins (T=6, B=5).
2026-04-17 09:25:47 +08:00
Jiayuan Zhang
3b7abae5b4 refactor(search): collapse cmd+k empty-state commands to primary action (#1225)
Previously every registered Command (New Issue, New Project, three theme
switches, plus contextual Copy actions on issue pages) surfaced on empty
query, leaving only 3–5 rows for Recent in a 400px panel. Low-frequency
commands (theme, copy, New Project) are now revealed by typing, matching
the progressive-disclosure pattern already used for Pages and Switch
Workspace. Refs MUL-991.
2026-04-17 09:09:55 +08:00
Jiayuan Zhang
7843da0315 refactor(issues): lighten board card styling (#1217)
Slimmer 0.5px border, 12/10 asymmetric padding, and a two-layer soft
drop shadow give the kanban card a more weightless look on the board.
2026-04-17 02:15:24 +08:00
Jiayuan Zhang
caa18a6983 feat(search): extend cmd+k palette (theme toggle, new issue/project, copy link, switch workspace) (#1208)
* feat(search): add light/dark/system theme toggle actions to cmd+k

The command palette now surfaces an "Actions" section with theme toggle
items (Light / Dark / System), searchable via keywords like "theme",
"light", "dark", "appearance", or "mode". The active theme is marked
with a check icon.

* feat(search): add quick-win commands to cmd+k palette

Extends the command palette with a "Commands" group that consolidates
theme toggles plus four new actions:

- New Issue / New Project — trigger the global create modals
- Copy Issue Link / Copy Identifier (MUL-xxx) — only when the current
  route is an issue detail page; mirrors the copy-link dropdown logic
  from issue-detail

Adds a "Switch Workspace" group that lists the user's other workspaces
(filtered by name/slug, or by typing "workspace"/"switch") and
navigates to the selected workspace's issues page.

To make "New Project" work from anywhere, the inline CreateProjectDialog
on ProjectsPage is extracted into a global CreateProjectModal mounted
via the existing ModalRegistry + modal store (same pattern as
create-issue / create-workspace). The modal store type gains a
"create-project" variant.

* feat(search): show Commands by default so they're discoverable

Before, cmd+k actions (New Issue / New Project / Copy link / Copy ID /
theme toggles) only appeared when the user typed a matching keyword,
leaving them invisible unless the user already knew they existed.

Now the Commands group renders as soon as the palette opens (no query),
with the whole command list shown; typing narrows it down as before.
Also trims the redundant "⌘K to open this anytime" hint from the empty
state — the palette is already open.
2026-04-17 02:03:03 +08:00
Jiayuan Zhang
6e980925cf chore(desktop): DESKTOP_APP_SUFFIX env for parallel-worktree dev (#1215)
Dev Electron uses a single userData path ("Multica Canary") derived from
the app name, which also locates the single-instance lock. Two worktrees
running dev simultaneously fight for that lock — the second `app.quit()`s
silently before opening a window.

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

  DESKTOP_APP_SUFFIX=foo  → "Multica Canary foo"

Default (no env var) keeps behavior unchanged.

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

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

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

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

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

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

app.setName() can't override the macOS menu bar title or Cmd+Tab label
— those come from CFBundleName baked into the running bundle's
Info.plist. Patch the bundled Electron.app's plist during `pnpm
dev:desktop` so dev launches read "Multica Canary" everywhere, not
"Electron". Idempotent; unlinks before rewriting so we don't mutate a
pnpm-store inode shared with other projects.
2026-04-17 01:21:53 +08:00
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
Bohan Jiang
3d98f64ea1 Revert "fix(daemon): normalize hostname by stripping .local mDNS suffix (#1070)" (#1207)
This reverts commit 6428a10046.
2026-04-17 00:35:06 +08:00
Jiayuan Zhang
ec30e46947 feat(issues): persist comment collapse state (#1008)
* feat(issues): persist comment collapse state across page reloads

Store collapsed comment IDs in a workspace-scoped Zustand store backed
by localStorage, replacing the transient useState(true) default.
Comments now remember their collapsed/expanded state per issue.

* test(issues): add useCommentCollapseStore mock to issue-detail tests

The existing vi.mock for @multica/core/issues/stores didn't include the
newly exported useCommentCollapseStore, causing CommentCard to throw at
render time.
2026-04-17 00:14:00 +08:00
pradeep7127
6428a10046 fix(daemon): normalize hostname by stripping .local mDNS suffix (#1070)
* fix(daemon): normalize hostname by stripping .local mDNS suffix

Daemons started via different methods (standalone CLI vs desktop app
bundled binary) resolve the hostname differently on macOS — one gets
'computer' and the other 'computer.local'. This caused duplicate runtime
registrations for the same machine.

Stripping the .local suffix at the point of hostname resolution ensures
both always register under the same identifier.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(daemon): move empty-host fallback to after .local trim; fix Makefile @ prefix

- Reorder: TrimSuffix runs first, then empty-check, so a hostname of
  just ".local" doesn't propagate as an empty daemon_id/device_name
- Add missing @ prefix on migrate command in Makefile so it isn't
  echoed twice at startup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 23:42:12 +08:00
LinYushen
fe6208c61f fix(desktop): strip leading '--' so --publish reaches electron-builder (#1199)
When invoked as `pnpm package -- --mac --arm64 --publish always`,
the bare `--` separator that pnpm inserts was forwarded into
electron-builder's argv. This terminated option parsing, causing
`--publish always` to be treated as positional arguments instead of
a named flag. As a result electron-builder built locally but never
uploaded artifacts to the GitHub Release (isPublish: false).

Add `stripLeadingSeparator()` to remove the leading `--` before
passing args through. Includes unit tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 23:33:14 +08:00
Naiyuan Qing
336f90fd26 fix(desktop): new tab inherits current workspace + guard against malformed tab paths (#1198)
* fix(desktop): new tab inherits current workspace + guard against malformed tab paths

Three layered fixes for the same root cause: tab URLs were being
constructed without a workspace slug in some code paths, triggering
NoAccessPage whenever the router interpreted the first segment as a
(non-existent) workspace slug.

## Layer 1 — tab-bar "+" button now inherits current workspace

The handler had a hardcoded `path = "/issues"` left over from before
the slug URL refactor. Without a workspace prefix, the router saw
`workspaceSlug = "issues"` and rendered NoAccessPage. Read
`getCurrentSlug()` and build `/{slug}/issues` instead. Falls back to
"/" (→ IndexRedirect) when there is no current workspace.

This matches terminal/IDE new-tab semantics: new tab opens in the
same workspace as the active tab, not in `wsList[0]`.

## Layer 2 — validateWorkspaceSlugs runs synchronously

PR #1178 added startup validation of persisted tab slugs against the
current workspace list, but ran it in a useEffect. useEffect fires
AFTER commit, so the initial render would briefly show NoAccessPage
on a stale slug before the effect reset the tab path. Moving the call
into render phase eliminates that flash; zustand supports setState in
render, and the validator is idempotent (early-returns if nothing
changed) so this doesn't loop.

## Layer 3 — tab store rejects malformed paths at construction

Any path whose first segment is a reserved slug (e.g. "/issues",
"/login") clearly lacks a workspace prefix and is a caller bug.
sanitizeTabPath catches these at makeTab time, rewrites to "/", and
logs a console.warn naming the offending path so the bug can be fixed
at source. Any future new-tab entry point that forgets the slug will
not reach NoAccessPage.

Net effect: NoAccessPage is reserved for its legitimate purpose —
users navigating to URLs they genuinely don't have access to — and
can no longer be triggered by system bugs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* review: read new-tab workspace from active tab + unify sanitize + add tests

Three follow-ups from self-review of PR #1198:

1. Resolve the current workspace from the active tab's path instead of
   from getCurrentSlug(). With N tabs mounted under <Activity>, every
   WorkspaceRouteLayout calls setCurrentWorkspace() in render — the
   singleton ends up holding "whichever tab rendered last", which is
   non-deterministic. activeTabId is the unambiguous source of truth
   for "which workspace is the user actually looking at right now".

2. Unify the persist merge's stale-path detection with sanitizeTabPath.
   The merge previously checked ROUTE_ICONS (dashboard segments only);
   sanitizeTabPath uses isReservedSlug (dashboard + auth + platform +
   RFC 2142 + hostname confusables). Same code path now, wider
   coverage, and one source of truth.

3. Add unit tests for sanitizeTabPath: root pass-through, global paths,
   valid workspace-scoped paths, malformed paths (reserved first
   segment) rejected with console.warn, and user slugs that happen to
   look path-like but aren't reserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 23:15:53 +08:00
25 changed files with 1204 additions and 382 deletions

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 735 KiB

After

Width:  |  Height:  |  Size: 534 KiB

View File

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

View File

@@ -39,6 +39,18 @@ function sh(cmd) {
}
}
/**
* Strip the leading `--` that npm/pnpm insert to separate their own
* flags from the ones meant for the underlying script. Without this,
* `pnpm package -- --mac --arm64 --publish always` forwards the bare
* `--` into electron-builder's argv, which terminates option parsing
* and turns `--publish always` into ignored positional arguments.
*/
export function stripLeadingSeparator(argv) {
if (argv.length > 0 && argv[0] === "--") return argv.slice(1);
return argv;
}
/**
* Pure transformation from the `git describe --tags --always --dirty`
* output to the value we feed into electron-builder's extraMetadata.version.
@@ -102,7 +114,7 @@ function main() {
}
// Step 4: assemble electron-builder args.
const passthrough = process.argv.slice(2);
const passthrough = stripLeadingSeparator(process.argv.slice(2));
const builderArgs = [];
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { normalizeGitVersion } from "./package.mjs";
import { normalizeGitVersion, stripLeadingSeparator } from "./package.mjs";
describe("normalizeGitVersion", () => {
it("returns null for empty / nullish input", () => {
@@ -37,3 +37,25 @@ describe("normalizeGitVersion", () => {
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-abc1234");
});
});
describe("stripLeadingSeparator", () => {
it("removes the leading -- inserted by npm/pnpm", () => {
expect(stripLeadingSeparator(["--", "--mac", "--arm64", "--publish", "always"])).toEqual([
"--mac", "--arm64", "--publish", "always",
]);
});
it("leaves args untouched when there is no leading --", () => {
expect(stripLeadingSeparator(["--mac", "--arm64"])).toEqual(["--mac", "--arm64"]);
});
it("does not strip a -- that appears mid-argv", () => {
expect(stripLeadingSeparator(["--mac", "--", "--arm64"])).toEqual([
"--mac", "--", "--arm64",
]);
});
it("handles an empty array", () => {
expect(stripLeadingSeparator([])).toEqual([]);
});
});

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

@@ -87,14 +87,18 @@ function AppContent() {
// Tabs survive across app restarts and account switches (persisted to
// localStorage `multica_tabs`), so a tab path like `/naiyuan/issues` may
// reference a workspace the current user can't access — showing
// NoAccessPage every time they open the app. Reset any such tab to `/`
// so IndexRedirect picks a valid workspace. Runs on every workspace list
// change (login, refetch, realtime workspace:deleted); idempotent.
useEffect(() => {
if (!workspaces) return;
// NoAccessPage every time they open the app.
//
// Run synchronously in render phase rather than in useEffect so the first
// render already sees validated tabs. useEffect runs AFTER commit, which
// means the initial render would briefly show NoAccessPage before the
// effect resets the tab. Zustand supports render-phase setState; the
// validator is idempotent (exits early if nothing changed) so this
// doesn't loop.
if (workspaces) {
const validSlugs = new Set(workspaces.map((w) => w.slug));
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
}, [workspaces]);
}
// null = undecided (pre-login or list hasn't settled yet)
// true = session started with zero workspaces; next transition to >=1 triggers restart
// false = session started with >=1 workspace, OR we've already restarted; skip

View File

@@ -30,6 +30,7 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@multica/ui/lib/utils";
import { useTabStore, resolveRouteIcon, type Tab } from "@/stores/tab-store";
import { isGlobalPath, paths } from "@multica/core/paths";
const TAB_ICONS: Record<string, LucideIcon> = {
Inbox,
@@ -124,10 +125,22 @@ function NewTabButton() {
const setActiveTab = useTabStore((s) => s.setActiveTab);
const handleClick = () => {
const path = "/issues";
// Inherit the active tab's workspace. Terminal/IDE convention: new tab
// opens in the same context as the active one. Read the slug from the
// active tab's path directly rather than from getCurrentSlug(), because
// that singleton is "last tab to render" (non-deterministic with N tabs
// mounted under <Activity>), while activeTabId is the unambiguous truth.
// Falls back to "/" (→ IndexRedirect → first workspace) when the active
// tab is on a global route (e.g. /workspaces/new, /login).
const { tabs, activeTabId } = useTabStore.getState();
const activePath = tabs.find((t) => t.id === activeTabId)?.path ?? "/";
let slug: string | null = null;
if (activePath !== "/" && !isGlobalPath(activePath)) {
slug = activePath.split("/").filter(Boolean)[0] ?? null;
}
const path = slug ? paths.workspace(slug).issues() : "/";
const tabId = addTab(path, "Issues", resolveRouteIcon(path));
setActiveTab(tabId);
// No navigate() — new tab's router starts at /issues automatically
};
return (

View File

@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vitest";
// createTabRouter transitively pulls in route modules that expect a browser
// router context. For pure-function tests we stub it out.
vi.mock("../routes", () => ({
createTabRouter: vi.fn(() => ({ dispose: vi.fn() })),
}));
import { sanitizeTabPath } from "./tab-store";
describe("sanitizeTabPath", () => {
it("passes through root sentinel", () => {
expect(sanitizeTabPath("/")).toBe("/");
});
it("passes through global paths", () => {
expect(sanitizeTabPath("/login")).toBe("/login");
expect(sanitizeTabPath("/workspaces/new")).toBe("/workspaces/new");
expect(sanitizeTabPath("/invite/abc")).toBe("/invite/abc");
expect(sanitizeTabPath("/auth/callback")).toBe("/auth/callback");
});
it("passes through valid workspace-scoped paths", () => {
expect(sanitizeTabPath("/acme/issues")).toBe("/acme/issues");
expect(sanitizeTabPath("/my-team/projects/abc")).toBe("/my-team/projects/abc");
});
it("rejects paths whose first segment is a reserved slug", () => {
// A stray "/issues" (pre-refactor leftover, missing workspace prefix)
// would be interpreted as workspaceSlug="issues" → NoAccessPage.
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(sanitizeTabPath("/issues")).toBe("/");
expect(sanitizeTabPath("/issues/abc-123")).toBe("/");
expect(sanitizeTabPath("/settings")).toBe("/");
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it("passes through user slugs that happen to look path-like but aren't reserved", () => {
// A workspace owner could legitimately pick "acme-issues" or
// "project-x" as their slug — sanitize must not touch these.
expect(sanitizeTabPath("/acme-issues/issues")).toBe("/acme-issues/issues");
expect(sanitizeTabPath("/project-x/inbox")).toBe("/project-x/inbox");
});
});

View File

@@ -3,7 +3,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
import { arrayMove } from "@dnd-kit/sortable";
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
import { createSafeId } from "@multica/core/utils";
import { isGlobalPath } from "@multica/core/paths";
import { isGlobalPath, isReservedSlug } from "@multica/core/paths";
import type { DataRouter } from "react-router-dom";
import { createTabRouter } from "../routes";
@@ -104,13 +104,44 @@ function createId(): string {
return createSafeId();
}
/**
* Defensive: catch tab paths that were constructed without a workspace slug
* (e.g. a hardcoded "/issues" leftover from before the URL refactor). Such
* paths would get matched as `workspaceSlug="issues"` by the router and
* render NoAccessPage. Sanitize by falling back to "/" (IndexRedirect picks
* a valid workspace).
*
* Passes through:
* - "/" and global paths (/login, /workspaces/new, /invite/..., /auth/...)
* - workspace-scoped paths whose first segment is not a reserved word
*
* Rejects (and rewrites to "/"):
* - Paths whose first segment is a reserved slug (=/=workspace slug), which
* means the caller forgot to prefix the workspace. Logs a warning so the
* buggy call site is easy to find.
*/
export function sanitizeTabPath(path: string): string {
if (path === DEFAULT_PATH || isGlobalPath(path)) return path;
const firstSegment = path.split("/").filter(Boolean)[0] ?? "";
if (isReservedSlug(firstSegment)) {
// eslint-disable-next-line no-console
console.warn(
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
`caller likely forgot the workspace prefix. Falling back to "/".`,
);
return DEFAULT_PATH;
}
return path;
}
function makeTab(path: string, title: string, icon: string): Tab {
const safePath = sanitizeTabPath(path);
return {
id: createId(),
path,
path: safePath,
title,
icon,
router: createTabRouter(path),
router: createTabRouter(safePath),
historyIndex: 0,
historyLength: 1,
};
@@ -239,19 +270,13 @@ export const useTabStore = create<TabStore>()(
if (!persisted?.tabs?.length) return currentState;
const tabs: Tab[] = persisted.tabs.map((tab) => {
// Migration: pre-refactor tab paths like "/issues/abc" lack a
// workspace slug prefix. These would 404 in the new router.
// Reset to "/" so IndexRedirect picks the right workspace.
let path = tab.path;
if (path !== "/" && !isGlobalPath(path)) {
const segments = path.split("/").filter(Boolean);
const firstSegment = segments[0] ?? "";
// If the first segment IS a known route name (e.g. "issues",
// "projects"), it's an old-format path missing the slug prefix.
if (ROUTE_ICONS[firstSegment]) {
path = "/";
}
}
// Sanitize persisted paths against reserved-slug rules. Catches
// both pre-refactor paths like "/issues/abc" (missing workspace
// slug) and any other malformed paths that slipped past the
// write-time guard. The defense across makeTab + merge + runtime
// validate ensures stale or malformed paths never reach the
// router.
const path = sanitizeTabPath(tab.path);
return {
...tab,
path,

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

@@ -0,0 +1,46 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
/**
* Tracks which comments are collapsed, keyed by issue ID.
* Only collapsed comment IDs are stored — expanded is the default state.
*/
interface CommentCollapseStore {
collapsedByIssue: Record<string, string[]>;
isCollapsed: (issueId: string, commentId: string) => boolean;
toggle: (issueId: string, commentId: string) => void;
}
export const useCommentCollapseStore = create<CommentCollapseStore>()(
persist(
(set, get) => ({
collapsedByIssue: {},
isCollapsed: (issueId, commentId) => {
const ids = get().collapsedByIssue[issueId];
return ids ? ids.includes(commentId) : false;
},
toggle: (issueId, commentId) =>
set((s) => {
const current = s.collapsedByIssue[issueId] ?? [];
const isCurrentlyCollapsed = current.includes(commentId);
if (isCurrentlyCollapsed) {
const next = current.filter((id) => id !== commentId);
if (next.length === 0) {
const { [issueId]: _, ...rest } = s.collapsedByIssue;
return { collapsedByIssue: rest };
}
return { collapsedByIssue: { ...s.collapsedByIssue, [issueId]: next } };
}
return { collapsedByIssue: { ...s.collapsedByIssue, [issueId]: [...current, commentId] } };
}),
}),
{
name: "multica_comment_collapse",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useCommentCollapseStore.persist.rehydrate());

View File

@@ -7,6 +7,7 @@ export {
useViewStoreApi,
} from "./view-store-context";
export { useIssuesScopeStore, type IssuesScope } from "./issues-scope-store";
export { useCommentCollapseStore } from "./comment-collapse-store";
export {
myIssuesViewStore,
type MyIssuesViewState,

View File

@@ -2,7 +2,7 @@
import { create } from "zustand";
type ModalType = "create-workspace" | "create-issue" | null;
type ModalType = "create-workspace" | "create-issue" | "create-project" | null;
interface ModalStore {
modal: ModalType;

View File

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

View File

@@ -1,6 +1,6 @@
"use client";
import { useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { ChevronRight, Copy, Download, FileText, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Card } from "@multica/ui/components/ui/card";
@@ -36,6 +36,7 @@ import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
import { ReplyInput } from "./reply-input";
import type { TimelineEntry, Attachment } from "@multica/core/types";
import { useCommentCollapseStore } from "@multica/core/issues/stores";
// ---------------------------------------------------------------------------
// Types
@@ -328,7 +329,10 @@ function CommentCard({
}: CommentCardProps) {
const { getActorName } = useActorName();
const { uploadWithToast } = useFileUpload(api);
const [open, setOpen] = useState(true);
const isCollapsed = useCommentCollapseStore((s) => s.isCollapsed(issueId, entry.id));
const toggleCollapse = useCommentCollapseStore((s) => s.toggle);
const open = !isCollapsed;
const handleOpenChange = useCallback((_open: boolean) => toggleCollapse(issueId, entry.id), [toggleCollapse, issueId, entry.id]);
const [editing, setEditing] = useState(false);
const editEditorRef = useRef<ContentEditorRef>(null);
const cancelledRef = useRef(false);
@@ -390,7 +394,7 @@ function CommentCard({
return (
<Card className={cn("!py-0 !gap-0 overflow-hidden transition-colors duration-700", isTemp && "opacity-60", isHighlighted && "ring-2 ring-brand/50 bg-brand/5")}>
<Collapsible open={open} onOpenChange={setOpen}>
<Collapsible open={open} onOpenChange={handleOpenChange}>
{/* Header — always visible, acts as toggle */}
<div className="px-4 py-3">
<div className="flex items-center gap-2.5">

View File

@@ -228,6 +228,14 @@ vi.mock("@multica/core/issues/stores", () => ({
},
{ getState: () => ({ items: [], recordVisit: mockRecordVisit }) },
),
useCommentCollapseStore: (selector?: any) => {
const state = {
collapsedByIssue: {},
isCollapsed: () => false,
toggle: () => {},
};
return selector ? selector(state) : state;
},
}));
// Mock modals

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

@@ -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": {