mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-18 04:09:13 +02:00
Compare commits
37 Commits
fix/codex-
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba56331f50 | ||
|
|
d7a8e9041e | ||
|
|
3b7abae5b4 | ||
|
|
7843da0315 | ||
|
|
caa18a6983 | ||
|
|
6e980925cf | ||
|
|
8bc20ce161 | ||
|
|
8816e1669c | ||
|
|
209300c86f | ||
|
|
3d98f64ea1 | ||
|
|
ec30e46947 | ||
|
|
6428a10046 | ||
|
|
fe6208c61f | ||
|
|
336f90fd26 | ||
|
|
6d6bc5a6f2 | ||
|
|
f3d20fd50d | ||
|
|
fe13259cc6 | ||
|
|
6a2432b16b | ||
|
|
3a5f94cbdd | ||
|
|
bfe407ac55 | ||
|
|
d12d690c38 | ||
|
|
a36252ca99 | ||
|
|
0fdd0054b9 | ||
|
|
9a97ee1f4c | ||
|
|
f029eb01b8 | ||
|
|
f0f3cb5c3a | ||
|
|
94c9d2807a | ||
|
|
fa804c2215 | ||
|
|
48a8a2793e | ||
|
|
cd50c31201 | ||
|
|
ac8b08e540 | ||
|
|
e3a1b951fb | ||
|
|
b5ee6f2579 | ||
|
|
c0b4e7e8b8 | ||
|
|
efb0c1dccf | ||
|
|
8c518c350a | ||
|
|
f8c6dd505f |
@@ -133,6 +133,7 @@ make start-worktree # Start using .env.worktree
|
||||
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
|
||||
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
|
||||
- Avoid broad refactors unless required by the task.
|
||||
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
|
||||
|
||||
### Package Boundary Rules
|
||||
|
||||
|
||||
@@ -143,6 +143,9 @@ The daemon auto-detects these AI CLIs on your PATH:
|
||||
| OpenCode | `opencode` | Open-source coding agent |
|
||||
| OpenClaw | `openclaw` | Open-source coding agent |
|
||||
| Hermes | `hermes` | Nous Research coding agent |
|
||||
| Gemini | `gemini` | Google's coding agent |
|
||||
| [Pi](https://pi.dev/) | `pi` | Pi coding agent |
|
||||
| [Cursor Agent](https://cursor.com/) | `cursor-agent` | Cursor's headless coding agent |
|
||||
|
||||
You need at least one installed. The daemon registers each detected CLI as an available runtime.
|
||||
|
||||
@@ -183,6 +186,12 @@ Agent-specific overrides:
|
||||
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
|
||||
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
|
||||
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
|
||||
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
|
||||
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
|
||||
| `MULTICA_PI_PATH` | Custom path to the `pi` binary |
|
||||
| `MULTICA_PI_MODEL` | Override the Pi model used |
|
||||
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
|
||||
| `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used |
|
||||
|
||||
### Self-Hosted Server
|
||||
|
||||
|
||||
@@ -165,12 +165,12 @@ Wait 3 seconds, then verify:
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`).
|
||||
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
|
||||
|
||||
**If daemon fails to start:**
|
||||
- Check logs: `multica daemon logs`
|
||||
- If a port conflict occurs, the daemon may already be running under a different profile.
|
||||
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, or `hermes`) is installed and on the `$PATH`.
|
||||
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
|
||||
|
||||
---
|
||||
|
||||
@@ -184,12 +184,12 @@ multica daemon status
|
||||
|
||||
Confirm:
|
||||
1. Status is `running`
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, or `hermes`)
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
|
||||
3. At least one workspace is being watched
|
||||
|
||||
If the agents list is empty, tell the user:
|
||||
|
||||
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, or `hermes`), then restart the daemon with `multica daemon stop && multica daemon start`."
|
||||
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
|
||||
|
||||
---
|
||||
|
||||
|
||||
2
Makefile
2
Makefile
@@ -182,7 +182,7 @@ server:
|
||||
cd server && go run ./cmd/server
|
||||
|
||||
daemon:
|
||||
@$(MAKE) multica MULTICA_ARGS="daemon"
|
||||
@$(MAKE) multica MULTICA_ARGS="daemon restart --profile local"
|
||||
|
||||
cli:
|
||||
@$(MAKE) multica MULTICA_ARGS="$(MULTICA_ARGS)"
|
||||
|
||||
16
README.md
16
README.md
@@ -30,7 +30,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
|
||||
|
||||
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
|
||||
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, and **OpenCode**.
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, and **Cursor Agent**.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
|
||||
@@ -97,7 +97,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
|
||||
multica setup # Configure, authenticate, and start the daemon
|
||||
```
|
||||
|
||||
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH.
|
||||
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`) on your PATH.
|
||||
|
||||
### 2. Verify your runtime
|
||||
|
||||
@@ -107,7 +107,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
|
||||
|
||||
### 3. Create an agent
|
||||
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
|
||||
### 4. Assign your first task
|
||||
|
||||
@@ -158,10 +158,10 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
|
||||
└──────────────┘ └──────┬───────┘ └──────────────────┘
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ (runs on your machine)
|
||||
│Claude/Codex/ │
|
||||
│OpenClaw/Code │
|
||||
└──────────────┘
|
||||
│ Agent Daemon │ runs on your machine
|
||||
└──────────────┘ (Claude Code, Codex, OpenCode,
|
||||
OpenClaw, Hermes, Gemini,
|
||||
Pi, Cursor Agent)
|
||||
```
|
||||
|
||||
| Layer | Stack |
|
||||
@@ -169,7 +169,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
|
||||
| Frontend | Next.js 16 (App Router) |
|
||||
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
|
||||
| Database | PostgreSQL 17 with pgvector |
|
||||
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, or OpenCode |
|
||||
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent |
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
|
||||
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw** 和 **OpenCode**。
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi** 和 **Cursor Agent**。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
|
||||
@@ -99,7 +99,7 @@ multica setup # 连接 Multica Cloud,登录,启动 daemon
|
||||
multica setup # 配置、认证、启动 daemon(一条命令搞定)
|
||||
```
|
||||
|
||||
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`、`openclaw`、`opencode`)。
|
||||
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`、`openclaw`、`opencode`、`hermes`、`gemini`、`pi`、`cursor-agent`)。
|
||||
|
||||
### 2. 确认运行时已连接
|
||||
|
||||
@@ -109,7 +109,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
|
||||
### 3. 创建 Agent
|
||||
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude Code、Codex、OpenClaw 或 OpenCode),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude Code、Codex、OpenClaw、OpenCode、Hermes、Gemini、Pi 或 Cursor Agent),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
|
||||
### 4. 分配你的第一个任务
|
||||
|
||||
@@ -141,10 +141,10 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
└──────────────┘ └──────┬───────┘ └──────────────────┘
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ (运行在你的机器上)
|
||||
│Claude/Codex/ │
|
||||
│OpenClaw/Code │
|
||||
└──────────────┘
|
||||
│ Agent Daemon │ 运行在你的机器上
|
||||
└──────────────┘ (Claude Code、Codex、OpenCode、
|
||||
OpenClaw、Hermes、Gemini、
|
||||
Pi、Cursor Agent)
|
||||
```
|
||||
|
||||
| 层级 | 技术栈 |
|
||||
@@ -152,7 +152,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
| 前端 | Next.js 16 (App Router) |
|
||||
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
|
||||
| 数据库 | PostgreSQL 17 with pgvector |
|
||||
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClaw 或 OpenCode |
|
||||
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClaw、OpenCode、Hermes、Gemini、Pi 或 Cursor Agent |
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
@@ -85,6 +85,9 @@ You also need at least one AI agent CLI installed:
|
||||
- [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH)
|
||||
- [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH)
|
||||
- [Hermes](https://github.com/NousResearch/hermes) (`hermes` on PATH)
|
||||
- Gemini (`gemini` on PATH)
|
||||
- [Pi](https://pi.dev/) (`pi` on PATH)
|
||||
- [Cursor Agent](https://cursor.com/) (`cursor-agent` on PATH)
|
||||
|
||||
### b) One-command setup
|
||||
|
||||
|
||||
@@ -80,6 +80,12 @@ Agent-specific overrides:
|
||||
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
|
||||
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
|
||||
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
|
||||
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
|
||||
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
|
||||
| `MULTICA_PI_PATH` | Custom path to the `pi` binary |
|
||||
| `MULTICA_PI_MODEL` | Override the Pi model used |
|
||||
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
|
||||
| `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used |
|
||||
|
||||
## Database Setup
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ export default defineConfig({
|
||||
},
|
||||
renderer: {
|
||||
server: {
|
||||
port: 5173,
|
||||
// Allow parallel worktrees to run `pnpm dev:desktop` side-by-side
|
||||
// (e.g. Multica Canary alongside a primary checkout) by overriding
|
||||
// the renderer port via env. Falls back to 5173 for the common case.
|
||||
port: Number(process.env.DESKTOP_RENDERER_PORT) || 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"bundle-cli": "node scripts/bundle-cli.mjs",
|
||||
"dev": "pnpm run bundle-cli && electron-vite dev",
|
||||
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
|
||||
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
|
||||
"build": "pnpm run bundle-cli && electron-vite build",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
|
||||
73
apps/desktop/scripts/brand-dev-electron.mjs
Normal file
73
apps/desktop/scripts/brand-dev-electron.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
// Rebrand the bundled Electron.app's Info.plist so `pnpm dev:desktop`
|
||||
// shows "Multica Canary" in the menu bar, Cmd+Tab switcher, and
|
||||
// Activity Monitor. On macOS these titles come from CFBundleName at
|
||||
// launch time — `app.setName()` cannot override them at runtime, so
|
||||
// patching the plist in node_modules is the only working fix.
|
||||
//
|
||||
// Idempotent: runs on every dev launch and no-ops once the plist already
|
||||
// matches. The patch is isolated to this worktree's node_modules — we
|
||||
// unlink the file before rewriting so we never mutate a pnpm-store inode
|
||||
// shared with another project.
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
if (process.platform !== "darwin") process.exit(0);
|
||||
|
||||
const DESIRED_NAME = "Multica Canary";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
// `require('electron')` returns the path to the executable
|
||||
// (.../Electron.app/Contents/MacOS/Electron). Walk up to Contents/Info.plist.
|
||||
const electronBin = require("electron");
|
||||
const plistPath = resolve(electronBin, "../../Info.plist");
|
||||
|
||||
function plistGet(key) {
|
||||
try {
|
||||
return execFileSync(
|
||||
"/usr/libexec/PlistBuddy",
|
||||
["-c", `Print :${key}`, plistPath],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
||||
).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function plistSet(key, value) {
|
||||
try {
|
||||
execFileSync("/usr/libexec/PlistBuddy", [
|
||||
"-c",
|
||||
`Set :${key} ${value}`,
|
||||
plistPath,
|
||||
]);
|
||||
} catch {
|
||||
execFileSync("/usr/libexec/PlistBuddy", [
|
||||
"-c",
|
||||
`Add :${key} string ${value}`,
|
||||
plistPath,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
plistGet("CFBundleName") === DESIRED_NAME &&
|
||||
plistGet("CFBundleDisplayName") === DESIRED_NAME
|
||||
) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Break any pnpm hardlink to the global store: read, unlink, rewrite.
|
||||
// PlistBuddy would otherwise write through the hardlink and mutate the
|
||||
// shared store file (and every other project's Electron.app with it).
|
||||
const original = readFileSync(plistPath);
|
||||
unlinkSync(plistPath);
|
||||
writeFileSync(plistPath, original);
|
||||
|
||||
plistSet("CFBundleName", DESIRED_NAME);
|
||||
plistSet("CFBundleDisplayName", DESIRED_NAME);
|
||||
|
||||
console.log(`[brand-dev-electron] ${plistPath} → CFBundleName="${DESIRED_NAME}"`);
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -94,6 +102,27 @@ function createWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dev / production isolation -------------------------------------------
|
||||
// Give dev mode a separate app name and userData path so it gets its own
|
||||
// single-instance lock file and doesn't conflict with the packaged production
|
||||
// app. Must run BEFORE requestSingleInstanceLock() because the lock location
|
||||
// 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(DEV_APP_NAME);
|
||||
app.setPath("userData", join(app.getPath("appData"), DEV_APP_NAME));
|
||||
}
|
||||
|
||||
// --- Protocol registration -----------------------------------------------
|
||||
|
||||
if (process.defaultApp) {
|
||||
@@ -125,7 +154,17 @@ if (!gotTheLock) {
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId("ai.multica.desktop");
|
||||
electronApp.setAppUserModelId(
|
||||
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);
|
||||
@@ -137,7 +176,7 @@ if (!gotTheLock) {
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
// modals (create-workspace, onboarding) can place UI in the top-left corner
|
||||
// modals (e.g. create-workspace) can place UI in the top-left corner
|
||||
// without fighting the native window controls' hit-test.
|
||||
ipcMain.handle("window:setImmersive", (_event, immersive: boolean) => {
|
||||
if (process.platform !== "darwin") return;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
@@ -10,6 +10,7 @@ import { Toaster } from "sonner";
|
||||
import { DesktopLoginPage } from "./pages/login";
|
||||
import { DesktopShell } from "./components/desktop-layout";
|
||||
import { UpdateNotification } from "./components/update-notification";
|
||||
import { useTabStore } from "./stores/tab-store";
|
||||
|
||||
function AppContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -20,8 +21,8 @@ function AppContent() {
|
||||
// as soon as getMe resolves, which would cause DesktopShell to mount
|
||||
// before the workspace list is hydrated and briefly see `!workspace`.
|
||||
// This local flag keeps the loading screen up until the whole chain
|
||||
// finishes, so the shell's "needs onboarding?" check gets a definitive
|
||||
// workspace state on first render.
|
||||
// finishes, so IndexRedirect gets a definitive workspace state on
|
||||
// first render.
|
||||
const [bootstrapping, setBootstrapping] = useState(false);
|
||||
|
||||
// Tell the main process which backend URL we talk to, so daemon-manager
|
||||
@@ -69,6 +70,55 @@ function AppContent() {
|
||||
})();
|
||||
}, [user]);
|
||||
|
||||
// When a user who started the session with zero workspaces creates their
|
||||
// first one, restart the daemon so it picks up the new workspace
|
||||
// immediately (otherwise workspaceSyncLoop's next 30s tick would be the
|
||||
// earliest pickup point). Specifically scoped to "started empty" because
|
||||
// account switches (user A logout → user B login) should not trigger a
|
||||
// daemon restart here — daemon-manager already restarts on user change
|
||||
// via syncToken.
|
||||
const { data: workspaces, isFetched: workspaceListFetched } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
const wsCount = workspaces?.length ?? 0;
|
||||
|
||||
// Validate persisted tab paths against the current user's workspace list.
|
||||
// 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.
|
||||
//
|
||||
// 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);
|
||||
}
|
||||
// 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
|
||||
const sessionStartedEmptyRef = useRef<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
sessionStartedEmptyRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (!workspaceListFetched) return;
|
||||
if (sessionStartedEmptyRef.current === null) {
|
||||
sessionStartedEmptyRef.current = wsCount === 0;
|
||||
return;
|
||||
}
|
||||
if (sessionStartedEmptyRef.current && wsCount >= 1) {
|
||||
void window.daemonAPI.restart();
|
||||
sessionStartedEmptyRef.current = false;
|
||||
}
|
||||
}, [user, workspaceListFetched, wsCount]);
|
||||
|
||||
if (isLoading || bootstrapping) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
|
||||
@@ -13,11 +13,9 @@ import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { AppSidebar } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StepWorkspace } from "@multica/views/onboarding";
|
||||
import { WorkspaceSlugProvider } from "@multica/core/paths";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
import { OnboardingGate } from "./onboarding-gate";
|
||||
import { TabBar } from "./tab-bar";
|
||||
import { TabContent } from "./tab-content";
|
||||
|
||||
@@ -109,39 +107,32 @@ export function DesktopShell() {
|
||||
|
||||
return (
|
||||
<DesktopNavigationProvider>
|
||||
<OnboardingGate
|
||||
onboarding={(onComplete) => (
|
||||
<div className="flex min-h-screen items-center justify-center overflow-auto bg-background px-6 py-12">
|
||||
<StepWorkspace onNext={onComplete} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{/* WorkspaceSlugProvider accepts null — components that need slug
|
||||
use useWorkspaceSlug() (nullable) or useRequiredWorkspaceSlug()
|
||||
(throws). TabContent MUST always render so the tab router can
|
||||
mount WorkspaceRouteLayout, which calls setCurrentWorkspace()
|
||||
to populate the slug. The sidebar gates on slug being present
|
||||
to avoid the useRequiredWorkspaceSlug throw. */}
|
||||
<WorkspaceSlugProvider slug={slug}>
|
||||
<div className="flex h-screen">
|
||||
<SidebarProvider className="flex-1">
|
||||
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
|
||||
{/* Right side: header + content container */}
|
||||
<div className="flex flex-1 min-w-0 flex-col">
|
||||
<MainTopBar />
|
||||
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
|
||||
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
|
||||
<TabContent />
|
||||
{slug && <ChatWindow />}
|
||||
{slug && <ChatFab />}
|
||||
</div>
|
||||
{/* WorkspaceSlugProvider accepts null — components that need slug
|
||||
use useWorkspaceSlug() (nullable) or useRequiredWorkspaceSlug()
|
||||
(throws). TabContent MUST always render so the tab router can
|
||||
mount WorkspaceRouteLayout, which calls setCurrentWorkspace()
|
||||
to populate the slug. The sidebar gates on slug being present
|
||||
to avoid the useRequiredWorkspaceSlug throw. Zero-workspace
|
||||
users are routed to /workspaces/new by IndexRedirect. */}
|
||||
<WorkspaceSlugProvider slug={slug}>
|
||||
<div className="flex h-screen">
|
||||
<SidebarProvider className="flex-1">
|
||||
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
|
||||
{/* Right side: header + content container */}
|
||||
<div className="flex flex-1 min-w-0 flex-col">
|
||||
<MainTopBar />
|
||||
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
|
||||
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
|
||||
<TabContent />
|
||||
{slug && <ChatWindow />}
|
||||
{slug && <ChatFab />}
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
{slug && <SearchCommand />}
|
||||
</WorkspaceSlugProvider>
|
||||
</OnboardingGate>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
{slug && <SearchCommand />}
|
||||
</WorkspaceSlugProvider>
|
||||
</DesktopNavigationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { OnboardingGate } from "./onboarding-gate";
|
||||
|
||||
// Prevent actual API calls — the tests seed data via setQueryData.
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listWorkspaces: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
}));
|
||||
|
||||
function createTestQueryClient(
|
||||
workspaces: Array<{ id: string; slug: string }> = [],
|
||||
) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
// Seed the workspace list so the gate can read it synchronously.
|
||||
qc.setQueryData(workspaceKeys.list(), workspaces);
|
||||
return qc;
|
||||
}
|
||||
|
||||
function renderGate(
|
||||
qc: QueryClient,
|
||||
onboarding?: (onComplete: () => void) => React.ReactNode,
|
||||
) {
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<OnboardingGate
|
||||
onboarding={
|
||||
onboarding ??
|
||||
((onComplete) => (
|
||||
<button type="button" data-testid="finish" onClick={onComplete}>
|
||||
wizard
|
||||
</button>
|
||||
))
|
||||
}
|
||||
>
|
||||
<div data-testid="main">main shell</div>
|
||||
</OnboardingGate>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("OnboardingGate", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders children when workspaces exist in cache", () => {
|
||||
const qc = createTestQueryClient([{ id: "ws-1", slug: "my-team" }]);
|
||||
renderGate(qc);
|
||||
|
||||
expect(screen.getByTestId("main")).toBeInTheDocument();
|
||||
expect(screen.queryByText("wizard")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders onboarding when workspace list is empty", () => {
|
||||
const qc = createTestQueryClient([]);
|
||||
renderGate(qc);
|
||||
|
||||
expect(screen.getByText("wizard")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("main")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the wizard mounted even after workspaces appear in cache mid-flow", () => {
|
||||
const qc = createTestQueryClient([]);
|
||||
renderGate(qc);
|
||||
|
||||
expect(screen.getByText("wizard")).toBeInTheDocument();
|
||||
|
||||
// Simulate the onboarding wizard creating a workspace mid-flow.
|
||||
act(() => {
|
||||
qc.setQueryData(workspaceKeys.list(), [
|
||||
{ id: "ws-new", slug: "new-team" },
|
||||
]);
|
||||
});
|
||||
|
||||
// Wizard should still be visible — only onComplete dismisses it.
|
||||
expect(screen.getByText("wizard")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("main")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("transitions to children after the wizard calls onComplete", () => {
|
||||
const qc = createTestQueryClient([]);
|
||||
renderGate(qc);
|
||||
|
||||
expect(screen.getByTestId("finish")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
screen.getByTestId("finish").click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("main")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("finish")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
|
||||
/**
|
||||
* Renders `onboarding` as a full-screen takeover when the user has no
|
||||
* workspaces, otherwise renders `children`.
|
||||
*
|
||||
* Reads the workspace list directly from React Query — this works regardless
|
||||
* of whether a WorkspaceSlugProvider is mounted, unlike useCurrentWorkspace()
|
||||
* which depends on slug context from the router tree.
|
||||
*
|
||||
* The onboarding decision is frozen at first mount via the lazy useState
|
||||
* initializer: this way the onboarding wizard controls its own exit by
|
||||
* calling the `onComplete` callback, instead of being unmounted the moment
|
||||
* the workspace list updates mid-flow (e.g. after the user creates their
|
||||
* first workspace in step 1 but still has steps 2-3 to complete).
|
||||
*
|
||||
* The frozen decision only triggers when the initial query has settled AND
|
||||
* the list is empty. While the list is loading, children are rendered
|
||||
* (the shell shows its own loading state).
|
||||
*/
|
||||
export function OnboardingGate({
|
||||
onboarding,
|
||||
children,
|
||||
}: {
|
||||
onboarding: (onComplete: () => void) => ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { data: workspaces, isFetched } = useQuery(workspaceListOptions());
|
||||
const hasWorkspaces = !isFetched || (workspaces?.length ?? 0) > 0;
|
||||
|
||||
const [initialNeedsOnboarding] = useState(() => !hasWorkspaces);
|
||||
const [onboardingDone, setOnboardingDone] = useState(false);
|
||||
|
||||
if (initialNeedsOnboarding && !onboardingDone) {
|
||||
return <>{onboarding(() => setOnboardingDone(true))}</>;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Outlet, useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { WorkspaceSlugProvider, paths } from "@multica/core/paths";
|
||||
import { workspaceBySlugOptions } from "@multica/core/workspace";
|
||||
import {
|
||||
setCurrentWorkspace,
|
||||
rehydrateAllWorkspaceStores,
|
||||
} from "@multica/core/platform";
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { NoAccessPage } from "@multica/views/workspace/no-access-page";
|
||||
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
|
||||
/**
|
||||
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
|
||||
*
|
||||
* Reads :workspaceSlug from react-router params, resolves it to a Workspace
|
||||
* object via the React Query list cache, and syncs the URL-derived workspace
|
||||
* into the platform singleton (slug + UUID). Children (DashboardGuard +
|
||||
* dashboard layout) handle auth check, loading, and workspace-not-found.
|
||||
* Resolves the URL slug → workspace UUID via the React Query list cache
|
||||
* (seeded by AuthInitializer). Children do not render until the workspace
|
||||
* is fully resolved — useWorkspaceId() inside child pages is therefore
|
||||
* guaranteed non-null when called. Two industry-standard identities are
|
||||
* kept distinct: slug (URL / browser) and UUID (API / cache keys).
|
||||
*
|
||||
* If the slug doesn't resolve to any workspace the user has access to,
|
||||
* we render NoAccessPage instead of silently redirecting — users get
|
||||
* explicit feedback for stale bookmarks or revoked access.
|
||||
*/
|
||||
export function WorkspaceRouteLayout() {
|
||||
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();
|
||||
@@ -23,34 +27,49 @@ export function WorkspaceRouteLayout() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAuthLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
// Workspace routes require auth. If user is unauthenticated (token
|
||||
// expired, logged out from another tab, etc.), bounce to /login.
|
||||
// Without this, the layout renders null and the user sees a blank page
|
||||
// stuck on /{slug}/...
|
||||
useEffect(() => {
|
||||
if (!isAuthLoading && !user) navigate(paths.login(), { replace: true });
|
||||
}, [isAuthLoading, user, navigate]);
|
||||
|
||||
const { data: workspace, isFetched: listFetched } = useQuery({
|
||||
...workspaceBySlugOptions(workspaceSlug ?? ""),
|
||||
enabled: !!user && !!workspaceSlug,
|
||||
});
|
||||
|
||||
// Render-phase sync (same pattern as web layout).
|
||||
const syncedSlugRef = useRef<string | null>(null);
|
||||
if (workspace && workspaceSlug && syncedSlugRef.current !== workspaceSlug) {
|
||||
// Feed the URL slug into the platform singleton so the API client's
|
||||
// X-Workspace-Slug header and persist namespace follow the active tab.
|
||||
// setCurrentWorkspace self-dedupes on slug equality — safe to call on
|
||||
// every render (matters on desktop, where N tabs each mount their own
|
||||
// layout). Rehydrate is the singleton's internal side effect.
|
||||
if (workspace && workspaceSlug) {
|
||||
setCurrentWorkspace(workspaceSlug, workspace.id);
|
||||
rehydrateAllWorkspaceStores();
|
||||
// Double-write legacy localStorage key for rollback compatibility — see
|
||||
// apps/web/app/[workspaceSlug]/layout.tsx for the full rationale.
|
||||
try {
|
||||
localStorage.setItem("multica_workspace_id", workspace.id);
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
syncedSlugRef.current = workspaceSlug;
|
||||
}
|
||||
|
||||
// Slug doesn't resolve → onboarding. Skip when user is null.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (listFetched && !workspace) navigate(paths.onboarding(), { replace: true });
|
||||
}, [user, listFetched, workspace, navigate]);
|
||||
// Remember whether this slug has resolved before (see hook docs). Gates
|
||||
// the NoAccessPage render below so active workspace removal doesn't
|
||||
// flash "Workspace not available" before the navigate lands.
|
||||
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
|
||||
|
||||
if (isAuthLoading) return null;
|
||||
if (!workspaceSlug) return null;
|
||||
// Don't render children until workspace is resolved. useWorkspaceId()
|
||||
// throws when the workspace list hasn't populated or the slug is
|
||||
// unknown — gating here is the single point where that invariant is
|
||||
// enforced, so every descendant can call useWorkspaceId() safely.
|
||||
if (!listFetched) return null;
|
||||
if (!workspace) {
|
||||
// Active workspace just removed (delete/leave/realtime eviction) —
|
||||
// navigate is in flight; hold null briefly instead of flashing
|
||||
// NoAccessPage.
|
||||
if (hasBeenSeen) return null;
|
||||
// Genuinely inaccessible slug (stale bookmark, revoked access, or a
|
||||
// link from a former teammate's workspace) → explicit feedback.
|
||||
return <NoAccessPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { OnboardingWizard } from "@multica/views/onboarding";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { paths } from "@multica/core/paths";
|
||||
@@ -59,11 +59,11 @@ function PageShell() {
|
||||
);
|
||||
}
|
||||
|
||||
function OnboardingRoute() {
|
||||
function NewWorkspaceRoute() {
|
||||
const nav = useNavigation();
|
||||
return (
|
||||
<OnboardingWizard
|
||||
onComplete={(ws) => nav.push(paths.workspace(ws.slug).issues())}
|
||||
<NewWorkspacePage
|
||||
onSuccess={(ws) => nav.push(paths.workspace(ws.slug).issues())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -76,22 +76,23 @@ function OnboardingRoute() {
|
||||
* duplicate fetches across tabs — each tab's memory router hits this
|
||||
* component independently but the query is deduped.
|
||||
*
|
||||
* Sends first-time users without any workspace to onboarding, everyone
|
||||
* else to their first workspace's issues page. Persisted tab paths that
|
||||
* already carry a workspace slug bypass this component entirely.
|
||||
* Sends first-time users without any workspace to /workspaces/new,
|
||||
* everyone else to their first workspace's issues page. Persisted tab
|
||||
* paths that already carry a workspace slug bypass this component
|
||||
* entirely.
|
||||
*/
|
||||
function IndexRedirect() {
|
||||
const { data: wsList, isFetched } = useQuery(workspaceListOptions());
|
||||
|
||||
// Wait for the query to settle so we don't redirect to onboarding on
|
||||
// the initial render before the seeded/fetched data arrives.
|
||||
// Wait for the query to settle so we don't redirect to /workspaces/new
|
||||
// on the initial render before the seeded/fetched data arrives.
|
||||
if (!isFetched) return null;
|
||||
|
||||
const firstWorkspace = wsList?.[0];
|
||||
if (firstWorkspace) {
|
||||
return <Navigate to={paths.workspace(firstWorkspace.slug).issues()} replace />;
|
||||
}
|
||||
return <Navigate to={paths.onboarding()} replace />;
|
||||
return <Navigate to={paths.newWorkspace()} replace />;
|
||||
}
|
||||
|
||||
function InviteRoute() {
|
||||
@@ -107,7 +108,7 @@ function InviteRoute() {
|
||||
* Structure mirrors the web app's [workspaceSlug]/... layout: all dashboard
|
||||
* pages live under /:workspaceSlug, with WorkspaceRouteLayout resolving the
|
||||
* slug to a workspace and syncing side-effects (api client, persist namespace,
|
||||
* Zustand mirror). Global (pre-workspace) routes — onboarding and invite —
|
||||
* Zustand mirror). Global (pre-workspace) routes — workspaces/new and invite —
|
||||
* sit at the top level alongside the workspace wrapper.
|
||||
*/
|
||||
export const appRoutes: RouteObject[] = [
|
||||
@@ -117,12 +118,12 @@ export const appRoutes: RouteObject[] = [
|
||||
// Top-level index: no slug yet. `IndexRedirect` reads the workspace
|
||||
// list from React Query cache (seeded by AuthInitializer on reopen
|
||||
// or App.tsx on deep-link login) and bounces to the first
|
||||
// workspace's issues page — or onboarding if the user has none.
|
||||
// workspace's issues page — or /workspaces/new if the user has none.
|
||||
{ index: true, element: <IndexRedirect /> },
|
||||
{
|
||||
path: "onboarding",
|
||||
element: <OnboardingRoute />,
|
||||
handle: { title: "Get Started" },
|
||||
path: "workspaces/new",
|
||||
element: <NewWorkspaceRoute />,
|
||||
handle: { title: "Create Workspace" },
|
||||
},
|
||||
{
|
||||
path: "invite/:id",
|
||||
|
||||
45
apps/desktop/src/renderer/src/stores/tab-store.test.ts
Normal file
45
apps/desktop/src/renderer/src/stores/tab-store.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -39,6 +39,15 @@ interface TabStore {
|
||||
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
|
||||
/** Reorder tabs by moving one from fromIndex to toIndex. Preserves router/history. */
|
||||
moveTab: (fromIndex: number, toIndex: number) => void;
|
||||
/**
|
||||
* Reset any tab whose first path segment references a workspace slug the
|
||||
* current user doesn't have access to. Called after login + workspace list
|
||||
* is populated (and on every subsequent list change, e.g. realtime
|
||||
* workspace:deleted). Stale tabs get reset to `/` so IndexRedirect picks
|
||||
* a valid workspace; tabs on global paths (/login, /workspaces/new, etc.)
|
||||
* are untouched.
|
||||
*/
|
||||
validateWorkspaceSlugs: (validSlugs: Set<string>) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -63,7 +72,7 @@ const ROUTE_ICONS: Record<string, string> = {
|
||||
*
|
||||
* Path shape after the workspace URL refactor:
|
||||
* - workspace-scoped: `/{workspaceSlug}/{route}/...` → use segment index 1
|
||||
* - global (onboarding/invite/auth/login): `/{route}/...` → use segment index 0
|
||||
* - global (workspaces/new, invite, auth, login): `/{route}/...` → use segment index 0
|
||||
*
|
||||
* `isGlobalPath` is the single source of truth for which prefixes are global.
|
||||
*/
|
||||
@@ -95,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,
|
||||
};
|
||||
@@ -182,6 +222,36 @@ export const useTabStore = create<TabStore>()(
|
||||
if (fromIndex === toIndex) return;
|
||||
set((s) => ({ tabs: arrayMove(s.tabs, fromIndex, toIndex) }));
|
||||
},
|
||||
|
||||
validateWorkspaceSlugs(validSlugs) {
|
||||
const { tabs } = get();
|
||||
let changed = false;
|
||||
const nextTabs = tabs.map((t) => {
|
||||
// Skip tabs on non-workspace-scoped paths — nothing to validate.
|
||||
if (t.path === "/" || isGlobalPath(t.path)) return t;
|
||||
|
||||
const firstSegment = t.path.split("/").filter(Boolean)[0] ?? "";
|
||||
if (validSlugs.has(firstSegment)) return t;
|
||||
|
||||
// Stale slug: dispose the old router and replace with a fresh one
|
||||
// pointing at `/`. IndexRedirect will send the tab to a valid
|
||||
// workspace (or /workspaces/new if the user now has none).
|
||||
changed = true;
|
||||
t.router.dispose();
|
||||
return {
|
||||
...t,
|
||||
path: DEFAULT_PATH,
|
||||
title: "Issues",
|
||||
icon: resolveRouteIcon(DEFAULT_PATH),
|
||||
router: createTabRouter(DEFAULT_PATH),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
};
|
||||
});
|
||||
|
||||
if (!changed) return;
|
||||
set({ tabs: nextTabs });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "multica_tabs",
|
||||
@@ -200,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,
|
||||
|
||||
@@ -78,7 +78,7 @@ multica daemon status
|
||||
|
||||
Confirm:
|
||||
1. Status is `running`
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, or `hermes`)
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, `hermes`, or `pi`)
|
||||
3. At least one workspace is being watched
|
||||
|
||||
If the agents list is empty, install at least one supported AI agent CLI:
|
||||
|
||||
@@ -45,7 +45,7 @@ Then configure, authenticate, and start the daemon:
|
||||
multica setup
|
||||
```
|
||||
|
||||
The daemon auto-detects available agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
|
||||
The daemon auto-detects available agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`, `pi`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
|
||||
|
||||
## 3. Verify your runtime
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Once you have the CLI installed (or signed up for [Multica Cloud](https://multic
|
||||
multica setup # Configure, authenticate, and start the daemon
|
||||
```
|
||||
|
||||
This configures the CLI, opens your browser for login, discovers your workspaces, and starts the agent daemon in the background. It auto-detects agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`) available on your PATH.
|
||||
This configures the CLI, opens your browser for login, discovers your workspaces, and starts the agent daemon in the background. It auto-detects agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`, `pi`) available on your PATH.
|
||||
|
||||
## 2. Verify your runtime
|
||||
|
||||
|
||||
@@ -28,8 +28,9 @@ function LoginPageContent() {
|
||||
// the user's workspace list.
|
||||
const nextUrl = searchParams.get("next");
|
||||
|
||||
// Already authenticated — honor ?next= or fall back to first workspace /
|
||||
// onboarding. Skip this entire path when the user arrived to authorize the CLI.
|
||||
// Already authenticated — honor ?next= or fall back to first workspace
|
||||
// (or /workspaces/new if the user has none). Skip this entire path when
|
||||
// the user arrived to authorize the CLI.
|
||||
useEffect(() => {
|
||||
if (isLoading || !user || cliCallbackRaw) return;
|
||||
if (nextUrl) {
|
||||
@@ -39,7 +40,7 @@ function LoginPageContent() {
|
||||
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
|
||||
const [first] = list;
|
||||
router.replace(
|
||||
first ? paths.workspace(first.slug).issues() : paths.onboarding(),
|
||||
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
|
||||
);
|
||||
}, [isLoading, user, router, nextUrl, cliCallbackRaw, qc]);
|
||||
|
||||
@@ -53,7 +54,7 @@ function LoginPageContent() {
|
||||
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
|
||||
const [first] = list;
|
||||
router.push(
|
||||
first ? paths.workspace(first.slug).issues() : paths.onboarding(),
|
||||
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { OnboardingWizard } from "@multica/views/onboarding";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
|
||||
export default function OnboardingPage() {
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) router.replace(paths.login());
|
||||
}, [isLoading, user, router]);
|
||||
@@ -19,8 +18,8 @@ export default function OnboardingPage() {
|
||||
if (isLoading || !user) return null;
|
||||
|
||||
return (
|
||||
<OnboardingWizard
|
||||
onComplete={(ws) => router.push(paths.workspace(ws.slug).issues())}
|
||||
<NewWorkspacePage
|
||||
onSuccess={(ws) => router.push(paths.workspace(ws.slug).issues())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { use, useEffect, useRef } from "react";
|
||||
import { use, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { WorkspaceSlugProvider, paths } from "@multica/core/paths";
|
||||
import { workspaceBySlugOptions } from "@multica/core/workspace";
|
||||
import {
|
||||
setCurrentWorkspace,
|
||||
rehydrateAllWorkspaceStores,
|
||||
} from "@multica/core/platform";
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { NoAccessPage } from "@multica/views/workspace/no-access-page";
|
||||
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
|
||||
export default function WorkspaceLayout({
|
||||
children,
|
||||
@@ -23,6 +22,14 @@ export default function WorkspaceLayout({
|
||||
const isAuthLoading = useAuthStore((s) => s.isLoading);
|
||||
const router = useRouter();
|
||||
|
||||
// Workspace routes require auth. If user is unauthenticated (initial visit
|
||||
// without a session, token expired, another tab logged out, etc.), bounce
|
||||
// to /login. Without this, the layout renders null and the user sees a
|
||||
// blank page stuck on /{slug}/...
|
||||
useEffect(() => {
|
||||
if (!isAuthLoading && !user) router.replace(paths.login());
|
||||
}, [isAuthLoading, user, router]);
|
||||
|
||||
// Resolve workspace by slug from the React Query list cache.
|
||||
// Enabled only when user is authenticated — otherwise the list query isn't seeded.
|
||||
const { data: workspace, isFetched: listFetched } = useQuery({
|
||||
@@ -30,46 +37,44 @@ export default function WorkspaceLayout({
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Render-phase sync: set the current workspace slug + UUID into the
|
||||
// platform singleton BEFORE children render. This ensures the first
|
||||
// child query's X-Workspace-Slug header is already correct.
|
||||
// The ref guard prevents re-running on every render.
|
||||
const syncedSlugRef = useRef<string | null>(null);
|
||||
if (workspace && syncedSlugRef.current !== workspaceSlug) {
|
||||
// Render-phase sync: feed the URL slug into the platform singleton so
|
||||
// the first child query's X-Workspace-Slug header is already correct.
|
||||
// setCurrentWorkspace self-dedupes + runs rehydrate as a side effect;
|
||||
// safe to call on every render.
|
||||
if (workspace) {
|
||||
setCurrentWorkspace(workspaceSlug, workspace.id);
|
||||
rehydrateAllWorkspaceStores();
|
||||
syncedSlugRef.current = workspaceSlug;
|
||||
}
|
||||
|
||||
// Cookie write (last_workspace_slug) — proxy reads it on next page load.
|
||||
// ALSO write legacy localStorage["multica_workspace_id"] for forward/back
|
||||
// compatibility: if this version ever gets reverted to the pre-refactor
|
||||
// build, the legacy code reads that localStorage key to know which
|
||||
// workspace to attach to API requests. Without double-writing, a rollback
|
||||
// would leave returning users with empty data (API calls would have no
|
||||
// X-Workspace-ID header). Forward compatible — new code ignores this key.
|
||||
// Cookie write (last_workspace_slug) — proxy reads it on next page load
|
||||
// to redirect unauthenticated-URL hits to the user's last workspace.
|
||||
useEffect(() => {
|
||||
if (!workspace || typeof document === "undefined") return;
|
||||
const oneYear = 60 * 60 * 24 * 365;
|
||||
const secure = location.protocol === "https:" ? "; Secure" : "";
|
||||
document.cookie = `last_workspace_slug=${encodeURIComponent(workspaceSlug)}; path=/; max-age=${oneYear}; SameSite=Lax${secure}`;
|
||||
try {
|
||||
localStorage.setItem("multica_workspace_id", workspace.id);
|
||||
} catch {
|
||||
// localStorage may be unavailable in restricted contexts; non-critical.
|
||||
}
|
||||
}, [workspace, workspaceSlug]);
|
||||
|
||||
// Slug doesn't match any workspace the user has access to → onboarding.
|
||||
// Wait for the list query to settle so we don't bounce on first render.
|
||||
// Skip when user is null — DashboardGuard handles the /login redirect.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (listFetched && !workspace) router.replace(paths.onboarding());
|
||||
}, [user, listFetched, workspace, router]);
|
||||
// Remember whether this slug has resolved before. Used below to avoid
|
||||
// flashing NoAccessPage during active workspace removal (delete, leave,
|
||||
// or realtime eviction) — in those cases the caller is navigating away
|
||||
// and we just need to hold null briefly.
|
||||
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
|
||||
|
||||
// Auth still loading → render nothing (let DashboardGuard show its loader).
|
||||
if (isAuthLoading) return null;
|
||||
// Don't render children until workspace is resolved. useWorkspaceId()
|
||||
// throws when the list hasn't populated or the slug is unknown — gating
|
||||
// here makes that invariant hold for every descendant.
|
||||
if (!listFetched) return null;
|
||||
if (!workspace) {
|
||||
// If we've resolved this slug before in this session, it was just
|
||||
// removed from our list (deleted/left/evicted). A navigate is almost
|
||||
// certainly in flight — render null to avoid a NoAccessPage flash.
|
||||
if (hasBeenSeen) return null;
|
||||
// Otherwise: the URL points at a workspace the user never had access
|
||||
// to. Show explicit feedback instead of silently redirecting. Doesn't
|
||||
// distinguish 404 vs 403 to avoid letting attackers enumerate slugs.
|
||||
return <NoAccessPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
|
||||
@@ -66,11 +66,11 @@ function CallbackContent() {
|
||||
// URL is now the source of truth for the current workspace — the
|
||||
// [workspaceSlug]/layout syncs stores + cookie once we navigate.
|
||||
// Honor ?next= first (e.g. came from /invite/{id}), otherwise land
|
||||
// in the first workspace's issues, or /onboarding if the user has none.
|
||||
// in the first workspace's issues, or /workspaces/new for zero-workspace users.
|
||||
const [first] = wsList;
|
||||
const defaultDest = first
|
||||
? paths.workspace(first.slug).issues()
|
||||
: paths.onboarding();
|
||||
: paths.newWorkspace();
|
||||
router.push(nextUrl || defaultDest);
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -45,24 +42,35 @@ export function LandingHero() {
|
||||
{user ? t.header.dashboard : t.hero.cta}
|
||||
</Link>
|
||||
<Link
|
||||
href={githubUrl}
|
||||
href="https://github.com/multica-ai/multica/releases/latest"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={heroButtonClassName("ghost")}
|
||||
>
|
||||
<GitHubMark className="size-4" />
|
||||
GitHub
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
{t.hero.downloadDesktop}
|
||||
</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>
|
||||
@@ -95,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">
|
||||
@@ -160,7 +110,6 @@ function LandingBackdrop() {
|
||||
src="/images/landing-bg.jpg"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
className="object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
@@ -176,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}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { paths } from "@multica/core/paths";
|
||||
* login* — before the user has ever visited a workspace — the cookie is
|
||||
* absent, so the proxy falls through to the landing page. This component
|
||||
* covers that gap: once auth is resolved and the workspace list has loaded,
|
||||
* push the user into their workspace (or onboarding if they have none).
|
||||
* push the user into their workspace (or /workspaces/new if they have none).
|
||||
*
|
||||
* Renders nothing. Uses `router.replace` so the landing page never enters
|
||||
* browser history for authenticated users.
|
||||
@@ -35,7 +35,7 @@ export function RedirectIfAuthenticated() {
|
||||
if (isLoading || !user || !list) return;
|
||||
const [first] = list;
|
||||
if (!first) {
|
||||
router.replace(paths.onboarding());
|
||||
router.replace(paths.newWorkspace());
|
||||
return;
|
||||
}
|
||||
router.replace(paths.workspace(first.slug).issues());
|
||||
|
||||
@@ -14,6 +14,7 @@ export const en: LandingDict = {
|
||||
subheading:
|
||||
"Multica is an open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills \u2014 manage your human + agent workforce in one place.",
|
||||
cta: "Start free trial",
|
||||
downloadDesktop: "Download Desktop",
|
||||
worksWith: "Works with",
|
||||
imageAlt: "Multica board view \u2014 issues managed by humans and agents",
|
||||
},
|
||||
@@ -223,6 +224,7 @@ export const en: LandingDict = {
|
||||
{ label: "Features", href: "#features" },
|
||||
{ label: "How it Works", href: "#how-it-works" },
|
||||
{ label: "Changelog", href: "/changelog" },
|
||||
{ label: "Desktop", href: "https://github.com/multica-ai/multica/releases/latest" },
|
||||
],
|
||||
},
|
||||
resources: {
|
||||
@@ -277,6 +279,27 @@ export const en: LandingDict = {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.1",
|
||||
date: "2026-04-16",
|
||||
title: "New Agent Runtimes",
|
||||
changes: [],
|
||||
features: [
|
||||
"GitHub Copilot CLI runtime support",
|
||||
"Cursor Agent CLI runtime support",
|
||||
"Pi agent runtime support",
|
||||
"Workspace URL refactor — slug-first routing (`/{slug}/issues`) with legacy URL redirects",
|
||||
],
|
||||
fixes: [
|
||||
"Codex threads resume across tasks on the same issue",
|
||||
"Codex turn errors surfaced instead of reporting empty output",
|
||||
"Workspace usage correctly bucketed by task completion time",
|
||||
"Autopilot run history rows fully clickable",
|
||||
"Workspace isolation enforced on additional daemon and GC endpoints (security)",
|
||||
"HTML-escape workspace and inviter names in invitation emails",
|
||||
"Dev and production desktop instances can now coexist",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.0",
|
||||
date: "2026-04-15",
|
||||
|
||||
@@ -26,6 +26,7 @@ export type LandingDict = {
|
||||
headlineLine2: string;
|
||||
subheading: string;
|
||||
cta: string;
|
||||
downloadDesktop: string;
|
||||
worksWith: string;
|
||||
imageAlt: string;
|
||||
};
|
||||
|
||||
@@ -13,8 +13,9 @@ export const zh: LandingDict = {
|
||||
headlineLine2: "\u4e0d\u662f\u4eba\u7c7b\u3002",
|
||||
subheading:
|
||||
"Multica \u662f\u4e00\u4e2a\u5f00\u6e90\u5e73\u53f0\uff0c\u5c06\u7f16\u7801 Agent \u53d8\u6210\u771f\u6b63\u7684\u961f\u53cb\u3002\u5206\u914d\u4efb\u52a1\u3001\u8ddf\u8e2a\u8fdb\u5ea6\u3001\u79ef\u7d2f\u6280\u80fd\u2014\u2014\u5728\u4e00\u4e2a\u5730\u65b9\u7ba1\u7406\u4f60\u7684\u4eba\u7c7b + Agent \u56e2\u961f\u3002",
|
||||
cta: "\u514d\u8d39\u5f00\u59cb",
|
||||
worksWith: "\u652f\u6301",
|
||||
cta: "免费开始",
|
||||
downloadDesktop: "下载桌面端",
|
||||
worksWith: "支持",
|
||||
imageAlt: "Multica \u770b\u677f\u89c6\u56fe\u2014\u2014\u4eba\u7c7b\u548c Agent \u534f\u540c\u7ba1\u7406\u4efb\u52a1",
|
||||
},
|
||||
|
||||
@@ -222,7 +223,8 @@ export const zh: LandingDict = {
|
||||
links: [
|
||||
{ label: "\u529f\u80fd\u7279\u6027", href: "#features" },
|
||||
{ label: "\u5982\u4f55\u5de5\u4f5c", href: "#how-it-works" },
|
||||
{ label: "\u66f4\u65b0\u65e5\u5fd7", href: "/changelog" },
|
||||
{ label: "更新日志", href: "/changelog" },
|
||||
{ label: "桌面端", href: "https://github.com/multica-ai/multica/releases/latest" },
|
||||
],
|
||||
},
|
||||
resources: {
|
||||
@@ -277,6 +279,27 @@ export const zh: LandingDict = {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.1",
|
||||
date: "2026-04-16",
|
||||
title: "新增 Agent 运行时",
|
||||
changes: [],
|
||||
features: [
|
||||
"支持 GitHub Copilot CLI 运行时",
|
||||
"支持 Cursor Agent CLI 运行时",
|
||||
"支持 Pi Agent 运行时",
|
||||
"工作区 URL 改造——slug 优先路由(`/{slug}/issues`),旧链接自动重定向",
|
||||
],
|
||||
fixes: [
|
||||
"Codex 同一 Issue 下跨任务恢复会话线程",
|
||||
"Codex 回合错误正确抛出,不再报告空输出",
|
||||
"工作区用量按任务完成时间正确分桶",
|
||||
"Autopilot 运行历史行整行可点击",
|
||||
"Daemon 和 GC 端点加强工作区隔离校验(安全)",
|
||||
"邀请邮件中的工作区和邀请人名称进行 HTML 转义",
|
||||
"桌面应用开发版和生产版现在可以同时运行",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.0",
|
||||
date: "2026-04-15",
|
||||
|
||||
1315
docs/plans/2026-04-16-remove-onboarding-and-fix-daemon-bootstrap.md
Normal file
1315
docs/plans/2026-04-16-remove-onboarding-and-fix-daemon-bootstrap.md
Normal file
File diff suppressed because it is too large
Load Diff
357
docs/plans/2026-04-16-unify-workspace-identity-resolver.md
Normal file
357
docs/plans/2026-04-16-unify-workspace-identity-resolver.md
Normal file
@@ -0,0 +1,357 @@
|
||||
# Unify Workspace Identity Resolver Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Fix broken file uploads caused by the workspace slug refactor (v2, PR #1138/#1141), and eliminate the structural bug source that allowed it. File uploads from within a workspace on the desktop and web apps currently land in S3 without a corresponding DB attachment record — the file is orphaned and the UI never sees it.
|
||||
|
||||
**Architecture:** The server currently has **two independent implementations** of the same logic — extract the workspace UUID from an HTTP request. One lives in the workspace middleware (post-v2, accepts slug header → DB lookup → UUID). The other lives inside the handler package (pre-v2, only accepts UUID header/query). The v2 refactor updated the middleware one and forgot the handler one; routes that sit *outside* the workspace middleware group (notably `/api/upload-file`) still run through the stale resolver and can't translate the frontend's new `X-Workspace-Slug` header.
|
||||
|
||||
The root cause is duplication. The fix is to collapse both resolvers into a single shared function that middleware and handlers both delegate to, so any future change to "how do we read workspace identity" is impossible to forget. The existing middleware's resolver already has the full logic; we extract it into a package-level function and have the handler helper call it.
|
||||
|
||||
**Tech Stack:** Go (Chi router, sqlc, pgx).
|
||||
|
||||
**Non-goals:**
|
||||
- No frontend changes. The frontend has been sending `X-Workspace-Slug` since v2; this plan makes the server finish accepting it everywhere.
|
||||
- No route reshuffling. `/api/upload-file` stays outside `RequireWorkspaceMember` because it serves two distinct use cases (avatar upload + workspace attachment); the avatar path needs to work without a workspace context.
|
||||
- No change to CLI / daemon clients. They still send `X-Workspace-ID` (UUID); the resolver keeps UUID as a fallback.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
| # | Change | Type | Files |
|
||||
|---|--------|------|-------|
|
||||
| 1 | Extract shared resolver into middleware package | Refactor | `server/internal/middleware/workspace.go` |
|
||||
| 2 | Promote handler `resolveWorkspaceID` to `(h *Handler).resolveWorkspaceID` + delegate to shared | Refactor | `server/internal/handler/handler.go` |
|
||||
| 3 | Rename 47 call sites from `resolveWorkspaceID(r)` → `h.resolveWorkspaceID(r)` | Mechanical | handler/*.go (see exhaustive list in task 3) |
|
||||
| 4 | Add test for upload-file with slug header | Test | `server/internal/handler/file_test.go` |
|
||||
| 5 | Add test for shared resolver | Test | `server/internal/middleware/workspace_test.go` |
|
||||
| 6 | `make check` and commit | Verify | — |
|
||||
|
||||
---
|
||||
|
||||
## Background: what's broken and why
|
||||
|
||||
**Frontend (current, post-v2):** `ApiClient.authHeaders()` in `packages/core/api/client.ts:121` sends:
|
||||
```
|
||||
X-Workspace-Slug: <slug>
|
||||
```
|
||||
|
||||
**Server middleware resolver** (`server/internal/middleware/workspace.go:53-86`, `resolveWorkspaceUUID`): accepts the slug header, looks up the slug via `queries.GetWorkspaceBySlug`, and writes the resolved UUID into the request context. Every handler behind `RequireWorkspaceMember` / `RequireWorkspaceRole` / `RequireWorkspaceMemberFromURL` sees the UUID in context and works correctly.
|
||||
|
||||
**Handler resolver** (`server/internal/handler/handler.go:155-165`, `resolveWorkspaceID`): a parallel implementation used by handlers that are NOT behind the workspace middleware. It only checks:
|
||||
1. `middleware.WorkspaceIDFromContext(r.Context())`
|
||||
2. `?workspace_id` query param
|
||||
3. `X-Workspace-ID` header
|
||||
|
||||
Never touches slug, because it has no `*db.Queries` access (it's a package-level function, not a method).
|
||||
|
||||
**Impact:** `/api/upload-file` (registered at `server/cmd/server/router.go:166`, in the user-scoped group, outside workspace middleware) calls `resolveWorkspaceID(r)`, gets `""` because the frontend only sends slug, thinks "no workspace context", and silently skips the DB attachment record creation (`server/internal/handler/file.go:235-245`). The file reaches S3; the UI never sees it.
|
||||
|
||||
**Why `/api/upload-file` is outside workspace middleware:** it serves both "avatar upload (no workspace)" and "attachment upload (with workspace)", branching on the resolved workspace ID inside the handler. Moving it under `RequireWorkspaceMember` would break avatar uploads.
|
||||
|
||||
**Structural root cause:** two resolvers, same job, divergent capabilities. The duplication is what let v2 ship "mostly working" — most handlers live behind middleware, so the broken handler resolver had a low blast radius that wasn't caught in review.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extract shared resolver into middleware package
|
||||
|
||||
**Problem:** The middleware's `resolveWorkspaceUUID` closure captures `*db.Queries` and can look up slugs. The handler's `resolveWorkspaceID` is a bare package-level function without queries access. We need a single implementation both sides can reuse. Putting it in the `middleware` package is fine — the `handler` package already imports `middleware`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/middleware/workspace.go`
|
||||
|
||||
**Step 1: Add `ResolveWorkspaceIDFromRequest` export**
|
||||
|
||||
After `errWorkspaceNotFound` (around line 45), add a package-level exported function that takes `(r *http.Request, queries *db.Queries)` and returns the workspace UUID as a string (empty if none found or slug doesn't resolve).
|
||||
|
||||
Priority order (mirrors `resolveWorkspaceUUID`, plus a context lookup first so handlers behind middleware still get the fast path):
|
||||
|
||||
```go
|
||||
// ResolveWorkspaceIDFromRequest returns the workspace UUID for an HTTP
|
||||
// request, using the same priority order as the workspace middleware.
|
||||
// Handlers behind workspace middleware get it from context (cheap); handlers
|
||||
// outside middleware (e.g. /api/upload-file) still resolve slug → UUID via
|
||||
// a DB lookup instead of silently falling through to "no workspace".
|
||||
//
|
||||
// Priority:
|
||||
// 1. middleware-injected context (if the route is behind workspace middleware)
|
||||
// 2. X-Workspace-Slug header → GetWorkspaceBySlug → UUID (post-refactor frontend)
|
||||
// 3. ?workspace_slug query → GetWorkspaceBySlug → UUID
|
||||
// 4. X-Workspace-ID header (CLI/daemon compat)
|
||||
// 5. ?workspace_id query (CLI/daemon compat)
|
||||
//
|
||||
// Returns "" when no identifier was provided OR a slug was provided but doesn't
|
||||
// resolve to any workspace. Callers that need the "slug provided but invalid"
|
||||
// distinction should use the resolver inside the middleware directly.
|
||||
func ResolveWorkspaceIDFromRequest(r *http.Request, queries *db.Queries) string {
|
||||
if id := WorkspaceIDFromContext(r.Context()); id != "" {
|
||||
return id
|
||||
}
|
||||
if slug := r.Header.Get("X-Workspace-Slug"); slug != "" {
|
||||
if ws, err := queries.GetWorkspaceBySlug(r.Context(), slug); err == nil {
|
||||
return util.UUIDToString(ws.ID)
|
||||
}
|
||||
}
|
||||
if slug := r.URL.Query().Get("workspace_slug"); slug != "" {
|
||||
if ws, err := queries.GetWorkspaceBySlug(r.Context(), slug); err == nil {
|
||||
return util.UUIDToString(ws.ID)
|
||||
}
|
||||
}
|
||||
if id := r.Header.Get("X-Workspace-ID"); id != "" {
|
||||
return id
|
||||
}
|
||||
return r.URL.Query().Get("workspace_id")
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Refactor `resolveWorkspaceUUID` to delegate**
|
||||
|
||||
The existing middleware closure has slightly different semantics (returns `errWorkspaceNotFound` when a slug was provided but doesn't resolve, so middleware can 404 instead of 400). Keep that, but share the resolution logic:
|
||||
|
||||
Leave `resolveWorkspaceUUID` as-is for now — it distinguishes "no identifier" (400) from "invalid slug" (404). `ResolveWorkspaceIDFromRequest` returns "" in both cases because handler-level callers don't need that distinction (they just check for empty).
|
||||
|
||||
Document in a comment near `resolveWorkspaceUUID` that it's an internal variant that preserves the error distinction for middleware gating, and point to `ResolveWorkspaceIDFromRequest` as the handler-facing API.
|
||||
|
||||
**Step 3: Build and verify**
|
||||
|
||||
```bash
|
||||
cd server && go build ./...
|
||||
```
|
||||
Expected: clean build.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```
|
||||
refactor(server): extract ResolveWorkspaceIDFromRequest from middleware
|
||||
|
||||
Introduces a shared helper that consolidates the workspace-identity
|
||||
resolution logic used by both the workspace middleware and the handler
|
||||
package. No behavior change yet — callers still use the old functions.
|
||||
Sets up the next commit to fix the /api/upload-file slug bug by routing
|
||||
the handler-side resolver through this shared function.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Promote handler resolver to a method + delegate
|
||||
|
||||
**Problem:** The package-level `resolveWorkspaceID(r *http.Request)` in `handler.go` can't call `GetWorkspaceBySlug` because it has no queries access. Promoting it to a method on `*Handler` gives it access to `h.Queries` at no syntactic cost elsewhere.
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/handler/handler.go:155-165`
|
||||
|
||||
**Step 1: Replace `resolveWorkspaceID` with a Handler method**
|
||||
|
||||
```go
|
||||
// resolveWorkspaceID resolves the workspace UUID for this request.
|
||||
// Delegates to middleware.ResolveWorkspaceIDFromRequest so routes inside
|
||||
// and outside workspace middleware see identical resolution behavior.
|
||||
//
|
||||
// Returns "" when no workspace identifier was provided or a slug was
|
||||
// provided but doesn't match any workspace.
|
||||
func (h *Handler) resolveWorkspaceID(r *http.Request) string {
|
||||
return middleware.ResolveWorkspaceIDFromRequest(r, h.Queries)
|
||||
}
|
||||
```
|
||||
|
||||
Delete the old package-level `resolveWorkspaceID` function.
|
||||
|
||||
**Step 2: Build — expect errors at 47 call sites**
|
||||
|
||||
```bash
|
||||
cd server && go build ./... 2>&1 | head -60
|
||||
```
|
||||
|
||||
Expected: `resolveWorkspaceID is not a value` or `undefined: resolveWorkspaceID` errors at each existing call site. That's the signal to run Task 3.
|
||||
|
||||
**Do not commit yet.** Task 2 and 3 are a single logical change; they commit together after Task 3 fixes the compile.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Rename 47 call sites to `h.resolveWorkspaceID(r)`
|
||||
|
||||
**Problem:** Every `resolveWorkspaceID(r)` call in the handler package now fails to compile because the function became a method. All 47 call sites are inside methods on `*Handler` (or similar receiver types that have access to `h`), so the rename is mechanical.
|
||||
|
||||
**Files affected** (verified via `grep -rn "resolveWorkspaceID" server/internal/handler/`):
|
||||
|
||||
- `server/internal/handler/handler.go:275, 365, 388` (3 sites)
|
||||
- `server/internal/handler/issue.go:447, 559, 731, 783, 1294, 1476` (6 sites)
|
||||
- `server/internal/handler/activity.go:133` (1 site)
|
||||
- `server/internal/handler/autopilot.go:178, 203, 255, 306, 386, 414, 490, 578, 615, 662` (10 sites)
|
||||
- `server/internal/handler/project.go:80, 127, 150, 192, 273, 430` (6 sites)
|
||||
- `server/internal/handler/comment.go:443, 510` (2 sites)
|
||||
- `server/internal/handler/runtime.go:207, 247, 296` (3 sites)
|
||||
- `server/internal/handler/pin.go:59, 105, 175, 202` (4 sites)
|
||||
- `server/internal/handler/reaction.go:43, 110` (2 sites)
|
||||
- `server/internal/handler/skill.go:126, 146, 187, 384, 815` (5 sites)
|
||||
- `server/internal/handler/agent.go:158, 254` (2 sites)
|
||||
- `server/internal/handler/file.go:83, 115, 282, 306` (4 sites)
|
||||
|
||||
Total: 48 (the resolver declaration itself + 47 callers).
|
||||
|
||||
**Step 1: Mechanical rename**
|
||||
|
||||
For each file above, change every `resolveWorkspaceID(r)` to `h.resolveWorkspaceID(r)`. In the one case in `file.go:83` inside `groupAttachments`, the receiver is already `*Handler`, so the method is accessible.
|
||||
|
||||
**Semantic check:** all 47 call sites are on methods with an `h *Handler` receiver (verifiable by scrolling up a few lines from each grep match). If any call site is inside a non-method function, that site needs to either take `*Handler` as a parameter or be skipped from this rename. Spot-check three sites before doing the rename.
|
||||
|
||||
**Step 2: Build**
|
||||
|
||||
```bash
|
||||
cd server && go build ./...
|
||||
```
|
||||
Expected: clean build.
|
||||
|
||||
**Step 3: Run Go tests**
|
||||
|
||||
```bash
|
||||
cd server && go test ./...
|
||||
```
|
||||
Expected: all pass. The 46 call sites behind workspace middleware hit the context branch (identical behavior to before). Only `UploadFile` gains new capability (slug resolution); it wasn't tested before, will be covered in Task 4.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```
|
||||
fix(server): resolve X-Workspace-Slug in /api/upload-file and other middleware-less handlers
|
||||
|
||||
The v2 workspace URL refactor updated the workspace middleware to accept
|
||||
X-Workspace-Slug but left the handler-package resolveWorkspaceID helper
|
||||
(used by handlers outside the middleware group) stuck on X-Workspace-ID.
|
||||
The frontend switched to the slug header, so /api/upload-file was
|
||||
receiving a slug it couldn't translate to a UUID, silently falling
|
||||
through to the avatar-upload branch and skipping DB attachment record
|
||||
creation — files were landing in S3 with no database reference.
|
||||
|
||||
Promote resolveWorkspaceID to a Handler method and delegate to the new
|
||||
middleware.ResolveWorkspaceIDFromRequest so middleware-behind and
|
||||
middleware-outside handlers share the same resolution logic. The 46
|
||||
call sites that live inside the workspace middleware group are
|
||||
unaffected (context lookup still wins). /api/upload-file now correctly
|
||||
recognizes slug requests and creates the attachment record.
|
||||
|
||||
Fixes: missing DB attachment rows for files uploaded since v2 (#1141)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add handler test for upload-file with slug header
|
||||
|
||||
**Problem:** The bug manifested exactly because there was no test covering the "upload-file with only a slug header" code path. Prevent regression.
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/handler/file_test.go` (or create if absent)
|
||||
|
||||
**Step 1: Locate existing upload-file test infrastructure**
|
||||
|
||||
```bash
|
||||
grep -rn "UploadFile\|upload-file" server/internal/handler/*_test.go
|
||||
```
|
||||
|
||||
If there's an existing upload-file test, add a new test case alongside it. If not, scaffold one using the same `handler_test.go` fixture pattern (`testWorkspaceID`, `testUserID`, seeded workspace).
|
||||
|
||||
**Step 2: Write the test**
|
||||
|
||||
Test name: `TestUploadFile_ResolvesWorkspaceViaSlugHeader`.
|
||||
|
||||
Flow:
|
||||
1. Seed a workspace with a known slug and the default test user as a member.
|
||||
2. POST a multipart form to `/api/upload-file` with an `issue_id` field referencing a seeded issue, with only `X-Workspace-Slug: <slug>` in headers (no `X-Workspace-ID`).
|
||||
3. Assert response is 200.
|
||||
4. Assert a DB row exists in `attachments` with the expected `workspace_id`, `uploader_id`, `issue_id`, and `filename`.
|
||||
|
||||
Anti-regression: also add `TestUploadFile_ResolvesWorkspaceViaIDHeaderStill` to confirm legacy `X-Workspace-ID` header still works (CLI / daemon compat).
|
||||
|
||||
**Step 3: Run the new test**
|
||||
|
||||
```bash
|
||||
cd server && go test ./internal/handler/ -run UploadFile
|
||||
```
|
||||
Expected: both pass.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```
|
||||
test(server): cover upload-file slug and UUID header resolution
|
||||
|
||||
Regression test for the v2 refactor bug: uploads from the frontend
|
||||
(which sends X-Workspace-Slug) now reach the workspace-aware branch
|
||||
and create attachment records.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add unit test for the shared resolver
|
||||
|
||||
**Problem:** The shared function will be the single point through which all workspace identity resolution flows. It deserves table-driven test coverage for each priority level.
|
||||
|
||||
**Files:**
|
||||
- Create or modify: `server/internal/middleware/workspace_test.go`
|
||||
|
||||
**Step 1: Table test**
|
||||
|
||||
Cases to cover:
|
||||
- Context UUID present → returns context UUID, ignores headers/query
|
||||
- Only `X-Workspace-Slug` → DB lookup succeeds → returns UUID
|
||||
- Only `X-Workspace-Slug` → DB lookup fails → returns ""
|
||||
- Only `?workspace_slug` → DB lookup succeeds → returns UUID
|
||||
- Only `X-Workspace-ID` → returns UUID
|
||||
- Only `?workspace_id` → returns UUID
|
||||
- Slug header + UUID header both present → slug wins (frontend priority)
|
||||
- Nothing → returns ""
|
||||
|
||||
**Step 2: Run**
|
||||
|
||||
```bash
|
||||
cd server && go test ./internal/middleware/ -run ResolveWorkspaceIDFromRequest
|
||||
```
|
||||
Expected: all cases pass.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```
|
||||
test(server): table-driven coverage for ResolveWorkspaceIDFromRequest
|
||||
|
||||
Pins down the priority order (context > slug header > slug query >
|
||||
UUID header > UUID query) so future changes can't silently diverge.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Full verification
|
||||
|
||||
**Step 1: `make check`**
|
||||
|
||||
```bash
|
||||
make check
|
||||
```
|
||||
Expected: typecheck, TS tests, Go tests, E2E (if backend+frontend up) all green.
|
||||
|
||||
**Step 2: Manual smoke test**
|
||||
|
||||
1. Start desktop dev environment.
|
||||
2. Open an issue, attach a file via drag-and-drop or the file picker.
|
||||
3. Refresh the issue. The attachment should appear in the attachments list.
|
||||
|
||||
Before this fix: attachment silently disappears on refresh (file is in S3, DB has no row).
|
||||
|
||||
**Step 3: Open PR**
|
||||
|
||||
Branch name: `fix/unify-workspace-identity-resolver`.
|
||||
|
||||
Title: `fix(server): resolve X-Workspace-Slug in middleware-less handlers`
|
||||
|
||||
Body should:
|
||||
- Link to the symptom PR (v2 refactor #1141) and reference that it's a latent follow-up.
|
||||
- Describe the structural change (two resolvers → one).
|
||||
- Note that 46 of 47 call sites see zero behavior change (context branch wins); only `/api/upload-file` gains capability.
|
||||
|
||||
---
|
||||
|
||||
## Risk / blast radius
|
||||
|
||||
**Low risk.** The 46 middleware-protected callers hit the context branch in `ResolveWorkspaceIDFromRequest` identically to how they hit `WorkspaceIDFromContext` before — zero semantic change. The only new code path exercised in production is the slug-header branch for `/api/upload-file`, which is already exercised by every other slug-header-carrying request (just via the middleware's version of the same logic). Task 4 and 5 lock the behavior down with tests.
|
||||
|
||||
## Rollback plan
|
||||
|
||||
If a regression surfaces after deploy, revert the single commit from Task 3. `ResolveWorkspaceIDFromRequest` and the Handler method remain but are unused — harmless dead code until the next attempt.
|
||||
93
packages/core/auth/store.test.ts
Normal file
93
packages/core/auth/store.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ApiClient } from "../api/client";
|
||||
import { ApiError } from "../api/client";
|
||||
import type { StorageAdapter, User } from "../types";
|
||||
import { createAuthStore } from "./store";
|
||||
|
||||
const fakeUser: User = {
|
||||
id: "u1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
avatar_url: null,
|
||||
} as User;
|
||||
|
||||
function makeStorage(initial: Record<string, string> = {}): StorageAdapter & {
|
||||
snapshot: () => Record<string, string>;
|
||||
} {
|
||||
const data = { ...initial };
|
||||
return {
|
||||
getItem: (k) => data[k] ?? null,
|
||||
setItem: (k, v) => {
|
||||
data[k] = v;
|
||||
},
|
||||
removeItem: (k) => {
|
||||
delete data[k];
|
||||
},
|
||||
snapshot: () => ({ ...data }),
|
||||
};
|
||||
}
|
||||
|
||||
function makeApi(getMe: () => Promise<User>): ApiClient {
|
||||
return {
|
||||
setToken: vi.fn(),
|
||||
getMe,
|
||||
// Only the methods touched by store.initialize are needed. Cast to
|
||||
// ApiClient for type compatibility — the store treats it opaquely.
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
describe("authStore.initialize — token mode", () => {
|
||||
it("keeps the stored token when getMe fails with a non-401 ApiError (e.g. 500)", async () => {
|
||||
const storage = makeStorage({ multica_token: "t" });
|
||||
const api = makeApi(() =>
|
||||
Promise.reject(new ApiError("server error", 500, "Internal Server Error")),
|
||||
);
|
||||
const store = createAuthStore({ api, storage });
|
||||
|
||||
await store.getState().initialize();
|
||||
|
||||
expect(store.getState().user).toBeNull();
|
||||
expect(store.getState().isLoading).toBe(false);
|
||||
expect(storage.snapshot().multica_token).toBe("t");
|
||||
});
|
||||
|
||||
it("keeps the stored token on a network failure (non-ApiError throw)", async () => {
|
||||
const storage = makeStorage({ multica_token: "t" });
|
||||
const api = makeApi(() => Promise.reject(new TypeError("fetch failed")));
|
||||
const store = createAuthStore({ api, storage });
|
||||
|
||||
await store.getState().initialize();
|
||||
|
||||
expect(store.getState().user).toBeNull();
|
||||
expect(storage.snapshot().multica_token).toBe("t");
|
||||
});
|
||||
|
||||
it("on 401, leaves storage cleanup to ApiClient.onUnauthorized and resets state", async () => {
|
||||
// Simulate the real path: ApiClient fires onUnauthorized on 401, which
|
||||
// removes the token from storage. The store's catch block must not
|
||||
// duplicate or short-circuit this — it should only reset in-memory
|
||||
// auth state.
|
||||
const storage = makeStorage({ multica_token: "t" });
|
||||
const api = makeApi(() => {
|
||||
storage.removeItem("multica_token"); // stand-in for onUnauthorized
|
||||
return Promise.reject(new ApiError("unauthorized", 401, "Unauthorized"));
|
||||
});
|
||||
const store = createAuthStore({ api, storage });
|
||||
|
||||
await store.getState().initialize();
|
||||
|
||||
expect(store.getState().user).toBeNull();
|
||||
expect(storage.snapshot().multica_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it("populates user when getMe succeeds", async () => {
|
||||
const storage = makeStorage({ multica_token: "t" });
|
||||
const api = makeApi(() => Promise.resolve(fakeUser));
|
||||
const store = createAuthStore({ api, storage });
|
||||
|
||||
await store.getState().initialize();
|
||||
|
||||
expect(store.getState().user).toEqual(fakeUser);
|
||||
expect(storage.snapshot().multica_token).toBe("t");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import type { User, StorageAdapter } from "../types";
|
||||
import type { ApiClient } from "../api/client";
|
||||
import { ApiError, type ApiClient } from "../api/client";
|
||||
import { setCurrentWorkspace } from "../platform/workspace-storage";
|
||||
|
||||
export interface AuthStoreOptions {
|
||||
@@ -57,10 +57,17 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
try {
|
||||
const user = await api.getMe();
|
||||
set({ user, isLoading: false });
|
||||
} catch {
|
||||
api.setToken(null);
|
||||
setCurrentWorkspace(null, null);
|
||||
storage.removeItem("multica_token");
|
||||
} catch (err) {
|
||||
// Only clear the stored token on a genuine auth failure (401). For
|
||||
// transient errors — network blips, backend rolling restarts, 5xx,
|
||||
// aborted fetches — keep the token so the next initialize() (next
|
||||
// page load or focus-refresh) can retry. The 401 path's token
|
||||
// cleanup is handled upstream by ApiClient.handleUnauthorized via
|
||||
// the onUnauthorized callback; we only need to reset the in-memory
|
||||
// user + workspace state here.
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
setCurrentWorkspace(null, null);
|
||||
}
|
||||
set({ user: null, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) });
|
||||
|
||||
46
packages/core/issues/stores/comment-collapse-store.ts
Normal file
46
packages/core/issues/stores/comment-collapse-store.ts
Normal 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());
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,13 +5,13 @@ import { describe, it, expect } from "vitest";
|
||||
// persistence — otherwise lastPath could contain /login etc, and on next
|
||||
// app load we'd "restore" a user to the login page.
|
||||
describe("useNavigationStore.lastPath excludes global paths", () => {
|
||||
it("does not persist /login, /onboarding, /invite/, /auth/, /logout, /signup", async () => {
|
||||
it("does not persist /login, /workspaces/new, /invite/, /auth/, /logout, /signup", async () => {
|
||||
const { useNavigationStore } = await import("./store");
|
||||
const globalPrefixes = [
|
||||
"/login",
|
||||
"/logout",
|
||||
"/signup",
|
||||
"/onboarding",
|
||||
"/workspaces/new",
|
||||
"/invite/abc",
|
||||
"/auth/callback",
|
||||
];
|
||||
|
||||
@@ -10,13 +10,13 @@ import { defaultStorage } from "../platform/storage";
|
||||
|
||||
// Paths that should not be persisted as "last visited":
|
||||
// - Auth flows (/login, /signup, /logout)
|
||||
// - Pre-workspace routes (/onboarding, /auth/, /invite/)
|
||||
// - Pre-workspace routes (/workspaces/new, /auth/, /invite/)
|
||||
// - Pair flow (/pair/)
|
||||
const EXCLUDED_PREFIXES = [
|
||||
"/login",
|
||||
"/signup",
|
||||
"/logout",
|
||||
"/onboarding",
|
||||
"/workspaces/",
|
||||
"/auth/",
|
||||
"/invite/",
|
||||
"/pair/",
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("global path / reserved slug consistency", () => {
|
||||
"/login",
|
||||
"/logout",
|
||||
"/signup",
|
||||
"/onboarding",
|
||||
"/workspaces/",
|
||||
"/invite/",
|
||||
"/auth/",
|
||||
];
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("paths.workspace(slug)", () => {
|
||||
describe("paths (global)", () => {
|
||||
it("builds global paths without slug", () => {
|
||||
expect(paths.login()).toBe("/login");
|
||||
expect(paths.onboarding()).toBe("/onboarding");
|
||||
expect(paths.newWorkspace()).toBe("/workspaces/new");
|
||||
expect(paths.invite("inv-1")).toBe("/invite/inv-1");
|
||||
expect(paths.authCallback()).toBe("/auth/callback");
|
||||
});
|
||||
@@ -36,7 +36,7 @@ describe("paths (global)", () => {
|
||||
describe("isGlobalPath", () => {
|
||||
it("returns true for pre-workspace routes", () => {
|
||||
expect(isGlobalPath("/login")).toBe(true);
|
||||
expect(isGlobalPath("/onboarding")).toBe(true);
|
||||
expect(isGlobalPath("/workspaces/new")).toBe(true);
|
||||
expect(isGlobalPath("/invite/abc")).toBe(true);
|
||||
expect(isGlobalPath("/auth/callback")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* Two kinds of paths:
|
||||
* - workspace-scoped: paths.workspace(slug).xxx() — carry workspace in URL
|
||||
* - global: paths.login(), paths.onboarding(), paths.invite(id) — pre-workspace routes
|
||||
* - global: paths.login(), paths.newWorkspace(), paths.invite(id) — pre-workspace routes
|
||||
*
|
||||
* Why pure functions + builder pattern:
|
||||
* - Changing a route shape (e.g. adding workspace slug prefix) becomes a single-file edit
|
||||
@@ -38,7 +38,7 @@ export const paths = {
|
||||
|
||||
// Global (pre-workspace) routes
|
||||
login: () => "/login",
|
||||
onboarding: () => "/onboarding",
|
||||
newWorkspace: () => "/workspaces/new",
|
||||
invite: (id: string) => `/invite/${encode(id)}`,
|
||||
authCallback: () => "/auth/callback",
|
||||
root: () => "/",
|
||||
@@ -48,7 +48,9 @@ export type WorkspacePaths = ReturnType<typeof workspaceScoped>;
|
||||
|
||||
// Prefixes — not slug names — because we match against full URL paths.
|
||||
// A path is global if it equals or begins with any of these.
|
||||
const GLOBAL_PREFIXES = ["/login", "/onboarding", "/invite/", "/auth/", "/logout", "/signup"];
|
||||
// Note: `/workspaces/` (trailing slash) is the prefix — `workspaces` is reserved,
|
||||
// so any path starting with `/workspaces/...` is system-owned, not user-owned.
|
||||
const GLOBAL_PREFIXES = ["/login", "/workspaces/", "/invite/", "/auth/", "/logout", "/signup"];
|
||||
|
||||
export function isGlobalPath(path: string): boolean {
|
||||
return GLOBAL_PREFIXES.some((p) => path === p || path.startsWith(p));
|
||||
|
||||
@@ -1,30 +1,53 @@
|
||||
/**
|
||||
* Slugs reserved because they collide with frontend top-level routes.
|
||||
* Slugs reserved because they collide with frontend top-level routes,
|
||||
* platform features, or web standards.
|
||||
*
|
||||
* Keep in sync with server/internal/handler/workspace_reserved_slugs.go.
|
||||
*
|
||||
* Convention for new global routes (CLAUDE.md): use a single word
|
||||
* (`/login`, `/inbox`) or `/{noun}/{verb}` (`/workspaces/new`). Hyphenated
|
||||
* root-level word groups (`/new-workspace`, `/create-team`) collide with
|
||||
* common user workspace names — see PR for full discussion.
|
||||
*/
|
||||
export const RESERVED_SLUGS = new Set([
|
||||
// Auth + onboarding
|
||||
// Auth flow
|
||||
"login",
|
||||
"logout",
|
||||
"signin",
|
||||
"signout",
|
||||
"signup",
|
||||
"onboarding",
|
||||
"invite",
|
||||
"auth",
|
||||
"oauth",
|
||||
"callback",
|
||||
"invite",
|
||||
"verify",
|
||||
"reset",
|
||||
"password",
|
||||
"onboarding", // historical, kept reserved post-removal
|
||||
|
||||
// Reserved for future platform routes
|
||||
// Platform / marketing routes (current + likely-future)
|
||||
"api",
|
||||
"admin",
|
||||
"help",
|
||||
"about",
|
||||
"pricing",
|
||||
"changelog",
|
||||
"docs",
|
||||
"support",
|
||||
"status",
|
||||
"legal",
|
||||
"privacy",
|
||||
"terms",
|
||||
"security",
|
||||
"contact",
|
||||
"blog",
|
||||
"careers",
|
||||
"press",
|
||||
"download",
|
||||
|
||||
// Dashboard route segments. Even though Next.js's route specificity
|
||||
// would technically resolve /{slug}/{view} correctly, having a workspace
|
||||
// slug equal to a route name (e.g. slug="issues") makes URLs visually
|
||||
// ambiguous — /issues/abc reads as either "issue abc in workspace
|
||||
// 'issues'" or "issue abc in some workspace". Reserve to avoid the
|
||||
// ambiguity entirely.
|
||||
// Dashboard / workspace route segments. Reserving the segment name
|
||||
// prevents `/{slug}/{view}` from being visually ambiguous (e.g. a
|
||||
// workspace named "issues" makes `/issues/abc` mean two things).
|
||||
"issues",
|
||||
"projects",
|
||||
"autopilots",
|
||||
@@ -34,8 +57,29 @@ export const RESERVED_SLUGS = new Set([
|
||||
"runtimes",
|
||||
"skills",
|
||||
"settings",
|
||||
"workspaces", // global `/workspaces/new` workspace creation page
|
||||
"teams", // reserved for future team management routes
|
||||
|
||||
// Next.js / hosting internals
|
||||
// RFC 2142 — privileged email mailboxes. Allowing user workspaces with
|
||||
// these slugs would let attackers spoof system messaging.
|
||||
"postmaster",
|
||||
"abuse",
|
||||
"noreply",
|
||||
"webmaster",
|
||||
"hostmaster",
|
||||
|
||||
// Hostname / subdomain confusables. Even on path-based routing these
|
||||
// names attract phishing and subdomain-takeover attempts.
|
||||
"mail",
|
||||
"ftp",
|
||||
"static",
|
||||
"cdn",
|
||||
"assets",
|
||||
"public",
|
||||
"files",
|
||||
"uploads",
|
||||
|
||||
// Next.js / web standards (framework-mandated)
|
||||
"_next",
|
||||
"favicon.ico",
|
||||
"robots.txt",
|
||||
|
||||
@@ -3,5 +3,5 @@ export type { CoreProviderProps } from "./types";
|
||||
export { AuthInitializer } from "./auth-initializer";
|
||||
export { defaultStorage } from "./storage";
|
||||
export { createPersistStorage } from "./persist-storage";
|
||||
export { createWorkspaceAwareStorage, setCurrentWorkspace, getCurrentSlug, getCurrentWsId, subscribeToCurrentSlug, registerForWorkspaceRehydration, rehydrateAllWorkspaceStores } from "./workspace-storage";
|
||||
export { createWorkspaceAwareStorage, setCurrentWorkspace, getCurrentSlug, getCurrentWsId, subscribeToCurrentSlug, registerForWorkspaceRehydration } from "./workspace-storage";
|
||||
export { clearWorkspaceStorage } from "./storage-cleanup";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { createWorkspaceAwareStorage, setCurrentWorkspace } from "./workspace-storage";
|
||||
import {
|
||||
createWorkspaceAwareStorage,
|
||||
setCurrentWorkspace,
|
||||
registerForWorkspaceRehydration,
|
||||
} from "./workspace-storage";
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
function mockAdapter(): StorageAdapter {
|
||||
@@ -59,3 +63,57 @@ describe("workspace-aware storage", () => {
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("draft:dev");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCurrentWorkspace — rehydrate side effect", () => {
|
||||
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
|
||||
|
||||
it("runs registered fns once when slug changes", async () => {
|
||||
const fn = vi.fn();
|
||||
registerForWorkspaceRehydration(fn);
|
||||
|
||||
setCurrentWorkspace("team-a", "ws_a");
|
||||
await flush();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is a no-op when slug is unchanged — repeat calls with same slug skip the side effect", async () => {
|
||||
const fn = vi.fn();
|
||||
registerForWorkspaceRehydration(fn);
|
||||
|
||||
setCurrentWorkspace("team-a", "ws_a");
|
||||
await flush();
|
||||
setCurrentWorkspace("team-a", "ws_a");
|
||||
setCurrentWorkspace("team-a", "ws_a");
|
||||
setCurrentWorkspace("team-a", "ws_a");
|
||||
await flush();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("runs again on real workspace switch", async () => {
|
||||
const fn = vi.fn();
|
||||
registerForWorkspaceRehydration(fn);
|
||||
|
||||
setCurrentWorkspace("team-a", "ws_a");
|
||||
await flush();
|
||||
setCurrentWorkspace("team-b", "ws_b");
|
||||
await flush();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("runs again after logout → re-entry into same workspace", async () => {
|
||||
const fn = vi.fn();
|
||||
registerForWorkspaceRehydration(fn);
|
||||
|
||||
setCurrentWorkspace("team-a", "ws_a");
|
||||
await flush();
|
||||
setCurrentWorkspace(null, null);
|
||||
await flush();
|
||||
setCurrentWorkspace("team-a", "ws_a");
|
||||
await flush();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,25 +14,35 @@ let _pendingNotify = false;
|
||||
let _pendingRehydrate = false;
|
||||
|
||||
/**
|
||||
* Set both the current workspace slug and UUID at once.
|
||||
* Called by the workspace layout's render-phase ref guard.
|
||||
* Notifies slug subscribers (e.g. WSProvider via useSyncExternalStore).
|
||||
* Update the current workspace identity. This is the single source of truth
|
||||
* for "which workspace is active"; everything downstream (WS connection,
|
||||
* persist namespace, cache-key derivation) follows from here.
|
||||
*
|
||||
* If the slug actually changed, two side effects fire:
|
||||
* 1. Subscribers are notified (e.g. WSProvider reconnects).
|
||||
* 2. All registered persist stores rehydrate from the new slug's namespace.
|
||||
*
|
||||
* Both side effects are idempotent on slug-equality: repeat calls with the
|
||||
* same slug are a pure no-op. This matters on desktop, where N tabs each
|
||||
* mount their own WorkspaceRouteLayout and each one naively tries to sync;
|
||||
* only the first call for a given slug does real work.
|
||||
*
|
||||
* Both side effects are deferred to a microtask because zustand persist
|
||||
* rehydrate + subscriber notifications both end up calling setState(), and
|
||||
* React 19 forbids "cross-component updates during render".
|
||||
*/
|
||||
export function setCurrentWorkspace(slug: string | null, wsId: string | null) {
|
||||
const slugChanged = _currentSlug !== slug;
|
||||
if (_currentSlug === slug) {
|
||||
// Slug unchanged: nothing to rehydrate, nothing to notify. Accept a
|
||||
// (possibly) updated wsId for consumers that read the UUID mirror.
|
||||
_currentWsId = wsId;
|
||||
return;
|
||||
}
|
||||
_currentSlug = slug;
|
||||
_currentWsId = wsId;
|
||||
if (slugChanged && !_pendingNotify) {
|
||||
|
||||
if (!_pendingNotify) {
|
||||
_pendingNotify = true;
|
||||
// Defer and deduplicate subscriber notifications:
|
||||
// 1. Defer: avoids "cannot update component B while rendering A"
|
||||
// (React 19 render-phase restriction).
|
||||
// 2. Deduplicate: rapid A→B switches only notify once with the
|
||||
// final slug, avoiding a wasted WS connect+disconnect cycle.
|
||||
// The module vars are already updated synchronously above, so
|
||||
// authHeaders() and getCurrentSlug() return the correct value
|
||||
// immediately — subscribers are only for async consumers like
|
||||
// WSProvider that need to reconnect the WebSocket.
|
||||
queueMicrotask(() => {
|
||||
_pendingNotify = false;
|
||||
const current = _currentSlug;
|
||||
@@ -41,6 +51,16 @@ export function setCurrentWorkspace(slug: string | null, wsId: string | null) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!_pendingRehydrate) {
|
||||
_pendingRehydrate = true;
|
||||
queueMicrotask(() => {
|
||||
_pendingRehydrate = false;
|
||||
for (const fn of _rehydrateFns) {
|
||||
fn();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Current workspace slug (from URL). */
|
||||
@@ -71,27 +91,6 @@ export function registerForWorkspaceRehydration(fn: () => void) {
|
||||
_rehydrateFns.push(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate all registered workspace-scoped persist stores from the new
|
||||
* namespace. Deferred to a microtask + deduplicated for the same reason
|
||||
* as slug subscriber notification: Zustand persist rehydrate synchronously
|
||||
* setState()s the store, which schedules updates on any component
|
||||
* subscribed to that store. Calling this from a component's render phase
|
||||
* would violate React 19's "no cross-component updates during render"
|
||||
* rule. Persist stores can tolerate one microtask of staleness — they're
|
||||
* UI preferences, not security-critical state.
|
||||
*/
|
||||
export function rehydrateAllWorkspaceStores() {
|
||||
if (_pendingRehydrate) return;
|
||||
_pendingRehydrate = true;
|
||||
queueMicrotask(() => {
|
||||
_pendingRehydrate = false;
|
||||
for (const fn of _rehydrateFns) {
|
||||
fn();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage that automatically namespaces keys with the current workspace slug.
|
||||
* Reads _currentSlug at call time, so it follows workspace switches dynamically.
|
||||
|
||||
@@ -261,17 +261,18 @@ export function useRealtimeSync(
|
||||
// --- Side-effect handlers (toast, navigation) ---
|
||||
|
||||
// After the current workspace disappears (deleted or we were kicked out),
|
||||
// navigate to another workspace the user still has access to, or to
|
||||
// onboarding. We use a full-page navigation: this reliably tears down any
|
||||
// in-flight queries / subscriptions tied to the dead workspace without
|
||||
// relying on framework-specific routers from here in core.
|
||||
// navigate to another workspace the user still has access to, or to the
|
||||
// create-workspace page. We use a full-page navigation: this reliably
|
||||
// tears down any in-flight queries / subscriptions tied to the dead
|
||||
// workspace without relying on framework-specific routers from here in
|
||||
// core.
|
||||
const relocateAfterWorkspaceLoss = async (lostWsId: string) => {
|
||||
const wsList = await qc.fetchQuery({
|
||||
...workspaceListOptions(),
|
||||
staleTime: 0,
|
||||
});
|
||||
const next = wsList.find((w) => w.id !== lostWsId);
|
||||
const target = next ? paths.workspace(next.slug).issues() : paths.onboarding();
|
||||
const target = next ? paths.workspace(next.slug).issues() : paths.newWorkspace();
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.assign(target);
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { LoginPage, validateCliCallback } from "./login-page";
|
||||
export { useLogout } from "./use-logout";
|
||||
|
||||
@@ -199,7 +199,8 @@ export function LoginPage({
|
||||
|
||||
// Normal path: seed the workspace list into the Query cache so the
|
||||
// caller's onSuccess can read it synchronously to compute a destination
|
||||
// URL (first workspace's slug, or onboarding).
|
||||
// URL (first workspace's slug, or /workspaces/new for zero-workspace
|
||||
// users).
|
||||
await useAuthStore.getState().verifyCode(email, value);
|
||||
const wsList = await api.listWorkspaces();
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
|
||||
63
packages/views/auth/use-logout.ts
Normal file
63
packages/views/auth/use-logout.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { clearWorkspaceStorage, defaultStorage } from "@multica/core/platform";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
import { useNavigation } from "../navigation";
|
||||
|
||||
/**
|
||||
* Performs a complete logout: clears per-workspace client storage, legacy
|
||||
* cookies, the desktop tab state, the entire React Query cache, the
|
||||
* in-memory auth store, and finally navigates to /login. Wraps what was
|
||||
* previously duplicated in app-sidebar's logout handler so NoAccessPage's
|
||||
* "Sign in as a different user" and any future entry point can use the
|
||||
* same flow.
|
||||
*
|
||||
* Without a unified logout, callers that only do `navigate('/login')`
|
||||
* leave the auth cookie + React Query cache + local storage intact —
|
||||
* AuthInitializer then silently re-authenticates the user on the login
|
||||
* page and redirects them back where they came from.
|
||||
*/
|
||||
export function useLogout() {
|
||||
const queryClient = useQueryClient();
|
||||
const authLogout = useAuthStore((s) => s.logout);
|
||||
const { push } = useNavigation();
|
||||
|
||||
return useCallback(() => {
|
||||
// Clear workspace-scoped storage for every workspace this user has
|
||||
// access to, BEFORE clearing the React Query cache (which holds the
|
||||
// workspace list). Otherwise per-workspace drafts/chat/etc would leak
|
||||
// to the next user on this device.
|
||||
const cachedWorkspaces =
|
||||
queryClient.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
|
||||
for (const ws of cachedWorkspaces) {
|
||||
clearWorkspaceStorage(defaultStorage, ws.slug);
|
||||
}
|
||||
|
||||
// Clear the last-workspace-slug cookie. Otherwise on a shared device
|
||||
// the next user gets redirected by the proxy to the previous user's
|
||||
// last workspace, then bounced to NoAccessPage — confusing.
|
||||
if (typeof document !== "undefined") {
|
||||
document.cookie =
|
||||
"last_workspace_slug=; path=/; max-age=0; SameSite=Lax";
|
||||
}
|
||||
|
||||
// Clear desktop tab state. Tab paths can contain workspace slugs and
|
||||
// issue UUIDs that must not survive across user sessions on a shared
|
||||
// machine. No-op on web (web doesn't write this key).
|
||||
defaultStorage.removeItem("multica_tabs");
|
||||
|
||||
queryClient.clear();
|
||||
authLogout();
|
||||
|
||||
// Navigate to /login explicitly. authLogout() clears state but doesn't
|
||||
// move the URL — without this the caller might be on a workspace URL
|
||||
// which renders null (layout gates on user) and leaves the user
|
||||
// stuck on a blank page.
|
||||
push(paths.login());
|
||||
}, [queryClient, authLogout, push]);
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -63,16 +64,14 @@ function RunRow({ run }: { run: AutopilotRun }) {
|
||||
const cfg = (RUN_STATUS_CONFIG[run.status] ?? RUN_STATUS_CONFIG["issue_created"])!;
|
||||
const StatusIcon = cfg.icon;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accent/30 transition-colors">
|
||||
const content = (
|
||||
<>
|
||||
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color)} />
|
||||
<span className={cn("w-24 shrink-0 text-xs font-medium", cfg.color)}>{cfg.label}</span>
|
||||
<span className="w-16 shrink-0 text-xs text-muted-foreground capitalize">{run.source}</span>
|
||||
<span className="flex-1 min-w-0 text-xs text-muted-foreground truncate">
|
||||
{run.issue_id ? (
|
||||
<AppLink href={wsPaths.issueDetail(run.issue_id)} className="hover:underline">
|
||||
Issue linked
|
||||
</AppLink>
|
||||
"Issue linked"
|
||||
) : run.failure_reason ? (
|
||||
<span className="text-destructive">{run.failure_reason}</span>
|
||||
) : null}
|
||||
@@ -80,8 +79,20 @@ function RunRow({ run }: { run: AutopilotRun }) {
|
||||
<span className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
|
||||
{formatDate(run.triggered_at || run.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const rowClass = "flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accent/30 transition-colors";
|
||||
|
||||
if (run.issue_id) {
|
||||
return (
|
||||
<AppLink href={wsPaths.issueDetail(run.issue_id)} className={cn(rowClass, "cursor-pointer")}>
|
||||
{content}
|
||||
</AppLink>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={rowClass}>{content}</div>;
|
||||
}
|
||||
|
||||
function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autopilotId: string }) {
|
||||
@@ -410,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 (
|
||||
@@ -425,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"}
|
||||
|
||||
108
packages/views/editor/extensions/mention-suggestion.test.tsx
Normal file
108
packages/views/editor/extensions/mention-suggestion.test.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { issueKeys } from "@multica/core/issues/queries";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
// Mock the workspace id singleton — items() reads it imperatively.
|
||||
vi.mock("@multica/core/platform", () => ({
|
||||
getCurrentWsId: () => "ws-1",
|
||||
}));
|
||||
|
||||
// Mock the API so we control searchIssues responses + observe calls.
|
||||
const searchIssuesMock = vi.fn();
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
get searchIssues() {
|
||||
return searchIssuesMock;
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { createMentionSuggestion, type MentionItem } from "./mention-suggestion";
|
||||
|
||||
function fakeQc(data: {
|
||||
members?: Array<{ user_id: string; name: string }>;
|
||||
agents?: Array<{ id: string; name: string; archived_at: string | null }>;
|
||||
issues?: Array<{ id: string; identifier: string; title: string; status: string }>;
|
||||
}): QueryClient {
|
||||
const map = new Map<string, unknown>();
|
||||
map.set(JSON.stringify(workspaceKeys.members("ws-1")), data.members ?? []);
|
||||
map.set(JSON.stringify(workspaceKeys.agents("ws-1")), data.agents ?? []);
|
||||
map.set(JSON.stringify(issueKeys.list("ws-1")), {
|
||||
issues: data.issues ?? [],
|
||||
total: data.issues?.length ?? 0,
|
||||
});
|
||||
return {
|
||||
getQueryData: (key: readonly unknown[]) => map.get(JSON.stringify(key)),
|
||||
} as unknown as QueryClient;
|
||||
}
|
||||
|
||||
describe("createMentionSuggestion", () => {
|
||||
beforeEach(() => {
|
||||
searchIssuesMock.mockReset();
|
||||
});
|
||||
|
||||
it("returns members and agents synchronously without waiting for the server search", () => {
|
||||
const qc = fakeQc({
|
||||
members: [{ user_id: "u1", name: "Alice" }],
|
||||
agents: [{ id: "a1", name: "Aegis", archived_at: null }],
|
||||
});
|
||||
// A pending fetch — would block the result if items() awaited it.
|
||||
searchIssuesMock.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const config = createMentionSuggestion(qc);
|
||||
const result = config.items!({ query: "a", editor: {} as never });
|
||||
|
||||
// Must be synchronous: a plain array, not a Promise.
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
const items = result as MentionItem[];
|
||||
expect(items.some((i) => i.type === "member" && i.label === "Alice")).toBe(true);
|
||||
expect(items.some((i) => i.type === "agent" && i.label === "Aegis")).toBe(true);
|
||||
});
|
||||
|
||||
it("calls searchIssues with include_closed=true so done issues are findable", async () => {
|
||||
const qc = fakeQc({});
|
||||
searchIssuesMock.mockResolvedValue({ issues: [], total: 0 });
|
||||
|
||||
const config = createMentionSuggestion(qc);
|
||||
config.items!({ query: "bug-xyz", editor: {} as never });
|
||||
|
||||
// Wait past the 150ms debounce.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
expect(searchIssuesMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ q: "bug-xyz", include_closed: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call searchIssues for an empty query", async () => {
|
||||
const qc = fakeQc({});
|
||||
searchIssuesMock.mockResolvedValue({ issues: [], total: 0 });
|
||||
|
||||
const config = createMentionSuggestion(qc);
|
||||
config.items!({ query: "", editor: {} as never });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
// No call with an empty q (other tests' fire-and-forget closures may leak,
|
||||
// so assert on the *content* of any call rather than absence).
|
||||
for (const call of searchIssuesMock.mock.calls) {
|
||||
expect(call[0].q).not.toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("includes cached issues in the synchronous response", () => {
|
||||
const qc = fakeQc({
|
||||
issues: [
|
||||
{ id: "i1", identifier: "MUL-1", title: "Login bug", status: "todo" },
|
||||
{ id: "i2", identifier: "MUL-2", title: "Other", status: "done" },
|
||||
],
|
||||
});
|
||||
searchIssuesMock.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const config = createMentionSuggestion(qc);
|
||||
const result = config.items!({ query: "bug", editor: {} as never });
|
||||
|
||||
const items = result as MentionItem[];
|
||||
expect(items.some((i) => i.type === "issue" && i.id === "i1")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||
import { getCurrentWsId } from "@multica/core/platform";
|
||||
import { issueKeys } from "@multica/core/issues/queries";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import type { Issue, ListIssuesResponse, MemberWithUser, Agent } from "@multica/core/types";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { StatusIcon } from "../../issues/components/status-icon";
|
||||
@@ -169,12 +170,15 @@ function MentionRow({
|
||||
buttonRef: (el: HTMLButtonElement | null) => void;
|
||||
}) {
|
||||
if (item.type === "issue") {
|
||||
// Visually dim closed issues (done/cancelled) so they're distinguishable
|
||||
// from active ones in the suggestion list — they're still selectable.
|
||||
const isClosed = item.status === "done" || item.status === "cancelled";
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors ${
|
||||
selected ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
} ${isClosed ? "opacity-60" : ""}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{item.status && (
|
||||
@@ -182,7 +186,11 @@ function MentionRow({
|
||||
)}
|
||||
<span className="shrink-0 text-muted-foreground">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="truncate text-muted-foreground">{item.description}</span>
|
||||
<span
|
||||
className={`truncate text-muted-foreground ${isClosed ? "line-through" : ""}`}
|
||||
>
|
||||
{item.description}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
@@ -213,69 +221,136 @@ function MentionRow({
|
||||
// Suggestion config factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function issueToMention(i: Pick<Issue, "id" | "identifier" | "title" | "status">): MentionItem {
|
||||
return {
|
||||
id: i.id,
|
||||
label: i.identifier,
|
||||
type: "issue" as const,
|
||||
description: i.title,
|
||||
status: i.status as IssueStatus,
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_ITEMS = 15;
|
||||
|
||||
export function createMentionSuggestion(qc: QueryClient): Omit<
|
||||
SuggestionOptions<MentionItem>,
|
||||
"editor"
|
||||
> {
|
||||
return {
|
||||
items: ({ query }) => {
|
||||
// Read workspace id imperatively because this runs in TipTap factory scope
|
||||
// (outside React render). getCurrentWsId() is the non-React
|
||||
// singleton set by the URL-driven workspace layout.
|
||||
const wsId = getCurrentWsId();
|
||||
const members: MemberWithUser[] = wsId ? qc.getQueryData(workspaceKeys.members(wsId)) ?? [] : [];
|
||||
const agents: Agent[] = wsId ? qc.getQueryData(workspaceKeys.agents(wsId)) ?? [] : [];
|
||||
const issues: Issue[] = wsId
|
||||
? qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId))?.issues ?? []
|
||||
// Per-editor state lives in this closure so multiple ContentEditor instances
|
||||
// (e.g. comment input + reply box) don't abort each other's searches.
|
||||
let renderer: ReactRenderer<MentionListRef> | null = null;
|
||||
let activeCommand: ((item: MentionItem) => void) | null = null;
|
||||
let searchSeq = 0;
|
||||
let searchAbort: AbortController | null = null;
|
||||
let popup: HTMLDivElement | null = null;
|
||||
|
||||
function buildSyncItems(query: string): MentionItem[] {
|
||||
// Read workspace id imperatively because this runs in TipTap factory scope
|
||||
// (outside React render). getCurrentWsId() is the non-React singleton set
|
||||
// by the URL-driven workspace layout.
|
||||
const wsId = getCurrentWsId();
|
||||
if (!wsId) return [];
|
||||
|
||||
const members: MemberWithUser[] = qc.getQueryData(workspaceKeys.members(wsId)) ?? [];
|
||||
const agents: Agent[] = qc.getQueryData(workspaceKeys.agents(wsId)) ?? [];
|
||||
const cachedIssues: Issue[] =
|
||||
qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId))?.issues ?? [];
|
||||
|
||||
const q = query.toLowerCase();
|
||||
|
||||
const allItem: MentionItem[] =
|
||||
"all members".includes(q) || "all".includes(q)
|
||||
? [{ id: "all", label: "All members", type: "all" as const }]
|
||||
: [];
|
||||
|
||||
const q = query.toLowerCase();
|
||||
const memberItems: MentionItem[] = members
|
||||
.filter((m) => m.name.toLowerCase().includes(q))
|
||||
.map((m) => ({
|
||||
id: m.user_id,
|
||||
label: m.name,
|
||||
type: "member" as const,
|
||||
}));
|
||||
|
||||
// Show "All members" option when query is empty or matches "all"
|
||||
const allItem: MentionItem[] =
|
||||
"all members".includes(q) || "all".includes(q)
|
||||
? [{ id: "all", label: "All members", type: "all" as const }]
|
||||
: [];
|
||||
const agentItems: MentionItem[] = agents
|
||||
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
|
||||
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
|
||||
|
||||
const memberItems: MentionItem[] = members
|
||||
.filter((m) => m.name.toLowerCase().includes(q))
|
||||
.map((m) => ({
|
||||
id: m.user_id,
|
||||
label: m.name,
|
||||
type: "member" as const,
|
||||
}));
|
||||
// Cached issues give an instant first paint; the server search below
|
||||
// adds done/cancelled and any other matches not in the local cache.
|
||||
const issueItems: MentionItem[] = cachedIssues
|
||||
.filter(
|
||||
(i) =>
|
||||
i.identifier.toLowerCase().includes(q) ||
|
||||
i.title.toLowerCase().includes(q),
|
||||
)
|
||||
.map(issueToMention);
|
||||
|
||||
const agentItems: MentionItem[] = agents
|
||||
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
|
||||
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
|
||||
return [...allItem, ...memberItems, ...agentItems, ...issueItems];
|
||||
}
|
||||
|
||||
const issueItems: MentionItem[] = issues
|
||||
.filter(
|
||||
(i) =>
|
||||
i.identifier.toLowerCase().includes(q) ||
|
||||
i.title.toLowerCase().includes(q),
|
||||
)
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
label: i.identifier,
|
||||
type: "issue" as const,
|
||||
description: i.title,
|
||||
status: i.status as IssueStatus,
|
||||
}));
|
||||
function startServerIssueSearch(query: string, syncItems: MentionItem[]) {
|
||||
// Supersede any in-flight search; the next-arrived response wins.
|
||||
if (searchAbort) searchAbort.abort();
|
||||
const mySeq = ++searchSeq;
|
||||
const wsId = getCurrentWsId();
|
||||
if (!wsId) return;
|
||||
|
||||
return [...allItem, ...memberItems, ...agentItems, ...issueItems].slice(0, 10);
|
||||
void (async () => {
|
||||
// Debounce: skip the fetch if a newer keystroke arrives within 150ms.
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
if (mySeq !== searchSeq) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
searchAbort = controller;
|
||||
try {
|
||||
const res = await api.searchIssues({
|
||||
q: query,
|
||||
limit: 10,
|
||||
include_closed: true,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (mySeq !== searchSeq) return;
|
||||
if (!renderer || !activeCommand) return;
|
||||
|
||||
const existingIssueIds = new Set(
|
||||
syncItems.filter((i) => i.type === "issue").map((i) => i.id),
|
||||
);
|
||||
const extraIssueItems = res.issues
|
||||
.map(issueToMention)
|
||||
.filter((i) => !existingIssueIds.has(i.id));
|
||||
if (extraIssueItems.length === 0) return;
|
||||
|
||||
const merged = [...syncItems, ...extraIssueItems].slice(0, MAX_ITEMS);
|
||||
renderer.updateProps({ items: merged, command: activeCommand });
|
||||
} catch {
|
||||
// Aborted or network error: nothing to do — sync items remain.
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return {
|
||||
items: ({ query }) => {
|
||||
const syncItems = buildSyncItems(query);
|
||||
// Empty query has no server search — cached issues are enough, and
|
||||
// we still bump the seq to cancel any pending fetch from a prior key.
|
||||
if (query === "") {
|
||||
if (searchAbort) searchAbort.abort();
|
||||
++searchSeq;
|
||||
} else {
|
||||
startServerIssueSearch(query, syncItems);
|
||||
}
|
||||
return syncItems.slice(0, MAX_ITEMS);
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let renderer: ReactRenderer<MentionListRef> | null = null;
|
||||
let popup: HTMLDivElement | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: SuggestionProps<MentionItem>) => {
|
||||
renderer = new ReactRenderer(MentionList, {
|
||||
props: { items: props.items, command: props.command },
|
||||
editor: props.editor,
|
||||
});
|
||||
activeCommand = props.command;
|
||||
|
||||
popup = document.createElement("div");
|
||||
popup.style.position = "fixed";
|
||||
@@ -291,6 +366,7 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
activeCommand = props.command;
|
||||
if (popup) updatePosition(popup, props.clientRect);
|
||||
},
|
||||
|
||||
@@ -328,8 +404,13 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
|
||||
function cleanup() {
|
||||
renderer?.destroy();
|
||||
renderer = null;
|
||||
activeCommand = null;
|
||||
popup?.remove();
|
||||
popup = null;
|
||||
// Cancel any in-flight server search; its result would target a
|
||||
// destroyed renderer.
|
||||
if (searchAbort) searchAbort.abort();
|
||||
++searchSeq;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ export function InvitePage({ invitationId }: InvitePageProps) {
|
||||
// page is a pre-workspace global route so we can't rely on WorkspaceSlugProvider.
|
||||
const { data: wsList = [] } = useQuery(workspaceListOptions());
|
||||
const fallbackDest =
|
||||
wsList[0] ? paths.workspace(wsList[0].slug).issues() : paths.onboarding();
|
||||
wsList[0] ? paths.workspace(wsList[0].slug).issues() : paths.newWorkspace();
|
||||
|
||||
const handleAccept = async () => {
|
||||
setAccepting(true);
|
||||
|
||||
@@ -422,6 +422,7 @@ function formatProvider(provider: string): string {
|
||||
claude: "Claude Code",
|
||||
"claude-code": "Claude Code",
|
||||
codex: "Codex",
|
||||
pi: "Pi",
|
||||
};
|
||||
return map[provider.toLowerCase()] ?? provider;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -75,8 +75,8 @@ import { useModalStore } from "@multica/core/modals";
|
||||
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
|
||||
import { pinListOptions } from "@multica/core/pins/queries";
|
||||
import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
|
||||
import type { PinnedItem, Workspace } from "@multica/core/types";
|
||||
import { clearWorkspaceStorage, defaultStorage } from "@multica/core/platform";
|
||||
import type { PinnedItem } from "@multica/core/types";
|
||||
import { useLogout } from "../auth";
|
||||
|
||||
// Nav items reference WorkspacePaths method names so they can be resolved
|
||||
// against the current workspace slug at render time (see AppSidebar body).
|
||||
@@ -196,7 +196,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
const { pathname, push } = useNavigation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const authLogout = useAuthStore((s) => s.logout);
|
||||
const logout = useLogout();
|
||||
const workspace = useCurrentWorkspace();
|
||||
const p = useWorkspacePaths();
|
||||
const { data: workspaces = [] } = useQuery(workspaceListOptions());
|
||||
@@ -262,28 +262,6 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
|
||||
},
|
||||
});
|
||||
const logout = () => {
|
||||
// Clear workspace-scoped storage for every workspace this user has access to,
|
||||
// before clearing the React Query cache (which holds the workspace list).
|
||||
// Otherwise per-workspace drafts/chat/etc would leak to the next user on this device.
|
||||
const cachedWorkspaces =
|
||||
queryClient.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
|
||||
for (const ws of cachedWorkspaces) {
|
||||
clearWorkspaceStorage(defaultStorage, ws.slug);
|
||||
}
|
||||
// Clear the last-workspace-slug cookie. Otherwise on a shared device the
|
||||
// next user gets redirected by the proxy to the previous user's last
|
||||
// workspace (then bounced to /onboarding by the layout — flash + confusing).
|
||||
if (typeof document !== "undefined") {
|
||||
document.cookie = "last_workspace_slug=; path=/; max-age=0; SameSite=Lax";
|
||||
}
|
||||
// Clear desktop tab state. Tab paths can contain issue UUIDs which must
|
||||
// not survive across user sessions on a shared machine. No-op on web
|
||||
// (web doesn't write this key).
|
||||
defaultStorage.removeItem("multica_tabs");
|
||||
queryClient.clear();
|
||||
authLogout();
|
||||
};
|
||||
|
||||
// Global "C" shortcut to open create-issue modal (like Linear)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,13 +14,13 @@ import { useNavigation } from "../navigation";
|
||||
* Redirect logic:
|
||||
* - Auth still loading → wait
|
||||
* - Not logged in → /login
|
||||
* - Logged in but workspace list not yet loaded → wait (don't bounce to /onboarding)
|
||||
* - Logged in but URL slug doesn't resolve to any workspace → /onboarding
|
||||
* - Logged in but workspace list not yet loaded → wait (don't bounce prematurely)
|
||||
* - Logged in but URL slug doesn't resolve to any workspace → /workspaces/new
|
||||
*
|
||||
* We read the workspace list query state directly (rather than relying on
|
||||
* useCurrentWorkspace's null return) so we can distinguish "list loading"
|
||||
* from "slug not found". Otherwise users could see a transient redirect
|
||||
* to /onboarding before their workspace list arrives.
|
||||
* to /workspaces/new before their workspace list arrives.
|
||||
*/
|
||||
export function useDashboardGuard() {
|
||||
const { pathname, replace } = useNavigation();
|
||||
@@ -41,7 +41,7 @@ export function useDashboardGuard() {
|
||||
// Wait for workspace list to settle before deciding "no workspace".
|
||||
if (!workspaceListFetched) return;
|
||||
if (!workspace) {
|
||||
replace(paths.onboarding());
|
||||
replace(paths.newWorkspace());
|
||||
}
|
||||
}, [user, isLoading, workspaceListFetched, workspace, replace]);
|
||||
|
||||
|
||||
352
packages/views/modals/create-project.tsx
Normal file
352
packages/views/modals/create-project.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCreateProject } from "@multica/core/projects/mutations";
|
||||
import {
|
||||
PROJECT_STATUS_CONFIG,
|
||||
PROJECT_STATUS_ORDER,
|
||||
PROJECT_PRIORITY_CONFIG,
|
||||
PROJECT_PRIORITY_ORDER,
|
||||
} from "@multica/core/projects/config";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
|
||||
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import type { ProjectStatus, ProjectPriority } from "@multica/core/types";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@multica/ui/components/ui/popover";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { EmojiPicker } from "@multica/ui/components/common/emoji-picker";
|
||||
import { ContentEditor, type ContentEditorRef, TitleEditor } from "../editor";
|
||||
import { PriorityIcon } from "../issues/components/priority-icon";
|
||||
import { ActorAvatar } from "../common/actor-avatar";
|
||||
import { useNavigation } from "../navigation";
|
||||
|
||||
function PillButton({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
|
||||
"hover:bg-accent/60 transition-colors cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
const router = useNavigation();
|
||||
const workspace = useCurrentWorkspace();
|
||||
const workspaceName = workspace?.name;
|
||||
const wsPaths = useWorkspacePaths();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { getActorName } = useActorName();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
const [status, setStatus] = useState<ProjectStatus>("planned");
|
||||
const [priority, setPriority] = useState<ProjectPriority>("none");
|
||||
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>();
|
||||
const [leadId, setLeadId] = useState<string | undefined>();
|
||||
const [icon, setIcon] = useState<string | undefined>();
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const [leadOpen, setLeadOpen] = useState(false);
|
||||
const [leadFilter, setLeadFilter] = useState("");
|
||||
|
||||
const leadQuery = leadFilter.toLowerCase();
|
||||
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery));
|
||||
const filteredAgents = agents.filter(
|
||||
(a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery),
|
||||
);
|
||||
|
||||
const leadLabel = leadType && leadId ? getActorName(leadType, leadId) : "Lead";
|
||||
|
||||
const createProject = useCreateProject();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const project = await createProject.mutateAsync({
|
||||
title: title.trim(),
|
||||
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
|
||||
icon,
|
||||
status,
|
||||
priority,
|
||||
lead_type: leadType,
|
||||
lead_id: leadId,
|
||||
});
|
||||
onClose();
|
||||
toast.success("Project created");
|
||||
router.push(wsPaths.projectDetail(project.id));
|
||||
} catch {
|
||||
toast.error("Failed to create project");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"p-0 gap-0 flex flex-col overflow-hidden",
|
||||
"!top-1/2 !left-1/2 !-translate-x-1/2",
|
||||
"!transition-all !duration-300 !ease-out",
|
||||
isExpanded
|
||||
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
|
||||
: "!max-w-2xl !w-full !h-96 !-translate-y-1/2",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">New Project</DialogTitle>
|
||||
|
||||
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{workspaceName}</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
<span className="font-medium">New project</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Close</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-2 shrink-0">
|
||||
<Popover open={iconPickerOpen} onOpenChange={setIconPickerOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-2xl cursor-pointer rounded-lg p-1 -ml-1 hover:bg-accent/60 transition-colors"
|
||||
title="Choose icon"
|
||||
>
|
||||
{icon || "📁"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-auto p-0">
|
||||
<EmojiPicker
|
||||
onSelect={(emoji) => {
|
||||
setIcon(emoji);
|
||||
setIconPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<TitleEditor
|
||||
autoFocus
|
||||
defaultValue=""
|
||||
placeholder="Project title"
|
||||
className="text-lg font-semibold"
|
||||
onChange={(v) => setTitle(v)}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-5">
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
defaultValue=""
|
||||
placeholder="Add description..."
|
||||
debounceMs={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<PillButton>
|
||||
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[status].dotColor)} />
|
||||
<span>{PROJECT_STATUS_CONFIG[status].label}</span>
|
||||
</PillButton>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
{PROJECT_STATUS_ORDER.map((s) => (
|
||||
<DropdownMenuItem key={s} onClick={() => setStatus(s)}>
|
||||
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
|
||||
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<PillButton>
|
||||
<PriorityIcon priority={priority} />
|
||||
<span>{PROJECT_PRIORITY_CONFIG[priority].label}</span>
|
||||
</PillButton>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
{PROJECT_PRIORITY_ORDER.map((pr) => (
|
||||
<DropdownMenuItem key={pr} onClick={() => setPriority(pr)}>
|
||||
<PriorityIcon priority={pr} />
|
||||
<span>{PROJECT_PRIORITY_CONFIG[pr].label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Popover
|
||||
open={leadOpen}
|
||||
onOpenChange={(v) => {
|
||||
setLeadOpen(v);
|
||||
if (!v) setLeadFilter("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<PillButton>
|
||||
{leadType && leadId ? (
|
||||
<>
|
||||
<ActorAvatar actorType={leadType} actorId={leadId} size={16} />
|
||||
<span>{leadLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Lead</span>
|
||||
)}
|
||||
</PillButton>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-52 p-0">
|
||||
<div className="px-2 py-1.5 border-b">
|
||||
<input
|
||||
type="text"
|
||||
value={leadFilter}
|
||||
onChange={(e) => setLeadFilter(e.target.value)}
|
||||
placeholder="Assign lead..."
|
||||
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1 max-h-60 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLeadType(undefined);
|
||||
setLeadId(undefined);
|
||||
setLeadOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">No lead</span>
|
||||
</button>
|
||||
{filteredMembers.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Members
|
||||
</div>
|
||||
{filteredMembers.map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
key={m.user_id}
|
||||
onClick={() => {
|
||||
setLeadType("member");
|
||||
setLeadId(m.user_id);
|
||||
setLeadOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
|
||||
<span>{m.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{filteredAgents.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Agents
|
||||
</div>
|
||||
{filteredAgents.map((a) => (
|
||||
<button
|
||||
type="button"
|
||||
key={a.id}
|
||||
onClick={() => {
|
||||
setLeadType("agent");
|
||||
setLeadId(a.id);
|
||||
setLeadOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
|
||||
<span>{a.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{filteredMembers.length === 0 &&
|
||||
filteredAgents.length === 0 &&
|
||||
leadFilter && (
|
||||
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
|
||||
No results
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create Project"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { useImmersiveMode } from "../platform";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -14,84 +10,15 @@ import {
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { useCreateWorkspace } from "@multica/core/workspace/mutations";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import {
|
||||
WORKSPACE_SLUG_CONFLICT_ERROR,
|
||||
WORKSPACE_SLUG_FORMAT_ERROR,
|
||||
WORKSPACE_SLUG_REGEX,
|
||||
isWorkspaceSlugConflict,
|
||||
nameToWorkspaceSlug,
|
||||
} from "../workspace/slug";
|
||||
import { CreateWorkspaceForm } from "../workspace/create-workspace-form";
|
||||
|
||||
export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
|
||||
// This modal is full-screen, so it covers the app titlebar. On macOS desktop
|
||||
// we hide the traffic lights for its lifetime so the Back button in the top-
|
||||
// left corner isn't stolen by the native controls' hit-test. No-op elsewhere.
|
||||
useImmersiveMode();
|
||||
|
||||
const router = useNavigation();
|
||||
const createWorkspace = useCreateWorkspace();
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [slugServerError, setSlugServerError] = useState<string | null>(null);
|
||||
const slugTouched = useRef(false);
|
||||
|
||||
const slugValidationError =
|
||||
slug.length > 0 && !WORKSPACE_SLUG_REGEX.test(slug)
|
||||
? WORKSPACE_SLUG_FORMAT_ERROR
|
||||
: null;
|
||||
const slugError = slugValidationError ?? slugServerError;
|
||||
|
||||
const canSubmit =
|
||||
name.trim().length > 0 && slug.trim().length > 0 && !slugError;
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (!slugTouched.current) {
|
||||
setSlug(nameToWorkspaceSlug(value));
|
||||
setSlugServerError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSlugChange = (value: string) => {
|
||||
slugTouched.current = true;
|
||||
setSlug(value);
|
||||
setSlugServerError(null);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!canSubmit) return;
|
||||
// The modal is only reachable from an authenticated workspace context
|
||||
// (via the global modal registry). After creating a new workspace the
|
||||
// user should land INSIDE it at its issues page, not in /onboarding —
|
||||
// onboarding exists only for users with zero workspaces. Navigation is the
|
||||
// only way to switch workspaces now (URL is the source of truth), so the
|
||||
// push below is sufficient — no imperative store writes needed.
|
||||
createWorkspace.mutate(
|
||||
{ name: name.trim(), slug: slug.trim() },
|
||||
{
|
||||
onSuccess: (newWs) => {
|
||||
onClose();
|
||||
// Navigate INTO the new workspace. The mutation's own onSuccess
|
||||
// (in core/workspace/mutations.ts) runs before this callback and
|
||||
// has already seeded the workspace list cache, so the destination
|
||||
// [workspaceSlug]/layout will resolve newWs.slug → workspace
|
||||
// synchronously without a loading flash.
|
||||
router.push(paths.workspace(newWs.slug).issues());
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isWorkspaceSlugConflict(error)) {
|
||||
setSlugServerError(WORKSPACE_SLUG_CONFLICT_ERROR);
|
||||
toast.error("Choose a different workspace URL");
|
||||
return;
|
||||
}
|
||||
toast.error("Failed to create workspace");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -135,49 +62,17 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
|
||||
projects and issues.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<Card className="w-full">
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Workspace Name</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Workspace"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Workspace URL</Label>
|
||||
<div className="flex items-center gap-0 rounded-md border bg-background focus-within:ring-2 focus-within:ring-ring">
|
||||
<span className="pl-3 text-sm text-muted-foreground select-none">
|
||||
multica.ai/
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => handleSlugChange(e.target.value)}
|
||||
placeholder="my-workspace"
|
||||
className="border-0 shadow-none focus-visible:ring-0"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
{slugError && (
|
||||
<p className="text-xs text-destructive">{slugError}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleCreate}
|
||||
disabled={createWorkspace.isPending || !canSubmit}
|
||||
>
|
||||
{createWorkspace.isPending ? "Creating..." : "Create workspace"}
|
||||
</Button>
|
||||
<CreateWorkspaceForm
|
||||
onSuccess={(newWs) => {
|
||||
onClose();
|
||||
// Navigate INTO the new workspace. The mutation's own onSuccess
|
||||
// (in core/workspace/mutations.ts) runs before this callback and
|
||||
// has already seeded the workspace list cache, so the destination
|
||||
// [workspaceSlug]/layout will resolve newWs.slug → workspace
|
||||
// synchronously without a loading flash.
|
||||
router.push(paths.workspace(newWs.slug).issues());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { CreateWorkspaceModal } from "./create-workspace";
|
||||
import { CreateIssueModal } from "./create-issue";
|
||||
import { CreateProjectModal } from "./create-project";
|
||||
|
||||
export function ModalRegistry() {
|
||||
const modal = useModalStore((s) => s.modal);
|
||||
@@ -14,6 +15,8 @@ export function ModalRegistry() {
|
||||
return <CreateWorkspaceModal onClose={close} />;
|
||||
case "create-issue":
|
||||
return <CreateIssueModal onClose={close} data={data} />;
|
||||
case "create-project":
|
||||
return <CreateProjectModal onClose={close} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export { OnboardingWizard } from "./onboarding-wizard";
|
||||
export type { OnboardingWizardProps } from "./onboarding-wizard";
|
||||
export { StepWorkspace } from "./step-workspace";
|
||||
@@ -1,131 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
type QueryClient as QC,
|
||||
} from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
|
||||
vi.mock("./step-workspace", () => ({
|
||||
StepWorkspace: ({ onNext }: { onNext: () => void }) => (
|
||||
<button type="button" onClick={onNext}>
|
||||
Finish workspace
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./step-runtime", () => ({
|
||||
StepRuntime: ({ wsId }: { wsId: string }) => (
|
||||
<div>Runtime step for {wsId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./step-agent", () => ({
|
||||
StepAgent: () => <div>Agent step</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./step-complete", () => ({
|
||||
StepComplete: () => <div>Complete step</div>,
|
||||
}));
|
||||
|
||||
// Stub the list query so the wizard reads whatever we seeded in the cache.
|
||||
// `listWorkspaces` returns a promise that never resolves so the seeded cache
|
||||
// data isn't overwritten by a background refetch during the test.
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listWorkspaces: vi.fn(() => new Promise(() => {})),
|
||||
},
|
||||
}));
|
||||
|
||||
import { OnboardingWizard } from "./onboarding-wizard";
|
||||
|
||||
function makeWorkspace(id: string, slug = id): Workspace {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
slug,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
} as Workspace;
|
||||
}
|
||||
|
||||
function renderWithCache(
|
||||
wsList: Workspace[],
|
||||
onComplete = vi.fn(),
|
||||
): { qc: QC } {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
render(<OnboardingWizard onComplete={onComplete} />, { wrapper });
|
||||
return { qc };
|
||||
}
|
||||
|
||||
describe("OnboardingWizard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("starts at workspace creation when no workspace exists", () => {
|
||||
renderWithCache([]);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Finish workspace" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("continues setup when a workspace already exists", () => {
|
||||
renderWithCache([makeWorkspace("ws-123")]);
|
||||
|
||||
expect(screen.getByText("Runtime step for ws-123")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("continues setup when the workspace becomes available after mount", async () => {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
qc.setQueryData(workspaceKeys.list(), []);
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
render(<OnboardingWizard onComplete={vi.fn()} />, { wrapper });
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Finish workspace" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Simulate useCreateWorkspace adding the new workspace to the list cache.
|
||||
qc.setQueryData(workspaceKeys.list(), [makeWorkspace("ws-456")]);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Runtime step for ws-456"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not skip runtime when workspace creation also advances step", () => {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
qc.setQueryData(workspaceKeys.list(), []);
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
render(<OnboardingWizard onComplete={vi.fn()} />, { wrapper });
|
||||
|
||||
// Mutation's onSuccess populates the cache first, then the step-workspace
|
||||
// mock calls onNext — we should land on step 1 (runtime), never step 2.
|
||||
qc.setQueryData(workspaceKeys.list(), [makeWorkspace("ws-789")]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Finish workspace" }));
|
||||
|
||||
expect(screen.getByText("Runtime step for ws-789")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Agent step")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import type { Agent, Workspace } from "@multica/core/types";
|
||||
import { StepWorkspace } from "./step-workspace";
|
||||
import { StepRuntime } from "./step-runtime";
|
||||
import { StepAgent } from "./step-agent";
|
||||
import { StepComplete } from "./step-complete";
|
||||
|
||||
const STEPS = [
|
||||
{ label: "Workspace" },
|
||||
{ label: "Runtime" },
|
||||
{ label: "Agent" },
|
||||
{ label: "Get Started" },
|
||||
] as const;
|
||||
|
||||
export interface OnboardingWizardProps {
|
||||
/**
|
||||
* Called when the user finishes the wizard. The just-configured workspace is
|
||||
* passed so the caller can navigate into it (/{slug}/issues). Onboarding is a
|
||||
* pre-workspace global route, so the URL has no slug while it runs.
|
||||
*/
|
||||
onComplete: (workspace: Workspace) => void;
|
||||
}
|
||||
|
||||
export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
||||
// Canonical source for workspace existence: the React Query list cache. The
|
||||
// onboarding route itself is global (no slug in URL), so useCurrentWorkspace
|
||||
// can't help here — we read the list directly. `useCreateWorkspace` adds the
|
||||
// new workspace to this cache in its onSuccess, so step 0 → step 1 happens
|
||||
// once the list query is populated.
|
||||
const { data: wsList = [] } = useQuery(workspaceListOptions());
|
||||
// A user arriving at /onboarding normally has 0 workspaces. After the first
|
||||
// step they have exactly one. In the rare case the list already has entries
|
||||
// (e.g. the user manually navigated to /onboarding), pick the most recent —
|
||||
// that's the one the onboarding flow should configure.
|
||||
const workspace: Workspace | null = wsList[wsList.length - 1] ?? null;
|
||||
const wsId = workspace?.id ?? null;
|
||||
|
||||
const [step, setStep] = useState(() => (workspace ? 1 : 0));
|
||||
const [createdAgent, setCreatedAgent] = useState<Agent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (step === 0 && wsId) {
|
||||
setStep(1);
|
||||
}
|
||||
}, [step, wsId]);
|
||||
|
||||
const startWorkspaceSetup = useCallback(() => setStep(1), []);
|
||||
|
||||
const next = useCallback(
|
||||
() => setStep((s) => Math.min(s + 1, STEPS.length - 1)),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col bg-background">
|
||||
{/* Progress bar */}
|
||||
<div className="flex items-center justify-center gap-2 px-6 pt-8">
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s.label} className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium transition-colors ${
|
||||
i <= step
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{i < step ? (
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : (
|
||||
i + 1
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
i <= step
|
||||
? "text-foreground font-medium"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div
|
||||
className={`h-px w-8 ${i < step ? "bg-primary" : "bg-border"}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="flex flex-1 items-center justify-center px-6 py-12">
|
||||
{step === 0 && <StepWorkspace onNext={startWorkspaceSetup} />}
|
||||
{step === 1 && wsId && (
|
||||
<StepRuntime wsId={wsId} onNext={next} />
|
||||
)}
|
||||
{step === 2 && wsId && (
|
||||
<StepAgent
|
||||
wsId={wsId}
|
||||
onNext={next}
|
||||
onAgentCreated={setCreatedAgent}
|
||||
/>
|
||||
)}
|
||||
{step === 3 && workspace && (
|
||||
<StepComplete
|
||||
wsId={workspace.id}
|
||||
agent={createdAgent}
|
||||
onEnter={() => onComplete(workspace)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,367 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronDown,
|
||||
Globe,
|
||||
Lock,
|
||||
AlertCircle,
|
||||
Crown,
|
||||
Code,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card } from "@multica/ui/components/ui/card";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import { api } from "@multica/core/api";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes/queries";
|
||||
import { ProviderLogo } from "../runtimes/components/provider-logo";
|
||||
import type {
|
||||
Agent,
|
||||
AgentVisibility,
|
||||
CreateAgentRequest,
|
||||
} from "@multica/core/types";
|
||||
|
||||
interface AgentTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
icon: typeof Crown;
|
||||
}
|
||||
|
||||
const AGENT_TEMPLATES: AgentTemplate[] = [
|
||||
{
|
||||
id: "master",
|
||||
name: "Master Agent",
|
||||
description: "Manages workspace, assigns tasks, and coordinates work",
|
||||
instructions:
|
||||
"You are a Master Agent for this workspace. Your role is to manage and coordinate tasks, triage incoming issues, and ensure work is distributed effectively across the team.",
|
||||
icon: Crown,
|
||||
},
|
||||
{
|
||||
id: "coding",
|
||||
name: "Coding Agent",
|
||||
description: "Checks out code, implements features, and submits PRs",
|
||||
instructions:
|
||||
"You are a Coding Agent. Your role is to check out code repositories, implement features and bug fixes based on issue descriptions, write tests, and submit pull requests.",
|
||||
icon: Code,
|
||||
},
|
||||
];
|
||||
|
||||
export function StepAgent({
|
||||
wsId,
|
||||
onNext,
|
||||
onAgentCreated,
|
||||
}: {
|
||||
wsId: string;
|
||||
onNext: () => void;
|
||||
onAgentCreated: (agent: Agent) => void;
|
||||
}) {
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
const hasRuntime = runtimes.length > 0;
|
||||
|
||||
// Template selection
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Form state — populated from template, editable
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState("");
|
||||
const [visibility, setVisibility] = useState<AgentVisibility>("workspace");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [runtimeOpen, setRuntimeOpen] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
// Auto-select first runtime
|
||||
useEffect(() => {
|
||||
if (!selectedRuntimeId && runtimes[0]) {
|
||||
setSelectedRuntimeId(runtimes[0].id);
|
||||
}
|
||||
}, [runtimes, selectedRuntimeId]);
|
||||
|
||||
const selectedRuntime =
|
||||
runtimes.find((r) => r.id === selectedRuntimeId) ?? null;
|
||||
|
||||
const handleSelectTemplate = (template: AgentTemplate) => {
|
||||
setSelectedTemplateId(template.id);
|
||||
setName(template.name);
|
||||
setDescription(template.description);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim() || !selectedRuntime) return;
|
||||
const template = AGENT_TEMPLATES.find((t) => t.id === selectedTemplateId);
|
||||
setCreating(true);
|
||||
try {
|
||||
const req: CreateAgentRequest = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
instructions: template?.instructions,
|
||||
runtime_id: selectedRuntime.id,
|
||||
visibility,
|
||||
};
|
||||
const agent = await api.createAgent(req);
|
||||
onAgentCreated(agent);
|
||||
onNext();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to create agent",
|
||||
);
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-lg flex-col items-center gap-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
Create Your First Agent
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Choose a template to get started, then customize your agent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* No runtime warning */}
|
||||
{!hasRuntime && (
|
||||
<div className="flex w-full items-start gap-2 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3 text-sm text-warning">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>
|
||||
No runtime connected. Go back to connect a runtime, or skip and set
|
||||
one up later.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template cards */}
|
||||
{!showForm && (
|
||||
<div className="grid w-full grid-cols-2 gap-4">
|
||||
{AGENT_TEMPLATES.map((template) => {
|
||||
const Icon = template.icon;
|
||||
return (
|
||||
<Card
|
||||
key={template.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelectTemplate(template);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer p-5 transition-all hover:border-foreground/20"
|
||||
>
|
||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="font-semibold">{template.name}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{template.description}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent configuration form */}
|
||||
{showForm && (
|
||||
<Card className="w-full p-5 space-y-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Agent Name</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Coding Agent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Description</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What does this agent do?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Runtime selector */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Runtime</Label>
|
||||
<Popover open={runtimeOpen} onOpenChange={setRuntimeOpen}>
|
||||
<PopoverTrigger
|
||||
disabled={!hasRuntime}
|
||||
className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{selectedRuntime ? (
|
||||
<ProviderLogo
|
||||
provider={selectedRuntime.provider}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-4 w-4 shrink-0 rounded-full bg-muted-foreground/30" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{selectedRuntime?.name ?? "No runtime available"}
|
||||
</span>
|
||||
{selectedRuntime?.runtime_mode === "cloud" && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
||||
Cloud
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{selectedRuntime
|
||||
? `${selectedRuntime.provider} · ${selectedRuntime.device_info}`
|
||||
: "Connect a runtime first"}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${runtimeOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--anchor-width)] max-h-60 overflow-y-auto p-1"
|
||||
>
|
||||
{runtimes.map((rt) => (
|
||||
<button
|
||||
key={rt.id}
|
||||
onClick={() => {
|
||||
setSelectedRuntimeId(rt.id);
|
||||
setRuntimeOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
rt.id === selectedRuntimeId
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<ProviderLogo
|
||||
provider={rt.provider}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{rt.name}</span>
|
||||
{rt.runtime_mode === "cloud" && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
||||
Cloud
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{rt.provider} · {rt.device_info}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
rt.status === "online"
|
||||
? "bg-success"
|
||||
: "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Visibility */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Visibility</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisibility("workspace")}
|
||||
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
|
||||
visibility === "workspace"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Workspace</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
All members can assign
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisibility("private")}
|
||||
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
|
||||
visibility === "private"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Private</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Only you can assign
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex w-full flex-col items-center gap-3">
|
||||
{showForm ? (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !name.trim() || !selectedRuntime}
|
||||
>
|
||||
{creating ? "Creating..." : "Create Agent"}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setSelectedTemplateId(null);
|
||||
}}
|
||||
className="text-sm text-muted-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
Back to templates
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
className="text-sm text-muted-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, ArrowRight, Loader2, Bot } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card } from "@multica/ui/components/ui/card";
|
||||
import { api } from "@multica/core/api";
|
||||
import type { Agent, Issue, CreateIssueRequest } from "@multica/core/types";
|
||||
|
||||
interface OnboardingIssueDef {
|
||||
title: string;
|
||||
description: string;
|
||||
/** If true, assigned to the agent with status "todo" so it gets picked up */
|
||||
assignToAgent: boolean;
|
||||
status: "todo" | "backlog";
|
||||
}
|
||||
|
||||
function getOnboardingIssues(): OnboardingIssueDef[] {
|
||||
return [
|
||||
{
|
||||
title: "Say hello to the team!",
|
||||
description: [
|
||||
"Welcome! This is your first automated task.",
|
||||
"",
|
||||
"Please introduce yourself to the team:",
|
||||
"- What's your name and role in this workspace?",
|
||||
"- What kinds of tasks can you help with?",
|
||||
"- Give 2–3 concrete examples of things the team can ask you to do",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"**Try it out!** After the agent responds, reply with one of these to see it in action:",
|
||||
'- "Review this function for bugs: `function add(a, b) { return a - b; }`"',
|
||||
'- "Draft a short description for a new onboarding feature"',
|
||||
'- "What are some best practices for writing clean commit messages?"',
|
||||
"",
|
||||
"This issue was automatically assigned to verify your agent is working end-to-end.",
|
||||
].join("\n"),
|
||||
assignToAgent: true,
|
||||
status: "todo",
|
||||
},
|
||||
{
|
||||
title: "Set up your repository connection",
|
||||
description: [
|
||||
"Connect a code repository so agents can check out code and submit pull requests.",
|
||||
"",
|
||||
"**Steps:**",
|
||||
"1. Go to **Settings** in the sidebar",
|
||||
"2. Under **Repositories**, add a Git repository URL",
|
||||
"3. The agent daemon will sync the repo locally",
|
||||
"",
|
||||
"Once connected, your agents can clone, branch, and push code as part of any task.",
|
||||
].join("\n"),
|
||||
assignToAgent: false,
|
||||
status: "backlog",
|
||||
},
|
||||
{
|
||||
title: "Create a skill for your agent",
|
||||
description: [
|
||||
"Skills are reusable instructions that make agents better at recurring tasks — deployments, code reviews, migrations, etc.",
|
||||
"",
|
||||
"**Note:** Skills already installed in your local runtime (e.g., `.claude/skills/`) are automatically available to agents — no need to re-upload them. Workspace skills here are for sharing knowledge across your team.",
|
||||
"",
|
||||
"**Steps:**",
|
||||
"1. Go to **Skills** in the sidebar",
|
||||
"2. Click **New Skill**",
|
||||
"3. Write a description and instructions (e.g., \"Code Review\" with your team's style guide)",
|
||||
"4. Assign the skill to an agent in the agent's settings",
|
||||
"",
|
||||
"Every skill you create compounds your team's capabilities over time.",
|
||||
].join("\n"),
|
||||
assignToAgent: false,
|
||||
status: "backlog",
|
||||
},
|
||||
{
|
||||
title: "Invite a teammate",
|
||||
description: [
|
||||
"Multica works best with a team. Invite a colleague to your workspace so you can collaborate on issues and share agents.",
|
||||
"",
|
||||
"**Steps:**",
|
||||
"1. Go to **Settings → Members**",
|
||||
"2. Click **Invite** and enter their email",
|
||||
"3. They'll get access to the workspace, all agents, and the issue board",
|
||||
].join("\n"),
|
||||
assignToAgent: false,
|
||||
status: "backlog",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function StepComplete({
|
||||
wsId,
|
||||
agent,
|
||||
onEnter,
|
||||
}: {
|
||||
wsId: string;
|
||||
agent: Agent | null;
|
||||
onEnter: () => void;
|
||||
}) {
|
||||
const [createdIssues, setCreatedIssues] = useState<Issue[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const didCreate = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (didCreate.current) return;
|
||||
didCreate.current = true;
|
||||
|
||||
async function createOnboardingIssues() {
|
||||
const defs = getOnboardingIssues();
|
||||
const issues: Issue[] = [];
|
||||
|
||||
for (const def of defs) {
|
||||
try {
|
||||
const req: CreateIssueRequest = {
|
||||
title: def.title,
|
||||
description: def.description,
|
||||
status: def.status,
|
||||
};
|
||||
if (def.assignToAgent && agent) {
|
||||
req.assignee_type = "agent";
|
||||
req.assignee_id = agent.id;
|
||||
}
|
||||
const issue = await api.createIssue(req);
|
||||
issues.push(issue);
|
||||
} catch {
|
||||
// Best-effort — continue with remaining issues
|
||||
}
|
||||
}
|
||||
|
||||
setCreatedIssues(issues);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
createOnboardingIssues();
|
||||
}, [agent, wsId]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-8">
|
||||
{/* Success icon */}
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-success/10">
|
||||
<Check className="h-8 w-8 text-success" />
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
You're all set!
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{agent
|
||||
? `Your workspace is ready and ${agent.name} is picking up its first task.`
|
||||
: "Your workspace is ready. Create issues and assign them to agents to get started."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Created issues */}
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Setting up your workspace...</span>
|
||||
</div>
|
||||
) : (
|
||||
createdIssues.length > 0 && (
|
||||
<Card className="w-full divide-y">
|
||||
{createdIssues.map((issue) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="flex items-center gap-3 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{issue.identifier} {issue.title}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{issue.assignee_id && agent
|
||||
? `Assigned to ${agent.name}`
|
||||
: issue.status === "todo"
|
||||
? "To do"
|
||||
: "Backlog"}
|
||||
</div>
|
||||
</div>
|
||||
{issue.assignee_id && agent && (
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-violet-100 dark:bg-violet-900/30">
|
||||
<Bot className="h-3.5 w-3.5 text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={onEnter}
|
||||
disabled={loading}
|
||||
>
|
||||
Go to Workspace
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, Terminal, Loader2 } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { useWSEvent } from "@multica/core/realtime";
|
||||
import { api } from "@multica/core/api";
|
||||
import { ProviderLogo } from "../runtimes/components/provider-logo";
|
||||
import {
|
||||
runtimeListOptions,
|
||||
runtimeKeys,
|
||||
} from "@multica/core/runtimes/queries";
|
||||
|
||||
const CLOUD_HOST = "multica.ai";
|
||||
|
||||
const INSTALL_STEP = {
|
||||
label: "Install the Multica CLI",
|
||||
cmd: "curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash",
|
||||
};
|
||||
|
||||
function isCloudEnvironment(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
return window.location.hostname.endsWith(CLOUD_HOST);
|
||||
}
|
||||
|
||||
function buildSetupCommand(): string {
|
||||
if (isCloudEnvironment()) return "multica setup";
|
||||
|
||||
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const apiBaseUrl = api.getBaseUrl?.() ?? "";
|
||||
const serverUrl = apiBaseUrl || appUrl;
|
||||
|
||||
if (!serverUrl || serverUrl === "http://localhost:8080") {
|
||||
// Default self-host — no flags needed
|
||||
return "multica setup self-host";
|
||||
}
|
||||
|
||||
const parts = ["multica setup self-host"];
|
||||
parts.push(`--server-url ${serverUrl}`);
|
||||
if (appUrl && appUrl !== serverUrl) {
|
||||
parts.push(`--app-url ${appUrl}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-success" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepRuntime({
|
||||
wsId,
|
||||
onNext,
|
||||
}: {
|
||||
wsId: string;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const setupSteps = useMemo(
|
||||
() => [
|
||||
INSTALL_STEP,
|
||||
{ label: "Set up and start the daemon", cmd: buildSetupCommand() },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
|
||||
const handleDaemonEvent = useCallback(() => {
|
||||
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
}, [qc, wsId]);
|
||||
|
||||
useWSEvent("daemon:register", handleDaemonEvent);
|
||||
|
||||
const hasRuntimes = runtimes.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-xl flex-col items-center gap-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
Connect a Runtime
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Install the CLI and run the setup command below to connect your
|
||||
machine. The daemon auto-detects agent CLIs (Claude Code, Codex,
|
||||
etc.) on your PATH.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Commands */}
|
||||
<Card className="w-full">
|
||||
<CardContent className="space-y-3 pt-4">
|
||||
{setupSteps.map((step, i) => (
|
||||
<div key={i}>
|
||||
<p className="mb-1.5 text-xs text-muted-foreground">
|
||||
{i + 1}. {step.label}
|
||||
</p>
|
||||
<div className="flex items-start gap-2 rounded-lg bg-muted px-3 py-2.5 font-mono text-sm">
|
||||
<Terminal className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<code className="min-w-0 flex-1 break-all whitespace-pre-wrap">
|
||||
{step.cmd}
|
||||
</code>
|
||||
<CopyButton text={step.cmd} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="pt-1 text-xs text-muted-foreground">
|
||||
The setup command handles authentication, configuration, and daemon
|
||||
startup — all in one step.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Connected runtimes */}
|
||||
<div className="w-full space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{hasRuntimes ? (
|
||||
<>
|
||||
<div className="h-2 w-2 rounded-full bg-success" />
|
||||
<span className="font-medium">
|
||||
{runtimes.length} runtime{runtimes.length > 1 ? "s" : ""}{" "}
|
||||
connected
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Waiting for connection...
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasRuntimes && (
|
||||
<Card className="w-full">
|
||||
<CardContent className="divide-y pt-0">
|
||||
{runtimes.map((rt) => (
|
||||
<div
|
||||
key={rt.id}
|
||||
className="flex items-center gap-3 py-3 first:pt-4 last:pb-4"
|
||||
>
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
rt.status === "online"
|
||||
? "bg-success"
|
||||
: "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{rt.name}
|
||||
</span>
|
||||
{rt.runtime_mode === "cloud" && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
||||
Cloud
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{rt.provider} · {rt.device_info}
|
||||
</div>
|
||||
</div>
|
||||
<ProviderLogo
|
||||
provider={rt.provider}
|
||||
className="h-5 w-5 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<Button className="w-full" size="lg" onClick={onNext}>
|
||||
{hasRuntimes ? "Continue" : "Skip for now"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it, beforeEach, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const mockCreateWorkspaceMutate = vi.hoisted(() => vi.fn());
|
||||
const mockToastError = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@multica/core/workspace/mutations", () => ({
|
||||
useCreateWorkspace: () => ({
|
||||
mutate: mockCreateWorkspaceMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: mockToastError,
|
||||
},
|
||||
}));
|
||||
|
||||
import { StepWorkspace } from "./step-workspace";
|
||||
|
||||
describe("StepWorkspace", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("asks the user to change the slug on conflict", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockCreateWorkspaceMutate.mockImplementation(
|
||||
(
|
||||
_data: unknown,
|
||||
options: { onError: (error: unknown) => void },
|
||||
) => {
|
||||
options.onError({ status: 409 });
|
||||
},
|
||||
);
|
||||
|
||||
render(<StepWorkspace onNext={vi.fn()} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("My Team"), "My Team");
|
||||
await user.click(screen.getByRole("button", { name: "Create Workspace" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("That workspace URL is already taken."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
"Choose a different workspace URL",
|
||||
);
|
||||
expect(mockCreateWorkspaceMutate).toHaveBeenCalledWith(
|
||||
{ name: "My Team", slug: "my-team" },
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -28,12 +28,15 @@
|
||||
"./inbox": "./inbox/index.ts",
|
||||
"./runtimes": "./runtimes/index.ts",
|
||||
"./workspace/workspace-avatar": "./workspace/workspace-avatar.tsx",
|
||||
"./workspace/create-workspace-form": "./workspace/create-workspace-form.tsx",
|
||||
"./workspace/no-access-page": "./workspace/no-access-page.tsx",
|
||||
"./workspace/new-workspace-page": "./workspace/new-workspace-page.tsx",
|
||||
"./workspace/use-workspace-seen": "./workspace/use-workspace-seen.ts",
|
||||
"./layout": "./layout/index.ts",
|
||||
"./auth": "./auth/index.ts",
|
||||
"./search": "./search/index.ts",
|
||||
"./chat": "./chat/index.ts",
|
||||
"./settings": "./settings/index.ts",
|
||||
"./onboarding": "./onboarding/index.ts",
|
||||
"./invite": "./invite/index.ts",
|
||||
"./platform": "./platform/index.ts"
|
||||
},
|
||||
|
||||
@@ -13,9 +13,9 @@ function getDesktopAPI(): ImmersiveCapableAPI | undefined {
|
||||
* Enter "immersive" mode for the lifetime of the component that calls it.
|
||||
*
|
||||
* On macOS desktop this hides the traffic-light window controls so full-screen
|
||||
* modals (create-workspace, onboarding, etc.) can place UI in the top-left
|
||||
* corner without fighting the native controls' hit-test. On web or non-macOS
|
||||
* desktop this is a no-op.
|
||||
* modals (e.g. create-workspace) can place UI in the top-left corner without
|
||||
* fighting the native controls' hit-test. On web or non-macOS desktop this
|
||||
* is a no-op.
|
||||
*/
|
||||
export function useImmersiveMode(): void {
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,44 @@ function HermesLogo({ className }: { className: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Pi (pi.dev) — official pixel-art "pi" wordmark, sourced from pi.dev/logo.svg
|
||||
function PiLogo({ className }: { className: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 800 800" fill="none" className={className}>
|
||||
<rect width="800" height="800" rx="150" fill="#09090b" />
|
||||
<path
|
||||
fill="#fff"
|
||||
fillRule="evenodd"
|
||||
d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"
|
||||
/>
|
||||
<path fill="#fff" d="M517.36 400H634.72V634.72H517.36Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// GitHub Copilot — GitHub mark (Invertocat)
|
||||
function CopilotLogo({ className }: { className: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<path d="M12 1C5.9225 1 1 5.9225 1 12C1 16.8675 4.14875 20.9787 8.52125 22.4362C9.07125 22.5325 9.2775 22.2025 9.2775 21.9137C9.2775 21.6525 9.26375 20.7862 9.26375 19.865C6.5 20.3737 5.785 19.1912 5.565 18.5725C5.44125 18.2562 4.905 17.28 4.4375 17.0187C4.0525 16.8125 3.5025 16.3037 4.42375 16.29C5.29 16.2762 5.90875 17.0875 6.115 17.4175C7.105 19.0812 8.68625 18.6137 9.31875 18.325C9.415 17.61 9.70375 17.1287 10.02 16.8537C7.5725 16.5787 5.015 15.63 5.015 11.4225C5.015 10.2262 5.44125 9.23625 6.1425 8.46625C6.0325 8.19125 5.6475 7.06375 6.2525 5.55125C6.2525 5.55125 7.17375 5.2625 9.2775 6.67875C10.1575 6.43125 11.0925 6.3075 12.0275 6.3075C12.9625 6.3075 13.8975 6.43125 14.7775 6.67875C16.8813 5.24875 17.8025 5.55125 17.8025 5.55125C18.4075 7.06375 18.0225 8.19125 17.9125 8.46625C18.6138 9.23625 19.04 10.2125 19.04 11.4225C19.04 15.6437 16.4688 16.5787 14.0213 16.8537C14.42 17.1975 14.7638 17.8575 14.7638 18.8887C14.7638 20.36 14.75 21.5425 14.75 21.9137C14.75 22.2025 14.9563 22.5462 15.5063 22.4362C19.8513 20.9787 23 16.8537 23 12C23 5.9225 18.0775 1 12 1Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Cursor — official brand logo from Cursor brand assets
|
||||
function CursorLogo({ className }: { className: string }) {
|
||||
return (
|
||||
<svg viewBox="600 300 400 400" fill="none" className={className}>
|
||||
<path fill="#14120B" d="M999.994 554.294C999.994 559.859 999.994 565.419 999.962 570.984C999.935 575.67 999.882 580.357 999.753 585.038C999.475 595.247 998.875 605.542 997.059 615.639C995.217 625.88 992.212 635.409 987.477 644.718C982.822 653.861 976.738 662.233 969.485 669.491C962.227 676.748 953.861 682.828 944.712 687.482C935.409 692.217 925.875 695.222 915.633 697.065C905.537 698.88 895.242 699.48 885.033 699.759C880.346 699.887 875.665 699.941 870.978 699.968C865.413 700.005 859.853 700 854.288 700H745.695C740.13 700 734.571 700 729.005 699.968C724.319 699.941 719.632 699.887 714.951 699.759C704.742 699.48 694.447 698.88 684.35 697.065C674.109 695.222 664.58 692.217 655.271 687.482C646.128 682.828 637.756 676.743 630.499 669.491C623.241 662.233 617.161 653.866 612.507 644.718C607.772 635.414 604.767 625.88 602.925 615.639C601.109 605.542 600.509 595.247 600.23 585.038C600.102 580.352 600.048 575.67 600.021 570.984C600 565.419 600 559.859 600 554.294V445.701C600 440.136 600 434.576 600.032 429.011C600.059 424.324 600.112 419.637 600.241 414.956C600.52 404.747 601.119 394.452 602.935 384.356C604.778 374.115 607.783 364.586 612.518 355.277C617.172 346.133 623.257 337.762 630.509 330.504C637.767 323.246 646.133 317.167 655.282 312.512C664.586 307.777 674.12 304.772 684.361 302.93C694.458 301.114 704.752 300.514 714.961 300.236C719.648 300.107 724.329 300.054 729.016 300.027C734.576 300 740.136 300 745.701 300H854.294C859.859 300 865.419 300 870.984 300.032C875.67 300.059 880.357 300.112 885.038 300.241C895.247 300.52 905.542 301.119 915.639 302.935C925.88 304.778 935.409 307.783 944.718 312.518C953.861 317.172 962.233 323.257 969.491 330.509C976.748 337.767 982.828 346.133 987.482 355.282C992.217 364.586 995.222 374.12 997.065 384.361C998.88 394.458 999.48 404.752 999.759 414.961C999.887 419.648 999.941 424.329 999.968 429.016C1000.01 434.581 1000 440.141 1000 445.706V554.299L999.994 554.294Z"/>
|
||||
<path fill="#72716D" d="M800.004 500L923.821 571.486C923.061 572.804 921.957 573.929 920.591 574.716L804.863 641.531C801.858 643.266 798.151 643.266 795.146 641.531L679.417 574.716C678.052 573.929 676.948 572.804 676.188 571.486L800.004 500Z"/>
|
||||
<path fill="#55544F" d="M800.005 357.168V500L676.188 571.486C675.427 570.168 675.004 568.647 675.004 567.072V432.928C675.004 429.774 676.686 426.865 679.418 425.285L795.141 358.47C796.646 357.602 798.323 357.168 799.999 357.168H800.005Z"/>
|
||||
<path fill="#43413C" d="M923.815 428.515C923.055 427.197 921.951 426.072 920.586 425.285L804.857 358.47C803.357 357.602 801.68 357.168 800.004 357.168V500L923.821 571.486C924.581 570.168 925.005 568.647 925.005 567.072V432.928C925.005 431.348 924.587 429.838 923.821 428.515H923.815Z"/>
|
||||
<path fill="#D6D5D2" d="M915.156 433.518C915.857 434.728 915.954 436.281 915.156 437.663L802.764 632.323C802.008 633.641 800 633.1 800 631.584V503.311C800 502.287 799.727 501.302 799.229 500.44L915.15 433.512H915.156V433.518Z"/>
|
||||
<path fill="white" d="M915.155 433.518L799.233 500.445C798.741 499.588 798.023 498.86 797.134 498.345L686.049 434.209C684.731 433.453 685.272 431.445 686.788 431.445H911.566C913.162 431.445 914.459 432.307 915.155 433.518Z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderLogo({
|
||||
provider,
|
||||
className = "h-4 w-4",
|
||||
@@ -91,6 +129,12 @@ export function ProviderLogo({
|
||||
return <OpenClawLogo className={className} />;
|
||||
case "hermes":
|
||||
return <HermesLogo className={className} />;
|
||||
case "pi":
|
||||
return <PiLogo className={className} />;
|
||||
case "copilot":
|
||||
return <CopilotLogo className={className} />;
|
||||
case "cursor":
|
||||
return <CursorLogo className={className} />;
|
||||
default:
|
||||
return <Monitor className={className} />;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,40 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SearchCommand } from "./search-command";
|
||||
import { useSearchStore } from "./search-store";
|
||||
|
||||
const { mockPush, mockSearchIssues, mockSearchProjects, mockRecentItems, mockAllIssues } = vi.hoisted(() => ({
|
||||
const {
|
||||
mockPush,
|
||||
mockSearchIssues,
|
||||
mockSearchProjects,
|
||||
mockRecentItems,
|
||||
mockAllIssues,
|
||||
mockSetTheme,
|
||||
mockTheme,
|
||||
mockPathname,
|
||||
mockGetShareableUrl,
|
||||
mockWorkspaces,
|
||||
mockCurrentWorkspace,
|
||||
mockOpenModal,
|
||||
mockToastSuccess,
|
||||
mockClipboardWrite,
|
||||
} = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockSearchIssues: vi.fn(),
|
||||
mockSearchProjects: vi.fn(),
|
||||
mockRecentItems: { current: [] as Array<{ id: string; visitedAt: number }> },
|
||||
mockAllIssues: { current: [] as Array<Record<string, unknown>> },
|
||||
mockSetTheme: vi.fn(),
|
||||
mockTheme: { current: "system" as "light" | "dark" | "system" },
|
||||
mockPathname: { current: "/ws-test/issues" as string },
|
||||
mockGetShareableUrl: vi.fn((p: string) => `https://app.multica/${p}`),
|
||||
mockWorkspaces: {
|
||||
current: [] as Array<{ id: string; name: string; slug: string }>,
|
||||
},
|
||||
mockCurrentWorkspace: {
|
||||
current: null as { id: string; name: string; slug: string } | null,
|
||||
},
|
||||
mockOpenModal: vi.fn(),
|
||||
mockToastSuccess: vi.fn(),
|
||||
mockClipboardWrite: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
@@ -32,6 +60,12 @@ vi.mock("@multica/core", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/paths", () => ({
|
||||
paths: {
|
||||
workspace: (slug: string) => ({
|
||||
issues: () => `/${slug}/issues`,
|
||||
}),
|
||||
},
|
||||
useCurrentWorkspace: () => mockCurrentWorkspace.current,
|
||||
useWorkspacePaths: () => ({
|
||||
inbox: () => "/ws-test/inbox",
|
||||
myIssues: () => "/ws-test/my-issues",
|
||||
@@ -50,16 +84,40 @@ vi.mock("@multica/core/issues/queries", () => ({
|
||||
issueListOptions: () => ({ queryKey: ["issues", "ws-test", "list"], enabled: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
workspaceListOptions: () => ({ queryKey: ["workspaces", "list"], enabled: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/modals", () => ({
|
||||
useModalStore: Object.assign(vi.fn(), {
|
||||
getState: () => ({ open: mockOpenModal }),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: mockAllIssues.current }),
|
||||
useQuery: (opts: { queryKey: readonly unknown[] }) => {
|
||||
const key = opts.queryKey;
|
||||
if (key[0] === "workspaces") return { data: mockWorkspaces.current };
|
||||
return { data: mockAllIssues.current };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../navigation", () => ({
|
||||
useNavigation: () => ({
|
||||
push: mockPush,
|
||||
pathname: mockPathname.current,
|
||||
getShareableUrl: mockGetShareableUrl,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/common/theme-provider", () => ({
|
||||
useTheme: () => ({ theme: mockTheme.current, setTheme: mockSetTheme }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: mockToastSuccess, error: vi.fn() },
|
||||
}));
|
||||
|
||||
describe("SearchCommand", () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockReset();
|
||||
@@ -67,6 +125,15 @@ describe("SearchCommand", () => {
|
||||
mockSearchProjects.mockReset().mockResolvedValue({ projects: [] });
|
||||
mockRecentItems.current = [];
|
||||
mockAllIssues.current = [];
|
||||
mockSetTheme.mockReset();
|
||||
mockTheme.current = "system";
|
||||
mockPathname.current = "/ws-test/issues";
|
||||
mockGetShareableUrl.mockReset().mockImplementation((p: string) => `https://app.multica/${p}`);
|
||||
mockWorkspaces.current = [];
|
||||
mockCurrentWorkspace.current = null;
|
||||
mockOpenModal.mockReset();
|
||||
mockToastSuccess.mockReset();
|
||||
mockClipboardWrite.mockReset().mockResolvedValue(undefined);
|
||||
|
||||
// cmdk calls scrollIntoView on the first selected item, which jsdom doesn't implement
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
@@ -94,10 +161,21 @@ describe("SearchCommand", () => {
|
||||
expect(screen.queryByPlaceholderText("Type a command or search...")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show pages when no query is entered", () => {
|
||||
it("shows only New Issue by default and hides Pages / Switch Workspace / low-frequency commands until query", () => {
|
||||
render(<SearchCommand />);
|
||||
|
||||
expect(screen.queryByText("Pages")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Switch Workspace")).not.toBeInTheDocument();
|
||||
// Only the primary creation action surfaces on empty query; everything
|
||||
// else (theme, copy, New Project) must be revealed by typing.
|
||||
expect(screen.getByText("Commands")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "New Issue" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("New Project")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Switch to Light Theme")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Switch to Dark Theme")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Use System Theme")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters navigation pages by query", async () => {
|
||||
@@ -112,7 +190,6 @@ describe("SearchCommand", () => {
|
||||
expect(screen.getByText((_, el) => el?.textContent === "Settings" && el?.tagName === "SPAN")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Inbox")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Projects")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates to page on selection", async () => {
|
||||
@@ -148,6 +225,198 @@ describe("SearchCommand", () => {
|
||||
expect(screen.getByText("MUL-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows New Issue / New Project under Commands and triggers the modal store", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SearchCommand />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "new");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Commands")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "New Issue" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "New Project" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const newIssue = await screen.findByText(
|
||||
(_, el) => el?.textContent === "New Issue" && el?.tagName === "SPAN",
|
||||
);
|
||||
await user.click(newIssue);
|
||||
|
||||
expect(mockOpenModal).toHaveBeenCalledWith("create-issue");
|
||||
expect(useSearchStore.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it("hides copy-link commands when not on an issue detail route", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockPathname.current = "/ws-test/projects";
|
||||
render(<SearchCommand />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "copy");
|
||||
|
||||
// Commands section may still be empty / absent.
|
||||
expect(screen.queryByText("Copy Issue Link")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("copies issue link and identifier when on an issue detail route", async () => {
|
||||
const user = userEvent.setup();
|
||||
// userEvent.setup() installs its own navigator.clipboard; spy on it so we
|
||||
// intercept the writeText call without clobbering userEvent's internals.
|
||||
const writeSpy = vi
|
||||
.spyOn(navigator.clipboard, "writeText")
|
||||
.mockImplementation(mockClipboardWrite);
|
||||
mockPathname.current = "/ws-test/issues/issue-1";
|
||||
mockAllIssues.current = [
|
||||
{ id: "issue-1", identifier: "MUL-42", title: "Demo", status: "todo" },
|
||||
];
|
||||
render(<SearchCommand />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "copy");
|
||||
|
||||
const linkItem = await screen.findByText(
|
||||
(_, el) => el?.textContent === "Copy Issue Link" && el?.tagName === "SPAN",
|
||||
);
|
||||
await user.click(linkItem);
|
||||
|
||||
expect(mockGetShareableUrl).toHaveBeenCalledWith("/ws-test/issues/issue-1");
|
||||
expect(mockClipboardWrite).toHaveBeenCalledWith("https://app.multica//ws-test/issues/issue-1");
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith("Link copied");
|
||||
|
||||
// Reopen palette and test identifier copy
|
||||
act(() => {
|
||||
useSearchStore.setState({ open: true });
|
||||
});
|
||||
const input2 = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input2, "copy");
|
||||
const idItem = await screen.findByText(
|
||||
(_, el) =>
|
||||
el?.textContent === "Copy Identifier (MUL-42)" && el?.tagName === "SPAN",
|
||||
);
|
||||
await user.click(idItem);
|
||||
expect(mockClipboardWrite).toHaveBeenCalledWith("MUL-42");
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith("Copied MUL-42");
|
||||
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("filters theme commands by query keywords", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SearchCommand />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "dark");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Commands")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "Switch to Dark Theme" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Switch to Light Theme")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Use System Theme")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies the selected theme and closes the palette", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockTheme.current = "light";
|
||||
render(<SearchCommand />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "dark");
|
||||
|
||||
const darkItem = await screen.findByText(
|
||||
(_, el) => el?.textContent === "Switch to Dark Theme" && el?.tagName === "SPAN",
|
||||
);
|
||||
await user.click(darkItem);
|
||||
|
||||
expect(mockSetTheme).toHaveBeenCalledWith("dark");
|
||||
expect(useSearchStore.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it("matches theme action via generic 'theme' keyword and marks current theme", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockTheme.current = "dark";
|
||||
render(<SearchCommand />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "theme");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "Switch to Light Theme" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "Switch to Dark Theme" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "Use System Theme" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByLabelText("Current theme")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists other workspaces under Switch Workspace and navigates on select", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockCurrentWorkspace.current = { id: "ws-current", name: "Current", slug: "current" };
|
||||
mockWorkspaces.current = [
|
||||
{ id: "ws-current", name: "Current", slug: "current" },
|
||||
{ id: "ws-alpha", name: "Alpha Co", slug: "alpha" },
|
||||
{ id: "ws-beta", name: "Beta Co", slug: "beta" },
|
||||
];
|
||||
render(<SearchCommand />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "alpha");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Switch Workspace")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "Alpha Co" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Beta Co")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Current")).not.toBeInTheDocument();
|
||||
|
||||
const alphaItem = await screen.findByText(
|
||||
(_, el) => el?.textContent === "Alpha Co" && el?.tagName === "SPAN",
|
||||
);
|
||||
await user.click(alphaItem);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/alpha/issues");
|
||||
expect(useSearchStore.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it("shows all other workspaces when typing 'workspace'", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockCurrentWorkspace.current = { id: "ws-current", name: "Current", slug: "current" };
|
||||
mockWorkspaces.current = [
|
||||
{ id: "ws-current", name: "Current", slug: "current" },
|
||||
{ id: "ws-alpha", name: "Alpha Co", slug: "alpha" },
|
||||
{ id: "ws-beta", name: "Beta Co", slug: "beta" },
|
||||
];
|
||||
render(<SearchCommand />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type a command or search...");
|
||||
await user.type(input, "workspace");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Switch Workspace")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "Alpha Co" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "Beta Co" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Current")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters out recent items not present in query cache", () => {
|
||||
mockRecentItems.current = [
|
||||
{ id: "issue-1", visitedAt: 1000 },
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
Clock,
|
||||
Copy,
|
||||
Link2,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
SearchIcon,
|
||||
Inbox,
|
||||
CircleUser,
|
||||
@@ -12,19 +16,25 @@ import {
|
||||
FolderKanban,
|
||||
Bot,
|
||||
Monitor,
|
||||
Moon,
|
||||
Sun,
|
||||
BookOpenText,
|
||||
Settings,
|
||||
Building2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import type { SearchIssueResult, SearchProjectResult } from "@multica/core/types";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useRecentIssuesStore } from "@multica/core/issues/stores";
|
||||
import { issueListOptions } from "@multica/core/issues/queries";
|
||||
import { useWorkspaceId } from "@multica/core";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { paths, useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
|
||||
import type { WorkspacePaths } from "@multica/core/paths";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { StatusIcon } from "../issues/components";
|
||||
import { STATUS_CONFIG } from "@multica/core/issues/config";
|
||||
import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
|
||||
@@ -36,6 +46,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { useTheme } from "@multica/ui/components/common/theme-provider";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { useSearchStore } from "./search-store";
|
||||
|
||||
@@ -106,19 +117,33 @@ const navPages: NavPage[] = [
|
||||
{ key: "settings", label: "Settings", icon: Settings, keywords: ["settings", "config", "preferences"] },
|
||||
];
|
||||
|
||||
type ThemeValue = "light" | "dark" | "system";
|
||||
|
||||
interface CommandItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
keywords: string[];
|
||||
trailing?: React.ReactNode;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
interface SearchResults {
|
||||
issues: SearchIssueResult[];
|
||||
projects: SearchProjectResult[];
|
||||
}
|
||||
|
||||
export function SearchCommand() {
|
||||
const { push } = useNavigation();
|
||||
const { push, pathname, getShareableUrl } = useNavigation();
|
||||
const open = useSearchStore((s) => s.open);
|
||||
const setOpen = useSearchStore((s) => s.setOpen);
|
||||
const recentItems = useRecentIssuesStore((s) => s.items);
|
||||
const wsId = useWorkspaceId();
|
||||
const p: WorkspacePaths = useWorkspacePaths();
|
||||
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
|
||||
const { theme, setTheme } = useTheme();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const { data: workspaces = [] } = useQuery(workspaceListOptions());
|
||||
|
||||
const recentIssues = useMemo(() => {
|
||||
const issueMap = new Map(allIssues.map((i) => [i.id, i]));
|
||||
@@ -144,6 +169,145 @@ export function SearchCommand() {
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
// Detect if current route is an issue detail page — /{slug}/issues/{id}.
|
||||
// Falls back to null on any other route; used to gate issue-specific commands.
|
||||
const currentIssue = useMemo(() => {
|
||||
const match = pathname.match(/\/issues\/([^/]+)$/);
|
||||
const raw = match?.[1];
|
||||
if (!raw) return null;
|
||||
const id = decodeURIComponent(raw);
|
||||
return allIssues.find((i) => i.id === id) ?? null;
|
||||
}, [pathname, allIssues]);
|
||||
|
||||
const commands = useMemo<CommandItem[]>(() => {
|
||||
const activeThemeCheck = (value: ThemeValue) =>
|
||||
theme === value ? (
|
||||
<Check
|
||||
aria-label="Current theme"
|
||||
className="ml-auto size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
const items: CommandItem[] = [
|
||||
{
|
||||
key: "new-issue",
|
||||
label: "New Issue",
|
||||
icon: Plus,
|
||||
keywords: ["new", "issue", "create", "add"],
|
||||
onSelect: () => {
|
||||
useModalStore.getState().open("create-issue");
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "new-project",
|
||||
label: "New Project",
|
||||
icon: Plus,
|
||||
keywords: ["new", "project", "create", "add"],
|
||||
onSelect: () => {
|
||||
useModalStore.getState().open("create-project");
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (currentIssue) {
|
||||
const identifier = currentIssue.identifier;
|
||||
items.push(
|
||||
{
|
||||
key: "copy-issue-link",
|
||||
label: "Copy Issue Link",
|
||||
icon: Link2,
|
||||
keywords: ["copy", "link", "share", "url", identifier.toLowerCase()],
|
||||
onSelect: () => {
|
||||
const url = getShareableUrl ? getShareableUrl(pathname) : window.location.href;
|
||||
void navigator.clipboard.writeText(url);
|
||||
toast.success("Link copied");
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "copy-issue-identifier",
|
||||
label: `Copy Identifier (${identifier})`,
|
||||
icon: Copy,
|
||||
keywords: ["copy", "id", "identifier", identifier.toLowerCase()],
|
||||
onSelect: () => {
|
||||
void navigator.clipboard.writeText(identifier);
|
||||
toast.success(`Copied ${identifier}`);
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
items.push(
|
||||
{
|
||||
key: "theme-light",
|
||||
label: "Switch to Light Theme",
|
||||
icon: Sun,
|
||||
keywords: ["light", "theme", "appearance", "mode", "bright"],
|
||||
trailing: activeThemeCheck("light"),
|
||||
onSelect: () => {
|
||||
setTheme("light");
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "theme-dark",
|
||||
label: "Switch to Dark Theme",
|
||||
icon: Moon,
|
||||
keywords: ["dark", "theme", "appearance", "mode", "night"],
|
||||
trailing: activeThemeCheck("dark"),
|
||||
onSelect: () => {
|
||||
setTheme("dark");
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "theme-system",
|
||||
label: "Use System Theme",
|
||||
icon: Monitor,
|
||||
keywords: ["system", "theme", "appearance", "mode", "auto"],
|
||||
trailing: activeThemeCheck("system"),
|
||||
onSelect: () => {
|
||||
setTheme("system");
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return items;
|
||||
}, [currentIssue, getShareableUrl, pathname, setOpen, setTheme, theme]);
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
// No query: only surface the primary creation action. Other commands
|
||||
// (theme switches, copy actions, New Project) are revealed as the user
|
||||
// types, leaving the empty-state space to Recent.
|
||||
if (!q) return commands.filter((c) => c.key === "new-issue");
|
||||
return commands.filter(
|
||||
(c) =>
|
||||
c.label.toLowerCase().includes(q) ||
|
||||
c.keywords.some((kw) => kw.includes(q)),
|
||||
);
|
||||
}, [commands, query]);
|
||||
|
||||
// Only show workspaces different from the current one, and only after the
|
||||
// user types >=2 chars — one char would match everything (e.g. "w").
|
||||
const filteredWorkspaces = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const others = workspaces.filter((w) => w.id !== currentWorkspace?.id);
|
||||
const wantsAll =
|
||||
q.length >= 2 && ("workspace".startsWith(q) || "switch".startsWith(q));
|
||||
return others.filter(
|
||||
(w) =>
|
||||
wantsAll ||
|
||||
w.name.toLowerCase().includes(q) ||
|
||||
w.slug.toLowerCase().includes(q),
|
||||
);
|
||||
}, [workspaces, currentWorkspace?.id, query]);
|
||||
|
||||
const hasResults = results.issues.length > 0 || results.projects.length > 0;
|
||||
|
||||
// Global Cmd+K / Ctrl+K shortcut
|
||||
@@ -262,6 +426,14 @@ export function SearchCommand() {
|
||||
[push, setOpen, p],
|
||||
);
|
||||
|
||||
const handleSwitchWorkspace = useCallback(
|
||||
(slug: string) => {
|
||||
push(paths.workspace(slug).issues());
|
||||
setOpen(false);
|
||||
},
|
||||
[push, setOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
@@ -317,17 +489,70 @@ export function SearchCommand() {
|
||||
</CommandPrimitive.Group>
|
||||
)}
|
||||
|
||||
{/* Commands section — New Issue / New Project / Copy link / Theme, only shown when query matches */}
|
||||
{filteredCommands.length > 0 && (
|
||||
<CommandPrimitive.Group className="p-2">
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Commands
|
||||
</div>
|
||||
{filteredCommands.map((cmd) => (
|
||||
<CommandPrimitive.Item
|
||||
key={cmd.key}
|
||||
value={`command:${cmd.key}`}
|
||||
onSelect={cmd.onSelect}
|
||||
className="flex cursor-default select-none items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
|
||||
>
|
||||
<cmd.icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
<HighlightText text={cmd.label} query={query} />
|
||||
</span>
|
||||
{cmd.trailing}
|
||||
</CommandPrimitive.Item>
|
||||
))}
|
||||
</CommandPrimitive.Group>
|
||||
)}
|
||||
|
||||
{/* Workspaces section — switch to a different workspace, only shown when query matches */}
|
||||
{filteredWorkspaces.length > 0 && (
|
||||
<CommandPrimitive.Group className="p-2">
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Switch Workspace
|
||||
</div>
|
||||
{filteredWorkspaces.map((ws) => (
|
||||
<CommandPrimitive.Item
|
||||
key={ws.id}
|
||||
value={`workspace:${ws.id}`}
|
||||
onSelect={() => handleSwitchWorkspace(ws.slug)}
|
||||
className="flex cursor-default select-none items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
|
||||
>
|
||||
<Building2 className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
<HighlightText text={ws.name} query={query} />
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground truncate">
|
||||
{ws.slug}
|
||||
</span>
|
||||
</CommandPrimitive.Item>
|
||||
))}
|
||||
</CommandPrimitive.Group>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && query.trim() && !hasResults && filteredPages.length === 0 && (
|
||||
<CommandPrimitive.Empty className="py-10 text-center text-sm text-muted-foreground">
|
||||
No results found.
|
||||
</CommandPrimitive.Empty>
|
||||
)}
|
||||
{!isLoading &&
|
||||
query.trim() &&
|
||||
!hasResults &&
|
||||
filteredPages.length === 0 &&
|
||||
filteredCommands.length === 0 &&
|
||||
filteredWorkspaces.length === 0 && (
|
||||
<CommandPrimitive.Empty className="py-10 text-center text-sm text-muted-foreground">
|
||||
No results found.
|
||||
</CommandPrimitive.Empty>
|
||||
)}
|
||||
|
||||
{!isLoading && results.projects.length > 0 && (
|
||||
<CommandPrimitive.Group
|
||||
@@ -448,9 +673,8 @@ export function SearchCommand() {
|
||||
)}
|
||||
|
||||
{!isLoading && !query.trim() && recentIssues.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-muted-foreground">
|
||||
<span>Type to search issues and projects...</span>
|
||||
<span className="text-xs">Press <kbd className="rounded bg-muted px-1.5 py-0.5 font-medium">⌘K</kbd> to open this anytime</span>
|
||||
<div className="px-5 py-4 text-center text-xs text-muted-foreground">
|
||||
Type to search issues and projects
|
||||
</div>
|
||||
)}
|
||||
</CommandPrimitive.List>
|
||||
|
||||
@@ -45,9 +45,10 @@ export function WorkspaceTab() {
|
||||
|
||||
/**
|
||||
* After leaving/deleting the current workspace, send the user to a safe URL:
|
||||
* another workspace they still have access to, or onboarding if they have none.
|
||||
* The list is freshly fetched (staleTime: 0) because the cache still contains
|
||||
* the just-removed workspace until the background invalidation resolves.
|
||||
* another workspace they still have access to, or /workspaces/new if none
|
||||
* remain. The list is freshly fetched (staleTime: 0) because the cache still
|
||||
* contains the just-removed workspace until the background invalidation
|
||||
* resolves.
|
||||
*/
|
||||
const navigateAwayFromCurrentWorkspace = async () => {
|
||||
const wsList = await qc.fetchQuery({
|
||||
@@ -57,7 +58,7 @@ export function WorkspaceTab() {
|
||||
const remaining = wsList.filter((w) => w.id !== workspace?.id);
|
||||
const next = remaining[0];
|
||||
navigation.push(
|
||||
next ? paths.workspace(next.slug).issues() : paths.onboarding(),
|
||||
next ? paths.workspace(next.slug).issues() : paths.newWorkspace(),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
85
packages/views/workspace/create-workspace-form.test.tsx
Normal file
85
packages/views/workspace/create-workspace-form.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { CreateWorkspaceForm } from "./create-workspace-form";
|
||||
|
||||
const mockMutate = vi.fn();
|
||||
vi.mock("@multica/core/workspace/mutations", () => ({
|
||||
useCreateWorkspace: () => ({ mutate: mockMutate, isPending: false }),
|
||||
}));
|
||||
|
||||
function renderForm(onSuccess = vi.fn()) {
|
||||
const qc = new QueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<CreateWorkspaceForm onSuccess={onSuccess} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CreateWorkspaceForm", () => {
|
||||
beforeEach(() => mockMutate.mockReset());
|
||||
|
||||
it("auto-generates slug from name until user edits slug", () => {
|
||||
renderForm();
|
||||
fireEvent.change(screen.getByLabelText(/workspace name/i), {
|
||||
target: { value: "Acme Corp" },
|
||||
});
|
||||
expect(screen.getByDisplayValue("acme-corp")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stops auto-generating slug once user edits slug directly", () => {
|
||||
renderForm();
|
||||
fireEvent.change(screen.getByLabelText(/workspace url/i), {
|
||||
target: { value: "custom" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/workspace name/i), {
|
||||
target: { value: "Different Name" },
|
||||
});
|
||||
expect(screen.getByDisplayValue("custom")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSuccess with the created workspace", async () => {
|
||||
const onSuccess = vi.fn();
|
||||
mockMutate.mockImplementation((_args, opts) => {
|
||||
opts?.onSuccess?.({ id: "ws-1", slug: "acme", name: "Acme" });
|
||||
});
|
||||
renderForm(onSuccess);
|
||||
fireEvent.change(screen.getByLabelText(/workspace name/i), {
|
||||
target: { value: "Acme" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /create workspace/i }));
|
||||
await waitFor(() =>
|
||||
expect(onSuccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ slug: "acme" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows slug-conflict error inline on 409", async () => {
|
||||
mockMutate.mockImplementation((_args, opts) => {
|
||||
opts?.onError?.({ status: 409 });
|
||||
});
|
||||
renderForm();
|
||||
fireEvent.change(screen.getByLabelText(/workspace name/i), {
|
||||
target: { value: "Taken" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /create workspace/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/already taken/i)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("disables submit when slug has invalid format", () => {
|
||||
renderForm();
|
||||
fireEvent.change(screen.getByLabelText(/workspace name/i), {
|
||||
target: { value: "Valid Name" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/workspace url/i), {
|
||||
target: { value: "Invalid Slug!" },
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: /create workspace/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -7,20 +7,24 @@ import { Label } from "@multica/ui/components/ui/label";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { useCreateWorkspace } from "@multica/core/workspace/mutations";
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
import {
|
||||
WORKSPACE_SLUG_CONFLICT_ERROR,
|
||||
WORKSPACE_SLUG_FORMAT_ERROR,
|
||||
WORKSPACE_SLUG_REGEX,
|
||||
isWorkspaceSlugConflict,
|
||||
nameToWorkspaceSlug,
|
||||
} from "../workspace/slug";
|
||||
} from "./slug";
|
||||
|
||||
export function StepWorkspace({ onNext }: { onNext: () => void }) {
|
||||
export interface CreateWorkspaceFormProps {
|
||||
onSuccess: (workspace: Workspace) => void;
|
||||
}
|
||||
|
||||
export function CreateWorkspaceForm({ onSuccess }: CreateWorkspaceFormProps) {
|
||||
const createWorkspace = useCreateWorkspace();
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [slugServerError, setSlugServerError] = useState<string | null>(null);
|
||||
// Track whether the user has manually edited the slug field.
|
||||
const slugTouched = useRef(false);
|
||||
|
||||
const slugValidationError =
|
||||
@@ -28,7 +32,6 @@ export function StepWorkspace({ onNext }: { onNext: () => void }) {
|
||||
? WORKSPACE_SLUG_FORMAT_ERROR
|
||||
: null;
|
||||
const slugError = slugValidationError ?? slugServerError;
|
||||
|
||||
const canSubmit =
|
||||
name.trim().length > 0 && slug.trim().length > 0 && !slugError;
|
||||
|
||||
@@ -51,7 +54,7 @@ export function StepWorkspace({ onNext }: { onNext: () => void }) {
|
||||
createWorkspace.mutate(
|
||||
{ name: name.trim(), slug: slug.trim() },
|
||||
{
|
||||
onSuccess: () => onNext(),
|
||||
onSuccess,
|
||||
onError: (error) => {
|
||||
if (isWorkspaceSlugConflict(error)) {
|
||||
setSlugServerError(WORKSPACE_SLUG_CONFLICT_ERROR);
|
||||
@@ -65,59 +68,49 @@ export function StepWorkspace({ onNext }: { onNext: () => void }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
Welcome to Multica
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Create your workspace to start building with AI agents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="w-full">
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Workspace Name</Label>
|
||||
<Card className="w-full">
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ws-name">Workspace Name</Label>
|
||||
<Input
|
||||
id="ws-name"
|
||||
autoFocus
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Workspace"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ws-slug">Workspace URL</Label>
|
||||
<div className="flex items-center gap-0 rounded-md border bg-background focus-within:ring-2 focus-within:ring-ring">
|
||||
<span className="pl-3 text-sm text-muted-foreground select-none">
|
||||
multica.ai/
|
||||
</span>
|
||||
<Input
|
||||
autoFocus
|
||||
id="ws-slug"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Team"
|
||||
value={slug}
|
||||
onChange={(e) => handleSlugChange(e.target.value)}
|
||||
placeholder="my-workspace"
|
||||
className="border-0 shadow-none focus-visible:ring-0"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Workspace URL</Label>
|
||||
<div className="flex items-center gap-0 rounded-md border bg-background focus-within:ring-2 focus-within:ring-ring">
|
||||
<span className="pl-3 text-sm text-muted-foreground select-none">
|
||||
multica.ai/
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => handleSlugChange(e.target.value)}
|
||||
placeholder="my-team"
|
||||
className="border-0 shadow-none focus-visible:ring-0"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
{slugError && (
|
||||
<p className="text-xs text-destructive">{slugError}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleCreate}
|
||||
disabled={createWorkspace.isPending || !canSubmit}
|
||||
>
|
||||
{createWorkspace.isPending ? "Creating..." : "Create Workspace"}
|
||||
</Button>
|
||||
</div>
|
||||
{slugError && (
|
||||
<p className="text-xs text-destructive">{slugError}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleCreate}
|
||||
disabled={createWorkspace.isPending || !canSubmit}
|
||||
>
|
||||
{createWorkspace.isPending ? "Creating..." : "Create workspace"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
32
packages/views/workspace/new-workspace-page.tsx
Normal file
32
packages/views/workspace/new-workspace-page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
import { CreateWorkspaceForm } from "./create-workspace-form";
|
||||
|
||||
/**
|
||||
* Full-page shell for the /workspaces/new route. Shared between web
|
||||
* (Next.js) and desktop (react-router) so the two apps can't drift.
|
||||
* Callers provide the onSuccess handler — that's the only app-specific
|
||||
* piece, because each app uses its own navigation primitive.
|
||||
*/
|
||||
export function NewWorkspacePage({
|
||||
onSuccess,
|
||||
}: {
|
||||
onSuccess: (workspace: Workspace) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center bg-background px-6 py-12">
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
Welcome to Multica
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Create your workspace to get started.
|
||||
</p>
|
||||
</div>
|
||||
<CreateWorkspaceForm onSuccess={onSuccess} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
packages/views/workspace/no-access-page.test.tsx
Normal file
45
packages/views/workspace/no-access-page.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { NoAccessPage } from "./no-access-page";
|
||||
|
||||
const navigate = vi.fn();
|
||||
const logout = vi.fn();
|
||||
|
||||
vi.mock("../navigation", () => ({
|
||||
useNavigation: () => ({ push: navigate, replace: navigate }),
|
||||
}));
|
||||
|
||||
vi.mock("../auth", () => ({
|
||||
useLogout: () => logout,
|
||||
}));
|
||||
|
||||
describe("NoAccessPage", () => {
|
||||
beforeEach(() => {
|
||||
navigate.mockReset();
|
||||
logout.mockReset();
|
||||
});
|
||||
|
||||
it("renders generic message that doesn't leak existence", () => {
|
||||
render(<NoAccessPage />);
|
||||
expect(
|
||||
screen.getByText(/doesn't exist or you don't have access/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates to root on 'Go to my workspaces'", () => {
|
||||
render(<NoAccessPage />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /go to my workspaces/i }));
|
||||
expect(navigate).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
it("fully logs out on 'Sign in as a different user' instead of just navigating", () => {
|
||||
render(<NoAccessPage />);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /sign in as a different user/i }),
|
||||
);
|
||||
expect(logout).toHaveBeenCalledTimes(1);
|
||||
// Should NOT just navigate to /login — that would leave the session
|
||||
// cookie + auth state intact and AuthInitializer would re-auth.
|
||||
expect(navigate).not.toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
37
packages/views/workspace/no-access-page.tsx
Normal file
37
packages/views/workspace/no-access-page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { useLogout } from "../auth";
|
||||
|
||||
/**
|
||||
* Rendered when the workspace slug in the URL does not resolve to a workspace
|
||||
* the current user can access. Deliberately doesn't distinguish "workspace
|
||||
* doesn't exist" from "workspace exists but I'm not a member" — showing
|
||||
* either would let attackers enumerate workspace slugs.
|
||||
*/
|
||||
export function NoAccessPage() {
|
||||
const nav = useNavigation();
|
||||
const logout = useLogout();
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-6 px-6 text-center">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Workspace not available
|
||||
</h1>
|
||||
<p className="max-w-md text-muted-foreground">
|
||||
This workspace doesn't exist or you don't have access.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button onClick={() => nav.push(paths.root())}>
|
||||
Go to my workspaces
|
||||
</Button>
|
||||
<Button variant="outline" onClick={logout}>
|
||||
Sign in as a different user
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
packages/views/workspace/use-workspace-seen.test.ts
Normal file
45
packages/views/workspace/use-workspace-seen.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useWorkspaceSeen } from "./use-workspace-seen";
|
||||
|
||||
describe("useWorkspaceSeen", () => {
|
||||
it("returns false when slug has never resolved", () => {
|
||||
const { result } = renderHook(() => useWorkspaceSeen("acme", false));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true after slug resolved at least once", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ slug, resolved }) => useWorkspaceSeen(slug, resolved),
|
||||
{ initialProps: { slug: "acme", resolved: true } },
|
||||
);
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Workspace disappears (e.g. just deleted) — hook still reports "seen"
|
||||
rerender({ slug: "acme", resolved: false });
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("remembers multiple slugs independently", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ slug, resolved }) => useWorkspaceSeen(slug, resolved),
|
||||
{ initialProps: { slug: "acme", resolved: true } },
|
||||
);
|
||||
// Switch to a different resolved slug
|
||||
rerender({ slug: "beta", resolved: true });
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Now check a never-seen slug — should not leak positive
|
||||
rerender({ slug: "gamma", resolved: false });
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
// Back to "acme" (which we saw first) — still seen
|
||||
rerender({ slug: "acme", resolved: false });
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for undefined slug", () => {
|
||||
const { result } = renderHook(() => useWorkspaceSeen(undefined, true));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
29
packages/views/workspace/use-workspace-seen.ts
Normal file
29
packages/views/workspace/use-workspace-seen.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
/**
|
||||
* Tracks workspace slugs that have successfully resolved to a workspace at
|
||||
* least once during this layout instance's lifetime. Used to distinguish:
|
||||
*
|
||||
* - "Active workspace was just removed" (slug seen before, now gone) —
|
||||
* the caller is typically navigating away (delete/leave mutation, or
|
||||
* realtime workspace:deleted event). Rendering NoAccessPage during
|
||||
* that window causes a jarring flash of "Workspace not available"
|
||||
* before the navigate completes. Return `true` so the layout can
|
||||
* render null while the navigate resolves.
|
||||
*
|
||||
* - "URL points to a workspace I've never had access to" (slug never
|
||||
* seen) — genuine access-denial case. Return `false` so the layout
|
||||
* can render NoAccessPage with its recovery buttons.
|
||||
*
|
||||
* Scope: one Set per layout instance. If the workspace layout unmounts
|
||||
* (e.g. desktop tab close), the memory is discarded — correct, since the
|
||||
* user lost that view anyway.
|
||||
*/
|
||||
export function useWorkspaceSeen(
|
||||
slug: string | undefined,
|
||||
resolved: boolean,
|
||||
): boolean {
|
||||
const seenRef = useRef<Set<string>>(new Set());
|
||||
if (resolved && slug) seenRef.current.add(slug);
|
||||
return slug ? seenRef.current.has(slug) : false;
|
||||
}
|
||||
@@ -44,6 +44,12 @@ var daemonStatusCmd = &cobra.Command{
|
||||
RunE: runDaemonStatus,
|
||||
}
|
||||
|
||||
var daemonRestartCmd = &cobra.Command{
|
||||
Use: "restart",
|
||||
Short: "Restart the running daemon (stop + start)",
|
||||
RunE: runDaemonRestart,
|
||||
}
|
||||
|
||||
var daemonLogsCmd = &cobra.Command{
|
||||
Use: "logs",
|
||||
Short: "Show daemon logs",
|
||||
@@ -66,8 +72,20 @@ func init() {
|
||||
|
||||
daemonStatusCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
|
||||
// restart shares all the same flags as start
|
||||
rf := daemonRestartCmd.Flags()
|
||||
rf.Bool("foreground", false, "Run in the foreground instead of background")
|
||||
rf.String("daemon-id", "", "Unique daemon identifier (env: MULTICA_DAEMON_ID)")
|
||||
rf.String("device-name", "", "Human-readable device name (env: MULTICA_DAEMON_DEVICE_NAME)")
|
||||
rf.String("runtime-name", "", "Runtime display name (env: MULTICA_AGENT_RUNTIME_NAME)")
|
||||
rf.Duration("poll-interval", 0, "Task poll interval (env: MULTICA_DAEMON_POLL_INTERVAL)")
|
||||
rf.Duration("heartbeat-interval", 0, "Heartbeat interval (env: MULTICA_DAEMON_HEARTBEAT_INTERVAL)")
|
||||
rf.Duration("agent-timeout", 0, "Per-task timeout (env: MULTICA_AGENT_TIMEOUT)")
|
||||
rf.Int("max-concurrent-tasks", 0, "Max tasks running in parallel (env: MULTICA_DAEMON_MAX_CONCURRENT_TASKS)")
|
||||
|
||||
daemonCmd.AddCommand(daemonStartCmd)
|
||||
daemonCmd.AddCommand(daemonStopCmd)
|
||||
daemonCmd.AddCommand(daemonRestartCmd)
|
||||
daemonCmd.AddCommand(daemonStatusCmd)
|
||||
daemonCmd.AddCommand(daemonLogsCmd)
|
||||
}
|
||||
@@ -128,7 +146,8 @@ func runDaemonBackground(cmd *cobra.Command) error {
|
||||
if profile != "" {
|
||||
label = fmt.Sprintf("daemon [%s]", profile)
|
||||
}
|
||||
return fmt.Errorf("%s is already running (pid %v)", label, health["pid"])
|
||||
pid, _ := health["pid"].(float64)
|
||||
return fmt.Errorf("%s is already running (pid %v). Use 'daemon restart' to restart it", label, int(pid))
|
||||
}
|
||||
|
||||
// Resolve current executable.
|
||||
@@ -328,6 +347,39 @@ func runDaemonForeground(cmd *cobra.Command) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- daemon restart ---
|
||||
|
||||
func runDaemonRestart(cmd *cobra.Command, args []string) error {
|
||||
profile := resolveProfile(cmd)
|
||||
healthPort := healthPortForProfile(profile)
|
||||
|
||||
// Stop if running.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
health := checkDaemonHealthOnPort(ctx, healthPort)
|
||||
if health["status"] == "running" {
|
||||
pid, _ := health["pid"].(float64)
|
||||
if pid > 0 {
|
||||
if p, err := os.FindProcess(int(pid)); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "Stopping daemon (pid %d)...\n", int(pid))
|
||||
_ = stopDaemonProcess(p)
|
||||
for i := 0; i < 10; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
sctx, scancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
h := checkDaemonHealthOnPort(sctx, healthPort)
|
||||
scancel()
|
||||
if h["status"] != "running" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start fresh.
|
||||
return runDaemonStart(cmd, args)
|
||||
}
|
||||
|
||||
// --- daemon stop ---
|
||||
|
||||
func runDaemonStop(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
@@ -77,7 +77,7 @@ func autoWatchWorkspaces(cmd *cobra.Command) error {
|
||||
|
||||
if len(workspaces) == 0 {
|
||||
var err error
|
||||
workspaces, err = waitForOnboarding(cmd, client)
|
||||
workspaces, err = waitForWorkspaceCreation(cmd, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -110,9 +110,9 @@ func autoWatchWorkspaces(cmd *cobra.Command) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForOnboarding opens the web onboarding page and polls until the user
|
||||
// creates a workspace, returning the new workspace list.
|
||||
func waitForOnboarding(cmd *cobra.Command, client *cli.APIClient) ([]struct {
|
||||
// waitForWorkspaceCreation opens the web workspace-creation page and polls
|
||||
// until the user creates a workspace, returning the new workspace list.
|
||||
func waitForWorkspaceCreation(cmd *cobra.Command, client *cli.APIClient) ([]struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}, error) {
|
||||
@@ -125,13 +125,13 @@ func waitForOnboarding(cmd *cobra.Command, client *cli.APIClient) ([]struct {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
onboardingURL := appURL + "/onboarding"
|
||||
createWorkspaceURL := appURL + "/workspaces/new"
|
||||
|
||||
fmt.Fprintln(os.Stderr, "\nNo workspaces found. Opening onboarding in your browser...")
|
||||
if err := openBrowser(onboardingURL); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "\nNo workspaces found. Opening workspace creation in your browser...")
|
||||
if err := openBrowser(createWorkspaceURL); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Could not open browser automatically.\n")
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "If the browser didn't open, visit:\n %s\n", onboardingURL)
|
||||
fmt.Fprintf(os.Stderr, "If the browser didn't open, visit:\n %s\n", createWorkspaceURL)
|
||||
fmt.Fprintln(os.Stderr, "\nWaiting for workspace creation...")
|
||||
|
||||
// Poll until a workspace appears or timeout (5 minutes).
|
||||
|
||||
@@ -347,7 +347,7 @@ func TestVerifyCodeNewUserHasNoWorkspace(t *testing.T) {
|
||||
}
|
||||
readJSON(t, resp, &loginResp)
|
||||
|
||||
// New users should have no workspaces (onboarding creates one)
|
||||
// New users should have no workspaces (/workspaces/new creates one)
|
||||
req, _ := http.NewRequest("GET", testServer.URL+"/api/workspaces", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+loginResp.Token)
|
||||
workspacesResp, err := http.DefaultClient.Do(req)
|
||||
|
||||
@@ -137,7 +137,6 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||
|
||||
r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime)
|
||||
r.Get("/runtimes/{runtimeId}/tasks/pending", h.ListPendingTasksByRuntime)
|
||||
r.Post("/runtimes/{runtimeId}/usage", h.ReportRuntimeUsage)
|
||||
r.Post("/runtimes/{runtimeId}/ping/{pingId}/result", h.ReportPingResult)
|
||||
r.Post("/runtimes/{runtimeId}/update/{updateId}/result", h.ReportUpdateResult)
|
||||
|
||||
|
||||
@@ -140,12 +140,6 @@ func (c *Client) GetTaskStatus(ctx context.Context, taskID string) (string, erro
|
||||
return resp.Status, nil
|
||||
}
|
||||
|
||||
func (c *Client) ReportUsage(ctx context.Context, runtimeID string, entries []map[string]any) error {
|
||||
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/runtimes/%s/usage", runtimeID), map[string]any{
|
||||
"entries": entries,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// HeartbeatResponse contains the server's response to a heartbeat, including any pending actions.
|
||||
type HeartbeatResponse struct {
|
||||
Status string `json:"status"`
|
||||
|
||||
@@ -33,7 +33,7 @@ type Config struct {
|
||||
CLIVersion string // multica CLI version (e.g. "0.1.13")
|
||||
LaunchedBy string // "desktop" when spawned by the Electron app, empty for standalone
|
||||
Profile string // profile name (empty = default)
|
||||
Agents map[string]AgentEntry // keyed by provider: claude, codex, opencode, openclaw, hermes, gemini
|
||||
Agents map[string]AgentEntry // keyed by provider: claude, codex, opencode, openclaw, hermes, gemini, pi
|
||||
WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces)
|
||||
KeepEnvAfterTask bool // preserve env after task for debugging
|
||||
HealthPort int // local HTTP port for health checks (default: 19514)
|
||||
@@ -120,8 +120,29 @@ func LoadConfig(overrides Overrides) (Config, error) {
|
||||
Model: strings.TrimSpace(os.Getenv("MULTICA_GEMINI_MODEL")),
|
||||
}
|
||||
}
|
||||
piPath := envOrDefault("MULTICA_PI_PATH", "pi")
|
||||
if _, err := exec.LookPath(piPath); err == nil {
|
||||
agents["pi"] = AgentEntry{
|
||||
Path: piPath,
|
||||
Model: strings.TrimSpace(os.Getenv("MULTICA_PI_MODEL")),
|
||||
}
|
||||
}
|
||||
cursorPath := envOrDefault("MULTICA_CURSOR_PATH", "cursor-agent")
|
||||
if _, err := exec.LookPath(cursorPath); err == nil {
|
||||
agents["cursor"] = AgentEntry{
|
||||
Path: cursorPath,
|
||||
Model: strings.TrimSpace(os.Getenv("MULTICA_CURSOR_MODEL")),
|
||||
}
|
||||
}
|
||||
copilotPath := envOrDefault("MULTICA_COPILOT_PATH", "copilot")
|
||||
if _, err := exec.LookPath(copilotPath); err == nil {
|
||||
agents["copilot"] = AgentEntry{
|
||||
Path: copilotPath,
|
||||
Model: strings.TrimSpace(os.Getenv("MULTICA_COPILOT_MODEL")),
|
||||
}
|
||||
}
|
||||
if len(agents) == 0 {
|
||||
return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, opencode, openclaw, hermes, or gemini and ensure it is on PATH")
|
||||
return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, or cursor-agent and ensure it is on PATH")
|
||||
}
|
||||
|
||||
// Host info
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
"github.com/multica-ai/multica/server/internal/daemon/execenv"
|
||||
"github.com/multica-ai/multica/server/internal/daemon/repocache"
|
||||
"github.com/multica-ai/multica/server/internal/daemon/usage"
|
||||
"github.com/multica-ai/multica/server/pkg/agent"
|
||||
)
|
||||
|
||||
@@ -92,13 +91,15 @@ func (d *Daemon) Run(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Fetch all user workspaces from the API and register runtimes.
|
||||
// Fetch all user workspaces from the API and register runtimes for any
|
||||
// that exist. Zero workspaces is a valid state — a newly-signed-up user
|
||||
// may start the daemon before creating their first workspace. The
|
||||
// workspaceSyncLoop below polls every 30s and will register runtimes
|
||||
// when a workspace appears, so the daemon stays useful as a long-lived
|
||||
// background process rather than crashing at startup.
|
||||
if err := d.syncWorkspacesFromAPI(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(d.allRuntimeIDs()) == 0 {
|
||||
return fmt.Errorf("no runtimes registered")
|
||||
}
|
||||
|
||||
// Deregister runtimes on shutdown (uses a fresh context since ctx will be cancelled).
|
||||
defer d.deregisterRuntimes()
|
||||
@@ -107,7 +108,6 @@ func (d *Daemon) Run(ctx context.Context) error {
|
||||
go d.workspaceSyncLoop(ctx)
|
||||
|
||||
go d.heartbeatLoop(ctx)
|
||||
go d.usageScanLoop(ctx)
|
||||
go d.gcLoop(ctx)
|
||||
go d.serveHealth(ctx, healthLn, time.Now())
|
||||
return d.pollLoop(ctx)
|
||||
@@ -176,17 +176,6 @@ func (d *Daemon) findRuntime(id string) *Runtime {
|
||||
return nil
|
||||
}
|
||||
|
||||
// providerToRuntimeMap returns a mapping from provider name to runtime ID.
|
||||
func (d *Daemon) providerToRuntimeMap() map[string]string {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
m := make(map[string]string)
|
||||
for id, rt := range d.runtimeIndex {
|
||||
m[rt.Provider] = id
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID string) (*RegisterResponse, error) {
|
||||
var runtimes []map[string]string
|
||||
for name, entry := range d.cfg.Agents {
|
||||
@@ -669,62 +658,6 @@ func (d *Daemon) triggerRestart() {
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) usageScanLoop(ctx context.Context) {
|
||||
scanner := usage.NewScanner(d.logger)
|
||||
|
||||
report := func() {
|
||||
records := scanner.Scan()
|
||||
if len(records) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Build provider -> runtime ID mapping from current state.
|
||||
providerToRuntime := d.providerToRuntimeMap()
|
||||
|
||||
// Group records by provider to send to the correct runtime.
|
||||
byProvider := make(map[string][]map[string]any)
|
||||
for _, r := range records {
|
||||
byProvider[r.Provider] = append(byProvider[r.Provider], map[string]any{
|
||||
"date": r.Date,
|
||||
"provider": r.Provider,
|
||||
"model": r.Model,
|
||||
"input_tokens": r.InputTokens,
|
||||
"output_tokens": r.OutputTokens,
|
||||
"cache_read_tokens": r.CacheReadTokens,
|
||||
"cache_write_tokens": r.CacheWriteTokens,
|
||||
})
|
||||
}
|
||||
|
||||
for provider, entries := range byProvider {
|
||||
runtimeID, ok := providerToRuntime[provider]
|
||||
if !ok {
|
||||
d.logger.Debug("no runtime for provider, skipping usage report", "provider", provider)
|
||||
continue
|
||||
}
|
||||
if err := d.client.ReportUsage(ctx, runtimeID, entries); err != nil {
|
||||
d.logger.Warn("usage report failed", "provider", provider, "runtime_id", runtimeID, "error", err)
|
||||
} else {
|
||||
d.logger.Info("usage reported", "provider", provider, "runtime_id", runtimeID, "entries", len(entries))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial scan on startup.
|
||||
report()
|
||||
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
report()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) pollLoop(ctx context.Context) error {
|
||||
sem := make(chan struct{}, d.cfg.MaxConcurrentTasks)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -13,7 +13,10 @@ import (
|
||||
//
|
||||
// Claude: skills → {workDir}/.claude/skills/{name}/SKILL.md (native discovery)
|
||||
// Codex: skills → handled separately in Prepare via codex-home
|
||||
// Copilot: skills → {workDir}/.agent_context/skills/{name}/SKILL.md (via AGENTS.md references)
|
||||
// OpenCode: skills → {workDir}/.config/opencode/skills/{name}/SKILL.md (native discovery)
|
||||
// Pi: skills → {workDir}/.pi/agent/skills/{name}/SKILL.md (native discovery)
|
||||
// Cursor: skills → {workDir}/.cursor/skills/{name}/SKILL.md (native discovery)
|
||||
// Default: skills → {workDir}/.agent_context/skills/{name}/SKILL.md
|
||||
func writeContextFiles(workDir, provider string, ctx TaskContextForEnv) error {
|
||||
contextDir := filepath.Join(workDir, ".agent_context")
|
||||
@@ -54,6 +57,12 @@ func resolveSkillsDir(workDir, provider string) (string, error) {
|
||||
case "opencode":
|
||||
// OpenCode natively discovers skills from .config/opencode/skills/ in the workdir.
|
||||
skillsDir = filepath.Join(workDir, ".config", "opencode", "skills")
|
||||
case "pi":
|
||||
// Pi natively discovers skills from .pi/agent/skills/ in the workdir.
|
||||
skillsDir = filepath.Join(workDir, ".pi", "agent", "skills")
|
||||
case "cursor":
|
||||
// Cursor natively discovers skills from .cursor/skills/ in the workdir.
|
||||
skillsDir = filepath.Join(workDir, ".cursor", "skills")
|
||||
default:
|
||||
// Fallback: write to .agent_context/skills/ (referenced by meta config).
|
||||
skillsDir = filepath.Join(workDir, ".agent_context", "skills")
|
||||
|
||||
@@ -12,16 +12,19 @@ import (
|
||||
//
|
||||
// For Claude: writes {workDir}/CLAUDE.md (skills discovered natively from .claude/skills/)
|
||||
// For Codex: writes {workDir}/AGENTS.md (skills discovered natively via CODEX_HOME)
|
||||
// For Copilot: writes {workDir}/AGENTS.md (Copilot CLI natively reads AGENTS.md)
|
||||
// For OpenCode: writes {workDir}/AGENTS.md (skills discovered natively from .config/opencode/skills/)
|
||||
// For OpenClaw: writes {workDir}/AGENTS.md (skills discovered natively from .openclaw/skills/)
|
||||
// For Gemini: writes {workDir}/GEMINI.md (discovered natively by the Gemini CLI)
|
||||
// For Pi: writes {workDir}/AGENTS.md (skills discovered natively from ~/.pi/agent/skills/)
|
||||
// For Cursor: writes {workDir}/AGENTS.md (skills discovered natively from .cursor/skills/)
|
||||
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error {
|
||||
content := buildMetaSkillContent(provider, ctx)
|
||||
|
||||
switch provider {
|
||||
case "claude":
|
||||
return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644)
|
||||
case "codex", "opencode", "openclaw":
|
||||
case "codex", "copilot", "opencode", "openclaw", "pi", "cursor":
|
||||
return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
|
||||
case "gemini":
|
||||
return os.WriteFile(filepath.Join(workDir, "GEMINI.md"), []byte(content), 0o644)
|
||||
@@ -140,8 +143,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
case "claude":
|
||||
// Claude discovers skills natively from .claude/skills/ — just list names.
|
||||
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
|
||||
case "codex", "opencode", "openclaw":
|
||||
// Codex, OpenCode, and OpenClaw discover skills natively from their respective paths — just list names.
|
||||
case "codex", "copilot", "opencode", "openclaw", "pi", "cursor":
|
||||
// Codex, Copilot, OpenCode, OpenClaw, Pi, and Cursor discover skills natively from their respective paths — just list names.
|
||||
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
|
||||
case "gemini":
|
||||
// Gemini reads GEMINI.md directly; point it at the fallback skills dir.
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
package usage
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// scanClaude reads Claude Code JSONL session logs from ~/.config/claude/projects/**/*.jsonl
|
||||
// and extracts token usage from "assistant" message lines.
|
||||
func (s *Scanner) scanClaude() []Record {
|
||||
roots := claudeLogRoots()
|
||||
if len(roots) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var allRecords []Record
|
||||
seen := make(map[string]bool) // dedup by "messageId:requestId"
|
||||
|
||||
for _, root := range roots {
|
||||
files, err := filepath.Glob(filepath.Join(root, "**", "*.jsonl"))
|
||||
if err != nil {
|
||||
s.logger.Debug("claude glob error", "root", root, "error", err)
|
||||
continue
|
||||
}
|
||||
// Also glob one level deeper for subagent logs
|
||||
deeper, _ := filepath.Glob(filepath.Join(root, "**", "**", "*.jsonl"))
|
||||
files = append(files, deeper...)
|
||||
|
||||
for _, f := range files {
|
||||
records := s.parseClaudeFile(f, seen)
|
||||
allRecords = append(allRecords, records...)
|
||||
}
|
||||
}
|
||||
|
||||
return mergeRecords(allRecords)
|
||||
}
|
||||
|
||||
// claudeLogRoots returns the directories to scan for Claude JSONL logs.
|
||||
func claudeLogRoots() []string {
|
||||
var roots []string
|
||||
|
||||
// Check CLAUDE_CONFIG_DIR env var
|
||||
if configDir := os.Getenv("CLAUDE_CONFIG_DIR"); configDir != "" {
|
||||
for _, dir := range strings.Split(configDir, ",") {
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir != "" {
|
||||
roots = append(roots, filepath.Join(dir, "projects"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Standard locations
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return roots
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
filepath.Join(home, ".config", "claude", "projects"),
|
||||
filepath.Join(home, ".claude", "projects"),
|
||||
}
|
||||
for _, dir := range candidates {
|
||||
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||
roots = append(roots, dir)
|
||||
}
|
||||
}
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
// claudeLine represents the subset of a Claude JSONL line we care about.
|
||||
type claudeLine struct {
|
||||
Type string `json:"type"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
RequestID string `json:"requestId"`
|
||||
Message *struct {
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Usage *struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
|
||||
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
|
||||
} `json:"usage"`
|
||||
} `json:"message"`
|
||||
}
|
||||
|
||||
func (s *Scanner) parseClaudeFile(path string, seen map[string]bool) []Record {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var records []Record
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024) // up to 1MB lines
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
// Fast pre-filter: skip lines that can't contain what we need
|
||||
if !bytesContains(line, `"type":"assistant"`) && !bytesContains(line, `"type": "assistant"`) {
|
||||
continue
|
||||
}
|
||||
if !bytesContains(line, `"usage"`) {
|
||||
continue
|
||||
}
|
||||
|
||||
var entry claudeLine
|
||||
if err := json.Unmarshal(line, &entry); err != nil {
|
||||
continue
|
||||
}
|
||||
if entry.Type != "assistant" || entry.Message == nil || entry.Message.Usage == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Dedup: Claude streaming produces multiple lines with same message.id + requestId
|
||||
// with cumulative token counts. Take only the first occurrence.
|
||||
dedupKey := entry.Message.ID + ":" + entry.RequestID
|
||||
if dedupKey != ":" && seen[dedupKey] {
|
||||
continue
|
||||
}
|
||||
if dedupKey != ":" {
|
||||
seen[dedupKey] = true
|
||||
}
|
||||
|
||||
// Parse timestamp to get date
|
||||
ts, err := time.Parse(time.RFC3339Nano, entry.Timestamp)
|
||||
if err != nil {
|
||||
ts, err = time.Parse(time.RFC3339, entry.Timestamp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
model := entry.Message.Model
|
||||
if model == "" {
|
||||
model = "unknown"
|
||||
}
|
||||
|
||||
records = append(records, Record{
|
||||
Date: ts.Local().Format("2006-01-02"),
|
||||
Provider: "claude",
|
||||
Model: normalizeClaudeModel(model),
|
||||
InputTokens: entry.Message.Usage.InputTokens,
|
||||
OutputTokens: entry.Message.Usage.OutputTokens,
|
||||
CacheReadTokens: entry.Message.Usage.CacheReadInputTokens,
|
||||
CacheWriteTokens: entry.Message.Usage.CacheCreationInputTokens,
|
||||
})
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
// normalizeClaudeModel strips common prefixes/suffixes from model names.
|
||||
func normalizeClaudeModel(model string) string {
|
||||
// Strip "anthropic." prefix
|
||||
model = strings.TrimPrefix(model, "anthropic.")
|
||||
// Strip Vertex AI prefixes like "us.anthropic."
|
||||
if idx := strings.LastIndex(model, "anthropic."); idx >= 0 {
|
||||
model = model[idx+len("anthropic."):]
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func bytesContains(data []byte, substr string) bool {
|
||||
return strings.Contains(string(data), substr)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user