Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan
ee7a440465 feat(modals): persist drafts for create-project and feedback modals
Add Zustand persisted draft stores for the create-project and feedback
modals, following the same pattern as the existing issue draft store.
Drafts are saved to localStorage on every field change and restored
when the modal reopens, preventing accidental data loss on close.
Draft is cleared on successful submit.
2026-04-29 17:53:46 +02:00
228 changed files with 1218 additions and 14317 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:
@@ -202,8 +185,6 @@ Agent-specific overrides:
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_CODEX_ARGS` | Default extra arguments for Codex runs |
| `MULTICA_COPILOT_PATH` | Custom path to the `copilot` binary |
| `MULTICA_COPILOT_MODEL` | Override the Copilot model used (note: GitHub Copilot routes models through your account entitlement, so this may not be honoured) |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |

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

@@ -111,22 +111,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"]) {

View File

@@ -110,58 +110,22 @@ 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.
// Workspace presence wins over onboarding state: a user invited into an
// existing workspace must enter that workspace, not be trapped in the
// onboarding overlay just because their personal `onboarded_at` is null.
// Onboarding is only the right destination when the account has zero
// workspaces AND has never onboarded.
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 (wsCount > 0) 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" });
} else {
open({ type: "new-workspace" });
}
open({ type: "new-workspace" });
return undefined;
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded, qc]);
}, [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

@@ -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,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();
@@ -103,12 +77,10 @@ function LoginPageContent() {
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;
@@ -118,8 +90,7 @@ function LoginPageContent() {
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

@@ -34,6 +34,7 @@ export default function OnboardingPage() {
...workspaceListOptions(),
enabled: !!user,
});
const hasWorkspaces = workspaces.length > 0;
useEffect(() => {
if (isLoading || !user) {
@@ -41,19 +42,15 @@ export default function OnboardingPage() {
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) {
// Bounce out if onboarding doesn't apply: either already onboarded, or
// the user already has a workspace (e.g. arrived via invitation) — we
// never trap an in-workspace user on the onboarding screen.
if (hasOnboarded || hasWorkspaces) {
router.replace(resolvePostAuthDestination(workspaces, hasOnboarded));
}
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, router]);
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, hasWorkspaces, router]);
if (isLoading || !user || hasOnboarded) return null;
if (isLoading || !user || hasOnboarded || hasWorkspaces) return null;
// Layout: page owns its own scroll (root layout sets `body {
// overflow: hidden }` for the app-shell convention). OnboardingFlow

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(),
},
}));
@@ -81,7 +69,6 @@ describe("CallbackPage", () => {
mockSearchParams.set("code", "test-code");
mockLoginWithGoogle.mockResolvedValue(makeUser());
mockListWorkspaces.mockResolvedValue([]);
mockListMyInvitations.mockResolvedValue([]);
});
it("unonboarded user honors a safe next= (e.g. /invite/{id}) so invitees aren't trapped", async () => {
@@ -91,39 +78,16 @@ describe("CallbackPage", () => {
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
});
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
// nextUrl is a fast path — listMyInvitations should not be queried.
expect(mockListMyInvitations).not.toHaveBeenCalled();
});
it("unonboarded user with no next= and no pending invitations lands on /onboarding", async () => {
it("unonboarded user with no next= and zero workspaces lands on /onboarding", async () => {
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
});
expect(mockListMyInvitations).toHaveBeenCalled();
});
it("unonboarded user with pending invitations lands on /invitations", async () => {
mockListMyInvitations.mockResolvedValue([
{
id: "inv-1",
workspace_id: "ws-1",
workspace_name: "Acme",
role: "member",
status: "pending",
},
]);
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.invitations());
});
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
});
it("onboarded user with workspace lands in that workspace", async () => {
mockLoginWithGoogle.mockResolvedValue(
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
);
it("unonboarded user with existing workspace lands in that workspace, not /onboarding", async () => {
mockListWorkspaces.mockResolvedValue([
{
id: "ws-1",
@@ -142,9 +106,7 @@ describe("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();
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
});
it("onboarded user ignores unsafe next= targets and lands on the default destination", async () => {
@@ -173,12 +135,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);
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));
// Workspace presence beats onboarding state: an invitee with zero
// `onboarded_at` but a real workspace must land in that workspace,
// not in the new-workspace wizard. A `next=` (e.g. /invite/<id>)
// always wins so invite acceptance flows survive auth round-trips.
router.push(
nextUrl || resolvePostAuthDestination(wsList, onboarded),
);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");

View File

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

View File

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

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

@@ -51,7 +51,7 @@ function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string
export const CHAT_MIN_W = 360;
export const CHAT_MIN_H = 480;
export const CHAT_DEFAULT_W = 380;
export const CHAT_DEFAULT_W = 420;
export const CHAT_DEFAULT_H = 600;
/**

View File

@@ -1,26 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useQuickCreateStore } from "./quick-create-store";
const RESET_STATE = {
lastAgentId: null,
prompt: "",
keepOpen: false,
};
describe("quick create store", () => {
beforeEach(() => {
useQuickCreateStore.setState(RESET_STATE);
});
it("persists the agent prompt draft until explicitly cleared", () => {
const { setPrompt, clearPrompt } = useQuickCreateStore.getState();
setPrompt("Investigate the inbox loading regression");
expect(useQuickCreateStore.getState().prompt).toBe(
"Investigate the inbox loading regression",
);
clearPrompt();
expect(useQuickCreateStore.getState().prompt).toBe("");
});
});

View File

@@ -15,9 +15,6 @@ import { defaultStorage } from "../../platform/storage";
interface QuickCreateState {
lastAgentId: string | null;
setLastAgentId: (id: string | null) => void;
prompt: string;
setPrompt: (prompt: string) => void;
clearPrompt: () => void;
keepOpen: boolean;
setKeepOpen: (v: boolean) => void;
}
@@ -27,9 +24,6 @@ export const useQuickCreateStore = create<QuickCreateState>()(
(set) => ({
lastAgentId: null,
setLastAgentId: (id) => set({ lastAgentId: id }),
prompt: "",
setPrompt: (prompt) => set({ prompt }),
clearPrompt: () => set({ prompt: "" }),
keepOpen: false,
setKeepOpen: (v) => set({ keepOpen: v }),
}),

View File

@@ -1,95 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { onIssueLabelsChanged } from "./ws-updaters";
import { issueKeys } from "./queries";
import { labelKeys } from "../labels/queries";
import type {
Issue,
IssueLabelsResponse,
Label,
ListIssuesCache,
} from "../types";
const WS_ID = "ws-1";
const ISSUE_ID = "issue-1";
const labelA: Label = {
id: "label-a",
workspace_id: WS_ID,
name: "bug",
color: "#ef4444",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
const labelB: Label = {
id: "label-b",
workspace_id: WS_ID,
name: "feature",
color: "#22c55e",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
const baseIssue: Issue = {
id: ISSUE_ID,
workspace_id: WS_ID,
number: 1,
identifier: "MUL-1",
title: "Test",
description: null,
status: "todo",
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: 0,
due_date: null,
labels: [labelA],
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
describe("onIssueLabelsChanged", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient();
});
it("patches the per-issue label cache when present (LabelPicker source)", () => {
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(WS_ID, ISSUE_ID), {
labels: [labelA],
});
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
expect(
qc.getQueryData<IssueLabelsResponse>(labelKeys.byIssue(WS_ID, ISSUE_ID)),
).toEqual({ labels: [labelB] });
});
it("leaves the per-issue label cache untouched when the picker has not fetched", () => {
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
expect(qc.getQueryData(labelKeys.byIssue(WS_ID, ISSUE_ID))).toBeUndefined();
});
it("still patches the list and detail caches", () => {
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), {
byStatus: { todo: { issues: [baseIssue], total: 1 } },
});
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), baseIssue);
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
expect(list?.byStatus.todo?.issues[0]?.labels).toEqual([labelB]);
const detail = qc.getQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID));
expect(detail?.labels).toEqual([labelB]);
});
});

View File

@@ -1,13 +1,12 @@
import type { QueryClient } from "@tanstack/react-query";
import { issueKeys } from "./queries";
import { labelKeys } from "../labels/queries";
import {
addIssueToBuckets,
findIssueLocation,
patchIssueInBuckets,
removeIssueFromBuckets,
} from "./cache-helpers";
import type { Issue, IssueLabelsResponse, Label } from "../types";
import type { Issue, Label } from "../types";
import type { ListIssuesCache } from "../types";
export function onIssueCreated(
@@ -74,15 +73,9 @@ export function onIssueUpdated(
}
/**
* Patch an issue's labels in-place across the list cache, my-issues caches,
* the detail cache, and the per-issue label cache. Triggered by the
* `issue_labels:changed` WS event after attach/detach so list/board chips
* and the issue-detail Properties LabelPicker update without a refetch.
*
* The byIssue cache backs `LabelPicker`; without patching it, externally
* driven label changes (agents, other tabs) leave the picker stale until it
* remounts — `staleTime: Infinity` + `refetchOnWindowFocus: false` (see
* `query-client.ts`) means focus changes won't recover it.
* Patch an issue's `labels` field in-place across the list cache, my-issues
* caches, and the detail cache. Triggered by the `issue_labels:changed` WS
* event after attach/detach so list/board chips update without a refetch.
*/
export function onIssueLabelsChanged(
qc: QueryClient,
@@ -96,9 +89,6 @@ export function onIssueLabelsChanged(
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) =>
old ? { ...old, labels } : old,
);
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), (old) =>
old ? { ...old, labels } : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
}

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,26 +19,24 @@ 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.
expect(resolvePostAuthDestination([], false)).toBe(paths.onboarding());
expect(resolvePostAuthDestination([makeWs("acme")], false)).toBe(
paths.onboarding(),
);
});
it("onboarded + has workspace → /<first.slug>/issues", () => {
it("has workspace → /<first.slug>/issues regardless of onboarded state", () => {
const ws = [makeWs("acme"), makeWs("beta")];
expect(resolvePostAuthDestination(ws, true)).toBe(
paths.workspace("acme").issues(),
);
expect(resolvePostAuthDestination(ws, false)).toBe(
paths.workspace("acme").issues(),
);
expect(resolvePostAuthDestination([makeWs("acme")], false)).toBe(
paths.workspace("acme").issues(),
);
});
it("onboarded + zero workspaces → /workspaces/new", () => {
it("zero workspaces + !onboarded → /onboarding", () => {
expect(resolvePostAuthDestination([], false)).toBe(paths.onboarding());
});
it("zero workspaces + onboarded → /workspaces/new", () => {
expect(resolvePostAuthDestination([], true)).toBe(paths.newWorkspace());
});
});

View File

@@ -4,34 +4,23 @@ import { paths } from "./paths";
/**
* Priority:
* !hasOnboarded → /onboarding
* hasOnboarded && has workspace → /<first.slug>/issues
* hasOnboarded && zero workspaces → /workspaces/new
* has workspace → /<first.slug>/issues
* zero workspaces && !hasOnboarded → /onboarding
* zero workspaces && hasOnboarded → /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.
* Workspace presence wins over onboarding state: a user invited into an
* existing workspace must NOT be bounced into the new-workspace wizard
* just because their personal `onboarded_at` is still null.
*/
export function resolvePostAuthDestination(
workspaces: Workspace[],
hasOnboarded: boolean,
): string {
if (!hasOnboarded) {
return paths.onboarding();
}
const first = workspaces[0];
if (first) {
return paths.workspace(first.slug).issues();
}
return paths.newWorkspace();
return hasOnboarded ? paths.newWorkspace() : paths.onboarding();
}
/**

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

@@ -5,4 +5,3 @@ export { defaultStorage } from "./storage";
export { createPersistStorage } from "./persist-storage";
export { createWorkspaceAwareStorage, setCurrentWorkspace, getCurrentSlug, getCurrentWsId, subscribeToCurrentSlug, registerForWorkspaceRehydration } from "./workspace-storage";
export { clearWorkspaceStorage } from "./storage-cleanup";
export { isMac, modKey, enterKey, formatShortcut } from "./keyboard";

View File

@@ -1,51 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});
beforeEach(() => {
vi.resetModules();
});
describe("keyboard platform helper", () => {
it("renders Mac symbols when navigator.platform is MacIntel", async () => {
vi.stubGlobal("navigator", { platform: "MacIntel" });
const mod = await import("./keyboard");
expect(mod.isMac).toBe(true);
expect(mod.modKey).toBe("⌘");
expect(mod.enterKey).toBe("↵");
expect(mod.formatShortcut(mod.modKey, "K")).toBe("⌘K");
expect(mod.formatShortcut(mod.modKey, mod.enterKey)).toBe("⌘↵");
});
it("renders Ctrl/Enter on Windows", async () => {
vi.stubGlobal("navigator", { platform: "Win32" });
const mod = await import("./keyboard");
expect(mod.isMac).toBe(false);
expect(mod.modKey).toBe("Ctrl");
expect(mod.enterKey).toBe("Enter");
expect(mod.formatShortcut(mod.modKey, "K")).toBe("Ctrl+K");
expect(mod.formatShortcut(mod.modKey, mod.enterKey)).toBe("Ctrl+Enter");
});
it("renders Ctrl/Enter on Linux", async () => {
vi.stubGlobal("navigator", { platform: "Linux x86_64" });
const mod = await import("./keyboard");
expect(mod.isMac).toBe(false);
expect(mod.modKey).toBe("Ctrl");
expect(mod.formatShortcut("Ctrl", "Shift", "P")).toBe("Ctrl+Shift+P");
});
it("falls back to non-Mac when navigator is unavailable (SSR)", async () => {
vi.stubGlobal("navigator", undefined);
const mod = await import("./keyboard");
expect(mod.isMac).toBe(false);
expect(mod.modKey).toBe("Ctrl");
});
});

View File

@@ -1,24 +0,0 @@
/**
* Coarse platform detection for keyboard-shortcut display.
*
* Eagerly evaluated at module load. On the server (no `navigator`) this
* resolves to `false`, so SSR always renders the non-Mac variant; on a
* real Mac the value is true after hydration. Acceptable trade-off for
* cosmetic shortcut hints — never gate functional behavior on this.
*/
export const isMac =
typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
/** Modifier key label — ⌘ on Mac, "Ctrl" elsewhere. */
export const modKey: string = isMac ? "⌘" : "Ctrl";
/** Enter / return key label — ↵ on Mac, "Enter" elsewhere. */
export const enterKey: string = isMac ? "↵" : "Enter";
/**
* Join key labels for display. Mac compresses combos with no separator
* ("⌘K", "⌘↵"); other platforms use "+" ("Ctrl+K", "Ctrl+Enter").
*/
export function formatShortcut(...keys: string[]): string {
return keys.join(isMac ? "" : "+");
}

View File

@@ -1,9 +1,3 @@
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

@@ -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

@@ -149,14 +149,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;
}
@@ -154,7 +151,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 +181,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,28 +77,25 @@ export function AgentDetailInspector({
runtimes,
members,
currentUserId,
canEdit,
onUpdate,
}: InspectorProps) {
const update = (data: Record<string, unknown>) => onUpdate(agent.id, data);
const isOnline = runtime?.status === "online";
return (
<aside className="flex w-full flex-col rounded-lg border bg-background md:h-full md:min-h-0 md:overflow-y-auto">
<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,40 +163,27 @@ 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>
)}
<div className="flex flex-1 min-h-0 flex-col gap-3 overflow-y-auto p-3 md:grid md:grid-cols-[320px_minmax(0,1fr)] md:gap-4 md:overflow-hidden md:p-6">
<div className="grid flex-1 min-h-0 grid-cols-[320px_minmax(0,1fr)] gap-4 p-6">
<AgentDetailInspector
agent={agent}
runtime={runtime}
@@ -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" />}
@@ -358,7 +334,7 @@ function DetailLoadingSkeleton() {
<PageHeader className="px-5">
<Skeleton className="h-5 w-48" />
</PageHeader>
<div className="flex flex-1 min-h-0 flex-col gap-3 overflow-y-auto p-3 md:grid md:grid-cols-[320px_minmax(0,1fr)] md:gap-4 md:overflow-hidden md:p-6">
<div className="grid flex-1 min-h-0 grid-cols-[320px_minmax(0,1fr)] gap-4 p-6">
<div className="flex flex-col gap-4 rounded-lg border p-5">
<Skeleton className="h-14 w-14 rounded-lg" />
<Skeleton className="h-5 w-40" />

View File

@@ -108,14 +108,14 @@ export function AgentOverviewPane({
};
return (
<div className="flex min-h-[60vh] flex-col overflow-hidden rounded-lg border bg-background md:h-full md:min-h-0">
<div className="flex shrink-0 items-center gap-0 overflow-x-auto border-b px-2 md:px-4">
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-background">
<div className="flex shrink-0 items-center gap-0 border-b px-4">
{detailTabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => requestTabChange(tab.id)}
className={`flex shrink-0 items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2.5 text-xs font-medium transition-colors ${
className={`flex items-center gap-1.5 border-b-2 px-3 py-2.5 text-xs font-medium transition-colors ${
activeTab === tab.id
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
@@ -204,6 +204,6 @@ export function AgentOverviewPane({
// list) still scrolls via the parent's overflow-y-auto.
function TabContent({ children }: { children: React.ReactNode }) {
return (
<div className="mx-auto flex h-full max-w-2xl flex-col p-4 md:p-6">{children}</div>
<div className="mx-auto flex h-full max-w-2xl flex-col p-6">{children}</div>
);
}

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,
};
});

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);
@@ -83,17 +80,6 @@ export function ModelPicker({
const triggerLabel = value || "Default";
const triggerTitle = `Model · ${triggerLabel}`;
if (!canEdit) {
return (
<span
className="min-w-0 truncate px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
title={triggerTitle}
>
{triggerLabel}
</span>
);
}
return (
<PropertyPicker
open={open}

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

@@ -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;
}
@@ -88,38 +81,15 @@ export function ChatInput({
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 +121,7 @@ export function ChatInput({
{rightAdornment}
<SubmitButton
onClick={handleSend}
disabled={isEmpty || !!disabled || !!noAgent}
disabled={isEmpty || !!disabled}
running={isRunning}
onStop={onStop}
/>

View File

@@ -32,12 +32,15 @@ interface ChatMessageListProps {
pendingTask: ChatPendingTask | null | undefined;
/** Resolved presence; pass `undefined` while loading to keep the pill copy neutral. */
availability: AgentAvailability | undefined;
/** Cancel handler exposed by the StatusPill once the task crosses the long-run threshold. */
onCancel?: () => void;
}
export function ChatMessageList({
messages,
pendingTask,
availability,
onCancel,
}: ChatMessageListProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const fadeStyle = useScrollFade(scrollRef);
@@ -84,6 +87,7 @@ export function ChatMessageList({
pendingTask={pendingTask}
taskMessages={liveTaskMessages ?? []}
availability={availability}
onCancel={onCancel}
/>
)}
</div>

View File

@@ -2,7 +2,6 @@
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "motion/react";
import { Minus, Maximize2, Minimize2, ChevronDown, Plus, Check } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
@@ -20,10 +19,9 @@ 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 { useAgentPresenceDetail } from "@multica/core/agents";
import { ActorAvatar } from "../../common/actor-avatar";
import { OfflineBanner } from "./offline-banner";
import { NoAgentBanner } from "./no-agent-banner";
import {
chatSessionsOptions,
allChatSessionsOptions,
@@ -105,13 +103,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`
@@ -335,8 +326,6 @@ export function ChatWindow() {
setOpen(false);
}, [activeSessionId, pendingTaskId, setOpen]);
const isExpanded = useChatStore((s) => s.isExpanded);
const windowRef = useRef<HTMLDivElement>(null);
const { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag } = useChatResize(windowRef);
@@ -344,37 +333,24 @@ export function ChatWindow() {
// a real message, or a pending task whose timeline will stream in.
const hasMessages = messages.length > 0 || !!pendingTaskId;
const isVisible = isOpen && (isExpanded || boundsReady);
const isVisible = isOpen && boundsReady;
const containerClass = isExpanded
? "absolute inset-3 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden"
: "absolute bottom-2 right-2 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden";
const containerClass = "absolute bottom-2 right-2 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden";
const containerStyle: React.CSSProperties = {
...(!isExpanded ? { width: renderWidth, height: renderHeight } : {}),
width: `${renderWidth}px`,
height: `${renderHeight}px`,
opacity: isVisible ? 1 : 0,
transform: isVisible ? "scale(1)" : "scale(0.95)",
transformOrigin: "bottom right",
pointerEvents: isOpen ? "auto" : "none",
transition: isDragging
? "none"
: "width 200ms ease-out, height 200ms ease-out, opacity 150ms ease-out, transform 150ms ease-out",
};
return (
<motion.div
ref={windowRef}
className={containerClass}
style={containerStyle}
layout="position"
initial={{ opacity: 0, scale: 0.95 }}
animate={{
opacity: isVisible ? 1 : 0,
scale: isVisible ? 1 : 0.95,
}}
transition={{
layout: isDragging
? { duration: 0 }
: { type: "spring", duration: 0.3, bounce: 0 },
opacity: { duration: 0.15 },
scale: { type: "spring", duration: 0.2, bounce: 0 },
}}
>
{!isExpanded && <ChatResizeHandles onDragStart={startDrag} />}
<div ref={windowRef} className={containerClass} style={containerStyle}>
<ChatResizeHandles onDragStart={startDrag} />
{/* Header — ⊕ new + session dropdown | window tools */}
<div className="flex items-center justify-between border-b px-4 py-2.5 gap-2">
<div className="flex items-center gap-1 min-w-0">
@@ -414,10 +390,10 @@ export function ChatWindow() {
/>
}
>
{isExpanded || isAtMax ? <Minimize2 /> : <Maximize2 />}
{isAtMax ? <Minimize2 /> : <Maximize2 />}
</TooltipTrigger>
<TooltipContent side="top">
{isExpanded || isAtMax ? "Restore" : "Fullscreen"}
{isAtMax ? "Restore" : "Expand"}
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -446,38 +422,27 @@ export function ChatWindow() {
messages={messages}
pendingTask={pendingTask}
availability={availability}
onCancel={handleStop}
/>
) : (
<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} />
)}
{/* Presence banner sits above the input card (not inside topSlot) so
* the "offline / unstable" hint reads as a global session signal,
* not an attachment to the message being composed. ContextAnchorCard
* stays in topSlot because that's per-message context. */}
<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={
@@ -490,7 +455,7 @@ export function ChatWindow() {
}
rightAdornment={<ContextAnchorButton />}
/>
</motion.div>
</div>
);
}
@@ -744,42 +709,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,8 +1,10 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { X } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { UnicodeSpinner } from "@multica/ui/components/common/unicode-spinner";
import type { BrailleSpinnerName } from "unicode-animations";
import type { AgentAvailability } from "@multica/core/agents";
import type { ChatPendingTask, TaskMessagePayload } from "@multica/core/types";
import { formatElapsedSecs } from "../lib/format";
@@ -14,6 +16,8 @@ interface Props {
taskMessages: readonly TaskMessagePayload[];
/** Resolved presence; pass `undefined` to suppress availability hints. */
availability: AgentAvailability | undefined;
/** When set, `onCancel` is exposed once the task crosses the long-run threshold. */
onCancel?: () => void;
}
interface Stage {
@@ -22,10 +26,11 @@ interface Stage {
* ChatGPT / Cursor / Claude style — the agent identity is already on
* the chat header, so we don't repeat it inline. */
label: string;
/** null = static (offline / unstable spinning would feel anxious). */
spinner: BrailleSpinnerName | null;
/** 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. */
* the label is rendered without the shimmer animation — shimmer implies
* "the agent is actively doing something", which a holding state isn't. */
static?: boolean;
}
@@ -33,21 +38,35 @@ interface Stage {
// 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_STAGES: Record<string, Stage> = {
bash: { label: "Running a command", spinner: "helix" },
exec: { label: "Running a command", spinner: "helix" },
read: { label: "Reading files", spinner: "scan" },
glob: { label: "Reading files", spinner: "scan" },
grep: { label: "Searching the code", spinner: "scan" },
write: { label: "Making edits", spinner: "cascade" },
edit: { label: "Making edits", spinner: "cascade" },
multi_edit: { label: "Making edits", spinner: "cascade" },
multiedit: { label: "Making edits", spinner: "cascade" },
web_search: { label: "Searching the web", spinner: "orbit" },
websearch: { label: "Searching the web", spinner: "orbit" },
};
const TOOL_FALLBACK = "Working";
const STAGE_FALLBACK: Stage = { label: "Working", spinner: "helix" };
// During the first-token gap (status=running but no task_message yet)
// the agent could be loading the model, opening an API session, or
// actually reasoning. Rotating the label by elapsed seconds — instead
// of pinning a single "Thinking..." — makes the wait feel progressive
// without claiming what the model is literally doing. Boundaries are
// tiered (each label implies "this is taking a bit longer") rather
// than randomised, which would jitter on every render.
function pickThinkingLabel(elapsedSecs: number): string {
if (elapsedSecs < 5) return "Thinking";
if (elapsedSecs < 15) return "Reasoning";
if (elapsedSecs < 30) return "Working through it";
return "Taking a closer look";
}
// Pure stage decision. Two-tier signal: presence + status drive the
// queued/wait copy, then taskMessages drive the running-state label.
@@ -58,21 +77,22 @@ function pickStage(
status: string | undefined,
taskMessages: readonly TaskMessagePayload[],
availability: AgentAvailability | undefined,
elapsedSecs: number,
): Stage {
if (
(status === "queued" || status === "dispatched") &&
availability === "offline"
) {
return { label: "Offline", static: true };
return { label: "Offline", spinner: null, static: true };
}
if (
(status === "queued" || status === "dispatched") &&
availability === "unstable"
) {
return { label: "Reconnecting" };
return { label: "Reconnecting", spinner: "pulse" };
}
if (status === "queued") return { label: "Queued" };
if (status === "dispatched") return { label: "Starting up" };
if (status === "queued") return { label: "Queued", spinner: "pulse" };
if (status === "dispatched") return { label: "Starting up", spinner: "breathe" };
// running: latest meaningful message decides the label. We deliberately
// skip both `error` rows (rendered inline by the timeline; flipping the
@@ -90,20 +110,34 @@ function pickStage(
}
}
if (!latest) return { label: "Thinking" };
if (latest.type === "thinking") return { label: "Thinking" };
if (latest.type === "text") return { label: "Typing" };
// No task_message yet — first-token delay. Rotate the thinking label
// by elapsed so the user perceives progressive waiting rather than
// a stuck "Thinking..." loop.
if (!latest) {
return { label: pickThinkingLabel(elapsedSecs), spinner: "breathe" };
}
if (latest.type === "thinking") {
return { label: pickThinkingLabel(elapsedSecs), spinner: "breathe" };
}
if (latest.type === "text") {
return { label: "Typing", spinner: "braille" };
}
if (latest.type === "tool_use") {
const tool = (latest.tool ?? "").toLowerCase();
return { label: TOOL_LABELS[tool] ?? TOOL_FALLBACK };
return TOOL_STAGES[tool] ?? STAGE_FALLBACK;
}
return { label: "Thinking" };
return { label: pickThinkingLabel(elapsedSecs), spinner: "breathe" };
}
const WARNING_THRESHOLD_S = 60;
const CANCEL_THRESHOLD_S = 300;
export function TaskStatusPill({
pendingTask,
taskMessages,
availability,
onCancel,
}: Props) {
// Anchor: locked on first render. Once set we never reassign — otherwise
// the timer would visibly snap backwards when an optimistic-seeded
@@ -133,22 +167,43 @@ export function TaskStatusPill({
// 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);
const stage = pickStage(status, taskMessages, availability, elapsedSecs);
const isWarning = elapsedSecs >= WARNING_THRESHOLD_S;
const showCancel = !!onCancel && elapsedSecs >= CANCEL_THRESHOLD_S;
// Shimmer the label whenever the agent is actively doing something —
// skipped for `static` stages (offline holding) and `isWarning` (the
// amber colour is the signal we want, shimmer would mute it under the
// gradient mask).
const animateLabel = !stage.static && !isWarning;
return (
<div
className="flex items-center gap-1.5 px-1 text-xs text-muted-foreground"
className={cn(
"flex items-center gap-1.5 px-1 text-xs",
isWarning ? "text-amber-700 dark:text-amber-300" : "text-muted-foreground",
)}
aria-live="polite"
>
{!stage.static && (
<UnicodeSpinner name="breathe" className="opacity-70" />
{stage.spinner && (
<UnicodeSpinner name={stage.spinner} className="opacity-70" />
)}
<span className="truncate">
<span className={cn(!stage.static && "animate-chat-text-shimmer")}>
<span className={cn(animateLabel && "animate-chat-text-shimmer")}>
{stage.label}
</span>
<span className="opacity-70"> · {formatElapsedSecs(elapsedSecs)}</span>
</span>
{showCancel && (
<button
type="button"
onClick={onCancel}
className="ml-2 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-foreground hover:bg-accent transition-colors"
>
<X className="size-3" />
Cancel
</button>
)}
</div>
);
}

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

@@ -34,7 +34,6 @@ import { posToDOMRect } from "@tiptap/core";
import { NodeSelection } from "@tiptap/pm/state";
import { toast } from "sonner";
import { useCreateIssue } from "@multica/core/issues/mutations";
import { modKey } from "@multica/core/platform";
import { Toggle } from "@multica/ui/components/ui/toggle";
import { Separator } from "@multica/ui/components/ui/separator";
import {
@@ -87,6 +86,10 @@ function shouldShowBubbleMenu(editor: Editor): boolean {
return true;
}
const isMac =
typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
const mod = isMac ? "\u2318" : "Ctrl";
// ---------------------------------------------------------------------------
// Mark Toggle Button
// ---------------------------------------------------------------------------
@@ -574,10 +577,10 @@ function EditorBubbleMenu({
) : (
<TooltipProvider delay={300}>
<div className="bubble-menu">
<MarkButton editor={editor} mark="bold" icon={Bold} label="Bold" shortcut={`${modKey}+B`} isActive={fmt.bold} />
<MarkButton editor={editor} mark="italic" icon={Italic} label="Italic" shortcut={`${modKey}+I`} isActive={fmt.italic} />
<MarkButton editor={editor} mark="strike" icon={Strikethrough} label="Strikethrough" shortcut={`${modKey}+Shift+S`} isActive={fmt.strike} />
<MarkButton editor={editor} mark="code" icon={Code} label="Code" shortcut={`${modKey}+E`} isActive={fmt.code} />
<MarkButton editor={editor} mark="bold" icon={Bold} label="Bold" shortcut={`${mod}+B`} isActive={fmt.bold} />
<MarkButton editor={editor} mark="italic" icon={Italic} label="Italic" shortcut={`${mod}+I`} isActive={fmt.italic} />
<MarkButton editor={editor} mark="strike" icon={Strikethrough} label="Strikethrough" shortcut={`${mod}+Shift+S`} isActive={fmt.strike} />
<MarkButton editor={editor} mark="code" icon={Code} label="Code" shortcut={`${mod}+E`} isActive={fmt.code} />
<Separator orientation="vertical" className="mx-0.5 h-5" />
<Tooltip>
<TooltipTrigger render={

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;
@@ -117,6 +113,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
defaultValue = "",
onUpdate,
placeholder: placeholderText = "",
editable = true,
className,
debounceMs = 300,
onSubmit,
@@ -134,6 +131,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 +154,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()).trimEnd();
lastEmittedRef.current = stripBlobUrls(ed.getMarkdown());
},
content: defaultValue ? preprocessMarkdown(defaultValue) : "",
contentType: defaultValue ? "markdown" : undefined,
extensions: createEditorExtensions({
editable,
placeholder: placeholderText,
queryClient,
onSubmitRef,
@@ -173,7 +173,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
if (!onUpdateRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const md = stripBlobUrls(ed.getMarkdown()).trimEnd();
const md = stripBlobUrls(ed.getMarkdown());
if (md === lastEmittedRef.current) return;
lastEmittedRef.current = md;
onUpdateRef.current?.(md);
@@ -199,7 +199,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 +215,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: () => {
@@ -244,7 +262,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 +281,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

@@ -49,13 +49,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 +82,7 @@ const ImageExtension = Image.extend({
});
export interface EditorExtensionsOptions {
editable: boolean;
placeholder?: string;
queryClient?: import("@tanstack/react-query").QueryClient;
onSubmitRef?: RefObject<(() => void) | undefined>;
@@ -98,9 +104,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 +120,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,8 +130,9 @@ 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.
// Make Cmd+C / Cmd+X / drag write Markdown source to clipboard text/plain.
// Registered for both editable and readonly so users can copy from rendered
// comments and paste the original Markdown elsewhere.
createMarkdownCopyExtension(),
FileCardExtension,
...(options.disableMentions
@@ -133,24 +140,31 @@ export function createEditorExtensions(
: [
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,130 +0,0 @@
import { describe, it, expect, afterEach } from "vitest";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "@tiptap/markdown";
import { createMarkdownPasteExtension } from "./markdown-paste";
interface FakeClipboard {
files: never[];
getData: (type: string) => string;
}
function fakePasteEvent(text: string, html?: string) {
const data: FakeClipboard = {
files: [],
getData: (type) =>
type === "text/plain" ? text : type === "text/html" ? (html ?? "") : "",
};
return {
clipboardData: data,
preventDefault: () => {},
} as unknown as ClipboardEvent;
}
function makeEditor(content: object) {
const element = document.createElement("div");
document.body.appendChild(element);
return new Editor({
element,
extensions: [StarterKit, Markdown, createMarkdownPasteExtension()],
content,
});
}
function paste(editor: Editor, text: string, html?: string): boolean {
const event = fakePasteEvent(text, html);
return (
editor.view.someProp("handlePaste", (handler) =>
handler(editor.view, event, editor.view.state.selection.content()),
) === true
);
}
interface JsonNode {
type: string;
text?: string;
content?: JsonNode[];
}
function findFirst(json: JsonNode, type: string): JsonNode | undefined {
if (json.type === type) return json;
for (const child of json.content ?? []) {
const hit = findFirst(child, type);
if (hit) return hit;
}
return undefined;
}
function nodeText(node: JsonNode): string {
if (node.text !== undefined) return node.text;
return (node.content ?? []).map(nodeText).join("");
}
describe("markdownPaste — code block context", () => {
let editor: Editor | null = null;
afterEach(() => {
editor?.destroy();
editor = null;
document.body.innerHTML = "";
});
it("preserves blank lines when pasting into a code block (#1982)", () => {
editor = makeEditor({
type: "doc",
content: [{ type: "codeBlock", content: [{ type: "text", text: "x" }] }],
});
// Place caret after "x" inside the code block.
editor.commands.setTextSelection(2);
expect(editor.state.selection.$from.parent.type.name).toBe("codeBlock");
const handled = paste(editor, "line1\n\nline2");
expect(handled).toBe(true);
const json = editor.getJSON() as JsonNode;
const codeBlock = findFirst(json, "codeBlock");
expect(codeBlock).toBeDefined();
// Code block content is preserved verbatim — blank line stays inside.
expect(nodeText(codeBlock!)).toBe("xline1\n\nline2");
// No paragraph leaked out carrying any of the pasted text.
const leakedParagraph = (json.content ?? []).find(
(n) => n.type === "paragraph" && nodeText(n).length > 0,
);
expect(leakedParagraph).toBeUndefined();
});
it("preserves fence characters pasted into a code block", () => {
editor = makeEditor({
type: "doc",
content: [{ type: "codeBlock", content: [] }],
});
editor.commands.setTextSelection(1);
expect(editor.state.selection.$from.parent.type.name).toBe("codeBlock");
paste(editor, "```\nhello\n```");
const json = editor.getJSON() as JsonNode;
const codeBlock = findFirst(json, "codeBlock");
expect(codeBlock).toBeDefined();
expect(nodeText(codeBlock!)).toBe("```\nhello\n```");
});
it("still parses Markdown when pasting into a regular paragraph", () => {
editor = makeEditor({
type: "doc",
content: [{ type: "paragraph" }],
});
editor.commands.setTextSelection(1);
expect(editor.state.selection.$from.parent.type.name).toBe("paragraph");
paste(editor, "# Heading\n\nbody");
const json = editor.getJSON() as JsonNode;
const types = (json.content ?? []).map((n) => n.type);
// Markdown parsing produced a heading at the top.
expect(types).toContain("heading");
});
});

View File

@@ -46,16 +46,6 @@ export function createMarkdownPasteExtension() {
const text = clipboard.getData("text/plain");
if (!text) return false;
// If the caret is inside a code block, insert the text as-is.
// Code blocks must keep newlines literal; running Markdown
// parsing here would split a blank line (\n\n) into two
// paragraphs and tear the code block open. (#1982)
const { $from } = view.state.selection;
if ($from.parent.type.name === "codeBlock") {
view.dispatch(view.state.tr.insertText(text));
return true;
}
const html = clipboard.getData("text/html");
// If HTML contains data-pm-slice, the source is another

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();
});
});
});

View File

@@ -16,8 +16,7 @@
* - Rendering mentions with the same IssueMentionCard component and .mention class
*/
import { isValidElement, memo, useEffect, useId, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useMemo, useRef, useState } from "react";
import ReactMarkdown, {
defaultUrlTransform,
type Components,
@@ -50,140 +49,6 @@ import "./content-editor.css";
const lowlight = createLowlight(common);
type MermaidAPI = typeof import("mermaid").default;
type MermaidLayout = {
width?: number;
height?: number;
};
let mermaidPromise: Promise<MermaidAPI> | null = null;
function getMermaid(): Promise<MermaidAPI> {
mermaidPromise ??= import("mermaid").then(({ default: mermaid }) => mermaid);
return mermaidPromise;
}
function toLegacyColor(color: string, fallback: string, ownerDocument: Document): string {
const canvas = ownerDocument.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return fallback;
// Mermaid's color parser only supports legacy color syntax. Canvas can parse
// modern CSS Color 4 values such as oklch(), then getImageData gives concrete
// 8-bit sRGB bytes that Mermaid can consume safely.
context.fillStyle = "#000";
context.fillStyle = color || fallback;
context.fillRect(0, 0, 1, 1);
const [red, green, blue] = context.getImageData(0, 0, 1, 1).data;
return `rgb(${red}, ${green}, ${blue})`;
}
function resolveCssColor(
host: HTMLElement,
variableName: string,
fallback: string,
): string {
const probe = host.ownerDocument.createElement("span");
probe.style.color = `var(${variableName})`;
probe.style.display = "none";
host.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return toLegacyColor(color || fallback, fallback, host.ownerDocument);
}
function getMermaidThemeVariables(host: HTMLElement | null) {
if (!host) {
return {
primaryColor: "rgb(245, 245, 245)",
primaryBorderColor: "rgb(59, 130, 246)",
primaryTextColor: "rgb(17, 24, 39)",
lineColor: "rgb(107, 114, 128)",
fontFamily: "inherit",
};
}
return {
primaryColor: resolveCssColor(host, "--muted", "rgb(245, 245, 245)"),
primaryBorderColor: resolveCssColor(host, "--primary", "rgb(59, 130, 246)"),
primaryTextColor: resolveCssColor(host, "--foreground", "rgb(17, 24, 39)"),
lineColor: resolveCssColor(host, "--muted-foreground", "rgb(107, 114, 128)"),
fontFamily: "inherit",
};
}
function getSandboxCssVariables(host: HTMLElement | null): string {
const styles = host ? getComputedStyle(host) : null;
return ["--muted", "--primary", "--foreground", "--muted-foreground"]
.map((name) => `${name}: ${styles?.getPropertyValue(name).trim() || "initial"};`)
.join(" ");
}
function getMermaidLayout(svg: string): MermaidLayout {
const viewBoxMatch = svg.match(
/viewBox=["']\s*([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s*["']/i,
);
const [, , , widthValue, heightValue] = viewBoxMatch ?? [];
const width = widthValue ? Number.parseFloat(widthValue) : undefined;
const height = heightValue ? Number.parseFloat(heightValue) : undefined;
if (width && height && width > 0 && height > 0) {
return {
width: Math.ceil(width),
height: Math.ceil(height),
};
}
return {};
}
function buildSandboxedMermaidDocument(svg: string, host: HTMLElement | null): string {
const cssVariables = getSandboxCssVariables(host);
return `<!doctype html><html><head><style>:root { ${cssVariables} } body { margin: 0; display: flex; justify-content: center; background: transparent; } svg { max-width: 100%; height: auto; }</style></head><body>${svg}</body></html>`;
}
function buildExpandedMermaidDocument(svg: string, host: HTMLElement | null): string {
const cssVariables = getSandboxCssVariables(host);
return `<!doctype html><html><head><style>:root { ${cssVariables} } html, body { width: 100%; height: 100%; } body { margin: 0; display: flex; align-items: center; justify-content: center; background: transparent; } svg { max-width: 100%; max-height: 100%; width: auto; height: auto; }</style></head><body>${svg}</body></html>`;
}
function useThemeVersion() {
const [themeVersion, setThemeVersion] = useState(0);
useEffect(() => {
const bumpThemeVersion = () => setThemeVersion((version) => version + 1);
const observer = new MutationObserver(bumpThemeVersion);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"],
});
if (document.body) {
observer.observe(document.body, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"],
});
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", bumpThemeVersion);
return () => {
observer.disconnect();
mediaQuery.removeEventListener("change", bumpThemeVersion);
};
}, []);
return themeVersion;
}
// ---------------------------------------------------------------------------
// Sanitization schema — extends GitHub defaults to allow file-card data attrs
// ---------------------------------------------------------------------------
@@ -293,144 +158,6 @@ function ReadonlyLink({
);
}
function MermaidLightbox({
srcDoc,
onClose,
}: {
srcDoc: string;
onClose: () => void;
}) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
return createPortal(
<div
className="mermaid-diagram-lightbox"
role="dialog"
aria-modal="true"
aria-label="Mermaid diagram fullscreen view"
onClick={onClose}
>
<iframe
className="mermaid-diagram-lightbox-frame"
sandbox=""
srcDoc={srcDoc}
title="Mermaid diagram fullscreen"
onClick={(e) => e.stopPropagation()}
/>
</div>,
document.body,
);
}
function MermaidDiagram({ chart }: { chart: string }) {
const reactId = useId();
const containerRef = useRef<HTMLDivElement>(null);
const diagramId = useMemo(
() => `mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`,
[reactId],
);
const themeVersion = useThemeVersion();
const [sandboxedDocument, setSandboxedDocument] = useState<string | null>(null);
const [expandedDocument, setExpandedDocument] = useState<string | null>(null);
const [layout, setLayout] = useState<MermaidLayout>({});
const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
useEffect(() => {
let cancelled = false;
async function renderDiagram() {
try {
setError(null);
setSandboxedDocument(null);
setExpandedDocument(null);
setLayout({});
const mermaid = await getMermaid();
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
theme: "base",
themeVariables: getMermaidThemeVariables(containerRef.current),
});
const { svg: renderedSvg } = await mermaid.render(diagramId, chart);
if (!cancelled) {
setLayout(getMermaidLayout(renderedSvg));
setSandboxedDocument(
buildSandboxedMermaidDocument(renderedSvg, containerRef.current),
);
setExpandedDocument(
buildExpandedMermaidDocument(renderedSvg, containerRef.current),
);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to render Mermaid diagram");
}
}
}
void renderDiagram();
return () => {
cancelled = true;
};
}, [chart, diagramId, themeVersion]);
if (error) {
return (
<div ref={containerRef} className="mermaid-diagram mermaid-diagram-error">
<p>Unable to render Mermaid diagram.</p>
<pre>
<code>{chart}</code>
</pre>
</div>
);
}
return (
<div ref={containerRef} className="mermaid-diagram" aria-label="Mermaid diagram">
{sandboxedDocument ? (
<>
<iframe
className="mermaid-diagram-frame"
sandbox=""
srcDoc={sandboxedDocument}
style={{
height: layout.height ? `${layout.height}px` : undefined,
width: layout.width ? `${layout.width}px` : undefined,
}}
title="Mermaid diagram"
/>
<div className="mermaid-diagram-toolbar">
<button
type="button"
onClick={() => setLightboxOpen(true)}
title="Open fullscreen"
aria-label="Open Mermaid diagram fullscreen"
>
<Maximize2 className="size-3.5" />
</button>
</div>
{lightboxOpen && expandedDocument && (
<MermaidLightbox
srcDoc={expandedDocument}
onClose={() => setLightboxOpen(false)}
/>
)}
</>
) : (
<div className="mermaid-diagram-loading">Rendering diagram</div>
)}
</div>
);
}
const components: Partial<Components> = {
// Links — route mention:// to mention components, others show preview card
a: ReadonlyLink,
@@ -524,10 +251,6 @@ const components: Partial<Components> = {
node?.position &&
node.position.start.line !== node.position.end.line;
if (isBlock && lang === "mermaid") {
return <MermaidDiagram chart={String(children).replace(/\n$/, "")} />;
}
if (!isBlock && !lang) {
// Inline code — CSS handles styling via .rich-text-editor code
return <code {...props}>{children}</code>;
@@ -556,12 +279,7 @@ const components: Partial<Components> = {
},
// Pre — pass through (CSS handles styling via .rich-text-editor pre)
pre: ({ children }) => {
if (isValidElement(children) && children.type === MermaidDiagram) {
return <>{children}</>;
}
return <pre>{children}</pre>;
},
pre: ({ children }) => <pre>{children}</pre>,
};
// ---------------------------------------------------------------------------
@@ -573,14 +291,7 @@ interface ReadonlyContentProps {
className?: string;
}
// Memoized so a long timeline of comments (Inbox + IssueDetail) does not
// re-run the full react-markdown + rehype-* + lowlight pipeline on every
// parent re-render. Props are `content` and `className` (both strings), so
// React.memo's default shallow comparison is value-equality here.
export const ReadonlyContent = memo(function ReadonlyContent({
content,
className,
}: ReadonlyContentProps) {
export function ReadonlyContent({ content, className }: ReadonlyContentProps) {
const processed = useMemo(() => preprocessMarkdown(content), [content]);
const wrapperRef = useRef<HTMLDivElement>(null);
const hover = useLinkHover(wrapperRef);
@@ -598,4 +309,4 @@ export const ReadonlyContent = memo(function ReadonlyContent({
<LinkHoverCard {...hover} />
</div>
);
});
}

View File

@@ -2,7 +2,7 @@
import { StatusIcon } from "../../issues/components";
import { ActorAvatar } from "../../common/actor-avatar";
import { Archive } from "lucide-react";
import { Archive, CircleCheck } from "lucide-react";
import type { InboxItem } from "@multica/core/types";
import { InboxDetailLabel } from "./inbox-detail-label";
import { getInboxDisplayTitle } from "./inbox-display";
@@ -25,11 +25,13 @@ export function InboxListItem({
isSelected,
onClick,
onArchive,
onDone,
}: {
item: InboxItem;
isSelected: boolean;
onClick: () => void;
onArchive: () => void;
onDone?: () => void;
}) {
const displayTitle = getInboxDisplayTitle(item);
@@ -59,6 +61,26 @@ export function InboxListItem({
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
{onDone && (
<span
role="button"
tabIndex={-1}
title="Mark as done"
onClick={(e) => {
e.stopPropagation();
onDone();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
onDone();
}
}}
className="hidden rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-info group-hover:inline-flex"
>
<CircleCheck className="h-3.5 w-3.5" />
</span>
)}
<span
role="button"
tabIndex={-1}

View File

@@ -20,7 +20,7 @@ import {
useArchiveAllReadInbox,
useArchiveCompletedInbox,
} from "@multica/core/inbox/mutations";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { IssueDetail } from "../../issues/components";
import { useNavigation } from "../../navigation";
import { toast } from "sonner";
@@ -118,6 +118,8 @@ export function InboxPage() {
const archiveAllMutation = useArchiveAllInbox();
const archiveAllReadMutation = useArchiveAllReadInbox();
const archiveCompletedMutation = useArchiveCompletedInbox();
const updateIssueMutation = useUpdateIssue();
// Auto-mark-read whenever a selected item is unread — covers both click-
// to-select and URL-param-select (e.g. OS notification click on desktop).
// The mutation flips `read: true` optimistically, so this effect settles
@@ -145,6 +147,18 @@ export function InboxPage() {
});
};
const handleDone = (item: InboxItem) => {
if (!item.issue_id) return;
setSelectedKey("");
updateIssueMutation.mutate(
{ id: item.issue_id, status: "done" },
{ onError: () => toast.error("Failed to mark as done") },
);
archiveMutation.mutate(item.id, {
onError: () => toast.error("Failed to archive"),
});
};
// Batch operations
const handleMarkAllRead = () => {
markAllReadMutation.mutate(undefined, {
@@ -235,6 +249,11 @@ export function InboxPage() {
isSelected={(item.issue_id ?? item.id) === selectedKey}
onClick={() => handleSelect(item)}
onArchive={() => handleArchive(item.id)}
onDone={
item.issue_id && item.issue_status !== "done" && item.issue_status !== "cancelled"
? () => handleDone(item)
: undefined
}
/>
))}
</div>
@@ -258,12 +277,6 @@ export function InboxPage() {
// longer exists.
setSelectedKey("");
}}
onDone={() => {
setSelectedKey("");
archiveMutation.mutate(selected.id, {
onError: () => toast.error("Failed to archive"),
});
}}
/>
) : selected ? (
<div className="p-6">

View File

@@ -1 +0,0 @@
export { InvitationsPage } from "./invitations-page";

View File

@@ -1,170 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import {
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
const {
navigate,
logout,
refreshMe,
acceptInvitation,
markOnboardingComplete,
listMyInvitations,
listWorkspaces,
} = vi.hoisted(() => ({
navigate: vi.fn(),
logout: vi.fn(),
refreshMe: vi.fn(),
acceptInvitation: vi.fn(),
markOnboardingComplete: vi.fn(),
listMyInvitations: vi.fn(),
listWorkspaces: vi.fn(),
}));
vi.mock("../navigation", () => ({
useNavigation: () => ({ push: navigate, replace: navigate }),
}));
vi.mock("../auth", () => ({
useLogout: () => logout,
}));
vi.mock("../platform", () => ({
DragStrip: () => null,
}));
vi.mock("@multica/core/auth", () => ({
useAuthStore: Object.assign(
(selector?: (s: unknown) => unknown) => {
const state = { refreshMe };
return selector ? selector(state) : state;
},
{
getState: () => ({ refreshMe }),
},
),
}));
vi.mock("@multica/core/api", () => ({
api: {
acceptInvitation,
markOnboardingComplete,
listMyInvitations,
listWorkspaces,
},
}));
import { InvitationsPage } from "./invitations-page";
function renderWithClient(client: QueryClient = new QueryClient()) {
return render(
<QueryClientProvider client={client}>
<InvitationsPage />
</QueryClientProvider>,
);
}
const mkInvite = (id: string, wsId: string, wsName: string) => ({
id,
workspace_id: wsId,
inviter_id: "u-2",
invitee_email: "x@example.com",
invitee_user_id: null,
role: "member" as const,
status: "pending" as const,
created_at: "",
updated_at: "",
expires_at: "",
workspace_name: wsName,
inviter_name: "Alice",
});
const mkWs = (id: string, slug: string) => ({
id,
name: slug,
slug,
description: null,
context: null,
settings: {},
repos: [],
issue_prefix: slug.toUpperCase(),
created_at: "",
updated_at: "",
});
describe("InvitationsPage", () => {
beforeEach(() => {
navigate.mockReset();
logout.mockReset();
refreshMe.mockReset();
acceptInvitation.mockReset();
markOnboardingComplete.mockReset();
listMyInvitations.mockReset();
listWorkspaces.mockReset();
refreshMe.mockResolvedValue(undefined);
acceptInvitation.mockResolvedValue({});
markOnboardingComplete.mockResolvedValue({});
});
it("renders pending invitations with workspace names", async () => {
listMyInvitations.mockResolvedValue([
mkInvite("inv-1", "ws-1", "Acme"),
mkInvite("inv-2", "ws-2", "Beta Corp"),
]);
renderWithClient();
await waitFor(() => {
expect(screen.getByText("Acme")).toBeInTheDocument();
expect(screen.getByText("Beta Corp")).toBeInTheDocument();
});
});
it("with no selections, submitting routes to /onboarding", async () => {
listMyInvitations.mockResolvedValue([mkInvite("inv-1", "ws-1", "Acme")]);
renderWithClient();
await waitFor(() => screen.getByText("Acme"));
fireEvent.click(screen.getByRole("button", { name: /skip/i }));
expect(navigate).toHaveBeenCalledWith("/onboarding");
// Empty submit doesn't accept anything or touch onboarding state.
expect(acceptInvitation).not.toHaveBeenCalled();
expect(markOnboardingComplete).not.toHaveBeenCalled();
});
it("accepts selected invitations, marks onboarded, navigates to first ws", async () => {
listMyInvitations.mockResolvedValue([
mkInvite("inv-1", "ws-1", "Acme"),
mkInvite("inv-2", "ws-2", "Beta"),
]);
listWorkspaces.mockResolvedValue([mkWs("ws-1", "acme"), mkWs("ws-2", "beta")]);
renderWithClient();
await waitFor(() => screen.getByText("Acme"));
// Select Acme via its label/checkbox row.
fireEvent.click(screen.getByText("Acme"));
fireEvent.click(screen.getByRole("button", { name: /join 1 workspace/i }));
await waitFor(() => {
expect(acceptInvitation).toHaveBeenCalledWith("inv-1");
expect(markOnboardingComplete).toHaveBeenCalledWith({
completion_path: "invite_accept",
});
expect(refreshMe).toHaveBeenCalled();
expect(navigate).toHaveBeenCalledWith("/acme/issues");
});
});
it("empty list falls through to onboarding via Continue button", async () => {
listMyInvitations.mockResolvedValue([]);
renderWithClient();
await waitFor(() =>
screen.getByRole("button", { name: /continue to setup/i }),
);
fireEvent.click(
screen.getByRole("button", { name: /continue to setup/i }),
);
expect(navigate).toHaveBeenCalledWith("/onboarding");
});
});

View File

@@ -1,280 +0,0 @@
"use client";
import { useState, type ReactNode } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@multica/core/api";
import { useAuthStore } from "@multica/core/auth";
import {
myInvitationListOptions,
workspaceKeys,
workspaceListOptions,
} from "@multica/core/workspace/queries";
import { paths } from "@multica/core/paths";
import type { Invitation } from "@multica/core/types";
import { useNavigation } from "../navigation";
import { useLogout } from "../auth";
import { DragStrip } from "../platform";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Checkbox } from "@multica/ui/components/ui/checkbox";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { LogOut, Mail, Users } from "lucide-react";
/**
* Batch invitation handling page for first-contact users who land here
* because callback / login detected pending invitations on their email.
*
* Design:
* - This route is only reachable for un-onboarded users (the entry-point
* judgment in callback/login routes already-onboarded users straight
* into their workspace; new invites for those users surface in the
* sidebar's pending-invitations dropdown instead).
* - The user picks zero or more invitations to accept. "Submit" then:
* • zero selected → continue to /onboarding
* • ≥1 selected → accept each, mark onboarding complete, navigate
* into the first accepted workspace.
* - Unselected invitations are intentionally left as `pending` in the DB.
* The user can later decline them from the sidebar; we don't auto-decline
* here because closing/refreshing this page should not be a destructive
* action.
*/
export function InvitationsPage() {
const { push } = useNavigation();
const qc = useQueryClient();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
data: invitations,
isLoading,
error: fetchError,
refetch,
} = useQuery(myInvitationListOptions());
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSubmit = async () => {
setError(null);
// Zero selected: hand off to onboarding. Pending invites stay pending and
// can be picked up later from the sidebar.
if (selected.size === 0) {
push(paths.onboarding());
return;
}
setSubmitting(true);
const acceptedIds: string[] = [];
try {
for (const id of selected) {
await api.acceptInvitation(id);
acceptedIds.push(id);
}
// markOnboardingComplete is a frontend-side belt to the backend braces:
// each AcceptInvitation transaction already sets onboarded_at via
// MarkUserOnboarded, but calling this from the client makes sure the
// returned `User` is freshly written and gives refreshMe something
// canonical to read.
await api.markOnboardingComplete({ completion_path: "invite_accept" });
await useAuthStore.getState().refreshMe();
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
const wsList = await qc.fetchQuery({
...workspaceListOptions(),
staleTime: 0,
});
const firstAcceptedInvite = invitations?.find(
(inv) => inv.id === acceptedIds[0],
);
const targetWs = firstAcceptedInvite
? wsList.find((w) => w.id === firstAcceptedInvite.workspace_id)
: undefined;
// If we can't resolve the just-accepted workspace by id (shouldn't
// happen — the backend just inserted the membership and we just
// refetched), fall back to the resolver. Don't blindly route to
// wsList[0]: that could teleport the user into an unrelated old
// workspace they happen to also belong to.
push(
targetWs ? paths.workspace(targetWs.slug).issues() : paths.newWorkspace(),
);
} catch (e) {
setError(
e instanceof Error
? e.message
: "Failed to process invitations. Please try again.",
);
// Partial success: any accepts that landed before the failure ALREADY
// set onboarded_at on the backend (the AcceptInvitation transaction
// is atomic per invite). Refresh local user + workspace state so the
// sidebar reflects the partial accept and the user isn't stuck with a
// stale `onboarded_at == null` view. The next submit is safe — the
// server returns 4xx on re-accept and the catch path will surface that.
if (acceptedIds.length > 0) {
await useAuthStore.getState().refreshMe().catch(() => {});
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
}
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
refetch();
} finally {
setSubmitting(false);
}
};
if (isLoading) {
return (
<InvitationsShell>
<Card className="w-full max-w-lg">
<CardContent className="flex flex-col gap-4 py-12">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</CardContent>
</Card>
</InvitationsShell>
);
}
// Empty / error: send the user on to onboarding so they're never stuck.
// Genuine fetch failure is rare; treating it as "no invites" is safer than
// trapping the user on an error screen they can't act on.
if (fetchError || !invitations || invitations.length === 0) {
return (
<InvitationsShell>
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Mail className="h-6 w-6 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold">No pending invitations</h2>
<p className="text-sm text-muted-foreground text-center">
Continue to set up your own workspace.
</p>
<Button onClick={() => push(paths.onboarding())}>
Continue to setup
</Button>
</CardContent>
</Card>
</InvitationsShell>
);
}
const submitLabel =
selected.size === 0
? "Skip and set up my own workspace"
: selected.size === 1
? "Join 1 workspace"
: `Join ${selected.size} workspaces`;
return (
<InvitationsShell>
<Card className="w-full max-w-lg">
<CardContent className="flex flex-col gap-6 py-10">
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Users className="h-6 w-6 text-primary" />
</div>
<div className="space-y-1">
<h2 className="text-xl font-semibold">
You&apos;ve been invited
</h2>
<p className="text-sm text-muted-foreground">
Pick the workspaces you want to join. You can always handle the
rest later from the sidebar.
</p>
</div>
</div>
<ul className="flex flex-col gap-2">
{invitations.map((inv) => (
<InvitationRow
key={inv.id}
invitation={inv}
checked={selected.has(inv.id)}
onToggle={() => toggle(inv.id)}
/>
))}
</ul>
<Button
className="w-full"
onClick={handleSubmit}
disabled={submitting}
>
{submitting ? "Joining..." : submitLabel}
</Button>
{error && (
<p className="text-sm text-destructive text-center">{error}</p>
)}
</CardContent>
</Card>
</InvitationsShell>
);
}
function InvitationRow({
invitation,
checked,
onToggle,
}: {
invitation: Invitation;
checked: boolean;
onToggle: () => void;
}) {
const inviter = invitation.inviter_name || invitation.inviter_email || "Someone";
return (
<li>
<label
className="flex cursor-pointer items-start gap-3 rounded-md border border-border bg-card p-4 hover:bg-accent/40"
>
<Checkbox
checked={checked}
onCheckedChange={onToggle}
className="mt-1"
/>
<div className="flex-1 min-w-0 space-y-1">
<div className="font-medium truncate">
{invitation.workspace_name ?? "Workspace"}
</div>
<div className="text-xs text-muted-foreground truncate">
{inviter} invited you as{" "}
{invitation.role === "admin" ? "an admin" : "a member"}
</div>
</div>
</label>
</li>
);
}
function InvitationsShell({ children }: { children: ReactNode }) {
const logout = useLogout();
return (
<div className="relative flex min-h-svh flex-col bg-background">
<DragStrip />
<Button
variant="ghost"
size="sm"
className="absolute top-16 right-12 text-muted-foreground hover:text-destructive"
onClick={logout}
>
<LogOut />
Log out
</Button>
<div className="flex flex-1 flex-col items-center justify-center px-6 pb-12">
{children}
</div>
</div>
);
}

View File

@@ -3,7 +3,6 @@
import { useState, type ReactNode } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@multica/core/api";
import { useAuthStore } from "@multica/core/auth";
import {
workspaceKeys,
workspaceListOptions,
@@ -63,12 +62,6 @@ export function InvitePage({ invitationId, onBack }: InvitePageProps) {
setError(null);
try {
await api.acceptInvitation(invitationId);
// Belt to the backend's braces: AcceptInvitation already sets
// onboarded_at inside the same transaction, but explicitly calling
// markOnboardingComplete + refreshMe here keeps local user state in
// sync immediately so downstream guards don't see stale `null`.
await api.markOnboardingComplete({ completion_path: "invite_accept" });
await useAuthStore.getState().refreshMe();
setDone("accepted");
// Fetch the refreshed workspace list so we know the joined workspace's slug.
const nextList = await qc.fetchQuery({

View File

@@ -127,9 +127,6 @@ export function BatchActionToolbar() {
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
selected issue{count > 1 ? "s" : ""} and all associated data.
<span className="mt-2 block text-xs text-muted-foreground/80">
Any workspace member can delete issues.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View File

@@ -1,6 +1,6 @@
"use client";
import { memo, useCallback, useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { ChevronRight, Copy, Download, FileText, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Card } from "@multica/ui/components/ui/card";
@@ -47,14 +47,6 @@ interface CommentCardProps {
entry: TimelineEntry;
allReplies: Map<string, TimelineEntry[]>;
currentUserId?: string;
/**
* True when the current user is a workspace owner/admin and can therefore
* moderate comments authored by anyone — restoring the admin override that
* the backend already grants at `comment.go:507-512`. Computed once in
* `issue-detail.tsx` and threaded down so neither this component nor
* `CommentRow` has to rerun the rule per row.
*/
canModerate?: boolean;
onReply: (parentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
onEdit: (commentId: string, content: string) => Promise<void>;
onDelete: (commentId: string) => void;
@@ -161,7 +153,6 @@ function CommentRow({
issueId,
entry,
currentUserId,
canModerate = false,
onEdit,
onDelete,
onToggleReaction,
@@ -169,7 +160,6 @@ function CommentRow({
issueId: string;
entry: TimelineEntry;
currentUserId?: string;
canModerate?: boolean;
onEdit: (commentId: string, content: string) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;
@@ -185,8 +175,6 @@ function CommentRow({
});
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
const canDeleteEntry = isOwn || canModerate;
const isTemp = entry.id.startsWith("temp-");
const [confirmDelete, setConfirmDelete] = useState(false);
@@ -264,22 +252,18 @@ function CommentRow({
<Copy className="h-3.5 w-3.5" />
Copy
</DropdownMenuItem>
{(canEditEntry || canDeleteEntry) && (
{isOwn && (
<>
<DropdownMenuSeparator />
{canEditEntry && (
<DropdownMenuItem onClick={startEdit}>
<Pencil className="h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
)}
{canEditEntry && canDeleteEntry && <DropdownMenuSeparator />}
{canDeleteEntry && (
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={startEdit}>
<Pencil className="h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
@@ -348,12 +332,11 @@ function CommentRow({
// CommentCard — One Card per thread (parent + all replies flat inside)
// ---------------------------------------------------------------------------
function CommentCardImpl({
function CommentCard({
issueId,
entry,
allReplies,
currentUserId,
canModerate = false,
onReply,
onEdit,
onDelete,
@@ -375,12 +358,6 @@ function CommentCardImpl({
});
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
// Author-only edit is the same as before; admins additionally get edit
// *and* delete on member-authored comments, plus delete on agent-authored
// ones. Edit on agent comments is intentionally never offered — agents
// own their own outputs.
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
const canDeleteEntry = isOwn || canModerate;
const isTemp = entry.id.startsWith("temp-");
const [confirmDelete, setConfirmDelete] = useState(false);
@@ -490,22 +467,18 @@ function CommentCardImpl({
<Copy className="h-3.5 w-3.5" />
Copy
</DropdownMenuItem>
{(canEditEntry || canDeleteEntry) && (
{isOwn && (
<>
<DropdownMenuSeparator />
{canEditEntry && (
<DropdownMenuItem onClick={startEdit}>
<Pencil className="h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
)}
{canEditEntry && canDeleteEntry && <DropdownMenuSeparator />}
{canDeleteEntry && (
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={startEdit}>
<Pencil className="h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
@@ -581,7 +554,6 @@ function CommentCardImpl({
issueId={issueId}
entry={reply}
currentUserId={currentUserId}
canModerate={canModerate}
onEdit={onEdit}
onDelete={onDelete}
onToggleReaction={onToggleReaction}
@@ -606,11 +578,4 @@ function CommentCardImpl({
);
}
// Memoized so a long timeline (e.g. Inbox-embedded IssueDetail with thousands
// of comments) does not re-render every card on each parent state update or
// WS-driven cache refresh. Default shallow comparison is sufficient: the
// timeline grouping is useMemo'd in issue-detail.tsx (stable Map ref), and
// every callback is stabilized via useCallback in use-issue-timeline.ts.
const CommentCard = memo(CommentCardImpl);
export { CommentCard, type CommentCardProps };

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