mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-18 12:18:45 +02:00
Compare commits
90 Commits
feat/quick
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8260e11c8a | ||
|
|
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 | ||
|
|
28b29ec5ee | ||
|
|
b98c2a5a0f | ||
|
|
b9118ae9b8 | ||
|
|
06880d6ba2 | ||
|
|
472e78022e | ||
|
|
5bf0e7022d | ||
|
|
665ac39730 | ||
|
|
55b7e2e93a | ||
|
|
80c5bb9e9e | ||
|
|
6a665c68a3 | ||
|
|
174b8c62a6 | ||
|
|
768d3f8b0c | ||
|
|
7dfa72465c | ||
|
|
0b969483a6 | ||
|
|
e024ab1232 | ||
|
|
f4eb83bd41 | ||
|
|
dde42ba84a | ||
|
|
9467a8c616 | ||
|
|
cfa38df97b | ||
|
|
4ad0a0b847 | ||
|
|
1fd583ef65 | ||
|
|
286ecf04b1 | ||
|
|
bd82607645 | ||
|
|
365e84b920 | ||
|
|
86e7de3e41 | ||
|
|
936ccce8fa | ||
|
|
49ccd22027 | ||
|
|
e66bd593ea | ||
|
|
7528022355 | ||
|
|
391a4ecd09 | ||
|
|
54d895a210 |
@@ -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:
|
||||
|
||||
@@ -181,8 +198,12 @@ Agent-specific overrides:
|
||||
|----------|-------------|
|
||||
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CLAUDE_ARGS` | Default extra arguments for Claude Code runs |
|
||||
| `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 |
|
||||
@@ -200,6 +221,8 @@ Agent-specific overrides:
|
||||
| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
|
||||
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
|
||||
|
||||
`MULTICA_CLAUDE_ARGS` and `MULTICA_CODEX_ARGS` are parsed with POSIX shellword quoting, so values such as `--model "gpt-5.1 codex" --sandbox read-only` are split like a shell command line. Agent arguments are applied in this order: hardcoded Multica defaults, daemon-wide env defaults, then per-agent `custom_args` from the task.
|
||||
|
||||
### Self-Hosted Server
|
||||
|
||||
When connecting to a self-hosted Multica instance, the easiest approach is:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -103,6 +103,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 |
|
||||
|
||||
33
apps/desktop/src/main/app-version.ts
Normal file
33
apps/desktop/src/main/app-version.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { app } from "electron";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Resolve the running app version. In packaged builds this is the value
|
||||
* `electron-builder` baked into package.json via `extraMetadata.version`
|
||||
* (driven by `git describe` — see `apps/desktop/scripts/package.mjs`), so
|
||||
* `app.getVersion()` matches the GitHub Release tag exactly.
|
||||
*
|
||||
* In dev (`pnpm dev:desktop`) `app.getVersion()` only sees the static
|
||||
* `apps/desktop/package.json` value, which is "0.1.0" and never bumped —
|
||||
* the Settings → Updates panel and any other UI surfacing the version
|
||||
* would mislead developers into thinking they're running ancient builds.
|
||||
* Fall back to `git describe --tags --always --dirty` (same source the
|
||||
* packager uses) so dev shows e.g. `0.2.19-14-gabcdef-dirty`. If git is
|
||||
* unavailable for whatever reason, we just return the package.json value.
|
||||
*/
|
||||
export function getAppVersion(): string {
|
||||
if (app.isPackaged) {
|
||||
return app.getVersion();
|
||||
}
|
||||
try {
|
||||
const raw = execSync("git describe --tags --always --dirty", {
|
||||
cwd: app.getAppPath(),
|
||||
encoding: "utf-8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
if (!raw) return app.getVersion();
|
||||
return raw.replace(/^v/, "");
|
||||
} catch {
|
||||
return app.getVersion();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { getAppVersion } from "./app-version";
|
||||
|
||||
// Bundled icon used for dev-mode dock/taskbar branding. In production the
|
||||
// app bundle icon (from electron-builder) wins; this path is only consumed
|
||||
@@ -110,6 +111,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"]) {
|
||||
@@ -203,7 +220,7 @@ if (!gotTheLock) {
|
||||
ipcMain.on("app:get-info", (event) => {
|
||||
const p = process.platform;
|
||||
const os = p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
|
||||
event.returnValue = { version: app.getVersion(), os };
|
||||
event.returnValue = { version: getAppVersion(), os };
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
|
||||
@@ -110,21 +110,58 @@ function AppContent() {
|
||||
: undefined;
|
||||
useDaemonIPCBridge(activeWsId);
|
||||
|
||||
// Onboarding and zero-workspace both resolve to an overlay, but
|
||||
// onboarding wins: a user who hasn't completed it gets the onboarding
|
||||
// overlay regardless of how many workspaces already exist.
|
||||
// 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 (overlay) return undefined;
|
||||
if (wsCount > 0) return undefined;
|
||||
if (!hasOnboarded) {
|
||||
open({ type: "onboarding" });
|
||||
return;
|
||||
// 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;
|
||||
};
|
||||
}
|
||||
if (wsCount === 0) {
|
||||
open({ type: "new-workspace" });
|
||||
}
|
||||
}, [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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import { Button } from "@multica/ui/components/ui/button";
|
||||
type CheckState =
|
||||
| { status: "idle" }
|
||||
| { status: "checking" }
|
||||
| { status: "up-to-date"; currentVersion: string }
|
||||
| { status: "up-to-date" }
|
||||
| { status: "available"; latestVersion: string }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
export function UpdatesSettingsTab() {
|
||||
const [state, setState] = useState<CheckState>({ status: "idle" });
|
||||
const currentVersion = window.desktopAPI.appInfo.version;
|
||||
|
||||
const handleCheck = useCallback(async () => {
|
||||
setState({ status: "checking" });
|
||||
@@ -22,7 +23,7 @@ export function UpdatesSettingsTab() {
|
||||
setState(
|
||||
result.available
|
||||
? { status: "available", latestVersion: result.latestVersion }
|
||||
: { status: "up-to-date", currentVersion: result.currentVersion },
|
||||
: { status: "up-to-date" },
|
||||
);
|
||||
}, []);
|
||||
|
||||
@@ -35,6 +36,15 @@ export function UpdatesSettingsTab() {
|
||||
</p>
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
<div className="flex items-center justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Current version</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 font-mono">
|
||||
v{currentVersion}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Check for updates</p>
|
||||
@@ -45,7 +55,7 @@ export function UpdatesSettingsTab() {
|
||||
{state.status === "up-to-date" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
<Check className="size-3.5 text-success" />
|
||||
You're on the latest version (v{state.currentVersion}).
|
||||
You're on the latest version.
|
||||
</p>
|
||||
)}
|
||||
{state.status === "available" && (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -61,6 +61,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 {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { create } from "zustand";
|
||||
export type WindowOverlay =
|
||||
| { type: "new-workspace" }
|
||||
| { type: "invite"; invitationId: string }
|
||||
| { type: "invitations" }
|
||||
| { type: "onboarding" };
|
||||
|
||||
interface WindowOverlayStore {
|
||||
|
||||
@@ -1 +1,38 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
key: (index: number) => Array.from(values.keys())[index] ?? null,
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const localStorageIsUsable =
|
||||
typeof globalThis.localStorage?.getItem === "function" &&
|
||||
typeof globalThis.localStorage?.setItem === "function" &&
|
||||
typeof globalThis.localStorage?.removeItem === "function" &&
|
||||
typeof globalThis.localStorage?.clear === "function";
|
||||
|
||||
if (!localStorageIsUsable) {
|
||||
const storage = createMemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
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();
|
||||
@@ -72,33 +98,28 @@ function LoginPageContent() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!hasOnboarded) {
|
||||
router.replace(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
if (nextUrl) {
|
||||
router.replace(nextUrl);
|
||||
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;
|
||||
const onboarded = currentUser?.onboarded_at != null;
|
||||
if (!onboarded) {
|
||||
router.push(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
if (nextUrl) {
|
||||
router.push(nextUrl);
|
||||
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
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function OnboardingPage() {
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
const { data: workspaces = [], isFetched: workspacesFetched } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user && hasOnboarded,
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -40,7 +40,15 @@ export default function OnboardingPage() {
|
||||
if (!isLoading && !user) router.replace(paths.login());
|
||||
return;
|
||||
}
|
||||
if (hasOnboarded && workspacesFetched) {
|
||||
if (!workspacesFetched) return;
|
||||
// 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, router]);
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}));
|
||||
@@ -61,26 +73,78 @@ import CallbackPage from "./page";
|
||||
describe("CallbackPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k));
|
||||
// Snapshot keys before deleting — forEach + delete skips entries because
|
||||
// the iteration index advances while the underlying list shrinks.
|
||||
Array.from(mockSearchParams.keys()).forEach((k) =>
|
||||
mockSearchParams.delete(k),
|
||||
);
|
||||
mockSearchParams.set("code", "test-code");
|
||||
mockLoginWithGoogle.mockResolvedValue(makeUser());
|
||||
mockListWorkspaces.mockResolvedValue([]);
|
||||
mockListMyInvitations.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("unonboarded user lands on /onboarding regardless of next=", async () => {
|
||||
it("unonboarded user honors a safe next= (e.g. /invite/{id}) so invitees aren't trapped", async () => {
|
||||
mockSearchParams.set("state", "next:/invite/abc123");
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
|
||||
});
|
||||
expect(mockPush).not.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= also 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 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",
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
description: null,
|
||||
context: null,
|
||||
settings: {},
|
||||
repos: [],
|
||||
issue_prefix: "ACME",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
]);
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.workspace("acme").issues());
|
||||
});
|
||||
// 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 () => {
|
||||
@@ -109,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;
|
||||
if (!onboarded) {
|
||||
router.push(paths.onboarding());
|
||||
|
||||
// 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;
|
||||
}
|
||||
router.push(
|
||||
nextUrl || resolvePostAuthDestination(wsList, onboarded),
|
||||
);
|
||||
|
||||
// 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");
|
||||
|
||||
61
apps/web/app/not-found.tsx
Normal file
61
apps/web/app/not-found.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Instrument_Serif } from "next/font/google";
|
||||
|
||||
// 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",
|
||||
});
|
||||
|
||||
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>
|
||||
.
|
||||
</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 }}
|
||||
>
|
||||
Back to Multica
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -283,6 +283,81 @@ 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",
|
||||
title: "Create Issue by Agent, Agent Presence v3 & Daemon WebSocket Heartbeat",
|
||||
changes: [],
|
||||
features: [
|
||||
"Create Issue by Agent — press `c`, write one line, pick an agent; issue creation runs async and the result lands in your inbox",
|
||||
"Agent Presence v3 — availability and last-task split into clearer signals, with an execution log on the issue panel showing active and recent runs",
|
||||
"Daemon ↔ server heartbeat now flows over WebSocket with HTTP fallback, cutting task wakeup latency",
|
||||
"Mention picker ranks suggestions by your local recency",
|
||||
],
|
||||
improvements: [
|
||||
"Server caches PAT / daemon token lookups in Redis, so large fleets stop hammering the database on every request",
|
||||
"Backend default agent CLI args via `MULTICA_CLAUDE_ARGS` / `MULTICA_CODEX_ARGS` env vars",
|
||||
"Manual and agent create-issue flows share one dialog shell, and picker agents become the default assignee",
|
||||
],
|
||||
fixes: [
|
||||
"Create-issue-by-agent no longer leaves tasks stuck queued, and no longer duplicates the issue when an attachment upload fails",
|
||||
"Agent comments respect newlines instead of rendering literal `\\n`, and multi-line replies keep their formatting",
|
||||
"Agent-authored root comments no longer inherit parent @mentions, breaking accidental agent loops",
|
||||
"Cursor agent on Windows preserves multi-line prompts",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.19",
|
||||
date: "2026-04-28",
|
||||
|
||||
@@ -283,6 +283,81 @@ 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",
|
||||
title: "Create Issue by Agent、Agent Presence v3 与 Daemon WebSocket 心跳",
|
||||
changes: [],
|
||||
features: [
|
||||
"Create Issue by Agent —— 按 `c` 输入一句话并选 Agent,Issue 异步创建,结果回执送达 Inbox",
|
||||
"Agent Presence v3 —— 可用性与最近任务拆成两条更清晰的信号;Issue 详情右侧新增 Execution Log,可看到当前 active run 与历史 run",
|
||||
"Daemon ↔ Server 心跳改走 WebSocket,HTTP 自动 fallback,任务起跑延迟更低",
|
||||
"Mention 选择器按本机最近使用排序",
|
||||
],
|
||||
improvements: [
|
||||
"Server 用 Redis 缓存 PAT / Daemon Token 校验,大型团队不再让 DB 抗下每次请求",
|
||||
"后端支持通过 `MULTICA_CLAUDE_ARGS` / `MULTICA_CODEX_ARGS` 配置 Agent CLI 默认参数",
|
||||
"Manual 与 Agent 创建 Issue 共享同一个 Dialog 外壳,picker Agent 会被默认设为 assignee",
|
||||
],
|
||||
fixes: [
|
||||
"Create Issue by Agent 不再卡住 queued 任务,也不再因附件上传失败而重复创建 Issue",
|
||||
"Agent 评论保留换行,不再渲染成字面量 `\\n`,多行回复的格式也被完整保留",
|
||||
"Agent 自身发出的根评论不再继承父评论的 @mention,避免互相唤起的死循环",
|
||||
"Windows 下 Cursor Agent 启动时保留多行 prompt",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.19",
|
||||
date: "2026-04-28",
|
||||
|
||||
@@ -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;
|
||||
@@ -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`);
|
||||
|
||||
@@ -16,6 +16,14 @@ const CHAT_HEIGHT_KEY = "multica:chat:height";
|
||||
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
|
||||
/** Focus mode is a personal preference — global across workspaces/sessions. */
|
||||
const FOCUS_MODE_KEY = "multica:chat:focusMode";
|
||||
/**
|
||||
* Open/closed preference, persisted globally (not per-workspace) — most users
|
||||
* have one habitual chat-panel preference across workspaces. Missing key =
|
||||
* new user (or cleared storage); default to OPEN so the chat is discoverable.
|
||||
* Once the user toggles even once, their explicit choice is respected on
|
||||
* every subsequent reload.
|
||||
*/
|
||||
const OPEN_KEY = "multica:chat:isOpen";
|
||||
|
||||
function readDrafts(storage: StorageAdapter, key: string): Record<string, string> {
|
||||
const raw = storage.getItem(key);
|
||||
@@ -43,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;
|
||||
|
||||
/**
|
||||
@@ -118,8 +126,14 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
return slug ? `${base}:${slug}` : base;
|
||||
};
|
||||
|
||||
// Resolve initial isOpen from storage. The three-state read (null /
|
||||
// "true" / "false") is what enables the "new user → open" default while
|
||||
// still honouring an explicit "I closed it" choice on every reload.
|
||||
const storedOpen = storage.getItem(OPEN_KEY);
|
||||
const initialIsOpen = storedOpen === null ? true : storedOpen === "true";
|
||||
|
||||
const store = create<ChatState>((set, get) => ({
|
||||
isOpen: false,
|
||||
isOpen: initialIsOpen,
|
||||
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
showHistory: false,
|
||||
@@ -130,11 +144,13 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
|
||||
setOpen: (open) => {
|
||||
logger.debug("setOpen", { from: get().isOpen, to: open });
|
||||
storage.setItem(OPEN_KEY, String(open));
|
||||
set({ isOpen: open });
|
||||
},
|
||||
toggle: () => {
|
||||
const next = !get().isOpen;
|
||||
logger.debug("toggle", { to: next });
|
||||
storage.setItem(OPEN_KEY, String(next));
|
||||
set({ isOpen: next });
|
||||
},
|
||||
setActiveSession: (id) => {
|
||||
|
||||
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";
|
||||
|
||||
@@ -15,6 +15,8 @@ import { defaultStorage } from "../../platform/storage";
|
||||
interface QuickCreateState {
|
||||
lastAgentId: string | null;
|
||||
setLastAgentId: (id: string | null) => void;
|
||||
keepOpen: boolean;
|
||||
setKeepOpen: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export const useQuickCreateStore = create<QuickCreateState>()(
|
||||
@@ -22,6 +24,8 @@ export const useQuickCreateStore = create<QuickCreateState>()(
|
||||
(set) => ({
|
||||
lastAgentId: null,
|
||||
setLastAgentId: (id) => set({ lastAgentId: id }),
|
||||
keepOpen: false,
|
||||
setKeepOpen: (v) => set({ keepOpen: v }),
|
||||
}),
|
||||
{
|
||||
name: "multica_quick_create",
|
||||
|
||||
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,14 +19,16 @@ function makeWs(slug: string): Workspace {
|
||||
}
|
||||
|
||||
describe("resolvePostAuthDestination", () => {
|
||||
it("not onboarded → /onboarding regardless of workspaces", () => {
|
||||
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(),
|
||||
);
|
||||
expect(
|
||||
resolvePostAuthDestination([makeWs("acme"), makeWs("beta")], false),
|
||||
).toBe(paths.onboarding());
|
||||
});
|
||||
|
||||
it("onboarded + has workspace → /<first.slug>/issues", () => {
|
||||
|
||||
@@ -7,6 +7,18 @@ import { paths } from "./paths";
|
||||
* !hasOnboarded → /onboarding
|
||||
* hasOnboarded && has workspace → /<first.slug>/issues
|
||||
* hasOnboarded && zero workspaces → /workspaces/new
|
||||
*
|
||||
* `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[],
|
||||
@@ -16,7 +28,10 @@ export function resolvePostAuthDestination(
|
||||
return paths.onboarding();
|
||||
}
|
||||
const first = workspaces[0];
|
||||
return first ? paths.workspace(first.slug).issues() : paths.newWorkspace();
|
||||
if (first) {
|
||||
return paths.workspace(first.slug).issues();
|
||||
}
|
||||
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),
|
||||
};
|
||||
}
|
||||
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),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -51,9 +51,13 @@ import type {
|
||||
SubscriberAddedPayload,
|
||||
SubscriberRemovedPayload,
|
||||
TaskMessagePayload,
|
||||
TaskQueuedPayload,
|
||||
TaskDispatchPayload,
|
||||
TaskCompletedPayload,
|
||||
TaskFailedPayload,
|
||||
TaskCancelledPayload,
|
||||
ChatDonePayload,
|
||||
ChatPendingTask,
|
||||
InvitationCreatedPayload,
|
||||
} from "../types";
|
||||
|
||||
@@ -525,6 +529,64 @@ export function useRealtimeSync(
|
||||
invalidateSessionLists();
|
||||
});
|
||||
|
||||
// Chat task lifecycle writethrough: keep `chatKeys.pendingTask(sessionId)`
|
||||
// synchronized with the server state machine via setQueryData rather than
|
||||
// invalidate-refetch. Same pattern as task:message — the WS payload
|
||||
// carries everything we need, and an HTTP roundtrip just to read what we
|
||||
// already know would add latency to every stage transition.
|
||||
//
|
||||
// task:queued is emitted by EnqueueChatTask. The optimistic seed in
|
||||
// chat-window.tsx may have already populated the cache with a temporary
|
||||
// id; this handler upgrades it to the real task_id (and reaffirms status
|
||||
// when reconnect replays the event for an already-running task).
|
||||
const unsubTaskQueued = ws.on("task:queued", (p) => {
|
||||
const payload = p as TaskQueuedPayload;
|
||||
if (!payload.chat_session_id) return;
|
||||
qc.setQueryData<ChatPendingTask>(
|
||||
chatKeys.pendingTask(payload.chat_session_id),
|
||||
(old) => ({
|
||||
...(old ?? {}),
|
||||
task_id: payload.task_id,
|
||||
status: "queued",
|
||||
}),
|
||||
);
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
// task:dispatch fires when the daemon claims the queued task. The daemon
|
||||
// immediately follows with StartTask, so dispatched→running is sub-second.
|
||||
// We collapse that window by writing "running" directly — the pill jumps
|
||||
// from "Queued" straight to "Thinking", skipping a meaningless "Starting"
|
||||
// frame. Stage decision in TaskStatusPill maps "running" + empty
|
||||
// taskMessages → "Thinking · Ns".
|
||||
const unsubTaskDispatch = ws.on("task:dispatch", (p) => {
|
||||
const payload = p as TaskDispatchPayload;
|
||||
if (!payload.chat_session_id) return;
|
||||
qc.setQueryData<ChatPendingTask>(
|
||||
chatKeys.pendingTask(payload.chat_session_id),
|
||||
(old) => {
|
||||
if (!old || old.task_id !== payload.task_id) return old;
|
||||
return { ...old, status: "running" };
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// task:cancelled reaches us when:
|
||||
// 1. handleStop already cleared the cache locally (this is a no-op confirm)
|
||||
// 2. another tab / admin / system cancels — this is the only path that
|
||||
// drops the pending pill in those cases. Without it the pill spins
|
||||
// forever in the second-tab scenario.
|
||||
const unsubTaskCancelled = ws.on("task:cancelled", (p) => {
|
||||
const payload = p as TaskCancelledPayload;
|
||||
if (!payload.chat_session_id) return;
|
||||
chatWsLogger.info("task:cancelled (global, chat)", {
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
const unsubTaskCompleted = ws.on("task:completed", (p) => {
|
||||
const payload = p as TaskCompletedPayload;
|
||||
if (!payload.chat_session_id) return; // issue tasks handled elsewhere
|
||||
@@ -545,8 +607,14 @@ export function useRealtimeSync(
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
// No new message; just flip the pending signal.
|
||||
// FailTask writes a failure chat_message (mirroring CompleteTask's
|
||||
// success message), so this path mirrors the task:completed handler:
|
||||
// clear the pending signal AND invalidate the messages list so the
|
||||
// failure bubble shows up without requiring a page refresh. Pre-#1823
|
||||
// this branch only flipped pending — the comment "No new message"
|
||||
// was true then, but FailTask now persists a row.
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
@@ -584,6 +652,9 @@ export function useRealtimeSync(
|
||||
unsubTaskMessage();
|
||||
unsubChatMessage();
|
||||
unsubChatDone();
|
||||
unsubTaskQueued();
|
||||
unsubTaskDispatch();
|
||||
unsubTaskCancelled();
|
||||
unsubTaskCompleted();
|
||||
unsubTaskFailed();
|
||||
unsubChatSessionRead();
|
||||
|
||||
61
packages/core/runtimes/cli-version.ts
Normal file
61
packages/core/runtimes/cli-version.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Frontend mirror of the server's MinQuickCreateCLIVersion gate. The
|
||||
* agent-create flow (Quick Create modal) requires the daemon's bundled
|
||||
* multica CLI to be at least this version — older daemons either
|
||||
* double-create issues on partial CLI failures or mishandle pasted
|
||||
* screenshot URLs (see PR #1851 / MUL-1496).
|
||||
*
|
||||
* Both the frontend pre-validation in the modal and the server's
|
||||
* `/api/issues/quick-create` handler enforce this; the server is the
|
||||
* authoritative trust boundary, the frontend just lets us tell the user
|
||||
* "your daemon needs an upgrade" before they hit submit.
|
||||
*/
|
||||
export const MIN_QUICK_CREATE_CLI_VERSION = "0.2.20";
|
||||
|
||||
export type CliVersionState = "ok" | "too_old" | "missing";
|
||||
|
||||
export interface CliVersionCheck {
|
||||
state: CliVersionState;
|
||||
/** What the daemon reported, or empty if missing/unparsable. */
|
||||
current: string;
|
||||
/** The hard minimum we gate on. */
|
||||
min: string;
|
||||
}
|
||||
|
||||
const SEMVER_RE = /v?(\d+)\.(\d+)\.(\d+)/;
|
||||
|
||||
function parseSemver(raw: string): [number, number, number] | null {
|
||||
const m = SEMVER_RE.exec(raw.trim());
|
||||
if (!m) return null;
|
||||
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
}
|
||||
|
||||
function lessThan(a: [number, number, number], b: [number, number, number]) {
|
||||
if (a[0] !== b[0]) return a[0] < b[0];
|
||||
if (a[1] !== b[1]) return a[1] < b[1];
|
||||
return a[2] < b[2];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function checkQuickCreateCliVersion(detected: string | undefined | null): CliVersionCheck {
|
||||
const current = (detected ?? "").trim();
|
||||
const parsed = current ? parseSemver(current) : null;
|
||||
if (!parsed) {
|
||||
return { state: "missing", current, min: MIN_QUICK_CREATE_CLI_VERSION };
|
||||
}
|
||||
const min = parseSemver(MIN_QUICK_CREATE_CLI_VERSION)!;
|
||||
if (lessThan(parsed, min)) {
|
||||
return { state: "too_old", current, min: MIN_QUICK_CREATE_CLI_VERSION };
|
||||
}
|
||||
return { state: "ok", current, min: MIN_QUICK_CREATE_CLI_VERSION };
|
||||
}
|
||||
|
||||
/** Pull `cli_version` off a runtime row's loosely-typed metadata bag. */
|
||||
export function readRuntimeCliVersion(metadata: Record<string, unknown> | undefined): string {
|
||||
const v = metadata?.cli_version;
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export * from "./local-skills";
|
||||
export * from "./types";
|
||||
export * from "./derive-health";
|
||||
export * from "./use-runtime-health";
|
||||
export * from "./cli-version";
|
||||
|
||||
@@ -28,18 +28,48 @@ export interface ChatMessage {
|
||||
content: string;
|
||||
task_id: string | null;
|
||||
created_at: string;
|
||||
/**
|
||||
* When set, this is an assistant message synthesized by the server's
|
||||
* FailTask fallback (mirrors the issue path's failure system comment).
|
||||
* `content` carries the raw daemon-reported errMsg; the front-end maps
|
||||
* `failure_reason` (an enum like "agent_error" / "connection_error" /
|
||||
* "timeout") to a user-facing label and renders a destructive bubble.
|
||||
* Null on success messages and on user messages.
|
||||
*/
|
||||
failure_reason?: string | null;
|
||||
/**
|
||||
* Wall-clock duration from `task.created_at` (user hit send) to terminal
|
||||
* state (completed/failed). Set by the server on assistant messages
|
||||
* synthesized by CompleteTask/FailTask. UI renders it as "Replied in
|
||||
* 38s" / "Failed after 12s" beneath the bubble. Null on user messages
|
||||
* and on legacy assistant messages predating migration 063.
|
||||
*/
|
||||
elapsed_ms?: number | null;
|
||||
}
|
||||
|
||||
export interface SendChatMessageResponse {
|
||||
message_id: string;
|
||||
task_id: string;
|
||||
/**
|
||||
* Server-authoritative task creation time. Optimistic StatusPill seed
|
||||
* uses this as its anchor so the timer starts from the real `0s` —
|
||||
* without it the front-end falls back to its local clock and the
|
||||
* timer "snaps backwards" later when WS events update the cache.
|
||||
*/
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from GET /api/chat/sessions/{id}/pending-task.
|
||||
* Both fields are absent when the session has no in-flight task.
|
||||
* All fields are absent when the session has no in-flight task.
|
||||
*
|
||||
* `created_at` is the server-authoritative anchor for the chat StatusPill's
|
||||
* elapsed-seconds timer — the optimistic seed in chat-window.tsx fills in
|
||||
* task_id/status only, then this query catches up with the real created_at
|
||||
* so the timer survives refresh / reopen without "resetting to 0s".
|
||||
*/
|
||||
export interface ChatPendingTask {
|
||||
task_id?: string;
|
||||
status?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
@@ -196,6 +196,22 @@ export interface TaskMessagePayload {
|
||||
output?: string;
|
||||
}
|
||||
|
||||
export interface TaskQueuedPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface TaskDispatchPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
runtime_id: string;
|
||||
chat_session_id?: string;
|
||||
}
|
||||
|
||||
export interface TaskCompletedPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ function FileUploadButton({
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
aria-label="Attach file"
|
||||
title="Attach file"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none",
|
||||
btnSize,
|
||||
|
||||
47
packages/ui/components/common/unicode-spinner.tsx
Normal file
47
packages/ui/components/common/unicode-spinner.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import spinners, { type BrailleSpinnerName } from "unicode-animations";
|
||||
|
||||
interface Props {
|
||||
name?: BrailleSpinnerName;
|
||||
className?: string;
|
||||
/** Stop advancing frames without unmounting (e.g., when an outer state freezes). */
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
// Inline-rendered braille spinner. Each frame is a unicode string from the
|
||||
// `unicode-animations` package; we tick frames on the spinner's own `interval`
|
||||
// and render the current one inside a fixed-width monospace span so different
|
||||
// frames never reflow neighbouring text. Width-jitter is the main reason this
|
||||
// component exists rather than dropping the raw strings into Tailwind classes.
|
||||
export function UnicodeSpinner({ name = "braille", className, paused }: Props) {
|
||||
const spec = spinners[name];
|
||||
const [frame, setFrame] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
setFrame(0);
|
||||
const timer = setInterval(
|
||||
() => setFrame((f) => (f + 1) % spec.frames.length),
|
||||
spec.interval,
|
||||
);
|
||||
return () => clearInterval(timer);
|
||||
}, [name, paused, spec]);
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
style={{
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
||||
display: "inline-block",
|
||||
minWidth: "1ch",
|
||||
textAlign: "center",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{spec.frames[frame]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import {
|
||||
flexRender,
|
||||
type Header as TanstackHeader,
|
||||
type Row,
|
||||
type Table as TanstackTable,
|
||||
} from "@tanstack/react-table";
|
||||
import type * as React from "react";
|
||||
import * as React from "react";
|
||||
|
||||
// We deliberately use the lower-level shadcn primitives (TableHeader /
|
||||
// TableBody / TableRow / TableHead / TableCell) but NOT the wrapping
|
||||
@@ -48,8 +49,8 @@ interface DataTableProps<TData> extends React.ComponentProps<"div"> {
|
||||
// makes each column's width come from its first row's <th>
|
||||
// inline width. column.size is authoritative for sized columns.
|
||||
// - Columns flagged `meta.grow: true` skip their inline width, so
|
||||
// fixed table-layout assigns them the leftover space (no spacer
|
||||
// column needed).
|
||||
// fixed table-layout assigns them the leftover space until the user
|
||||
// resizes them. Once resized, the explicit width is applied.
|
||||
// - The table's `min-width` is the sum of every column's TanStack
|
||||
// size (`table.getTotalSize()`). That gives grow columns a real
|
||||
// floor — fixed mode ignores cell-level min-width, but it does
|
||||
@@ -64,6 +65,98 @@ export function DataTable<TData>({
|
||||
className,
|
||||
...props
|
||||
}: DataTableProps<TData>) {
|
||||
const [resizingColumnId, setResizingColumnId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const columnSizing = table.getState().columnSizing;
|
||||
const hasExplicitSize = React.useCallback(
|
||||
(columnId: string) =>
|
||||
Object.prototype.hasOwnProperty.call(columnSizing, columnId),
|
||||
[columnSizing],
|
||||
);
|
||||
|
||||
const setColumnWidth = React.useCallback(
|
||||
(header: TanstackHeader<TData, unknown>, width: number) => {
|
||||
const minSize = header.column.columnDef.minSize ?? 48;
|
||||
const maxSize =
|
||||
header.column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER;
|
||||
const next = Math.min(maxSize, Math.max(minSize, Math.round(width)));
|
||||
|
||||
table.setColumnSizing((old) => ({
|
||||
...old,
|
||||
[header.column.id]: next,
|
||||
}));
|
||||
},
|
||||
[table],
|
||||
);
|
||||
|
||||
const beginColumnResize = React.useCallback(
|
||||
(
|
||||
header: TanstackHeader<TData, unknown>,
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (!header.column.getCanResize()) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const startX = event.clientX;
|
||||
const headerCell = event.currentTarget.closest("th");
|
||||
const startWidth =
|
||||
headerCell?.getBoundingClientRect().width ?? header.column.getSize();
|
||||
|
||||
setResizingColumnId(header.column.id);
|
||||
setColumnWidth(header, startWidth);
|
||||
|
||||
const originalCursor = document.body.style.cursor;
|
||||
const originalUserSelect = document.body.style.userSelect;
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
const handlePointerMove = (pointerEvent: PointerEvent) => {
|
||||
setColumnWidth(header, startWidth + pointerEvent.clientX - startX);
|
||||
};
|
||||
|
||||
const stopResize = () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", stopResize);
|
||||
window.removeEventListener("pointercancel", stopResize);
|
||||
document.body.style.cursor = originalCursor;
|
||||
document.body.style.userSelect = originalUserSelect;
|
||||
setResizingColumnId(null);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", stopResize);
|
||||
window.addEventListener("pointercancel", stopResize);
|
||||
},
|
||||
[setColumnWidth],
|
||||
);
|
||||
|
||||
const handleResizeKeyDown = React.useCallback(
|
||||
(
|
||||
header: TanstackHeader<TData, unknown>,
|
||||
event: React.KeyboardEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const headerCell = event.currentTarget.closest("th");
|
||||
const currentWidth = hasExplicitSize(header.column.id)
|
||||
? header.column.getSize()
|
||||
: (headerCell?.getBoundingClientRect().width ??
|
||||
header.column.getSize());
|
||||
const direction = event.key === "ArrowRight" ? 1 : -1;
|
||||
const step = event.shiftKey ? 20 : 8;
|
||||
|
||||
setColumnWidth(header, currentWidth + direction * step);
|
||||
},
|
||||
[hasExplicitSize, setColumnWidth],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex min-h-0 flex-1 flex-col", className)}
|
||||
@@ -79,6 +172,13 @@ export function DataTable<TData>({
|
||||
<TableRow key={headerGroup.id} className="hover:bg-transparent">
|
||||
{headerGroup.headers.map((header) => {
|
||||
const isPinned = header.column.getIsPinned();
|
||||
const columnHasExplicitSize = hasExplicitSize(
|
||||
header.column.id,
|
||||
);
|
||||
const headerLabel =
|
||||
typeof header.column.columnDef.header === "string"
|
||||
? header.column.columnDef.header
|
||||
: header.column.id;
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
@@ -98,10 +198,13 @@ export function DataTable<TData>({
|
||||
// into the header strip rather than appearing as
|
||||
// a white block under sticky scroll.
|
||||
className={cn(
|
||||
"h-8 overflow-hidden px-4 py-2 text-xs uppercase tracking-wider text-muted-foreground",
|
||||
"relative h-8 overflow-hidden px-4 py-2 text-xs uppercase tracking-wider text-muted-foreground",
|
||||
isPinned && "bg-muted/30 backdrop-blur",
|
||||
)}
|
||||
style={getCellStyle(header.column, { withBorder: true })}
|
||||
style={getCellStyle(header.column, {
|
||||
withBorder: true,
|
||||
hasExplicitSize: columnHasExplicitSize,
|
||||
})}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
@@ -109,6 +212,33 @@ export function DataTable<TData>({
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
{!header.isPlaceholder &&
|
||||
header.column.getCanResize() && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-label={`Resize ${headerLabel} column`}
|
||||
aria-orientation="vertical"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"absolute top-0 right-0 h-full w-2 cursor-col-resize touch-none select-none outline-none",
|
||||
"after:absolute after:top-1/2 after:right-0 after:h-4 after:w-px after:-translate-y-1/2 after:bg-border after:opacity-0 after:transition-opacity",
|
||||
"hover:after:opacity-100 focus-visible:after:opacity-100",
|
||||
resizingColumnId === header.column.id &&
|
||||
"after:bg-primary after:opacity-100",
|
||||
)}
|
||||
onPointerDown={(event) =>
|
||||
beginColumnResize(header, event)
|
||||
}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
header.column.resetSize();
|
||||
}}
|
||||
onKeyDown={(event) =>
|
||||
handleResizeKeyDown(header, event)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
@@ -135,6 +265,9 @@ export function DataTable<TData>({
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const isPinned = cell.column.getIsPinned();
|
||||
const columnHasExplicitSize = hasExplicitSize(
|
||||
cell.column.id,
|
||||
);
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
@@ -151,7 +284,10 @@ export function DataTable<TData>({
|
||||
isPinned &&
|
||||
"bg-background group-hover:bg-muted/50",
|
||||
)}
|
||||
style={getCellStyle(cell.column, { withBorder: true })}
|
||||
style={getCellStyle(cell.column, {
|
||||
withBorder: true,
|
||||
hasExplicitSize: columnHasExplicitSize,
|
||||
})}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
|
||||
@@ -4,10 +4,9 @@ import type * as React from "react";
|
||||
// Extend TanStack Table's ColumnMeta with a `grow` flag. TanStack merges
|
||||
// a default `size: 150` into every columnDef, so "no explicit size" can't
|
||||
// be detected by inspecting columnDef.size (it's always a number). Setting
|
||||
// `meta: { grow: true }` is the official extension point — DataTable then
|
||||
// skips the inline width for these columns and lets fixed table-layout
|
||||
// assign them the leftover space (Linear / GitHub-PR-list pattern: title
|
||||
// column grows, others stay at their declared widths).
|
||||
// `meta: { grow: true }` is the official extension point: DataTable skips
|
||||
// the inline width for these columns until the user explicitly resizes them,
|
||||
// then the resized width wins.
|
||||
declare module "@tanstack/react-table" {
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
grow?: boolean;
|
||||
@@ -25,10 +24,10 @@ declare module "@tanstack/react-table" {
|
||||
// `group-hover:`.
|
||||
export function getCellStyle<TData>(
|
||||
column: Column<TData>,
|
||||
options?: { withBorder?: boolean },
|
||||
options?: { withBorder?: boolean; hasExplicitSize?: boolean },
|
||||
): React.CSSProperties {
|
||||
const grow = column.columnDef.meta?.grow;
|
||||
const width = grow ? undefined : column.columnDef.size;
|
||||
const width = grow && !options?.hasExplicitSize ? undefined : column.getSize();
|
||||
|
||||
const isPinned = column.getIsPinned();
|
||||
if (!isPinned) {
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "catalog:",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"unicode-animations": "catalog:",
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -83,6 +83,37 @@
|
||||
animation: chat-impulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ChatGPT-style "thinking" shimmer for inline text — a soft light sweep
|
||||
* runs across the glyphs, signalling "the agent is doing something" without
|
||||
* a separate spinner. Pure CSS: linear-gradient clipped to the text shape,
|
||||
* the gradient slid across via background-position. Uses the same muted →
|
||||
* foreground tokens chat copy normally uses, so the effect adapts to light
|
||||
* and dark mode without per-mode overrides.
|
||||
*
|
||||
* Apply to a <span> wrapping the label only — not the whole pill, since
|
||||
* the timer counter and Cancel button shouldn't shimmer. */
|
||||
@keyframes chat-text-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.animate-chat-text-shimmer {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--muted-foreground) 0%,
|
||||
var(--muted-foreground) 35%,
|
||||
var(--foreground) 50%,
|
||||
var(--muted-foreground) 65%,
|
||||
var(--muted-foreground) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: chat-text-shimmer 2.5s linear infinite;
|
||||
}
|
||||
|
||||
/* Sidebar: open triggers (dropdown/popover) get active background */
|
||||
[data-sidebar="menu-button"][data-popup-open] {
|
||||
background-color: var(--sidebar-accent);
|
||||
@@ -118,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;
|
||||
}
|
||||
@@ -38,18 +41,17 @@ export interface AgentRow {
|
||||
// column.size doubles as the cell's effective max-width: truncatable
|
||||
// cells with `truncate` inside hit ellipsis at the column edge.
|
||||
//
|
||||
// The Agent column has `meta.grow: true` so DataTable skips its inline
|
||||
// `width` — that lets fixed table-layout assign it the leftover space
|
||||
// (= container width − sum of other columns), so the table fills the
|
||||
// viewport without an empty spacer column.
|
||||
// The Agent and Runtime columns have `meta.grow: true` so DataTable skips
|
||||
// their inline widths until the user resizes them. Fixed table-layout splits
|
||||
// the leftover space between them, which keeps Agent from monopolising wide
|
||||
// viewports while still giving both columns a real floor.
|
||||
//
|
||||
// The Agent column also keeps `size: 240` even though it isn't used for
|
||||
// rendering. TanStack folds this into `table.getTotalSize()`, which
|
||||
// DataTable applies as the table's `min-width`. That's how the agent
|
||||
// column gets a real 240px floor: when the viewport drops below
|
||||
// `sum + 240`, the table refuses to shrink further and the container
|
||||
// scrolls instead. (Fixed table-layout ignores cell-level min-width
|
||||
// per spec, so the floor has to live on the table itself.)
|
||||
// The grow columns also keep their `size` values even though those widths
|
||||
// are skipped for initial rendering. TanStack folds them into
|
||||
// `table.getTotalSize()`, which DataTable applies as the table's `min-width`.
|
||||
// That's how the grow columns get real floors: when the viewport drops below
|
||||
// the summed column sizes, the table refuses to shrink further and the
|
||||
// container scrolls instead.
|
||||
const COL_WIDTHS = {
|
||||
agent: 240,
|
||||
status: 120,
|
||||
@@ -102,6 +104,7 @@ export function createAgentColumns({
|
||||
id: "runtime",
|
||||
header: "Runtime",
|
||||
size: COL_WIDTHS.runtime,
|
||||
meta: { grow: true },
|
||||
cell: ({ row }) => <RuntimeCell row={row.original} />,
|
||||
},
|
||||
{
|
||||
@@ -126,6 +129,7 @@ export function createAgentColumns({
|
||||
id: "actions",
|
||||
header: () => null,
|
||||
size: COL_WIDTHS.actions,
|
||||
enableResizing: false,
|
||||
cell: ({ row }) => (
|
||||
<div
|
||||
className="flex justify-end"
|
||||
@@ -150,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";
|
||||
|
||||
@@ -180,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,6 +86,7 @@ export function AgentDetailInspector({
|
||||
runtimes,
|
||||
members,
|
||||
currentUserId,
|
||||
canEdit,
|
||||
onUpdate,
|
||||
}: InspectorProps) {
|
||||
const update = (data: Record<string, unknown>) => onUpdate(agent.id, data);
|
||||
@@ -86,16 +96,18 @@ export function AgentDetailInspector({
|
||||
<aside className="flex h-full min-h-0 w-full flex-col overflow-y-auto rounded-lg border bg-background">
|
||||
{/* 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,23 +171,36 @@ 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>
|
||||
)}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -334,6 +359,7 @@ export function AgentsPage() {
|
||||
data: agentRows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
enableColumnResizing: true,
|
||||
// Pin the kebab column right so it stays accessible during horizontal
|
||||
// scroll — matches the pattern in Linear / Notion / GitHub.
|
||||
initialState: { columnPinning: { right: ["actions"] } },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,14 +11,25 @@ const MAX = 50;
|
||||
|
||||
export function ConcurrencyPicker({
|
||||
value,
|
||||
canEdit = true,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
/** When false, render a static read-only display and skip the popover. */
|
||||
canEdit?: boolean;
|
||||
onChange: (next: number) => Promise<void> | void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState(String(value));
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Reset draft from authoritative value whenever the popover (re-)opens or
|
||||
// the prop changes from elsewhere — protects against stale draft state if
|
||||
// the user closes mid-edit and reopens later.
|
||||
|
||||
@@ -26,11 +26,14 @@ export function ModelPicker({
|
||||
runtimeId,
|
||||
runtimeOnline,
|
||||
value,
|
||||
canEdit = true,
|
||||
onChange,
|
||||
}: {
|
||||
runtimeId: string | null;
|
||||
runtimeOnline: boolean;
|
||||
value: string;
|
||||
/** When false, render a static read-only display and skip the popover. */
|
||||
canEdit?: boolean;
|
||||
onChange: (next: string) => Promise<void> | void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -41,13 +44,12 @@ export function ModelPicker({
|
||||
);
|
||||
const supported = modelsQuery.data?.supported ?? true;
|
||||
// Memoise the model list so every downstream useMemo gets a stable
|
||||
// reference — `?? []` would mint a fresh array on every render and
|
||||
// invalidate filters / defaultModel needlessly.
|
||||
// reference; `?? []` would mint a fresh array on every render and
|
||||
// invalidate filters needlessly.
|
||||
const models = useMemo(
|
||||
() => modelsQuery.data?.models ?? [],
|
||||
[modelsQuery.data],
|
||||
);
|
||||
const defaultModel = useMemo(() => models.find((m) => m.default), [models]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const s = search.trim().toLowerCase();
|
||||
@@ -78,11 +80,20 @@ export function ModelPicker({
|
||||
);
|
||||
}
|
||||
|
||||
const triggerLabel =
|
||||
value ||
|
||||
(defaultModel ? `Default — ${defaultModel.label}` : "Default");
|
||||
const triggerLabel = value || "Default";
|
||||
const triggerTitle = `Model · ${triggerLabel}`;
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<span
|
||||
className="min-w-0 truncate px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
|
||||
title={triggerTitle}
|
||||
>
|
||||
{triggerLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PropertyPicker
|
||||
open={open}
|
||||
|
||||
@@ -24,12 +24,15 @@ export function RuntimePicker({
|
||||
runtimes,
|
||||
members,
|
||||
currentUserId,
|
||||
canEdit = true,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
runtimes: AgentRuntime[];
|
||||
members: MemberWithUser[];
|
||||
currentUserId: string | null;
|
||||
/** When false, render a static read-only display and skip the popover. */
|
||||
canEdit?: boolean;
|
||||
onChange: (runtimeId: string) => Promise<void> | void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -37,6 +40,25 @@ export function RuntimePicker({
|
||||
|
||||
const selected = runtimes.find((r) => r.id === value) ?? null;
|
||||
const Icon = selected?.runtime_mode === "cloud" ? Cloud : Monitor;
|
||||
|
||||
if (!canEdit) {
|
||||
const isOnline = selected?.status === "online";
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
<span className="min-w-0 truncate font-mono">
|
||||
{selected?.name ?? "No runtime"}
|
||||
</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={`ml-auto h-1.5 w-1.5 shrink-0 rounded-full ${
|
||||
isOnline ? "bg-success" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// The chip shows only the runtime name. `runtime.name` already comes back
|
||||
// from the back-end pre-formatted as e.g. "Claude (host.local)", so we
|
||||
// deliberately do NOT append `device_info` to the tooltip — that string
|
||||
|
||||
@@ -17,7 +17,14 @@ import { SkillAddDialog } from "../skill-add-dialog";
|
||||
* Hidden when there's nothing left to attach so we don't dangle a chip
|
||||
* that opens an empty dialog.
|
||||
*/
|
||||
export function SkillAttach({ agent }: { agent: Agent }) {
|
||||
export function SkillAttach({
|
||||
agent,
|
||||
canEdit = true,
|
||||
}: {
|
||||
agent: Agent;
|
||||
/** When false, hide the attach trigger entirely. */
|
||||
canEdit?: boolean;
|
||||
}) {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -27,7 +34,7 @@ export function SkillAttach({ agent }: { agent: Agent }) {
|
||||
(s) => !agentSkillIds.has(s.id),
|
||||
).length;
|
||||
|
||||
if (availableCount === 0) return null;
|
||||
if (!canEdit || availableCount === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -2,27 +2,38 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Globe, Lock } from "lucide-react";
|
||||
import {
|
||||
VISIBILITY_DESCRIPTION,
|
||||
VISIBILITY_LABEL,
|
||||
VISIBILITY_TOOLTIP,
|
||||
} from "@multica/core/agents";
|
||||
import type { AgentVisibility } from "@multica/core/types";
|
||||
import {
|
||||
PickerItem,
|
||||
PropertyPicker,
|
||||
} from "../../../issues/components/pickers";
|
||||
import { VisibilityBadge } from "../visibility-badge";
|
||||
import { CHIP_CLASS } from "./chip";
|
||||
|
||||
export function VisibilityPicker({
|
||||
value,
|
||||
canEdit = true,
|
||||
onChange,
|
||||
}: {
|
||||
value: AgentVisibility;
|
||||
/** When false, render a read-only `<VisibilityBadge>` and skip the popover. */
|
||||
canEdit?: boolean;
|
||||
onChange: (next: AgentVisibility) => Promise<void> | void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!canEdit) {
|
||||
return <VisibilityBadge value={value} />;
|
||||
}
|
||||
|
||||
const Icon = value === "private" ? Lock : Globe;
|
||||
const label = value === "private" ? "Private" : "Workspace";
|
||||
const tooltip =
|
||||
value === "private"
|
||||
? "Visibility · Private — only you can assign"
|
||||
: "Visibility · Workspace — all members can assign";
|
||||
const label = VISIBILITY_LABEL[value];
|
||||
const tooltip = `Visibility · ${VISIBILITY_TOOLTIP[value]}`;
|
||||
|
||||
const select = async (next: AgentVisibility) => {
|
||||
setOpen(false);
|
||||
@@ -52,9 +63,9 @@ export function VisibilityPicker({
|
||||
>
|
||||
<Globe className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Workspace</div>
|
||||
<div className="font-medium">{VISIBILITY_LABEL.workspace}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
All members can assign
|
||||
{VISIBILITY_DESCRIPTION.workspace}
|
||||
</div>
|
||||
</div>
|
||||
</PickerItem>
|
||||
@@ -64,9 +75,9 @@ export function VisibilityPicker({
|
||||
>
|
||||
<Lock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Private</div>
|
||||
<div className="font-medium">{VISIBILITY_LABEL.private}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Only you can assign
|
||||
{VISIBILITY_DESCRIPTION.private}
|
||||
</div>
|
||||
</div>
|
||||
</PickerItem>
|
||||
|
||||
@@ -42,7 +42,6 @@ export function ModelDropdown({
|
||||
|
||||
const supported = modelsQuery.data?.supported ?? true;
|
||||
const models = modelsQuery.data?.models ?? [];
|
||||
const defaultModel = useMemo(() => models.find((m) => m.default), [models]);
|
||||
const grouped = useMemo(() => groupByProvider(models), [models]);
|
||||
|
||||
// When the selected runtime reports it doesn't support per-agent
|
||||
@@ -86,9 +85,7 @@ export function ModelDropdown({
|
||||
(disabled
|
||||
? "Select a runtime first"
|
||||
: runtimeOnline
|
||||
? defaultModel
|
||||
? `Default — ${defaultModel.label}`
|
||||
: "Default (provider)"
|
||||
? "Default (provider)"
|
||||
: "Runtime offline — enter manually");
|
||||
|
||||
if (!supported && !modelsQuery.isLoading) {
|
||||
|
||||
49
packages/views/agents/components/visibility-badge.tsx
Normal file
49
packages/views/agents/components/visibility-badge.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Globe, Lock } from "lucide-react";
|
||||
import {
|
||||
VISIBILITY_LABEL,
|
||||
VISIBILITY_TOOLTIP,
|
||||
} from "@multica/core/agents";
|
||||
import type { AgentVisibility } from "@multica/core/types";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
|
||||
/**
|
||||
* Read-only visibility badge — used wherever a user should *see* an agent's
|
||||
* visibility (Personal / Workspace) without being able to change it. Replaces
|
||||
* the interactive `<VisibilityPicker>` for non-managers on the detail page,
|
||||
* and is also the canonical badge for hover cards and list rows.
|
||||
*
|
||||
* `compact` drops the text label and shows just the icon — for tight spaces
|
||||
* like the agent table where the column header already labels the field.
|
||||
*/
|
||||
export function VisibilityBadge({
|
||||
value,
|
||||
compact = false,
|
||||
className = "",
|
||||
}: {
|
||||
value: AgentVisibility;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = value === "private" ? Lock : Globe;
|
||||
const label = VISIBILITY_LABEL[value];
|
||||
const tooltip = VISIBILITY_TOOLTIP[value];
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-xs text-muted-foreground ${className}`}
|
||||
aria-label={tooltip}
|
||||
>
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
{!compact && <span className="truncate">{label}</span>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -44,7 +44,9 @@ import {
|
||||
} from "./trigger-config";
|
||||
import type { TriggerConfig } from "./trigger-config";
|
||||
import type { AutopilotExecutionMode, AutopilotRun, AutopilotTrigger } from "@multica/core/types";
|
||||
import type { AgentTask } from "@multica/core/types/agent";
|
||||
import { ReadonlyContent } from "../../editor";
|
||||
import { TranscriptButton } from "../../common/task-transcript";
|
||||
import { AutopilotDialog } from "./autopilot-dialog";
|
||||
|
||||
function formatDate(date: string): string {
|
||||
@@ -63,11 +65,34 @@ const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: ty
|
||||
failed: { label: "Failed", color: "text-destructive", icon: XCircle },
|
||||
};
|
||||
|
||||
function RunRow({ run }: { run: AutopilotRun }) {
|
||||
function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: string; agentName: string }) {
|
||||
const wsPaths = useWorkspacePaths();
|
||||
const cfg = (RUN_STATUS_CONFIG[run.status] ?? RUN_STATUS_CONFIG["issue_created"])!;
|
||||
const StatusIcon = cfg.icon;
|
||||
|
||||
// For runs with a task_id (run_only mode), build a minimal AgentTask so
|
||||
// TranscriptButton can lazy-load the execution transcript.
|
||||
const syntheticTask: AgentTask | null = run.task_id
|
||||
? {
|
||||
id: run.task_id,
|
||||
agent_id: agentId,
|
||||
runtime_id: "",
|
||||
issue_id: "",
|
||||
status:
|
||||
run.status === "running" ? "running" :
|
||||
run.status === "completed" ? "completed" :
|
||||
run.status === "failed" ? "failed" :
|
||||
"queued",
|
||||
priority: 0,
|
||||
dispatched_at: null,
|
||||
started_at: run.triggered_at || null,
|
||||
completed_at: run.completed_at || null,
|
||||
result: null,
|
||||
error: run.failure_reason || null,
|
||||
created_at: run.created_at,
|
||||
}
|
||||
: null;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color, cfg.spin && "animate-spin")} />
|
||||
@@ -83,6 +108,14 @@ function RunRow({ run }: { run: AutopilotRun }) {
|
||||
<span className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
|
||||
{formatDate(run.triggered_at || run.created_at)}
|
||||
</span>
|
||||
{syntheticTask && !run.issue_id && (
|
||||
<TranscriptButton
|
||||
task={syntheticTask}
|
||||
agentName={agentName}
|
||||
isLive={run.status === "running"}
|
||||
title="View execution log"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -438,7 +471,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
) : (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
{runs.map((run) => (
|
||||
<RunRow key={run.id} run={run} />
|
||||
<RunRow key={run.id} run={run} agentId={autopilot.assignee_id} agentName={getActorName("agent", autopilot.assignee_id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -130,38 +130,40 @@ function AutopilotRow({ autopilot }: { autopilot: Autopilot }) {
|
||||
const StatusIcon = statusCfg.icon;
|
||||
|
||||
return (
|
||||
<div className="group/row flex h-11 items-center gap-2 px-5 text-sm transition-colors hover:bg-accent/40">
|
||||
<div className="group/row flex flex-col gap-2 border-b px-4 py-3 text-sm transition-colors hover:bg-accent/40 sm:h-11 sm:flex-row sm:items-center sm:gap-2 sm:border-b-0 sm:px-5 sm:py-0">
|
||||
<AppLink
|
||||
href={wsPaths.autopilotDetail(autopilot.id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2"
|
||||
className="flex min-w-0 items-center gap-2 sm:flex-1"
|
||||
>
|
||||
<Zap className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{autopilot.title}</span>
|
||||
</AppLink>
|
||||
|
||||
{/* Agent */}
|
||||
<span className="flex w-32 items-center gap-1.5 shrink-0">
|
||||
<ActorAvatar actorType="agent" actorId={autopilot.assignee_id} size={18} enableHoverCard showStatusDot />
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{getActorName("agent", autopilot.assignee_id)}
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 pl-6 text-xs sm:contents sm:pl-0">
|
||||
{/* Agent */}
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-muted-foreground sm:w-32 sm:shrink-0">
|
||||
<ActorAvatar actorType="agent" actorId={autopilot.assignee_id} size={18} enableHoverCard showStatusDot />
|
||||
<span className="truncate">
|
||||
{getActorName("agent", autopilot.assignee_id)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Mode */}
|
||||
<span className="w-24 shrink-0 text-center text-xs text-muted-foreground">
|
||||
{EXECUTION_MODE_LABELS[autopilot.execution_mode] ?? autopilot.execution_mode}
|
||||
</span>
|
||||
{/* Mode */}
|
||||
<span className="text-muted-foreground sm:w-24 sm:shrink-0 sm:text-center">
|
||||
{EXECUTION_MODE_LABELS[autopilot.execution_mode] ?? autopilot.execution_mode}
|
||||
</span>
|
||||
|
||||
{/* Status */}
|
||||
<span className={cn("flex w-20 items-center justify-center gap-1 shrink-0 text-xs", statusCfg.color)}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
{/* Status */}
|
||||
<span className={cn("flex items-center gap-1 sm:w-20 sm:shrink-0 sm:justify-center", statusCfg.color)}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
|
||||
{/* Last run */}
|
||||
<span className="w-20 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
|
||||
{autopilot.last_run_at ? formatRelativeDate(autopilot.last_run_at) : "--"}
|
||||
</span>
|
||||
{/* Last run */}
|
||||
<span className="text-muted-foreground tabular-nums sm:w-20 sm:shrink-0 sm:text-right">
|
||||
{autopilot.last_run_at ? formatRelativeDate(autopilot.last_run_at) : "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -198,7 +200,7 @@ export function AutopilotsPage() {
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5">
|
||||
<div className="sticky top-0 z-[1] hidden h-8 items-center gap-2 border-b bg-muted/30 px-5 sm:flex">
|
||||
<span className="shrink-0 w-4" />
|
||||
<Skeleton className="h-3 w-12 flex-1 max-w-[48px]" />
|
||||
<Skeleton className="h-3 w-12 shrink-0" />
|
||||
@@ -206,9 +208,9 @@ export function AutopilotsPage() {
|
||||
<Skeleton className="h-3 w-10 shrink-0" />
|
||||
<Skeleton className="h-3 w-12 shrink-0" />
|
||||
</div>
|
||||
<div className="p-5 pt-1 space-y-1">
|
||||
<div className="space-y-2 p-4 sm:space-y-1 sm:p-5 sm:pt-1">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-11 w-full" />
|
||||
<Skeleton key={i} className="h-[72px] w-full sm:h-11" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
@@ -246,7 +248,7 @@ export function AutopilotsPage() {
|
||||
) : (
|
||||
<>
|
||||
{/* Column headers */}
|
||||
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5 text-xs font-medium text-muted-foreground">
|
||||
<div className="sticky top-0 z-[1] hidden h-8 items-center gap-2 border-b bg-muted/30 px-5 text-xs font-medium text-muted-foreground sm:flex">
|
||||
<span className="shrink-0 w-4" />
|
||||
<span className="min-w-0 flex-1">Name</span>
|
||||
<span className="w-32 shrink-0">Agent</span>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { ContentEditor, type ContentEditorRef } from "../../editor";
|
||||
import { SubmitButton } from "@multica/ui/components/common/submit-button";
|
||||
import { useChatStore, DRAFT_NEW_SESSION } from "@multica/core/chat";
|
||||
@@ -14,6 +15,10 @@ interface ChatInputProps {
|
||||
onStop?: () => void;
|
||||
isRunning?: boolean;
|
||||
disabled?: boolean;
|
||||
/** True when the user has no agent available — disables the editor and
|
||||
* surfaces a distinct placeholder. Kept separate from `disabled` so
|
||||
* archived-session copy stays untouched. */
|
||||
noAgent?: boolean;
|
||||
/** Name of the currently selected agent, used in the placeholder. */
|
||||
agentName?: string;
|
||||
/** Rendered at the bottom-left of the input bar — typically the agent picker. */
|
||||
@@ -30,6 +35,7 @@ export function ChatInput({
|
||||
onStop,
|
||||
isRunning,
|
||||
disabled,
|
||||
noAgent,
|
||||
agentName,
|
||||
leftAdornment,
|
||||
rightAdornment,
|
||||
@@ -54,11 +60,12 @@ export function ChatInput({
|
||||
|
||||
const handleSend = () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
if (!content || isRunning || disabled) {
|
||||
if (!content || isRunning || disabled || noAgent) {
|
||||
logger.debug("input.send skipped", {
|
||||
emptyContent: !content,
|
||||
isRunning,
|
||||
disabled,
|
||||
noAgent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -69,19 +76,50 @@ export function ChatInput({
|
||||
logger.info("input.send", { contentLength: content.length, draftKey: keyAtSend });
|
||||
onSend(content);
|
||||
editorRef.current?.clearContent();
|
||||
// Drop focus so the caret doesn't keep blinking under the StatusPill /
|
||||
// streaming reply that's about to take over the user's attention. The
|
||||
// input is also `disabled` once isRunning flips, and a focused-but-
|
||||
// disabled editor reads as a stale cursor. We deliberately don't auto-
|
||||
// refocus on completion — that would interrupt the user if they're
|
||||
// selecting text from the assistant reply; one click to refocus is
|
||||
// a fair price for not stealing focus mid-action.
|
||||
editorRef.current?.blur();
|
||||
clearInputDraft(keyAtSend);
|
||||
setIsEmpty(true);
|
||||
};
|
||||
|
||||
const placeholder = disabled
|
||||
? "This session is archived"
|
||||
: agentName
|
||||
? `Tell ${agentName} what to do…`
|
||||
: "Tell me what to do…";
|
||||
const placeholder = noAgent
|
||||
? "Create an agent to start chatting"
|
||||
: disabled
|
||||
? "This session is archived"
|
||||
: agentName
|
||||
? `Tell ${agentName} what to do…`
|
||||
: "Tell me what to do…";
|
||||
|
||||
return (
|
||||
<div className="px-5 pb-3 pt-0">
|
||||
<div className="relative mx-auto flex min-h-16 max-h-40 w-full max-w-4xl flex-col rounded-lg bg-card pb-9 border-1 border-border transition-colors focus-within:border-brand">
|
||||
<div
|
||||
className={cn(
|
||||
"px-5 pb-3 pt-0",
|
||||
// Outer wrapper carries the disabled cursor. Inner card sets
|
||||
// pointer-events-none, which suppresses hover (and therefore
|
||||
// any cursor of its own) — splitting the two layers lets hover
|
||||
// bubble back here so the browser actually reads cursor.
|
||||
noAgent && "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-auto flex min-h-16 max-h-40 w-full max-w-4xl flex-col rounded-lg bg-card pb-9 border-1 border-border transition-colors focus-within:border-brand",
|
||||
// Visual + interaction lock when there's no agent. We don't
|
||||
// toggle ContentEditor's editable mode (Tiptap can't switch
|
||||
// cleanly post-mount, and the prop has been removed); instead
|
||||
// we drop pointer events at the wrapper level so clicks miss
|
||||
// the editor entirely, and dim the surface so it reads as
|
||||
// "disabled" rather than "broken".
|
||||
noAgent && "pointer-events-none opacity-60",
|
||||
)}
|
||||
aria-disabled={noAgent || undefined}
|
||||
>
|
||||
{topSlot}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
||||
<ContentEditor
|
||||
@@ -113,7 +151,7 @@ export function ChatInput({
|
||||
{rightAdornment}
|
||||
<SubmitButton
|
||||
onClick={handleSend}
|
||||
disabled={isEmpty || !!disabled}
|
||||
disabled={isEmpty || !!disabled || !!noAgent}
|
||||
running={isRunning}
|
||||
onStop={onStop}
|
||||
/>
|
||||
|
||||
@@ -9,36 +9,46 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@multica/ui/components/ui/collapsible";
|
||||
import { Loader2, ChevronRight, ChevronDown, Brain, AlertCircle } from "lucide-react";
|
||||
import { ChevronRight, ChevronDown, Brain, AlertCircle, AlertTriangle } from "lucide-react";
|
||||
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
||||
import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll";
|
||||
import { taskMessagesOptions } from "@multica/core/chat/queries";
|
||||
import { Markdown } from "@multica/views/common/markdown";
|
||||
import type { ChatMessage, TaskMessagePayload } from "@multica/core/types";
|
||||
import type { AgentAvailability } from "@multica/core/agents";
|
||||
import type { ChatMessage, ChatPendingTask, TaskMessagePayload, TaskFailureReason } from "@multica/core/types";
|
||||
import type { ChatTimelineItem } from "@multica/core/chat";
|
||||
import { failureReasonLabel } from "../../agents/components/tabs/task-failure";
|
||||
import { TaskStatusPill } from "./task-status-pill";
|
||||
import { formatElapsedMs } from "../lib/format";
|
||||
|
||||
// ─── Public component ────────────────────────────────────────────────────
|
||||
|
||||
interface ChatMessageListProps {
|
||||
messages: ChatMessage[];
|
||||
/** When set, streams the live timeline for this task from task-messages cache. */
|
||||
pendingTaskId: string | null;
|
||||
isWaiting: boolean;
|
||||
/**
|
||||
* Server-authoritative pending-task snapshot. `null` / undefined means
|
||||
* no in-flight task — list renders without StatusPill.
|
||||
*/
|
||||
pendingTask: ChatPendingTask | null | undefined;
|
||||
/** Resolved presence; pass `undefined` while loading to keep the pill copy neutral. */
|
||||
availability: AgentAvailability | undefined;
|
||||
}
|
||||
|
||||
export function ChatMessageList({
|
||||
messages,
|
||||
pendingTaskId,
|
||||
isWaiting,
|
||||
pendingTask,
|
||||
availability,
|
||||
}: ChatMessageListProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fadeStyle = useScrollFade(scrollRef);
|
||||
useAutoScroll(scrollRef);
|
||||
|
||||
const pendingTaskId = pendingTask?.task_id ?? null;
|
||||
|
||||
// Once the assistant message for this pending task has landed in the
|
||||
// messages list, AssistantMessage owns its rendering — suppress the live
|
||||
// timeline to avoid rendering the same content in two places during the
|
||||
// invalidate → refetch window.
|
||||
// timeline (and pill) to avoid rendering the same content in two places
|
||||
// during the invalidate → refetch window.
|
||||
const pendingAlreadyPersisted = !!pendingTaskId && messages.some(
|
||||
(m) => m.role === "assistant" && m.task_id === pendingTaskId,
|
||||
);
|
||||
@@ -52,6 +62,7 @@ export function ChatMessageList({
|
||||
});
|
||||
const liveTimeline: ChatTimelineItem[] = (liveTaskMessages ?? []).map(toTimelineItem);
|
||||
const hasLive = showLiveTimeline && liveTimeline.length > 0;
|
||||
const showStatusPill = !!pendingTaskId && !pendingAlreadyPersisted && !!pendingTask;
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} style={fadeStyle} className="flex-1 overflow-y-auto">
|
||||
@@ -68,8 +79,12 @@ export function ChatMessageList({
|
||||
<TimelineView items={liveTimeline} />
|
||||
</div>
|
||||
)}
|
||||
{isWaiting && !hasLive && !pendingAlreadyPersisted && (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
{showStatusPill && pendingTask && (
|
||||
<TaskStatusPill
|
||||
pendingTask={pendingTask}
|
||||
taskMessages={liveTaskMessages ?? []}
|
||||
availability={availability}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,6 +168,21 @@ function AssistantMessage({
|
||||
|
||||
const timeline: ChatTimelineItem[] = (taskMessages ?? []).map(toTimelineItem);
|
||||
|
||||
// Failure bubble path: when the server's FailTask wrote a failure
|
||||
// chat_message (failure_reason set), render a destructive bubble with the
|
||||
// human-readable reason label + collapsible raw errMsg + the same timeline
|
||||
// so the user can see exactly where the run broke.
|
||||
if (message.failure_reason) {
|
||||
return (
|
||||
<FailureBubble
|
||||
reason={message.failure_reason}
|
||||
rawError={message.content}
|
||||
timeline={timeline}
|
||||
elapsedMs={message.elapsed_ms}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-1.5">
|
||||
{timeline.length > 0 ? (
|
||||
@@ -162,6 +192,86 @@ function AssistantMessage({
|
||||
<Markdown>{message.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{message.elapsed_ms != null && (
|
||||
<ElapsedCaption verb="Replied in" elapsedMs={message.elapsed_ms} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Persisted "Replied in 38s" / "Failed after 12s" line under the assistant
|
||||
// bubble. Reads `elapsed_ms` straight off the chat_message — server computes
|
||||
// it once at task completion, so this caption is identical across reloads
|
||||
// and devices. Skipped silently when null (legacy messages predating
|
||||
// migration 063 + user messages).
|
||||
function ElapsedCaption({
|
||||
verb,
|
||||
elapsedMs,
|
||||
className,
|
||||
}: {
|
||||
verb: string;
|
||||
elapsedMs: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("text-[11px] text-muted-foreground/80", className)}>
|
||||
{verb} {formatElapsedMs(elapsedMs)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FailureBubble({
|
||||
reason,
|
||||
rawError,
|
||||
timeline,
|
||||
elapsedMs,
|
||||
}: {
|
||||
reason: string;
|
||||
rawError: string;
|
||||
timeline: ChatTimelineItem[];
|
||||
elapsedMs?: number | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// Map the back-end enum to copy via the shared label table; an unknown
|
||||
// reason (e.g. a future enum value the front-end doesn't ship yet)
|
||||
// falls back to a generic "Task failed" so we never render a bare slug.
|
||||
const label =
|
||||
failureReasonLabel[reason as TaskFailureReason] ?? "Task failed";
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-1.5">
|
||||
{/* Failure read as an inline, low-key note — not a destructive
|
||||
* alert. Intentionally borderless / no background tint: a chat
|
||||
* failure is informational ("this didn't work"), not a system
|
||||
* error. The icon + muted destructive text are signal enough,
|
||||
* the rest stays in the normal reply rhythm. */}
|
||||
<div className="flex items-start gap-1.5 text-sm">
|
||||
<AlertTriangle className="size-3.5 shrink-0 text-destructive/80 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-destructive/90">{label}</div>
|
||||
{rawError.trim() && (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="mt-0.5 flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
|
||||
{open ? (
|
||||
<ChevronDown className="size-3" />
|
||||
) : (
|
||||
<ChevronRight className="size-3" />
|
||||
)}
|
||||
<span>Show details</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="mt-1 max-h-40 overflow-auto rounded bg-muted/40 p-2 text-[11px] text-muted-foreground whitespace-pre-wrap break-all">
|
||||
{rawError}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{timeline.length > 0 && <TimelineView items={timeline} />}
|
||||
{elapsedMs != null && (
|
||||
<ElapsedCaption verb="Failed after" elapsedMs={elapsedMs} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Minus, Maximize2, Minimize2, ChevronDown, Bot, Plus, Check } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
|
||||
import { Minus, Maximize2, Minimize2, ChevronDown, Plus, Check } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import {
|
||||
@@ -20,11 +19,16 @@ import { useAuthStore } from "@multica/core/auth";
|
||||
import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { canAssignAgent } from "@multica/views/issues/components";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useAgentPresenceDetail, useWorkspaceAgentAvailability } from "@multica/core/agents";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { OfflineBanner } from "./offline-banner";
|
||||
import { NoAgentBanner } from "./no-agent-banner";
|
||||
import {
|
||||
chatSessionsOptions,
|
||||
allChatSessionsOptions,
|
||||
chatMessagesOptions,
|
||||
pendingChatTaskOptions,
|
||||
pendingChatTasksOptions,
|
||||
chatKeys,
|
||||
} from "@multica/core/chat/queries";
|
||||
import { useCreateChatSession, useMarkChatSessionRead } from "@multica/core/chat/mutations";
|
||||
@@ -40,7 +44,7 @@ import {
|
||||
import { ChatResizeHandles } from "./chat-resize-handles";
|
||||
import { useChatResize } from "./use-chat-resize";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
import type { Agent, ChatMessage, ChatSession } from "@multica/core/types";
|
||||
import type { Agent, ChatMessage, ChatPendingTask, ChatSession } from "@multica/core/types";
|
||||
|
||||
const uiLogger = createLogger("chat.ui");
|
||||
const apiLogger = createLogger("chat.api");
|
||||
@@ -100,6 +104,22 @@ export function ChatWindow() {
|
||||
availableAgents[0] ??
|
||||
null;
|
||||
|
||||
// Three-state availability — "loading" stays neutral (no banner, no
|
||||
// disable) so the input doesn't flash a fake "no agent" state in the
|
||||
// few hundred ms before the agent list query resolves. Only `"none"`
|
||||
// (server confirmed: zero usable agents) drives the disabled UI.
|
||||
const agentAvailability = useWorkspaceAgentAvailability();
|
||||
const noAgent = agentAvailability === "none";
|
||||
|
||||
// Presence drives both the avatar status dot (via ActorAvatar) and the
|
||||
// OfflineBanner / TaskStatusPill availability copy. `useAgentPresenceDetail`
|
||||
// returns "loading" while queries are still resolving — pass `undefined`
|
||||
// downstream so banners and pill copy stay silent during loading rather
|
||||
// than flash speculative offline text.
|
||||
const presenceDetail = useAgentPresenceDetail(wsId, activeAgent?.id);
|
||||
const availability =
|
||||
presenceDetail === "loading" ? undefined : presenceDetail.availability;
|
||||
|
||||
// Mount / unmount logging. ChatWindow lives in DashboardLayout, so this
|
||||
// fires on layout mount (login / workspace switch / fresh page load).
|
||||
useEffect(() => {
|
||||
@@ -119,28 +139,11 @@ export function ChatWindow() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per mount
|
||||
}, []);
|
||||
|
||||
// Auto-restore most recent active session from server (only once on mount)
|
||||
const didRestoreRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (didRestoreRef.current) return;
|
||||
didRestoreRef.current = true;
|
||||
if (activeSessionId || sessions.length === 0) {
|
||||
uiLogger.debug("restore session skipped", {
|
||||
reason: activeSessionId ? "already has session" : "no sessions",
|
||||
activeSessionId,
|
||||
sessionCount: sessions.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const latest = sessions.find((s) => s.status === "active");
|
||||
if (latest) {
|
||||
uiLogger.info("restore session on mount", { sessionId: latest.id });
|
||||
setActiveSession(latest.id);
|
||||
} else {
|
||||
uiLogger.debug("restore session: no active session found");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once when sessions load
|
||||
}, [sessions]);
|
||||
// Open intent is fully driven by `activeSessionId` in storage — no mount
|
||||
// restore, no self-heal. Adding either reintroduces a "two signals
|
||||
// describing one fact" race (the previous self-heal mis-cleared the
|
||||
// freshly-created session because allSessions was still stale during the
|
||||
// post-create invalidate-refetch window).
|
||||
|
||||
// WS events are handled globally in useRealtimeSync — the query cache
|
||||
// stays current even when this window is closed. See packages/core/realtime/.
|
||||
@@ -197,19 +200,34 @@ export function ChatWindow() {
|
||||
setActiveSession(sessionId);
|
||||
}
|
||||
|
||||
// Optimistic: show user message immediately.
|
||||
// Optimistic burst — everything that gives the user "I sent a message
|
||||
// and the agent is now working" feedback fires BEFORE the HTTP roundtrip.
|
||||
// Pre-#status-pill the pending-task seed lived after `await
|
||||
// sendChatMessage` and the pill blinked in a few hundred ms after the
|
||||
// user's message — small but visible "did it actually send?" gap.
|
||||
const sentAt = new Date().toISOString();
|
||||
const optimistic: ChatMessage = {
|
||||
id: `optimistic-${Date.now()}`,
|
||||
chat_session_id: sessionId,
|
||||
role: "user",
|
||||
content: finalContent,
|
||||
task_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
created_at: sentAt,
|
||||
};
|
||||
qc.setQueryData<ChatMessage[]>(
|
||||
chatKeys.messages(sessionId),
|
||||
(old) => (old ? [...old, optimistic] : [optimistic]),
|
||||
);
|
||||
// Seed the pending-task with a temporary id so the StatusPill mounts
|
||||
// and starts ticking the instant the user clicks send. Real task_id
|
||||
// and server-authoritative created_at land below; until then the pill
|
||||
// is anchored to the local clock (drift is the request RTT, ~50–200ms,
|
||||
// which doesn't change the rendered "Ns" value).
|
||||
qc.setQueryData<ChatPendingTask>(chatKeys.pendingTask(sessionId), {
|
||||
task_id: `optimistic-${optimistic.id}`,
|
||||
status: "queued",
|
||||
created_at: sentAt,
|
||||
});
|
||||
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
|
||||
|
||||
const result = await api.sendChatMessage(sessionId, finalContent);
|
||||
@@ -218,11 +236,13 @@ export function ChatWindow() {
|
||||
messageId: result.message_id,
|
||||
taskId: result.task_id,
|
||||
});
|
||||
// Seed pending-task optimistically so the spinner shows instantly —
|
||||
// the WS chat:message handler will invalidate + refetch to confirm.
|
||||
qc.setQueryData(chatKeys.pendingTask(sessionId), {
|
||||
// Replace the temporary task_id with the server's real one (so the WS
|
||||
// task: handlers can match against it) and snap the anchor to the
|
||||
// server's created_at — keeping the elapsed-seconds reading stable.
|
||||
qc.setQueryData<ChatPendingTask>(chatKeys.pendingTask(sessionId), {
|
||||
task_id: result.task_id,
|
||||
status: "queued",
|
||||
created_at: result.created_at,
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
|
||||
},
|
||||
@@ -236,24 +256,30 @@ export function ChatWindow() {
|
||||
],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
if (!pendingTaskId) {
|
||||
const handleStop = useCallback(() => {
|
||||
if (!pendingTaskId || !activeSessionId) {
|
||||
apiLogger.debug("cancelTask skipped: no pending task");
|
||||
return;
|
||||
}
|
||||
// Optimistic clear — pill disappears + input unlocks the moment the
|
||||
// user clicks Stop, instead of after the HTTP roundtrip. WS
|
||||
// task:cancelled will confirm later (no-op if cache is already empty);
|
||||
// if the cancel POST fails because the task already finished, the
|
||||
// assistant message arrives via task:completed → chat:done and renders
|
||||
// normally. Either way the UI is in sync with reality without latency.
|
||||
apiLogger.info("cancelTask.start", { taskId: pendingTaskId, sessionId: activeSessionId });
|
||||
try {
|
||||
await api.cancelTaskById(pendingTaskId);
|
||||
apiLogger.info("cancelTask.success", { taskId: pendingTaskId });
|
||||
} catch (err) {
|
||||
// Task may already be completed
|
||||
apiLogger.warn("cancelTask.error (task may have already finished)", { taskId: pendingTaskId, err });
|
||||
}
|
||||
if (activeSessionId) {
|
||||
// Clear pending immediately; WS task:cancelled will confirm.
|
||||
qc.setQueryData(chatKeys.pendingTask(activeSessionId), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(activeSessionId) });
|
||||
}
|
||||
qc.setQueryData(chatKeys.pendingTask(activeSessionId), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(activeSessionId) });
|
||||
// Fire-and-forget — UI is already in its post-cancel state. We log the
|
||||
// outcome but never block on it.
|
||||
api.cancelTaskById(pendingTaskId).then(
|
||||
() => apiLogger.info("cancelTask.success", { taskId: pendingTaskId }),
|
||||
(err) =>
|
||||
apiLogger.warn("cancelTask.error (task may have already finished)", {
|
||||
taskId: pendingTaskId,
|
||||
err,
|
||||
}),
|
||||
);
|
||||
}, [pendingTaskId, activeSessionId, qc]);
|
||||
|
||||
const handleSelectAgent = useCallback(
|
||||
@@ -402,22 +428,40 @@ export function ChatWindow() {
|
||||
) : hasMessages ? (
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
pendingTaskId={pendingTaskId}
|
||||
isWaiting={!!pendingTaskId}
|
||||
pendingTask={pendingTask}
|
||||
availability={availability}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
hasSessions={sessions.length > 0}
|
||||
agentName={activeAgent?.name}
|
||||
onPickPrompt={(text) => handleSend(text)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Input — disabled for archived sessions */}
|
||||
{/* Status banner above the input — single mutually-exclusive slot.
|
||||
* Priority: no-agent > offline / unstable. Agent presence is the
|
||||
* hard prerequisite (you can't send anything without one), so it
|
||||
* always wins over a presence hint. ContextAnchorCard stays in
|
||||
* topSlot because that's per-message context, not session state.
|
||||
*
|
||||
* We key off `noAgent` (the resolved-empty state) rather than
|
||||
* `!activeAgent`, so the loading window between mount and the
|
||||
* first agent-list response stays banner-free. */}
|
||||
{noAgent ? (
|
||||
<NoAgentBanner />
|
||||
) : (
|
||||
<OfflineBanner agentName={activeAgent?.name} availability={availability} />
|
||||
)}
|
||||
|
||||
{/* Input — disabled for archived sessions; locked out entirely
|
||||
* when there's no agent (the EmptyState above carries the CTA). */}
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isRunning={!!pendingTaskId}
|
||||
disabled={isSessionArchived}
|
||||
noAgent={noAgent}
|
||||
agentName={activeAgent?.name}
|
||||
topSlot={<ContextAnchorCard />}
|
||||
leftAdornment={
|
||||
@@ -469,7 +513,13 @@ function AgentDropdown({
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 rounded-md px-1.5 py-1 -ml-1 cursor-pointer outline-none transition-colors hover:bg-accent aria-expanded:bg-accent">
|
||||
<AgentAvatarSmall agent={activeAgent} />
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={activeAgent.id}
|
||||
size={24}
|
||||
enableHoverCard
|
||||
showStatusDot
|
||||
/>
|
||||
<span className="text-xs font-medium max-w-28 truncate">{activeAgent.name}</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -520,7 +570,13 @@ function AgentMenuItem({
|
||||
onClick={() => onSelect(agent)}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<AgentAvatarSmall agent={agent} />
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={agent.id}
|
||||
size={24}
|
||||
enableHoverCard
|
||||
showStatusDot
|
||||
/>
|
||||
<span className="truncate flex-1">{agent.name}</span>
|
||||
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
@@ -545,16 +601,60 @@ function SessionDropdown({
|
||||
activeSessionId: string | null;
|
||||
onSelectSession: (session: ChatSession) => void;
|
||||
}) {
|
||||
const wsId = useWorkspaceId();
|
||||
const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
|
||||
const activeSession = sessions.find((s) => s.id === activeSessionId);
|
||||
const title = activeSession?.title?.trim() || "New chat";
|
||||
const triggerAgent = activeSession ? agentById.get(activeSession.agent_id) ?? null : null;
|
||||
|
||||
// Aggregate "which sessions have an in-flight task right now". Reuses
|
||||
// the same workspace-scoped query the FAB consumes, so toggling the chat
|
||||
// window doesn't fire a second request — TanStack dedupes by key.
|
||||
const { data: pending } = useQuery(pendingChatTasksOptions(wsId));
|
||||
const inFlightSessionIds = useMemo(
|
||||
() => new Set((pending?.tasks ?? []).map((t) => t.chat_session_id)),
|
||||
[pending],
|
||||
);
|
||||
|
||||
// Cross-session aggregate signal for the closed-dropdown trigger.
|
||||
// "Active" here means there's something interesting happening in a
|
||||
// session OTHER than the one the user is currently looking at — the
|
||||
// user already sees their own session's state via the StatusPill /
|
||||
// unread auto-mark, so highlighting it on the trigger would be noise.
|
||||
// Same priority rule as the row pips: running > unread.
|
||||
const otherSessionRunning = sessions.some(
|
||||
(s) => s.id !== activeSessionId && inFlightSessionIds.has(s.id),
|
||||
);
|
||||
const otherSessionUnread = sessions.some(
|
||||
(s) => s.id !== activeSessionId && s.has_unread,
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 min-w-0 rounded-md px-1.5 py-1 transition-colors hover:bg-accent aria-expanded:bg-accent">
|
||||
{triggerAgent && <AgentAvatarSmall agent={triggerAgent} />}
|
||||
{triggerAgent && (
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={triggerAgent.id}
|
||||
size={24}
|
||||
enableHoverCard
|
||||
showStatusDot
|
||||
/>
|
||||
)}
|
||||
<span className="truncate text-sm font-medium">{title}</span>
|
||||
{otherSessionRunning ? (
|
||||
<span
|
||||
aria-label="Another chat is running"
|
||||
title="Another chat is running"
|
||||
className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse"
|
||||
/>
|
||||
) : otherSessionUnread ? (
|
||||
<span
|
||||
aria-label="Another chat has unread replies"
|
||||
title="Another chat has unread replies"
|
||||
className="size-1.5 shrink-0 rounded-full bg-brand"
|
||||
/>
|
||||
) : null}
|
||||
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-80 w-auto min-w-56 max-w-80">
|
||||
@@ -566,6 +666,7 @@ function SessionDropdown({
|
||||
sessions.map((session) => {
|
||||
const isCurrent = session.id === activeSessionId;
|
||||
const agent = agentById.get(session.agent_id) ?? null;
|
||||
const isRunning = inFlightSessionIds.has(session.id);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={session.id}
|
||||
@@ -573,16 +674,38 @@ function SessionDropdown({
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
{agent ? (
|
||||
<AgentAvatarSmall agent={agent} />
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={agent.id}
|
||||
size={24}
|
||||
enableHoverCard
|
||||
showStatusDot
|
||||
/>
|
||||
) : (
|
||||
<span className="size-6 shrink-0" />
|
||||
)}
|
||||
<span className="truncate flex-1 text-sm">
|
||||
{session.title?.trim() || "New chat"}
|
||||
</span>
|
||||
{session.has_unread && (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-brand" />
|
||||
)}
|
||||
{/* Right-edge status pip: in-flight wins over unread because
|
||||
* "still working" is more actionable than "has reply" — and
|
||||
* the two rarely coexist in practice (the unread flag fires
|
||||
* on chat_message write, by which point the task has just
|
||||
* finished). Same pip shape as unread for visual rhythm,
|
||||
* amber + pulse to read as activity. */}
|
||||
{isRunning ? (
|
||||
<span
|
||||
aria-label="Running"
|
||||
title="Running"
|
||||
className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse"
|
||||
/>
|
||||
) : session.has_unread ? (
|
||||
<span
|
||||
aria-label="Unread"
|
||||
title="Unread"
|
||||
className="size-1.5 shrink-0 rounded-full bg-brand"
|
||||
/>
|
||||
) : null}
|
||||
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
@@ -593,17 +716,6 @@ function SessionDropdown({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentAvatarSmall({ agent }: { agent: Agent }) {
|
||||
return (
|
||||
<Avatar className="size-6">
|
||||
{agent.avatar_url && <AvatarImage src={agent.avatar_url} />}
|
||||
<AvatarFallback className="bg-purple-100 text-purple-700">
|
||||
<Bot className="size-3.5" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Three starter prompts shown on the empty state. Tapping one sends it
|
||||
* immediately — ChatGPT-style — because the point is showing users what
|
||||
@@ -616,12 +728,42 @@ const STARTER_PROMPTS: { icon: string; text: string }[] = [
|
||||
];
|
||||
|
||||
function EmptyState({
|
||||
hasSessions,
|
||||
agentName,
|
||||
onPickPrompt,
|
||||
}: {
|
||||
hasSessions: boolean;
|
||||
agentName?: string;
|
||||
onPickPrompt: (text: string) => void;
|
||||
}) {
|
||||
// First-time experience: the user has never started a chat in this
|
||||
// workspace. Educate before suggesting actions — starter prompts
|
||||
// presume the user already knows what chat is for.
|
||||
//
|
||||
// Independent of agent state: missing-agent feedback lives in the
|
||||
// banner above the input, not here. That keeps this surface focused
|
||||
// on "what is chat" rather than "what's broken right now".
|
||||
if (!hasSessions) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-8">
|
||||
<div className="text-center space-y-3">
|
||||
<h3 className="text-base font-semibold">Chat with your agents</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
✨ They know your workspace —{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
issues, projects, skills
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ask for a summary, plan your day, or hand off a quick task.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Returning user: starter prompts are the fastest path back to action.
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-5 px-6 py-8">
|
||||
<div className="text-center space-y-1">
|
||||
|
||||
29
packages/views/chat/components/no-agent-banner.tsx
Normal file
29
packages/views/chat/components/no-agent-banner.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { Bot } from "lucide-react";
|
||||
|
||||
// Sibling of ChatInput, occupying the same banner slot as OfflineBanner.
|
||||
// Shown when the workspace has no agent the current user can chat with —
|
||||
// the input above is disabled, and this banner explains why.
|
||||
//
|
||||
// Pure copy by design: the banner doesn't link to /agents because the
|
||||
// information ("you need an agent") is what's actionable here, not the
|
||||
// destination — pushing users out of chat to a settings page mid-thought
|
||||
// is more disruptive than just stating the prerequisite. Users who want
|
||||
// to act go to Agents on their own.
|
||||
//
|
||||
// Layout (`px-5` outer, `mx-auto max-w-4xl` inner) mirrors OfflineBanner
|
||||
// and ChatInput so the banner's edges line up with the input on every
|
||||
// viewport size.
|
||||
export function NoAgentBanner() {
|
||||
return (
|
||||
<div className="px-5 mb-1.5">
|
||||
<div className="mx-auto flex w-full max-w-4xl items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs bg-muted text-muted-foreground ring-1 ring-border">
|
||||
<Bot className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
You need an agent to start chatting.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
packages/views/chat/components/offline-banner.tsx
Normal file
54
packages/views/chat/components/offline-banner.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, WifiOff } from "lucide-react";
|
||||
import type { AgentAvailability } from "@multica/core/agents";
|
||||
|
||||
interface Props {
|
||||
/** Display name shown in the banner copy. */
|
||||
agentName?: string;
|
||||
/**
|
||||
* Resolved presence availability. Pass `undefined` (or "loading") to
|
||||
* suppress the banner — we only surface known offline / unstable states,
|
||||
* never speculative copy.
|
||||
*/
|
||||
availability: AgentAvailability | undefined;
|
||||
}
|
||||
|
||||
// Inline notice rendered above the chat input when the active agent isn't
|
||||
// reachable. Hides on `online`, `undefined`, or while presence is loading —
|
||||
// users get the silent default behaviour and only see copy when there's a
|
||||
// real-world implication for the message they're about to send.
|
||||
//
|
||||
// Sits outside the input card (sibling of ChatInput) so the hint reads as
|
||||
// a session-level signal rather than per-message context. The outer wrapper
|
||||
// (`px-5`) and the inner container (`mx-auto max-w-4xl`) mirror ChatInput's
|
||||
// own layout so the banner's edges line up with the input box on every
|
||||
// viewport size — without `max-w-4xl` the banner stretches wider than the
|
||||
// input on large screens and looks "loose".
|
||||
export function OfflineBanner({ agentName, availability }: Props) {
|
||||
if (availability !== "offline" && availability !== "unstable") return null;
|
||||
|
||||
const name = agentName?.trim() || "the agent";
|
||||
if (availability === "unstable") {
|
||||
return (
|
||||
<div className="px-5 mb-1.5">
|
||||
<div className="mx-auto flex w-full max-w-4xl items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs bg-amber-50 dark:bg-amber-950/40 text-amber-900 dark:text-amber-200 ring-1 ring-amber-200/60 dark:ring-amber-900/40">
|
||||
<AlertCircle className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{name}'s connection is unstable — replies may be delayed.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="px-5 mb-1.5">
|
||||
<div className="mx-auto flex w-full max-w-4xl items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs bg-muted text-muted-foreground ring-1 ring-border">
|
||||
<WifiOff className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{name} is offline — your message will be delivered when they're back.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
packages/views/chat/components/task-status-pill.tsx
Normal file
154
packages/views/chat/components/task-status-pill.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { UnicodeSpinner } from "@multica/ui/components/common/unicode-spinner";
|
||||
import type { AgentAvailability } from "@multica/core/agents";
|
||||
import type { ChatPendingTask, TaskMessagePayload } from "@multica/core/types";
|
||||
import { formatElapsedSecs } from "../lib/format";
|
||||
|
||||
interface Props {
|
||||
/** Server-authoritative pending-task snapshot (`created_at` anchors the timer). */
|
||||
pendingTask: ChatPendingTask;
|
||||
/** Live task-message stream — the latest non-error entry decides the running-stage label. */
|
||||
taskMessages: readonly TaskMessagePayload[];
|
||||
/** Resolved presence; pass `undefined` to suppress availability hints. */
|
||||
availability: AgentAvailability | undefined;
|
||||
}
|
||||
|
||||
interface Stage {
|
||||
/** Standalone label, capitalised so it reads as a complete short phrase
|
||||
* ("Searching the web · 14s") without needing a subject. Matches the
|
||||
* ChatGPT / Cursor / Claude style — the agent identity is already on
|
||||
* the chat header, so we don't repeat it inline. */
|
||||
label: string;
|
||||
/** Stage represents a stable holding state (offline / waiting). When true,
|
||||
* the spinner is suppressed and the shimmer animation is disabled —
|
||||
* shimmer / spinning implies "the agent is actively doing something",
|
||||
* which a holding state isn't. */
|
||||
static?: boolean;
|
||||
}
|
||||
|
||||
// Tool → label. Short, action-flavoured phrases — the daemon-reported tool
|
||||
// slug is meaningful but ugly ("ToolUse: read"); these are the user-facing
|
||||
// translations. Unknown tools fall back to "Working" rather than leaking
|
||||
// the raw slug.
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
bash: "Running a command",
|
||||
exec: "Running a command",
|
||||
read: "Reading files",
|
||||
glob: "Reading files",
|
||||
grep: "Searching the code",
|
||||
write: "Making edits",
|
||||
edit: "Making edits",
|
||||
multi_edit: "Making edits",
|
||||
multiedit: "Making edits",
|
||||
web_search: "Searching the web",
|
||||
websearch: "Searching the web",
|
||||
};
|
||||
|
||||
const TOOL_FALLBACK = "Working";
|
||||
|
||||
// Pure stage decision. Two-tier signal: presence + status drive the
|
||||
// queued/wait copy, then taskMessages drive the running-state label.
|
||||
// Errors deliberately don't flip the pill — the timeline already renders
|
||||
// the error inline, and overwriting the label would mask whatever the
|
||||
// agent does next.
|
||||
function pickStage(
|
||||
status: string | undefined,
|
||||
taskMessages: readonly TaskMessagePayload[],
|
||||
availability: AgentAvailability | undefined,
|
||||
): Stage {
|
||||
if (
|
||||
(status === "queued" || status === "dispatched") &&
|
||||
availability === "offline"
|
||||
) {
|
||||
return { label: "Offline", static: true };
|
||||
}
|
||||
if (
|
||||
(status === "queued" || status === "dispatched") &&
|
||||
availability === "unstable"
|
||||
) {
|
||||
return { label: "Reconnecting" };
|
||||
}
|
||||
if (status === "queued") return { label: "Queued" };
|
||||
if (status === "dispatched") return { label: "Starting up" };
|
||||
|
||||
// running: latest meaningful message decides the label. We deliberately
|
||||
// skip both `error` rows (rendered inline by the timeline; flipping the
|
||||
// pill would mask the next real action) and `tool_result` rows
|
||||
// (tool_result is the completion event for a tool_use, not a new stage —
|
||||
// treating it as one made the pill flicker bash → Thinking → grep →
|
||||
// Thinking → web_search on every tool boundary, where reality is just
|
||||
// bash → grep → web_search).
|
||||
let latest: TaskMessagePayload | null = null;
|
||||
for (let i = taskMessages.length - 1; i >= 0; i--) {
|
||||
const m = taskMessages[i];
|
||||
if (m && m.type !== "error" && m.type !== "tool_result") {
|
||||
latest = m;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!latest) return { label: "Thinking" };
|
||||
if (latest.type === "thinking") return { label: "Thinking" };
|
||||
if (latest.type === "text") return { label: "Typing" };
|
||||
if (latest.type === "tool_use") {
|
||||
const tool = (latest.tool ?? "").toLowerCase();
|
||||
return { label: TOOL_LABELS[tool] ?? TOOL_FALLBACK };
|
||||
}
|
||||
return { label: "Thinking" };
|
||||
}
|
||||
|
||||
export function TaskStatusPill({
|
||||
pendingTask,
|
||||
taskMessages,
|
||||
availability,
|
||||
}: Props) {
|
||||
// Anchor: locked on first render. Once set we never reassign — otherwise
|
||||
// the timer would visibly snap backwards when an optimistic-seeded
|
||||
// `Date.now()` anchor is later replaced by a server-side created_at that
|
||||
// happened a few hundred ms earlier. Monotonic elapsed > strict accuracy.
|
||||
const anchorRef = useRef<number | null>(null);
|
||||
if (anchorRef.current === null) {
|
||||
if (pendingTask.created_at) {
|
||||
const t = Date.parse(pendingTask.created_at);
|
||||
anchorRef.current = Number.isFinite(t) ? t : Date.now();
|
||||
} else {
|
||||
anchorRef.current = Date.now();
|
||||
}
|
||||
}
|
||||
const anchor = anchorRef.current;
|
||||
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// Effective status — defense-in-depth derive on top of the cache. If any
|
||||
// task_message has streamed in, the daemon has by definition started
|
||||
// running; we trust that observation over a stale cache. Catches WS gaps,
|
||||
// reconnect windows, or out-of-order delivery where the cache hasn't been
|
||||
// writethrough'd yet.
|
||||
const status = taskMessages.length > 0 ? "running" : pendingTask.status;
|
||||
const elapsedSecs = Math.max(0, Math.floor((now - anchor) / 1000));
|
||||
const stage = pickStage(status, taskMessages, availability);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-1 text-xs text-muted-foreground"
|
||||
aria-live="polite"
|
||||
>
|
||||
{!stage.static && (
|
||||
<UnicodeSpinner name="breathe" className="opacity-70" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
<span className={cn(!stage.static && "animate-chat-text-shimmer")}>
|
||||
{stage.label}
|
||||
</span>
|
||||
<span className="opacity-70"> · {formatElapsedSecs(elapsedSecs)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
packages/views/chat/lib/format.ts
Normal file
19
packages/views/chat/lib/format.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Format an elapsed seconds value as `Ns` (under a minute) or `Nm Ms`
|
||||
* (over a minute). Drops the seconds part when the remainder is 0 to
|
||||
* keep round-minute readings short ("3m" rather than "3m 0s"). Shared
|
||||
* by the live StatusPill timer and the persistent assistant-message
|
||||
* timing line — keeping them in lockstep avoids visible drift between
|
||||
* "Working · 38s" mid-flight and a final "Replied in 39s" caption.
|
||||
*/
|
||||
export function formatElapsedSecs(secs: number): string {
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return s ? `${m}m ${s}s` : `${m}m`;
|
||||
}
|
||||
|
||||
/** Convenience: same formatting, but the input is milliseconds (server-stored elapsed_ms). */
|
||||
export function formatElapsedMs(ms: number): string {
|
||||
return formatElapsedSecs(Math.max(0, Math.round(ms / 1000)));
|
||||
}
|
||||
@@ -1,8 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Two-column property row used in detail-page sidebars: a fixed-width muted
|
||||
* label on the left and a flexible value on the right.
|
||||
* Two-column property row used in detail-page sidebars: a muted label on the
|
||||
* left and a flexible value on the right.
|
||||
*
|
||||
* Uses **subgrid**, so the parent must declare the column tracks:
|
||||
*
|
||||
* <div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5">
|
||||
* <PropRow label="…">…</PropRow>
|
||||
* <PropRow label="…">…</PropRow>
|
||||
* </div>
|
||||
*
|
||||
* The `auto` track sizes to the widest label across all rows in the parent
|
||||
* grid, so labels always fit and values stay aligned across rows without
|
||||
* picking a magic pixel width. Earlier versions used a fixed `w-16` label;
|
||||
* that broke whenever a label (e.g. "Concurrency") rendered wider than 64px
|
||||
* — the label would overflow into the gap and collide with the value.
|
||||
*
|
||||
* `interactive` (default `true`) controls whether the row gets a hover
|
||||
* highlight. Most rows wrap a Picker/Popover trigger and are clickable
|
||||
@@ -14,10 +27,6 @@ import type { ReactNode } from "react";
|
||||
* Used by:
|
||||
* - issue detail sidebar (Status / Priority / Assignee / …)
|
||||
* - agent detail inspector (Runtime / Model / Visibility / …)
|
||||
*
|
||||
* Width of the label is intentionally narrow (`w-16` = 64px) so even
|
||||
* 320px-wide sidebars (agent inspector) leave reasonable room for the
|
||||
* value column.
|
||||
*/
|
||||
export function PropRow({
|
||||
label,
|
||||
@@ -30,14 +39,12 @@ export function PropRow({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`-mx-2 flex min-h-8 items-center gap-2 rounded-md px-2 ${
|
||||
className={`-mx-2 col-span-2 grid min-h-8 grid-cols-subgrid items-center rounded-md px-2 ${
|
||||
interactive ? "transition-colors hover:bg-accent/50" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="w-16 shrink-0 text-xs text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-xs">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<div className="flex min-w-0 items-center gap-1.5 truncate text-xs">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -200,6 +200,91 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Mermaid diagrams */
|
||||
.rich-text-editor .mermaid-diagram {
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin: 0.75rem 0;
|
||||
overflow-x: auto;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rich-text-editor .mermaid-diagram-frame {
|
||||
border: 0;
|
||||
display: block;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rich-text-editor .mermaid-diagram-loading,
|
||||
.rich-text-editor .mermaid-diagram-error p {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.8125rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rich-text-editor .mermaid-diagram-error pre {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Mermaid toolbar — dark pill, top-right corner, appears on hover */
|
||||
.rich-text-editor .mermaid-diagram-toolbar {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
padding: 0.25rem;
|
||||
background: color-mix(in srgb, black 75%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: var(--radius);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.rich-text-editor .mermaid-diagram:hover .mermaid-diagram-toolbar,
|
||||
.rich-text-editor .mermaid-diagram-toolbar:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rich-text-editor .mermaid-diagram-toolbar button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
color: white;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.rich-text-editor .mermaid-diagram-toolbar button:hover {
|
||||
background: color-mix(in srgb, white 15%, transparent);
|
||||
}
|
||||
|
||||
/* Mermaid lightbox — full-screen preview (ESC or click backdrop to close) */
|
||||
.mermaid-diagram-lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, black 80%, transparent);
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
.mermaid-diagram-lightbox-frame {
|
||||
border: 0;
|
||||
width: 90vw;
|
||||
height: 90vh;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Syntax highlighting — lowlight (hljs) */
|
||||
.rich-text-editor .hljs-keyword,
|
||||
.rich-text-editor .hljs-selector-tag,
|
||||
@@ -529,4 +614,3 @@
|
||||
max-width: min(360px, calc(100vw - 2rem));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ContentEditor — the single rich-text editor for the entire application.
|
||||
* ContentEditor — the rich-text editor used wherever the user TYPES content.
|
||||
*
|
||||
* Architecture decisions (April 2026 refactor):
|
||||
*
|
||||
* 1. ONE COMPONENT for both editing and readonly display. The `editable` prop
|
||||
* controls the mode. Previously we had RichTextEditor + ReadonlyEditor as
|
||||
* separate components with duplicated extension configs — this caused
|
||||
* visual inconsistency between edit and display modes.
|
||||
* 1. EDITING ONLY. Read-only display is handled by `ReadonlyContent` (a
|
||||
* react-markdown renderer), not this component. There used to be an
|
||||
* `editable` prop here that toggled between modes, but every readonly
|
||||
* callsite migrated to ReadonlyContent and the prop only invited
|
||||
* misuse — Tiptap's `useEditor` reads `editable` at mount, so toggling
|
||||
* the prop later silently failed (mounted-as-readonly editors stayed
|
||||
* unfocusable forever). To express "currently disabled", wrap this
|
||||
* component in a layout that sets `pointer-events-none` / `aria-disabled`
|
||||
* — don't reach into the editor.
|
||||
*
|
||||
* 2. ONE MARKDOWN PIPELINE via @tiptap/markdown. Content is loaded with
|
||||
* `contentType: 'markdown'` and saved with `editor.getMarkdown()`.
|
||||
@@ -66,7 +71,6 @@ interface ContentEditorProps {
|
||||
defaultValue?: string;
|
||||
onUpdate?: (markdown: string) => void;
|
||||
placeholder?: string;
|
||||
editable?: boolean;
|
||||
className?: string;
|
||||
debounceMs?: number;
|
||||
onSubmit?: () => void;
|
||||
@@ -94,6 +98,10 @@ interface ContentEditorRef {
|
||||
getMarkdown: () => string;
|
||||
clearContent: () => void;
|
||||
focus: () => void;
|
||||
/** Drop focus from the editor — used by chat after send so the caret
|
||||
* stops competing with the StatusPill / streaming reply for the user's
|
||||
* attention. */
|
||||
blur: () => void;
|
||||
uploadFile: (file: File) => void;
|
||||
/** True when file uploads are still in progress. */
|
||||
hasActiveUploads: () => boolean;
|
||||
@@ -109,7 +117,6 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
defaultValue = "",
|
||||
onUpdate,
|
||||
placeholder: placeholderText = "",
|
||||
editable = true,
|
||||
className,
|
||||
debounceMs = 300,
|
||||
onSubmit,
|
||||
@@ -127,7 +134,6 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
const onBlurRef = useRef(onBlur);
|
||||
const onUploadFileRef = useRef(onUploadFile);
|
||||
const prevContentRef = useRef(defaultValue);
|
||||
const lastEmittedRef = useRef<string | null>(null);
|
||||
|
||||
// Current workspace slug kept in a ref so the click handler always sees the
|
||||
@@ -150,14 +156,12 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
// Note: in v3.22.1 the default is already false/undefined (same behavior).
|
||||
// Explicit for clarity — the real perf win is useEditorState in BubbleMenu.
|
||||
shouldRerenderOnTransaction: false,
|
||||
editable,
|
||||
onCreate: ({ editor: ed }) => {
|
||||
lastEmittedRef.current = stripBlobUrls(ed.getMarkdown());
|
||||
},
|
||||
content: defaultValue ? preprocessMarkdown(defaultValue) : "",
|
||||
contentType: defaultValue ? "markdown" : undefined,
|
||||
extensions: createEditorExtensions({
|
||||
editable,
|
||||
placeholder: placeholderText,
|
||||
queryClient,
|
||||
onSubmitRef,
|
||||
@@ -195,11 +199,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
class: cn(
|
||||
"rich-text-editor text-sm outline-none",
|
||||
!editable && "readonly",
|
||||
className,
|
||||
),
|
||||
class: cn("rich-text-editor text-sm outline-none", className),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -211,20 +211,6 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Readonly content update: when defaultValue changes and editor is readonly,
|
||||
// re-set the content (e.g. after editing a comment, the readonly view updates)
|
||||
useEffect(() => {
|
||||
if (!editor || editable) return;
|
||||
if (defaultValue === prevContentRef.current) return;
|
||||
prevContentRef.current = defaultValue;
|
||||
const processed = defaultValue ? preprocessMarkdown(defaultValue) : "";
|
||||
if (processed) {
|
||||
editor.commands.setContent(processed, { contentType: "markdown" });
|
||||
} else {
|
||||
editor.commands.clearContent();
|
||||
}
|
||||
}, [editor, editable, defaultValue]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => stripBlobUrls(editor?.getMarkdown() ?? ""),
|
||||
clearContent: () => {
|
||||
@@ -233,6 +219,9 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
focus: () => {
|
||||
editor?.commands.focus();
|
||||
},
|
||||
blur: () => {
|
||||
editor?.commands.blur();
|
||||
},
|
||||
uploadFile: (file: File) => {
|
||||
if (!editor || !onUploadFileRef.current) return;
|
||||
const endPos = editor.state.doc.content.size;
|
||||
@@ -255,7 +244,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
const hover = useLinkHover(wrapperRef, hoverDisabled);
|
||||
|
||||
const handleContainerMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!editable || !editor) return;
|
||||
if (!editor) return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest(".ProseMirror")) return;
|
||||
@@ -274,7 +263,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
onMouseDown={handleContainerMouseDown}
|
||||
>
|
||||
<EditorContent className="flex-1 min-h-full" editor={editor} />
|
||||
{editable && showBubbleMenu && (
|
||||
{showBubbleMenu && (
|
||||
<EditorBubbleMenu editor={editor} currentIssueId={currentIssueId} />
|
||||
)}
|
||||
<LinkHoverCard {...hover} />
|
||||
|
||||
@@ -39,6 +39,7 @@ import { BaseMentionExtension } from "./mention-extension";
|
||||
import { createMentionSuggestion } from "./mention-suggestion";
|
||||
import { CodeBlockView } from "./code-block-view";
|
||||
import { createMarkdownPasteExtension } from "./markdown-paste";
|
||||
import { createMarkdownCopyExtension } from "./markdown-copy";
|
||||
import { createSubmitExtension } from "./submit-shortcut";
|
||||
import { createBlurShortcutExtension } from "./blur-shortcut";
|
||||
import { createFileUploadExtension } from "./file-upload";
|
||||
@@ -48,18 +49,13 @@ import { BlockMathExtension, InlineMathExtension } from "./math";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
const LinkEditable = Link.extend({ inclusive: false }).configure({
|
||||
const LinkExtension = Link.extend({ inclusive: false }).configure({
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
defaultProtocol: "https",
|
||||
});
|
||||
|
||||
const LinkReadonly = Link.configure({
|
||||
openOnClick: false,
|
||||
autolink: false,
|
||||
});
|
||||
|
||||
const ImageExtension = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
@@ -81,7 +77,6 @@ const ImageExtension = Image.extend({
|
||||
});
|
||||
|
||||
export interface EditorExtensionsOptions {
|
||||
editable: boolean;
|
||||
placeholder?: string;
|
||||
queryClient?: import("@tanstack/react-query").QueryClient;
|
||||
onSubmitRef?: RefObject<(() => void) | undefined>;
|
||||
@@ -103,9 +98,9 @@ export interface EditorExtensionsOptions {
|
||||
export function createEditorExtensions(
|
||||
options: EditorExtensionsOptions,
|
||||
): AnyExtension[] {
|
||||
const { editable, placeholder: placeholderText } = options;
|
||||
const { placeholder: placeholderText } = options;
|
||||
|
||||
const extensions: AnyExtension[] = [
|
||||
return [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
link: false,
|
||||
@@ -119,7 +114,7 @@ export function createEditorExtensions(
|
||||
// ⚠️ Link MUST appear before markdownPaste in this array.
|
||||
// linkOnPaste relies on Link's handlePaste plugin firing first;
|
||||
// markdownPaste's handlePaste is a catch-all that returns true.
|
||||
editable ? LinkEditable : LinkReadonly,
|
||||
LinkExtension,
|
||||
ImageExtension,
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
@@ -129,37 +124,33 @@ export function createEditorExtensions(
|
||||
InlineMathExtension,
|
||||
// 3-space indent so nested ordered lists survive CommonMark in ReadonlyContent.
|
||||
Markdown.configure({ indentation: { style: "space", size: 3 } }),
|
||||
// Make Cmd+C / Cmd+X / drag write Markdown source to clipboard text/plain
|
||||
// so users can copy rich content out as the original Markdown.
|
||||
createMarkdownCopyExtension(),
|
||||
FileCardExtension,
|
||||
...(options.disableMentions
|
||||
? []
|
||||
: [
|
||||
BaseMentionExtension.configure({
|
||||
HTMLAttributes: { class: "mention" },
|
||||
...(editable && options.queryClient
|
||||
...(options.queryClient
|
||||
? { suggestion: createMentionSuggestion(options.queryClient) }
|
||||
: {}),
|
||||
}),
|
||||
]),
|
||||
Typography,
|
||||
Placeholder.configure({ placeholder: placeholderText }),
|
||||
createMarkdownPasteExtension(),
|
||||
createSubmitExtension(
|
||||
() => {
|
||||
const fn = options.onSubmitRef?.current;
|
||||
if (!fn) return false; // no submit wired — let default Enter insert newline
|
||||
fn();
|
||||
return true;
|
||||
},
|
||||
{ submitOnEnter: options.submitOnEnter ?? false },
|
||||
),
|
||||
createBlurShortcutExtension(),
|
||||
createFileUploadExtension(options.onUploadFileRef!),
|
||||
];
|
||||
|
||||
if (editable) {
|
||||
extensions.push(
|
||||
Typography,
|
||||
Placeholder.configure({ placeholder: placeholderText }),
|
||||
createMarkdownPasteExtension(),
|
||||
createSubmitExtension(
|
||||
() => {
|
||||
const fn = options.onSubmitRef?.current;
|
||||
if (!fn) return false; // no submit wired — let default Enter insert newline
|
||||
fn();
|
||||
return true;
|
||||
},
|
||||
{ submitOnEnter: options.submitOnEnter ?? false },
|
||||
),
|
||||
createBlurShortcutExtension(),
|
||||
createFileUploadExtension(options.onUploadFileRef!),
|
||||
);
|
||||
}
|
||||
|
||||
return extensions;
|
||||
}
|
||||
|
||||
62
packages/views/editor/extensions/markdown-copy.ts
Normal file
62
packages/views/editor/extensions/markdown-copy.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Markdown copy extension — make the clipboard's text/plain channel carry
|
||||
* Markdown source instead of plain textContent.
|
||||
*
|
||||
* Symmetric to markdown-paste.ts:
|
||||
* paste: text/plain → editor.markdown.parse → doc
|
||||
* copy: slice → editor.markdown.serialize → text/plain
|
||||
*
|
||||
* Why: ProseMirror's default clipboardTextSerializer calls Slice.textBetween,
|
||||
* which flattens every node to its inner text. Headings, lists, code blocks,
|
||||
* mentions, file cards — all lose their Markdown markers. Pasting into VS
|
||||
* Code, terminals, or messaging apps then sees only naked text.
|
||||
*
|
||||
* The text/html channel is left at ProseMirror's default so pasting back
|
||||
* into another ProseMirror editor still preserves exact node structure via
|
||||
* data-pm-slice.
|
||||
*/
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import type { Slice } from "@tiptap/pm/model";
|
||||
|
||||
// Blob URLs (blob:http://…) are process-local; never let them leave the page.
|
||||
const BLOB_IMAGE_RE = /!\[[^\]]*\]\(blob:[^)]*\)\n?/g;
|
||||
|
||||
export function createMarkdownCopyExtension() {
|
||||
return Extension.create({
|
||||
name: "markdownCopy",
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
|
||||
const fallback = (slice: Slice) =>
|
||||
slice.content.textBetween(0, slice.content.size, "\n\n");
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("markdownCopy"),
|
||||
props: {
|
||||
clipboardTextSerializer(slice: Slice) {
|
||||
if (!editor.markdown) return fallback(slice);
|
||||
try {
|
||||
// Wrap slice content in a temp doc so the serializer walks
|
||||
// it like a real document. Inline-only slices auto-wrap
|
||||
// into doc → paragraph; block slices pass through.
|
||||
const doc = editor.schema.topNodeType.create(
|
||||
null,
|
||||
slice.content,
|
||||
);
|
||||
const md = editor.markdown.serialize(doc.toJSON());
|
||||
return md.replace(BLOB_IMAGE_RE, "").replace(/\n+$/, "");
|
||||
} catch {
|
||||
// Special selections (e.g. table cellSelection) may fail
|
||||
// schema validation when wrapped in a doc node. Fall back
|
||||
// so copy never breaks.
|
||||
return fallback(slice);
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
84
packages/views/editor/extensions/mention-extension.test.ts
Normal file
84
packages/views/editor/extensions/mention-extension.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { BaseMentionExtension } from "./mention-extension";
|
||||
|
||||
const tokenizer = BaseMentionExtension.config.markdownTokenizer!;
|
||||
|
||||
// The tiptap MarkdownTokenizer/renderMarkdown types have broad signatures
|
||||
// (multi-arg overloads). Our extension always provides single-argument
|
||||
// implementations, so cast for test convenience.
|
||||
const startFn = tokenizer.start as (src: string) => number;
|
||||
const tokenizeFn = tokenizer.tokenize as (
|
||||
src: string,
|
||||
) => { type: string; raw: string; attributes: Record<string, string> } | undefined;
|
||||
const renderMarkdown = BaseMentionExtension.config.renderMarkdown as (
|
||||
node: { attrs: Record<string, string> },
|
||||
) => string;
|
||||
|
||||
function tokenize(src: string) {
|
||||
const start = startFn(src);
|
||||
if (start === -1) return undefined;
|
||||
return tokenizeFn(src.slice(start));
|
||||
}
|
||||
|
||||
describe("mention tokenizer", () => {
|
||||
it("parses a plain mention", () => {
|
||||
const token = tokenize("[@Alice](mention://member/aaa-bbb)");
|
||||
expect(token).toBeDefined();
|
||||
expect(token!.attributes.label).toBe("Alice");
|
||||
expect(token!.attributes.type).toBe("member");
|
||||
expect(token!.attributes.id).toBe("aaa-bbb");
|
||||
});
|
||||
|
||||
it("parses a mention with escaped brackets (round-trip from renderMarkdown)", () => {
|
||||
// renderMarkdown escapes brackets: David[TF] → David\[TF\]
|
||||
const md = renderMarkdown({
|
||||
attrs: { id: "aaa-bbb", label: "David[TF]", type: "agent" },
|
||||
});
|
||||
expect(md).toBe("[@David\\[TF\\]](mention://agent/aaa-bbb)");
|
||||
|
||||
const token = tokenize(md);
|
||||
expect(token).toBeDefined();
|
||||
expect(token!.attributes.label).toBe("David[TF]");
|
||||
expect(token!.attributes.type).toBe("agent");
|
||||
});
|
||||
|
||||
it("does not match an ordinary Markdown link before a mention", () => {
|
||||
const src =
|
||||
"Check [docs](https://example.com) - [@User](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)";
|
||||
|
||||
// start() must NOT land on the [docs] link at index 6
|
||||
const start = startFn(src);
|
||||
expect(start).toBeGreaterThan(6);
|
||||
|
||||
// tokenize from the correct start position
|
||||
const token = tokenizeFn(src.slice(start));
|
||||
expect(token).toBeDefined();
|
||||
expect(token!.attributes.label).toBe("User");
|
||||
expect(token!.attributes.id).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
});
|
||||
|
||||
it("handles multiple ordinary links before a mention", () => {
|
||||
const src =
|
||||
"See [a](https://a.com) and [b](https://b.com) - [@Bot](mention://agent/abc-123)";
|
||||
const start = startFn(src);
|
||||
const token = tokenizeFn(src.slice(start));
|
||||
expect(token).toBeDefined();
|
||||
expect(token!.attributes.label).toBe("Bot");
|
||||
});
|
||||
|
||||
it("round-trips an agent label with nested brackets", () => {
|
||||
const md = renderMarkdown({
|
||||
attrs: { id: "x-y-z", label: "Bot[v2][beta]", type: "agent" },
|
||||
});
|
||||
const token = tokenize(md);
|
||||
expect(token).toBeDefined();
|
||||
expect(token!.attributes.label).toBe("Bot[v2][beta]");
|
||||
});
|
||||
|
||||
it("parses issue mentions without @ prefix", () => {
|
||||
const token = tokenize("[MUL-123](mention://issue/aaa-bbb)");
|
||||
expect(token).toBeDefined();
|
||||
expect(token!.attributes.label).toBe("MUL-123");
|
||||
expect(token!.attributes.type).toBe("issue");
|
||||
});
|
||||
});
|
||||
@@ -39,17 +39,25 @@ export const BaseMentionExtension = Mention.extend({
|
||||
name: "mention",
|
||||
level: "inline" as const,
|
||||
start(src: string) {
|
||||
return src.search(/\[@?[^\]]+\]\(mention:\/\//);
|
||||
// Accept escaped brackets (\\[ \\]) and non-] chars in the label.
|
||||
// This prevents matching ordinary Markdown links like [docs](url)
|
||||
// that appear before a mention on the same line.
|
||||
return src.search(/\[@?(?:\\.|[^\]])+\]\(mention:\/\//);
|
||||
},
|
||||
tokenize(src: string) {
|
||||
// Label accepts escaped chars (\\[ \\]) or any non-] character.
|
||||
// This prevents the label from crossing a ]( Markdown link boundary
|
||||
// while still supporting bracket-containing names like "David\[TF\]".
|
||||
const match = src.match(
|
||||
/^\[@?([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
|
||||
/^\[@?((?:\\.|[^\]])+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
|
||||
);
|
||||
if (!match) return undefined;
|
||||
// Unescape backslash-escaped brackets that renderMarkdown may produce.
|
||||
const rawLabel = match[1]?.replace(/\\\[/g, "[").replace(/\\\]/g, "]");
|
||||
return {
|
||||
type: "mention",
|
||||
raw: match[0],
|
||||
attributes: { label: match[1], type: match[2] ?? "member", id: match[3] },
|
||||
attributes: { label: rawLabel, type: match[2] ?? "member", id: match[3] },
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -59,6 +67,9 @@ export const BaseMentionExtension = Mention.extend({
|
||||
renderMarkdown: (node: any) => {
|
||||
const { id, label, type = "member" } = node.attrs || {};
|
||||
const prefix = type === "issue" ? "" : "@";
|
||||
return `[${prefix}${label ?? id}](mention://${type}/${id})`;
|
||||
// Escape square brackets in the label so the markdown link syntax
|
||||
// is not broken when the name contains [ or ] (e.g. "David[TF]").
|
||||
const safeLabel = (label ?? id).replace(/\[/g, "\\[").replace(/\]/g, "\\]");
|
||||
return `[${prefix}${safeLabel}](mention://${type}/${id})`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,6 +21,13 @@ vi.mock("@multica/core/api", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the auth store: items() reads `useAuthStore.getState()` imperatively
|
||||
// to identify the current user when filtering personal agents.
|
||||
const authState = { user: { id: "u1" } as { id: string } | null };
|
||||
vi.mock("@multica/core/auth", () => ({
|
||||
useAuthStore: { getState: () => authState },
|
||||
}));
|
||||
|
||||
import {
|
||||
createMentionSuggestion,
|
||||
MentionList,
|
||||
@@ -29,8 +36,14 @@ import {
|
||||
} from "./mention-suggestion";
|
||||
|
||||
function fakeQc(data: {
|
||||
members?: Array<{ user_id: string; name: string }>;
|
||||
agents?: Array<{ id: string; name: string; archived_at: string | null }>;
|
||||
members?: Array<{ user_id: string; name: string; role?: string }>;
|
||||
agents?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
archived_at: string | null;
|
||||
visibility?: "workspace" | "private";
|
||||
owner_id?: string | null;
|
||||
}>;
|
||||
issues?: Array<{ id: string; identifier: string; title: string; status: string }>;
|
||||
}): QueryClient {
|
||||
const map = new Map<string, unknown>();
|
||||
@@ -57,8 +70,16 @@ describe("createMentionSuggestion", () => {
|
||||
|
||||
it("returns members and agents synchronously without waiting for the server search", () => {
|
||||
const qc = fakeQc({
|
||||
members: [{ user_id: "u1", name: "Alice" }],
|
||||
agents: [{ id: "a1", name: "Aegis", archived_at: null }],
|
||||
members: [{ user_id: "u1", name: "Alice", role: "member" }],
|
||||
agents: [
|
||||
{
|
||||
id: "a1",
|
||||
name: "Aegis",
|
||||
archived_at: null,
|
||||
visibility: "workspace",
|
||||
owner_id: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
// A pending fetch — would block the result if items() awaited it.
|
||||
searchIssuesMock.mockReturnValue(new Promise(() => {}));
|
||||
@@ -119,6 +140,78 @@ describe("createMentionSuggestion", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides personal agents owned by someone else from a regular member", () => {
|
||||
const qc = fakeQc({
|
||||
members: [
|
||||
{ user_id: "u1", name: "Alice", role: "member" },
|
||||
{ user_id: "u2", name: "Bob", role: "member" },
|
||||
],
|
||||
agents: [
|
||||
// Bob's personal agent — Alice (current user) should not see it.
|
||||
{
|
||||
id: "a-personal-bob",
|
||||
name: "Atlas",
|
||||
archived_at: null,
|
||||
visibility: "private",
|
||||
owner_id: "u2",
|
||||
},
|
||||
// Alice's own personal agent — should be visible.
|
||||
{
|
||||
id: "a-personal-alice",
|
||||
name: "Athena",
|
||||
archived_at: null,
|
||||
visibility: "private",
|
||||
owner_id: "u1",
|
||||
},
|
||||
// Workspace agent — visible to everyone.
|
||||
{
|
||||
id: "a-shared",
|
||||
name: "Aether",
|
||||
archived_at: null,
|
||||
visibility: "workspace",
|
||||
owner_id: "u2",
|
||||
},
|
||||
],
|
||||
});
|
||||
searchIssuesMock.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const config = createMentionSuggestion(qc);
|
||||
const result = config.items!({ query: "a", editor: {} as never });
|
||||
const items = result as MentionItem[];
|
||||
|
||||
expect(items.some((i) => i.type === "agent" && i.label === "Athena")).toBe(true);
|
||||
expect(items.some((i) => i.type === "agent" && i.label === "Aether")).toBe(true);
|
||||
expect(items.some((i) => i.type === "agent" && i.label === "Atlas")).toBe(false);
|
||||
});
|
||||
|
||||
it("shows everyone's personal agents to a workspace admin", () => {
|
||||
// Role lives in the member fixture, not in authState — promoting Alice
|
||||
// to admin here is enough to flip the gate. Backend gate allows admins
|
||||
// to assign anyone's personal agent, so the @mention list mirrors that.
|
||||
const qc = fakeQc({
|
||||
members: [
|
||||
{ user_id: "u1", name: "Alice", role: "admin" },
|
||||
{ user_id: "u2", name: "Bob", role: "member" },
|
||||
],
|
||||
agents: [
|
||||
{
|
||||
id: "a-personal-bob",
|
||||
name: "Atlas",
|
||||
archived_at: null,
|
||||
visibility: "private",
|
||||
owner_id: "u2",
|
||||
},
|
||||
],
|
||||
});
|
||||
searchIssuesMock.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const config = createMentionSuggestion(qc);
|
||||
const result = config.items!({ query: "a", editor: {} as never });
|
||||
const items = result as MentionItem[];
|
||||
|
||||
expect(items.some((i) => i.type === "agent" && i.label === "Atlas")).toBe(true);
|
||||
});
|
||||
|
||||
it("includes cached issues in the synchronous response", () => {
|
||||
const qc = fakeQc({
|
||||
issues: [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user