Compare commits

..

2 Commits

Author SHA1 Message Date
Jiang Bohan
20515e5582 feat(quick-create): gate on daemon CLI version with pre-check + server enforcement
The agent-create flow depends on multica CLI behavior introduced in
v0.2.20 (URL attachment handling, no-retry semantics on
`multica issue create` failure — see PR #1851 / MUL-1496). Older
daemons either double-create issues on partial CLI failures or
mishandle pasted screenshot URLs. Per J's review on MUL-1496, gate
the flow at two layers — frontend pre-check for fast feedback,
server re-check as the trust boundary, both fail-closed on
missing/unparsable versions.

Server:
- New MinQuickCreateCLIVersion + CheckMinCLIVersion helper in
  pkg/agent (with sentinel errors for missing vs too-old).
- QuickCreateIssue handler reads runtime metadata.cli_version and
  returns a stable 422 { code: "daemon_version_unsupported",
  current_version, min_version, runtime_id } before enqueuing.
- The check runs after the existing online + ownership validation,
  so all rejections surface uniformly through the modal's existing
  error path.

Frontend:
- New @multica/core/runtimes/cli-version with the min version
  constant, parser, and runtime-metadata reader (tiny semver, no
  new lib dep).
- AgentCreatePanel resolves the selected agent's runtime, runs the
  same check, shows an inline amber notice below the agent picker
  when missing/too old, and disables the Create button.
- Submit handler also catches the server's 422 (defensive race —
  runtime can re-register between pre-check and submit) and
  surfaces the same wording in the error row.

Switching to manual create remains a clean escape hatch — manual
mode doesn't talk to a daemon at all, so an outdated CLI doesn't
block the user from filing the issue.
2026-04-29 18:31:01 +08:00
Jiang Bohan
4ed5f3ce50 fix(quick-create): bound dialog height + scroll editor when content overflows
Pasting a screenshot into the agent-create prompt expanded the editor
unbounded, which dragged DialogContent past the viewport since the agent
mode className had no max-height. Manual mode was unaffected because
manualDialogContentClass pins `!h-96`.

- Cap agent-mode DialogContent at `!max-h-[80vh]` (width stays
  `!max-w-xl`); short prompts still render compact, tall content stops
  at 80% of the viewport.
- Switch the editor wrapper to `flex-1 min-h-[140px] overflow-y-auto`
  so it absorbs the remaining vertical space inside the now-bounded
  DialogContent and scrolls internally instead of pushing the dialog.
2026-04-29 16:40:16 +08:00
282 changed files with 1489 additions and 18038 deletions

View File

@@ -70,10 +70,10 @@ Opens your browser for OAuth authentication, creates a 90-day personal access to
### Token Login
```bash
multica login --token <mul_...>
multica login --token
```
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).
Authenticate by pasting a personal access token directly. Useful for headless environments.
### Check Status
@@ -140,7 +140,6 @@ 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 |
@@ -175,22 +174,6 @@ 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:
@@ -198,12 +181,8 @@ 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 |
@@ -221,8 +200,6 @@ 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:

View File

@@ -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 <mul_...>` (use `--token=` with an empty value to be prompted interactively).
- 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 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`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
**If daemon fails to start:**
- Check logs: `multica daemon logs`
- If a port conflict occurs, the daemon may already be running under a different profile.
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `copilot`, `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`, `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`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
3. At least one workspace is being watched
If the agents list is empty, tell the user:
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `copilot`, `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`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
---

View File

@@ -30,24 +30,12 @@ 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**, **GitHub Copilot CLI**, **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**, **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.
@@ -110,7 +98,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`, `copilot`, `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`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`) on your PATH.
### 2. Verify your runtime
@@ -120,7 +108,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, 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.
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.
### 4. Assign your first task
@@ -172,9 +160,10 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
┌──────┴───────┐
│ Agent Daemon │ runs on your machine
└──────────────┘ (Claude Code, Codex, GitHub Copilot CLI,
OpenCode, OpenClaw, Hermes, Gemini,
Pi, Cursor Agent, Kimi, Kiro CLI)
└──────────────┘ (Claude Code, Codex, OpenCode,
OpenClaw, Hermes, Gemini,
Pi, Cursor Agent, Kimi,
Kiro CLI)
```
| Layer | Stack |
@@ -182,7 +171,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, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI |
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI |
## Development

View File

@@ -30,24 +30,12 @@
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**GitHub Copilot CLI**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi****Cursor Agent**、**Kimi** 和 **Kiro CLI**
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi****Cursor Agent**
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
</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 生命周期:从任务分配到执行监控再到技能复用。
@@ -111,7 +99,7 @@ multica setup # 连接 Multica Cloud登录启动 daemon
multica setup # 配置、认证、启动 daemon一条命令搞定
```
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``copilot``openclaw``opencode``hermes``gemini``pi``cursor-agent``kimi``kiro-cli`)。
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``openclaw``opencode``hermes``gemini``pi``cursor-agent`)。
### 2. 确认运行时已连接
@@ -121,7 +109,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
### 3. 创建 Agent
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、PiCursor Agent、Kimi 或 Kiro CLI),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、OpenClaw、OpenCode、Hermes、Gemini、PiCursor Agent并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
### 4. 分配你的第一个任务
@@ -154,9 +142,9 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
┌──────┴───────┐
│ Agent Daemon │ 运行在你的机器上
└──────────────┘ Claude Code、Codex、GitHub Copilot CLI
OpenCode、OpenClaw、Hermes、Gemini、
Pi、Cursor Agent、Kimi、Kiro CLI
└──────────────┘ Claude Code、Codex、OpenCode
OpenClaw、Hermes、Gemini、
Pi、Cursor Agent
```
| 层级 | 技术栈 |
@@ -164,7 +152,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
| 前端 | Next.js 16 (App Router) |
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
| 数据库 | PostgreSQL 17 with pgvector |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、PiCursor Agent、Kimi 或 Kiro CLI |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClaw、OpenCode、Hermes、Gemini、PiCursor Agent |
## 开发

View File

@@ -92,7 +92,6 @@ 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)

View File

@@ -103,8 +103,6 @@ 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 |

View File

@@ -1,33 +0,0 @@
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();
}
}

View File

@@ -7,7 +7,6 @@ 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
@@ -111,22 +110,6 @@ 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"]) {
@@ -220,7 +203,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: getAppVersion(), os };
event.returnValue = { version: app.getVersion(), os };
});
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen

View File

@@ -110,58 +110,21 @@ function AppContent() {
: undefined;
useDaemonIPCBridge(activeWsId);
// 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.
// 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.
useEffect(() => {
if (!user || !workspaceListFetched) return undefined;
if (!user || !workspaceListFetched) return;
const { overlay, open } = useWindowOverlayStore.getState();
if (overlay) return undefined;
if (wsCount > 0) return undefined;
if (overlay) return;
if (!hasOnboarded) {
// 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;
};
open({ type: "onboarding" });
return;
}
open({ type: "new-workspace" });
return undefined;
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded, qc]);
if (wsCount === 0) {
open({ type: "new-workspace" });
}
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded]);
// Validate persisted tab state against the current user's workspace list,
// and pick an active workspace if none is set. Runs in useLayoutEffect

View File

@@ -1,243 +0,0 @@
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();
});
});

View File

@@ -1,17 +1,11 @@
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { capturePageview } from "@multica/core/analytics";
import { useAuthStore } from "@multica/core/auth";
import {
getActiveTab,
useActiveTabIdentity,
useTabStore,
} from "@/stores/tab-store";
import { 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,
* EXCEPT for re-activations of an already-known tab on its already-known
* path.
* Fires a PostHog $pageview whenever the user's visible surface changes.
*
* Desktop has three layers that can own the visible page:
*
@@ -23,18 +17,10 @@ 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`.
*
* 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.
* 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.
*
* PostHog's `capture_pageview: true` auto-capture is intentionally off (see
* `initAnalytics`) so this component owns the event shape, matching the web
@@ -43,75 +29,34 @@ import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overl
export function PageviewTracker() {
const user = useAuthStore((s) => s.user);
const overlay = useWindowOverlayStore((s) => s.overlay);
const { slug: activeWorkspaceSlug, tabId: activeTabId } = useActiveTabIdentity();
const activeTabPath = useTabStore((s) => getActiveTab(s)?.path ?? null);
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;
});
// (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 });
const path = resolvePath(user, overlay, activeTabPath);
useEffect(() => {
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;
if (!path) return;
capturePageview(path);
lastSurfaceRef.current = next;
}, [user, overlay, activeWorkspaceSlug, activeTabId, activeTabPath]);
}, [path]);
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":
@@ -120,7 +65,5 @@ function overlayPath(overlay: WindowOverlay): string {
return "/onboarding";
case "invite":
return `/invite/${overlay.invitationId}`;
case "invitations":
return "/invitations";
}
}

View File

@@ -5,13 +5,12 @@ import { Button } from "@multica/ui/components/ui/button";
type CheckState =
| { status: "idle" }
| { status: "checking" }
| { status: "up-to-date" }
| { status: "up-to-date"; currentVersion: string }
| { 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" });
@@ -23,7 +22,7 @@ export function UpdatesSettingsTab() {
setState(
result.available
? { status: "available", latestVersion: result.latestVersion }
: { status: "up-to-date" },
: { status: "up-to-date", currentVersion: result.currentVersion },
);
}, []);
@@ -36,15 +35,6 @@ 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>
@@ -55,7 +45,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&apos;re on the latest version.
You&apos;re on the latest version (v{state.currentVersion}).
</p>
)}
{state.status === "available" && (

View File

@@ -1,7 +1,6 @@
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";
@@ -59,7 +58,6 @@ function WindowOverlayInner() {
onBack={onBack}
/>
)}
{overlay.type === "invitations" && <InvitationsPage />}
{overlay.type === "onboarding" && (
<OnboardingFlow
onComplete={(ws) => {

View File

@@ -61,13 +61,6 @@ 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 {

View File

@@ -15,7 +15,6 @@ import { create } from "zustand";
export type WindowOverlay =
| { type: "new-workspace" }
| { type: "invite"; invitationId: string }
| { type: "invitations" }
| { type: "onboarding" };
interface WindowOverlayStore {

View File

@@ -1,38 +1 @@
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,
});
}

View File

@@ -1,14 +1,8 @@
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"],

View File

@@ -18,10 +18,10 @@ Opens your browser for OAuth authentication, creates a 90-day personal access to
### Token Login
```bash
multica login --token <mul_...>
multica login --token
```
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).
Authenticate by pasting a personal access token directly. Useful for headless environments.
### Check Status

View File

@@ -10,7 +10,6 @@
"members-roles",
"issues",
"comments",
"project-resources",
"---Agents---",
"agents",
"agents-create",

View File

@@ -1,144 +0,0 @@
---
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.

View File

@@ -1,28 +0,0 @@
"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 />;
}

View File

@@ -2,7 +2,7 @@
import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useQueryClient } 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,32 +27,6 @@ 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();
@@ -98,28 +72,33 @@ function LoginPageContent() {
});
return;
}
if (!hasOnboarded) {
router.replace(paths.onboarding());
return;
}
if (nextUrl) {
router.replace(nextUrl);
return;
}
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
void resolveLoggedInDestination(qc, hasOnboarded, list).then((dest) =>
router.replace(dest),
);
router.replace(resolvePostAuthDestination(list, hasOnboarded));
}, [isLoading, user, router, nextUrl, cliCallbackRaw, isDesktopHandoff, hasOnboarded, qc]);
const handleSuccess = async () => {
const handleSuccess = () => {
// 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()) ?? [];
const dest = await resolveLoggedInDestination(qc, onboarded, list);
router.push(dest);
router.push(resolvePostAuthDestination(list, onboarded));
};
// Build Google OAuth state: encode platform + next URL so the callback

View File

@@ -32,7 +32,7 @@ export default function OnboardingPage() {
const hasOnboarded = useHasOnboarded();
const { data: workspaces = [], isFetched: workspacesFetched } = useQuery({
...workspaceListOptions(),
enabled: !!user,
enabled: !!user && hasOnboarded,
});
useEffect(() => {
@@ -40,15 +40,7 @@ export default function OnboardingPage() {
if (!isLoading && !user) router.replace(paths.login());
return;
}
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 45
// (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) {
if (hasOnboarded && workspacesFetched) {
router.replace(resolvePostAuthDestination(workspaces, hasOnboarded));
}
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, router]);

View File

@@ -2,21 +2,13 @@ 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,
mockListMyInvitations,
mockSetQueryData,
} = vi.hoisted(() => ({
mockPush: vi.fn(),
mockSearchParams: new URLSearchParams(),
mockLoginWithGoogle: vi.fn(),
mockListWorkspaces: vi.fn(),
mockListMyInvitations: vi.fn(),
mockSetQueryData: vi.fn(),
}));
const { mockPush, mockSearchParams, mockLoginWithGoogle, mockListWorkspaces } =
vi.hoisted(() => ({
mockPush: vi.fn(),
mockSearchParams: new URLSearchParams(),
mockLoginWithGoogle: vi.fn(),
mockListWorkspaces: vi.fn(),
}));
const makeUser = (overrides: Partial<{ onboarded_at: string | null }> = {}) => ({
id: "user-1",
@@ -36,7 +28,7 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ setQueryData: mockSetQueryData }),
useQueryClient: () => ({ setQueryData: vi.fn() }),
}));
// Preserve the real sanitizeNextUrl so the "drop unsafe ?next=" behavior is
@@ -54,16 +46,12 @@ vi.mock("@multica/core/auth", async () => {
});
vi.mock("@multica/core/workspace/queries", () => ({
workspaceKeys: {
list: () => ["workspaces"],
myInvitations: () => ["invitations", "mine"],
},
workspaceKeys: { list: () => ["workspaces"] },
}));
vi.mock("@multica/core/api", () => ({
api: {
listWorkspaces: mockListWorkspaces,
listMyInvitations: mockListMyInvitations,
googleLogin: vi.fn(),
},
}));
@@ -73,78 +61,26 @@ import CallbackPage from "./page";
describe("CallbackPage", () => {
beforeEach(() => {
vi.clearAllMocks();
// 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.forEach((_v, k) => mockSearchParams.delete(k));
mockSearchParams.set("code", "test-code");
mockLoginWithGoogle.mockResolvedValue(makeUser());
mockListWorkspaces.mockResolvedValue([]);
mockListMyInvitations.mockResolvedValue([]);
});
it("unonboarded user honors a safe next= (e.g. /invite/{id}) so invitees aren't trapped", async () => {
it("unonboarded user lands on /onboarding regardless of next=", async () => {
mockSearchParams.set("state", "next:/invite/abc123");
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
});
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
// nextUrl is a fast path — listMyInvitations should not be queried.
expect(mockListMyInvitations).not.toHaveBeenCalled();
});
it("unonboarded user with no next= and no pending invitations lands on /onboarding", async () => {
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
});
expect(mockListMyInvitations).toHaveBeenCalled();
expect(mockPush).not.toHaveBeenCalledWith("/invite/abc123");
});
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",
},
]);
it("unonboarded user with no next= also lands on /onboarding", async () => {
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.invitations());
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
});
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 () => {
@@ -173,12 +109,4 @@ 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());
});
});
});

View File

@@ -66,42 +66,13 @@ function CallbackContent() {
const wsList = await api.listWorkspaces();
qc.setQueryData(workspaceKeys.list(), wsList);
const onboarded = loggedInUser.onboarded_at != null;
// 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);
if (!onboarded) {
router.push(paths.onboarding());
return;
}
// 2. Un-onboarded users may have pending invitations on their
// email even when no `next=` was carried (came from a fresh
// login on app.multica.ai instead of clicking the email link,
// or `state` was lost across the round-trip). Look them up by
// email and route to the batch /invitations page if any.
// Already-onboarded users skip this lookup — their new invites
// surface in the sidebar dropdown, not as a forced wall.
if (!onboarded) {
try {
const invites = await api.listMyInvitations();
if (invites.length > 0) {
qc.setQueryData(workspaceKeys.myInvitations(), invites);
router.push(paths.invitations());
return;
}
} catch {
// Network blip on the invite lookup is non-fatal — fall through
// to the normal post-auth destination so the user isn't stuck
// on a blank callback screen. Worst case they land on
// /onboarding and the sidebar will surface invites later.
}
}
// 3. Default: hand off to the resolver (onboarding for first-timers,
// first workspace for returning users, /workspaces/new for
// onboarded users with zero workspaces).
router.push(resolvePostAuthDestination(wsList, onboarded));
router.push(
nextUrl || resolvePostAuthDestination(wsList, onboarded),
);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");

View File

@@ -1,61 +0,0 @@
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&rsquo;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>
);
}

View File

@@ -283,81 +283,6 @@ 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",

View File

@@ -283,81 +283,6 @@ 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` 输入一句话并选 AgentIssue 异步创建,结果回执送达 Inbox",
"Agent Presence v3 —— 可用性与最近任务拆成两条更清晰的信号Issue 详情右侧新增 Execution Log可看到当前 active run 与历史 run",
"Daemon ↔ Server 心跳改走 WebSocketHTTP 自动 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",

View File

@@ -5,5 +5,3 @@ 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";

View File

@@ -1,60 +0,0 @@
"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";
}

View File

@@ -1,31 +0,0 @@
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];
}

View File

@@ -55,9 +55,6 @@ import type {
CreateProjectRequest,
UpdateProjectRequest,
ListProjectsResponse,
ProjectResource,
CreateProjectResourceRequest,
ListProjectResourcesResponse,
Label,
CreateLabelRequest,
UpdateLabelRequest,
@@ -78,8 +75,6 @@ import type {
ListAutopilotsResponse,
GetAutopilotResponse,
ListAutopilotRunsResponse,
NotificationPreferenceResponse,
NotificationPreferences,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import { type Logger, noopLogger } from "../logger";
@@ -788,18 +783,6 @@ 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;
@@ -1077,32 +1060,6 @@ 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`);

View File

@@ -16,14 +16,6 @@ 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);
@@ -51,7 +43,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 = 380;
export const CHAT_DEFAULT_W = 420;
export const CHAT_DEFAULT_H = 600;
/**
@@ -126,14 +118,8 @@ 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: initialIsOpen,
isOpen: false,
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
showHistory: false,
@@ -144,13 +130,11 @@ 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) => {

View File

@@ -1,41 +0,0 @@
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());

View File

@@ -1,2 +1 @@
export * from "./mutations";
export { useFeedbackDraftStore } from "./draft-store";

View File

@@ -15,8 +15,6 @@ 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>()(
@@ -24,8 +22,6 @@ export const useQuickCreateStore = create<QuickCreateState>()(
(set) => ({
lastAgentId: null,
setLastAgentId: (id) => set({ lastAgentId: id }),
keepOpen: false,
setKeepOpen: (v) => set({ keepOpen: v }),
}),
{
name: "multica_quick_create",

View File

@@ -1,2 +0,0 @@
export * from "./queries";
export * from "./mutations";

View File

@@ -1,34 +0,0 @@
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) });
},
});
}

View File

@@ -1,13 +0,0 @@
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(),
});
}

View File

@@ -16,8 +16,7 @@ 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
| "invite_accept"; // Accepted at least one invite from /invitations
| "skip_existing"; // "I've done this before" from Welcome
export type TeamSize = "solo" | "team" | "other";

View File

@@ -35,9 +35,6 @@
"./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",
@@ -49,8 +46,6 @@
"./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",

View File

@@ -43,7 +43,6 @@ export const paths = {
login: () => "/login",
newWorkspace: () => "/workspaces/new",
invite: (id: string) => `/invite/${encode(id)}`,
invitations: () => "/invitations",
onboarding: () => "/onboarding",
authCallback: () => "/auth/callback",
root: () => "/",
@@ -55,7 +54,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/", "/invitations", "/onboarding", "/auth/", "/logout", "/signup"];
const GLOBAL_PREFIXES = ["/login", "/workspaces/", "/invite/", "/onboarding", "/auth/", "/logout", "/signup"];
export function isGlobalPath(path: string): boolean {
return GLOBAL_PREFIXES.some((p) => path === p || path.startsWith(p));

View File

@@ -20,7 +20,6 @@ export const RESERVED_SLUGS = new Set([
"oauth",
"callback",
"invite",
"invitations",
"verify",
"reset",
"password",

View File

@@ -19,16 +19,14 @@ function makeWs(slug: string): Workspace {
}
describe("resolvePostAuthDestination", () => {
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.
it("not onboarded → /onboarding regardless of workspaces", () => {
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", () => {

View File

@@ -7,18 +7,6 @@ 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[],
@@ -28,10 +16,7 @@ export function resolvePostAuthDestination(
return paths.onboarding();
}
const first = workspaces[0];
if (first) {
return paths.workspace(first.slug).issues();
}
return paths.newWorkspace();
return first ? paths.workspace(first.slug).issues() : paths.newWorkspace();
}
/**

View File

@@ -1,20 +0,0 @@
/**
* 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";

View File

@@ -1,329 +0,0 @@
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);
});
});

View File

@@ -1,210 +0,0 @@
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;
}

View File

@@ -1,52 +0,0 @@
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 };
}

View File

@@ -1,32 +0,0 @@
"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,
};
}

View File

@@ -1,65 +0,0 @@
"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),
};
}

View File

@@ -1,54 +0,0 @@
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());

View File

@@ -1,9 +1,2 @@
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";

View File

@@ -1,87 +0,0 @@
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),
});
},
});
}

View File

@@ -51,13 +51,9 @@ import type {
SubscriberAddedPayload,
SubscriberRemovedPayload,
TaskMessagePayload,
TaskQueuedPayload,
TaskDispatchPayload,
TaskCompletedPayload,
TaskFailedPayload,
TaskCancelledPayload,
ChatDonePayload,
ChatPendingTask,
InvitationCreatedPayload,
} from "../types";
@@ -529,64 +525,6 @@ 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
@@ -607,14 +545,8 @@ export function useRealtimeSync(
task_id: payload.task_id,
chat_session_id: payload.chat_session_id,
});
// 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.
// No new message; just flip the pending signal.
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();
});
@@ -652,9 +584,6 @@ export function useRealtimeSync(
unsubTaskMessage();
unsubChatMessage();
unsubChatDone();
unsubTaskQueued();
unsubTaskDispatch();
unsubTaskCancelled();
unsubTaskCompleted();
unsubTaskFailed();
unsubChatSessionRead();

View File

@@ -28,48 +28,18 @@ 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.
* 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".
* Both fields are absent when the session has no in-flight task.
*/
export interface ChatPendingTask {
task_id?: string;
status?: string;
created_at?: string;
}

View File

@@ -196,22 +196,6 @@ 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;

View File

@@ -38,7 +38,6 @@ 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";
@@ -48,19 +47,7 @@ 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,
ProjectResource,
ProjectResourceType,
GithubRepoResourceRef,
CreateProjectResourceRequest,
ListProjectResourcesResponse,
} from "./project";
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
export type {
Autopilot,

View File

@@ -1,15 +0,0 @@
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;
}

View File

@@ -26,9 +26,6 @@ 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 {
@@ -45,39 +42,3 @@ 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;
}

View File

@@ -2,6 +2,7 @@ export type MemberRole = "owner" | "admin" | "member";
export interface WorkspaceRepo {
url: string;
description: string;
}
export interface Workspace {

View File

@@ -1,90 +0,0 @@
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
}
}

View File

@@ -36,8 +36,6 @@ 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,

View File

@@ -1,47 +0,0 @@
"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>
);
}

View File

@@ -2,11 +2,10 @@
import {
flexRender,
type Header as TanstackHeader,
type Row,
type Table as TanstackTable,
} from "@tanstack/react-table";
import * as React from "react";
import type * as React from "react";
// We deliberately use the lower-level shadcn primitives (TableHeader /
// TableBody / TableRow / TableHead / TableCell) but NOT the wrapping
@@ -49,8 +48,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 until the user
// resizes them. Once resized, the explicit width is applied.
// fixed table-layout assigns them the leftover space (no spacer
// column needed).
// - 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
@@ -65,98 +64,6 @@ 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)}
@@ -172,13 +79,6 @@ 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}
@@ -198,13 +98,10 @@ export function DataTable<TData>({
// into the header strip rather than appearing as
// a white block under sticky scroll.
className={cn(
"relative h-8 overflow-hidden px-4 py-2 text-xs uppercase tracking-wider text-muted-foreground",
"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,
hasExplicitSize: columnHasExplicitSize,
})}
style={getCellStyle(header.column, { withBorder: true })}
>
{header.isPlaceholder
? null
@@ -212,33 +109,6 @@ 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>
);
})}
@@ -265,9 +135,6 @@ export function DataTable<TData>({
>
{row.getVisibleCells().map((cell) => {
const isPinned = cell.column.getIsPinned();
const columnHasExplicitSize = hasExplicitSize(
cell.column.id,
);
return (
<TableCell
key={cell.id}
@@ -284,10 +151,7 @@ export function DataTable<TData>({
isPinned &&
"bg-background group-hover:bg-muted/50",
)}
style={getCellStyle(cell.column, {
withBorder: true,
hasExplicitSize: columnHasExplicitSize,
})}
style={getCellStyle(cell.column, { withBorder: true })}
>
{flexRender(
cell.column.columnDef.cell,

View File

@@ -4,9 +4,10 @@ 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 skips
// the inline width for these columns until the user explicitly resizes them,
// then the resized width wins.
// `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).
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
grow?: boolean;
@@ -24,10 +25,10 @@ declare module "@tanstack/react-table" {
// `group-hover:`.
export function getCellStyle<TData>(
column: Column<TData>,
options?: { withBorder?: boolean; hasExplicitSize?: boolean },
options?: { withBorder?: boolean },
): React.CSSProperties {
const grow = column.columnDef.meta?.grow;
const width = grow && !options?.hasExplicitSize ? undefined : column.getSize();
const width = grow ? undefined : column.columnDef.size;
const isPinned = column.getIsPinned();
if (!isPinned) {

View File

@@ -48,7 +48,6 @@
"sonner": "^2.0.7",
"tailwind-merge": "catalog:",
"tw-animate-css": "^1.4.0",
"unicode-animations": "catalog:",
"vaul": "^1.1.2"
},
"peerDependencies": {

View File

@@ -83,37 +83,6 @@
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);
@@ -149,14 +118,4 @@
* 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;
}
}
}

View File

@@ -7,7 +7,6 @@ import {
type AgentActivity,
type AgentPresenceDetail,
summarizeActivityWindow,
VISIBILITY_TOOLTIP,
} from "@multica/core/agents";
import {
Tooltip,
@@ -31,8 +30,6 @@ 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;
}
@@ -41,17 +38,18 @@ 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 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 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 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.
// 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.)
const COL_WIDTHS = {
agent: 240,
status: 120,
@@ -104,7 +102,6 @@ export function createAgentColumns({
id: "runtime",
header: "Runtime",
size: COL_WIDTHS.runtime,
meta: { grow: true },
cell: ({ row }) => <RuntimeCell row={row.original} />,
},
{
@@ -129,7 +126,6 @@ export function createAgentColumns({
id: "actions",
header: () => null,
size: COL_WIDTHS.actions,
enableResizing: false,
cell: ({ row }) => (
<div
className="flex justify-end"
@@ -154,7 +150,7 @@ export function createAgentColumns({
// ---------------------------------------------------------------------------
function AgentNameCell({ row }: { row: AgentRow }) {
const { agent, ownerIdToShow, isOwnedByMe } = row;
const { agent, ownerIdToShow } = row;
const isArchived = !!agent.archived_at;
const isPrivate = agent.visibility === "private";
@@ -184,15 +180,10 @@ function AgentNameCell({ row }: { row: AgentRow }) {
}
/>
<TooltipContent>
{VISIBILITY_TOOLTIP.private}
Private only the owner can assign work
</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"

View File

@@ -55,15 +55,6 @@ 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>;
}
@@ -86,7 +77,6 @@ export function AgentDetailInspector({
runtimes,
members,
currentUserId,
canEdit,
onUpdate,
}: InspectorProps) {
const update = (data: Record<string, unknown>) => onUpdate(agent.id, data);
@@ -96,18 +86,16 @@ 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} canEdit={canEdit} onUpdate={update} />
<NameAndDescription
agent={agent}
canEdit={canEdit}
onUpdate={update}
/>
<AvatarEditor agent={agent} onUpdate={update} />
<NameAndDescription agent={agent} onUpdate={update} />
<PresenceBadge presence={presence} />
</div>
{/* 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. */}
{/* 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. */}
<Section label="Properties">
<PropRow label="Runtime" interactive={false}>
<RuntimePicker
@@ -115,7 +103,6 @@ export function AgentDetailInspector({
runtimes={runtimes}
members={members}
currentUserId={currentUserId}
canEdit={canEdit}
onChange={(id) => update({ runtime_id: id })}
/>
</PropRow>
@@ -124,21 +111,18 @@ 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>
@@ -189,7 +173,7 @@ export function AgentDetailInspector({
{s.name}
</span>
))}
<SkillAttach agent={agent} canEdit={canEdit} />
<SkillAttach agent={agent} />
</div>
</div>
</aside>
@@ -208,13 +192,11 @@ function Section({
children: ReactNode;
}) {
return (
<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">
<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">
{label}
</div>
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5">
{children}
</div>
{children}
</div>
);
}
@@ -225,29 +207,14 @@ 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;
@@ -300,32 +267,11 @@ 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

View File

@@ -24,9 +24,7 @@ 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,
@@ -76,12 +74,6 @@ 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>) => {
@@ -171,36 +163,23 @@ 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>
{canEdit.allowed && (
<Button
variant="outline"
size="sm"
className="h-6 text-xs"
onClick={() => handleRestore(agent.id)}
>
Restore
</Button>
)}
<Button
variant="outline"
size="sm"
className="h-6 text-xs"
onClick={() => handleRestore(agent.id)}
>
Restore
</Button>
</div>
)}
@@ -213,7 +192,6 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
runtimes={runtimes}
members={members}
currentUserId={currentUser?.id ?? null}
canEdit={canEdit.allowed}
onUpdate={handleUpdate}
/>
@@ -276,13 +254,11 @@ function DetailHeader({
agent,
presence,
backHref,
canArchive,
onArchive,
}: {
agent: Agent;
presence: AgentPresenceDetail | null;
backHref: string;
canArchive: boolean;
onArchive: () => void;
}) {
const isArchived = !!agent.archived_at;
@@ -314,7 +290,7 @@ function DetailHeader({
)}
</div>
{!isArchived && canArchive && (
{!isArchived && (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="icon-sm" />}

View File

@@ -16,7 +16,6 @@ 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;
@@ -82,7 +81,6 @@ 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

View File

@@ -22,7 +22,6 @@ 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,
@@ -144,42 +143,27 @@ export function AgentsPage() {
[agents, view],
);
// 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
// Layer 1b — ownership scope. Counts shown on the segment are
// computed against the inView 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 visibleInView) {
for (const a of inView) {
if (a.owner_id === currentUser.id) mine += 1;
}
}
return { all: visibleInView.length, mine };
}, [visibleInView, currentUser]);
return { all: inView.length, mine };
}, [inView, 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 visibleInView;
if (scope === "all" || !currentUser) return visibleInView;
return visibleInView.filter((a) => a.owner_id === currentUser.id);
}, [visibleInView, scope, currentUser, view]);
if (view === "archived") return inView;
if (scope === "all" || !currentUser) return inView;
return inView.filter((a) => a.owner_id === currentUser.id);
}, [inView, scope, currentUser, view]);
// Final cut — availability chip + search.
const filteredAgents = useMemo(() => {
@@ -280,7 +264,6 @@ 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
@@ -292,21 +275,14 @@ 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.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];
});
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
setShowCreate(false);
setDuplicateTemplate(null);
navigation.push(paths.agentDetail(agent.id));
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
};
const handleDuplicate = useCallback((agent: Agent) => {
@@ -335,7 +311,6 @@ export function AgentsPage() {
activity: activityMap.get(agent.id) ?? null,
runCount: runCountsById.get(agent.id) ?? 0,
ownerIdToShow,
isOwnedByMe: isOwner,
canManage,
};
});
@@ -359,7 +334,6 @@ 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"] } },

View File

@@ -29,11 +29,7 @@ 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,
VISIBILITY_DESCRIPTION,
VISIBILITY_LABEL,
} from "@multica/core/agents";
import { AGENT_DESCRIPTION_MAX_LENGTH } from "@multica/core/agents";
import { CharCounter } from "./char-counter";
type RuntimeFilter = "mine" | "all";
@@ -206,10 +202,8 @@ export function CreateAgentDialog({
>
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">{VISIBILITY_LABEL.workspace}</div>
<div className="text-xs text-muted-foreground">
{VISIBILITY_DESCRIPTION.workspace}
</div>
<div className="font-medium">Workspace</div>
<div className="text-xs text-muted-foreground">All members can assign</div>
</div>
</button>
<button
@@ -223,10 +217,8 @@ export function CreateAgentDialog({
>
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">{VISIBILITY_LABEL.private}</div>
<div className="text-xs text-muted-foreground">
{VISIBILITY_DESCRIPTION.private}
</div>
<div className="font-medium">Private</div>
<div className="text-xs text-muted-foreground">Only you can assign</div>
</div>
</button>
</div>

View File

@@ -11,25 +11,14 @@ 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.

View File

@@ -26,14 +26,11 @@ 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);
@@ -44,12 +41,13 @@ 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 needlessly.
// reference `?? []` would mint a fresh array on every render and
// invalidate filters / defaultModel 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();
@@ -80,20 +78,11 @@ export function ModelPicker({
);
}
const triggerLabel = value || "Default";
const triggerLabel =
value ||
(defaultModel ? `Default — ${defaultModel.label}` : "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}

View File

@@ -24,15 +24,12 @@ 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);
@@ -40,25 +37,6 @@ 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

View File

@@ -17,14 +17,7 @@ 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,
canEdit = true,
}: {
agent: Agent;
/** When false, hide the attach trigger entirely. */
canEdit?: boolean;
}) {
export function SkillAttach({ agent }: { agent: Agent }) {
const wsId = useWorkspaceId();
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
const [open, setOpen] = useState(false);
@@ -34,7 +27,7 @@ export function SkillAttach({
(s) => !agentSkillIds.has(s.id),
).length;
if (!canEdit || availableCount === 0) return null;
if (availableCount === 0) return null;
return (
<>

View File

@@ -2,38 +2,27 @@
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 = VISIBILITY_LABEL[value];
const tooltip = `Visibility · ${VISIBILITY_TOOLTIP[value]}`;
const label = value === "private" ? "Private" : "Workspace";
const tooltip =
value === "private"
? "Visibility · Private — only you can assign"
: "Visibility · Workspace — all members can assign";
const select = async (next: AgentVisibility) => {
setOpen(false);
@@ -63,9 +52,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">{VISIBILITY_LABEL.workspace}</div>
<div className="font-medium">Workspace</div>
<div className="text-xs text-muted-foreground">
{VISIBILITY_DESCRIPTION.workspace}
All members can assign
</div>
</div>
</PickerItem>
@@ -75,9 +64,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">{VISIBILITY_LABEL.private}</div>
<div className="font-medium">Private</div>
<div className="text-xs text-muted-foreground">
{VISIBILITY_DESCRIPTION.private}
Only you can assign
</div>
</div>
</PickerItem>

View File

@@ -42,6 +42,7 @@ 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
@@ -85,7 +86,9 @@ export function ModelDropdown({
(disabled
? "Select a runtime first"
: runtimeOnline
? "Default (provider)"
? defaultModel
? `Default — ${defaultModel.label}`
: "Default (provider)"
: "Runtime offline — enter manually");
if (!supported && !modelsQuery.isLoading) {

View File

@@ -1,49 +0,0 @@
"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>
);
}

View File

@@ -44,9 +44,7 @@ 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 {
@@ -65,34 +63,11 @@ const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: ty
failed: { label: "Failed", color: "text-destructive", icon: XCircle },
};
function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: string; agentName: string }) {
function RunRow({ run }: { run: AutopilotRun }) {
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")} />
@@ -108,14 +83,6 @@ function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: strin
<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"
/>
)}
</>
);
@@ -471,7 +438,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
) : (
<div className="rounded-md border overflow-hidden">
{runs.map((run) => (
<RunRow key={run.id} run={run} agentId={autopilot.assignee_id} agentName={getActorName("agent", autopilot.assignee_id)} />
<RunRow key={run.id} run={run} />
))}
</div>
)}

View File

@@ -130,40 +130,38 @@ function AutopilotRow({ autopilot }: { autopilot: Autopilot }) {
const StatusIcon = statusCfg.icon;
return (
<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">
<div className="group/row flex h-11 items-center gap-2 px-5 text-sm transition-colors hover:bg-accent/40">
<AppLink
href={wsPaths.autopilotDetail(autopilot.id)}
className="flex min-w-0 items-center gap-2 sm:flex-1"
className="flex min-w-0 flex-1 items-center gap-2"
>
<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>
<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>
{/* 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)}
</span>
</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>
{/* Mode */}
<span className="w-24 shrink-0 text-center text-xs text-muted-foreground">
{EXECUTION_MODE_LABELS[autopilot.execution_mode] ?? autopilot.execution_mode}
</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>
{/* 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>
{/* 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>
{/* 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>
</div>
);
}
@@ -200,7 +198,7 @@ export function AutopilotsPage() {
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<>
<div className="sticky top-0 z-[1] hidden h-8 items-center gap-2 border-b bg-muted/30 px-5 sm:flex">
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5">
<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" />
@@ -208,9 +206,9 @@ export function AutopilotsPage() {
<Skeleton className="h-3 w-10 shrink-0" />
<Skeleton className="h-3 w-12 shrink-0" />
</div>
<div className="space-y-2 p-4 sm:space-y-1 sm:p-5 sm:pt-1">
<div className="p-5 pt-1 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-[72px] w-full sm:h-11" />
<Skeleton key={i} className="h-11 w-full" />
))}
</div>
</>
@@ -248,7 +246,7 @@ export function AutopilotsPage() {
) : (
<>
{/* Column headers */}
<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">
<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">
<span className="shrink-0 w-4" />
<span className="min-w-0 flex-1">Name</span>
<span className="w-32 shrink-0">Agent</span>

View File

@@ -2,7 +2,6 @@
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";
@@ -15,10 +14,6 @@ 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. */
@@ -35,7 +30,6 @@ export function ChatInput({
onStop,
isRunning,
disabled,
noAgent,
agentName,
leftAdornment,
rightAdornment,
@@ -60,12 +54,11 @@ export function ChatInput({
const handleSend = () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || isRunning || disabled || noAgent) {
if (!content || isRunning || disabled) {
logger.debug("input.send skipped", {
emptyContent: !content,
isRunning,
disabled,
noAgent,
});
return;
}
@@ -76,50 +69,19 @@ 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 = noAgent
? "Create an agent to start chatting"
: disabled
? "This session is archived"
: agentName
? `Tell ${agentName} what to do…`
: "Tell me what to do…";
const placeholder = disabled
? "This session is archived"
: agentName
? `Tell ${agentName} what to do…`
: "Tell me what to do…";
return (
<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}
>
<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">
{topSlot}
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
<ContentEditor
@@ -151,7 +113,7 @@ export function ChatInput({
{rightAdornment}
<SubmitButton
onClick={handleSend}
disabled={isEmpty || !!disabled || !!noAgent}
disabled={isEmpty || !!disabled}
running={isRunning}
onStop={onStop}
/>

View File

@@ -9,46 +9,36 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@multica/ui/components/ui/collapsible";
import { ChevronRight, ChevronDown, Brain, AlertCircle, AlertTriangle } from "lucide-react";
import { Loader2, ChevronRight, ChevronDown, Brain, AlertCircle } 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 { AgentAvailability } from "@multica/core/agents";
import type { ChatMessage, ChatPendingTask, TaskMessagePayload, TaskFailureReason } from "@multica/core/types";
import type { ChatMessage, TaskMessagePayload } 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[];
/**
* 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;
/** When set, streams the live timeline for this task from task-messages cache. */
pendingTaskId: string | null;
isWaiting: boolean;
}
export function ChatMessageList({
messages,
pendingTask,
availability,
pendingTaskId,
isWaiting,
}: 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 (and pill) to avoid rendering the same content in two places
// during the invalidate → refetch window.
// timeline 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,
);
@@ -62,7 +52,6 @@ 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">
@@ -79,12 +68,8 @@ export function ChatMessageList({
<TimelineView items={liveTimeline} />
</div>
)}
{showStatusPill && pendingTask && (
<TaskStatusPill
pendingTask={pendingTask}
taskMessages={liveTaskMessages ?? []}
availability={availability}
/>
{isWaiting && !hasLive && !pendingAlreadyPersisted && (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
)}
</div>
</div>
@@ -168,21 +153,6 @@ 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 ? (
@@ -192,86 +162,6 @@ 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>
);
}

View File

@@ -2,7 +2,8 @@
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Minus, Maximize2, Minimize2, ChevronDown, Plus, Check } from "lucide-react";
import { Minus, Maximize2, Minimize2, ChevronDown, Bot, Plus, Check } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import {
@@ -19,16 +20,11 @@ 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";
@@ -44,7 +40,7 @@ import {
import { ChatResizeHandles } from "./chat-resize-handles";
import { useChatResize } from "./use-chat-resize";
import { createLogger } from "@multica/core/logger";
import type { Agent, ChatMessage, ChatPendingTask, ChatSession } from "@multica/core/types";
import type { Agent, ChatMessage, ChatSession } from "@multica/core/types";
const uiLogger = createLogger("chat.ui");
const apiLogger = createLogger("chat.api");
@@ -104,22 +100,6 @@ 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(() => {
@@ -139,11 +119,28 @@ export function ChatWindow() {
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per mount
}, []);
// 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).
// 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]);
// WS events are handled globally in useRealtimeSync — the query cache
// stays current even when this window is closed. See packages/core/realtime/.
@@ -200,34 +197,19 @@ export function ChatWindow() {
setActiveSession(sessionId);
}
// 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();
// Optimistic: show user message immediately.
const optimistic: ChatMessage = {
id: `optimistic-${Date.now()}`,
chat_session_id: sessionId,
role: "user",
content: finalContent,
task_id: null,
created_at: sentAt,
created_at: new Date().toISOString(),
};
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, ~50200ms,
// 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);
@@ -236,13 +218,11 @@ export function ChatWindow() {
messageId: result.message_id,
taskId: result.task_id,
});
// 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), {
// Seed pending-task optimistically so the spinner shows instantly —
// the WS chat:message handler will invalidate + refetch to confirm.
qc.setQueryData(chatKeys.pendingTask(sessionId), {
task_id: result.task_id,
status: "queued",
created_at: result.created_at,
});
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
},
@@ -256,30 +236,24 @@ export function ChatWindow() {
],
);
const handleStop = useCallback(() => {
if (!pendingTaskId || !activeSessionId) {
const handleStop = useCallback(async () => {
if (!pendingTaskId) {
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 });
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,
}),
);
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) });
}
}, [pendingTaskId, activeSessionId, qc]);
const handleSelectAgent = useCallback(
@@ -428,40 +402,22 @@ export function ChatWindow() {
) : hasMessages ? (
<ChatMessageList
messages={messages}
pendingTask={pendingTask}
availability={availability}
pendingTaskId={pendingTaskId}
isWaiting={!!pendingTaskId}
/>
) : (
<EmptyState
hasSessions={sessions.length > 0}
agentName={activeAgent?.name}
onPickPrompt={(text) => handleSend(text)}
/>
)}
{/* 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). */}
{/* Input — disabled for archived sessions */}
<ChatInput
onSend={handleSend}
onStop={handleStop}
isRunning={!!pendingTaskId}
disabled={isSessionArchived}
noAgent={noAgent}
agentName={activeAgent?.name}
topSlot={<ContextAnchorCard />}
leftAdornment={
@@ -513,13 +469,7 @@ 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">
<ActorAvatar
actorType="agent"
actorId={activeAgent.id}
size={24}
enableHoverCard
showStatusDot
/>
<AgentAvatarSmall agent={activeAgent} />
<span className="text-xs font-medium max-w-28 truncate">{activeAgent.name}</span>
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
</DropdownMenuTrigger>
@@ -570,13 +520,7 @@ function AgentMenuItem({
onClick={() => onSelect(agent)}
className="flex min-w-0 items-center gap-2"
>
<ActorAvatar
actorType="agent"
actorId={agent.id}
size={24}
enableHoverCard
showStatusDot
/>
<AgentAvatarSmall agent={agent} />
<span className="truncate flex-1">{agent.name}</span>
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
</DropdownMenuItem>
@@ -601,60 +545,16 @@ 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 && (
<ActorAvatar
actorType="agent"
actorId={triggerAgent.id}
size={24}
enableHoverCard
showStatusDot
/>
)}
{triggerAgent && <AgentAvatarSmall agent={triggerAgent} />}
<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">
@@ -666,7 +566,6 @@ 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}
@@ -674,38 +573,16 @@ function SessionDropdown({
className="flex min-w-0 items-center gap-2"
>
{agent ? (
<ActorAvatar
actorType="agent"
actorId={agent.id}
size={24}
enableHoverCard
showStatusDot
/>
<AgentAvatarSmall agent={agent} />
) : (
<span className="size-6 shrink-0" />
)}
<span className="truncate flex-1 text-sm">
{session.title?.trim() || "New chat"}
</span>
{/* 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}
{session.has_unread && (
<span className="size-1.5 shrink-0 rounded-full bg-brand" />
)}
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
</DropdownMenuItem>
);
@@ -716,6 +593,17 @@ 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
@@ -728,42 +616,12 @@ 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">

View File

@@ -1,29 +0,0 @@
"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>
);
}

View File

@@ -1,54 +0,0 @@
"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}&apos;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&apos;re back.
</span>
</div>
</div>
);
}

View File

@@ -1,154 +0,0 @@
"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>
);
}

View File

@@ -1,19 +0,0 @@
/**
* 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)));
}

View File

@@ -1,21 +1,8 @@
import type { ReactNode } from "react";
/**
* 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.
* Two-column property row used in detail-page sidebars: a fixed-width muted
* label on the left and a flexible value on the right.
*
* `interactive` (default `true`) controls whether the row gets a hover
* highlight. Most rows wrap a Picker/Popover trigger and are clickable
@@ -27,6 +14,10 @@ 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,
@@ -39,12 +30,14 @@ export function PropRow({
}) {
return (
<div
className={`-mx-2 col-span-2 grid min-h-8 grid-cols-subgrid items-center rounded-md px-2 ${
className={`-mx-2 flex min-h-8 items-center gap-2 rounded-md px-2 ${
interactive ? "transition-colors hover:bg-accent/50" : ""
}`}
>
<span className="text-xs text-muted-foreground">{label}</span>
<div className="flex min-w-0 items-center gap-1.5 truncate text-xs">
<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">
{children}
</div>
</div>

View File

@@ -200,91 +200,6 @@
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,
@@ -614,3 +529,4 @@
max-width: min(360px, calc(100vw - 2rem));
white-space: nowrap;
}

View File

@@ -1,19 +1,14 @@
"use client";
/**
* ContentEditor — the rich-text editor used wherever the user TYPES content.
* ContentEditor — the single rich-text editor for the entire application.
*
* Architecture decisions (April 2026 refactor):
*
* 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.
* 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.
*
* 2. ONE MARKDOWN PIPELINE via @tiptap/markdown. Content is loaded with
* `contentType: 'markdown'` and saved with `editor.getMarkdown()`.
@@ -71,6 +66,7 @@ interface ContentEditorProps {
defaultValue?: string;
onUpdate?: (markdown: string) => void;
placeholder?: string;
editable?: boolean;
className?: string;
debounceMs?: number;
onSubmit?: () => void;
@@ -98,10 +94,6 @@ 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;
@@ -117,6 +109,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
defaultValue = "",
onUpdate,
placeholder: placeholderText = "",
editable = true,
className,
debounceMs = 300,
onSubmit,
@@ -134,6 +127,7 @@ 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
@@ -156,12 +150,14 @@ 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,
@@ -199,7 +195,11 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
},
},
attributes: {
class: cn("rich-text-editor text-sm outline-none", className),
class: cn(
"rich-text-editor text-sm outline-none",
!editable && "readonly",
className,
),
},
},
});
@@ -211,6 +211,20 @@ 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: () => {
@@ -219,9 +233,6 @@ 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;
@@ -244,7 +255,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
const hover = useLinkHover(wrapperRef, hoverDisabled);
const handleContainerMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
if (!editor) return;
if (!editable || !editor) return;
const target = event.target as HTMLElement;
if (target.closest(".ProseMirror")) return;
@@ -263,7 +274,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
onMouseDown={handleContainerMouseDown}
>
<EditorContent className="flex-1 min-h-full" editor={editor} />
{showBubbleMenu && (
{editable && showBubbleMenu && (
<EditorBubbleMenu editor={editor} currentIssueId={currentIssueId} />
)}
<LinkHoverCard {...hover} />

View File

@@ -39,7 +39,6 @@ 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";
@@ -49,13 +48,18 @@ import { BlockMathExtension, InlineMathExtension } from "./math";
const lowlight = createLowlight(common);
const LinkExtension = Link.extend({ inclusive: false }).configure({
const LinkEditable = 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 {
@@ -77,6 +81,7 @@ const ImageExtension = Image.extend({
});
export interface EditorExtensionsOptions {
editable: boolean;
placeholder?: string;
queryClient?: import("@tanstack/react-query").QueryClient;
onSubmitRef?: RefObject<(() => void) | undefined>;
@@ -98,9 +103,9 @@ export interface EditorExtensionsOptions {
export function createEditorExtensions(
options: EditorExtensionsOptions,
): AnyExtension[] {
const { placeholder: placeholderText } = options;
const { editable, placeholder: placeholderText } = options;
return [
const extensions: AnyExtension[] = [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: false,
@@ -114,7 +119,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.
LinkExtension,
editable ? LinkEditable : LinkReadonly,
ImageExtension,
Table.configure({ resizable: false }),
TableRow,
@@ -124,33 +129,37 @@ 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" },
...(options.queryClient
...(editable && 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;
}

View File

@@ -1,62 +0,0 @@
/**
* 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);
}
},
},
}),
];
},
});
}

View File

@@ -1,84 +0,0 @@
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");
});
});

View File

@@ -39,25 +39,17 @@ export const BaseMentionExtension = Mention.extend({
name: "mention",
level: "inline" as const,
start(src: string) {
// 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:\/\//);
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: rawLabel, type: match[2] ?? "member", id: match[3] },
attributes: { label: match[1], type: match[2] ?? "member", id: match[3] },
};
},
},
@@ -67,9 +59,6 @@ export const BaseMentionExtension = Mention.extend({
renderMarkdown: (node: any) => {
const { id, label, type = "member" } = node.attrs || {};
const prefix = type === "issue" ? "" : "@";
// 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})`;
return `[${prefix}${label ?? id}](mention://${type}/${id})`;
},
});

View File

@@ -21,13 +21,6 @@ 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,
@@ -36,14 +29,8 @@ import {
} from "./mention-suggestion";
function fakeQc(data: {
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;
}>;
members?: Array<{ user_id: string; name: string }>;
agents?: Array<{ id: string; name: string; archived_at: string | null }>;
issues?: Array<{ id: string; identifier: string; title: string; status: string }>;
}): QueryClient {
const map = new Map<string, unknown>();
@@ -70,16 +57,8 @@ describe("createMentionSuggestion", () => {
it("returns members and agents synchronously without waiting for the server search", () => {
const qc = fakeQc({
members: [{ user_id: "u1", name: "Alice", role: "member" }],
agents: [
{
id: "a1",
name: "Aegis",
archived_at: null,
visibility: "workspace",
owner_id: null,
},
],
members: [{ user_id: "u1", name: "Alice" }],
agents: [{ id: "a1", name: "Aegis", archived_at: null }],
});
// A pending fetch — would block the result if items() awaited it.
searchIssuesMock.mockReturnValue(new Promise(() => {}));
@@ -140,78 +119,6 @@ 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: [

View File

@@ -15,8 +15,6 @@ import type { QueryClient } from "@tanstack/react-query";
import { getCurrentWsId } from "@multica/core/platform";
import { flattenIssueBuckets, issueKeys } from "@multica/core/issues/queries";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { useAuthStore } from "@multica/core/auth";
import { canAssignAgentToIssue } from "@multica/core/permissions";
import { api } from "@multica/core/api";
import type {
Issue,
@@ -365,15 +363,6 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
const cachedResponse = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const cachedIssues: Issue[] = cachedResponse ? flattenIssueBuckets(cachedResponse) : [];
// Read current user identity imperatively — this factory runs outside
// React render so we can't useAuthStore() as a hook here. The Proxy in
// packages/core/auth/index.ts forwards `.getState()` to the registered
// store. Used to gate personal agents in the @mention list so members
// don't see (or auto-complete) agents they couldn't assign anyway.
const userId = useAuthStore.getState().user?.id ?? null;
const myRole =
members.find((m) => m.user_id === userId)?.role ?? null;
const q = query.toLowerCase();
const allItem: MentionItem[] =
@@ -390,12 +379,7 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
}));
const agentItems: MentionItem[] = agents
.filter(
(a) =>
!a.archived_at &&
a.name.toLowerCase().includes(q) &&
canAssignAgentToIssue(a, { userId, role: myRole }).allowed,
)
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
// Members and agents share a single ranked list — recently mentioned

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
vi.mock("@multica/core/paths", () => ({
useWorkspacePaths: () => ({
@@ -32,48 +32,8 @@ vi.mock("./utils/link-handler", () => ({
isMentionHref: (href?: string) => Boolean(href?.startsWith("mention://")),
}));
vi.mock("mermaid", () => ({
default: {
initialize: vi.fn(),
render: vi.fn().mockResolvedValue({
svg: '<svg viewBox="0 0 123 45"><g><text>mock diagram</text></g></svg>',
}),
},
}));
Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
value: () => ({
fillStyle: "#000",
fillRect: vi.fn(),
getImageData: () => ({ data: new Uint8ClampedArray([12, 34, 56, 255]) }),
}),
});
import mermaid from "mermaid";
import { ReadonlyContent } from "./readonly-content";
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("ReadonlyContent memoization", () => {
// Long-timeline issues (Inbox + IssueDetail with thousands of comments)
// freeze the tab when each comment re-runs the full react-markdown pipeline
// on every parent re-render. Wrapping the component in React.memo is the
// mitigation; this test guards against a future revert that would silently
// reintroduce the perf regression.
it("is wrapped in React.memo", () => {
const memoTypeSymbol = Symbol.for("react.memo");
expect((ReadonlyContent as unknown as { $$typeof: symbol }).$$typeof).toBe(
memoTypeSymbol,
);
});
});
describe("ReadonlyContent math rendering", () => {
it("renders inline and block LaTeX with KaTeX markup", () => {
const { container } = render(
@@ -112,77 +72,3 @@ describe("ReadonlyContent line breaks", () => {
expect(container.querySelectorAll("p").length).toBeGreaterThanOrEqual(2);
});
});
describe("ReadonlyContent Mermaid rendering", () => {
it("renders mermaid code fences in a sized sandbox iframe with legacy rgb colors", async () => {
const originalGetComputedStyle = window.getComputedStyle;
vi.spyOn(window, "getComputedStyle").mockImplementation((element, pseudoElt) => {
if (element instanceof HTMLElement && element.style.color.startsWith("var(")) {
return { color: "oklch(60% 0.2 120)" } as CSSStyleDeclaration;
}
return originalGetComputedStyle.call(window, element, pseudoElt);
});
const { container } = render(
<ReadonlyContent
content={["```mermaid", "graph LR", " A[Start] --> B[Done]", "```"].join("\n")}
/>,
);
expect(container.querySelector(".mermaid-diagram")).not.toBeNull();
expect(container.querySelector("pre code.language-mermaid")).toBeNull();
await waitFor(() => {
const iframe = container.querySelector<HTMLIFrameElement>(".mermaid-diagram-frame");
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute("sandbox")).toBe("");
expect(iframe?.srcdoc).toContain("mock diagram");
expect(iframe?.style.width).toBe("123px");
expect(iframe?.style.height).toBe("45px");
});
expect(mermaid.initialize).toHaveBeenCalledWith(
expect.objectContaining({
themeVariables: expect.objectContaining({
lineColor: "rgb(12, 34, 56)",
primaryBorderColor: "rgb(12, 34, 56)",
primaryColor: "rgb(12, 34, 56)",
primaryTextColor: "rgb(12, 34, 56)",
}),
}),
);
});
it("opens a fullscreen lightbox when the toolbar button is clicked", async () => {
const { container } = render(
<ReadonlyContent
content={["```mermaid", "graph LR", " A[Start] --> B[Done]", "```"].join("\n")}
/>,
);
const button = await waitFor(() => {
const found = container.querySelector<HTMLButtonElement>(
".mermaid-diagram-toolbar button",
);
expect(found).not.toBeNull();
return found!;
});
expect(document.querySelector(".mermaid-diagram-lightbox")).toBeNull();
fireEvent.click(button);
const lightboxFrame = document.querySelector<HTMLIFrameElement>(
".mermaid-diagram-lightbox-frame",
);
expect(lightboxFrame).not.toBeNull();
expect(lightboxFrame?.getAttribute("sandbox")).toBe("");
expect(lightboxFrame?.srcdoc).toContain("mock diagram");
expect(lightboxFrame?.srcdoc).toContain("max-height: 100%");
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(document.querySelector(".mermaid-diagram-lightbox")).toBeNull();
});
});
});

Some files were not shown because too many files have changed in this diff Show More