mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
Compare commits
87 Commits
agent/lamb
...
NevilleQin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3636517fdc | ||
|
|
931afa05cf | ||
|
|
d28298cda9 | ||
|
|
6ef9be10d6 | ||
|
|
60b215f44f | ||
|
|
f1082b10a4 | ||
|
|
44a0ced558 | ||
|
|
89b939b07c | ||
|
|
8b0eeb0615 | ||
|
|
64c605e227 | ||
|
|
820d57535e | ||
|
|
a7299bf857 | ||
|
|
baac4080e9 | ||
|
|
99f6cb8130 | ||
|
|
b5f1e506e5 | ||
|
|
00cde21724 | ||
|
|
1476c268dd | ||
|
|
9a5f5ca498 | ||
|
|
daf0e935f6 | ||
|
|
5c42ed1649 | ||
|
|
a57dd76faf | ||
|
|
c24191a884 | ||
|
|
629f4136ac | ||
|
|
cb078c0f36 | ||
|
|
e13e5edc8e | ||
|
|
fee393df1f | ||
|
|
1ff4e27e77 | ||
|
|
fbf9460d5e | ||
|
|
d492b9d7a6 | ||
|
|
3dc3e49a47 | ||
|
|
ae9098637d | ||
|
|
cc94fbd305 | ||
|
|
a039c4d803 | ||
|
|
cf0d58ab50 | ||
|
|
3fe3b84981 | ||
|
|
c4352da126 | ||
|
|
d0c66f3173 | ||
|
|
170fa2102b | ||
|
|
a414a00b4a | ||
|
|
862b0509df | ||
|
|
ba5b7db78e | ||
|
|
3f046d03f7 | ||
|
|
e665b597b3 | ||
|
|
075a845d9a | ||
|
|
972c65dbc1 | ||
|
|
f85b7cce91 | ||
|
|
cf47d9b702 | ||
|
|
c2f199650a | ||
|
|
3df95c84b8 | ||
|
|
050a2f0a5b | ||
|
|
374f62be13 | ||
|
|
d9e5cf87dd | ||
|
|
13fe614903 | ||
|
|
2305f7d180 | ||
|
|
befde379b5 | ||
|
|
51fdc5aec3 | ||
|
|
32d61d018e | ||
|
|
51bc5a818f | ||
|
|
2dddfaa196 | ||
|
|
cbe7f2c886 | ||
|
|
1d1dedbf6e | ||
|
|
298ed75b1d | ||
|
|
47b5e38dc6 | ||
|
|
da5dbc6224 | ||
|
|
2129aa3dee | ||
|
|
2fd388da08 | ||
|
|
cba3db0d7f | ||
|
|
b1345685a3 | ||
|
|
44608713bb | ||
|
|
a28312c0b4 | ||
|
|
72d5135bf0 | ||
|
|
924c69114d | ||
|
|
700e6f3f24 | ||
|
|
d68f1f4bf1 | ||
|
|
281779330e | ||
|
|
949dffdf7e | ||
|
|
e6e9c64484 | ||
|
|
c6a26facd3 | ||
|
|
b6a3f8ed58 | ||
|
|
8c9c52b023 | ||
|
|
562949e1cb | ||
|
|
65f6e9c9f2 | ||
|
|
79d28b0da6 | ||
|
|
aeccd4f26e | ||
|
|
68ed2a32d9 | ||
|
|
f508190065 | ||
|
|
d5611d550a |
@@ -63,6 +63,9 @@ GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
|
||||
# S3 / CloudFront
|
||||
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
|
||||
# ".s3.<region>.amazonaws.com" suffix; the server builds the public URL
|
||||
# from S3_BUCKET + S3_REGION. S3_REGION must match the bucket's real region.
|
||||
S3_BUCKET=
|
||||
S3_REGION=us-west-2
|
||||
CLOUDFRONT_KEY_PAIR_ID=
|
||||
|
||||
@@ -70,10 +70,10 @@ Opens your browser for OAuth authentication, creates a 90-day personal access to
|
||||
### Token Login
|
||||
|
||||
```bash
|
||||
multica login --token
|
||||
multica login --token <mul_...>
|
||||
```
|
||||
|
||||
Authenticate by pasting a personal access token directly. Useful for headless environments.
|
||||
Authenticate using a personal access token directly. Useful for headless environments. Pass `--token=` with an empty value to be prompted interactively (so the token never lands in shell history).
|
||||
|
||||
### Check Status
|
||||
|
||||
@@ -140,6 +140,7 @@ The daemon auto-detects these AI CLIs on your PATH:
|
||||
|-----|---------|-------------|
|
||||
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
|
||||
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
|
||||
| [GitHub Copilot CLI](https://docs.github.com/en/copilot) | `copilot` | GitHub's coding agent (model routed by your GitHub entitlement) |
|
||||
| OpenCode | `opencode` | Open-source coding agent |
|
||||
| OpenClaw | `openclaw` | Open-source coding agent |
|
||||
| Hermes | `hermes` | Nous Research coding agent |
|
||||
@@ -174,6 +175,22 @@ Daemon behavior is configured via flags or environment variables:
|
||||
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |
|
||||
| Runtime name | `--runtime-name` | `MULTICA_AGENT_RUNTIME_NAME` | `Local Agent` |
|
||||
| Workspaces root | — | `MULTICA_WORKSPACES_ROOT` | `~/multica_workspaces` |
|
||||
| GC enabled | — | `MULTICA_GC_ENABLED` | `true` (set `false`/`0` to disable) |
|
||||
| GC scan interval | — | `MULTICA_GC_INTERVAL` | `1h` |
|
||||
| GC TTL (done/cancelled issues) | — | `MULTICA_GC_TTL` | `24h` |
|
||||
| GC orphan TTL (no `.gc_meta.json`) | — | `MULTICA_GC_ORPHAN_TTL` | `72h` |
|
||||
| GC artifact TTL (open issues) | — | `MULTICA_GC_ARTIFACT_TTL` | `12h` (set `0` to disable) |
|
||||
| GC artifact patterns | — | `MULTICA_GC_ARTIFACT_PATTERNS` | `node_modules,.next,.turbo` |
|
||||
|
||||
#### Workspace garbage collection
|
||||
|
||||
The daemon periodically scans `MULTICA_WORKSPACES_ROOT` and reclaims disk space in three modes:
|
||||
|
||||
- **Full task cleanup** — when an issue's status is `done` or `cancelled` and has been idle for `MULTICA_GC_TTL`, the entire task directory is removed.
|
||||
- **Orphan cleanup** — task directories with no `.gc_meta.json` (e.g. left over from a daemon crash) are removed once they exceed `MULTICA_GC_ORPHAN_TTL`.
|
||||
- **Artifact-only cleanup** — when a task has been completed for at least `MULTICA_GC_ARTIFACT_TTL` but the issue is still open, regenerable build outputs whose directory basename matches `MULTICA_GC_ARTIFACT_PATTERNS` are removed; the rest of the workdir (source, `.git`, `output/`, `logs/`, `.gc_meta.json`) is preserved so the agent can resume the same workdir on the next task.
|
||||
|
||||
Patterns are basename-only — entries containing `/` or `\` are silently dropped — and `.git` subtrees are never descended into. The default list (`node_modules`, `.next`, `.turbo`) is intentionally narrow; extend it per deployment if your repos consistently produce other regenerable directories (for example, `MULTICA_GC_ARTIFACT_PATTERNS=node_modules,.next,.turbo,target,__pycache__`). To disable artifact cleanup entirely, set `MULTICA_GC_ARTIFACT_TTL=0`.
|
||||
|
||||
Agent-specific overrides:
|
||||
|
||||
@@ -185,6 +202,8 @@ Agent-specific overrides:
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_CODEX_ARGS` | Default extra arguments for Codex runs |
|
||||
| `MULTICA_COPILOT_PATH` | Custom path to the `copilot` binary |
|
||||
| `MULTICA_COPILOT_MODEL` | Override the Copilot model used (note: GitHub Copilot routes models through your account entitlement, so this may not be honoured) |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
@@ -286,10 +305,11 @@ multica workspace members <workspace-id>
|
||||
multica issue list
|
||||
multica issue list --status in_progress
|
||||
multica issue list --priority urgent --assignee "Agent Name"
|
||||
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue list --limit 20 --output json
|
||||
```
|
||||
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
|
||||
Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
|
||||
|
||||
### Get Issue
|
||||
|
||||
@@ -302,9 +322,10 @@ multica issue get <id> --output json
|
||||
|
||||
```bash
|
||||
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
|
||||
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace members --output json` / `multica agent list --output json`.
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -316,9 +337,12 @@ multica issue update <id> --title "New title" --priority urgent
|
||||
|
||||
```bash
|
||||
multica issue assign <id> --to "Lambda"
|
||||
multica issue assign <id> --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue assign <id> --unassign
|
||||
```
|
||||
|
||||
Pass `--to-id <uuid>` to assign by canonical UUID (mutually exclusive with `--to`); useful when names overlap across members and agents.
|
||||
|
||||
### Change Status
|
||||
|
||||
```bash
|
||||
|
||||
@@ -140,7 +140,7 @@ multica auth status
|
||||
Expected output should show the authenticated user and server URL.
|
||||
|
||||
**If login fails:**
|
||||
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token`
|
||||
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token <mul_...>` (use `--token=` with an empty value to be prompted interactively).
|
||||
- If the server URL needs to be customized: `multica config set server_url <url>` before logging in.
|
||||
|
||||
---
|
||||
@@ -166,12 +166,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`, `gemini`, `pi`, `cursor-agent`).
|
||||
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `copilot`, `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`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
|
||||
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
|
||||
|
||||
---
|
||||
|
||||
@@ -185,12 +185,12 @@ multica daemon status
|
||||
|
||||
Confirm:
|
||||
1. Status is `running`
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `copilot`, `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`, `hermes`, `gemini`, `pi`, or `cursor-agent`), 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`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
|
||||
|
||||
---
|
||||
|
||||
|
||||
27
README.md
27
README.md
@@ -30,12 +30,24 @@ 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**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
|
||||
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**, **GitHub Copilot CLI**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
|
||||
</p>
|
||||
|
||||
## Why "Multica"?
|
||||
|
||||
Multica — **Mul**tiplexed **I**nformation and **C**omputing **A**gent.
|
||||
|
||||
The name is a nod to Multics, the pioneering operating system of the 1960s that introduced time-sharing — letting multiple users share a single machine as if each had it to themselves. Unix was born as a deliberate simplification of Multics: one user, one task, one elegant philosophy.
|
||||
|
||||
We think the same inflection is happening again. For decades, software teams have been single-threaded — one engineer, one task, one context switch at a time. AI agents change that equation. Multica brings time-sharing back, but for an era where the "users" multiplexing the system are both humans and autonomous agents.
|
||||
|
||||
In Multica, agents are first-class teammates. They get assigned issues, report progress, raise blockers, and ship code — just like their human colleagues. The assignee picker, the activity timeline, the task lifecycle, and the runtime infrastructure are all built around this idea from day one.
|
||||
|
||||
Like Multics before it, the bet is on multiplexing: a small team shouldn't feel small. With the right system, two engineers and a fleet of agents can move like twenty.
|
||||
|
||||
## Features
|
||||
|
||||
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.
|
||||
@@ -98,7 +110,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`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`) on your PATH.
|
||||
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`) on your PATH.
|
||||
|
||||
### 2. Verify your runtime
|
||||
|
||||
@@ -108,7 +120,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, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI). 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, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
|
||||
### 4. Assign your first task
|
||||
|
||||
@@ -160,10 +172,9 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ runs on your machine
|
||||
└──────────────┘ (Claude Code, Codex, OpenCode,
|
||||
OpenClaw, Hermes, Gemini,
|
||||
Pi, Cursor Agent, Kimi,
|
||||
Kiro CLI)
|
||||
└──────────────┘ (Claude Code, Codex, GitHub Copilot CLI,
|
||||
OpenCode, OpenClaw, Hermes, Gemini,
|
||||
Pi, Cursor Agent, Kimi, Kiro CLI)
|
||||
```
|
||||
|
||||
| Layer | Stack |
|
||||
@@ -171,7 +182,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, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI |
|
||||
| Agent Runtime | Local daemon executing Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI |
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -30,12 +30,24 @@
|
||||
|
||||
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
|
||||
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi** 和 **Cursor Agent**。
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**GitHub Copilot CLI**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi**、**Cursor Agent**、**Kimi** 和 **Kiro CLI**。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
|
||||
</p>
|
||||
|
||||
## 为什么叫 "Multica"?
|
||||
|
||||
Multica——**Mul**tiplexed **I**nformation and **C**omputing **A**gent。
|
||||
|
||||
这个名字是在向 20 世纪 60 年代具有开创意义的操作系统 Multics 致意。Multics 首创了分时系统,让多个用户能够共享同一台机器,同时又像各自独占它一样使用。Unix 则是在有意简化 Multics 的基础上诞生的,强调一个用户、一个任务、一种优雅的哲学。
|
||||
|
||||
我们认为,类似的转折点正在再次出现。几十年来,软件团队一直处于一种单线程的工作模式,一个工程师处理一个任务,一次只专注于一个上下文。AI agents 改变了这个等式。Multica 将"分时"重新带回这个时代,只不过今天在系统中进行多路复用的"用户",既包括人类,也包括自主代理。
|
||||
|
||||
在 Multica 中,agents 是一级团队成员。它们会被分配 issue,汇报进展,提出阻塞,并交付代码,就像人类同事一样。任务分配、活动时间线、任务生命周期,以及运行时基础设施,Multica 从第一天起就是围绕这一理念构建的。
|
||||
|
||||
和当年的 Multics 一样,这一判断建立在"多路复用"之上。一个小团队不该因为人数少就显得能力有限。有了合适的系统,两名工程师加上一组 agents,就能发挥出二十人团队的推进速度。
|
||||
|
||||
## 功能特性
|
||||
|
||||
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
|
||||
@@ -99,7 +111,7 @@ multica setup # 连接 Multica Cloud,登录,启动 daemon
|
||||
multica setup # 配置、认证、启动 daemon(一条命令搞定)
|
||||
```
|
||||
|
||||
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`、`openclaw`、`opencode`、`hermes`、`gemini`、`pi`、`cursor-agent`)。
|
||||
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`、`copilot`、`openclaw`、`opencode`、`hermes`、`gemini`、`pi`、`cursor-agent`、`kimi`、`kiro-cli`)。
|
||||
|
||||
### 2. 确认运行时已连接
|
||||
|
||||
@@ -109,7 +121,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
|
||||
### 3. 创建 Agent
|
||||
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude Code、Codex、OpenClaw、OpenCode、Hermes、Gemini、Pi 或 Cursor Agent),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、Kimi 或 Kiro CLI),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
|
||||
### 4. 分配你的第一个任务
|
||||
|
||||
@@ -142,9 +154,9 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ 运行在你的机器上
|
||||
└──────────────┘ (Claude Code、Codex、OpenCode、
|
||||
OpenClaw、Hermes、Gemini、
|
||||
Pi、Cursor Agent)
|
||||
└──────────────┘ (Claude Code、Codex、GitHub Copilot CLI、
|
||||
OpenCode、OpenClaw、Hermes、Gemini、
|
||||
Pi、Cursor Agent、Kimi、Kiro CLI)
|
||||
```
|
||||
|
||||
| 层级 | 技术栈 |
|
||||
@@ -152,7 +164,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、Hermes、Gemini、Pi 或 Cursor Agent |
|
||||
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、Kimi 或 Kiro CLI |
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ brew install multica-ai/tap/multica
|
||||
You also need at least one AI agent CLI installed:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
||||
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
|
||||
- [GitHub Copilot CLI](https://docs.github.com/en/copilot) (`copilot` on PATH)
|
||||
- [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)
|
||||
|
||||
@@ -56,13 +56,15 @@ Changes take effect after restarting the backend / compose stack. The web UI rea
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
For file uploads and attachments, configure S3 and (optionally) CloudFront:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `S3_BUCKET` | S3 bucket name |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`) |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
|
||||
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
|
||||
@@ -103,6 +105,8 @@ Agent-specific overrides:
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_COPILOT_PATH` | Custom path to the `copilot` (GitHub Copilot CLI) binary |
|
||||
| `MULTICA_COPILOT_MODEL` | Override the Copilot model used (note: GitHub Copilot routes models through your account entitlement, so this may not be honoured) |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# Production environment for `pnpm package` / `pnpm build`.
|
||||
# electron-vite (Vite under the hood) reads this automatically in
|
||||
# production mode and inlines the values into the renderer bundle via
|
||||
# import.meta.env.VITE_*. These are public URLs, not secrets.
|
||||
|
||||
# Backend API + websocket the desktop app talks to.
|
||||
VITE_API_URL=https://api.multica.ai
|
||||
VITE_WS_URL=wss://api.multica.ai/ws
|
||||
|
||||
# Public web app URL — used to build shareable links like "Copy link to
|
||||
# issue" that users paste into Slack / messages. See platform/navigation.tsx.
|
||||
VITE_APP_URL=https://multica.ai
|
||||
@@ -8,6 +8,8 @@ import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
// Bundled icon used for dev-mode dock/taskbar branding. In production the
|
||||
// app bundle icon (from electron-builder) wins; this path is only consumed
|
||||
@@ -37,6 +39,10 @@ if (process.platform !== "win32") {
|
||||
const PROTOCOL = "multica";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let runtimeConfigResult: RuntimeConfigResult = {
|
||||
ok: false,
|
||||
error: { message: "Runtime config has not loaded yet" },
|
||||
};
|
||||
|
||||
// --- Deep link helpers ---------------------------------------------------
|
||||
|
||||
@@ -111,6 +117,22 @@ function createWindow(): void {
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
|
||||
// reloading the page. In a desktop app an accidental reload destroys
|
||||
// in-memory state (tabs, drafts, WS connections) with no URL bar to
|
||||
// navigate back. DevTools refresh (via the DevTools UI) still works.
|
||||
mainWindow.webContents.on("before-input-event", (_event, input) => {
|
||||
if (input.type !== "keyDown") return;
|
||||
const cmdOrCtrl =
|
||||
process.platform === "darwin" ? input.meta : input.control;
|
||||
if (
|
||||
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
|
||||
input.key === "F5"
|
||||
) {
|
||||
_event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
installContextMenu(mainWindow.webContents);
|
||||
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
@@ -171,7 +193,25 @@ if (!gotTheLock) {
|
||||
if (deepLinkUrl) handleDeepLink(deepLinkUrl);
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
app.whenReady().then(async () => {
|
||||
const viteEnv = import.meta.env as ImportMetaEnv & {
|
||||
readonly VITE_API_URL?: string;
|
||||
readonly VITE_WS_URL?: string;
|
||||
readonly VITE_APP_URL?: string;
|
||||
};
|
||||
|
||||
runtimeConfigResult = await loadRuntimeConfig({
|
||||
isDev: is.dev,
|
||||
// electron-vite exposes VITE_* on import.meta.env for the main process;
|
||||
// keep dev URL overrides on the same source the renderer used before
|
||||
// runtime config moved endpoint resolution into main/preload.
|
||||
env: {
|
||||
apiUrl: viteEnv.VITE_API_URL,
|
||||
wsUrl: viteEnv.VITE_WS_URL,
|
||||
appUrl: viteEnv.VITE_APP_URL,
|
||||
},
|
||||
});
|
||||
|
||||
electronApp.setAppUserModelId(
|
||||
is.dev ? "ai.multica.desktop.dev" : "ai.multica.desktop",
|
||||
);
|
||||
@@ -207,6 +247,13 @@ if (!gotTheLock) {
|
||||
event.returnValue = { version: getAppVersion(), os };
|
||||
});
|
||||
|
||||
// Sync IPC: preload exposes the validated runtime config before renderer
|
||||
// boot. If desktop.json exists but is invalid, renderer receives the
|
||||
// blocking error and must not silently fall back to the cloud defaults.
|
||||
ipcMain.on("runtime-config:get", (event) => {
|
||||
event.returnValue = runtimeConfigResult;
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
// modals (e.g. create-workspace) can place UI in the top-left corner
|
||||
// without fighting the native window controls' hit-test.
|
||||
|
||||
90
apps/desktop/src/main/runtime-config-loader.test.ts
Normal file
90
apps/desktop/src/main/runtime-config-loader.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { mkdtemp, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
|
||||
describe("loadRuntimeConfig", () => {
|
||||
it("uses dev env and ignores desktop.json during electron-vite dev", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
const configPath = join(dir, "desktop.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ schemaVersion: 1, apiUrl: "https://prod.example.com" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadRuntimeConfig({
|
||||
isDev: true,
|
||||
configPath,
|
||||
env: {
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses cloud defaults when packaged config is absent", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
await expect(
|
||||
loadRuntimeConfig({
|
||||
isDev: false,
|
||||
configPath: join(dir, "missing.json"),
|
||||
env: {},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a valid packaged desktop.json", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
const configPath = join(dir, "desktop.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ schemaVersion: 1, apiUrl: "https://api.example.com" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadRuntimeConfig({ isDev: false, configPath, env: {} }),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "wss://api.example.com/ws",
|
||||
appUrl: "https://api.example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when packaged desktop.json is invalid", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
const configPath = join(dir, "desktop.json");
|
||||
await writeFile(configPath, "{");
|
||||
|
||||
const result = await loadRuntimeConfig({ isDev: false, configPath, env: {} });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain(configPath);
|
||||
expect(result.error.message).toContain("Invalid desktop runtime config JSON");
|
||||
}
|
||||
});
|
||||
});
|
||||
60
apps/desktop/src/main/runtime-config-loader.ts
Normal file
60
apps/desktop/src/main/runtime-config-loader.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { app } from "electron";
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import {
|
||||
DEFAULT_RUNTIME_CONFIG,
|
||||
parseRuntimeConfig,
|
||||
runtimeConfigFromDevEnv,
|
||||
type RuntimeConfig,
|
||||
type RuntimeConfigEnv,
|
||||
type RuntimeConfigResult,
|
||||
} from "../shared/runtime-config";
|
||||
|
||||
export async function loadRuntimeConfig(options: {
|
||||
isDev: boolean;
|
||||
env: RuntimeConfigEnv;
|
||||
configPath?: string;
|
||||
}): Promise<RuntimeConfigResult> {
|
||||
if (options.isDev) {
|
||||
try {
|
||||
return { ok: true, config: runtimeConfigFromDevEnv(options.env) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: { message: errorMessage(err) } };
|
||||
}
|
||||
}
|
||||
|
||||
const configPath = options.configPath ?? desktopConfigPath();
|
||||
try {
|
||||
const raw = await readFile(configPath, "utf-8");
|
||||
return { ok: true, config: parseRuntimeConfig(raw) };
|
||||
} catch (err) {
|
||||
if (isMissingFileError(err)) {
|
||||
return { ok: true, config: { ...DEFAULT_RUNTIME_CONFIG } };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: `Invalid ${configPath}: ${errorMessage(err)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function desktopConfigPath(): string {
|
||||
return join(app.getPath("home"), ".multica", "desktop.json");
|
||||
}
|
||||
|
||||
function isMissingFileError(err: unknown): boolean {
|
||||
return Boolean(
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"code" in err &&
|
||||
(err as NodeJS.ErrnoException).code === "ENOENT",
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
export type { RuntimeConfig, RuntimeConfigResult };
|
||||
3
apps/desktop/src/preload/index.d.ts
vendored
3
apps/desktop/src/preload/index.d.ts
vendored
@@ -1,4 +1,5 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** App version + normalized OS, captured synchronously at preload time. */
|
||||
@@ -6,6 +7,8 @@ interface DesktopAPI {
|
||||
version: string;
|
||||
os: "macos" | "windows" | "linux" | "unknown";
|
||||
};
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig: RuntimeConfigResult;
|
||||
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
|
||||
onAuthToken: (callback: (token: string) => void) => () => void;
|
||||
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
// Synchronously fetch app metadata from main at preload time so the renderer
|
||||
// can pass it into CoreProvider during the initial render — the alternative
|
||||
@@ -21,12 +22,30 @@ function fetchAppInfo(): { version: string; os: "macos" | "windows" | "linux" |
|
||||
return { version: "unknown", os };
|
||||
}
|
||||
|
||||
function fetchRuntimeConfig(): RuntimeConfigResult {
|
||||
try {
|
||||
const result = ipcRenderer.sendSync("runtime-config:get") as RuntimeConfigResult | undefined;
|
||||
if (result && typeof result === "object" && "ok" in result) return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: false, error: { message: "Runtime config unavailable" } };
|
||||
}
|
||||
|
||||
const appInfo = fetchAppInfo();
|
||||
const runtimeConfig = fetchRuntimeConfig();
|
||||
|
||||
const desktopAPI = {
|
||||
/** App version + normalized OS. Read once at preload time so the renderer
|
||||
* can use it synchronously when initializing the API client. */
|
||||
appInfo,
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig,
|
||||
/** Listen for auth token delivered via deep link */
|
||||
onAuthToken: (callback: (token: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
|
||||
|
||||
@@ -30,11 +30,16 @@ function AppContent() {
|
||||
// first render.
|
||||
const [bootstrapping, setBootstrapping] = useState(false);
|
||||
|
||||
const runtimeConfig = window.desktopAPI.runtimeConfig.ok
|
||||
? window.desktopAPI.runtimeConfig.config
|
||||
: null;
|
||||
|
||||
// Tell the main process which backend URL we talk to, so daemon-manager
|
||||
// can pick the matching CLI profile (server_url from ~/.multica config).
|
||||
useEffect(() => {
|
||||
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
|
||||
}, []);
|
||||
if (!runtimeConfig) return;
|
||||
window.daemonAPI.setTargetApiUrl(runtimeConfig.apiUrl);
|
||||
}, [runtimeConfig]);
|
||||
|
||||
// Listen for invite IDs delivered via deep link (multica://invite/<id>).
|
||||
// We open the overlay regardless of login state — if the user isn't logged
|
||||
@@ -110,22 +115,58 @@ function AppContent() {
|
||||
: undefined;
|
||||
useDaemonIPCBridge(activeWsId);
|
||||
|
||||
// Workspace presence wins over onboarding state: a user invited into an
|
||||
// existing workspace must enter that workspace, not be trapped in the
|
||||
// onboarding overlay just because their personal `onboarded_at` is null.
|
||||
// Onboarding is only the right destination when the account has zero
|
||||
// workspaces AND has never onboarded.
|
||||
// Pre-workspace overlay routing for desktop. Mirrors the web entry-point
|
||||
// judgment in callback / login:
|
||||
// un-onboarded:
|
||||
// pending invites on email → /invitations overlay
|
||||
// no invites → /onboarding overlay
|
||||
// already onboarded:
|
||||
// zero workspaces → /workspaces/new overlay
|
||||
// ≥1 workspaces → no overlay, fall through to dashboard
|
||||
//
|
||||
// The "un-onboarded but in workspace" state is now physically impossible
|
||||
// because backend transactions atomically set onboarded_at when a user
|
||||
// joins the `member` table. Anyone with workspaces is by definition
|
||||
// onboarded.
|
||||
useEffect(() => {
|
||||
if (!user || !workspaceListFetched) return;
|
||||
if (!user || !workspaceListFetched) return undefined;
|
||||
const { overlay, open } = useWindowOverlayStore.getState();
|
||||
if (overlay) return;
|
||||
if (wsCount > 0) return;
|
||||
if (overlay) return undefined;
|
||||
if (wsCount > 0) return undefined;
|
||||
if (!hasOnboarded) {
|
||||
open({ type: "onboarding" });
|
||||
} else {
|
||||
open({ type: "new-workspace" });
|
||||
// Look up pending invitations by email. Network blip is non-fatal —
|
||||
// fall through to onboarding so the user isn't stuck on a blank
|
||||
// window. The sidebar's pending-invitations dropdown will surface
|
||||
// missed invites later once they're onboarded.
|
||||
let cancelled = false;
|
||||
void api
|
||||
.listMyInvitations()
|
||||
.then((invites) => {
|
||||
if (cancelled) return;
|
||||
const { overlay: latestOverlay, open: latestOpen } =
|
||||
useWindowOverlayStore.getState();
|
||||
if (latestOverlay) return;
|
||||
if (invites.length > 0) {
|
||||
qc.setQueryData(workspaceKeys.myInvitations(), invites);
|
||||
latestOpen({ type: "invitations" });
|
||||
} else {
|
||||
latestOpen({ type: "onboarding" });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
const { overlay: latestOverlay, open: latestOpen } =
|
||||
useWindowOverlayStore.getState();
|
||||
if (latestOverlay) return;
|
||||
latestOpen({ type: "onboarding" });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded]);
|
||||
open({ type: "new-workspace" });
|
||||
return undefined;
|
||||
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded, qc]);
|
||||
|
||||
// Validate persisted tab state against the current user's workspace list,
|
||||
// and pick an active workspace if none is set. Runs in useLayoutEffect
|
||||
@@ -190,9 +231,21 @@ function AppContent() {
|
||||
);
|
||||
}
|
||||
|
||||
// Backend the daemon should connect to — same URL the renderer talks to.
|
||||
const DAEMON_TARGET_API_URL =
|
||||
import.meta.env.VITE_API_URL || "http://localhost:8080";
|
||||
function BlockingRuntimeConfigError({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background p-8 text-foreground">
|
||||
<div className="max-w-xl rounded-lg border bg-card p-6 shadow-sm">
|
||||
<h1 className="text-lg font-semibold">Desktop configuration error</h1>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Multica Desktop could not load <code>~/.multica/desktop.json</code>. Fix or remove the file and restart the app.
|
||||
</p>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-md bg-muted p-3 text-xs text-muted-foreground">
|
||||
{message}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// On logout, wipe desktop-only in-memory state and stop the daemon so that
|
||||
// a subsequent login as a different user never inherits the previous user's
|
||||
@@ -216,6 +269,7 @@ async function handleDaemonLogout() {
|
||||
|
||||
export default function App() {
|
||||
const { version, os } = window.desktopAPI.appInfo;
|
||||
const runtimeConfigResult = window.desktopAPI.runtimeConfig;
|
||||
// Stable identity reference so downstream effects (WS reconnect) don't
|
||||
// tear down on every parent render.
|
||||
const identity = useMemo(
|
||||
@@ -224,14 +278,18 @@ export default function App() {
|
||||
);
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
|
||||
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
|
||||
onLogout={handleDaemonLogout}
|
||||
identity={identity}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
{runtimeConfigResult.ok ? (
|
||||
<CoreProvider
|
||||
apiBaseUrl={runtimeConfigResult.config.apiUrl}
|
||||
wsUrl={runtimeConfigResult.config.wsUrl}
|
||||
onLogout={handleDaemonLogout}
|
||||
identity={identity}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
) : (
|
||||
<BlockingRuntimeConfigError message={runtimeConfigResult.error.message} />
|
||||
)}
|
||||
<Toaster />
|
||||
<UpdateNotification />
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
// vi.hoisted shared state — every store mock reads the same object so each
|
||||
// test can mutate it then re-render to drive the tracker.
|
||||
const state = vi.hoisted(() => ({
|
||||
user: null as { id: string } | null,
|
||||
overlay: null as { type: string; invitationId?: string } | null,
|
||||
activeWorkspaceSlug: null as string | null,
|
||||
byWorkspace: {} as Record<
|
||||
string,
|
||||
{ activeTabId: string; tabs: { id: string; path: string }[] }
|
||||
>,
|
||||
capturePageview: vi.fn<(path?: string) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/analytics", () => ({
|
||||
capturePageview: state.capturePageview,
|
||||
}));
|
||||
|
||||
// Auth store — single selector pattern (`s => s.user`).
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
const useAuthStore = (selector: (s: typeof state) => unknown) =>
|
||||
selector(state);
|
||||
return { useAuthStore };
|
||||
});
|
||||
|
||||
// Window overlay store — same shape.
|
||||
vi.mock("@/stores/window-overlay-store", () => {
|
||||
const useWindowOverlayStore = (selector: (s: typeof state) => unknown) =>
|
||||
selector(state);
|
||||
return { useWindowOverlayStore };
|
||||
});
|
||||
|
||||
// Tab store — selectors read activeWorkspaceSlug + byWorkspace. Also expose
|
||||
// getState() for the seed pass and the helpers the tracker imports
|
||||
// (useActiveTabIdentity, getActiveTab) so we don't have to re-import them
|
||||
// from the real store inside a mocked module.
|
||||
vi.mock("@/stores/tab-store", () => {
|
||||
const useTabStore = Object.assign(
|
||||
(selector: (s: typeof state) => unknown) => selector(state),
|
||||
{ getState: () => state },
|
||||
);
|
||||
const getActiveTab = (s: typeof state) => {
|
||||
const slug = s.activeWorkspaceSlug;
|
||||
if (!slug) return null;
|
||||
const group = s.byWorkspace[slug];
|
||||
if (!group) return null;
|
||||
return group.tabs.find((t) => t.id === group.activeTabId) ?? null;
|
||||
};
|
||||
const useActiveTabIdentity = () => ({
|
||||
slug: state.activeWorkspaceSlug,
|
||||
tabId: state.activeWorkspaceSlug
|
||||
? (state.byWorkspace[state.activeWorkspaceSlug]?.activeTabId ?? null)
|
||||
: null,
|
||||
});
|
||||
return { useTabStore, getActiveTab, useActiveTabIdentity };
|
||||
});
|
||||
|
||||
import { PageviewTracker } from "./pageview-tracker";
|
||||
|
||||
function reset() {
|
||||
state.user = { id: "u1" };
|
||||
state.overlay = null;
|
||||
state.activeWorkspaceSlug = null;
|
||||
state.byWorkspace = {};
|
||||
state.capturePageview.mockClear();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reset();
|
||||
});
|
||||
|
||||
describe("PageviewTracker", () => {
|
||||
it("suppresses pageview when switching to a previously-visible tab on its existing path", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues" },
|
||||
{ id: "tB", path: "/acme/inbox" },
|
||||
],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
// Initial mount on tA — seeded as observed, no pageview because both
|
||||
// tabs were already in the persisted store before the tracker mounted.
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
|
||||
// Switch to tB (already-known tab on its already-known path).
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tB",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues" },
|
||||
{ id: "tB", path: "/acme/inbox" },
|
||||
],
|
||||
},
|
||||
};
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
|
||||
// Switch back to tA — still no pageview.
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues" },
|
||||
{ id: "tB", path: "/acme/inbox" },
|
||||
],
|
||||
},
|
||||
};
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires pageview when a new tab is opened (openInNewTab / addTab)", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
// Simulate openInNewTab("/acme/agents") → new tab tC added and activated.
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tC",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues" },
|
||||
{ id: "tC", path: "/acme/agents" },
|
||||
],
|
||||
},
|
||||
};
|
||||
rerender(<PageviewTracker />);
|
||||
|
||||
expect(state.capturePageview).toHaveBeenCalledTimes(1);
|
||||
expect(state.capturePageview).toHaveBeenCalledWith("/acme/agents");
|
||||
});
|
||||
|
||||
it("fires pageview when switchWorkspace opens a new path in another workspace", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
// Cross-workspace navigation: switchWorkspace("butter", "/butter/inbox")
|
||||
// creates a fresh tab in the destination workspace and makes it active.
|
||||
state.byWorkspace = {
|
||||
acme: { activeTabId: "tA", tabs: [{ id: "tA", path: "/acme/issues" }] },
|
||||
butter: {
|
||||
activeTabId: "tD",
|
||||
tabs: [{ id: "tD", path: "/butter/inbox" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "butter";
|
||||
rerender(<PageviewTracker />);
|
||||
|
||||
expect(state.capturePageview).toHaveBeenCalledTimes(1);
|
||||
expect(state.capturePageview).toHaveBeenCalledWith("/butter/inbox");
|
||||
});
|
||||
|
||||
it("fires pageview on intra-tab navigation (path changes for the same tabId)", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues/123" }],
|
||||
},
|
||||
};
|
||||
rerender(<PageviewTracker />);
|
||||
|
||||
expect(state.capturePageview).toHaveBeenCalledTimes(1);
|
||||
expect(state.capturePageview).toHaveBeenCalledWith("/acme/issues/123");
|
||||
});
|
||||
|
||||
it("fires overlay and login pageviews and suppresses re-entry into the same tab afterward", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
// Open onboarding overlay.
|
||||
state.overlay = { type: "onboarding" };
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).toHaveBeenLastCalledWith("/onboarding");
|
||||
|
||||
// Close overlay back to the tab — the tab is already observed on
|
||||
// /acme/issues so this is a re-activation, no pageview.
|
||||
state.capturePageview.mockClear();
|
||||
state.overlay = null;
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
|
||||
// Logout fires /login.
|
||||
state.user = null;
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).toHaveBeenLastCalledWith("/login");
|
||||
});
|
||||
|
||||
it("suppresses on initial mount when the active tab was restored from persistence", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
render(<PageviewTracker />);
|
||||
// Restored tab — seeded, treated as a re-activation.
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { capturePageview } from "@multica/core/analytics";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
import {
|
||||
getActiveTab,
|
||||
useActiveTabIdentity,
|
||||
useTabStore,
|
||||
} from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
|
||||
|
||||
/**
|
||||
* Fires a PostHog $pageview whenever the user's visible surface changes.
|
||||
* Fires a PostHog $pageview whenever the user's visible surface changes,
|
||||
* EXCEPT for re-activations of an already-known tab on its already-known
|
||||
* path.
|
||||
*
|
||||
* Desktop has three layers that can own the visible page:
|
||||
*
|
||||
@@ -17,10 +23,18 @@ import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overl
|
||||
* 3. Otherwise → the active tab's path (workspace-scoped, e.g.
|
||||
* `/acme/issues/123`). Kept in sync by `useTabRouterSync`.
|
||||
*
|
||||
* The overlay takes precedence over the tab path because it is visually in
|
||||
* front of the tab system; the logged-out state shadows both because the
|
||||
* shell doesn't render at all yet. This keeps the `$pageview` stream aligned
|
||||
* with what the user actually sees.
|
||||
* Tab-switch suppression: re-activating an already-open tab surfaces a
|
||||
* previously-visited path under a `(workspace, tabId)` we have already
|
||||
* seen — the pageview was emitted when the user originally navigated
|
||||
* there, so re-emitting on every switch just inflates PostHog billing
|
||||
* without adding signal (real-data audit: desktop tab switches were
|
||||
* ~50% of all `$pageview` events).
|
||||
*
|
||||
* Newly opened tabs (`openInNewTab`, `addTab`) and cross-workspace
|
||||
* `switchWorkspace(slug, path)` to a previously-unseen tab still fire,
|
||||
* because their key is not in the observed map yet. The map is seeded
|
||||
* from the persisted tab store on first render so tabs restored from a
|
||||
* previous session don't all re-emit on first activation.
|
||||
*
|
||||
* PostHog's `capture_pageview: true` auto-capture is intentionally off (see
|
||||
* `initAnalytics`) so this component owns the event shape, matching the web
|
||||
@@ -29,34 +43,75 @@ import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overl
|
||||
export function PageviewTracker() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const overlay = useWindowOverlayStore((s) => s.overlay);
|
||||
const activeTabPath = useTabStore((s) => {
|
||||
const slug = s.activeWorkspaceSlug;
|
||||
if (!slug) return null;
|
||||
const group = s.byWorkspace[slug];
|
||||
if (!group) return null;
|
||||
return group.tabs.find((t) => t.id === group.activeTabId)?.path ?? null;
|
||||
});
|
||||
const { slug: activeWorkspaceSlug, tabId: activeTabId } = useActiveTabIdentity();
|
||||
const activeTabPath = useTabStore((s) => getActiveTab(s)?.path ?? null);
|
||||
|
||||
const path = resolvePath(user, overlay, activeTabPath);
|
||||
// (slug:tabId) → last path observed while that tab was visible. Lets us
|
||||
// tell "re-activating a tab on a path we already saw" (suppress) apart
|
||||
// from "newly opened tab" or "intra-tab navigation" (fire). Seeded
|
||||
// synchronously on first render from the persisted tab store so
|
||||
// session-restored tabs don't re-emit on first click.
|
||||
const observedTabsRef = useRef<Map<string, string> | null>(null);
|
||||
if (observedTabsRef.current === null) {
|
||||
const seed = new Map<string, string>();
|
||||
for (const [slug, group] of Object.entries(useTabStore.getState().byWorkspace)) {
|
||||
for (const tab of group.tabs) {
|
||||
seed.set(`${slug}:${tab.id}`, tab.path);
|
||||
}
|
||||
}
|
||||
observedTabsRef.current = seed;
|
||||
}
|
||||
const lastSurfaceRef = useRef<{
|
||||
kind: "login" | "overlay" | "tab" | null;
|
||||
key: string | null;
|
||||
path: string | null;
|
||||
}>({ kind: null, key: null, path: null });
|
||||
|
||||
useEffect(() => {
|
||||
if (!path) return;
|
||||
let kind: "login" | "overlay" | "tab";
|
||||
let path: string;
|
||||
let key: string | null = null;
|
||||
|
||||
if (!user) {
|
||||
kind = "login";
|
||||
path = "/login";
|
||||
} else if (overlay) {
|
||||
kind = "overlay";
|
||||
path = overlayPath(overlay);
|
||||
} else if (activeTabPath && activeTabId && activeWorkspaceSlug) {
|
||||
kind = "tab";
|
||||
key = `${activeWorkspaceSlug}:${activeTabId}`;
|
||||
path = activeTabPath;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const observed = observedTabsRef.current!;
|
||||
const last = lastSurfaceRef.current;
|
||||
const next = { kind, key, path };
|
||||
|
||||
if (kind === "tab" && key !== null) {
|
||||
const knownPath = observed.get(key);
|
||||
const isReactivation =
|
||||
last.key !== key && knownPath !== undefined && knownPath === path;
|
||||
observed.set(key, path);
|
||||
if (isReactivation) {
|
||||
lastSurfaceRef.current = next;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const unchanged =
|
||||
last.kind === kind && last.key === key && last.path === path;
|
||||
if (unchanged) return;
|
||||
|
||||
capturePageview(path);
|
||||
}, [path]);
|
||||
lastSurfaceRef.current = next;
|
||||
}, [user, overlay, activeWorkspaceSlug, activeTabId, activeTabPath]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvePath(
|
||||
user: unknown,
|
||||
overlay: WindowOverlay | null,
|
||||
activeTabPath: string | null,
|
||||
): string | null {
|
||||
if (!user) return "/login";
|
||||
if (overlay) return overlayPath(overlay);
|
||||
return activeTabPath;
|
||||
}
|
||||
|
||||
function overlayPath(overlay: WindowOverlay): string {
|
||||
switch (overlay.type) {
|
||||
case "new-workspace":
|
||||
@@ -65,5 +120,7 @@ function overlayPath(overlay: WindowOverlay): string {
|
||||
return "/onboarding";
|
||||
case "invite":
|
||||
return `/invite/${overlay.invitationId}`;
|
||||
case "invitations":
|
||||
return "/invitations";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
import { InvitationsPage } from "@multica/views/invitations";
|
||||
import { OnboardingFlow } from "@multica/views/onboarding";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { paths } from "@multica/core/paths";
|
||||
@@ -58,6 +59,7 @@ function WindowOverlayInner() {
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
{overlay.type === "invitations" && <InvitationsPage />}
|
||||
{overlay.type === "onboarding" && (
|
||||
<OnboardingFlow
|
||||
onComplete={(ws) => {
|
||||
|
||||
@@ -2,14 +2,23 @@ import { LoginPage } from "@multica/views/auth";
|
||||
import { DragStrip } from "@multica/views/platform";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
|
||||
const WEB_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
function requireRuntimeAppUrl(): string {
|
||||
const runtimeConfig = window.desktopAPI.runtimeConfig;
|
||||
if (!runtimeConfig.ok) {
|
||||
throw new Error(
|
||||
"Invariant violated: DesktopLoginPage rendered before App accepted runtime config",
|
||||
);
|
||||
}
|
||||
return runtimeConfig.config.appUrl;
|
||||
}
|
||||
|
||||
export function DesktopLoginPage() {
|
||||
const webUrl = requireRuntimeAppUrl();
|
||||
const handleGoogleLogin = () => {
|
||||
// Open web login page in the default browser with platform=desktop flag.
|
||||
// The web callback will redirect back via multica:// deep link with the token.
|
||||
window.desktopAPI.openExternal(
|
||||
`${WEB_URL}/login?platform=desktop`,
|
||||
`${webUrl}/login?platform=desktop`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -15,11 +15,15 @@ import {
|
||||
} from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
// Public web app URL — injected at build time via .env.production. In dev
|
||||
// (no VITE_APP_URL set) falls back to the local web dev server so "Copy
|
||||
// link" in a dev build yields a URL that points at the running dev
|
||||
// frontend, not the prod host. Matches the fallback used in pages/login.tsx.
|
||||
const APP_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
function requireRuntimeAppUrl(scope: string): string {
|
||||
const runtimeConfig = window.desktopAPI.runtimeConfig;
|
||||
if (!runtimeConfig.ok) {
|
||||
throw new Error(
|
||||
`Invariant violated: ${scope} rendered before App accepted runtime config`,
|
||||
);
|
||||
}
|
||||
return runtimeConfig.config.appUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the leading workspace slug from a path, or null if the path isn't
|
||||
@@ -61,6 +65,13 @@ function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (path === "/invitations") {
|
||||
overlay.open({ type: "invitations" });
|
||||
if (router && router.state.location.pathname !== "/") {
|
||||
router.navigate("/", { replace: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (path.startsWith("/invite/")) {
|
||||
let id = "";
|
||||
try {
|
||||
@@ -109,6 +120,7 @@ export function DesktopNavigationProvider({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const appUrl = requireRuntimeAppUrl("DesktopNavigationProvider");
|
||||
// Primitive-only subscriptions so this component doesn't re-render on
|
||||
// unrelated store updates (e.g. an inactive tab's router tick). We
|
||||
// resolve the active router here only to subscribe once per tab switch.
|
||||
@@ -179,9 +191,9 @@ export function DesktopNavigationProvider({
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
getShareableUrl: (path: string) => `${appUrl}${path}`,
|
||||
}),
|
||||
[location],
|
||||
[appUrl, location],
|
||||
);
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
@@ -204,6 +216,7 @@ export function TabNavigationProvider({
|
||||
router: DataRouter;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const appUrl = requireRuntimeAppUrl("TabNavigationProvider");
|
||||
const [location, setLocation] = useState(router.state.location);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -239,9 +252,9 @@ export function TabNavigationProvider({
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
getShareableUrl: (path: string) => `${appUrl}${path}`,
|
||||
}),
|
||||
[router, location],
|
||||
[appUrl, router, location],
|
||||
);
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { create } from "zustand";
|
||||
export type WindowOverlay =
|
||||
| { type: "new-workspace" }
|
||||
| { type: "invite"; invitationId: string }
|
||||
| { type: "invitations" }
|
||||
| { type: "onboarding" };
|
||||
|
||||
interface WindowOverlayStore {
|
||||
|
||||
99
apps/desktop/src/shared/runtime-config.test.ts
Normal file
99
apps/desktop/src/shared/runtime-config.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_RUNTIME_CONFIG,
|
||||
deriveWsUrl,
|
||||
parseRuntimeConfig,
|
||||
runtimeConfigFromDevEnv,
|
||||
} from "./runtime-config";
|
||||
|
||||
describe("runtime config", () => {
|
||||
it("uses cloud defaults without a desktop.json file", () => {
|
||||
expect(DEFAULT_RUNTIME_CONFIG).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives https/wss compatible URLs from apiUrl", () => {
|
||||
expect(
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
|
||||
wsUrl: "wss://congvc-x99.taila6fa8a.ts.net:18443/ws",
|
||||
appUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives ws for http api URLs", () => {
|
||||
expect(deriveWsUrl("http://localhost:8080")).toBe("ws://localhost:8080/ws");
|
||||
});
|
||||
|
||||
it("accepts explicit appUrl and wsUrl", () => {
|
||||
expect(
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com/",
|
||||
wsUrl: "wss://ws.example.com/socket/",
|
||||
appUrl: "https://app.example.com/",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "wss://ws.example.com/socket",
|
||||
appUrl: "https://app.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
expect(() => parseRuntimeConfig("{")).toThrow(/Invalid desktop runtime config JSON/);
|
||||
});
|
||||
|
||||
it("rejects unsupported schema versions", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig(JSON.stringify({ schemaVersion: 2, apiUrl: "https://api.example.com" })),
|
||||
).toThrow(/schemaVersion/);
|
||||
});
|
||||
|
||||
it("rejects non-http api schemes", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig(JSON.stringify({ schemaVersion: 1, apiUrl: "file:///tmp/multica" })),
|
||||
).toThrow(/apiUrl must use http or https/);
|
||||
});
|
||||
|
||||
it("rejects non-ws websocket schemes", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "https://api.example.com/ws",
|
||||
}),
|
||||
),
|
||||
).toThrow(/wsUrl must use ws or wss/);
|
||||
});
|
||||
|
||||
it("preserves electron-vite dev env precedence", () => {
|
||||
expect(
|
||||
runtimeConfigFromDevEnv({
|
||||
apiUrl: "http://dev-api.example.test:8080/",
|
||||
wsUrl: "ws://dev-api.example.test:8080/ws/",
|
||||
appUrl: "http://dev-app.example.test:3000/",
|
||||
}),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://dev-api.example.test:8080",
|
||||
wsUrl: "ws://dev-api.example.test:8080/ws",
|
||||
appUrl: "http://dev-app.example.test:3000",
|
||||
});
|
||||
});
|
||||
});
|
||||
157
apps/desktop/src/shared/runtime-config.ts
Normal file
157
apps/desktop/src/shared/runtime-config.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
export interface RuntimeConfig {
|
||||
schemaVersion: 1;
|
||||
apiUrl: string;
|
||||
wsUrl: string;
|
||||
appUrl: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfigError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type RuntimeConfigResult =
|
||||
| { ok: true; config: RuntimeConfig }
|
||||
| { ok: false; error: RuntimeConfigError };
|
||||
|
||||
export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
});
|
||||
|
||||
const LOCAL_DEV_RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
});
|
||||
|
||||
export interface RuntimeConfigEnv {
|
||||
apiUrl?: string;
|
||||
wsUrl?: string;
|
||||
appUrl?: string;
|
||||
}
|
||||
|
||||
export function runtimeConfigFromDevEnv(env: RuntimeConfigEnv): RuntimeConfig {
|
||||
const apiUrl = normalizeHttpUrl(
|
||||
env.apiUrl || LOCAL_DEV_RUNTIME_CONFIG.apiUrl,
|
||||
"VITE_API_URL",
|
||||
);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
apiUrl,
|
||||
wsUrl: env.wsUrl
|
||||
? normalizeWsUrl(env.wsUrl, "VITE_WS_URL")
|
||||
: deriveWsUrl(apiUrl),
|
||||
appUrl: normalizeHttpUrl(
|
||||
env.appUrl || LOCAL_DEV_RUNTIME_CONFIG.appUrl,
|
||||
"VITE_APP_URL",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRuntimeConfig(raw: string): RuntimeConfig {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Invalid desktop runtime config JSON: ${err instanceof Error ? err.message : "parse failed"}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Invalid desktop runtime config: expected a JSON object");
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (obj.schemaVersion !== 1) {
|
||||
throw new Error("Unsupported desktop runtime config schemaVersion: expected 1");
|
||||
}
|
||||
|
||||
const apiUrl = requiredString(obj.apiUrl, "apiUrl");
|
||||
const appUrl = optionalString(obj.appUrl, "appUrl");
|
||||
const wsUrl = optionalString(obj.wsUrl, "wsUrl");
|
||||
|
||||
const normalizedApiUrl = normalizeHttpUrl(apiUrl, "apiUrl");
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
apiUrl: normalizedApiUrl,
|
||||
wsUrl: wsUrl ? normalizeWsUrl(wsUrl, "wsUrl") : deriveWsUrl(normalizedApiUrl),
|
||||
appUrl: appUrl ? normalizeHttpUrl(appUrl, "appUrl") : deriveAppUrl(normalizedApiUrl),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveWsUrl(apiUrl: string): string {
|
||||
const url = new URL(apiUrl);
|
||||
if (url.protocol === "https:") url.protocol = "wss:";
|
||||
else if (url.protocol === "http:") url.protocol = "ws:";
|
||||
else throw new Error("apiUrl must use http or https");
|
||||
url.pathname = joinPath(url.pathname, "/ws");
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
export function deriveAppUrl(apiUrl: string): string {
|
||||
const url = new URL(apiUrl);
|
||||
url.pathname = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a non-empty string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, field: string): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a non-empty string when set`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeHttpUrl(value: string, field: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a valid URL`);
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must use http or https`);
|
||||
}
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
function normalizeWsUrl(value: string, field: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a valid URL`);
|
||||
}
|
||||
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must use ws or wss`);
|
||||
}
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
function joinPath(base: string, suffix: string): string {
|
||||
const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base;
|
||||
return `${normalizedBase}${suffix}`;
|
||||
}
|
||||
|
||||
function trimTrailingSlash(value: string): string {
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { resolve } from "path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src/renderer/src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.mjs"],
|
||||
|
||||
@@ -32,9 +32,10 @@ The command-line equivalent:
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-42 --to alice
|
||||
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
`--to` takes a member username or an agent name. Giving agents memorable names makes this step smoother — if multiple agents share a name in the workspace, the first one listed wins, so rename before assigning.
|
||||
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace members --output json` / `multica agent list --output json`. UUID matching is strict and unambiguous, which is what you want from scripts and from agents driving the CLI. `--to` and `--to-id` are mutually exclusive.
|
||||
|
||||
Unassign:
|
||||
|
||||
|
||||
@@ -32,9 +32,10 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-42 --to alice
|
||||
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
`--to` 后跟成员用户名或智能体名字。给智能体起个好记的名字会让这一步顺很多——工作区里重名的会按列出顺序选第一个,建议先改名再分配。
|
||||
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`:UUID 来自 `multica workspace members --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥。
|
||||
|
||||
取消分配:
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ Opens your browser for OAuth authentication, creates a 90-day personal access to
|
||||
### Token Login
|
||||
|
||||
```bash
|
||||
multica login --token
|
||||
multica login --token <mul_...>
|
||||
```
|
||||
|
||||
Authenticate by pasting a personal access token directly. Useful for headless environments.
|
||||
Authenticate using a personal access token directly. Useful for headless environments. Pass `--token=` with an empty value to be prompted interactively (so the token never lands in shell history).
|
||||
|
||||
### Check Status
|
||||
|
||||
@@ -221,10 +221,11 @@ multica workspace members <workspace-id>
|
||||
multica issue list
|
||||
multica issue list --status in_progress
|
||||
multica issue list --priority urgent --assignee "Agent Name"
|
||||
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue list --limit 20 --output json
|
||||
```
|
||||
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
|
||||
Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. 在重名 workspace 下用 `--assignee-id <uuid>` 可以精确锁定一个成员或 agent。
|
||||
|
||||
### Get Issue
|
||||
|
||||
@@ -237,9 +238,10 @@ multica issue get <id> --output json
|
||||
|
||||
```bash
|
||||
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
|
||||
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. 脚本里如果已经拿到了 UUID(例如来自 `multica workspace members --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -251,9 +253,12 @@ multica issue update <id> --title "New title" --priority urgent
|
||||
|
||||
```bash
|
||||
multica issue assign <id> --to "Lambda"
|
||||
multica issue assign <id> --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue assign <id> --unassign
|
||||
```
|
||||
|
||||
`--to-id <uuid>`(与 `--to` 互斥)按 UUID 精确分配;适合重名 workspace 下脚本化场景。
|
||||
|
||||
### Change Status
|
||||
|
||||
```bash
|
||||
|
||||
@@ -99,7 +99,7 @@ Assign the issue to the agent you just created — click its avatar in the web U
|
||||
multica issue assign MUL-1 --to my-agent-name
|
||||
```
|
||||
|
||||
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it.
|
||||
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it. If your workspace has overlapping names, pass `--to-id <uuid>` instead (mutually exclusive with `--to`); look up the UUID via `multica agent list --output json` or `multica workspace members --output json`.
|
||||
|
||||
**What happens next from the daemon**:
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ multica issue create --title "给 README 加一段 ASCII 架构图"
|
||||
multica issue assign MUL-1 --to my-agent-name
|
||||
```
|
||||
|
||||
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。
|
||||
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。如果工作区里名字相互重叠或冲突,改用 `--to-id <uuid>`(与 `--to` 互斥);UUID 来自 `multica agent list --output json` 或 `multica workspace members --output json`。
|
||||
|
||||
**接下来守护进程会**:
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: What Multica Desktop is, how it differs from the web app, and when
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica Desktop is a native desktop app for macOS, Windows, and Linux. It talks to the same backend as the web app and shows the same data, but it adds a few things the browser can't: **independent tab groups per [workspace](/workspaces)**, **automatic [daemon](/daemon-runtimes) startup**, and **one-click upgrades**.
|
||||
Multica Desktop is a native desktop app for macOS, Windows, and Linux. For the environment it is configured for, it talks to the same backend as the web app and shows the same data. By default Desktop uses Multica Cloud; self-hosted instances can be configured with a local runtime config file. Desktop also adds a few things the browser can't: **independent tab groups per [workspace](/workspaces)**, **automatic [daemon](/daemon-runtimes) startup**, and **one-click upgrades**.
|
||||
|
||||
## Desktop or web — which to pick
|
||||
|
||||
@@ -66,25 +66,34 @@ Grab the installer for your platform from the [Multica downloads page](https://m
|
||||
|
||||
On first launch you'll need to sign in — the same email + verification code flow as the web app. Once you're in, Desktop syncs your workspace list automatically.
|
||||
|
||||
<Callout type="warning">
|
||||
**Released Desktop builds are pinned to Multica Cloud.** The backend, websocket, and web URLs are baked in at build time (`VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`) — there is no in-app option to point Desktop at a self-hosted instance. To use Desktop against a self-hosted backend you need to build it yourself:
|
||||
<Callout type="info">
|
||||
**Desktop defaults to Multica Cloud, but can be pointed at a self-hosted instance with a local config file.** There is still no in-app "connect to self-host" picker. Desktop reads `~/.multica/desktop.json` before the renderer starts; if the file is missing, it uses the Cloud defaults.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
# Edit apps/desktop/.env.production:
|
||||
# VITE_API_URL=https://api.your-domain
|
||||
# VITE_WS_URL=wss://api.your-domain/ws
|
||||
# VITE_APP_URL=https://your-domain
|
||||
pnpm install
|
||||
pnpm --filter @multica/desktop package
|
||||
Minimal self-host config:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain"
|
||||
}
|
||||
```
|
||||
|
||||
If you'd rather not build from source, the supported self-hosted path is **web frontend + CLI** — see [Self-host quickstart](/self-host-quickstart). Runtime backend configuration in Desktop is tracked in [issue #1371](https://github.com/multica-ai/multica/issues/1371).
|
||||
`apiUrl` is required and must use `http` or `https`. Desktop derives `wsUrl` as `/ws` on the same origin (`wss` for `https`, `ws` for `http`) and derives `appUrl` from the API origin. If your deployment uses different origins, set them explicitly:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain",
|
||||
"wsUrl": "wss://api.your-domain/ws",
|
||||
"appUrl": "https://your-domain"
|
||||
}
|
||||
```
|
||||
|
||||
If `desktop.json` exists but is invalid, Desktop fails closed and shows a blocking config error instead of silently falling back to Cloud. For development builds, `VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL` still take precedence during `electron-vite dev`. Runtime Desktop self-host configuration was implemented for [issue #1371](https://github.com/multica-ai/multica/issues/1371).
|
||||
</Callout>
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Cloud Quickstart](/cloud-quickstart) — the Cloud onboarding flow for Desktop
|
||||
- [Self-Host Quickstart](/self-host-quickstart) — running your own backend (Desktop against self-host requires a custom build, see the callout above)
|
||||
- [Self-Host Quickstart](/self-host-quickstart) — running your own backend and connecting with the CLI or Desktop runtime config
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how the daemon works (Desktop starts it for you, but the behavior is the same)
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Multica Desktop 是什么、和 Web 有什么区别、什么时候
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica Desktop 是原生桌面应用——macOS / Windows / Linux 三个平台。它和 Web 版连同一个后端,看到的数据完全一样,但给了几个 Web 做不到的能力:**[工作区](/workspaces) 独立的多标签页**、**自动启动 [守护进程](/daemon-runtimes)**、**一键升级**。
|
||||
Multica Desktop 是原生桌面应用——macOS / Windows / Linux 三个平台。对它当前配置的环境来说,它和 Web 版连同一个后端、看到的数据完全一样。Desktop 默认使用 Multica Cloud;自部署实例可以通过本地运行时配置文件接入。它还给了几个 Web 做不到的能力:**[工作区](/workspaces) 独立的多标签页**、**自动启动 [守护进程](/daemon-runtimes)**、**一键升级**。
|
||||
|
||||
## Desktop 和 Web 该用哪个
|
||||
|
||||
@@ -66,25 +66,34 @@ macOS 版本已经签名 + 公证,第一次打开不会有"未知开发者"的
|
||||
|
||||
安装后第一次打开需要登录——和 Web 版一样的 email + 验证码流程。登录成功后 Desktop 自动把工作区列表同步下来。
|
||||
|
||||
<Callout type="warning">
|
||||
**发布版的 Desktop 是锁死连 Multica Cloud 的**。后端 / WebSocket / Web 前端 URL(`VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`)在构建时就写死了,应用内**没有切换后端的入口**。要让 Desktop 连自部署后端,需要你自己从源码 build:
|
||||
<Callout type="info">
|
||||
**Desktop 默认连接 Multica Cloud,但可以通过本地配置文件指向自部署实例。** 应用内仍然没有“连接自部署”的切换入口。Desktop 会在 renderer 启动前读取 `~/.multica/desktop.json`;如果这个文件不存在,就使用 Cloud 默认值。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
# 编辑 apps/desktop/.env.production:
|
||||
# VITE_API_URL=https://api.your-domain
|
||||
# VITE_WS_URL=wss://api.your-domain/ws
|
||||
# VITE_APP_URL=https://your-domain
|
||||
pnpm install
|
||||
pnpm --filter @multica/desktop package
|
||||
最小自部署配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain"
|
||||
}
|
||||
```
|
||||
|
||||
不想自己 build 的话,自部署的官方路径是 **Web 前端 + CLI**——见 [自部署快速上手](/self-host-quickstart)。Desktop 运行时切换后端的能力跟踪在 [issue #1371](https://github.com/multica-ai/multica/issues/1371)。
|
||||
`apiUrl` 是必填项,必须使用 `http` 或 `https`。Desktop 会自动从它推导 `wsUrl`(同源 `/ws`,`https` 对应 `wss`,`http` 对应 `ws`)和 `appUrl`(API 的同源地址)。如果你的部署使用不同域名,可以显式设置:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain",
|
||||
"wsUrl": "wss://api.your-domain/ws",
|
||||
"appUrl": "https://your-domain"
|
||||
}
|
||||
```
|
||||
|
||||
如果 `desktop.json` 存在但内容无效,Desktop 会 fail closed,显示阻塞式配置错误,而不是悄悄回退到 Cloud。开发构建里,`electron-vite dev` 仍然优先使用 `VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`。Desktop 运行时自部署配置能力对应 [issue #1371](https://github.com/multica-ai/multica/issues/1371)。
|
||||
</Callout>
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Cloud Quickstart](/cloud-quickstart) —— Desktop 版的 Cloud 接入流程
|
||||
- [Self-Host Quickstart](/self-host-quickstart) —— 自部署后端(Desktop 连自部署需要自行构建,见上方提示)
|
||||
- [Self-Host Quickstart](/self-host-quickstart) —— 自部署后端,并通过 CLI 或 Desktop 运行时配置连接
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 守护进程机制(Desktop 自动起它,但行为一样)
|
||||
|
||||
@@ -66,13 +66,19 @@ Multica stores user-uploaded attachments (images and files in comments). **S3 is
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `S3_BUCKET` | empty | Setting this enables S3 storage |
|
||||
| `S3_REGION` | `us-west-2` | AWS region |
|
||||
| `S3_BUCKET` | empty | **Bucket name only** (for example `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public host from `S3_BUCKET` + `S3_REGION`. Setting this enables S3 storage |
|
||||
| `S3_REGION` | `us-west-2` | AWS region. Must match the bucket's actual region — it is used both for SDK signing and for building the public URL |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | empty | Static credentials. When both are unset, the AWS SDK default credential chain is used (IAM role / environment credentials) |
|
||||
| `AWS_ENDPOINT_URL` | empty | Custom S3-compatible endpoint (for example [MinIO](https://min.io/)). Setting this switches to path-style URLs |
|
||||
|
||||
**When `S3_BUCKET` is unset**: the server logs `"S3_BUCKET not set, cloud upload disabled"` at startup, and all uploads fall back to local disk.
|
||||
|
||||
**Public URLs** are constructed in this order of priority:
|
||||
|
||||
1. `https://<CLOUDFRONT_DOMAIN>/<key>` if `CLOUDFRONT_DOMAIN` is set.
|
||||
2. `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>` (path-style) if `AWS_ENDPOINT_URL` is set.
|
||||
3. `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>` (virtual-hosted-style). When `S3_BUCKET` contains dots, the server falls back to `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>` (path-style) because the AWS-issued wildcard TLS certificate does not validate dotted bucket hosts.
|
||||
|
||||
### Local disk (when S3 is not configured)
|
||||
|
||||
| Variable | Default | Description |
|
||||
|
||||
@@ -66,13 +66,19 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `S3_BUCKET` | 空 | 设了就启用 S3 存储 |
|
||||
| `S3_REGION` | `us-west-2` | AWS 区域 |
|
||||
| `S3_BUCKET` | 空 | **只填 bucket 名**(例如 `my-bucket`),**不要**带 `.s3.<region>.amazonaws.com` 后缀——server 会用 `S3_BUCKET` + `S3_REGION` 自己拼公开 host。设了就启用 S3 存储 |
|
||||
| `S3_REGION` | `us-west-2` | AWS 区域。必须和 bucket 所在区域一致——SDK 签名和公开 URL 都用它 |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | 空 | 静态凭证。全未设时用 AWS SDK 默认凭证链(IAM role / 环境凭证)|
|
||||
| `AWS_ENDPOINT_URL` | 空 | 自定义 S3 兼容端点(例如 [MinIO](https://min.io/))。设了会切到 path-style URL |
|
||||
|
||||
**`S3_BUCKET` 未设时**:server 启动时打 info 日志 `"S3_BUCKET not set, cloud upload disabled"`,所有上传回落到本地磁盘。
|
||||
|
||||
**公开 URL** 按优先级拼装:
|
||||
|
||||
1. 设了 `CLOUDFRONT_DOMAIN` → `https://<CLOUDFRONT_DOMAIN>/<key>`
|
||||
2. 设了 `AWS_ENDPOINT_URL` → `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>`(path-style)
|
||||
3. 默认走 AWS S3 → `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>`(virtual-hosted-style)。bucket 名含点时会回落到 `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>`(path-style),因为 AWS 通配证书无法覆盖含点 host。
|
||||
|
||||
### 本地磁盘(S3 未配时)
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|
||||
@@ -212,13 +212,15 @@ Changes take effect after restarting the backend / compose stack. The web UI rea
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
For file uploads and attachments, configure S3 and (optionally) CloudFront:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `S3_BUCKET` | S3 bucket name |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`) |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
|
||||
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ The daemon auto-detects which CLIs are available on your PATH and registers them
|
||||
|
||||
Multica supports two layers of skills:
|
||||
|
||||
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.config/opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
|
||||
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
|
||||
- **Workspace skills** — Skills created or imported in the Multica Skills page are shared across the workspace. They are automatically injected into agent runs as supplementary context, so every team member's agents benefit from them.
|
||||
|
||||
Workspace skills are designed for team-wide sharing and collaboration — codify your team's best practices once, and every agent can leverage them:
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"members-roles",
|
||||
"issues",
|
||||
"comments",
|
||||
"project-resources",
|
||||
"---Agents---",
|
||||
"agents",
|
||||
"agents-create",
|
||||
|
||||
144
apps/docs/content/docs/project-resources.mdx
Normal file
144
apps/docs/content/docs/project-resources.mdx
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: Project Resources
|
||||
description: Attach typed pointers (Git repos today, more later) to a project so agents can pick them up as scoped context.
|
||||
---
|
||||
|
||||
A **Project Resource** is a typed pointer — a Git repo URL today, a Notion page or document link tomorrow — attached to a [project](/workspaces). When an [agent](/agents) runs against an issue inside that project, the daemon automatically writes the project's resource list into the agent's working directory and into its [meta-skill](/skills) prompt.
|
||||
|
||||
The result: the agent knows which repo to check out, which docs are the "primary references" for this project, without anyone copy-pasting context into the issue body.
|
||||
|
||||
## Mental model
|
||||
|
||||
A project is no longer just a label. It is a small **resource container**:
|
||||
|
||||
- A project has 0..N **resources**.
|
||||
- A resource has a `resource_type` (e.g. `github_repo`) and a `resource_ref` (a JSON payload typed by `resource_type`).
|
||||
- New resource types add a string + a handler. **No schema migration. No frontend rewrite.**
|
||||
|
||||
This shape is intentional — it's the same pattern Multica already uses for agent providers: a `type` discriminator and a typed payload. It keeps the schema stable so adding "Notion page", "Google Doc", "uploaded file", or "external URL" later is a small, additive change.
|
||||
|
||||
## Today: `github_repo`
|
||||
|
||||
The first resource type ships ready to use:
|
||||
|
||||
```json
|
||||
{
|
||||
"resource_type": "github_repo",
|
||||
"resource_ref": {
|
||||
"url": "https://github.com/owner/repo",
|
||||
"default_branch_hint": "main"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`default_branch_hint` is optional — if present, the daemon surfaces it in the meta-skill so the agent knows which branch to base its work on.
|
||||
|
||||
## Attaching repos at project creation
|
||||
|
||||
In the **Web** or **Desktop** app, opening *New project* now shows a **Repos** pill alongside Status / Priority / Lead. Selecting workspace-bound repos (or pasting an ad-hoc URL) attaches them as `github_repo` resources the moment the project is created.
|
||||
|
||||
From the **CLI**:
|
||||
|
||||
```bash
|
||||
# Create + attach in one shot. The server attaches resources in the same
|
||||
# transaction as the project create — invalid resources roll back the whole
|
||||
# operation, so you never end up with a project that has half its resources.
|
||||
multica project create \
|
||||
--title "Agent UX 2026" \
|
||||
--repo https://github.com/multica-ai/multica
|
||||
|
||||
# Manage resources later
|
||||
multica project resource list <project-id>
|
||||
multica project resource add <project-id> --type github_repo --url <url>
|
||||
multica project resource remove <project-id> <resource-id>
|
||||
|
||||
# Generic escape hatch for any resource_type the server understands —
|
||||
# no CLI change needed when a new type ships:
|
||||
multica project resource add <project-id> \
|
||||
--type notion_page \
|
||||
--ref '{"page_id":"…","title":"…"}'
|
||||
```
|
||||
|
||||
`--repo` may be repeated; each value is attached as a separate `github_repo` resource.
|
||||
|
||||
## What the agent sees at runtime
|
||||
|
||||
When the daemon spawns an agent for an issue inside a project, two things happen:
|
||||
|
||||
### 1. `.multica/project/resources.json`
|
||||
|
||||
A structured pass-through of the API response, written into the agent's working directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"project_id": "…",
|
||||
"project_title": "Agent UX 2026",
|
||||
"resources": [
|
||||
{
|
||||
"id": "…",
|
||||
"resource_type": "github_repo",
|
||||
"resource_ref": {
|
||||
"url": "https://github.com/multica-ai/multica",
|
||||
"default_branch_hint": "main"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Skills, helper scripts, or the agent itself can parse this file when they need the *exact* set of resources for the run.
|
||||
|
||||
### 2. A "Project Context" section in the meta-skill prompt
|
||||
|
||||
The agent's `CLAUDE.md` / `AGENTS.md` (depending on provider) now includes a human-readable summary:
|
||||
|
||||
```
|
||||
## Project Context
|
||||
|
||||
This issue belongs to **Agent UX 2026**.
|
||||
|
||||
Project resources (also written to `.multica/project/resources.json`):
|
||||
|
||||
- **GitHub repo**: https://github.com/multica-ai/multica (default branch: `main`)
|
||||
|
||||
Resources are pointers — open them only when relevant to the task. For
|
||||
`github_repo` resources, use `multica repo checkout <url>` to fetch the code.
|
||||
```
|
||||
|
||||
The text is intentionally minimal. The full payload is on disk; the prompt only orients the agent so it knows the project exists and what's attached.
|
||||
|
||||
### Failure mode
|
||||
|
||||
Resource fetch is **best-effort**. If the API call fails, the project section is omitted from the prompt and the file is not written, but the task still starts. Agents never block on missing project context.
|
||||
|
||||
## Adding a new resource type
|
||||
|
||||
The whole point of the abstraction is that new types are cheap. The full path:
|
||||
|
||||
1. **Server validator** (`server/internal/handler/project_resource.go`) — add a case in `validateAndNormalizeResourceRef` that parses and normalizes the new payload.
|
||||
2. **Daemon meta-skill formatter** (`server/internal/daemon/execenv/runtime_config.go`) — add a case in `formatProjectResource` so the agent prompt renders the new type as a readable bullet.
|
||||
3. **TypeScript types** (`packages/core/types/project.ts`) — extend `ProjectResourceType` and add the payload interface.
|
||||
4. **UI renderer** (`packages/views/projects/components/project-resources-section.tsx`) — add a case in `ResourceRow` for the new type.
|
||||
|
||||
There is **no schema migration**, no new sqlc query, no new endpoint, **and no CLI change** — the CLI's generic `--ref '<json>'` flag accepts any payload the validator understands, so day-one support for a new type is purely the four steps above. (You may *optionally* add a per-type CLI shortcut later; not required.)
|
||||
|
||||
The same `project_resource` table and the same three CRUD calls handle every type.
|
||||
|
||||
## Workspace repos vs. project repos
|
||||
|
||||
The repo list shown to the agent (`## Repositories` block in `CLAUDE.md` / `AGENTS.md`) is chosen by the daemon claim handler with this precedence:
|
||||
|
||||
- **Project has at least one `github_repo` resource** → only those repos are surfaced to the agent. Workspace-bound repos are intentionally hidden so the agent doesn't have to guess which one belongs to this issue.
|
||||
- **Project has no `github_repo` resources (or the issue isn't in a project)** → fall back to the workspace's repo list as before.
|
||||
|
||||
This keeps the agent's working set tight: when a project is explicit about its repos, that's the authoritative answer. The structured resource list at `.multica/project/resources.json` always carries the full set, so a skill that wants to inspect everything still can.
|
||||
|
||||
The daemon mirrors this on the checkout side: when a task arrives with project-scoped `github_repo` URLs, those URLs are merged into the per-workspace allowlist *and* synced into the local repo cache before the agent spawns. So a project repo URL that isn't bound at the workspace level is still a valid argument to `multica repo checkout` — the daemon won't reject it as "not configured." The allowlist split is internal: workspace-bound URLs and task-scoped URLs are tracked separately, so a workspace-repos refresh doesn't accidentally revoke a project URL mid-run.
|
||||
|
||||
## What's intentionally **not** in scope here
|
||||
|
||||
- **Cross-project sharing.** Each resource lives on exactly one project today.
|
||||
- **Per-skill resource scoping.** All resources are visible to every skill on the agent's run; type-aware filtering is a follow-up.
|
||||
- **Caching / sync.** `github_repo` is just metadata — checkout still happens via `multica repo checkout` on demand. Cached document text for Notion / Google Docs will arrive with those types.
|
||||
|
||||
These are deliberate omissions — the goal of the first cut is to validate the abstraction with the smallest set of moving parts.
|
||||
@@ -21,7 +21,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
|
||||
| **Hermes** | Nous Research | ✅ | ❌ | `.agent_context/skills/` (fallback) | Dynamic discovery |
|
||||
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | Dynamic discovery |
|
||||
| **Kiro CLI** | Amazon | ✅ | ❌ | `.kiro/skills/` | Dynamic discovery |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.config/opencode/skills/` | Dynamic discovery |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.opencode/skills/` | Dynamic discovery |
|
||||
| **OpenClaw** | Open source | ✅ | ❌ | `.agent_context/skills/` (fallback) | Bound to the agent, can't be switched per task |
|
||||
| **Pi** | Inflection AI | ✅ (session is a file path) | ❌ | `.pi/skills/` | Dynamic discovery |
|
||||
|
||||
@@ -103,7 +103,7 @@ Each tool uses **its own** skill discovery path. Before a task runs, the Multica
|
||||
| Cursor | `.cursor/skills/` | ✅ Native |
|
||||
| Kimi | `.kimi/skills/` | ✅ Native |
|
||||
| Kiro CLI | `.kiro/skills/` | ✅ Native |
|
||||
| OpenCode | `.config/opencode/skills/` | ✅ Native |
|
||||
| OpenCode | `.opencode/skills/` | ✅ Native |
|
||||
| Pi | `.pi/skills/` | ✅ Native |
|
||||
| Gemini | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
| Hermes | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
|
||||
@@ -21,7 +21,7 @@ Multica 内置支持 **11 款 AI 编程工具**。它们都实现了同一套接
|
||||
| **Hermes** | Nous Research | ✅ | ❌ | `.agent_context/skills/` (fallback)| 动态发现 |
|
||||
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | 动态发现 |
|
||||
| **Kiro CLI** | Amazon | ✅ | ❌ | `.kiro/skills/` | 动态发现 |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.config/opencode/skills/` | 动态发现 |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.opencode/skills/` | 动态发现 |
|
||||
| **OpenClaw** | 开源项目 | ✅ | ❌ | `.agent_context/skills/` (fallback)| 绑定在智能体上,不能在任务里切换 |
|
||||
| **Pi** | Inflection AI | ✅(session 为文件路径)| ❌ | `.pi/skills/` | 动态发现 |
|
||||
|
||||
@@ -103,7 +103,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
|
||||
| Cursor | `.cursor/skills/` | ✅ 原生 |
|
||||
| Kimi | `.kimi/skills/` | ✅ 原生 |
|
||||
| Kiro CLI | `.kiro/skills/` | ✅ 原生 |
|
||||
| OpenCode | `.config/opencode/skills/` | ✅ 原生 |
|
||||
| OpenCode | `.opencode/skills/` | ✅ 原生 |
|
||||
| Pi | `.pi/skills/` | ✅ 原生 |
|
||||
| Gemini | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
| Hermes | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
|
||||
@@ -116,4 +116,4 @@ Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-
|
||||
- [Environment variables](/environment-variables) — full env reference
|
||||
- [Auth setup](/auth-setup) — Resend / OAuth / signup allowlist in detail
|
||||
- [Troubleshooting](/troubleshooting) — start here when things go wrong
|
||||
- [Desktop app](/desktop-app) — released Desktop builds connect to Multica Cloud only; using Desktop with self-host requires a custom build (see the callout in the desktop-app page)
|
||||
- [Desktop app](/desktop-app) — optional Desktop setup via `~/.multica/desktop.json`; the web frontend + CLI remains the quickest self-host path
|
||||
|
||||
@@ -115,4 +115,4 @@ multica setup self-host
|
||||
- [环境变量](/environment-variables) —— 完整 env 清单
|
||||
- [登录与注册配置](/auth-setup) —— Resend / OAuth / 注册白名单详细配置
|
||||
- [故障排查](/troubleshooting) —— 遇到问题先来这里
|
||||
- [桌面应用](/desktop-app) —— 发布版 Desktop 只连 Multica Cloud;要让 Desktop 连自部署后端需要自行构建(详见 desktop-app 页的提示)
|
||||
- [桌面应用](/desktop-app) —— 可以通过 `~/.multica/desktop.json` 连接 Desktop;Web 前端 + CLI 仍然是最快的自部署路径
|
||||
|
||||
28
apps/web/app/(auth)/invitations/page.tsx
Normal file
28
apps/web/app/(auth)/invitations/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { InvitationsPage } from "@multica/views/invitations";
|
||||
|
||||
export default function InvitationsRoutePage() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
// Unauthenticated users have nowhere meaningful to land here — kick them
|
||||
// through login and bring them back. The login page will eventually run
|
||||
// its own listMyInvitations() check and route them here again.
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
router.replace(
|
||||
`${paths.login()}?next=${encodeURIComponent(paths.invitations())}`,
|
||||
);
|
||||
}
|
||||
}, [isLoading, user, router]);
|
||||
|
||||
if (isLoading || !user) return null;
|
||||
|
||||
return <InvitationsPage />;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
|
||||
import { useConfigStore } from "@multica/core/config";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
@@ -27,6 +27,32 @@ import { setLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import Link from "next/link";
|
||||
import { LoginPage, validateCliCallback } from "@multica/views/auth";
|
||||
|
||||
/**
|
||||
* Pick where a logged-in user with no explicit `?next=` should land.
|
||||
* Un-onboarded users with pending invitations on their email get routed to
|
||||
* the batch /invitations page; everyone else falls through to the standard
|
||||
* resolver. A network blip on listMyInvitations is non-fatal — we fall
|
||||
* through rather than trap the user on an error screen.
|
||||
*/
|
||||
async function resolveLoggedInDestination(
|
||||
qc: QueryClient,
|
||||
hasOnboarded: boolean,
|
||||
workspaces: Workspace[],
|
||||
): Promise<string> {
|
||||
if (!hasOnboarded) {
|
||||
try {
|
||||
const invites = await api.listMyInvitations();
|
||||
if (invites.length > 0) {
|
||||
qc.setQueryData(workspaceKeys.myInvitations(), invites);
|
||||
return paths.invitations();
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return resolvePostAuthDestination(workspaces, hasOnboarded);
|
||||
}
|
||||
|
||||
function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
@@ -77,10 +103,12 @@ function LoginPageContent() {
|
||||
return;
|
||||
}
|
||||
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
|
||||
router.replace(resolvePostAuthDestination(list, hasOnboarded));
|
||||
void resolveLoggedInDestination(qc, hasOnboarded, list).then((dest) =>
|
||||
router.replace(dest),
|
||||
);
|
||||
}, [isLoading, user, router, nextUrl, cliCallbackRaw, isDesktopHandoff, hasOnboarded, qc]);
|
||||
|
||||
const handleSuccess = () => {
|
||||
const handleSuccess = async () => {
|
||||
// Read the latest user snapshot directly — the closure's `hasOnboarded`
|
||||
// was captured before login completed and would be stale here.
|
||||
const currentUser = useAuthStore.getState().user;
|
||||
@@ -90,7 +118,8 @@ function LoginPageContent() {
|
||||
return;
|
||||
}
|
||||
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
|
||||
router.push(resolvePostAuthDestination(list, onboarded));
|
||||
const dest = await resolveLoggedInDestination(qc, onboarded, list);
|
||||
router.push(dest);
|
||||
};
|
||||
|
||||
// Build Google OAuth state: encode platform + next URL so the callback
|
||||
|
||||
@@ -34,7 +34,6 @@ export default function OnboardingPage() {
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
const hasWorkspaces = workspaces.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user) {
|
||||
@@ -42,15 +41,19 @@ export default function OnboardingPage() {
|
||||
return;
|
||||
}
|
||||
if (!workspacesFetched) return;
|
||||
// Bounce out if onboarding doesn't apply: either already onboarded, or
|
||||
// the user already has a workspace (e.g. arrived via invitation) — we
|
||||
// never trap an in-workspace user on the onboarding screen.
|
||||
if (hasOnboarded || hasWorkspaces) {
|
||||
// Bounce out only when onboarding genuinely doesn't apply: the user is
|
||||
// already onboarded. We deliberately don't bounce on `workspaces.length`
|
||||
// here — Step 3 of the flow creates a workspace mid-onboarding, and a
|
||||
// hasWorkspaces bounce here would kick the user out before Steps 4–5
|
||||
// (runtime / agent / first issue) can run. The new entry-point
|
||||
// judgment in callback / login handles "where should this user go on
|
||||
// login" so OnboardingPage no longer needs to second-guess it.
|
||||
if (hasOnboarded) {
|
||||
router.replace(resolvePostAuthDestination(workspaces, hasOnboarded));
|
||||
}
|
||||
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, hasWorkspaces, router]);
|
||||
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, router]);
|
||||
|
||||
if (isLoading || !user || hasOnboarded || hasWorkspaces) return null;
|
||||
if (isLoading || !user || hasOnboarded) return null;
|
||||
|
||||
// Layout: page owns its own scroll (root layout sets `body {
|
||||
// overflow: hidden }` for the app-shell convention). OnboardingFlow
|
||||
|
||||
@@ -2,13 +2,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { paths } from "@multica/core/paths";
|
||||
|
||||
const { mockPush, mockSearchParams, mockLoginWithGoogle, mockListWorkspaces } =
|
||||
vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockSearchParams: new URLSearchParams(),
|
||||
mockLoginWithGoogle: vi.fn(),
|
||||
mockListWorkspaces: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
mockPush,
|
||||
mockSearchParams,
|
||||
mockLoginWithGoogle,
|
||||
mockListWorkspaces,
|
||||
mockListMyInvitations,
|
||||
mockSetQueryData,
|
||||
} = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockSearchParams: new URLSearchParams(),
|
||||
mockLoginWithGoogle: vi.fn(),
|
||||
mockListWorkspaces: vi.fn(),
|
||||
mockListMyInvitations: vi.fn(),
|
||||
mockSetQueryData: vi.fn(),
|
||||
}));
|
||||
|
||||
const makeUser = (overrides: Partial<{ onboarded_at: string | null }> = {}) => ({
|
||||
id: "user-1",
|
||||
@@ -28,7 +36,7 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ setQueryData: vi.fn() }),
|
||||
useQueryClient: () => ({ setQueryData: mockSetQueryData }),
|
||||
}));
|
||||
|
||||
// Preserve the real sanitizeNextUrl so the "drop unsafe ?next=" behavior is
|
||||
@@ -46,12 +54,16 @@ vi.mock("@multica/core/auth", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
workspaceKeys: { list: () => ["workspaces"] },
|
||||
workspaceKeys: {
|
||||
list: () => ["workspaces"],
|
||||
myInvitations: () => ["invitations", "mine"],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listWorkspaces: mockListWorkspaces,
|
||||
listMyInvitations: mockListMyInvitations,
|
||||
googleLogin: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -69,6 +81,7 @@ describe("CallbackPage", () => {
|
||||
mockSearchParams.set("code", "test-code");
|
||||
mockLoginWithGoogle.mockResolvedValue(makeUser());
|
||||
mockListWorkspaces.mockResolvedValue([]);
|
||||
mockListMyInvitations.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("unonboarded user honors a safe next= (e.g. /invite/{id}) so invitees aren't trapped", async () => {
|
||||
@@ -78,16 +91,39 @@ describe("CallbackPage", () => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
|
||||
});
|
||||
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
|
||||
// nextUrl is a fast path — listMyInvitations should not be queried.
|
||||
expect(mockListMyInvitations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unonboarded user with no next= and zero workspaces lands on /onboarding", async () => {
|
||||
it("unonboarded user with no next= and no pending invitations lands on /onboarding", async () => {
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
expect(mockListMyInvitations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unonboarded user with existing workspace lands in that workspace, not /onboarding", async () => {
|
||||
it("unonboarded user with pending invitations lands on /invitations", async () => {
|
||||
mockListMyInvitations.mockResolvedValue([
|
||||
{
|
||||
id: "inv-1",
|
||||
workspace_id: "ws-1",
|
||||
workspace_name: "Acme",
|
||||
role: "member",
|
||||
status: "pending",
|
||||
},
|
||||
]);
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.invitations());
|
||||
});
|
||||
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
|
||||
it("onboarded user with workspace lands in that workspace", async () => {
|
||||
mockLoginWithGoogle.mockResolvedValue(
|
||||
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
|
||||
);
|
||||
mockListWorkspaces.mockResolvedValue([
|
||||
{
|
||||
id: "ws-1",
|
||||
@@ -106,7 +142,9 @@ describe("CallbackPage", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.workspace("acme").issues());
|
||||
});
|
||||
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
|
||||
// Already-onboarded users skip the listMyInvitations check; new invites
|
||||
// surface in the sidebar instead of the wall.
|
||||
expect(mockListMyInvitations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onboarded user ignores unsafe next= targets and lands on the default destination", async () => {
|
||||
@@ -135,4 +173,12 @@ describe("CallbackPage", () => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
|
||||
});
|
||||
});
|
||||
|
||||
it("falls through to /onboarding when listMyInvitations errors", async () => {
|
||||
mockListMyInvitations.mockRejectedValue(new Error("network"));
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,13 +66,42 @@ function CallbackContent() {
|
||||
const wsList = await api.listWorkspaces();
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
const onboarded = loggedInUser.onboarded_at != null;
|
||||
// Workspace presence beats onboarding state: an invitee with zero
|
||||
// `onboarded_at` but a real workspace must land in that workspace,
|
||||
// not in the new-workspace wizard. A `next=` (e.g. /invite/<id>)
|
||||
// always wins so invite acceptance flows survive auth round-trips.
|
||||
router.push(
|
||||
nextUrl || resolvePostAuthDestination(wsList, onboarded),
|
||||
);
|
||||
|
||||
// 1. nextUrl wins: a `next=/invite/<id>` always survives the OAuth
|
||||
// round-trip — the user clicked a specific link and we should
|
||||
// honor exactly that destination.
|
||||
if (nextUrl) {
|
||||
router.push(nextUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Un-onboarded users may have pending invitations on their
|
||||
// email even when no `next=` was carried (came from a fresh
|
||||
// login on app.multica.ai instead of clicking the email link,
|
||||
// or `state` was lost across the round-trip). Look them up by
|
||||
// email and route to the batch /invitations page if any.
|
||||
// Already-onboarded users skip this lookup — their new invites
|
||||
// surface in the sidebar dropdown, not as a forced wall.
|
||||
if (!onboarded) {
|
||||
try {
|
||||
const invites = await api.listMyInvitations();
|
||||
if (invites.length > 0) {
|
||||
qc.setQueryData(workspaceKeys.myInvitations(), invites);
|
||||
router.push(paths.invitations());
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Network blip on the invite lookup is non-fatal — fall through
|
||||
// to the normal post-auth destination so the user isn't stuck
|
||||
// on a blank callback screen. Worst case they land on
|
||||
// /onboarding and the sidebar will surface invites later.
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Default: hand off to the resolver (onboarding for first-timers,
|
||||
// first workspace for returning users, /workspaces/new for
|
||||
// onboarded users with zero workspaces).
|
||||
router.push(resolvePostAuthDestination(wsList, onboarded));
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
|
||||
@@ -1,61 +1,19 @@
|
||||
import { Instrument_Serif } from "next/font/google";
|
||||
"use client";
|
||||
|
||||
// Editorial-style 404. Cream + ink + terracotta palette is intentionally
|
||||
// inline — these brand experiments have not been promoted to design tokens.
|
||||
// The route lives outside the (landing) group's font scope, so we attach
|
||||
// Instrument Serif locally to match the editorial direction.
|
||||
const CREAM = "#faf9f6";
|
||||
const INK = "#1b1812";
|
||||
const TERRACOTTA = "#a64a2c";
|
||||
|
||||
const editorialSerif = Instrument_Serif({
|
||||
subsets: ["latin"],
|
||||
weight: "400",
|
||||
variable: "--font-serif",
|
||||
});
|
||||
import Link from "next/link";
|
||||
import { buttonVariants } from "@multica/ui/components/ui/button";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<section
|
||||
className={`${editorialSerif.variable} relative flex min-h-screen flex-col items-center justify-center px-6 py-16`}
|
||||
style={{ backgroundColor: CREAM, color: INK }}
|
||||
>
|
||||
{/* tracking is wider than Tailwind's tracking-widest (0.1em) — editorial eyebrow detail, deliberate. */}
|
||||
<div
|
||||
className="flex items-center gap-3 text-xs uppercase tracking-[0.25em]"
|
||||
style={{ color: TERRACOTTA }}
|
||||
>
|
||||
<span aria-hidden="true" className="inline-block h-px w-10" style={{ background: TERRACOTTA }} />
|
||||
<span>error · not found</span>
|
||||
<span aria-hidden="true" className="inline-block h-px w-10" style={{ background: TERRACOTTA }} />
|
||||
</div>
|
||||
|
||||
{/* Fluid hero size + ultra-tight leading; outside the Tailwind type scale by design. */}
|
||||
<h1 className="mt-12 font-serif text-[clamp(7rem,16vw,15rem)] leading-[0.85] tracking-tight">
|
||||
404
|
||||
</h1>
|
||||
|
||||
<p className="mt-10 max-w-xl text-center font-serif text-3xl leading-tight">
|
||||
This page{" "}
|
||||
<em className="not-italic" style={{ color: TERRACOTTA }}>
|
||||
doesn’t exist
|
||||
</em>
|
||||
.
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 py-24 text-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">404</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Page not found</h1>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
The page you are looking for doesn’t exist or has been moved.
|
||||
</p>
|
||||
<p
|
||||
className="mt-5 max-w-md text-center text-sm leading-relaxed"
|
||||
style={{ color: INK, opacity: 0.6 }}
|
||||
>
|
||||
The URL may have changed, the resource may be deleted, or you arrived from a stale link.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
className="mt-12 inline-flex h-10 items-center rounded-full px-6 text-sm font-medium transition hover:opacity-90"
|
||||
style={{ background: INK, color: CREAM }}
|
||||
>
|
||||
<Link href="/" className={buttonVariants({ className: "mt-2" })}>
|
||||
Back to Multica
|
||||
</a>
|
||||
</section>
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -283,6 +283,58 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.24",
|
||||
date: "2026-05-03",
|
||||
title: "Repo Checkout `--ref`, Hermes Replay Fix & Multi-Replica Model Picker",
|
||||
changes: [],
|
||||
features: [
|
||||
"`multica repo checkout --ref` targets a branch, tag, or specific commit when pulling a repo into the workspace",
|
||||
"`multica agent avatar` uploads an agent avatar straight from the CLI",
|
||||
"Inbox shows an archive button on done tasks; the redundant mark-as-done hover button is gone",
|
||||
],
|
||||
improvements: [
|
||||
"Long-timeline issues open instantly from Inbox — the markdown render pipeline is memoized so unrelated WS events no longer re-render thousands of comments",
|
||||
"Model picker works on multi-replica deployments — pending requests persist via Redis, with daemon retries on transient report failures",
|
||||
"Daemon empty-claim cache TTL bumped, further reducing idle DB load",
|
||||
],
|
||||
fixes: [
|
||||
"Newly created agents show up everywhere immediately — the agent cache is hydrated on create",
|
||||
"Hermes no longer replays the previous answer when a new turn starts — historical chunks are gated behind a per-turn flag",
|
||||
"Codex runtime model picker exposes the GPT-5.5 family",
|
||||
"`multica login --token <PAT>` accepts the PAT as a flag value instead of rejecting it",
|
||||
"CLI update completion status is now reliable",
|
||||
"Session resume is guarded by runtime, preventing cross-runtime resume",
|
||||
"Kanban display settings survive when dragging issues across columns",
|
||||
"Autopilot list is responsive on mobile viewports",
|
||||
"Quick Create prompts produce higher-fidelity descriptions from the user's input",
|
||||
"Skill upsert sanitizes null bytes, fixing a PostgreSQL UTF8 error",
|
||||
"Connect Remote dialog points to the correct install script URL",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.21",
|
||||
date: "2026-04-30",
|
||||
title: "Quick Capture Overhaul, Mermaid Diagrams & Typed Project Resources",
|
||||
changes: [],
|
||||
features: [
|
||||
"Quick Capture replaces the old New Issue dialog — continuous-create mode, file uploads, and automatic enrichment from pasted URLs",
|
||||
"Mermaid diagrams render inline in markdown, with a fullscreen lightbox for complex graphs",
|
||||
"Projects can bind their own repo, separate from the workspace default",
|
||||
"Permission-aware UI across agents, comments, runtimes, and skills — actions you can't take are no longer offered",
|
||||
],
|
||||
improvements: [
|
||||
"Daemon `/tasks/claim` polling uses a Redis empty-claim fast-path, dropping idle DB load and reclaiming disk on long-open issues",
|
||||
"Multica Agent commits include a `Co-authored-by` trailer for proper Git attribution",
|
||||
"Desktop blocks Cmd+R / Ctrl+R / F5 from reloading the app and shows the real version in dev and Updates settings",
|
||||
],
|
||||
fixes: [
|
||||
"Quick Create no longer invents requirements beyond user input, and subscribes the requester to the issue it creates",
|
||||
"Inbox jumps straight to the targeted comment, and auto-archives when the issue is marked Done from the detail page",
|
||||
"Task rerun starts a fresh session and skips poisoned resume state",
|
||||
"Invitees land on their workspace after sign-in instead of being forced through `/onboarding`",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.20",
|
||||
date: "2026-04-29",
|
||||
|
||||
@@ -283,6 +283,58 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.24",
|
||||
date: "2026-05-03",
|
||||
title: "Repo Checkout `--ref`、Hermes 历史回放修复与多副本 Model Picker",
|
||||
changes: [],
|
||||
features: [
|
||||
"`multica repo checkout --ref` 支持按分支、tag 或指定 commit 拉取仓库",
|
||||
"`multica agent avatar` 命令支持直接通过 CLI 上传 Agent 头像",
|
||||
"Inbox 中已完成任务新增 archive 按钮,移除冗余的 mark-as-done 悬浮按钮",
|
||||
],
|
||||
improvements: [
|
||||
"长 timeline 的 Issue 从 Inbox 打开不再卡顿 —— Markdown 渲染管线已 memoize,无关的 WS 事件不会再重渲染数千条评论",
|
||||
"Model Picker 在多副本部署下可用 —— pending 请求改走 Redis 持久化,Daemon 上报失败也会自动重试",
|
||||
"Daemon 空认领缓存 TTL 调高,空闲态 DB 压力进一步下降",
|
||||
],
|
||||
fixes: [
|
||||
"新创建的 Agent 立刻在各处可见 —— 创建时即 hydrate Agent 缓存",
|
||||
"Hermes 在新一轮对话开始时不再重放上一轮答案 —— 历史 chunk 受单轮门禁限制",
|
||||
"Codex runtime 模型选择器开放 GPT-5.5 系列",
|
||||
"`multica login --token <PAT>` 正确接收 PAT 作为参数值",
|
||||
"CLI update 完成状态上报更可靠",
|
||||
"Session resume 按 runtime 正确守卫,避免跨 runtime 复用 session",
|
||||
"看板拖拽 Issue 时显示设置不再丢失",
|
||||
"Autopilot 列表在移动端 viewport 下响应式排版",
|
||||
"Quick Create 生成的描述更贴合用户输入",
|
||||
"Skill upsert 清理 null bytes,修复 PostgreSQL UTF8 错误",
|
||||
"Connect Remote 弹窗的安装脚本 URL 修正",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.21",
|
||||
date: "2026-04-30",
|
||||
title: "Quick Capture 全面升级、Mermaid 图表与 Typed Project Resources",
|
||||
changes: [],
|
||||
features: [
|
||||
"Quick Capture 取代旧的 New Issue 弹窗 —— 支持连续创建、文件上传,并能根据粘贴的 URL 自动丰富标题与描述",
|
||||
"Markdown 内联渲染 Mermaid 图表,复杂图支持全屏 lightbox",
|
||||
"Project 支持单独绑定 repo,无需依赖 workspace 默认配置",
|
||||
"Agent / 评论 / Runtime / Skill 全面接入权限感知 UI,没有权限的操作不再展示",
|
||||
],
|
||||
improvements: [
|
||||
"Daemon `/tasks/claim` 轮询走 Redis 空认领 fast-path,空闲态 DB 压力下降,长期 open 的 Issue 自动回收磁盘",
|
||||
"Multica Agent 的 Git 提交自动追加 `Co-authored-by` trailer,归属更清晰",
|
||||
"Desktop 拦截 Cmd+R / Ctrl+R / F5 防止意外刷新,开发模式与 Updates 设置中均展示真实版本号",
|
||||
],
|
||||
fixes: [
|
||||
"Quick Create 不再凭空脑补需求,并自动把发起人订阅到 Issue",
|
||||
"Inbox 点击通知后立即跳到目标评论;从 Issue 详情页 Mark as Done 时自动归档",
|
||||
"Task rerun 启动全新 session,跳过被污染的 resume 状态",
|
||||
"受邀成员登录后路由到所在 workspace,不再强制带去 `/onboarding`",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.20",
|
||||
date: "2026-04-29",
|
||||
|
||||
@@ -372,7 +372,7 @@ skill
|
||||
3. **注入**:当 agent 认领任务时,daemon 把挂载的 skill 内容写到任务工作目录的 **provider 原生位置**:
|
||||
- Claude Code → `.claude/skills/{name}/SKILL.md`
|
||||
- Codex → `CODEX_HOME/skills/{name}/`
|
||||
- OpenCode → `.config/opencode/skills/{name}/SKILL.md`
|
||||
- OpenCode → `.opencode/skills/{name}/SKILL.md`
|
||||
- Pi → `.pi/skills/{name}/SKILL.md`
|
||||
- Cursor → `.cursor/skills/{name}/SKILL.md`
|
||||
- GitHub Copilot → `.github/skills/{name}/SKILL.md`
|
||||
|
||||
@@ -5,3 +5,5 @@ export * from "./use-agent-presence";
|
||||
export * from "./use-agent-activity";
|
||||
export * from "./use-workspace-presence-prefetch";
|
||||
export * from "./constants";
|
||||
export * from "./visibility-label";
|
||||
export * from "./use-workspace-agent-availability";
|
||||
|
||||
60
packages/core/agents/use-workspace-agent-availability.ts
Normal file
60
packages/core/agents/use-workspace-agent-availability.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { useAuthStore } from "../auth";
|
||||
import { agentListOptions, memberListOptions } from "../workspace/queries";
|
||||
import { canAssignAgentToIssue } from "../permissions";
|
||||
|
||||
/**
|
||||
* Three-state availability for "does the current user have any agent
|
||||
* they can chat with in this workspace?".
|
||||
*
|
||||
* Why three states (not a boolean): the answer to "is there an agent?"
|
||||
* lives on the server. Until the agent-list query resolves, the answer
|
||||
* is genuinely *unknown*. Callers must distinguish "loading" from
|
||||
* "confirmed empty" — collapsing them to a boolean causes UIs to flash
|
||||
* disabled/empty states for the first few hundred ms after mount, even
|
||||
* when the workspace actually has agents.
|
||||
*
|
||||
* "loading" — agent or member list still in flight (be neutral in UI)
|
||||
* "none" — both queries resolved, user has zero assignable agents
|
||||
* "available" — at least one agent passes archive + visibility filters
|
||||
*/
|
||||
export type WorkspaceAgentAvailability = "loading" | "none" | "available";
|
||||
|
||||
/**
|
||||
* Mirrors the per-agent visibility/archived filter used by AssigneePicker
|
||||
* and the chat agent dropdown, so the three pickers can never disagree on
|
||||
* "is this agent reachable?".
|
||||
*
|
||||
* Members are queried because `canAssignAgentToIssue` reads the caller's
|
||||
* role to decide visibility for `private` agents — without member data,
|
||||
* a freshly-loaded agent list could still produce wrong answers.
|
||||
*/
|
||||
export function useWorkspaceAgentAvailability(): WorkspaceAgentAvailability {
|
||||
const wsId = useWorkspaceId();
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const { data: agents, isFetched: agentsFetched } = useQuery(
|
||||
agentListOptions(wsId),
|
||||
);
|
||||
const { data: members, isFetched: membersFetched } = useQuery(
|
||||
memberListOptions(wsId),
|
||||
);
|
||||
|
||||
if (!agentsFetched || !membersFetched) return "loading";
|
||||
|
||||
const rawRole = members?.find((m) => m.user_id === userId)?.role;
|
||||
const role =
|
||||
rawRole === "owner" || rawRole === "admin" || rawRole === "member"
|
||||
? rawRole
|
||||
: null;
|
||||
|
||||
const hasVisibleAgent = (agents ?? []).some(
|
||||
(a) =>
|
||||
!a.archived_at &&
|
||||
canAssignAgentToIssue(a, { userId: userId ?? null, role }).allowed,
|
||||
);
|
||||
|
||||
return hasVisibleAgent ? "available" : "none";
|
||||
}
|
||||
31
packages/core/agents/visibility-label.ts
Normal file
31
packages/core/agents/visibility-label.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { AgentVisibility } from "../types";
|
||||
|
||||
/**
|
||||
* Display labels for agent visibility. The DB stores `private` as the value
|
||||
* but the UI surface name is "Personal" — better matches what the field
|
||||
* actually means now that workspace admins can also assign private agents.
|
||||
*/
|
||||
export const VISIBILITY_LABEL: Record<AgentVisibility, string> = {
|
||||
workspace: "Workspace",
|
||||
private: "Personal",
|
||||
};
|
||||
|
||||
/**
|
||||
* Honest descriptions for assignability. The previous "Only you can assign"
|
||||
* text was a lie — workspace owners and admins can assign private agents too
|
||||
* (server `issue.go:1471-1490`).
|
||||
*/
|
||||
export const VISIBILITY_DESCRIPTION: Record<AgentVisibility, string> = {
|
||||
workspace: "All members can assign",
|
||||
private: "Only you and workspace admins can assign",
|
||||
};
|
||||
|
||||
/** Tooltip suitable for read-only badges on hover/list rows. */
|
||||
export const VISIBILITY_TOOLTIP: Record<AgentVisibility, string> = {
|
||||
workspace: "Workspace — all members can assign",
|
||||
private: "Personal — only you and workspace admins can assign",
|
||||
};
|
||||
|
||||
export function visibilityLabel(v: AgentVisibility): string {
|
||||
return VISIBILITY_LABEL[v];
|
||||
}
|
||||
@@ -55,6 +55,9 @@ import type {
|
||||
CreateProjectRequest,
|
||||
UpdateProjectRequest,
|
||||
ListProjectsResponse,
|
||||
ProjectResource,
|
||||
CreateProjectResourceRequest,
|
||||
ListProjectResourcesResponse,
|
||||
Label,
|
||||
CreateLabelRequest,
|
||||
UpdateLabelRequest,
|
||||
@@ -75,6 +78,8 @@ import type {
|
||||
ListAutopilotsResponse,
|
||||
GetAutopilotResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
NotificationPreferenceResponse,
|
||||
NotificationPreferences,
|
||||
} from "../types";
|
||||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
@@ -783,6 +788,18 @@ export class ApiClient {
|
||||
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
|
||||
}
|
||||
|
||||
// Notification preferences
|
||||
async getNotificationPreferences(): Promise<NotificationPreferenceResponse> {
|
||||
return this.fetch("/api/notification-preferences");
|
||||
}
|
||||
|
||||
async updateNotificationPreferences(preferences: NotificationPreferences): Promise<NotificationPreferenceResponse> {
|
||||
return this.fetch("/api/notification-preferences", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ preferences }),
|
||||
});
|
||||
}
|
||||
|
||||
// App Config
|
||||
async getConfig(): Promise<{
|
||||
cdn_domain: string;
|
||||
@@ -992,7 +1009,7 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async archiveChatSession(id: string): Promise<void> {
|
||||
async deleteChatSession(id: string): Promise<void> {
|
||||
await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
@@ -1060,6 +1077,32 @@ export class ApiClient {
|
||||
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Project resources
|
||||
async listProjectResources(
|
||||
projectId: string,
|
||||
): Promise<ListProjectResourcesResponse> {
|
||||
return this.fetch(`/api/projects/${projectId}/resources`);
|
||||
}
|
||||
|
||||
async createProjectResource(
|
||||
projectId: string,
|
||||
data: CreateProjectResourceRequest,
|
||||
): Promise<ProjectResource> {
|
||||
return this.fetch(`/api/projects/${projectId}/resources`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProjectResource(
|
||||
projectId: string,
|
||||
resourceId: string,
|
||||
): Promise<void> {
|
||||
await this.fetch(`/api/projects/${projectId}/resources/${resourceId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
// Labels
|
||||
async listLabels(): Promise<ListLabelsResponse> {
|
||||
return this.fetch(`/api/labels`);
|
||||
|
||||
@@ -70,14 +70,20 @@ export function useMarkChatSessionRead() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useArchiveChatSession() {
|
||||
/**
|
||||
* Hard-deletes a chat session. Optimistically removes the row from both
|
||||
* the active and all-sessions lists so the history panel updates instantly;
|
||||
* rolls back on error. The matching `chat:session_deleted` WS event keeps
|
||||
* other tabs/devices in sync — see use-realtime-sync.ts.
|
||||
*/
|
||||
export function useDeleteChatSession() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) => {
|
||||
logger.info("archiveChatSession.start", { sessionId });
|
||||
return api.archiveChatSession(sessionId);
|
||||
logger.info("deleteChatSession.start", { sessionId });
|
||||
return api.deleteChatSession(sessionId);
|
||||
},
|
||||
onMutate: async (sessionId) => {
|
||||
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
@@ -86,26 +92,20 @@ export function useArchiveChatSession() {
|
||||
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
|
||||
const prevAll = qc.getQueryData<ChatSession[]>(chatKeys.allSessions(wsId));
|
||||
|
||||
// Optimistic: remove from active, mark as archived in allSessions
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), (old) =>
|
||||
old ? old.filter((s) => s.id !== sessionId) : old,
|
||||
);
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), (old) =>
|
||||
old?.map((s) =>
|
||||
s.id === sessionId ? { ...s, status: "archived" as const } : s,
|
||||
),
|
||||
);
|
||||
const drop = (old?: ChatSession[]) => old?.filter((s) => s.id !== sessionId);
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), drop);
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), drop);
|
||||
|
||||
logger.debug("archiveChatSession.optimistic", { sessionId });
|
||||
logger.debug("deleteChatSession.optimistic", { sessionId });
|
||||
return { prevSessions, prevAll };
|
||||
},
|
||||
onError: (err, sessionId, ctx) => {
|
||||
logger.error("archiveChatSession.error.rollback", { sessionId, err });
|
||||
logger.error("deleteChatSession.error.rollback", { sessionId, err });
|
||||
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
|
||||
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
|
||||
},
|
||||
onSettled: (_data, _err, sessionId) => {
|
||||
logger.debug("archiveChatSession.settled", { sessionId });
|
||||
logger.debug("deleteChatSession.settled", { sessionId });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
},
|
||||
|
||||
@@ -51,7 +51,7 @@ function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string
|
||||
|
||||
export const CHAT_MIN_W = 360;
|
||||
export const CHAT_MIN_H = 480;
|
||||
export const CHAT_DEFAULT_W = 420;
|
||||
export const CHAT_DEFAULT_W = 380;
|
||||
export const CHAT_DEFAULT_H = 600;
|
||||
|
||||
/**
|
||||
|
||||
41
packages/core/feedback/draft-store.ts
Normal file
41
packages/core/feedback/draft-store.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../platform/workspace-storage";
|
||||
import { defaultStorage } from "../platform/storage";
|
||||
|
||||
interface FeedbackDraft {
|
||||
message: string;
|
||||
}
|
||||
|
||||
const EMPTY_DRAFT: FeedbackDraft = {
|
||||
message: "",
|
||||
};
|
||||
|
||||
interface FeedbackDraftStore {
|
||||
draft: FeedbackDraft;
|
||||
setDraft: (patch: Partial<FeedbackDraft>) => void;
|
||||
clearDraft: () => void;
|
||||
hasDraft: () => boolean;
|
||||
}
|
||||
|
||||
export const useFeedbackDraftStore = create<FeedbackDraftStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
draft: { ...EMPTY_DRAFT },
|
||||
setDraft: (patch) =>
|
||||
set((s) => ({ draft: { ...s.draft, ...patch } })),
|
||||
clearDraft: () =>
|
||||
set({ draft: { ...EMPTY_DRAFT } }),
|
||||
hasDraft: () => {
|
||||
const { draft } = get();
|
||||
return !!draft.message;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "multica_feedback_draft",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useFeedbackDraftStore.persist.rehydrate());
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./mutations";
|
||||
export { useFeedbackDraftStore } from "./draft-store";
|
||||
|
||||
26
packages/core/issues/stores/quick-create-store.test.ts
Normal file
26
packages/core/issues/stores/quick-create-store.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useQuickCreateStore } from "./quick-create-store";
|
||||
|
||||
const RESET_STATE = {
|
||||
lastAgentId: null,
|
||||
prompt: "",
|
||||
keepOpen: false,
|
||||
};
|
||||
|
||||
describe("quick create store", () => {
|
||||
beforeEach(() => {
|
||||
useQuickCreateStore.setState(RESET_STATE);
|
||||
});
|
||||
|
||||
it("persists the agent prompt draft until explicitly cleared", () => {
|
||||
const { setPrompt, clearPrompt } = useQuickCreateStore.getState();
|
||||
|
||||
setPrompt("Investigate the inbox loading regression");
|
||||
expect(useQuickCreateStore.getState().prompt).toBe(
|
||||
"Investigate the inbox loading regression",
|
||||
);
|
||||
|
||||
clearPrompt();
|
||||
expect(useQuickCreateStore.getState().prompt).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,9 @@ import { defaultStorage } from "../../platform/storage";
|
||||
interface QuickCreateState {
|
||||
lastAgentId: string | null;
|
||||
setLastAgentId: (id: string | null) => void;
|
||||
prompt: string;
|
||||
setPrompt: (prompt: string) => void;
|
||||
clearPrompt: () => void;
|
||||
keepOpen: boolean;
|
||||
setKeepOpen: (v: boolean) => void;
|
||||
}
|
||||
@@ -24,6 +27,9 @@ export const useQuickCreateStore = create<QuickCreateState>()(
|
||||
(set) => ({
|
||||
lastAgentId: null,
|
||||
setLastAgentId: (id) => set({ lastAgentId: id }),
|
||||
prompt: "",
|
||||
setPrompt: (prompt) => set({ prompt }),
|
||||
clearPrompt: () => set({ prompt: "" }),
|
||||
keepOpen: false,
|
||||
setKeepOpen: (v) => set({ keepOpen: v }),
|
||||
}),
|
||||
|
||||
95
packages/core/issues/ws-updaters.test.ts
Normal file
95
packages/core/issues/ws-updaters.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { onIssueLabelsChanged } from "./ws-updaters";
|
||||
import { issueKeys } from "./queries";
|
||||
import { labelKeys } from "../labels/queries";
|
||||
import type {
|
||||
Issue,
|
||||
IssueLabelsResponse,
|
||||
Label,
|
||||
ListIssuesCache,
|
||||
} from "../types";
|
||||
|
||||
const WS_ID = "ws-1";
|
||||
const ISSUE_ID = "issue-1";
|
||||
|
||||
const labelA: Label = {
|
||||
id: "label-a",
|
||||
workspace_id: WS_ID,
|
||||
name: "bug",
|
||||
color: "#ef4444",
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const labelB: Label = {
|
||||
id: "label-b",
|
||||
workspace_id: WS_ID,
|
||||
name: "feature",
|
||||
color: "#22c55e",
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const baseIssue: Issue = {
|
||||
id: ISSUE_ID,
|
||||
workspace_id: WS_ID,
|
||||
number: 1,
|
||||
identifier: "MUL-1",
|
||||
title: "Test",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "none",
|
||||
assignee_type: null,
|
||||
assignee_id: null,
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
position: 0,
|
||||
due_date: null,
|
||||
labels: [labelA],
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("onIssueLabelsChanged", () => {
|
||||
let qc: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient();
|
||||
});
|
||||
|
||||
it("patches the per-issue label cache when present (LabelPicker source)", () => {
|
||||
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(WS_ID, ISSUE_ID), {
|
||||
labels: [labelA],
|
||||
});
|
||||
|
||||
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
|
||||
|
||||
expect(
|
||||
qc.getQueryData<IssueLabelsResponse>(labelKeys.byIssue(WS_ID, ISSUE_ID)),
|
||||
).toEqual({ labels: [labelB] });
|
||||
});
|
||||
|
||||
it("leaves the per-issue label cache untouched when the picker has not fetched", () => {
|
||||
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
|
||||
|
||||
expect(qc.getQueryData(labelKeys.byIssue(WS_ID, ISSUE_ID))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still patches the list and detail caches", () => {
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), {
|
||||
byStatus: { todo: { issues: [baseIssue], total: 1 } },
|
||||
});
|
||||
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), baseIssue);
|
||||
|
||||
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
|
||||
|
||||
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
|
||||
expect(list?.byStatus.todo?.issues[0]?.labels).toEqual([labelB]);
|
||||
|
||||
const detail = qc.getQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID));
|
||||
expect(detail?.labels).toEqual([labelB]);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { issueKeys } from "./queries";
|
||||
import { labelKeys } from "../labels/queries";
|
||||
import {
|
||||
addIssueToBuckets,
|
||||
findIssueLocation,
|
||||
patchIssueInBuckets,
|
||||
removeIssueFromBuckets,
|
||||
} from "./cache-helpers";
|
||||
import type { Issue, Label } from "../types";
|
||||
import type { Issue, IssueLabelsResponse, Label } from "../types";
|
||||
import type { ListIssuesCache } from "../types";
|
||||
|
||||
export function onIssueCreated(
|
||||
@@ -73,9 +74,15 @@ export function onIssueUpdated(
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch an issue's `labels` field in-place across the list cache, my-issues
|
||||
* caches, and the detail cache. Triggered by the `issue_labels:changed` WS
|
||||
* event after attach/detach so list/board chips update without a refetch.
|
||||
* Patch an issue's labels in-place across the list cache, my-issues caches,
|
||||
* the detail cache, and the per-issue label cache. Triggered by the
|
||||
* `issue_labels:changed` WS event after attach/detach so list/board chips
|
||||
* and the issue-detail Properties LabelPicker update without a refetch.
|
||||
*
|
||||
* The byIssue cache backs `LabelPicker`; without patching it, externally
|
||||
* driven label changes (agents, other tabs) leave the picker stale until it
|
||||
* remounts — `staleTime: Infinity` + `refetchOnWindowFocus: false` (see
|
||||
* `query-client.ts`) means focus changes won't recover it.
|
||||
*/
|
||||
export function onIssueLabelsChanged(
|
||||
qc: QueryClient,
|
||||
@@ -89,6 +96,9 @@ export function onIssueLabelsChanged(
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) =>
|
||||
old ? { ...old, labels } : old,
|
||||
);
|
||||
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), (old) =>
|
||||
old ? { ...old, labels } : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
}
|
||||
|
||||
|
||||
2
packages/core/notification-preferences/index.ts
Normal file
2
packages/core/notification-preferences/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./queries";
|
||||
export * from "./mutations";
|
||||
34
packages/core/notification-preferences/mutations.ts
Normal file
34
packages/core/notification-preferences/mutations.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { notificationPreferenceKeys } from "./queries";
|
||||
import type { NotificationPreferences, NotificationPreferenceResponse } from "../types";
|
||||
|
||||
export function useUpdateNotificationPreferences() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (preferences: NotificationPreferences) =>
|
||||
api.updateNotificationPreferences(preferences),
|
||||
onMutate: async (preferences) => {
|
||||
await qc.cancelQueries({ queryKey: notificationPreferenceKeys.all(wsId) });
|
||||
const prev = qc.getQueryData<NotificationPreferenceResponse>(
|
||||
notificationPreferenceKeys.all(wsId),
|
||||
);
|
||||
qc.setQueryData<NotificationPreferenceResponse>(
|
||||
notificationPreferenceKeys.all(wsId),
|
||||
(old) => old ? { ...old, preferences } : { workspace_id: wsId, preferences },
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev) {
|
||||
qc.setQueryData(notificationPreferenceKeys.all(wsId), ctx.prev);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: notificationPreferenceKeys.all(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
13
packages/core/notification-preferences/queries.ts
Normal file
13
packages/core/notification-preferences/queries.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
export const notificationPreferenceKeys = {
|
||||
all: (wsId: string) => ["notification-preferences", wsId] as const,
|
||||
};
|
||||
|
||||
export function notificationPreferenceOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: notificationPreferenceKeys.all(wsId),
|
||||
queryFn: () => api.getNotificationPreferences(),
|
||||
});
|
||||
}
|
||||
@@ -16,7 +16,8 @@ export type OnboardingCompletionPath =
|
||||
| "full" // Reached Step 5 (first_issue) with a runtime connected
|
||||
| "runtime_skipped" // Step 3 skipped (no runtime) but still completed
|
||||
| "cloud_waitlist" // Submitted the cloud waitlist form and skipped Step 3
|
||||
| "skip_existing"; // "I've done this before" from Welcome
|
||||
| "skip_existing" // "I've done this before" from Welcome
|
||||
| "invite_accept"; // Accepted at least one invite from /invitations
|
||||
|
||||
export type TeamSize = "solo" | "team" | "other";
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
"./inbox/queries": "./inbox/queries.ts",
|
||||
"./inbox/mutations": "./inbox/mutations.ts",
|
||||
"./inbox/ws-updaters": "./inbox/ws-updaters.ts",
|
||||
"./notification-preferences": "./notification-preferences/index.ts",
|
||||
"./notification-preferences/queries": "./notification-preferences/queries.ts",
|
||||
"./notification-preferences/mutations": "./notification-preferences/mutations.ts",
|
||||
"./chat": "./chat/index.ts",
|
||||
"./chat/queries": "./chat/queries.ts",
|
||||
"./chat/mutations": "./chat/mutations.ts",
|
||||
@@ -46,6 +49,8 @@
|
||||
"./agents/queries": "./agents/queries.ts",
|
||||
"./agents/derive-presence": "./agents/derive-presence.ts",
|
||||
"./agents/use-agent-presence": "./agents/use-agent-presence.ts",
|
||||
"./agents/visibility-label": "./agents/visibility-label.ts",
|
||||
"./permissions": "./permissions/index.ts",
|
||||
"./projects": "./projects/index.ts",
|
||||
"./projects/queries": "./projects/queries.ts",
|
||||
"./projects/mutations": "./projects/mutations.ts",
|
||||
|
||||
@@ -43,6 +43,7 @@ export const paths = {
|
||||
login: () => "/login",
|
||||
newWorkspace: () => "/workspaces/new",
|
||||
invite: (id: string) => `/invite/${encode(id)}`,
|
||||
invitations: () => "/invitations",
|
||||
onboarding: () => "/onboarding",
|
||||
authCallback: () => "/auth/callback",
|
||||
root: () => "/",
|
||||
@@ -54,7 +55,7 @@ export type WorkspacePaths = ReturnType<typeof workspaceScoped>;
|
||||
// A path is global if it equals or begins with any of these.
|
||||
// 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/", "/onboarding", "/auth/", "/logout", "/signup"];
|
||||
const GLOBAL_PREFIXES = ["/login", "/workspaces/", "/invite/", "/invitations", "/onboarding", "/auth/", "/logout", "/signup"];
|
||||
|
||||
export function isGlobalPath(path: string): boolean {
|
||||
return GLOBAL_PREFIXES.some((p) => path === p || path.startsWith(p));
|
||||
|
||||
@@ -20,6 +20,7 @@ export const RESERVED_SLUGS = new Set([
|
||||
"oauth",
|
||||
"callback",
|
||||
"invite",
|
||||
"invitations",
|
||||
"verify",
|
||||
"reset",
|
||||
"password",
|
||||
|
||||
@@ -19,24 +19,26 @@ function makeWs(slug: string): Workspace {
|
||||
}
|
||||
|
||||
describe("resolvePostAuthDestination", () => {
|
||||
it("has workspace → /<first.slug>/issues regardless of onboarded state", () => {
|
||||
it("!onboarded → /onboarding regardless of workspace count", () => {
|
||||
// Un-onboarded users are routed back to the onboarding flow. The
|
||||
// "un-onboarded but in workspace" state is now physically impossible
|
||||
// (backend invariant + migration 065 backfill), but the resolver still
|
||||
// does the right thing if it ever appears: send the user to onboarding
|
||||
// rather than dropping them into a workspace with `onboarded_at` null.
|
||||
expect(resolvePostAuthDestination([], false)).toBe(paths.onboarding());
|
||||
expect(resolvePostAuthDestination([makeWs("acme")], false)).toBe(
|
||||
paths.onboarding(),
|
||||
);
|
||||
});
|
||||
|
||||
it("onboarded + has workspace → /<first.slug>/issues", () => {
|
||||
const ws = [makeWs("acme"), makeWs("beta")];
|
||||
expect(resolvePostAuthDestination(ws, true)).toBe(
|
||||
paths.workspace("acme").issues(),
|
||||
);
|
||||
expect(resolvePostAuthDestination(ws, false)).toBe(
|
||||
paths.workspace("acme").issues(),
|
||||
);
|
||||
expect(resolvePostAuthDestination([makeWs("acme")], false)).toBe(
|
||||
paths.workspace("acme").issues(),
|
||||
);
|
||||
});
|
||||
|
||||
it("zero workspaces + !onboarded → /onboarding", () => {
|
||||
expect(resolvePostAuthDestination([], false)).toBe(paths.onboarding());
|
||||
});
|
||||
|
||||
it("zero workspaces + onboarded → /workspaces/new", () => {
|
||||
it("onboarded + zero workspaces → /workspaces/new", () => {
|
||||
expect(resolvePostAuthDestination([], true)).toBe(paths.newWorkspace());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,23 +4,34 @@ import { paths } from "./paths";
|
||||
|
||||
/**
|
||||
* Priority:
|
||||
* has workspace → /<first.slug>/issues
|
||||
* zero workspaces && !hasOnboarded → /onboarding
|
||||
* zero workspaces && hasOnboarded → /workspaces/new
|
||||
* !hasOnboarded → /onboarding
|
||||
* hasOnboarded && has workspace → /<first.slug>/issues
|
||||
* hasOnboarded && zero workspaces → /workspaces/new
|
||||
*
|
||||
* Workspace presence wins over onboarding state: a user invited into an
|
||||
* existing workspace must NOT be bounced into the new-workspace wizard
|
||||
* just because their personal `onboarded_at` is still null.
|
||||
* `onboarded_at` is the single source of truth for whether the user has
|
||||
* passed first-contact. Backend transactions (CreateWorkspace,
|
||||
* AcceptInvitation) atomically set this field whenever a user joins a
|
||||
* `member` row, so "has workspace but !onboarded" is now a
|
||||
* physically impossible state — see migration 065 for the existing-data
|
||||
* backfill that closed the door retroactively.
|
||||
*
|
||||
* Callers that need invitation-aware routing (callback / login) handle the
|
||||
* "un-onboarded with pending invites" branch themselves before calling
|
||||
* this resolver — this resolver only deals with the post-invite-check
|
||||
* destination.
|
||||
*/
|
||||
export function resolvePostAuthDestination(
|
||||
workspaces: Workspace[],
|
||||
hasOnboarded: boolean,
|
||||
): string {
|
||||
if (!hasOnboarded) {
|
||||
return paths.onboarding();
|
||||
}
|
||||
const first = workspaces[0];
|
||||
if (first) {
|
||||
return paths.workspace(first.slug).issues();
|
||||
}
|
||||
return hasOnboarded ? paths.newWorkspace() : paths.onboarding();
|
||||
return paths.newWorkspace();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
20
packages/core/permissions/index.ts
Normal file
20
packages/core/permissions/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Public API for the permissions module.
|
||||
*
|
||||
* Exports only what the views currently consume. The full pure-rule set lives
|
||||
* in `./rules` and is available to tests and future surfaces directly. Adding
|
||||
* a new rule to the public API should follow the same minimum-surface pattern
|
||||
* — only export when there's a caller.
|
||||
*/
|
||||
export type {
|
||||
Decision,
|
||||
DecisionReason,
|
||||
PermissionContext,
|
||||
} from "./types";
|
||||
|
||||
export { canAssignAgentToIssue, canEditAgent } from "./rules";
|
||||
|
||||
export {
|
||||
useAgentPermissions,
|
||||
useSkillPermissions,
|
||||
} from "./use-resource-permissions";
|
||||
329
packages/core/permissions/rules.test.ts
Normal file
329
packages/core/permissions/rules.test.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent, Comment, Member, RuntimeDevice, Skill } from "../types";
|
||||
import {
|
||||
canAssignAgentToIssue,
|
||||
canChangeMemberRole,
|
||||
canDeleteComment,
|
||||
canDeleteRuntime,
|
||||
canDeleteSkill,
|
||||
canDeleteWorkspace,
|
||||
canEditAgent,
|
||||
canEditComment,
|
||||
canEditSkill,
|
||||
canManageMembers,
|
||||
canUpdateWorkspaceSettings,
|
||||
} from "./rules";
|
||||
|
||||
const ALICE = "user-alice";
|
||||
const BOB = "user-bob";
|
||||
|
||||
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
id: "agt_1",
|
||||
workspace_id: "ws_1",
|
||||
runtime_id: "rt_1",
|
||||
name: "agent",
|
||||
description: "",
|
||||
instructions: "",
|
||||
avatar_url: null,
|
||||
runtime_mode: "local",
|
||||
runtime_config: {},
|
||||
custom_env: {},
|
||||
custom_args: [],
|
||||
custom_env_redacted: false,
|
||||
visibility: "workspace",
|
||||
status: "idle",
|
||||
max_concurrent_tasks: 1,
|
||||
model: "default",
|
||||
owner_id: ALICE,
|
||||
skills: [],
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
archived_at: null,
|
||||
archived_by: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSkill(createdBy: string | null): Skill {
|
||||
return {
|
||||
id: "skl_1",
|
||||
workspace_id: "ws_1",
|
||||
name: "skill",
|
||||
description: "",
|
||||
content: "",
|
||||
config: {},
|
||||
files: [],
|
||||
created_by: createdBy,
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function makeComment(overrides: Partial<Comment> = {}): Comment {
|
||||
return {
|
||||
id: "cmt_1",
|
||||
issue_id: "iss_1",
|
||||
author_type: "member",
|
||||
author_id: ALICE,
|
||||
content: "hi",
|
||||
type: "comment",
|
||||
parent_id: null,
|
||||
reactions: [],
|
||||
attachments: [],
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRuntime(ownerId: string | null): RuntimeDevice {
|
||||
return {
|
||||
id: "rt_1",
|
||||
workspace_id: "ws_1",
|
||||
daemon_id: null,
|
||||
name: "runtime",
|
||||
runtime_mode: "local",
|
||||
provider: "anthropic",
|
||||
launch_header: "",
|
||||
status: "online",
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: ownerId,
|
||||
last_seen_at: null,
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("canEditAgent", () => {
|
||||
const agent = makeAgent({ owner_id: ALICE });
|
||||
|
||||
it("allows the owner", () => {
|
||||
expect(canEditAgent(agent, { userId: ALICE, role: "member" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("allows workspace owner", () => {
|
||||
expect(canEditAgent(agent, { userId: BOB, role: "owner" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("allows workspace admin", () => {
|
||||
expect(canEditAgent(agent, { userId: BOB, role: "admin" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("denies non-owner member", () => {
|
||||
const d = canEditAgent(agent, { userId: BOB, role: "member" });
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toBe("not_resource_owner");
|
||||
});
|
||||
it("denies when userId is null", () => {
|
||||
const d = canEditAgent(agent, { userId: null, role: null });
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toBe("not_authenticated");
|
||||
});
|
||||
it("denies when agent owner_id is null and user is plain member", () => {
|
||||
const orphan = makeAgent({ owner_id: null });
|
||||
expect(
|
||||
canEditAgent(orphan, { userId: ALICE, role: "member" }).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
it("admin can still edit an orphan (owner_id null) agent", () => {
|
||||
const orphan = makeAgent({ owner_id: null });
|
||||
expect(canEditAgent(orphan, { userId: BOB, role: "admin" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAssignAgentToIssue", () => {
|
||||
it("allows any member to assign workspace-visibility agents", () => {
|
||||
const a = makeAgent({ visibility: "workspace", owner_id: ALICE });
|
||||
expect(
|
||||
canAssignAgentToIssue(a, { userId: BOB, role: "member" }).allowed,
|
||||
).toBe(true);
|
||||
});
|
||||
it("denies non-members from assigning workspace agents", () => {
|
||||
const a = makeAgent({ visibility: "workspace", owner_id: ALICE });
|
||||
const d = canAssignAgentToIssue(a, { userId: BOB, role: null });
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toBe("not_member");
|
||||
});
|
||||
it("allows the owner to assign their private agent", () => {
|
||||
const a = makeAgent({ visibility: "private", owner_id: ALICE });
|
||||
expect(
|
||||
canAssignAgentToIssue(a, { userId: ALICE, role: "member" }).allowed,
|
||||
).toBe(true);
|
||||
});
|
||||
it("allows workspace admin to assign someone else's private agent", () => {
|
||||
const a = makeAgent({ visibility: "private", owner_id: ALICE });
|
||||
expect(
|
||||
canAssignAgentToIssue(a, { userId: BOB, role: "admin" }).allowed,
|
||||
).toBe(true);
|
||||
});
|
||||
it("denies a plain member from assigning someone else's private agent", () => {
|
||||
const a = makeAgent({ visibility: "private", owner_id: ALICE });
|
||||
const d = canAssignAgentToIssue(a, { userId: BOB, role: "member" });
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toBe("private_visibility");
|
||||
});
|
||||
it("denies logged-out users", () => {
|
||||
const a = makeAgent({ visibility: "workspace" });
|
||||
const d = canAssignAgentToIssue(a, { userId: null, role: null });
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toBe("not_authenticated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEditSkill / canDeleteSkill", () => {
|
||||
const skill = makeSkill(ALICE);
|
||||
it("allows admins", () => {
|
||||
expect(canEditSkill(skill, { userId: BOB, role: "admin" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("allows the creator", () => {
|
||||
expect(canEditSkill(skill, { userId: ALICE, role: "member" }).allowed)
|
||||
.toBe(true);
|
||||
});
|
||||
it("denies non-creator member", () => {
|
||||
expect(canEditSkill(skill, { userId: BOB, role: "member" }).allowed)
|
||||
.toBe(false);
|
||||
});
|
||||
it("denies when created_by is null and user is plain member", () => {
|
||||
expect(
|
||||
canEditSkill(makeSkill(null), { userId: ALICE, role: "member" }).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
it("canDeleteSkill mirrors canEditSkill", () => {
|
||||
expect(canDeleteSkill(skill, { userId: ALICE, role: "member" }).allowed)
|
||||
.toBe(true);
|
||||
expect(canDeleteSkill(skill, { userId: BOB, role: "member" }).allowed)
|
||||
.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEditComment / canDeleteComment", () => {
|
||||
it("allows the author to edit their own comment", () => {
|
||||
const c = makeComment({ author_id: ALICE });
|
||||
expect(canEditComment(c, { userId: ALICE, role: "member" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("allows workspace admin to edit someone else's comment", () => {
|
||||
const c = makeComment({ author_id: ALICE });
|
||||
expect(canEditComment(c, { userId: BOB, role: "admin" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("denies non-author non-admin", () => {
|
||||
const c = makeComment({ author_id: ALICE });
|
||||
expect(canEditComment(c, { userId: BOB, role: "member" }).allowed).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("denies edit on agent-authored comments", () => {
|
||||
const c = makeComment({ author_type: "agent", author_id: "agt_1" });
|
||||
const d = canEditComment(c, { userId: BOB, role: "owner" });
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toBe("not_resource_owner");
|
||||
});
|
||||
it("admin CAN delete an agent-authored comment", () => {
|
||||
// delete is broader than edit — admins moderate any comment regardless of
|
||||
// author type. Mirrors backend `comment.go:507-512`.
|
||||
const c = makeComment({ author_type: "agent", author_id: "agt_1" });
|
||||
expect(canDeleteComment(c, { userId: BOB, role: "admin" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("denies plain member from deleting agent-authored comment", () => {
|
||||
const c = makeComment({ author_type: "agent", author_id: "agt_1" });
|
||||
expect(
|
||||
canDeleteComment(c, { userId: BOB, role: "member" }).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canDeleteRuntime", () => {
|
||||
it("allows the owner", () => {
|
||||
const r = makeRuntime(ALICE);
|
||||
expect(canDeleteRuntime(r, { userId: ALICE, role: "member" }).allowed)
|
||||
.toBe(true);
|
||||
});
|
||||
it("allows workspace admin", () => {
|
||||
const r = makeRuntime(ALICE);
|
||||
expect(canDeleteRuntime(r, { userId: BOB, role: "admin" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("denies non-owner non-admin", () => {
|
||||
const r = makeRuntime(ALICE);
|
||||
expect(canDeleteRuntime(r, { userId: BOB, role: "member" }).allowed)
|
||||
.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace-level rules", () => {
|
||||
it("only owner can delete workspace", () => {
|
||||
expect(canDeleteWorkspace({ userId: ALICE, role: "owner" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
expect(canDeleteWorkspace({ userId: ALICE, role: "admin" }).allowed).toBe(
|
||||
false,
|
||||
);
|
||||
expect(canDeleteWorkspace({ userId: ALICE, role: "member" }).allowed)
|
||||
.toBe(false);
|
||||
});
|
||||
it("owner+admin can update settings, member cannot", () => {
|
||||
expect(
|
||||
canUpdateWorkspaceSettings({ userId: ALICE, role: "owner" }).allowed,
|
||||
).toBe(true);
|
||||
expect(
|
||||
canUpdateWorkspaceSettings({ userId: ALICE, role: "admin" }).allowed,
|
||||
).toBe(true);
|
||||
expect(
|
||||
canUpdateWorkspaceSettings({ userId: ALICE, role: "member" }).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
it("manage members same gate as settings", () => {
|
||||
expect(canManageMembers({ userId: ALICE, role: "admin" }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
expect(canManageMembers({ userId: ALICE, role: "member" }).allowed).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canChangeMemberRole", () => {
|
||||
const ctxOwner = { userId: ALICE, role: "owner" as const };
|
||||
const ctxAdmin = { userId: ALICE, role: "admin" as const };
|
||||
const ctxMember = { userId: ALICE, role: "member" as const };
|
||||
|
||||
const targetOwner: Pick<Member, "role"> = { role: "owner" };
|
||||
const targetAdmin: Pick<Member, "role"> = { role: "admin" };
|
||||
const targetMember: Pick<Member, "role"> = { role: "member" };
|
||||
|
||||
it("non-managers cannot change roles", () => {
|
||||
expect(canChangeMemberRole(targetMember, 2, ctxMember).allowed).toBe(false);
|
||||
});
|
||||
it("admin cannot change owner's role", () => {
|
||||
const d = canChangeMemberRole(targetOwner, 2, ctxAdmin);
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toBe("not_owner_role");
|
||||
});
|
||||
it("admin can change admin/member roles", () => {
|
||||
expect(canChangeMemberRole(targetAdmin, 1, ctxAdmin).allowed).toBe(true);
|
||||
expect(canChangeMemberRole(targetMember, 1, ctxAdmin).allowed).toBe(true);
|
||||
});
|
||||
it("owner cannot demote the last owner", () => {
|
||||
const d = canChangeMemberRole(targetOwner, 1, ctxOwner);
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.reason).toBe("last_owner");
|
||||
});
|
||||
it("owner can change owner role when 2+ owners exist", () => {
|
||||
expect(canChangeMemberRole(targetOwner, 2, ctxOwner).allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
210
packages/core/permissions/rules.ts
Normal file
210
packages/core/permissions/rules.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type {
|
||||
Agent,
|
||||
Comment,
|
||||
Member,
|
||||
MemberRole,
|
||||
RuntimeDevice,
|
||||
Skill,
|
||||
} from "../types";
|
||||
import { ALLOW, deny, type Decision, type PermissionContext } from "./types";
|
||||
|
||||
/**
|
||||
* Pure permission rules — single source of truth that mirrors the Go backend
|
||||
* gates in `server/internal/handler/`. Hooks in `use-resource-permissions.ts`
|
||||
* are thin wrappers that pull `PermissionContext` from auth + member queries
|
||||
* and forward to these.
|
||||
*
|
||||
* Returning a `Decision` (not a boolean) lets every surface — disabled state,
|
||||
* tooltip, banner copy — read the same `reason` and stay consistent without
|
||||
* sprinkling copy through the view layer.
|
||||
*/
|
||||
|
||||
const isAdminLike = (role: MemberRole | null) =>
|
||||
role === "owner" || role === "admin";
|
||||
|
||||
// ---- Agents ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update / archive / restore agent fields. The backend gates archive and
|
||||
* restore identically to edit (`server/internal/handler/agent.go:519-535`),
|
||||
* so callers can use `canEditAgent` for all three.
|
||||
*/
|
||||
export function canEditAgent(agent: Agent, ctx: PermissionContext): Decision {
|
||||
if (ctx.userId === null) {
|
||||
return deny("not_authenticated", "Sign in to edit this agent.");
|
||||
}
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
if (agent.owner_id !== null && agent.owner_id === ctx.userId) return ALLOW;
|
||||
return deny(
|
||||
"not_resource_owner",
|
||||
"Only the agent owner and workspace admins can edit this agent.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign an agent to an issue. Workspace-visibility agents are assignable by
|
||||
* any workspace member; private agents are restricted to their owner plus
|
||||
* workspace admins/owners. Mirrors `issue.go:1471-1490`.
|
||||
*/
|
||||
export function canAssignAgentToIssue(
|
||||
agent: Agent,
|
||||
ctx: PermissionContext,
|
||||
): Decision {
|
||||
if (ctx.userId === null) {
|
||||
return deny("not_authenticated", "Sign in to assign agents.");
|
||||
}
|
||||
if (agent.visibility === "workspace") {
|
||||
if (ctx.role === null) {
|
||||
return deny("not_member", "Join this workspace to assign agents.");
|
||||
}
|
||||
return ALLOW;
|
||||
}
|
||||
// visibility === "private"
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
if (agent.owner_id !== null && agent.owner_id === ctx.userId) return ALLOW;
|
||||
return deny(
|
||||
"private_visibility",
|
||||
"Personal agent — only the owner and workspace admins can assign work.",
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Skills ----------------------------------------------------------------
|
||||
|
||||
export function canEditSkill(skill: Skill, ctx: PermissionContext): Decision {
|
||||
if (ctx.userId === null) {
|
||||
return deny("not_authenticated", "Sign in to edit this skill.");
|
||||
}
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
if (skill.created_by !== null && skill.created_by === ctx.userId) {
|
||||
return ALLOW;
|
||||
}
|
||||
return deny(
|
||||
"not_resource_owner",
|
||||
"Only the creator and workspace admins can edit this skill.",
|
||||
);
|
||||
}
|
||||
|
||||
export function canDeleteSkill(skill: Skill, ctx: PermissionContext): Decision {
|
||||
return canEditSkill(skill, ctx);
|
||||
}
|
||||
|
||||
// ---- Comments --------------------------------------------------------------
|
||||
|
||||
export function canEditComment(
|
||||
comment: Comment,
|
||||
ctx: PermissionContext,
|
||||
): Decision {
|
||||
if (ctx.userId === null) {
|
||||
return deny("not_authenticated", "Sign in to edit comments.");
|
||||
}
|
||||
// Only member-authored comments can be edited; agent-authored comments are
|
||||
// immutable from any human's perspective.
|
||||
if (comment.author_type !== "member") {
|
||||
return deny(
|
||||
"not_resource_owner",
|
||||
"Agent-authored comments cannot be edited.",
|
||||
);
|
||||
}
|
||||
if (comment.author_id === ctx.userId) return ALLOW;
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
return deny(
|
||||
"not_resource_owner",
|
||||
"Only the author and workspace admins can edit this comment.",
|
||||
);
|
||||
}
|
||||
|
||||
export function canDeleteComment(
|
||||
comment: Comment,
|
||||
ctx: PermissionContext,
|
||||
): Decision {
|
||||
if (ctx.userId === null) {
|
||||
return deny("not_authenticated", "Sign in to delete comments.");
|
||||
}
|
||||
if (comment.author_type === "member" && comment.author_id === ctx.userId) {
|
||||
return ALLOW;
|
||||
}
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
return deny(
|
||||
"not_resource_owner",
|
||||
"Only the author and workspace admins can delete this comment.",
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Runtimes --------------------------------------------------------------
|
||||
|
||||
export function canDeleteRuntime(
|
||||
runtime: RuntimeDevice,
|
||||
ctx: PermissionContext,
|
||||
): Decision {
|
||||
if (ctx.userId === null) {
|
||||
return deny("not_authenticated", "Sign in to delete runtimes.");
|
||||
}
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
if (runtime.owner_id !== null && runtime.owner_id === ctx.userId) {
|
||||
return ALLOW;
|
||||
}
|
||||
return deny(
|
||||
"not_resource_owner",
|
||||
"Only the runtime owner and workspace admins can delete this runtime.",
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Workspace -------------------------------------------------------------
|
||||
|
||||
export function canUpdateWorkspaceSettings(ctx: PermissionContext): Decision {
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
return deny(
|
||||
"not_admin_role",
|
||||
"Only workspace owners and admins can update workspace settings.",
|
||||
);
|
||||
}
|
||||
|
||||
export function canDeleteWorkspace(ctx: PermissionContext): Decision {
|
||||
if (ctx.role === "owner") return ALLOW;
|
||||
return deny(
|
||||
"not_owner_role",
|
||||
"Only the workspace owner can delete this workspace.",
|
||||
);
|
||||
}
|
||||
|
||||
export function canManageMembers(ctx: PermissionContext): Decision {
|
||||
if (isAdminLike(ctx.role)) return ALLOW;
|
||||
return deny(
|
||||
"not_admin_role",
|
||||
"Only workspace owners and admins can manage members.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the role-change matrix from `workspace.go:458-530`:
|
||||
* - admins cannot touch the owner role (neither demote owners nor promote)
|
||||
* - the last owner cannot be demoted
|
||||
* - non-managers cannot change roles at all
|
||||
*
|
||||
* `ownerCount` is the number of workspace members currently with role=owner.
|
||||
* Caller derives it locally from the cached member list.
|
||||
*/
|
||||
export function canChangeMemberRole(
|
||||
target: Pick<Member, "role">,
|
||||
ownerCount: number,
|
||||
ctx: PermissionContext,
|
||||
): Decision {
|
||||
const manage = canManageMembers(ctx);
|
||||
if (!manage.allowed) return manage;
|
||||
|
||||
if (target.role === "owner") {
|
||||
if (ctx.role !== "owner") {
|
||||
return deny(
|
||||
"not_owner_role",
|
||||
"Only the workspace owner can change another owner's role.",
|
||||
);
|
||||
}
|
||||
if (ownerCount <= 1) {
|
||||
return deny(
|
||||
"last_owner",
|
||||
"Promote another member to owner first — a workspace must keep at least one owner.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return ALLOW;
|
||||
}
|
||||
52
packages/core/permissions/types.ts
Normal file
52
packages/core/permissions/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { MemberRole } from "../types";
|
||||
|
||||
/**
|
||||
* Inputs to every permission rule. Stays role-typed so we don't have to thread
|
||||
* `MemberWithUser` (with PII) into pure logic — only what we actually need.
|
||||
*
|
||||
* `userId === null` models the logged-out edge case; `role === null` models the
|
||||
* "not a workspace member" / "member list still loading" case. Both must
|
||||
* gracefully deny without throwing.
|
||||
*/
|
||||
export interface PermissionContext {
|
||||
userId: string | null;
|
||||
role: MemberRole | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable enum of *why* a permission was denied (or allowed). Lets UIs pick
|
||||
* different copy / disabled states / banner variants without parsing the
|
||||
* `message` string. Tests assert on `reason`.
|
||||
*/
|
||||
export type DecisionReason =
|
||||
| "allowed"
|
||||
| "not_authenticated"
|
||||
| "not_member"
|
||||
| "not_owner_role"
|
||||
| "not_admin_role"
|
||||
| "not_resource_owner"
|
||||
| "last_owner"
|
||||
| "private_visibility"
|
||||
| "unknown";
|
||||
|
||||
export interface Decision {
|
||||
allowed: boolean;
|
||||
reason: DecisionReason;
|
||||
/**
|
||||
* Human-readable copy for tooltips / banners. Centralised here so view code
|
||||
* doesn't drift. UI may still wrap it for emphasis but should not invent
|
||||
* its own copy.
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Builder helpers — keeps rules.ts tight. */
|
||||
export const ALLOW: Decision = {
|
||||
allowed: true,
|
||||
reason: "allowed",
|
||||
message: "",
|
||||
};
|
||||
|
||||
export function deny(reason: DecisionReason, message: string): Decision {
|
||||
return { allowed: false, reason, message };
|
||||
}
|
||||
32
packages/core/permissions/use-current-member.ts
Normal file
32
packages/core/permissions/use-current-member.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "../auth";
|
||||
import type { MemberRole, MemberWithUser } from "../types";
|
||||
import { memberListOptions } from "../workspace/queries";
|
||||
|
||||
/**
|
||||
* Resolves the current user's membership in the given workspace. Single source
|
||||
* of truth for "what role am I" — replaces ad-hoc `members.find(...)` lookups
|
||||
* scattered across the views.
|
||||
*
|
||||
* `wsId` is explicit (not via `useWorkspaceId()` Context) so this hook stays
|
||||
* usable in components that may render before workspace context is wired,
|
||||
* matching the repo rule for workspace-aware hooks.
|
||||
*/
|
||||
export function useCurrentMember(wsId: string): {
|
||||
userId: string | null;
|
||||
role: MemberRole | null;
|
||||
member: MemberWithUser | null;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const userId = useAuthStore((s) => s.user?.id ?? null);
|
||||
const { data: members, isLoading } = useQuery(memberListOptions(wsId));
|
||||
const member = members?.find((m) => m.user_id === userId) ?? null;
|
||||
return {
|
||||
userId,
|
||||
role: member?.role ?? null,
|
||||
member,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
65
packages/core/permissions/use-resource-permissions.ts
Normal file
65
packages/core/permissions/use-resource-permissions.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import type { Agent, Skill } from "../types";
|
||||
import { useCurrentMember } from "./use-current-member";
|
||||
import {
|
||||
canAssignAgentToIssue,
|
||||
canDeleteSkill,
|
||||
canEditAgent,
|
||||
canEditSkill,
|
||||
} from "./rules";
|
||||
import { deny, type Decision } from "./types";
|
||||
|
||||
const PENDING: Decision = deny("unknown", "");
|
||||
|
||||
/**
|
||||
* Per-resource hook that returns a `Decision` for every relevant capability.
|
||||
* Each hook calls `useCurrentMember()` once and threads the context into the
|
||||
* pure rules in `rules.ts`.
|
||||
*
|
||||
* `wsId` is explicit (not read from `WorkspaceIdProvider`) so the hook stays
|
||||
* usable outside a workspace context — matches the repo rule for
|
||||
* workspace-aware hooks.
|
||||
*
|
||||
* Resource = `null` collapses every Decision to a denied "unknown" — keeps
|
||||
* callers branch-free during loading.
|
||||
*
|
||||
* `canArchive` / `canRestore` / `canManage` are deliberately not exposed:
|
||||
* the backend gates them identically to `canEdit`, so callers can use
|
||||
* `canEdit` everywhere and read better at the call site.
|
||||
*/
|
||||
export function useAgentPermissions(
|
||||
agent: Agent | null,
|
||||
wsId: string,
|
||||
): {
|
||||
canEdit: Decision;
|
||||
canAssign: Decision;
|
||||
} {
|
||||
const { userId, role } = useCurrentMember(wsId);
|
||||
const ctx = { userId, role };
|
||||
if (agent === null) {
|
||||
return { canEdit: PENDING, canAssign: PENDING };
|
||||
}
|
||||
return {
|
||||
canEdit: canEditAgent(agent, ctx),
|
||||
canAssign: canAssignAgentToIssue(agent, ctx),
|
||||
};
|
||||
}
|
||||
|
||||
export function useSkillPermissions(
|
||||
skill: Skill | null,
|
||||
wsId: string,
|
||||
): {
|
||||
canEdit: Decision;
|
||||
canDelete: Decision;
|
||||
} {
|
||||
const { userId, role } = useCurrentMember(wsId);
|
||||
const ctx = { userId, role };
|
||||
if (skill === null) {
|
||||
return { canEdit: PENDING, canDelete: PENDING };
|
||||
}
|
||||
return {
|
||||
canEdit: canEditSkill(skill, ctx),
|
||||
canDelete: canDeleteSkill(skill, ctx),
|
||||
};
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export { defaultStorage } from "./storage";
|
||||
export { createPersistStorage } from "./persist-storage";
|
||||
export { createWorkspaceAwareStorage, setCurrentWorkspace, getCurrentSlug, getCurrentWsId, subscribeToCurrentSlug, registerForWorkspaceRehydration } from "./workspace-storage";
|
||||
export { clearWorkspaceStorage } from "./storage-cleanup";
|
||||
export { isMac, modKey, enterKey, formatShortcut } from "./keyboard";
|
||||
|
||||
51
packages/core/platform/keyboard.test.ts
Normal file
51
packages/core/platform/keyboard.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("keyboard platform helper", () => {
|
||||
it("renders Mac symbols when navigator.platform is MacIntel", async () => {
|
||||
vi.stubGlobal("navigator", { platform: "MacIntel" });
|
||||
const mod = await import("./keyboard");
|
||||
|
||||
expect(mod.isMac).toBe(true);
|
||||
expect(mod.modKey).toBe("⌘");
|
||||
expect(mod.enterKey).toBe("↵");
|
||||
expect(mod.formatShortcut(mod.modKey, "K")).toBe("⌘K");
|
||||
expect(mod.formatShortcut(mod.modKey, mod.enterKey)).toBe("⌘↵");
|
||||
});
|
||||
|
||||
it("renders Ctrl/Enter on Windows", async () => {
|
||||
vi.stubGlobal("navigator", { platform: "Win32" });
|
||||
const mod = await import("./keyboard");
|
||||
|
||||
expect(mod.isMac).toBe(false);
|
||||
expect(mod.modKey).toBe("Ctrl");
|
||||
expect(mod.enterKey).toBe("Enter");
|
||||
expect(mod.formatShortcut(mod.modKey, "K")).toBe("Ctrl+K");
|
||||
expect(mod.formatShortcut(mod.modKey, mod.enterKey)).toBe("Ctrl+Enter");
|
||||
});
|
||||
|
||||
it("renders Ctrl/Enter on Linux", async () => {
|
||||
vi.stubGlobal("navigator", { platform: "Linux x86_64" });
|
||||
const mod = await import("./keyboard");
|
||||
|
||||
expect(mod.isMac).toBe(false);
|
||||
expect(mod.modKey).toBe("Ctrl");
|
||||
expect(mod.formatShortcut("Ctrl", "Shift", "P")).toBe("Ctrl+Shift+P");
|
||||
});
|
||||
|
||||
it("falls back to non-Mac when navigator is unavailable (SSR)", async () => {
|
||||
vi.stubGlobal("navigator", undefined);
|
||||
const mod = await import("./keyboard");
|
||||
|
||||
expect(mod.isMac).toBe(false);
|
||||
expect(mod.modKey).toBe("Ctrl");
|
||||
});
|
||||
});
|
||||
24
packages/core/platform/keyboard.ts
Normal file
24
packages/core/platform/keyboard.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Coarse platform detection for keyboard-shortcut display.
|
||||
*
|
||||
* Eagerly evaluated at module load. On the server (no `navigator`) this
|
||||
* resolves to `false`, so SSR always renders the non-Mac variant; on a
|
||||
* real Mac the value is true after hydration. Acceptable trade-off for
|
||||
* cosmetic shortcut hints — never gate functional behavior on this.
|
||||
*/
|
||||
export const isMac =
|
||||
typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
|
||||
|
||||
/** Modifier key label — ⌘ on Mac, "Ctrl" elsewhere. */
|
||||
export const modKey: string = isMac ? "⌘" : "Ctrl";
|
||||
|
||||
/** Enter / return key label — ↵ on Mac, "Enter" elsewhere. */
|
||||
export const enterKey: string = isMac ? "↵" : "Enter";
|
||||
|
||||
/**
|
||||
* Join key labels for display. Mac compresses combos with no separator
|
||||
* ("⌘K", "⌘↵"); other platforms use "+" ("Ctrl+K", "Ctrl+Enter").
|
||||
*/
|
||||
export function formatShortcut(...keys: string[]): string {
|
||||
return keys.join(isMac ? "" : "+");
|
||||
}
|
||||
54
packages/core/projects/draft-store.ts
Normal file
54
packages/core/projects/draft-store.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import type { ProjectStatus, ProjectPriority } from "../types";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../platform/workspace-storage";
|
||||
import { defaultStorage } from "../platform/storage";
|
||||
|
||||
interface ProjectDraft {
|
||||
title: string;
|
||||
description: string;
|
||||
status: ProjectStatus;
|
||||
priority: ProjectPriority;
|
||||
leadType?: "member" | "agent";
|
||||
leadId?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const EMPTY_DRAFT: ProjectDraft = {
|
||||
title: "",
|
||||
description: "",
|
||||
status: "planned",
|
||||
priority: "none",
|
||||
leadType: undefined,
|
||||
leadId: undefined,
|
||||
icon: undefined,
|
||||
};
|
||||
|
||||
interface ProjectDraftStore {
|
||||
draft: ProjectDraft;
|
||||
setDraft: (patch: Partial<ProjectDraft>) => void;
|
||||
clearDraft: () => void;
|
||||
hasDraft: () => boolean;
|
||||
}
|
||||
|
||||
export const useProjectDraftStore = create<ProjectDraftStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
draft: { ...EMPTY_DRAFT },
|
||||
setDraft: (patch) =>
|
||||
set((s) => ({ draft: { ...s.draft, ...patch } })),
|
||||
clearDraft: () =>
|
||||
set({ draft: { ...EMPTY_DRAFT } }),
|
||||
hasDraft: () => {
|
||||
const { draft } = get();
|
||||
return !!(draft.title || draft.description);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "multica_project_draft",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useProjectDraftStore.persist.rehydrate());
|
||||
@@ -1,2 +1,9 @@
|
||||
export { projectKeys, projectListOptions, projectDetailOptions } from "./queries";
|
||||
export { useCreateProject, useUpdateProject, useDeleteProject } from "./mutations";
|
||||
export { useProjectDraftStore } from "./draft-store";
|
||||
export {
|
||||
projectResourceKeys,
|
||||
projectResourcesOptions,
|
||||
useCreateProjectResource,
|
||||
useDeleteProjectResource,
|
||||
} from "./resource-queries";
|
||||
|
||||
87
packages/core/projects/resource-queries.ts
Normal file
87
packages/core/projects/resource-queries.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { projectKeys } from "./queries";
|
||||
import type {
|
||||
CreateProjectResourceRequest,
|
||||
ListProjectResourcesResponse,
|
||||
ProjectResource,
|
||||
} from "../types";
|
||||
|
||||
export const projectResourceKeys = {
|
||||
list: (wsId: string, projectId: string) =>
|
||||
[...projectKeys.detail(wsId, projectId), "resources"] as const,
|
||||
};
|
||||
|
||||
export function projectResourcesOptions(wsId: string, projectId: string) {
|
||||
return queryOptions({
|
||||
queryKey: projectResourceKeys.list(wsId, projectId),
|
||||
queryFn: () => api.listProjectResources(projectId),
|
||||
select: (data) => data.resources,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateProjectResource(wsId: string, projectId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateProjectResourceRequest) =>
|
||||
api.createProjectResource(projectId, data),
|
||||
onSuccess: (created) => {
|
||||
qc.setQueryData<ListProjectResourcesResponse>(
|
||||
projectResourceKeys.list(wsId, projectId),
|
||||
(old) =>
|
||||
old && !old.resources.some((r) => r.id === created.id)
|
||||
? {
|
||||
...old,
|
||||
resources: [...old.resources, created],
|
||||
total: old.total + 1,
|
||||
}
|
||||
: old,
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: projectResourceKeys.list(wsId, projectId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteProjectResource(wsId: string, projectId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (resourceId: string) =>
|
||||
api.deleteProjectResource(projectId, resourceId),
|
||||
onMutate: async (resourceId) => {
|
||||
await qc.cancelQueries({
|
||||
queryKey: projectResourceKeys.list(wsId, projectId),
|
||||
});
|
||||
const prev = qc.getQueryData<ListProjectResourcesResponse>(
|
||||
projectResourceKeys.list(wsId, projectId),
|
||||
);
|
||||
qc.setQueryData<ListProjectResourcesResponse>(
|
||||
projectResourceKeys.list(wsId, projectId),
|
||||
(old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
resources: old.resources.filter(
|
||||
(r: ProjectResource) => r.id !== resourceId,
|
||||
),
|
||||
total: old.total - 1,
|
||||
}
|
||||
: old,
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prev) {
|
||||
qc.setQueryData(projectResourceKeys.list(wsId, projectId), ctx.prev);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: projectResourceKeys.list(wsId, projectId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueD
|
||||
import { inboxKeys } from "../inbox/queries";
|
||||
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
|
||||
import { chatKeys } from "../chat/queries";
|
||||
import { useChatStore } from "../chat";
|
||||
import { resolvePostAuthDestination, useHasOnboarded } from "../paths";
|
||||
import type {
|
||||
MemberAddedPayload,
|
||||
@@ -206,7 +207,7 @@ export function useRealtimeSync(
|
||||
"subscriber:added", "subscriber:removed",
|
||||
"daemon:heartbeat",
|
||||
// Chat events are handled explicitly below; do not double-invalidate.
|
||||
"chat:message", "chat:done", "chat:session_read",
|
||||
"chat:message", "chat:done", "chat:session_read", "chat:session_deleted",
|
||||
// task:message stays out of the prefix path because it fires per
|
||||
// streamed message during a long run — invalidating the snapshot on
|
||||
// every message would flood the network. Specific chat handlers below
|
||||
@@ -625,6 +626,31 @@ export function useRealtimeSync(
|
||||
invalidateSessionLists();
|
||||
});
|
||||
|
||||
// chat:session_deleted fires after a hard delete. The originating tab has
|
||||
// already optimistically dropped the row via useDeleteChatSession; this
|
||||
// handler keeps OTHER tabs/devices in sync and also clears the active
|
||||
// session pointer so a deleted session doesn't keep the chat window
|
||||
// pointed at vanished messages.
|
||||
const unsubChatSessionDeleted = ws.on("chat:session_deleted", (p) => {
|
||||
const payload = p as { chat_session_id: string };
|
||||
chatWsLogger.info("chat:session_deleted (global)", payload);
|
||||
const id = getCurrentWsId();
|
||||
if (id) {
|
||||
const drop = (old?: { id: string }[]) =>
|
||||
old?.filter((s) => s.id !== payload.chat_session_id);
|
||||
qc.setQueryData(chatKeys.sessions(id), drop);
|
||||
qc.setQueryData(chatKeys.allSessions(id), drop);
|
||||
}
|
||||
qc.removeQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.removeQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
|
||||
const chatState = useChatStore.getState?.();
|
||||
if (chatState && chatState.activeSessionId === payload.chat_session_id) {
|
||||
chatState.setActiveSession(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubAny();
|
||||
unsubIssueUpdated();
|
||||
@@ -658,6 +684,7 @@ export function useRealtimeSync(
|
||||
unsubTaskCompleted();
|
||||
unsubTaskFailed();
|
||||
unsubChatSessionRead();
|
||||
unsubChatSessionDeleted();
|
||||
timers.forEach(clearTimeout);
|
||||
timers.clear();
|
||||
};
|
||||
|
||||
25
packages/core/runtimes/cli-version.test.ts
Normal file
25
packages/core/runtimes/cli-version.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { checkQuickCreateCliVersion } from "./cli-version";
|
||||
|
||||
describe("checkQuickCreateCliVersion", () => {
|
||||
it("returns ok for a tagged release at or above the minimum", () => {
|
||||
expect(checkQuickCreateCliVersion("v0.2.20").state).toBe("ok");
|
||||
expect(checkQuickCreateCliVersion("0.3.1").state).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns too_old for a tagged release below the minimum", () => {
|
||||
expect(checkQuickCreateCliVersion("v0.2.15").state).toBe("too_old");
|
||||
});
|
||||
|
||||
it("returns missing for empty or unparsable input", () => {
|
||||
expect(checkQuickCreateCliVersion("").state).toBe("missing");
|
||||
expect(checkQuickCreateCliVersion(undefined).state).toBe("missing");
|
||||
expect(checkQuickCreateCliVersion("not-a-version").state).toBe("missing");
|
||||
});
|
||||
|
||||
it("treats git-describe dev builds as ok regardless of base tag", () => {
|
||||
expect(checkQuickCreateCliVersion("v0.2.15-235-gdaf0e935").state).toBe("ok");
|
||||
expect(checkQuickCreateCliVersion("v0.2.15-235-gdaf0e935-dirty").state).toBe("ok");
|
||||
expect(checkQuickCreateCliVersion("0.1.0-1-gabc1234").state).toBe("ok");
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,14 @@ export interface CliVersionCheck {
|
||||
|
||||
const SEMVER_RE = /v?(\d+)\.(\d+)\.(\d+)/;
|
||||
|
||||
// Matches the `git describe --tags --always --dirty` output for a build past
|
||||
// the latest tag, e.g. `v0.2.15-235-gdaf0e935` or `v0.2.15-235-gdaf0e935-dirty`.
|
||||
// Daemons built from source (Makefile `make build` / `make daemon`) report this
|
||||
// shape; tagged releases are bare semver. Treating dev-described daemons as OK
|
||||
// is what keeps `pnpm dev:desktop` + `make daemon` unblocked without weakening
|
||||
// the gate for staging or production users running stale stable releases.
|
||||
const DEV_DESCRIBE_RE = /^v?\d+\.\d+\.\d+-\d+-g[0-9a-fA-F]+/;
|
||||
|
||||
function parseSemver(raw: string): [number, number, number] | null {
|
||||
const m = SEMVER_RE.exec(raw.trim());
|
||||
if (!m) return null;
|
||||
@@ -40,9 +48,14 @@ function lessThan(a: [number, number, number], b: [number, number, number]) {
|
||||
* Check a daemon-reported CLI version string against the minimum. Returns
|
||||
* `"missing"` for empty/unparsable input (fail closed — same policy as the
|
||||
* server) and `"too_old"` for a parsable version below the threshold.
|
||||
* Dev-built daemons (git-describe shape) are always OK — the version string
|
||||
* itself is the shared signal, so frontend and server agree by construction.
|
||||
*/
|
||||
export function checkQuickCreateCliVersion(detected: string | undefined | null): CliVersionCheck {
|
||||
const current = (detected ?? "").trim();
|
||||
if (DEV_DESCRIBE_RE.test(current)) {
|
||||
return { state: "ok", current, min: MIN_QUICK_CREATE_CLI_VERSION };
|
||||
}
|
||||
const parsed = current ? parseSemver(current) : null;
|
||||
if (!parsed) {
|
||||
return { state: "missing", current, min: MIN_QUICK_CREATE_CLI_VERSION };
|
||||
|
||||
@@ -23,4 +23,6 @@ export interface TimelineEntry {
|
||||
comment_type?: string;
|
||||
reactions?: Reaction[];
|
||||
attachments?: Attachment[];
|
||||
/** Set by frontend coalescing when consecutive identical activities are merged. */
|
||||
coalesced_count?: number;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export type WSEventType =
|
||||
| "chat:message"
|
||||
| "chat:done"
|
||||
| "chat:session_read"
|
||||
| "chat:session_deleted"
|
||||
| "project:created"
|
||||
| "project:updated"
|
||||
| "project:deleted"
|
||||
@@ -280,6 +281,10 @@ export interface ChatSessionReadPayload {
|
||||
chat_session_id: string;
|
||||
}
|
||||
|
||||
export interface ChatSessionDeletedPayload {
|
||||
chat_session_id: string;
|
||||
}
|
||||
|
||||
export interface ProjectCreatedPayload {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export type {
|
||||
} from "./agent";
|
||||
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
|
||||
export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference";
|
||||
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
|
||||
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";
|
||||
export type { TimelineEntry, AssigneeFrequencyEntry } from "./activity";
|
||||
@@ -47,7 +48,19 @@ export type * from "./api";
|
||||
export type { Attachment } from "./attachment";
|
||||
export type { ChatSession, ChatMessage, ChatPendingTask, PendingChatTaskItem, PendingChatTasksResponse, SendChatMessageResponse } from "./chat";
|
||||
export type { StorageAdapter } from "./storage";
|
||||
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
|
||||
export type {
|
||||
Project,
|
||||
ProjectStatus,
|
||||
ProjectPriority,
|
||||
CreateProjectRequest,
|
||||
UpdateProjectRequest,
|
||||
ListProjectsResponse,
|
||||
ProjectResource,
|
||||
ProjectResourceType,
|
||||
GithubRepoResourceRef,
|
||||
CreateProjectResourceRequest,
|
||||
ListProjectResourcesResponse,
|
||||
} from "./project";
|
||||
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
|
||||
export type {
|
||||
Autopilot,
|
||||
|
||||
15
packages/core/types/notification-preference.ts
Normal file
15
packages/core/types/notification-preference.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type NotificationGroupKey =
|
||||
| "assignments"
|
||||
| "status_changes"
|
||||
| "comments"
|
||||
| "updates"
|
||||
| "agent_activity";
|
||||
|
||||
export type NotificationGroupValue = "all" | "muted";
|
||||
|
||||
export type NotificationPreferences = Partial<Record<NotificationGroupKey, NotificationGroupValue>>;
|
||||
|
||||
export interface NotificationPreferenceResponse {
|
||||
workspace_id: string;
|
||||
preferences: NotificationPreferences;
|
||||
}
|
||||
@@ -26,6 +26,9 @@ export interface CreateProjectRequest {
|
||||
priority?: ProjectPriority;
|
||||
lead_type?: "member" | "agent";
|
||||
lead_id?: string;
|
||||
// Resources to attach in the same transaction as the project. Server returns
|
||||
// 4xx (and rolls back) if any one is invalid or duplicate.
|
||||
resources?: CreateProjectResourceRequest[];
|
||||
}
|
||||
|
||||
export interface UpdateProjectRequest {
|
||||
@@ -42,3 +45,39 @@ export interface ListProjectsResponse {
|
||||
projects: Project[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ProjectResource is a typed pointer from a project to an external resource.
|
||||
// The resource_ref shape depends on resource_type (e.g. github_repo carries
|
||||
// { url, default_branch_hint? }). New types add a case in
|
||||
// validateAndNormalizeResourceRef on the server and a renderer in the UI;
|
||||
// no schema or type changes required.
|
||||
export type ProjectResourceType = "github_repo";
|
||||
|
||||
export interface GithubRepoResourceRef {
|
||||
url: string;
|
||||
default_branch_hint?: string;
|
||||
}
|
||||
|
||||
export interface ProjectResource {
|
||||
id: string;
|
||||
project_id: string;
|
||||
workspace_id: string;
|
||||
resource_type: ProjectResourceType;
|
||||
resource_ref: GithubRepoResourceRef | Record<string, unknown>;
|
||||
label: string | null;
|
||||
position: number;
|
||||
created_at: string;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
export interface CreateProjectResourceRequest {
|
||||
resource_type: ProjectResourceType;
|
||||
resource_ref: GithubRepoResourceRef | Record<string, unknown>;
|
||||
label?: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface ListProjectResourcesResponse {
|
||||
resources: ProjectResource[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ export type MemberRole = "owner" | "admin" | "member";
|
||||
|
||||
export interface WorkspaceRepo {
|
||||
url: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
|
||||
90
packages/ui/components/common/capability-banner.tsx
Normal file
90
packages/ui/components/common/capability-banner.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Lock } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
type Resource = "agent" | "skill" | "comment" | "runtime" | "workspace";
|
||||
|
||||
type Reason =
|
||||
| "allowed"
|
||||
| "not_authenticated"
|
||||
| "not_member"
|
||||
| "not_owner_role"
|
||||
| "not_admin_role"
|
||||
| "not_resource_owner"
|
||||
| "last_owner"
|
||||
| "private_visibility"
|
||||
| "unknown";
|
||||
|
||||
const RESOURCE_NOUN: Record<Resource, string> = {
|
||||
agent: "agent",
|
||||
skill: "skill",
|
||||
comment: "comment",
|
||||
runtime: "runtime",
|
||||
workspace: "workspace",
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only banner for resource detail pages — appears when the current user
|
||||
* cannot edit the resource. Single component owns all the copy variants so
|
||||
* the wording stays consistent across agent, skill, runtime detail pages.
|
||||
*
|
||||
* Returns `null` when the user *can* edit (reason === "allowed") so callers
|
||||
* can mount it unconditionally.
|
||||
*/
|
||||
export function CapabilityBanner({
|
||||
reason,
|
||||
resource,
|
||||
ownerName,
|
||||
className,
|
||||
}: {
|
||||
reason: Reason;
|
||||
resource: Resource;
|
||||
/** Display name of the resource owner / creator. Optional — copy degrades gracefully. */
|
||||
ownerName?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
if (reason === "allowed" || reason === "unknown") return null;
|
||||
|
||||
const noun = RESOURCE_NOUN[resource];
|
||||
const message = getCopy(reason, noun, ownerName);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border border-dashed bg-muted/30 px-3 py-2 text-xs text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Lock className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCopy(reason: Reason, noun: string, ownerName?: string): string {
|
||||
switch (reason) {
|
||||
case "not_authenticated":
|
||||
return `Sign in to edit this ${noun}.`;
|
||||
case "not_member":
|
||||
return `Join this workspace to edit this ${noun}.`;
|
||||
case "not_owner_role":
|
||||
return `View only — only the workspace owner can manage this ${noun}.`;
|
||||
case "not_admin_role":
|
||||
return `View only — only workspace owners and admins can manage this ${noun}.`;
|
||||
case "not_resource_owner":
|
||||
if (ownerName) {
|
||||
return `View only — only ${ownerName} and workspace admins can edit this ${noun}.`;
|
||||
}
|
||||
return `View only — only the ${noun} owner and workspace admins can edit this ${noun}.`;
|
||||
case "last_owner":
|
||||
return `A workspace must keep at least one owner — promote another member first.`;
|
||||
case "private_visibility":
|
||||
if (ownerName) {
|
||||
return `Personal ${noun} — only ${ownerName} and workspace admins can use this.`;
|
||||
}
|
||||
return `Personal ${noun} — only the owner and workspace admins can use this.`;
|
||||
case "allowed":
|
||||
case "unknown":
|
||||
return ""; // unreachable; component returned null above
|
||||
}
|
||||
}
|
||||
@@ -149,4 +149,14 @@
|
||||
* Progressive enhancement: browsers that don't support it simply ignore the rule. */
|
||||
text-autospace: ideograph-alpha ideograph-numeric;
|
||||
}
|
||||
|
||||
@media (max-width: 767px), (pointer: coarse) {
|
||||
input:not([type="button"]):not([type="checkbox"]):not([type="color"]):not([type="file"]):not([type="hidden"]):not([type="image"]):not([type="radio"]):not([type="range"]):not([type="reset"]):not([type="submit"]),
|
||||
textarea,
|
||||
select,
|
||||
[contenteditable]:not([contenteditable="false"]) {
|
||||
/* iOS Safari zooms the page when focused editable text is below 16px. */
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type AgentActivity,
|
||||
type AgentPresenceDetail,
|
||||
summarizeActivityWindow,
|
||||
VISIBILITY_TOOLTIP,
|
||||
} from "@multica/core/agents";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -30,6 +31,8 @@ export interface AgentRow {
|
||||
// Inline owner avatar — non-null when the page wants to attribute the
|
||||
// agent to a teammate (typically All scope on someone else's agent).
|
||||
ownerIdToShow: string | null;
|
||||
// True when the current user owns this agent (drives the "You" badge).
|
||||
isOwnedByMe: boolean;
|
||||
// True when the current user can archive / cancel-tasks on this agent.
|
||||
canManage: boolean;
|
||||
}
|
||||
@@ -151,7 +154,7 @@ export function createAgentColumns({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AgentNameCell({ row }: { row: AgentRow }) {
|
||||
const { agent, ownerIdToShow } = row;
|
||||
const { agent, ownerIdToShow, isOwnedByMe } = row;
|
||||
const isArchived = !!agent.archived_at;
|
||||
const isPrivate = agent.visibility === "private";
|
||||
|
||||
@@ -181,10 +184,15 @@ function AgentNameCell({ row }: { row: AgentRow }) {
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
Private — only the owner can assign work
|
||||
{VISIBILITY_TOOLTIP.private}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isOwnedByMe && !ownerIdToShow && (
|
||||
<span className="shrink-0 rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
{ownerIdToShow && (
|
||||
<ActorAvatar
|
||||
actorType="member"
|
||||
|
||||
@@ -55,6 +55,15 @@ interface InspectorProps {
|
||||
runtimes: AgentRuntime[];
|
||||
members: MemberWithUser[];
|
||||
currentUserId: string | null;
|
||||
/**
|
||||
* Computed by the parent via `useAgentPermissions(agent).canEdit.allowed`.
|
||||
* When false the inspector renders all editable surfaces as static
|
||||
* read-only displays — pickers become text/badges, name/description lose
|
||||
* their pencil affordance, the avatar is no longer clickable, and the
|
||||
* "Attach skill" trigger is hidden. Mirrors the backend gate at
|
||||
* `server/internal/handler/agent.go:519-535`.
|
||||
*/
|
||||
canEdit: boolean;
|
||||
onUpdate: (id: string, data: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -77,25 +86,28 @@ export function AgentDetailInspector({
|
||||
runtimes,
|
||||
members,
|
||||
currentUserId,
|
||||
canEdit,
|
||||
onUpdate,
|
||||
}: InspectorProps) {
|
||||
const update = (data: Record<string, unknown>) => onUpdate(agent.id, data);
|
||||
const isOnline = runtime?.status === "online";
|
||||
|
||||
return (
|
||||
<aside className="flex h-full min-h-0 w-full flex-col overflow-y-auto rounded-lg border bg-background">
|
||||
<aside className="flex w-full flex-col rounded-lg border bg-background md:h-full md:min-h-0 md:overflow-y-auto">
|
||||
{/* Identity */}
|
||||
<div className="flex flex-col gap-3 border-b px-5 pb-5 pt-5">
|
||||
<AvatarEditor agent={agent} onUpdate={update} />
|
||||
<NameAndDescription agent={agent} onUpdate={update} />
|
||||
<AvatarEditor agent={agent} canEdit={canEdit} onUpdate={update} />
|
||||
<NameAndDescription
|
||||
agent={agent}
|
||||
canEdit={canEdit}
|
||||
onUpdate={update}
|
||||
/>
|
||||
<PresenceBadge presence={presence} />
|
||||
</div>
|
||||
|
||||
{/* Properties — editable. Row hover is OFF here on purpose: each chip
|
||||
(RuntimePicker, ModelPicker, …) carries its own border + hover-bg
|
||||
treatment that already telegraphs "this is a button". A second
|
||||
row-wide hover layer on top would just smudge the chip boundary
|
||||
and make it harder, not easier, to see what's clickable. */}
|
||||
{/* Properties — editable when canEdit. When the current user lacks
|
||||
permission, each picker self-renders a static read-only display so
|
||||
the value is visible but not interactive. */}
|
||||
<Section label="Properties">
|
||||
<PropRow label="Runtime" interactive={false}>
|
||||
<RuntimePicker
|
||||
@@ -103,6 +115,7 @@ export function AgentDetailInspector({
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={currentUserId}
|
||||
canEdit={canEdit}
|
||||
onChange={(id) => update({ runtime_id: id })}
|
||||
/>
|
||||
</PropRow>
|
||||
@@ -111,18 +124,21 @@ export function AgentDetailInspector({
|
||||
runtimeId={agent.runtime_id}
|
||||
runtimeOnline={!!isOnline}
|
||||
value={agent.model ?? ""}
|
||||
canEdit={canEdit}
|
||||
onChange={(m) => update({ model: m })}
|
||||
/>
|
||||
</PropRow>
|
||||
<PropRow label="Visibility" interactive={false}>
|
||||
<VisibilityPicker
|
||||
value={agent.visibility}
|
||||
canEdit={canEdit}
|
||||
onChange={(v) => update({ visibility: v })}
|
||||
/>
|
||||
</PropRow>
|
||||
<PropRow label="Concurrency" interactive={false}>
|
||||
<ConcurrencyPicker
|
||||
value={agent.max_concurrent_tasks}
|
||||
canEdit={canEdit}
|
||||
onChange={(n) => update({ max_concurrent_tasks: n })}
|
||||
/>
|
||||
</PropRow>
|
||||
@@ -173,7 +189,7 @@ export function AgentDetailInspector({
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
<SkillAttach agent={agent} />
|
||||
<SkillAttach agent={agent} canEdit={canEdit} />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -192,11 +208,13 @@ function Section({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 border-b px-5 py-4">
|
||||
<div className="mb-1 px-2 -mx-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<div className="border-b px-5 py-4">
|
||||
<div className="mb-1 -mx-2 px-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -207,14 +225,29 @@ function Section({
|
||||
|
||||
function AvatarEditor({
|
||||
agent,
|
||||
canEdit,
|
||||
onUpdate,
|
||||
}: {
|
||||
agent: Agent;
|
||||
canEdit: boolean;
|
||||
onUpdate: (data: Record<string, unknown>) => Promise<void>;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { upload, uploading } = useFileUpload(api);
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted">
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={agent.id}
|
||||
size={56}
|
||||
className="rounded-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -267,11 +300,32 @@ function AvatarEditor({
|
||||
|
||||
function NameAndDescription({
|
||||
agent,
|
||||
canEdit,
|
||||
onUpdate,
|
||||
}: {
|
||||
agent: Agent;
|
||||
canEdit: boolean;
|
||||
onUpdate: (data: Record<string, unknown>) => Promise<void>;
|
||||
}) {
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-semibold leading-tight">
|
||||
{agent.name}
|
||||
</span>
|
||||
{agent.description ? (
|
||||
<span className="text-xs leading-relaxed text-muted-foreground">
|
||||
{agent.description}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs italic leading-relaxed text-muted-foreground/50">
|
||||
No description
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<InlineEditPopover
|
||||
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
workspaceKeys,
|
||||
} from "@multica/core/workspace/queries";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes";
|
||||
import { useAgentPermissions } from "@multica/core/permissions";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { CapabilityBanner } from "@multica/ui/components/common/capability-banner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -74,6 +76,12 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
const presence: AgentPresenceDetail | null =
|
||||
agent ? presenceMap.get(agent.id) ?? null : null;
|
||||
|
||||
// Permission hook MUST be called unconditionally — its `agent | null`
|
||||
// signature handles the not-found / loading case internally so the early
|
||||
// returns below don't violate the rules of hooks. Backend gates archive
|
||||
// and restore identically to edit, so a single `canEdit` covers them all.
|
||||
const { canEdit } = useAgentPermissions(agent, wsId);
|
||||
|
||||
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||
|
||||
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
|
||||
@@ -163,27 +171,40 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
agent={agent}
|
||||
presence={presence}
|
||||
backHref={paths.agents()}
|
||||
canArchive={canEdit.allowed}
|
||||
onArchive={() => setConfirmArchive(true)}
|
||||
/>
|
||||
|
||||
{!canEdit.allowed && (
|
||||
<div className="px-6 pt-3">
|
||||
<CapabilityBanner
|
||||
reason={canEdit.reason}
|
||||
resource="agent"
|
||||
ownerName={owner?.name}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isArchived && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/50 px-6 py-2 text-xs text-muted-foreground">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1">
|
||||
This agent is archived. It cannot be assigned or mentioned.
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => handleRestore(agent.id)}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
{canEdit.allowed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => handleRestore(agent.id)}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid flex-1 min-h-0 grid-cols-[320px_minmax(0,1fr)] gap-4 p-6">
|
||||
<div className="flex flex-1 min-h-0 flex-col gap-3 overflow-y-auto p-3 md:grid md:grid-cols-[320px_minmax(0,1fr)] md:gap-4 md:overflow-hidden md:p-6">
|
||||
<AgentDetailInspector
|
||||
agent={agent}
|
||||
runtime={runtime}
|
||||
@@ -192,6 +213,7 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={currentUser?.id ?? null}
|
||||
canEdit={canEdit.allowed}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
|
||||
@@ -254,11 +276,13 @@ function DetailHeader({
|
||||
agent,
|
||||
presence,
|
||||
backHref,
|
||||
canArchive,
|
||||
onArchive,
|
||||
}: {
|
||||
agent: Agent;
|
||||
presence: AgentPresenceDetail | null;
|
||||
backHref: string;
|
||||
canArchive: boolean;
|
||||
onArchive: () => void;
|
||||
}) {
|
||||
const isArchived = !!agent.archived_at;
|
||||
@@ -290,7 +314,7 @@ function DetailHeader({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isArchived && (
|
||||
{!isArchived && canArchive && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="ghost" size="icon-sm" />}
|
||||
@@ -334,7 +358,7 @@ function DetailLoadingSkeleton() {
|
||||
<PageHeader className="px-5">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
</PageHeader>
|
||||
<div className="grid flex-1 min-h-0 grid-cols-[320px_minmax(0,1fr)] gap-4 p-6">
|
||||
<div className="flex flex-1 min-h-0 flex-col gap-3 overflow-y-auto p-3 md:grid md:grid-cols-[320px_minmax(0,1fr)] md:gap-4 md:overflow-hidden md:p-6">
|
||||
<div className="flex flex-col gap-4 rounded-lg border p-5">
|
||||
<Skeleton className="h-14 w-14 rounded-lg" />
|
||||
<Skeleton className="h-5 w-40" />
|
||||
|
||||
@@ -108,14 +108,14 @@ export function AgentOverviewPane({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-background">
|
||||
<div className="flex shrink-0 items-center gap-0 border-b px-4">
|
||||
<div className="flex min-h-[60vh] flex-col overflow-hidden rounded-lg border bg-background md:h-full md:min-h-0">
|
||||
<div className="flex shrink-0 items-center gap-0 overflow-x-auto border-b px-2 md:px-4">
|
||||
{detailTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => requestTabChange(tab.id)}
|
||||
className={`flex items-center gap-1.5 border-b-2 px-3 py-2.5 text-xs font-medium transition-colors ${
|
||||
className={`flex shrink-0 items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2.5 text-xs font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
@@ -204,6 +204,6 @@ export function AgentOverviewPane({
|
||||
// list) still scrolls via the parent's overflow-y-auto.
|
||||
function TabContent({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto flex h-full max-w-2xl flex-col p-6">{children}</div>
|
||||
<div className="mx-auto flex h-full max-w-2xl flex-col p-4 md:p-6">{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { AppLink } from "../../navigation";
|
||||
import { HealthIcon } from "../../runtimes/components/shared";
|
||||
import { availabilityConfig } from "../presence";
|
||||
import { VisibilityBadge } from "./visibility-badge";
|
||||
|
||||
interface AgentProfileCardProps {
|
||||
agentId: string;
|
||||
@@ -81,6 +82,7 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="truncate text-sm font-semibold">{agent.name}</p>
|
||||
{!isArchived && <VisibilityBadge value={agent.visibility} compact />}
|
||||
{isArchived && (
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
Archived
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { api } from "@multica/core/api";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { canAssignAgentToIssue } from "@multica/core/permissions";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import {
|
||||
agentListOptions,
|
||||
@@ -143,27 +144,42 @@ export function AgentsPage() {
|
||||
[agents, view],
|
||||
);
|
||||
|
||||
// Layer 1b — ownership scope. Counts shown on the segment are
|
||||
// computed against the inView set so the numbers always reflect
|
||||
// Layer 1b — visibility. Personal (visibility=private) agents owned by
|
||||
// someone else are hidden from regular members; workspace owners/admins
|
||||
// still see everything. Mirrors the assign-to-issue gate so the list
|
||||
// only ever shows agents the user could actually act on. Backend keeps
|
||||
// returning all agents, so admin tools (and the API itself) are
|
||||
// unaffected — this is a UI-only filter.
|
||||
const visibleInView = useMemo(() => {
|
||||
return inView.filter((a) =>
|
||||
canAssignAgentToIssue(a, {
|
||||
userId: currentUser?.id ?? null,
|
||||
role: myRole,
|
||||
}).allowed,
|
||||
);
|
||||
}, [inView, currentUser?.id, myRole]);
|
||||
|
||||
// Layer 1c — ownership scope. Counts shown on the segment are
|
||||
// computed against the visibleInView set so the numbers always reflect
|
||||
// "what would I see if I clicked this".
|
||||
const scopeCounts = useMemo(() => {
|
||||
let mine = 0;
|
||||
if (currentUser) {
|
||||
for (const a of inView) {
|
||||
for (const a of visibleInView) {
|
||||
if (a.owner_id === currentUser.id) mine += 1;
|
||||
}
|
||||
}
|
||||
return { all: inView.length, mine };
|
||||
}, [inView, currentUser]);
|
||||
return { all: visibleInView.length, mine };
|
||||
}, [visibleInView, currentUser]);
|
||||
|
||||
const inScope = useMemo(() => {
|
||||
// Archived view ignores Mine / All — its toolbar has no scope
|
||||
// segment, so silently filtering by `scope` would hide other
|
||||
// people's archived agents without any UI to explain why.
|
||||
if (view === "archived") return inView;
|
||||
if (scope === "all" || !currentUser) return inView;
|
||||
return inView.filter((a) => a.owner_id === currentUser.id);
|
||||
}, [inView, scope, currentUser, view]);
|
||||
if (view === "archived") return visibleInView;
|
||||
if (scope === "all" || !currentUser) return visibleInView;
|
||||
return visibleInView.filter((a) => a.owner_id === currentUser.id);
|
||||
}, [visibleInView, scope, currentUser, view]);
|
||||
|
||||
// Final cut — availability chip + search.
|
||||
const filteredAgents = useMemo(() => {
|
||||
@@ -264,6 +280,7 @@ export function AgentsPage() {
|
||||
|
||||
const handleCreate = async (data: CreateAgentRequest) => {
|
||||
const agent = await api.createAgent(data);
|
||||
let cachedAgent = agent;
|
||||
// When duplicating, carry the source agent's skill assignments over.
|
||||
// Skills aren't part of CreateAgentRequest (they're managed via
|
||||
// setAgentSkills) so the create endpoint can't take them inline; we
|
||||
@@ -275,14 +292,21 @@ export function AgentsPage() {
|
||||
await api.setAgentSkills(agent.id, {
|
||||
skill_ids: duplicateTemplate.skills.map((s) => s.id),
|
||||
});
|
||||
cachedAgent = { ...agent, skills: duplicateTemplate.skills };
|
||||
} catch {
|
||||
// Surfaced softly; the agent itself is fine.
|
||||
}
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
qc.setQueryData<Agent[]>(workspaceKeys.agents(wsId), (current = []) => {
|
||||
const exists = current.some((a) => a.id === cachedAgent.id);
|
||||
return exists
|
||||
? current.map((a) => (a.id === cachedAgent.id ? cachedAgent : a))
|
||||
: [...current, cachedAgent];
|
||||
});
|
||||
setShowCreate(false);
|
||||
setDuplicateTemplate(null);
|
||||
navigation.push(paths.agentDetail(agent.id));
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
};
|
||||
|
||||
const handleDuplicate = useCallback((agent: Agent) => {
|
||||
@@ -311,6 +335,7 @@ export function AgentsPage() {
|
||||
activity: activityMap.get(agent.id) ?? null,
|
||||
runCount: runCountsById.get(agent.id) ?? 0,
|
||||
ownerIdToShow,
|
||||
isOwnedByMe: isOwner,
|
||||
canManage,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -29,7 +29,11 @@ import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { AGENT_DESCRIPTION_MAX_LENGTH } from "@multica/core/agents";
|
||||
import {
|
||||
AGENT_DESCRIPTION_MAX_LENGTH,
|
||||
VISIBILITY_DESCRIPTION,
|
||||
VISIBILITY_LABEL,
|
||||
} from "@multica/core/agents";
|
||||
import { CharCounter } from "./char-counter";
|
||||
|
||||
type RuntimeFilter = "mine" | "all";
|
||||
@@ -202,8 +206,10 @@ export function CreateAgentDialog({
|
||||
>
|
||||
<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 className="font-medium">{VISIBILITY_LABEL.workspace}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{VISIBILITY_DESCRIPTION.workspace}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@@ -217,8 +223,10 @@ export function CreateAgentDialog({
|
||||
>
|
||||
<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 className="font-medium">{VISIBILITY_LABEL.private}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{VISIBILITY_DESCRIPTION.private}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user