Compare commits

..

1 Commits

Author SHA1 Message Date
Jiang Bohan
e149075d20 fix(desktop): show git-described version in dev instead of stale 0.1.0
Packaged builds are unaffected: scripts/package.mjs already injects the
git tag into electron-builder's extraMetadata.version, so the .app users
download from GitHub Release reports the right version through
app.getVersion() and the auto-updater's latest.yml comparison works
correctly.

Dev mode (`pnpm dev:desktop`) didn't go through that path though, so
app.getVersion() returned the static "0.1.0" from package.json — the
new Settings → Updates panel surfaced this and made it look like the
dev build was ancient. Add a tiny getAppVersion() helper that falls
back to `git describe --tags --always --dirty` only when !app.isPackaged,
and use it for the app-info IPC. No change to packaged behavior; if git
is unavailable for any reason, we silently fall back to app.getVersion().
2026-04-29 19:15:03 +08:00
189 changed files with 787 additions and 10058 deletions

View File

@@ -174,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:

View File

@@ -36,18 +36,6 @@ No more copy-pasting prompts. No more babysitting runs. Your agents show up on t
<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.

View File

@@ -36,18 +36,6 @@ Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配
<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 生命周期:从任务分配到执行监控再到技能复用。

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,21 @@ function AppContent() {
: undefined;
useDaemonIPCBridge(activeWsId);
// Pre-workspace overlay routing for desktop. Mirrors the web entry-point
// judgment in callback / login:
// un-onboarded:
// pending invites on email → /invitations overlay
// no invites → /onboarding overlay
// already onboarded:
// zero workspaces → /workspaces/new overlay
// ≥1 workspaces → no overlay, fall through to dashboard
//
// The "un-onboarded but in workspace" state is now physically impossible
// because backend transactions atomically set onboarded_at when a user
// joins the `member` table. Anyone with workspaces is by definition
// onboarded.
// Onboarding and zero-workspace both resolve to an overlay, but
// onboarding wins: a user who hasn't completed it gets the onboarding
// overlay regardless of how many workspaces already exist.
useEffect(() => {
if (!user || !workspaceListFetched) return undefined;
if (!user || !workspaceListFetched) return;
const { overlay, open } = useWindowOverlayStore.getState();
if (overlay) return undefined;
if (wsCount > 0) return undefined;
if (overlay) return;
if (!hasOnboarded) {
// Look up pending invitations by email. Network blip is non-fatal —
// fall through to onboarding so the user isn't stuck on a blank
// window. The sidebar's pending-invitations dropdown will surface
// missed invites later once they're onboarded.
let cancelled = false;
void api
.listMyInvitations()
.then((invites) => {
if (cancelled) return;
const { overlay: latestOverlay, open: latestOpen } =
useWindowOverlayStore.getState();
if (latestOverlay) return;
if (invites.length > 0) {
qc.setQueryData(workspaceKeys.myInvitations(), invites);
latestOpen({ type: "invitations" });
} else {
latestOpen({ type: "onboarding" });
}
})
.catch(() => {
if (cancelled) return;
const { overlay: latestOverlay, open: latestOpen } =
useWindowOverlayStore.getState();
if (latestOverlay) return;
latestOpen({ type: "onboarding" });
});
return () => {
cancelled = true;
};
open({ type: "onboarding" });
return;
}
open({ type: "new-workspace" });
return undefined;
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded, qc]);
if (wsCount === 0) {
open({ type: "new-workspace" });
}
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded]);
// Validate persisted tab state against the current user's workspace list,
// and pick an active workspace if none is set. Runs in useLayoutEffect

View File

@@ -65,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,38 +1 @@
import "@testing-library/jest-dom/vitest";
function createMemoryStorage(): Storage {
const values = new Map<string, string>();
return {
get length() {
return values.size;
},
clear: () => values.clear(),
getItem: (key: string) => values.get(key) ?? null,
key: (index: number) => Array.from(values.keys())[index] ?? null,
removeItem: (key: string) => {
values.delete(key);
},
setItem: (key: string, value: string) => {
values.set(key, value);
},
};
}
const localStorageIsUsable =
typeof globalThis.localStorage?.getItem === "function" &&
typeof globalThis.localStorage?.setItem === "function" &&
typeof globalThis.localStorage?.removeItem === "function" &&
typeof globalThis.localStorage?.clear === "function";
if (!localStorageIsUsable) {
const storage = createMemoryStorage();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: storage,
});
Object.defineProperty(window, "localStorage", {
configurable: true,
value: storage,
});
}

View File

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

View File

@@ -1,144 +0,0 @@
---
title: Project Resources
description: Attach typed pointers (Git repos today, more later) to a project so agents can pick them up as scoped context.
---
A **Project Resource** is a typed pointer — a Git repo URL today, a Notion page or document link tomorrow — attached to a [project](/workspaces). When an [agent](/agents) runs against an issue inside that project, the daemon automatically writes the project's resource list into the agent's working directory and into its [meta-skill](/skills) prompt.
The result: the agent knows which repo to check out, which docs are the "primary references" for this project, without anyone copy-pasting context into the issue body.
## Mental model
A project is no longer just a label. It is a small **resource container**:
- A project has 0..N **resources**.
- A resource has a `resource_type` (e.g. `github_repo`) and a `resource_ref` (a JSON payload typed by `resource_type`).
- New resource types add a string + a handler. **No schema migration. No frontend rewrite.**
This shape is intentional — it's the same pattern Multica already uses for agent providers: a `type` discriminator and a typed payload. It keeps the schema stable so adding "Notion page", "Google Doc", "uploaded file", or "external URL" later is a small, additive change.
## Today: `github_repo`
The first resource type ships ready to use:
```json
{
"resource_type": "github_repo",
"resource_ref": {
"url": "https://github.com/owner/repo",
"default_branch_hint": "main"
}
}
```
`default_branch_hint` is optional — if present, the daemon surfaces it in the meta-skill so the agent knows which branch to base its work on.
## Attaching repos at project creation
In the **Web** or **Desktop** app, opening *New project* now shows a **Repos** pill alongside Status / Priority / Lead. Selecting workspace-bound repos (or pasting an ad-hoc URL) attaches them as `github_repo` resources the moment the project is created.
From the **CLI**:
```bash
# Create + attach in one shot. The server attaches resources in the same
# transaction as the project create — invalid resources roll back the whole
# operation, so you never end up with a project that has half its resources.
multica project create \
--title "Agent UX 2026" \
--repo https://github.com/multica-ai/multica
# Manage resources later
multica project resource list <project-id>
multica project resource add <project-id> --type github_repo --url <url>
multica project resource remove <project-id> <resource-id>
# Generic escape hatch for any resource_type the server understands —
# no CLI change needed when a new type ships:
multica project resource add <project-id> \
--type notion_page \
--ref '{"page_id":"…","title":"…"}'
```
`--repo` may be repeated; each value is attached as a separate `github_repo` resource.
## What the agent sees at runtime
When the daemon spawns an agent for an issue inside a project, two things happen:
### 1. `.multica/project/resources.json`
A structured pass-through of the API response, written into the agent's working directory:
```json
{
"project_id": "…",
"project_title": "Agent UX 2026",
"resources": [
{
"id": "…",
"resource_type": "github_repo",
"resource_ref": {
"url": "https://github.com/multica-ai/multica",
"default_branch_hint": "main"
}
}
]
}
```
Skills, helper scripts, or the agent itself can parse this file when they need the *exact* set of resources for the run.
### 2. A "Project Context" section in the meta-skill prompt
The agent's `CLAUDE.md` / `AGENTS.md` (depending on provider) now includes a human-readable summary:
```
## Project Context
This issue belongs to **Agent UX 2026**.
Project resources (also written to `.multica/project/resources.json`):
- **GitHub repo**: https://github.com/multica-ai/multica (default branch: `main`)
Resources are pointers — open them only when relevant to the task. For
`github_repo` resources, use `multica repo checkout <url>` to fetch the code.
```
The text is intentionally minimal. The full payload is on disk; the prompt only orients the agent so it knows the project exists and what's attached.
### Failure mode
Resource fetch is **best-effort**. If the API call fails, the project section is omitted from the prompt and the file is not written, but the task still starts. Agents never block on missing project context.
## Adding a new resource type
The whole point of the abstraction is that new types are cheap. The full path:
1. **Server validator** (`server/internal/handler/project_resource.go`) — add a case in `validateAndNormalizeResourceRef` that parses and normalizes the new payload.
2. **Daemon meta-skill formatter** (`server/internal/daemon/execenv/runtime_config.go`) — add a case in `formatProjectResource` so the agent prompt renders the new type as a readable bullet.
3. **TypeScript types** (`packages/core/types/project.ts`) — extend `ProjectResourceType` and add the payload interface.
4. **UI renderer** (`packages/views/projects/components/project-resources-section.tsx`) — add a case in `ResourceRow` for the new type.
There is **no schema migration**, no new sqlc query, no new endpoint, **and no CLI change** — the CLI's generic `--ref '<json>'` flag accepts any payload the validator understands, so day-one support for a new type is purely the four steps above. (You may *optionally* add a per-type CLI shortcut later; not required.)
The same `project_resource` table and the same three CRUD calls handle every type.
## Workspace repos vs. project repos
The repo list shown to the agent (`## Repositories` block in `CLAUDE.md` / `AGENTS.md`) is chosen by the daemon claim handler with this precedence:
- **Project has at least one `github_repo` resource** → only those repos are surfaced to the agent. Workspace-bound repos are intentionally hidden so the agent doesn't have to guess which one belongs to this issue.
- **Project has no `github_repo` resources (or the issue isn't in a project)** → fall back to the workspace's repo list as before.
This keeps the agent's working set tight: when a project is explicit about its repos, that's the authoritative answer. The structured resource list at `.multica/project/resources.json` always carries the full set, so a skill that wants to inspect everything still can.
The daemon mirrors this on the checkout side: when a task arrives with project-scoped `github_repo` URLs, those URLs are merged into the per-workspace allowlist *and* synced into the local repo cache before the agent spawns. So a project repo URL that isn't bound at the workspace level is still a valid argument to `multica repo checkout` — the daemon won't reject it as "not configured." The allowlist split is internal: workspace-bound URLs and task-scoped URLs are tracked separately, so a workspace-repos refresh doesn't accidentally revoke a project URL mid-run.
## What's intentionally **not** in scope here
- **Cross-project sharing.** Each resource lives on exactly one project today.
- **Per-skill resource scoping.** All resources are visible to every skill on the agent's run; type-aware filtering is a follow-up.
- **Caching / sync.** `github_repo` is just metadata — checkout still happens via `multica repo checkout` on demand. Cached document text for Notion / Google Docs will arrive with those types.
These are deliberate omissions — the goal of the first cut is to validate the abstraction with the smallest set of moving parts.

View File

@@ -1,28 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@multica/core/auth";
import { paths } from "@multica/core/paths";
import { InvitationsPage } from "@multica/views/invitations";
export default function InvitationsRoutePage() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
// Unauthenticated users have nowhere meaningful to land here — kick them
// through login and bring them back. The login page will eventually run
// its own listMyInvitations() check and route them here again.
useEffect(() => {
if (!isLoading && !user) {
router.replace(
`${paths.login()}?next=${encodeURIComponent(paths.invitations())}`,
);
}
}, [isLoading, user, router]);
if (isLoading || !user) return null;
return <InvitationsPage />;
}

View File

@@ -2,7 +2,7 @@
import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
import { useConfigStore } from "@multica/core/config";
import { workspaceKeys } from "@multica/core/workspace/queries";
@@ -27,32 +27,6 @@ import { setLoggedInCookie } from "@/features/auth/auth-cookie";
import Link from "next/link";
import { LoginPage, validateCliCallback } from "@multica/views/auth";
/**
* Pick where a logged-in user with no explicit `?next=` should land.
* Un-onboarded users with pending invitations on their email get routed to
* the batch /invitations page; everyone else falls through to the standard
* resolver. A network blip on listMyInvitations is non-fatal — we fall
* through rather than trap the user on an error screen.
*/
async function resolveLoggedInDestination(
qc: QueryClient,
hasOnboarded: boolean,
workspaces: Workspace[],
): Promise<string> {
if (!hasOnboarded) {
try {
const invites = await api.listMyInvitations();
if (invites.length > 0) {
qc.setQueryData(workspaceKeys.myInvitations(), invites);
return paths.invitations();
}
} catch {
// fall through
}
}
return resolvePostAuthDestination(workspaces, hasOnboarded);
}
function LoginPageContent() {
const router = useRouter();
const qc = useQueryClient();
@@ -98,28 +72,33 @@ function LoginPageContent() {
});
return;
}
if (!hasOnboarded) {
router.replace(paths.onboarding());
return;
}
if (nextUrl) {
router.replace(nextUrl);
return;
}
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
void resolveLoggedInDestination(qc, hasOnboarded, list).then((dest) =>
router.replace(dest),
);
router.replace(resolvePostAuthDestination(list, hasOnboarded));
}, [isLoading, user, router, nextUrl, cliCallbackRaw, isDesktopHandoff, hasOnboarded, qc]);
const handleSuccess = async () => {
const handleSuccess = () => {
// Read the latest user snapshot directly — the closure's `hasOnboarded`
// was captured before login completed and would be stale here.
const currentUser = useAuthStore.getState().user;
const onboarded = currentUser?.onboarded_at != null;
if (!onboarded) {
router.push(paths.onboarding());
return;
}
if (nextUrl) {
router.push(nextUrl);
return;
}
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
const dest = await resolveLoggedInDestination(qc, onboarded, list);
router.push(dest);
router.push(resolvePostAuthDestination(list, onboarded));
};
// Build Google OAuth state: encode platform + next URL so the callback

View File

@@ -32,7 +32,7 @@ export default function OnboardingPage() {
const hasOnboarded = useHasOnboarded();
const { data: workspaces = [], isFetched: workspacesFetched } = useQuery({
...workspaceListOptions(),
enabled: !!user,
enabled: !!user && hasOnboarded,
});
useEffect(() => {
@@ -40,15 +40,7 @@ export default function OnboardingPage() {
if (!isLoading && !user) router.replace(paths.login());
return;
}
if (!workspacesFetched) return;
// Bounce out only when onboarding genuinely doesn't apply: the user is
// already onboarded. We deliberately don't bounce on `workspaces.length`
// here — Step 3 of the flow creates a workspace mid-onboarding, and a
// hasWorkspaces bounce here would kick the user out before Steps 45
// (runtime / agent / first issue) can run. The new entry-point
// judgment in callback / login handles "where should this user go on
// login" so OnboardingPage no longer needs to second-guess it.
if (hasOnboarded) {
if (hasOnboarded && workspacesFetched) {
router.replace(resolvePostAuthDestination(workspaces, hasOnboarded));
}
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, router]);

View File

@@ -2,21 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, waitFor } from "@testing-library/react";
import { paths } from "@multica/core/paths";
const {
mockPush,
mockSearchParams,
mockLoginWithGoogle,
mockListWorkspaces,
mockListMyInvitations,
mockSetQueryData,
} = vi.hoisted(() => ({
mockPush: vi.fn(),
mockSearchParams: new URLSearchParams(),
mockLoginWithGoogle: vi.fn(),
mockListWorkspaces: vi.fn(),
mockListMyInvitations: vi.fn(),
mockSetQueryData: vi.fn(),
}));
const { mockPush, mockSearchParams, mockLoginWithGoogle, mockListWorkspaces } =
vi.hoisted(() => ({
mockPush: vi.fn(),
mockSearchParams: new URLSearchParams(),
mockLoginWithGoogle: vi.fn(),
mockListWorkspaces: vi.fn(),
}));
const makeUser = (overrides: Partial<{ onboarded_at: string | null }> = {}) => ({
id: "user-1",
@@ -36,7 +28,7 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ setQueryData: mockSetQueryData }),
useQueryClient: () => ({ setQueryData: vi.fn() }),
}));
// Preserve the real sanitizeNextUrl so the "drop unsafe ?next=" behavior is
@@ -54,16 +46,12 @@ vi.mock("@multica/core/auth", async () => {
});
vi.mock("@multica/core/workspace/queries", () => ({
workspaceKeys: {
list: () => ["workspaces"],
myInvitations: () => ["invitations", "mine"],
},
workspaceKeys: { list: () => ["workspaces"] },
}));
vi.mock("@multica/core/api", () => ({
api: {
listWorkspaces: mockListWorkspaces,
listMyInvitations: mockListMyInvitations,
googleLogin: vi.fn(),
},
}));
@@ -73,78 +61,26 @@ import CallbackPage from "./page";
describe("CallbackPage", () => {
beforeEach(() => {
vi.clearAllMocks();
// Snapshot keys before deleting — forEach + delete skips entries because
// the iteration index advances while the underlying list shrinks.
Array.from(mockSearchParams.keys()).forEach((k) =>
mockSearchParams.delete(k),
);
mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k));
mockSearchParams.set("code", "test-code");
mockLoginWithGoogle.mockResolvedValue(makeUser());
mockListWorkspaces.mockResolvedValue([]);
mockListMyInvitations.mockResolvedValue([]);
});
it("unonboarded user honors a safe next= (e.g. /invite/{id}) so invitees aren't trapped", async () => {
it("unonboarded user lands on /onboarding regardless of next=", async () => {
mockSearchParams.set("state", "next:/invite/abc123");
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
});
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
// nextUrl is a fast path — listMyInvitations should not be queried.
expect(mockListMyInvitations).not.toHaveBeenCalled();
});
it("unonboarded user with no next= and no pending invitations lands on /onboarding", async () => {
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
});
expect(mockListMyInvitations).toHaveBeenCalled();
expect(mockPush).not.toHaveBeenCalledWith("/invite/abc123");
});
it("unonboarded user with pending invitations lands on /invitations", async () => {
mockListMyInvitations.mockResolvedValue([
{
id: "inv-1",
workspace_id: "ws-1",
workspace_name: "Acme",
role: "member",
status: "pending",
},
]);
it("unonboarded user with no next= also lands on /onboarding", async () => {
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.invitations());
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
});
expect(mockPush).not.toHaveBeenCalledWith(paths.onboarding());
});
it("onboarded user with workspace lands in that workspace", async () => {
mockLoginWithGoogle.mockResolvedValue(
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
);
mockListWorkspaces.mockResolvedValue([
{
id: "ws-1",
name: "Acme",
slug: "acme",
description: null,
context: null,
settings: {},
repos: [],
issue_prefix: "ACME",
created_at: "",
updated_at: "",
},
]);
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.workspace("acme").issues());
});
// Already-onboarded users skip the listMyInvitations check; new invites
// surface in the sidebar instead of the wall.
expect(mockListMyInvitations).not.toHaveBeenCalled();
});
it("onboarded user ignores unsafe next= targets and lands on the default destination", async () => {
@@ -173,12 +109,4 @@ describe("CallbackPage", () => {
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
});
});
it("falls through to /onboarding when listMyInvitations errors", async () => {
mockListMyInvitations.mockRejectedValue(new Error("network"));
render(<CallbackPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
});
});
});

View File

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

View File

@@ -283,29 +283,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
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,29 +283,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
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,41 +0,0 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../platform/workspace-storage";
import { defaultStorage } from "../platform/storage";
interface FeedbackDraft {
message: string;
}
const EMPTY_DRAFT: FeedbackDraft = {
message: "",
};
interface FeedbackDraftStore {
draft: FeedbackDraft;
setDraft: (patch: Partial<FeedbackDraft>) => void;
clearDraft: () => void;
hasDraft: () => boolean;
}
export const useFeedbackDraftStore = create<FeedbackDraftStore>()(
persist(
(set, get) => ({
draft: { ...EMPTY_DRAFT },
setDraft: (patch) =>
set((s) => ({ draft: { ...s.draft, ...patch } })),
clearDraft: () =>
set({ draft: { ...EMPTY_DRAFT } }),
hasDraft: () => {
const { draft } = get();
return !!draft.message;
},
}),
{
name: "multica_feedback_draft",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useFeedbackDraftStore.persist.rehydrate());

View File

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

View File

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

View File

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

View File

@@ -1,34 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { useWorkspaceId } from "../hooks";
import { notificationPreferenceKeys } from "./queries";
import type { NotificationPreferences, NotificationPreferenceResponse } from "../types";
export function useUpdateNotificationPreferences() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (preferences: NotificationPreferences) =>
api.updateNotificationPreferences(preferences),
onMutate: async (preferences) => {
await qc.cancelQueries({ queryKey: notificationPreferenceKeys.all(wsId) });
const prev = qc.getQueryData<NotificationPreferenceResponse>(
notificationPreferenceKeys.all(wsId),
);
qc.setQueryData<NotificationPreferenceResponse>(
notificationPreferenceKeys.all(wsId),
(old) => old ? { ...old, preferences } : { workspace_id: wsId, preferences },
);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) {
qc.setQueryData(notificationPreferenceKeys.all(wsId), ctx.prev);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: notificationPreferenceKeys.all(wsId) });
},
});
}

View File

@@ -1,13 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
export const notificationPreferenceKeys = {
all: (wsId: string) => ["notification-preferences", wsId] as const,
};
export function notificationPreferenceOptions(wsId: string) {
return queryOptions({
queryKey: notificationPreferenceKeys.all(wsId),
queryFn: () => api.getNotificationPreferences(),
});
}

View File

@@ -16,8 +16,7 @@ export type OnboardingCompletionPath =
| "full" // Reached Step 5 (first_issue) with a runtime connected
| "runtime_skipped" // Step 3 skipped (no runtime) but still completed
| "cloud_waitlist" // Submitted the cloud waitlist form and skipped Step 3
| "skip_existing" // "I've done this before" from Welcome
| "invite_accept"; // Accepted at least one invite from /invitations
| "skip_existing"; // "I've done this before" from Welcome
export type TeamSize = "solo" | "team" | "other";

View File

@@ -35,9 +35,6 @@
"./inbox/queries": "./inbox/queries.ts",
"./inbox/mutations": "./inbox/mutations.ts",
"./inbox/ws-updaters": "./inbox/ws-updaters.ts",
"./notification-preferences": "./notification-preferences/index.ts",
"./notification-preferences/queries": "./notification-preferences/queries.ts",
"./notification-preferences/mutations": "./notification-preferences/mutations.ts",
"./chat": "./chat/index.ts",
"./chat/queries": "./chat/queries.ts",
"./chat/mutations": "./chat/mutations.ts",
@@ -49,8 +46,6 @@
"./agents/queries": "./agents/queries.ts",
"./agents/derive-presence": "./agents/derive-presence.ts",
"./agents/use-agent-presence": "./agents/use-agent-presence.ts",
"./agents/visibility-label": "./agents/visibility-label.ts",
"./permissions": "./permissions/index.ts",
"./projects": "./projects/index.ts",
"./projects/queries": "./projects/queries.ts",
"./projects/mutations": "./projects/mutations.ts",

View File

@@ -43,7 +43,6 @@ export const paths = {
login: () => "/login",
newWorkspace: () => "/workspaces/new",
invite: (id: string) => `/invite/${encode(id)}`,
invitations: () => "/invitations",
onboarding: () => "/onboarding",
authCallback: () => "/auth/callback",
root: () => "/",
@@ -55,7 +54,7 @@ export type WorkspacePaths = ReturnType<typeof workspaceScoped>;
// A path is global if it equals or begins with any of these.
// Note: `/workspaces/` (trailing slash) is the prefix — `workspaces` is reserved,
// so any path starting with `/workspaces/...` is system-owned, not user-owned.
const GLOBAL_PREFIXES = ["/login", "/workspaces/", "/invite/", "/invitations", "/onboarding", "/auth/", "/logout", "/signup"];
const GLOBAL_PREFIXES = ["/login", "/workspaces/", "/invite/", "/onboarding", "/auth/", "/logout", "/signup"];
export function isGlobalPath(path: string): boolean {
return GLOBAL_PREFIXES.some((p) => path === p || path.startsWith(p));

View File

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

View File

@@ -19,16 +19,14 @@ function makeWs(slug: string): Workspace {
}
describe("resolvePostAuthDestination", () => {
it("!onboarded → /onboarding regardless of workspace count", () => {
// Un-onboarded users are routed back to the onboarding flow. The
// "un-onboarded but in workspace" state is now physically impossible
// (backend invariant + migration 065 backfill), but the resolver still
// does the right thing if it ever appears: send the user to onboarding
// rather than dropping them into a workspace with `onboarded_at` null.
it("not onboarded → /onboarding regardless of workspaces", () => {
expect(resolvePostAuthDestination([], false)).toBe(paths.onboarding());
expect(resolvePostAuthDestination([makeWs("acme")], false)).toBe(
paths.onboarding(),
);
expect(
resolvePostAuthDestination([makeWs("acme"), makeWs("beta")], false),
).toBe(paths.onboarding());
});
it("onboarded + has workspace → /<first.slug>/issues", () => {

View File

@@ -7,18 +7,6 @@ import { paths } from "./paths";
* !hasOnboarded → /onboarding
* hasOnboarded && has workspace → /<first.slug>/issues
* hasOnboarded && zero workspaces → /workspaces/new
*
* `onboarded_at` is the single source of truth for whether the user has
* passed first-contact. Backend transactions (CreateWorkspace,
* AcceptInvitation) atomically set this field whenever a user joins a
* `member` row, so "has workspace but !onboarded" is now a
* physically impossible state — see migration 065 for the existing-data
* backfill that closed the door retroactively.
*
* Callers that need invitation-aware routing (callback / login) handle the
* "un-onboarded with pending invites" branch themselves before calling
* this resolver — this resolver only deals with the post-invite-check
* destination.
*/
export function resolvePostAuthDestination(
workspaces: Workspace[],
@@ -28,10 +16,7 @@ export function resolvePostAuthDestination(
return paths.onboarding();
}
const first = workspaces[0];
if (first) {
return paths.workspace(first.slug).issues();
}
return paths.newWorkspace();
return first ? paths.workspace(first.slug).issues() : paths.newWorkspace();
}
/**

View File

@@ -1,20 +0,0 @@
/**
* Public API for the permissions module.
*
* Exports only what the views currently consume. The full pure-rule set lives
* in `./rules` and is available to tests and future surfaces directly. Adding
* a new rule to the public API should follow the same minimum-surface pattern
* — only export when there's a caller.
*/
export type {
Decision,
DecisionReason,
PermissionContext,
} from "./types";
export { canAssignAgentToIssue, canEditAgent } from "./rules";
export {
useAgentPermissions,
useSkillPermissions,
} from "./use-resource-permissions";

View File

@@ -1,329 +0,0 @@
import { describe, expect, it } from "vitest";
import type { Agent, Comment, Member, RuntimeDevice, Skill } from "../types";
import {
canAssignAgentToIssue,
canChangeMemberRole,
canDeleteComment,
canDeleteRuntime,
canDeleteSkill,
canDeleteWorkspace,
canEditAgent,
canEditComment,
canEditSkill,
canManageMembers,
canUpdateWorkspaceSettings,
} from "./rules";
const ALICE = "user-alice";
const BOB = "user-bob";
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: "agt_1",
workspace_id: "ws_1",
runtime_id: "rt_1",
name: "agent",
description: "",
instructions: "",
avatar_url: null,
runtime_mode: "local",
runtime_config: {},
custom_env: {},
custom_args: [],
custom_env_redacted: false,
visibility: "workspace",
status: "idle",
max_concurrent_tasks: 1,
model: "default",
owner_id: ALICE,
skills: [],
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
archived_at: null,
archived_by: null,
...overrides,
};
}
function makeSkill(createdBy: string | null): Skill {
return {
id: "skl_1",
workspace_id: "ws_1",
name: "skill",
description: "",
content: "",
config: {},
files: [],
created_by: createdBy,
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
};
}
function makeComment(overrides: Partial<Comment> = {}): Comment {
return {
id: "cmt_1",
issue_id: "iss_1",
author_type: "member",
author_id: ALICE,
content: "hi",
type: "comment",
parent_id: null,
reactions: [],
attachments: [],
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
...overrides,
};
}
function makeRuntime(ownerId: string | null): RuntimeDevice {
return {
id: "rt_1",
workspace_id: "ws_1",
daemon_id: null,
name: "runtime",
runtime_mode: "local",
provider: "anthropic",
launch_header: "",
status: "online",
device_info: "",
metadata: {},
owner_id: ownerId,
last_seen_at: null,
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
};
}
describe("canEditAgent", () => {
const agent = makeAgent({ owner_id: ALICE });
it("allows the owner", () => {
expect(canEditAgent(agent, { userId: ALICE, role: "member" }).allowed).toBe(
true,
);
});
it("allows workspace owner", () => {
expect(canEditAgent(agent, { userId: BOB, role: "owner" }).allowed).toBe(
true,
);
});
it("allows workspace admin", () => {
expect(canEditAgent(agent, { userId: BOB, role: "admin" }).allowed).toBe(
true,
);
});
it("denies non-owner member", () => {
const d = canEditAgent(agent, { userId: BOB, role: "member" });
expect(d.allowed).toBe(false);
expect(d.reason).toBe("not_resource_owner");
});
it("denies when userId is null", () => {
const d = canEditAgent(agent, { userId: null, role: null });
expect(d.allowed).toBe(false);
expect(d.reason).toBe("not_authenticated");
});
it("denies when agent owner_id is null and user is plain member", () => {
const orphan = makeAgent({ owner_id: null });
expect(
canEditAgent(orphan, { userId: ALICE, role: "member" }).allowed,
).toBe(false);
});
it("admin can still edit an orphan (owner_id null) agent", () => {
const orphan = makeAgent({ owner_id: null });
expect(canEditAgent(orphan, { userId: BOB, role: "admin" }).allowed).toBe(
true,
);
});
});
describe("canAssignAgentToIssue", () => {
it("allows any member to assign workspace-visibility agents", () => {
const a = makeAgent({ visibility: "workspace", owner_id: ALICE });
expect(
canAssignAgentToIssue(a, { userId: BOB, role: "member" }).allowed,
).toBe(true);
});
it("denies non-members from assigning workspace agents", () => {
const a = makeAgent({ visibility: "workspace", owner_id: ALICE });
const d = canAssignAgentToIssue(a, { userId: BOB, role: null });
expect(d.allowed).toBe(false);
expect(d.reason).toBe("not_member");
});
it("allows the owner to assign their private agent", () => {
const a = makeAgent({ visibility: "private", owner_id: ALICE });
expect(
canAssignAgentToIssue(a, { userId: ALICE, role: "member" }).allowed,
).toBe(true);
});
it("allows workspace admin to assign someone else's private agent", () => {
const a = makeAgent({ visibility: "private", owner_id: ALICE });
expect(
canAssignAgentToIssue(a, { userId: BOB, role: "admin" }).allowed,
).toBe(true);
});
it("denies a plain member from assigning someone else's private agent", () => {
const a = makeAgent({ visibility: "private", owner_id: ALICE });
const d = canAssignAgentToIssue(a, { userId: BOB, role: "member" });
expect(d.allowed).toBe(false);
expect(d.reason).toBe("private_visibility");
});
it("denies logged-out users", () => {
const a = makeAgent({ visibility: "workspace" });
const d = canAssignAgentToIssue(a, { userId: null, role: null });
expect(d.allowed).toBe(false);
expect(d.reason).toBe("not_authenticated");
});
});
describe("canEditSkill / canDeleteSkill", () => {
const skill = makeSkill(ALICE);
it("allows admins", () => {
expect(canEditSkill(skill, { userId: BOB, role: "admin" }).allowed).toBe(
true,
);
});
it("allows the creator", () => {
expect(canEditSkill(skill, { userId: ALICE, role: "member" }).allowed)
.toBe(true);
});
it("denies non-creator member", () => {
expect(canEditSkill(skill, { userId: BOB, role: "member" }).allowed)
.toBe(false);
});
it("denies when created_by is null and user is plain member", () => {
expect(
canEditSkill(makeSkill(null), { userId: ALICE, role: "member" }).allowed,
).toBe(false);
});
it("canDeleteSkill mirrors canEditSkill", () => {
expect(canDeleteSkill(skill, { userId: ALICE, role: "member" }).allowed)
.toBe(true);
expect(canDeleteSkill(skill, { userId: BOB, role: "member" }).allowed)
.toBe(false);
});
});
describe("canEditComment / canDeleteComment", () => {
it("allows the author to edit their own comment", () => {
const c = makeComment({ author_id: ALICE });
expect(canEditComment(c, { userId: ALICE, role: "member" }).allowed).toBe(
true,
);
});
it("allows workspace admin to edit someone else's comment", () => {
const c = makeComment({ author_id: ALICE });
expect(canEditComment(c, { userId: BOB, role: "admin" }).allowed).toBe(
true,
);
});
it("denies non-author non-admin", () => {
const c = makeComment({ author_id: ALICE });
expect(canEditComment(c, { userId: BOB, role: "member" }).allowed).toBe(
false,
);
});
it("denies edit on agent-authored comments", () => {
const c = makeComment({ author_type: "agent", author_id: "agt_1" });
const d = canEditComment(c, { userId: BOB, role: "owner" });
expect(d.allowed).toBe(false);
expect(d.reason).toBe("not_resource_owner");
});
it("admin CAN delete an agent-authored comment", () => {
// delete is broader than edit — admins moderate any comment regardless of
// author type. Mirrors backend `comment.go:507-512`.
const c = makeComment({ author_type: "agent", author_id: "agt_1" });
expect(canDeleteComment(c, { userId: BOB, role: "admin" }).allowed).toBe(
true,
);
});
it("denies plain member from deleting agent-authored comment", () => {
const c = makeComment({ author_type: "agent", author_id: "agt_1" });
expect(
canDeleteComment(c, { userId: BOB, role: "member" }).allowed,
).toBe(false);
});
});
describe("canDeleteRuntime", () => {
it("allows the owner", () => {
const r = makeRuntime(ALICE);
expect(canDeleteRuntime(r, { userId: ALICE, role: "member" }).allowed)
.toBe(true);
});
it("allows workspace admin", () => {
const r = makeRuntime(ALICE);
expect(canDeleteRuntime(r, { userId: BOB, role: "admin" }).allowed).toBe(
true,
);
});
it("denies non-owner non-admin", () => {
const r = makeRuntime(ALICE);
expect(canDeleteRuntime(r, { userId: BOB, role: "member" }).allowed)
.toBe(false);
});
});
describe("workspace-level rules", () => {
it("only owner can delete workspace", () => {
expect(canDeleteWorkspace({ userId: ALICE, role: "owner" }).allowed).toBe(
true,
);
expect(canDeleteWorkspace({ userId: ALICE, role: "admin" }).allowed).toBe(
false,
);
expect(canDeleteWorkspace({ userId: ALICE, role: "member" }).allowed)
.toBe(false);
});
it("owner+admin can update settings, member cannot", () => {
expect(
canUpdateWorkspaceSettings({ userId: ALICE, role: "owner" }).allowed,
).toBe(true);
expect(
canUpdateWorkspaceSettings({ userId: ALICE, role: "admin" }).allowed,
).toBe(true);
expect(
canUpdateWorkspaceSettings({ userId: ALICE, role: "member" }).allowed,
).toBe(false);
});
it("manage members same gate as settings", () => {
expect(canManageMembers({ userId: ALICE, role: "admin" }).allowed).toBe(
true,
);
expect(canManageMembers({ userId: ALICE, role: "member" }).allowed).toBe(
false,
);
});
});
describe("canChangeMemberRole", () => {
const ctxOwner = { userId: ALICE, role: "owner" as const };
const ctxAdmin = { userId: ALICE, role: "admin" as const };
const ctxMember = { userId: ALICE, role: "member" as const };
const targetOwner: Pick<Member, "role"> = { role: "owner" };
const targetAdmin: Pick<Member, "role"> = { role: "admin" };
const targetMember: Pick<Member, "role"> = { role: "member" };
it("non-managers cannot change roles", () => {
expect(canChangeMemberRole(targetMember, 2, ctxMember).allowed).toBe(false);
});
it("admin cannot change owner's role", () => {
const d = canChangeMemberRole(targetOwner, 2, ctxAdmin);
expect(d.allowed).toBe(false);
expect(d.reason).toBe("not_owner_role");
});
it("admin can change admin/member roles", () => {
expect(canChangeMemberRole(targetAdmin, 1, ctxAdmin).allowed).toBe(true);
expect(canChangeMemberRole(targetMember, 1, ctxAdmin).allowed).toBe(true);
});
it("owner cannot demote the last owner", () => {
const d = canChangeMemberRole(targetOwner, 1, ctxOwner);
expect(d.allowed).toBe(false);
expect(d.reason).toBe("last_owner");
});
it("owner can change owner role when 2+ owners exist", () => {
expect(canChangeMemberRole(targetOwner, 2, ctxOwner).allowed).toBe(true);
});
});

View File

@@ -1,210 +0,0 @@
import type {
Agent,
Comment,
Member,
MemberRole,
RuntimeDevice,
Skill,
} from "../types";
import { ALLOW, deny, type Decision, type PermissionContext } from "./types";
/**
* Pure permission rules — single source of truth that mirrors the Go backend
* gates in `server/internal/handler/`. Hooks in `use-resource-permissions.ts`
* are thin wrappers that pull `PermissionContext` from auth + member queries
* and forward to these.
*
* Returning a `Decision` (not a boolean) lets every surface — disabled state,
* tooltip, banner copy — read the same `reason` and stay consistent without
* sprinkling copy through the view layer.
*/
const isAdminLike = (role: MemberRole | null) =>
role === "owner" || role === "admin";
// ---- Agents ----------------------------------------------------------------
/**
* Update / archive / restore agent fields. The backend gates archive and
* restore identically to edit (`server/internal/handler/agent.go:519-535`),
* so callers can use `canEditAgent` for all three.
*/
export function canEditAgent(agent: Agent, ctx: PermissionContext): Decision {
if (ctx.userId === null) {
return deny("not_authenticated", "Sign in to edit this agent.");
}
if (isAdminLike(ctx.role)) return ALLOW;
if (agent.owner_id !== null && agent.owner_id === ctx.userId) return ALLOW;
return deny(
"not_resource_owner",
"Only the agent owner and workspace admins can edit this agent.",
);
}
/**
* Assign an agent to an issue. Workspace-visibility agents are assignable by
* any workspace member; private agents are restricted to their owner plus
* workspace admins/owners. Mirrors `issue.go:1471-1490`.
*/
export function canAssignAgentToIssue(
agent: Agent,
ctx: PermissionContext,
): Decision {
if (ctx.userId === null) {
return deny("not_authenticated", "Sign in to assign agents.");
}
if (agent.visibility === "workspace") {
if (ctx.role === null) {
return deny("not_member", "Join this workspace to assign agents.");
}
return ALLOW;
}
// visibility === "private"
if (isAdminLike(ctx.role)) return ALLOW;
if (agent.owner_id !== null && agent.owner_id === ctx.userId) return ALLOW;
return deny(
"private_visibility",
"Personal agent — only the owner and workspace admins can assign work.",
);
}
// ---- Skills ----------------------------------------------------------------
export function canEditSkill(skill: Skill, ctx: PermissionContext): Decision {
if (ctx.userId === null) {
return deny("not_authenticated", "Sign in to edit this skill.");
}
if (isAdminLike(ctx.role)) return ALLOW;
if (skill.created_by !== null && skill.created_by === ctx.userId) {
return ALLOW;
}
return deny(
"not_resource_owner",
"Only the creator and workspace admins can edit this skill.",
);
}
export function canDeleteSkill(skill: Skill, ctx: PermissionContext): Decision {
return canEditSkill(skill, ctx);
}
// ---- Comments --------------------------------------------------------------
export function canEditComment(
comment: Comment,
ctx: PermissionContext,
): Decision {
if (ctx.userId === null) {
return deny("not_authenticated", "Sign in to edit comments.");
}
// Only member-authored comments can be edited; agent-authored comments are
// immutable from any human's perspective.
if (comment.author_type !== "member") {
return deny(
"not_resource_owner",
"Agent-authored comments cannot be edited.",
);
}
if (comment.author_id === ctx.userId) return ALLOW;
if (isAdminLike(ctx.role)) return ALLOW;
return deny(
"not_resource_owner",
"Only the author and workspace admins can edit this comment.",
);
}
export function canDeleteComment(
comment: Comment,
ctx: PermissionContext,
): Decision {
if (ctx.userId === null) {
return deny("not_authenticated", "Sign in to delete comments.");
}
if (comment.author_type === "member" && comment.author_id === ctx.userId) {
return ALLOW;
}
if (isAdminLike(ctx.role)) return ALLOW;
return deny(
"not_resource_owner",
"Only the author and workspace admins can delete this comment.",
);
}
// ---- Runtimes --------------------------------------------------------------
export function canDeleteRuntime(
runtime: RuntimeDevice,
ctx: PermissionContext,
): Decision {
if (ctx.userId === null) {
return deny("not_authenticated", "Sign in to delete runtimes.");
}
if (isAdminLike(ctx.role)) return ALLOW;
if (runtime.owner_id !== null && runtime.owner_id === ctx.userId) {
return ALLOW;
}
return deny(
"not_resource_owner",
"Only the runtime owner and workspace admins can delete this runtime.",
);
}
// ---- Workspace -------------------------------------------------------------
export function canUpdateWorkspaceSettings(ctx: PermissionContext): Decision {
if (isAdminLike(ctx.role)) return ALLOW;
return deny(
"not_admin_role",
"Only workspace owners and admins can update workspace settings.",
);
}
export function canDeleteWorkspace(ctx: PermissionContext): Decision {
if (ctx.role === "owner") return ALLOW;
return deny(
"not_owner_role",
"Only the workspace owner can delete this workspace.",
);
}
export function canManageMembers(ctx: PermissionContext): Decision {
if (isAdminLike(ctx.role)) return ALLOW;
return deny(
"not_admin_role",
"Only workspace owners and admins can manage members.",
);
}
/**
* Encodes the role-change matrix from `workspace.go:458-530`:
* - admins cannot touch the owner role (neither demote owners nor promote)
* - the last owner cannot be demoted
* - non-managers cannot change roles at all
*
* `ownerCount` is the number of workspace members currently with role=owner.
* Caller derives it locally from the cached member list.
*/
export function canChangeMemberRole(
target: Pick<Member, "role">,
ownerCount: number,
ctx: PermissionContext,
): Decision {
const manage = canManageMembers(ctx);
if (!manage.allowed) return manage;
if (target.role === "owner") {
if (ctx.role !== "owner") {
return deny(
"not_owner_role",
"Only the workspace owner can change another owner's role.",
);
}
if (ownerCount <= 1) {
return deny(
"last_owner",
"Promote another member to owner first — a workspace must keep at least one owner.",
);
}
}
return ALLOW;
}

View File

@@ -1,52 +0,0 @@
import type { MemberRole } from "../types";
/**
* Inputs to every permission rule. Stays role-typed so we don't have to thread
* `MemberWithUser` (with PII) into pure logic — only what we actually need.
*
* `userId === null` models the logged-out edge case; `role === null` models the
* "not a workspace member" / "member list still loading" case. Both must
* gracefully deny without throwing.
*/
export interface PermissionContext {
userId: string | null;
role: MemberRole | null;
}
/**
* Stable enum of *why* a permission was denied (or allowed). Lets UIs pick
* different copy / disabled states / banner variants without parsing the
* `message` string. Tests assert on `reason`.
*/
export type DecisionReason =
| "allowed"
| "not_authenticated"
| "not_member"
| "not_owner_role"
| "not_admin_role"
| "not_resource_owner"
| "last_owner"
| "private_visibility"
| "unknown";
export interface Decision {
allowed: boolean;
reason: DecisionReason;
/**
* Human-readable copy for tooltips / banners. Centralised here so view code
* doesn't drift. UI may still wrap it for emphasis but should not invent
* its own copy.
*/
message: string;
}
/** Builder helpers — keeps rules.ts tight. */
export const ALLOW: Decision = {
allowed: true,
reason: "allowed",
message: "",
};
export function deny(reason: DecisionReason, message: string): Decision {
return { allowed: false, reason, message };
}

View File

@@ -1,32 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "../auth";
import type { MemberRole, MemberWithUser } from "../types";
import { memberListOptions } from "../workspace/queries";
/**
* Resolves the current user's membership in the given workspace. Single source
* of truth for "what role am I" — replaces ad-hoc `members.find(...)` lookups
* scattered across the views.
*
* `wsId` is explicit (not via `useWorkspaceId()` Context) so this hook stays
* usable in components that may render before workspace context is wired,
* matching the repo rule for workspace-aware hooks.
*/
export function useCurrentMember(wsId: string): {
userId: string | null;
role: MemberRole | null;
member: MemberWithUser | null;
isLoading: boolean;
} {
const userId = useAuthStore((s) => s.user?.id ?? null);
const { data: members, isLoading } = useQuery(memberListOptions(wsId));
const member = members?.find((m) => m.user_id === userId) ?? null;
return {
userId,
role: member?.role ?? null,
member,
isLoading,
};
}

View File

@@ -1,65 +0,0 @@
"use client";
import type { Agent, Skill } from "../types";
import { useCurrentMember } from "./use-current-member";
import {
canAssignAgentToIssue,
canDeleteSkill,
canEditAgent,
canEditSkill,
} from "./rules";
import { deny, type Decision } from "./types";
const PENDING: Decision = deny("unknown", "");
/**
* Per-resource hook that returns a `Decision` for every relevant capability.
* Each hook calls `useCurrentMember()` once and threads the context into the
* pure rules in `rules.ts`.
*
* `wsId` is explicit (not read from `WorkspaceIdProvider`) so the hook stays
* usable outside a workspace context — matches the repo rule for
* workspace-aware hooks.
*
* Resource = `null` collapses every Decision to a denied "unknown" — keeps
* callers branch-free during loading.
*
* `canArchive` / `canRestore` / `canManage` are deliberately not exposed:
* the backend gates them identically to `canEdit`, so callers can use
* `canEdit` everywhere and read better at the call site.
*/
export function useAgentPermissions(
agent: Agent | null,
wsId: string,
): {
canEdit: Decision;
canAssign: Decision;
} {
const { userId, role } = useCurrentMember(wsId);
const ctx = { userId, role };
if (agent === null) {
return { canEdit: PENDING, canAssign: PENDING };
}
return {
canEdit: canEditAgent(agent, ctx),
canAssign: canAssignAgentToIssue(agent, ctx),
};
}
export function useSkillPermissions(
skill: Skill | null,
wsId: string,
): {
canEdit: Decision;
canDelete: Decision;
} {
const { userId, role } = useCurrentMember(wsId);
const ctx = { userId, role };
if (skill === null) {
return { canEdit: PENDING, canDelete: PENDING };
}
return {
canEdit: canEditSkill(skill, ctx),
canDelete: canDeleteSkill(skill, ctx),
};
}

View File

@@ -1,54 +0,0 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { ProjectStatus, ProjectPriority } from "../types";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../platform/workspace-storage";
import { defaultStorage } from "../platform/storage";
interface ProjectDraft {
title: string;
description: string;
status: ProjectStatus;
priority: ProjectPriority;
leadType?: "member" | "agent";
leadId?: string;
icon?: string;
}
const EMPTY_DRAFT: ProjectDraft = {
title: "",
description: "",
status: "planned",
priority: "none",
leadType: undefined,
leadId: undefined,
icon: undefined,
};
interface ProjectDraftStore {
draft: ProjectDraft;
setDraft: (patch: Partial<ProjectDraft>) => void;
clearDraft: () => void;
hasDraft: () => boolean;
}
export const useProjectDraftStore = create<ProjectDraftStore>()(
persist(
(set, get) => ({
draft: { ...EMPTY_DRAFT },
setDraft: (patch) =>
set((s) => ({ draft: { ...s.draft, ...patch } })),
clearDraft: () =>
set({ draft: { ...EMPTY_DRAFT } }),
hasDraft: () => {
const { draft } = get();
return !!(draft.title || draft.description);
},
}),
{
name: "multica_project_draft",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useProjectDraftStore.persist.rehydrate());

View File

@@ -1,9 +1,2 @@
export { projectKeys, projectListOptions, projectDetailOptions } from "./queries";
export { useCreateProject, useUpdateProject, useDeleteProject } from "./mutations";
export { useProjectDraftStore } from "./draft-store";
export {
projectResourceKeys,
projectResourcesOptions,
useCreateProjectResource,
useDeleteProjectResource,
} from "./resource-queries";

View File

@@ -1,87 +0,0 @@
import { queryOptions, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { projectKeys } from "./queries";
import type {
CreateProjectResourceRequest,
ListProjectResourcesResponse,
ProjectResource,
} from "../types";
export const projectResourceKeys = {
list: (wsId: string, projectId: string) =>
[...projectKeys.detail(wsId, projectId), "resources"] as const,
};
export function projectResourcesOptions(wsId: string, projectId: string) {
return queryOptions({
queryKey: projectResourceKeys.list(wsId, projectId),
queryFn: () => api.listProjectResources(projectId),
select: (data) => data.resources,
});
}
export function useCreateProjectResource(wsId: string, projectId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateProjectResourceRequest) =>
api.createProjectResource(projectId, data),
onSuccess: (created) => {
qc.setQueryData<ListProjectResourcesResponse>(
projectResourceKeys.list(wsId, projectId),
(old) =>
old && !old.resources.some((r) => r.id === created.id)
? {
...old,
resources: [...old.resources, created],
total: old.total + 1,
}
: old,
);
},
onSettled: () => {
qc.invalidateQueries({
queryKey: projectResourceKeys.list(wsId, projectId),
});
},
});
}
export function useDeleteProjectResource(wsId: string, projectId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (resourceId: string) =>
api.deleteProjectResource(projectId, resourceId),
onMutate: async (resourceId) => {
await qc.cancelQueries({
queryKey: projectResourceKeys.list(wsId, projectId),
});
const prev = qc.getQueryData<ListProjectResourcesResponse>(
projectResourceKeys.list(wsId, projectId),
);
qc.setQueryData<ListProjectResourcesResponse>(
projectResourceKeys.list(wsId, projectId),
(old) =>
old
? {
...old,
resources: old.resources.filter(
(r: ProjectResource) => r.id !== resourceId,
),
total: old.total - 1,
}
: old,
);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) {
qc.setQueryData(projectResourceKeys.list(wsId, projectId), ctx.prev);
}
},
onSettled: () => {
qc.invalidateQueries({
queryKey: projectResourceKeys.list(wsId, projectId),
});
},
});
}

View File

@@ -38,7 +38,6 @@ export type {
} from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference";
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";
export type { TimelineEntry, AssigneeFrequencyEntry } from "./activity";
@@ -48,19 +47,7 @@ export type * from "./api";
export type { Attachment } from "./attachment";
export type { ChatSession, ChatMessage, ChatPendingTask, PendingChatTaskItem, PendingChatTasksResponse, SendChatMessageResponse } from "./chat";
export type { StorageAdapter } from "./storage";
export type {
Project,
ProjectStatus,
ProjectPriority,
CreateProjectRequest,
UpdateProjectRequest,
ListProjectsResponse,
ProjectResource,
ProjectResourceType,
GithubRepoResourceRef,
CreateProjectResourceRequest,
ListProjectResourcesResponse,
} from "./project";
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
export type {
Autopilot,

View File

@@ -1,15 +0,0 @@
export type NotificationGroupKey =
| "assignments"
| "status_changes"
| "comments"
| "updates"
| "agent_activity";
export type NotificationGroupValue = "all" | "muted";
export type NotificationPreferences = Partial<Record<NotificationGroupKey, NotificationGroupValue>>;
export interface NotificationPreferenceResponse {
workspace_id: string;
preferences: NotificationPreferences;
}

View File

@@ -26,9 +26,6 @@ export interface CreateProjectRequest {
priority?: ProjectPriority;
lead_type?: "member" | "agent";
lead_id?: string;
// Resources to attach in the same transaction as the project. Server returns
// 4xx (and rolls back) if any one is invalid or duplicate.
resources?: CreateProjectResourceRequest[];
}
export interface UpdateProjectRequest {
@@ -45,39 +42,3 @@ export interface ListProjectsResponse {
projects: Project[];
total: number;
}
// ProjectResource is a typed pointer from a project to an external resource.
// The resource_ref shape depends on resource_type (e.g. github_repo carries
// { url, default_branch_hint? }). New types add a case in
// validateAndNormalizeResourceRef on the server and a renderer in the UI;
// no schema or type changes required.
export type ProjectResourceType = "github_repo";
export interface GithubRepoResourceRef {
url: string;
default_branch_hint?: string;
}
export interface ProjectResource {
id: string;
project_id: string;
workspace_id: string;
resource_type: ProjectResourceType;
resource_ref: GithubRepoResourceRef | Record<string, unknown>;
label: string | null;
position: number;
created_at: string;
created_by: string | null;
}
export interface CreateProjectResourceRequest {
resource_type: ProjectResourceType;
resource_ref: GithubRepoResourceRef | Record<string, unknown>;
label?: string;
position?: number;
}
export interface ListProjectResourcesResponse {
resources: ProjectResource[];
total: number;
}

View File

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

View File

@@ -1,90 +0,0 @@
import { Lock } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
type Resource = "agent" | "skill" | "comment" | "runtime" | "workspace";
type Reason =
| "allowed"
| "not_authenticated"
| "not_member"
| "not_owner_role"
| "not_admin_role"
| "not_resource_owner"
| "last_owner"
| "private_visibility"
| "unknown";
const RESOURCE_NOUN: Record<Resource, string> = {
agent: "agent",
skill: "skill",
comment: "comment",
runtime: "runtime",
workspace: "workspace",
};
/**
* Read-only banner for resource detail pages — appears when the current user
* cannot edit the resource. Single component owns all the copy variants so
* the wording stays consistent across agent, skill, runtime detail pages.
*
* Returns `null` when the user *can* edit (reason === "allowed") so callers
* can mount it unconditionally.
*/
export function CapabilityBanner({
reason,
resource,
ownerName,
className,
}: {
reason: Reason;
resource: Resource;
/** Display name of the resource owner / creator. Optional — copy degrades gracefully. */
ownerName?: string;
className?: string;
}) {
if (reason === "allowed" || reason === "unknown") return null;
const noun = RESOURCE_NOUN[resource];
const message = getCopy(reason, noun, ownerName);
return (
<div
role="status"
className={cn(
"flex items-center gap-2 rounded-md border border-dashed bg-muted/30 px-3 py-2 text-xs text-muted-foreground",
className,
)}
>
<Lock className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span>{message}</span>
</div>
);
}
function getCopy(reason: Reason, noun: string, ownerName?: string): string {
switch (reason) {
case "not_authenticated":
return `Sign in to edit this ${noun}.`;
case "not_member":
return `Join this workspace to edit this ${noun}.`;
case "not_owner_role":
return `View only — only the workspace owner can manage this ${noun}.`;
case "not_admin_role":
return `View only — only workspace owners and admins can manage this ${noun}.`;
case "not_resource_owner":
if (ownerName) {
return `View only — only ${ownerName} and workspace admins can edit this ${noun}.`;
}
return `View only — only the ${noun} owner and workspace admins can edit this ${noun}.`;
case "last_owner":
return `A workspace must keep at least one owner — promote another member first.`;
case "private_visibility":
if (ownerName) {
return `Personal ${noun} — only ${ownerName} and workspace admins can use this.`;
}
return `Personal ${noun} — only the owner and workspace admins can use this.`;
case "allowed":
case "unknown":
return ""; // unreachable; component returned null above
}
}

View File

@@ -36,8 +36,6 @@ function FileUploadButton({
type="button"
onClick={() => inputRef.current?.click()}
disabled={disabled}
aria-label="Attach file"
title="Attach file"
className={cn(
"inline-flex items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none",
btnSize,

View File

@@ -2,11 +2,10 @@
import {
flexRender,
type Header as TanstackHeader,
type Row,
type Table as TanstackTable,
} from "@tanstack/react-table";
import * as React from "react";
import type * as React from "react";
// We deliberately use the lower-level shadcn primitives (TableHeader /
// TableBody / TableRow / TableHead / TableCell) but NOT the wrapping
@@ -49,8 +48,8 @@ interface DataTableProps<TData> extends React.ComponentProps<"div"> {
// makes each column's width come from its first row's <th>
// inline width. column.size is authoritative for sized columns.
// - Columns flagged `meta.grow: true` skip their inline width, so
// fixed table-layout assigns them the leftover space until the user
// resizes them. Once resized, the explicit width is applied.
// fixed table-layout assigns them the leftover space (no spacer
// column needed).
// - The table's `min-width` is the sum of every column's TanStack
// size (`table.getTotalSize()`). That gives grow columns a real
// floor — fixed mode ignores cell-level min-width, but it does
@@ -65,98 +64,6 @@ export function DataTable<TData>({
className,
...props
}: DataTableProps<TData>) {
const [resizingColumnId, setResizingColumnId] = React.useState<string | null>(
null,
);
const columnSizing = table.getState().columnSizing;
const hasExplicitSize = React.useCallback(
(columnId: string) =>
Object.prototype.hasOwnProperty.call(columnSizing, columnId),
[columnSizing],
);
const setColumnWidth = React.useCallback(
(header: TanstackHeader<TData, unknown>, width: number) => {
const minSize = header.column.columnDef.minSize ?? 48;
const maxSize =
header.column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER;
const next = Math.min(maxSize, Math.max(minSize, Math.round(width)));
table.setColumnSizing((old) => ({
...old,
[header.column.id]: next,
}));
},
[table],
);
const beginColumnResize = React.useCallback(
(
header: TanstackHeader<TData, unknown>,
event: React.PointerEvent<HTMLDivElement>,
) => {
if (!header.column.getCanResize()) return;
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const headerCell = event.currentTarget.closest("th");
const startWidth =
headerCell?.getBoundingClientRect().width ?? header.column.getSize();
setResizingColumnId(header.column.id);
setColumnWidth(header, startWidth);
const originalCursor = document.body.style.cursor;
const originalUserSelect = document.body.style.userSelect;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const handlePointerMove = (pointerEvent: PointerEvent) => {
setColumnWidth(header, startWidth + pointerEvent.clientX - startX);
};
const stopResize = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopResize);
window.removeEventListener("pointercancel", stopResize);
document.body.style.cursor = originalCursor;
document.body.style.userSelect = originalUserSelect;
setResizingColumnId(null);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopResize);
window.addEventListener("pointercancel", stopResize);
},
[setColumnWidth],
);
const handleResizeKeyDown = React.useCallback(
(
header: TanstackHeader<TData, unknown>,
event: React.KeyboardEvent<HTMLDivElement>,
) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
event.stopPropagation();
const headerCell = event.currentTarget.closest("th");
const currentWidth = hasExplicitSize(header.column.id)
? header.column.getSize()
: (headerCell?.getBoundingClientRect().width ??
header.column.getSize());
const direction = event.key === "ArrowRight" ? 1 : -1;
const step = event.shiftKey ? 20 : 8;
setColumnWidth(header, currentWidth + direction * step);
},
[hasExplicitSize, setColumnWidth],
);
return (
<div
className={cn("flex min-h-0 flex-1 flex-col", className)}
@@ -172,13 +79,6 @@ export function DataTable<TData>({
<TableRow key={headerGroup.id} className="hover:bg-transparent">
{headerGroup.headers.map((header) => {
const isPinned = header.column.getIsPinned();
const columnHasExplicitSize = hasExplicitSize(
header.column.id,
);
const headerLabel =
typeof header.column.columnDef.header === "string"
? header.column.columnDef.header
: header.column.id;
return (
<TableHead
key={header.id}
@@ -198,13 +98,10 @@ export function DataTable<TData>({
// into the header strip rather than appearing as
// a white block under sticky scroll.
className={cn(
"relative h-8 overflow-hidden px-4 py-2 text-xs uppercase tracking-wider text-muted-foreground",
"h-8 overflow-hidden px-4 py-2 text-xs uppercase tracking-wider text-muted-foreground",
isPinned && "bg-muted/30 backdrop-blur",
)}
style={getCellStyle(header.column, {
withBorder: true,
hasExplicitSize: columnHasExplicitSize,
})}
style={getCellStyle(header.column, { withBorder: true })}
>
{header.isPlaceholder
? null
@@ -212,33 +109,6 @@ export function DataTable<TData>({
header.column.columnDef.header,
header.getContext(),
)}
{!header.isPlaceholder &&
header.column.getCanResize() && (
<div
role="separator"
aria-label={`Resize ${headerLabel} column`}
aria-orientation="vertical"
tabIndex={0}
className={cn(
"absolute top-0 right-0 h-full w-2 cursor-col-resize touch-none select-none outline-none",
"after:absolute after:top-1/2 after:right-0 after:h-4 after:w-px after:-translate-y-1/2 after:bg-border after:opacity-0 after:transition-opacity",
"hover:after:opacity-100 focus-visible:after:opacity-100",
resizingColumnId === header.column.id &&
"after:bg-primary after:opacity-100",
)}
onPointerDown={(event) =>
beginColumnResize(header, event)
}
onDoubleClick={(event) => {
event.preventDefault();
event.stopPropagation();
header.column.resetSize();
}}
onKeyDown={(event) =>
handleResizeKeyDown(header, event)
}
/>
)}
</TableHead>
);
})}
@@ -265,9 +135,6 @@ export function DataTable<TData>({
>
{row.getVisibleCells().map((cell) => {
const isPinned = cell.column.getIsPinned();
const columnHasExplicitSize = hasExplicitSize(
cell.column.id,
);
return (
<TableCell
key={cell.id}
@@ -284,10 +151,7 @@ export function DataTable<TData>({
isPinned &&
"bg-background group-hover:bg-muted/50",
)}
style={getCellStyle(cell.column, {
withBorder: true,
hasExplicitSize: columnHasExplicitSize,
})}
style={getCellStyle(cell.column, { withBorder: true })}
>
{flexRender(
cell.column.columnDef.cell,

View File

@@ -4,9 +4,10 @@ import type * as React from "react";
// Extend TanStack Table's ColumnMeta with a `grow` flag. TanStack merges
// a default `size: 150` into every columnDef, so "no explicit size" can't
// be detected by inspecting columnDef.size (it's always a number). Setting
// `meta: { grow: true }` is the official extension point: DataTable skips
// the inline width for these columns until the user explicitly resizes them,
// then the resized width wins.
// `meta: { grow: true }` is the official extension point DataTable then
// skips the inline width for these columns and lets fixed table-layout
// assign them the leftover space (Linear / GitHub-PR-list pattern: title
// column grows, others stay at their declared widths).
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
grow?: boolean;
@@ -24,10 +25,10 @@ declare module "@tanstack/react-table" {
// `group-hover:`.
export function getCellStyle<TData>(
column: Column<TData>,
options?: { withBorder?: boolean; hasExplicitSize?: boolean },
options?: { withBorder?: boolean },
): React.CSSProperties {
const grow = column.columnDef.meta?.grow;
const width = grow && !options?.hasExplicitSize ? undefined : column.getSize();
const width = grow ? undefined : column.columnDef.size;
const isPinned = column.getIsPinned();
if (!isPinned) {

View File

@@ -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;
}
@@ -41,17 +38,18 @@ export interface AgentRow {
// column.size doubles as the cell's effective max-width: truncatable
// cells with `truncate` inside hit ellipsis at the column edge.
//
// The Agent and Runtime columns have `meta.grow: true` so DataTable skips
// their inline widths until the user resizes them. Fixed table-layout splits
// the leftover space between them, which keeps Agent from monopolising wide
// viewports while still giving both columns a real floor.
// The Agent column has `meta.grow: true` so DataTable skips its inline
// `width` — that lets fixed table-layout assign it the leftover space
// (= container width sum of other columns), so the table fills the
// viewport without an empty spacer column.
//
// The grow columns also keep their `size` values even though those widths
// are skipped for initial rendering. TanStack folds them into
// `table.getTotalSize()`, which DataTable applies as the table's `min-width`.
// That's how the grow columns get real floors: when the viewport drops below
// the summed column sizes, the table refuses to shrink further and the
// container scrolls instead.
// The Agent column also keeps `size: 240` even though it isn't used for
// rendering. TanStack folds this into `table.getTotalSize()`, which
// DataTable applies as the table's `min-width`. That's how the agent
// column gets a real 240px floor: when the viewport drops below
// `sum + 240`, the table refuses to shrink further and the container
// scrolls instead. (Fixed table-layout ignores cell-level min-width
// per spec, so the floor has to live on the table itself.)
const COL_WIDTHS = {
agent: 240,
status: 120,
@@ -104,7 +102,6 @@ export function createAgentColumns({
id: "runtime",
header: "Runtime",
size: COL_WIDTHS.runtime,
meta: { grow: true },
cell: ({ row }) => <RuntimeCell row={row.original} />,
},
{
@@ -129,7 +126,6 @@ export function createAgentColumns({
id: "actions",
header: () => null,
size: COL_WIDTHS.actions,
enableResizing: false,
cell: ({ row }) => (
<div
className="flex justify-end"
@@ -154,7 +150,7 @@ export function createAgentColumns({
// ---------------------------------------------------------------------------
function AgentNameCell({ row }: { row: AgentRow }) {
const { agent, ownerIdToShow, isOwnedByMe } = row;
const { agent, ownerIdToShow } = row;
const isArchived = !!agent.archived_at;
const isPrivate = agent.visibility === "private";
@@ -184,15 +180,10 @@ function AgentNameCell({ row }: { row: AgentRow }) {
}
/>
<TooltipContent>
{VISIBILITY_TOOLTIP.private}
Private only the owner can assign work
</TooltipContent>
</Tooltip>
)}
{isOwnedByMe && !ownerIdToShow && (
<span className="shrink-0 rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
You
</span>
)}
{ownerIdToShow && (
<ActorAvatar
actorType="member"

View File

@@ -55,15 +55,6 @@ interface InspectorProps {
runtimes: AgentRuntime[];
members: MemberWithUser[];
currentUserId: string | null;
/**
* Computed by the parent via `useAgentPermissions(agent).canEdit.allowed`.
* When false the inspector renders all editable surfaces as static
* read-only displays — pickers become text/badges, name/description lose
* their pencil affordance, the avatar is no longer clickable, and the
* "Attach skill" trigger is hidden. Mirrors the backend gate at
* `server/internal/handler/agent.go:519-535`.
*/
canEdit: boolean;
onUpdate: (id: string, data: Record<string, unknown>) => Promise<void>;
}
@@ -86,7 +77,6 @@ export function AgentDetailInspector({
runtimes,
members,
currentUserId,
canEdit,
onUpdate,
}: InspectorProps) {
const update = (data: Record<string, unknown>) => onUpdate(agent.id, data);
@@ -96,18 +86,16 @@ export function AgentDetailInspector({
<aside className="flex h-full min-h-0 w-full flex-col overflow-y-auto rounded-lg border bg-background">
{/* Identity */}
<div className="flex flex-col gap-3 border-b px-5 pb-5 pt-5">
<AvatarEditor agent={agent} canEdit={canEdit} onUpdate={update} />
<NameAndDescription
agent={agent}
canEdit={canEdit}
onUpdate={update}
/>
<AvatarEditor agent={agent} onUpdate={update} />
<NameAndDescription agent={agent} onUpdate={update} />
<PresenceBadge presence={presence} />
</div>
{/* Properties — editable when canEdit. When the current user lacks
permission, each picker self-renders a static read-only display so
the value is visible but not interactive. */}
{/* Properties — editable. Row hover is OFF here on purpose: each chip
(RuntimePicker, ModelPicker, …) carries its own border + hover-bg
treatment that already telegraphs "this is a button". A second
row-wide hover layer on top would just smudge the chip boundary
and make it harder, not easier, to see what's clickable. */}
<Section label="Properties">
<PropRow label="Runtime" interactive={false}>
<RuntimePicker
@@ -115,7 +103,6 @@ export function AgentDetailInspector({
runtimes={runtimes}
members={members}
currentUserId={currentUserId}
canEdit={canEdit}
onChange={(id) => update({ runtime_id: id })}
/>
</PropRow>
@@ -124,21 +111,18 @@ export function AgentDetailInspector({
runtimeId={agent.runtime_id}
runtimeOnline={!!isOnline}
value={agent.model ?? ""}
canEdit={canEdit}
onChange={(m) => update({ model: m })}
/>
</PropRow>
<PropRow label="Visibility" interactive={false}>
<VisibilityPicker
value={agent.visibility}
canEdit={canEdit}
onChange={(v) => update({ visibility: v })}
/>
</PropRow>
<PropRow label="Concurrency" interactive={false}>
<ConcurrencyPicker
value={agent.max_concurrent_tasks}
canEdit={canEdit}
onChange={(n) => update({ max_concurrent_tasks: n })}
/>
</PropRow>
@@ -189,7 +173,7 @@ export function AgentDetailInspector({
{s.name}
</span>
))}
<SkillAttach agent={agent} canEdit={canEdit} />
<SkillAttach agent={agent} />
</div>
</div>
</aside>
@@ -208,13 +192,11 @@ function Section({
children: ReactNode;
}) {
return (
<div className="border-b px-5 py-4">
<div className="mb-1 -mx-2 px-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
<div className="flex flex-col gap-0.5 border-b px-5 py-4">
<div className="mb-1 px-2 -mx-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
</div>
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5">
{children}
</div>
{children}
</div>
);
}
@@ -225,29 +207,14 @@ function Section({
function AvatarEditor({
agent,
canEdit,
onUpdate,
}: {
agent: Agent;
canEdit: boolean;
onUpdate: (data: Record<string, unknown>) => Promise<void>;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const { upload, uploading } = useFileUpload(api);
if (!canEdit) {
return (
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted">
<ActorAvatar
actorType="agent"
actorId={agent.id}
size={56}
className="rounded-none"
/>
</div>
);
}
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -300,32 +267,11 @@ function AvatarEditor({
function NameAndDescription({
agent,
canEdit,
onUpdate,
}: {
agent: Agent;
canEdit: boolean;
onUpdate: (data: Record<string, unknown>) => Promise<void>;
}) {
if (!canEdit) {
return (
<div className="flex flex-col gap-1">
<span className="text-base font-semibold leading-tight">
{agent.name}
</span>
{agent.description ? (
<span className="text-xs leading-relaxed text-muted-foreground">
{agent.description}
</span>
) : (
<span className="text-xs italic leading-relaxed text-muted-foreground/50">
No description
</span>
)}
</div>
);
}
return (
<div className="flex flex-col gap-1">
<InlineEditPopover

View File

@@ -24,9 +24,7 @@ import {
workspaceKeys,
} from "@multica/core/workspace/queries";
import { runtimeListOptions } from "@multica/core/runtimes";
import { useAgentPermissions } from "@multica/core/permissions";
import { Button } from "@multica/ui/components/ui/button";
import { CapabilityBanner } from "@multica/ui/components/common/capability-banner";
import {
Dialog,
DialogContent,
@@ -76,12 +74,6 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
const presence: AgentPresenceDetail | null =
agent ? presenceMap.get(agent.id) ?? null : null;
// Permission hook MUST be called unconditionally — its `agent | null`
// signature handles the not-found / loading case internally so the early
// returns below don't violate the rules of hooks. Backend gates archive
// and restore identically to edit, so a single `canEdit` covers them all.
const { canEdit } = useAgentPermissions(agent, wsId);
const [confirmArchive, setConfirmArchive] = useState(false);
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
@@ -171,36 +163,23 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
agent={agent}
presence={presence}
backHref={paths.agents()}
canArchive={canEdit.allowed}
onArchive={() => setConfirmArchive(true)}
/>
{!canEdit.allowed && (
<div className="px-6 pt-3">
<CapabilityBanner
reason={canEdit.reason}
resource="agent"
ownerName={owner?.name}
/>
</div>
)}
{isArchived && (
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/50 px-6 py-2 text-xs text-muted-foreground">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">
This agent is archived. It cannot be assigned or mentioned.
</span>
{canEdit.allowed && (
<Button
variant="outline"
size="sm"
className="h-6 text-xs"
onClick={() => handleRestore(agent.id)}
>
Restore
</Button>
)}
<Button
variant="outline"
size="sm"
className="h-6 text-xs"
onClick={() => handleRestore(agent.id)}
>
Restore
</Button>
</div>
)}
@@ -213,7 +192,6 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
runtimes={runtimes}
members={members}
currentUserId={currentUser?.id ?? null}
canEdit={canEdit.allowed}
onUpdate={handleUpdate}
/>
@@ -276,13 +254,11 @@ function DetailHeader({
agent,
presence,
backHref,
canArchive,
onArchive,
}: {
agent: Agent;
presence: AgentPresenceDetail | null;
backHref: string;
canArchive: boolean;
onArchive: () => void;
}) {
const isArchived = !!agent.archived_at;
@@ -314,7 +290,7 @@ function DetailHeader({
)}
</div>
{!isArchived && canArchive && (
{!isArchived && (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="icon-sm" />}

View File

@@ -16,7 +16,6 @@ import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { AppLink } from "../../navigation";
import { HealthIcon } from "../../runtimes/components/shared";
import { availabilityConfig } from "../presence";
import { VisibilityBadge } from "./visibility-badge";
interface AgentProfileCardProps {
agentId: string;
@@ -82,7 +81,6 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="truncate text-sm font-semibold">{agent.name}</p>
{!isArchived && <VisibilityBadge value={agent.visibility} compact />}
{isArchived && (
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
Archived

View File

@@ -22,7 +22,6 @@ import {
import { api } from "@multica/core/api";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { canAssignAgentToIssue } from "@multica/core/permissions";
import { useWorkspacePaths } from "@multica/core/paths";
import {
agentListOptions,
@@ -144,42 +143,27 @@ export function AgentsPage() {
[agents, view],
);
// Layer 1b — visibility. Personal (visibility=private) agents owned by
// someone else are hidden from regular members; workspace owners/admins
// still see everything. Mirrors the assign-to-issue gate so the list
// only ever shows agents the user could actually act on. Backend keeps
// returning all agents, so admin tools (and the API itself) are
// unaffected — this is a UI-only filter.
const visibleInView = useMemo(() => {
return inView.filter((a) =>
canAssignAgentToIssue(a, {
userId: currentUser?.id ?? null,
role: myRole,
}).allowed,
);
}, [inView, currentUser?.id, myRole]);
// Layer 1c — ownership scope. Counts shown on the segment are
// computed against the visibleInView set so the numbers always reflect
// Layer 1b — ownership scope. Counts shown on the segment are
// computed against the inView set so the numbers always reflect
// "what would I see if I clicked this".
const scopeCounts = useMemo(() => {
let mine = 0;
if (currentUser) {
for (const a of visibleInView) {
for (const a of inView) {
if (a.owner_id === currentUser.id) mine += 1;
}
}
return { all: visibleInView.length, mine };
}, [visibleInView, currentUser]);
return { all: inView.length, mine };
}, [inView, currentUser]);
const inScope = useMemo(() => {
// Archived view ignores Mine / All — its toolbar has no scope
// segment, so silently filtering by `scope` would hide other
// people's archived agents without any UI to explain why.
if (view === "archived") return visibleInView;
if (scope === "all" || !currentUser) return visibleInView;
return visibleInView.filter((a) => a.owner_id === currentUser.id);
}, [visibleInView, scope, currentUser, view]);
if (view === "archived") return inView;
if (scope === "all" || !currentUser) return inView;
return inView.filter((a) => a.owner_id === currentUser.id);
}, [inView, scope, currentUser, view]);
// Final cut — availability chip + search.
const filteredAgents = useMemo(() => {
@@ -295,10 +279,10 @@ export function AgentsPage() {
// Surfaced softly; the agent itself is fine.
}
}
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) => {
@@ -327,7 +311,6 @@ export function AgentsPage() {
activity: activityMap.get(agent.id) ?? null,
runCount: runCountsById.get(agent.id) ?? 0,
ownerIdToShow,
isOwnedByMe: isOwner,
canManage,
};
});
@@ -351,7 +334,6 @@ export function AgentsPage() {
data: agentRows,
columns,
getCoreRowModel: getCoreRowModel(),
enableColumnResizing: true,
// Pin the kebab column right so it stays accessible during horizontal
// scroll — matches the pattern in Linear / Notion / GitHub.
initialState: { columnPinning: { right: ["actions"] } },

View File

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

View File

@@ -11,25 +11,14 @@ const MAX = 50;
export function ConcurrencyPicker({
value,
canEdit = true,
onChange,
}: {
value: number;
/** When false, render a static read-only display and skip the popover. */
canEdit?: boolean;
onChange: (next: number) => Promise<void> | void;
}) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(String(value));
if (!canEdit) {
return (
<span className="font-mono text-xs tabular-nums text-muted-foreground">
{value}
</span>
);
}
// Reset draft from authoritative value whenever the popover (re-)opens or
// the prop changes from elsewhere — protects against stale draft state if
// the user closes mid-edit and reopens later.

View File

@@ -26,14 +26,11 @@ export function ModelPicker({
runtimeId,
runtimeOnline,
value,
canEdit = true,
onChange,
}: {
runtimeId: string | null;
runtimeOnline: boolean;
value: string;
/** When false, render a static read-only display and skip the popover. */
canEdit?: boolean;
onChange: (next: string) => Promise<void> | void;
}) {
const [open, setOpen] = useState(false);
@@ -44,12 +41,13 @@ export function ModelPicker({
);
const supported = modelsQuery.data?.supported ?? true;
// Memoise the model list so every downstream useMemo gets a stable
// reference; `?? []` would mint a fresh array on every render and
// invalidate filters needlessly.
// reference `?? []` would mint a fresh array on every render and
// invalidate filters / defaultModel needlessly.
const models = useMemo(
() => modelsQuery.data?.models ?? [],
[modelsQuery.data],
);
const defaultModel = useMemo(() => models.find((m) => m.default), [models]);
const filtered = useMemo(() => {
const s = search.trim().toLowerCase();
@@ -80,20 +78,11 @@ export function ModelPicker({
);
}
const triggerLabel = value || "Default";
const triggerLabel =
value ||
(defaultModel ? `Default — ${defaultModel.label}` : "Default");
const triggerTitle = `Model · ${triggerLabel}`;
if (!canEdit) {
return (
<span
className="min-w-0 truncate px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
title={triggerTitle}
>
{triggerLabel}
</span>
);
}
return (
<PropertyPicker
open={open}

View File

@@ -24,15 +24,12 @@ export function RuntimePicker({
runtimes,
members,
currentUserId,
canEdit = true,
onChange,
}: {
value: string;
runtimes: AgentRuntime[];
members: MemberWithUser[];
currentUserId: string | null;
/** When false, render a static read-only display and skip the popover. */
canEdit?: boolean;
onChange: (runtimeId: string) => Promise<void> | void;
}) {
const [open, setOpen] = useState(false);
@@ -40,25 +37,6 @@ export function RuntimePicker({
const selected = runtimes.find((r) => r.id === value) ?? null;
const Icon = selected?.runtime_mode === "cloud" ? Cloud : Monitor;
if (!canEdit) {
const isOnline = selected?.status === "online";
return (
<span className="inline-flex min-w-0 items-center gap-1.5 px-1.5 py-0.5 text-xs text-muted-foreground">
<Icon className="h-3 w-3 shrink-0" />
<span className="min-w-0 truncate font-mono">
{selected?.name ?? "No runtime"}
</span>
{selected && (
<span
className={`ml-auto h-1.5 w-1.5 shrink-0 rounded-full ${
isOnline ? "bg-success" : "bg-muted-foreground/40"
}`}
/>
)}
</span>
);
}
// The chip shows only the runtime name. `runtime.name` already comes back
// from the back-end pre-formatted as e.g. "Claude (host.local)", so we
// deliberately do NOT append `device_info` to the tooltip — that string

View File

@@ -17,14 +17,7 @@ import { SkillAddDialog } from "../skill-add-dialog";
* Hidden when there's nothing left to attach so we don't dangle a chip
* that opens an empty dialog.
*/
export function SkillAttach({
agent,
canEdit = true,
}: {
agent: Agent;
/** When false, hide the attach trigger entirely. */
canEdit?: boolean;
}) {
export function SkillAttach({ agent }: { agent: Agent }) {
const wsId = useWorkspaceId();
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
const [open, setOpen] = useState(false);
@@ -34,7 +27,7 @@ export function SkillAttach({
(s) => !agentSkillIds.has(s.id),
).length;
if (!canEdit || availableCount === 0) return null;
if (availableCount === 0) return null;
return (
<>

View File

@@ -2,38 +2,27 @@
import { useState } from "react";
import { Globe, Lock } from "lucide-react";
import {
VISIBILITY_DESCRIPTION,
VISIBILITY_LABEL,
VISIBILITY_TOOLTIP,
} from "@multica/core/agents";
import type { AgentVisibility } from "@multica/core/types";
import {
PickerItem,
PropertyPicker,
} from "../../../issues/components/pickers";
import { VisibilityBadge } from "../visibility-badge";
import { CHIP_CLASS } from "./chip";
export function VisibilityPicker({
value,
canEdit = true,
onChange,
}: {
value: AgentVisibility;
/** When false, render a read-only `<VisibilityBadge>` and skip the popover. */
canEdit?: boolean;
onChange: (next: AgentVisibility) => Promise<void> | void;
}) {
const [open, setOpen] = useState(false);
if (!canEdit) {
return <VisibilityBadge value={value} />;
}
const Icon = value === "private" ? Lock : Globe;
const label = VISIBILITY_LABEL[value];
const tooltip = `Visibility · ${VISIBILITY_TOOLTIP[value]}`;
const label = value === "private" ? "Private" : "Workspace";
const tooltip =
value === "private"
? "Visibility · Private — only you can assign"
: "Visibility · Workspace — all members can assign";
const select = async (next: AgentVisibility) => {
setOpen(false);
@@ -63,9 +52,9 @@ export function VisibilityPicker({
>
<Globe className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">{VISIBILITY_LABEL.workspace}</div>
<div className="font-medium">Workspace</div>
<div className="text-xs text-muted-foreground">
{VISIBILITY_DESCRIPTION.workspace}
All members can assign
</div>
</div>
</PickerItem>
@@ -75,9 +64,9 @@ export function VisibilityPicker({
>
<Lock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">{VISIBILITY_LABEL.private}</div>
<div className="font-medium">Private</div>
<div className="text-xs text-muted-foreground">
{VISIBILITY_DESCRIPTION.private}
Only you can assign
</div>
</div>
</PickerItem>

View File

@@ -42,6 +42,7 @@ export function ModelDropdown({
const supported = modelsQuery.data?.supported ?? true;
const models = modelsQuery.data?.models ?? [];
const defaultModel = useMemo(() => models.find((m) => m.default), [models]);
const grouped = useMemo(() => groupByProvider(models), [models]);
// When the selected runtime reports it doesn't support per-agent
@@ -85,7 +86,9 @@ export function ModelDropdown({
(disabled
? "Select a runtime first"
: runtimeOnline
? "Default (provider)"
? defaultModel
? `Default — ${defaultModel.label}`
: "Default (provider)"
: "Runtime offline — enter manually");
if (!supported && !modelsQuery.isLoading) {

View File

@@ -1,49 +0,0 @@
"use client";
import { Globe, Lock } from "lucide-react";
import {
VISIBILITY_LABEL,
VISIBILITY_TOOLTIP,
} from "@multica/core/agents";
import type { AgentVisibility } from "@multica/core/types";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
/**
* Read-only visibility badge — used wherever a user should *see* an agent's
* visibility (Personal / Workspace) without being able to change it. Replaces
* the interactive `<VisibilityPicker>` for non-managers on the detail page,
* and is also the canonical badge for hover cards and list rows.
*
* `compact` drops the text label and shows just the icon — for tight spaces
* like the agent table where the column header already labels the field.
*/
export function VisibilityBadge({
value,
compact = false,
className = "",
}: {
value: AgentVisibility;
compact?: boolean;
className?: string;
}) {
const Icon = value === "private" ? Lock : Globe;
const label = VISIBILITY_LABEL[value];
const tooltip = VISIBILITY_TOOLTIP[value];
return (
<Tooltip>
<TooltipTrigger
render={
<span
className={`inline-flex items-center gap-1 text-xs text-muted-foreground ${className}`}
aria-label={tooltip}
>
<Icon className="h-3 w-3 shrink-0" />
{!compact && <span className="truncate">{label}</span>}
</span>
}
/>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
}

View File

@@ -44,9 +44,7 @@ import {
} from "./trigger-config";
import type { TriggerConfig } from "./trigger-config";
import type { AutopilotExecutionMode, AutopilotRun, AutopilotTrigger } from "@multica/core/types";
import type { AgentTask } from "@multica/core/types/agent";
import { ReadonlyContent } from "../../editor";
import { TranscriptButton } from "../../common/task-transcript";
import { AutopilotDialog } from "./autopilot-dialog";
function formatDate(date: string): string {
@@ -65,34 +63,11 @@ const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: ty
failed: { label: "Failed", color: "text-destructive", icon: XCircle },
};
function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: string; agentName: string }) {
function RunRow({ run }: { run: AutopilotRun }) {
const wsPaths = useWorkspacePaths();
const cfg = (RUN_STATUS_CONFIG[run.status] ?? RUN_STATUS_CONFIG["issue_created"])!;
const StatusIcon = cfg.icon;
// For runs with a task_id (run_only mode), build a minimal AgentTask so
// TranscriptButton can lazy-load the execution transcript.
const syntheticTask: AgentTask | null = run.task_id
? {
id: run.task_id,
agent_id: agentId,
runtime_id: "",
issue_id: "",
status:
run.status === "running" ? "running" :
run.status === "completed" ? "completed" :
run.status === "failed" ? "failed" :
"queued",
priority: 0,
dispatched_at: null,
started_at: run.triggered_at || null,
completed_at: run.completed_at || null,
result: null,
error: run.failure_reason || null,
created_at: run.created_at,
}
: null;
const content = (
<>
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color, cfg.spin && "animate-spin")} />
@@ -108,14 +83,6 @@ function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: strin
<span className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{formatDate(run.triggered_at || run.created_at)}
</span>
{syntheticTask && !run.issue_id && (
<TranscriptButton
task={syntheticTask}
agentName={agentName}
isLive={run.status === "running"}
title="View execution log"
/>
)}
</>
);
@@ -471,7 +438,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
) : (
<div className="rounded-md border overflow-hidden">
{runs.map((run) => (
<RunRow key={run.id} run={run} agentId={autopilot.assignee_id} agentName={getActorName("agent", autopilot.assignee_id)} />
<RunRow key={run.id} run={run} />
))}
</div>
)}

View File

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

@@ -19,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,
@@ -104,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`
@@ -430,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={
@@ -728,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

@@ -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());
},
content: defaultValue ? preprocessMarkdown(defaultValue) : "",
contentType: defaultValue ? "markdown" : undefined,
extensions: createEditorExtensions({
editable,
placeholder: placeholderText,
queryClient,
onSubmitRef,
@@ -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

@@ -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,34 +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 math rendering", () => {
it("renders inline and block LaTeX with KaTeX markup", () => {
const { container } = render(
@@ -98,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, 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>,
};
// ---------------------------------------------------------------------------

View File

@@ -4,7 +4,6 @@ import { STATUS_CONFIG, PRIORITY_CONFIG } from "@multica/core/issues/config";
import { useActorName } from "@multica/core/workspace/hooks";
import { StatusIcon, PriorityIcon } from "../../issues/components";
import type { InboxItem, InboxItemType, IssueStatus, IssuePriority } from "@multica/core/types";
import { getQuickCreateFailureDetail } from "./inbox-display";
const typeLabels: Record<InboxItemType, string> = {
issue_assigned: "Assigned",
@@ -21,8 +20,8 @@ const typeLabels: Record<InboxItemType, string> = {
agent_blocked: "Agent blocked",
agent_completed: "Agent completed",
reaction_added: "Reacted",
quick_create_done: "Created with agent",
quick_create_failed: "Create with agent failed",
quick_create_done: "Quick create done",
quick_create_failed: "Quick create failed",
};
export { typeLabels };
@@ -89,16 +88,6 @@ export function InboxDetailLabel({ item }: { item: InboxItem }) {
if (emoji) return <span>Reacted {emoji} to your comment</span>;
return <span>{typeLabels[item.type]}</span>;
}
case "quick_create_done": {
const identifier = details.identifier;
if (identifier) return <span>Created with agent: {identifier}</span>;
return <span>{typeLabels[item.type]}</span>;
}
case "quick_create_failed": {
const detail = getQuickCreateFailureDetail(item);
if (detail) return <span>Failed: {detail}</span>;
return <span>{typeLabels[item.type]}</span>;
}
default:
return <span>{typeLabels[item.type] ?? item.type}</span>;
}

View File

@@ -1,80 +0,0 @@
import { describe, expect, it } from "vitest";
import type { InboxItem } from "@multica/core/types";
import {
getInboxDisplayTitle,
getQuickCreateFailureDetail,
stripQuickCreatePrefix,
} from "./inbox-display";
function item(overrides: Partial<InboxItem>): InboxItem {
return {
id: "inbox-1",
workspace_id: "workspace-1",
recipient_type: "member",
recipient_id: "member-1",
actor_type: "agent",
actor_id: "agent-1",
type: "new_comment",
severity: "info",
issue_id: "issue-1",
title: "Issue title",
body: null,
issue_status: null,
read: false,
archived: false,
created_at: "2026-04-29T12:00:00Z",
details: null,
...overrides,
};
}
describe("inbox display helpers", () => {
it("removes legacy quick-create created prefixes from list titles", () => {
expect(
stripQuickCreatePrefix(
"Created MUL-1583: Fix agent list column widths",
"MUL-1583",
),
).toBe("Fix agent list column widths");
});
it("cleans quick-create success titles before rendering the inbox row", () => {
const quickCreateItem = item({
type: "quick_create_done",
title: "Created MUL-1583: Fix agent list column widths",
details: { identifier: "MUL-1583" },
});
expect(getInboxDisplayTitle(quickCreateItem)).toBe(
"Fix agent list column widths",
);
});
it("uses the original prompt as the failed quick-create row title", () => {
const failedItem = item({
type: "quick_create_failed",
title: "Quick create failed",
body: "agent finished without creating an issue",
issue_id: null,
details: {
original_prompt: "Optimize QuickCapture UI\nand attached screenshot",
},
});
expect(getInboxDisplayTitle(failedItem)).toBe(
"Optimize QuickCapture UI and attached screenshot",
);
});
it("uses the redacted failure detail for failed quick-create subtitles", () => {
const failedItem = item({
type: "quick_create_failed",
body: "fallback body",
details: { error: "CLI failed\nwith exit status 1" },
});
expect(getQuickCreateFailureDetail(failedItem)).toBe(
"CLI failed with exit status 1",
);
});
});

View File

@@ -1,49 +0,0 @@
import type { InboxItem } from "@multica/core/types";
function singleLine(value: string | null | undefined): string {
return (value ?? "").replace(/\s+/g, " ").trim();
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function stripQuickCreatePrefix(title: string, identifier?: string): string {
const normalized = singleLine(title);
if (!normalized) return "";
if (identifier) {
const exactPrefix = new RegExp(
`^Created\\s+${escapeRegExp(identifier)}:\\s*`,
"i",
);
const withoutExactPrefix = normalized.replace(exactPrefix, "");
if (withoutExactPrefix !== normalized) return withoutExactPrefix.trim();
}
return normalized.replace(/^Created\s+[A-Z][A-Z0-9]*-\d+:\s*/i, "").trim();
}
export function getInboxDisplayTitle(item: InboxItem): string {
const details = item.details ?? {};
if (item.type === "quick_create_done") {
const cleanedTitle = stripQuickCreatePrefix(item.title, details.identifier);
if (cleanedTitle) return cleanedTitle;
const prompt = singleLine(details.original_prompt);
if (prompt) return prompt;
}
if (item.type === "quick_create_failed") {
const prompt = singleLine(details.original_prompt);
if (prompt) return prompt;
}
return item.title;
}
export function getQuickCreateFailureDetail(item: InboxItem): string {
const details = item.details ?? {};
return singleLine(details.error) || singleLine(item.body);
}

View File

@@ -2,10 +2,9 @@
import { StatusIcon } from "../../issues/components";
import { ActorAvatar } from "../../common/actor-avatar";
import { Archive, CircleCheck } from "lucide-react";
import { Archive } from "lucide-react";
import type { InboxItem } from "@multica/core/types";
import { InboxDetailLabel } from "./inbox-detail-label";
import { getInboxDisplayTitle } from "./inbox-display";
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
@@ -25,16 +24,12 @@ export function InboxListItem({
isSelected,
onClick,
onArchive,
onDone,
}: {
item: InboxItem;
isSelected: boolean;
onClick: () => void;
onArchive: () => void;
onDone?: () => void;
}) {
const displayTitle = getInboxDisplayTitle(item);
return (
<button
onClick={onClick}
@@ -57,30 +52,10 @@ export function InboxListItem({
<span
className={`truncate text-sm ${!item.read ? "font-medium" : "text-muted-foreground"}`}
>
{displayTitle}
{item.title}
</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,6 @@ 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";
@@ -52,7 +51,6 @@ import { useIsMobile } from "@multica/ui/hooks/use-mobile";
import { PageHeader } from "../../layout/page-header";
import { InboxListItem, timeAgo } from "./inbox-list-item";
import { typeLabels } from "./inbox-detail-label";
import { getInboxDisplayTitle } from "./inbox-display";
export function InboxPage() {
const { searchParams, replace } = useNavigation();
@@ -118,7 +116,6 @@ 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).
@@ -147,18 +144,6 @@ 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, {
@@ -249,11 +234,6 @@ 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>
@@ -277,16 +257,10 @@ export function InboxPage() {
// longer exists.
setSelectedKey("");
}}
onDone={() => {
setSelectedKey("");
archiveMutation.mutate(selected.id, {
onError: () => toast.error("Failed to archive"),
});
}}
/>
) : selected ? (
<div className="p-6">
<h2 className="text-lg font-semibold">{getInboxDisplayTitle(selected)}</h2>
<h2 className="text-lg font-semibold">{selected.title}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{typeLabels[selected.type]} · {timeAgo(selected.created_at)}
</p>

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

@@ -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>
@@ -353,7 +337,6 @@ function CommentCard({
entry,
allReplies,
currentUserId,
canModerate = false,
onReply,
onEdit,
onDelete,
@@ -375,12 +358,6 @@ function CommentCard({
});
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 CommentCard({
<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 CommentCard({
issueId={issueId}
entry={reply}
currentUserId={currentUserId}
canModerate={canModerate}
onEdit={onEdit}
onDelete={onDelete}
onToggleReaction={onToggleReaction}

View File

@@ -3,13 +3,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, TimelineEntry } from "@multica/core/types";
const mockViewport = vi.hoisted(() => ({ isMobile: false }));
vi.mock("@multica/ui/hooks/use-mobile", () => ({
useIsMobile: () => mockViewport.isMobile,
}));
// useWorkspaceId() derives from useCurrentWorkspace (relative import inside
// @multica/core/hooks.tsx). vi.mock("@multica/core/paths") only intercepts
// the bare-specifier, not the internal relative import. Mock the hooks module
@@ -371,7 +364,6 @@ function renderIssueDetail(issueId = "issue-1") {
describe("IssueDetail (shared)", () => {
beforeEach(() => {
vi.clearAllMocks();
mockViewport.isMobile = false;
// Default: issue loads successfully
mockApiObj.getIssue.mockResolvedValue(mockIssue);
mockApiObj.listTimeline.mockResolvedValue(mockTimeline);
@@ -407,6 +399,14 @@ describe("IssueDetail (shared)", () => {
expect(screen.getByDisplayValue("Add JWT auth to the backend")).toBeInTheDocument();
});
it("renders issue identifier in the breadcrumb", async () => {
renderIssueDetail();
await waitFor(() => {
expect(screen.getByText("TES-1")).toBeInTheDocument();
});
});
it("renders workspace name as breadcrumb link", async () => {
renderIssueDetail();
@@ -433,19 +433,6 @@ describe("IssueDetail (shared)", () => {
expect(screen.getByText("Due date")).toBeInTheDocument();
});
it("uses a non-resizable layout with the sidebar sheet closed by default on mobile", async () => {
mockViewport.isMobile = true;
renderIssueDetail();
await waitFor(() => {
expect(screen.getByDisplayValue("Implement authentication")).toBeInTheDocument();
});
expect(screen.queryByTestId("panel-group")).not.toBeInTheDocument();
expect(screen.queryByText("Properties")).not.toBeInTheDocument();
});
it("renders Details section with Created by and dates", async () => {
renderIssueDetail();

View File

@@ -9,7 +9,6 @@ import {
ChevronDown,
ChevronLeft,
ChevronRight,
CircleCheck,
MoreHorizontal,
PanelRight,
Pin,
@@ -139,8 +138,6 @@ function formatTokenCount(n: number): string {
interface IssueDetailProps {
issueId: string;
onDelete?: () => void;
/** Called after the issue is marked as done via the toolbar button. */
onDone?: () => void;
defaultSidebarOpen?: boolean;
layoutId?: string;
/** When set, the issue detail will auto-scroll to this comment and briefly highlight it. */
@@ -151,7 +148,7 @@ interface IssueDetailProps {
// IssueDetail
// ---------------------------------------------------------------------------
export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = true, layoutId = "multica_issue_detail_layout", highlightCommentId }: IssueDetailProps) {
export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layoutId = "multica_issue_detail_layout", highlightCommentId }: IssueDetailProps) {
const id = issueId;
const router = useNavigation();
const user = useAuthStore((s) => s.user);
@@ -162,13 +159,6 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
// Workspace owners and admins moderate any comment authored by anyone
// (mirrors backend `comment.go:507-512`). Computed here so per-comment
// rendering doesn't have to re-derive it for every row.
const currentUserRole =
members.find((m) => m.user_id === user?.id)?.role ?? null;
const canModerateComments =
currentUserRole === "owner" || currentUserRole === "admin";
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
const { getActorName } = useActorName();
const { uploadWithToast } = useFileUpload(api);
@@ -177,15 +167,14 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
});
const sidebarRef = usePanelRef();
const isMobile = useIsMobile();
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(defaultSidebarOpen);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen);
useEffect(() => {
if (isMobile) {
setMobileSidebarOpen(false);
setSidebarOpen(false);
sidebarRef.current?.collapse();
}
}, [isMobile]);
const sidebarOpen = isMobile ? mobileSidebarOpen : desktopSidebarOpen;
const [propertiesOpen, setPropertiesOpen] = useState(true);
const [detailsOpen, setDetailsOpen] = useState(true);
const [parentIssueOpen, setParentIssueOpen] = useState(true);
@@ -284,7 +273,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
if (el) {
didHighlightRef.current = highlightCommentId;
requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "instant", block: "center" });
el.scrollIntoView({ behavior: "smooth", block: "center" });
setHighlightedId(highlightCommentId);
const timer = setTimeout(() => setHighlightedId(null), 2000);
return () => clearTimeout(timer);
@@ -308,18 +297,6 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
const actions = useIssueActions(issue);
const handleUpdateField = actions.updateField;
const handleToggleSidebar = useCallback(() => {
if (isMobile) {
setMobileSidebarOpen((open) => !open);
return;
}
const panel = sidebarRef.current;
if (!panel) return;
if (panel.isCollapsed()) panel.expand();
else panel.collapse();
}, [isMobile, sidebarRef]);
if (loading) {
return (
<div className="flex flex-1 min-h-0 flex-col">
@@ -395,7 +372,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
Properties
<ChevronRight className={`!size-3 shrink-0 stroke-[2.5] text-muted-foreground transition-transform ${propertiesOpen ? "rotate-90" : ""}`} />
</button>
{propertiesOpen && <div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 pl-2">
{propertiesOpen && <div className="space-y-0.5 pl-2">
<PropRow label="Status">
<StatusPicker status={issue.status} onUpdate={handleUpdateField} align="start" />
</PropRow>
@@ -449,7 +426,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
Details
<ChevronRight className={`!size-3 shrink-0 stroke-[2.5] text-muted-foreground transition-transform ${detailsOpen ? "rotate-90" : ""}`} />
</button>
{detailsOpen && <div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 pl-2">
{detailsOpen && <div className="space-y-0.5 pl-2">
<PropRow label="Created by">
<ActorAvatar actorType={issue.creator_type} actorId={issue.creator_id} size={18} enableHoverCard />
<span className="cursor-pointer truncate">{getActorName(issue.creator_type, issue.creator_id)}</span>
@@ -478,7 +455,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
Token usage
<ChevronRight className={`!size-3 shrink-0 stroke-[2.5] text-muted-foreground transition-transform ${tokenUsageOpen ? "rotate-90" : ""}`} />
</button>
{tokenUsageOpen && <div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 pl-2">
{tokenUsageOpen && <div className="space-y-0.5 pl-2">
<PropRow label="Input">
<span className="text-muted-foreground">{formatTokenCount(usage.total_input_tokens)}</span>
</PropRow>
@@ -501,8 +478,10 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
</div>
);
const detailContent = (
<div className="flex h-full min-w-0 flex-1 flex-col">
return (
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="content" minSize="50%">
<div className="flex h-full flex-col">
<PageHeader className="gap-2 bg-background text-sm">
<div className="flex flex-1 items-center gap-1.5 min-w-0">
{workspace && (
@@ -527,28 +506,14 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
</>
)}
<span className="shrink-0 text-muted-foreground">
{issue.identifier}
</span>
<span className="truncate font-medium text-foreground">
{issue.title}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{onDone && issue.status !== "done" && issue.status !== "cancelled" && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => { handleUpdateField({ status: "done" }); onDone?.(); }}
>
<CircleCheck />
</Button>
}
/>
<TooltipContent side="bottom">Mark as done</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger
render={
@@ -583,7 +548,16 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
variant={sidebarOpen ? "secondary" : "ghost"}
size="icon-sm"
className={sidebarOpen ? "" : "text-muted-foreground"}
onClick={handleToggleSidebar}
onClick={() => {
if (isMobile) {
setSidebarOpen(!sidebarOpen);
} else {
const panel = sidebarRef.current;
if (!panel) return;
if (panel.isCollapsed()) panel.expand();
else panel.collapse();
}
}}
>
<PanelRight />
</Button>
@@ -934,7 +908,6 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
entry={entry}
allReplies={repliesByParent}
currentUserId={user?.id}
canModerate={canModerateComments}
onReply={submitReply}
onEdit={editComment}
onDelete={deleteComment}
@@ -1002,27 +975,9 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
</div>
</div>
</div>
);
if (isMobile) {
return (
<div className="flex flex-1 min-h-0">
{detailContent}
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
<SheetContent side="right" showCloseButton={false} className="w-[320px] overflow-y-auto p-4">
{sidebarContent}
</SheetContent>
</Sheet>
</div>
);
}
return (
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="content" minSize="50%">
{detailContent}
</ResizablePanel>
<ResizableHandle />
{!isMobile && <ResizableHandle />}
{!isMobile && (
<ResizablePanel
id="sidebar"
defaultSize={defaultSidebarOpen ? 320 : 0}
@@ -1031,7 +986,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
collapsible
groupResizeBehavior="preserve-pixel-size"
panelRef={sidebarRef}
onResize={(size) => setDesktopSidebarOpen(size.inPixels > 0)}
onResize={(size) => setSidebarOpen(size.inPixels > 0)}
>
<div className="overflow-y-auto border-l h-full">
<div className="p-4">
@@ -1039,6 +994,14 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
</div>
</div>
</ResizablePanel>
)}
{isMobile && (
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetContent side="right" showCloseButton={false} className="w-[320px] overflow-y-auto p-4">
{sidebarContent}
</SheetContent>
</Sheet>
)}
</ResizablePanelGroup>
);
}

View File

@@ -5,7 +5,6 @@ import { Lock, UserMinus } from "lucide-react";
import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@multica/core/types";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { canAssignAgentToIssue } from "@multica/core/permissions";
import { useActorName } from "@multica/core/workspace/hooks";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions, agentListOptions, assigneeFrequencyOptions } from "@multica/core/workspace/queries";
@@ -17,22 +16,11 @@ import {
PickerEmpty,
} from "./property-picker";
/**
* Legacy boolean shape kept around for callers (e.g. `use-issue-actions.ts`)
* that haven't migrated to the new `canAssignAgentToIssue` Decision API yet.
* Internally redirects to the canonical rule so behaviour stays in sync.
*/
export function canAssignAgent(
agent: Agent,
userId: string | undefined,
memberRole: string | undefined,
): boolean {
return canAssignAgentToIssue(agent, {
userId: userId ?? null,
role: memberRole === "owner" || memberRole === "admin" || memberRole === "member"
? memberRole
: null,
}).allowed;
export function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
if (agent.visibility !== "private") return true;
if (agent.owner_id === userId) return true;
if (memberRole === "owner" || memberRole === "admin") return true;
return false;
}
export function AssigneePicker({
@@ -159,22 +147,12 @@ export function AssigneePicker({
{filteredAgents.length > 0 && (
<PickerSection label="Agents">
{filteredAgents.map((a) => {
const decision = canAssignAgentToIssue(a, {
userId: user?.id ?? null,
role:
memberRole === "owner" ||
memberRole === "admin" ||
memberRole === "member"
? memberRole
: null,
});
const allowed = decision.allowed;
const allowed = canAssignAgent(a, user?.id, memberRole);
return (
<PickerItem
key={a.id}
selected={isSelected("agent", a.id)}
disabled={!allowed}
tooltip={!allowed ? decision.message : undefined}
onClick={() => {
if (!allowed) return;
onUpdate({

View File

@@ -560,7 +560,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
<SidebarMenuItem>
<SidebarMenuButton
className="text-muted-foreground"
onClick={() => useModalStore.getState().open("quick-create-issue")}
onClick={() => useModalStore.getState().open("create-issue")}
>
<span className="relative">
<SquarePen />

View File

@@ -21,15 +21,8 @@ import { useNavigation } from "../navigation";
* - Not logged in → /login
* - Logged in but workspace list not yet loaded → wait (don't bounce prematurely)
* - Logged in but URL slug doesn't resolve to any workspace →
* `resolvePostAuthDestination(list, hasOnboarded)`:
* • un-onboarded → /onboarding
* • onboarded with workspaces → first workspace
* • onboarded with zero workspaces → /workspaces/new
*
* The "un-onboarded but in workspace" state is now physically impossible:
* CreateWorkspace and AcceptInvitation both atomically set `onboarded_at`
* inside the same transaction that inserts the `member` row.
* Existing dirty rows from PR #1868 are cleaned by migration 065.
* `resolvePostAuthDestination(list, hasOnboarded)` — onboarding for
* first-timers, /workspaces/new for returning users who deleted out.
*
* We read the workspace list query state directly (rather than relying on
* useCurrentWorkspace's null return) so we can distinguish "list loading"
@@ -54,6 +47,10 @@ export function useDashboardGuard() {
return;
}
if (!workspaceListFetched) return;
if (!hasOnboarded) {
replace(paths.onboarding());
return;
}
if (!workspace) {
replace(resolvePostAuthDestination(workspaces, hasOnboarded));
}

View File

@@ -9,7 +9,6 @@ const mockCreateIssue = vi.hoisted(() => vi.fn());
const mockSetDraft = vi.hoisted(() => vi.fn());
const mockClearDraft = vi.hoisted(() => vi.fn());
const mockSetLastAssignee = vi.hoisted(() => vi.fn());
const mockSetKeepOpen = vi.hoisted(() => vi.fn());
const mockToastCustom = vi.hoisted(() => vi.fn());
const mockToastDismiss = vi.hoisted(() => vi.fn());
const mockToastError = vi.hoisted(() => vi.fn());
@@ -31,11 +30,6 @@ const mockDraftStore = {
setLastAssignee: mockSetLastAssignee,
};
const mockQuickCreateStore = {
keepOpen: false,
setKeepOpen: mockSetKeepOpen,
};
vi.mock("../navigation", () => ({
useNavigation: () => ({ push: mockPush }),
}));
@@ -66,11 +60,6 @@ vi.mock("@multica/core/issues/stores/draft-store", () => ({
),
}));
vi.mock("@multica/core/issues/stores/quick-create-store", () => ({
useQuickCreateStore: (selector?: (state: typeof mockQuickCreateStore) => unknown) =>
(selector ? selector(mockQuickCreateStore) : mockQuickCreateStore),
}));
vi.mock("@multica/core/issues/mutations", () => ({
useCreateIssue: () => ({ mutateAsync: mockCreateIssue }),
useUpdateIssue: () => ({ mutate: vi.fn() }),
@@ -90,10 +79,6 @@ vi.mock("../editor", () => {
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
getMarkdown: () => valueRef.current,
clearContent: () => {
valueRef.current = "";
setValue("");
},
uploadFile: vi.fn(),
}));
return (
@@ -193,23 +178,6 @@ vi.mock("@multica/ui/components/ui/button", () => ({
),
}));
vi.mock("@multica/ui/components/ui/switch", () => ({
Switch: ({
checked,
onCheckedChange,
}: {
checked: boolean;
onCheckedChange: (v: boolean) => void;
}) => (
<input
aria-label="Create another"
type="checkbox"
checked={checked}
onChange={(e) => onCheckedChange(e.target.checked)}
/>
),
}));
vi.mock("@multica/ui/components/common/file-upload-button", () => ({
FileUploadButton: ({ onSelect }: { onSelect: (file: File) => void }) => (
<button type="button" onClick={() => onSelect(new File(["test"], "test.txt"))}>
@@ -242,10 +210,6 @@ function renderModal(element: React.ReactElement) {
describe("CreateIssueModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockQuickCreateStore.keepOpen = false;
mockSetKeepOpen.mockImplementation((v: boolean) => {
mockQuickCreateStore.keepOpen = v;
});
mockCreateIssue.mockResolvedValue({
id: "issue-123",
identifier: "TES-123",
@@ -297,44 +261,4 @@ describe("CreateIssueModal", () => {
expect(mockPush).toHaveBeenCalledWith("/ws-test/issues/issue-123");
expect(mockToastDismiss).toHaveBeenCalledWith("toast-1");
});
it("keeps manual mode open and clears content when create another is enabled", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
mockQuickCreateStore.keepOpen = true;
renderModal(<CreateIssueModal onClose={onClose} />);
await user.type(screen.getByPlaceholderText("Issue title"), "First follow-up issue");
await user.type(screen.getByPlaceholderText("Add description..."), "Description to clear");
await user.click(screen.getByRole("button", { name: "Create Issue" }));
await waitFor(() => {
expect(mockCreateIssue).toHaveBeenCalledWith({
title: "First follow-up issue",
description: "Description to clear",
status: "todo",
priority: "none",
assignee_type: undefined,
assignee_id: undefined,
due_date: undefined,
attachment_ids: undefined,
parent_issue_id: undefined,
project_id: undefined,
});
});
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByPlaceholderText("Issue title")).toHaveValue("");
expect(screen.getByPlaceholderText("Add description...")).toHaveValue("");
expect(mockSetDraft).toHaveBeenCalledWith({
title: "",
description: "",
status: "todo",
priority: "none",
assigneeType: undefined,
assigneeId: undefined,
dueDate: null,
});
});
});

View File

@@ -30,7 +30,6 @@ import {
} from "@multica/ui/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { Button } from "@multica/ui/components/ui/button";
import { Switch } from "@multica/ui/components/ui/switch";
import { ContentEditor, type ContentEditorRef, TitleEditor, useFileDropZone, FileDropOverlay } from "../editor";
import { StatusIcon, StatusPicker, PriorityPicker, AssigneePicker, DueDatePicker } from "../issues/components";
import { BacklogAgentHintContent } from "../issues/components/backlog-agent-hint-dialog";
@@ -39,7 +38,6 @@ import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
import { useWorkspaceId } from "@multica/core/hooks";
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store";
import { useQuickCreateStore } from "@multica/core/issues/stores/quick-create-store";
import { issueDetailOptions } from "@multica/core/issues/queries";
import { useCreateIssue, useUpdateIssue } from "@multica/core/issues/mutations";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
@@ -86,11 +84,8 @@ export function ManualCreatePanel({
const clearDraft = useIssueDraftStore((s) => s.clearDraft);
const setLastAssignee = useIssueDraftStore((s) => s.setLastAssignee);
const setLastMode = useCreateModeStore((s) => s.setLastMode);
const keepOpen = useQuickCreateStore((s) => s.keepOpen);
const setKeepOpen = useQuickCreateStore((s) => s.setKeepOpen);
const [title, setTitle] = useState(draft.title);
const [formResetKey, setFormResetKey] = useState(0);
const descEditorRef = useRef<ContentEditorRef>(null);
const { isDragOver: descDragOver, dropZoneProps: descDropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => descEditorRef.current?.uploadFile(f)),
@@ -143,28 +138,6 @@ export function ManualCreatePanel({
const createIssueMutation = useCreateIssue();
const updateIssueMutation = useUpdateIssue();
const resetForNextIssue = () => {
setTitle("");
setStatus("todo");
setPriority("none");
setDueDate(null);
setProjectId(undefined);
setParentIssueId(undefined);
setChildIssues([]);
setAttachmentIds([]);
setDraft({
title: "",
description: "",
status: "todo",
priority: "none",
assigneeType,
assigneeId,
dueDate: null,
});
descEditorRef.current?.clearContent();
setFormResetKey((key) => key + 1);
};
const handleSubmit = async () => {
if (!title.trim() || submitting) return;
setSubmitting(true);
@@ -213,8 +186,6 @@ export function ManualCreatePanel({
if (shouldShowBacklogHint) {
setBacklogHintIssueId(issue.id);
} else if (keepOpen) {
resetForNextIssue();
} else {
onClose();
}
@@ -333,7 +304,6 @@ export function ManualCreatePanel({
{/* Title */}
<div className="px-5 pb-2 shrink-0">
<TitleEditor
key={formResetKey}
autoFocus
defaultValue={draft.title}
placeholder="Issue title"
@@ -524,30 +494,20 @@ export function ManualCreatePanel({
/>
{/* Footer */}
<div className="flex flex-col gap-2 border-t px-4 py-3 shrink-0 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-h-7 items-center gap-2">
<FileUploadButton
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
/>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
<FileUploadButton
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={switchToAgent}
title="Switch to create with agent — describe in one line and let the agent file it"
className="flex shrink-0 items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
>
<ArrowLeftRight className="size-3.5" />
Switch to Agent
Switch to agent
</button>
<label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<Switch
size="sm"
checked={keepOpen}
onCheckedChange={setKeepOpen}
/>
Create another
</label>
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Issue"}
</Button>

View File

@@ -2,28 +2,8 @@
import { useState, useRef } from "react";
import { ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus } from "lucide-react";
/**
* GitHub mark — lucide-react v1 dropped brand icons, so we inline the
* Octicon-style mark here (24×24 viewBox, currentColor fill so it inherits
* the parent's text color). Stays in this file because there's only one
* caller; promote to packages/ui if a second use crops up.
*/
function GithubIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
className={className}
>
<path d="M12 .5C5.73.5.66 5.57.66 11.84c0 5.01 3.25 9.26 7.76 10.76.57.1.78-.25.78-.55 0-.27-.01-1.17-.02-2.13-3.16.69-3.83-1.34-3.83-1.34-.52-1.31-1.27-1.66-1.27-1.66-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.02 1.75 2.68 1.24 3.34.95.1-.74.4-1.24.72-1.53-2.52-.29-5.18-1.26-5.18-5.62 0-1.24.45-2.26 1.18-3.06-.12-.29-.51-1.45.11-3.02 0 0 .96-.31 3.15 1.17a10.93 10.93 0 0 1 5.74 0c2.19-1.48 3.15-1.17 3.15-1.17.62 1.57.23 2.73.11 3.02.74.8 1.18 1.82 1.18 3.06 0 4.37-2.67 5.32-5.21 5.61.41.35.78 1.04.78 2.1 0 1.52-.01 2.74-.01 3.11 0 .3.21.66.79.55 4.51-1.5 7.76-5.75 7.76-10.76C23.34 5.57 18.27.5 12 .5Z" />
</svg>
);
}
import { useQuery } from "@tanstack/react-query";
import { useCreateProject } from "@multica/core/projects/mutations";
import { useProjectDraftStore } from "@multica/core/projects";
import {
PROJECT_STATUS_CONFIG,
PROJECT_STATUS_ORDER,
@@ -83,37 +63,16 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { getActorName } = useActorName();
const draft = useProjectDraftStore((s) => s.draft);
const setDraft = useProjectDraftStore((s) => s.setDraft);
const clearDraft = useProjectDraftStore((s) => s.clearDraft);
const [title, setTitle] = useState(draft.title);
const [title, setTitle] = useState("");
const descEditorRef = useRef<ContentEditorRef>(null);
const [status, setStatus] = useState<ProjectStatus>(draft.status);
const [priority, setPriority] = useState<ProjectPriority>(draft.priority);
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>(draft.leadType);
const [leadId, setLeadId] = useState<string | undefined>(draft.leadId);
const [icon, setIcon] = useState<string | undefined>(draft.icon);
const [status, setStatus] = useState<ProjectStatus>("planned");
const [priority, setPriority] = useState<ProjectPriority>("none");
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>();
const [leadId, setLeadId] = useState<string | undefined>();
const [icon, setIcon] = useState<string | undefined>();
const [iconPickerOpen, setIconPickerOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
// Repos selected to attach as github_repo resources after the project is
// created. Stored as URLs (not full ProjectResource rows) — they're not
// persisted until handleSubmit fires the createProjectResource calls.
const [selectedRepos, setSelectedRepos] = useState<string[]>([]);
const [repoPopoverOpen, setRepoPopoverOpen] = useState(false);
const [customRepoUrl, setCustomRepoUrl] = useState("");
const workspaceRepos = workspace?.repos ?? [];
// Sync field changes to draft store
const updateTitle = (v: string) => { setTitle(v); setDraft({ title: v }); };
const updateStatus = (v: ProjectStatus) => { setStatus(v); setDraft({ status: v }); };
const updatePriority = (v: ProjectPriority) => { setPriority(v); setDraft({ priority: v }); };
const updateLead = (type?: "member" | "agent", id?: string) => {
setLeadType(type); setLeadId(id);
setDraft({ leadType: type, leadId: id });
};
const updateIcon = (v: string | undefined) => { setIcon(v); setDraft({ icon: v }); };
const [leadOpen, setLeadOpen] = useState(false);
const [leadFilter, setLeadFilter] = useState("");
@@ -140,16 +99,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
priority,
lead_type: leadType,
lead_id: leadId,
// Server attaches these in the same transaction as the project.
resources:
selectedRepos.length > 0
? selectedRepos.map((url) => ({
resource_type: "github_repo" as const,
resource_ref: { url },
}))
: undefined,
});
clearDraft();
onClose();
toast.success("Project created");
router.push(wsPaths.projectDetail(project.id));
@@ -160,19 +110,6 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
}
};
const toggleRepo = (url: string) => {
setSelectedRepos((prev) =>
prev.includes(url) ? prev.filter((u) => u !== url) : [...prev, url],
);
};
const addCustomRepo = () => {
const url = customRepoUrl.trim();
if (!url) return;
setSelectedRepos((prev) => (prev.includes(url) ? prev : [...prev, url]));
setCustomRepoUrl("");
};
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent
@@ -240,7 +177,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
<PopoverContent align="start" className="w-auto p-0">
<EmojiPicker
onSelect={(emoji) => {
updateIcon(emoji);
setIcon(emoji);
setIconPickerOpen(false);
}}
/>
@@ -248,10 +185,10 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
</Popover>
<TitleEditor
autoFocus
defaultValue={draft.title}
defaultValue=""
placeholder="Project title"
className="text-lg font-semibold"
onChange={(v) => updateTitle(v)}
onChange={(v) => setTitle(v)}
onSubmit={handleSubmit}
/>
</div>
@@ -259,21 +196,13 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
<div className="flex-1 min-h-0 overflow-y-auto px-5">
<ContentEditor
ref={descEditorRef}
defaultValue={draft.description}
defaultValue=""
placeholder="Add description..."
onUpdate={(md) => setDraft({ description: md })}
debounceMs={500}
/>
</div>
{/* Footer: properties (left, wrap) + Create button (right). Single row
so the modal stays compact — Linear-style.
Repos lives here alongside the property pills for now. Once we
support more resource types (Linear / Notion / Figma / Slack), pull
them out into a dedicated Resources strip above this footer — a
single Repos pill on its own row looked too sparse. */}
<div className="flex items-center justify-between gap-2 px-4 py-3 border-t shrink-0">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
<DropdownMenu>
<DropdownMenuTrigger
render={
@@ -285,7 +214,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_STATUS_ORDER.map((s) => (
<DropdownMenuItem key={s} onClick={() => updateStatus(s)}>
<DropdownMenuItem key={s} onClick={() => setStatus(s)}>
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
</DropdownMenuItem>
@@ -304,7 +233,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_PRIORITY_ORDER.map((pr) => (
<DropdownMenuItem key={pr} onClick={() => updatePriority(pr)}>
<DropdownMenuItem key={pr} onClick={() => setPriority(pr)}>
<PriorityIcon priority={pr} />
<span>{PROJECT_PRIORITY_CONFIG[pr].label}</span>
</DropdownMenuItem>
@@ -347,7 +276,8 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
<button
type="button"
onClick={() => {
updateLead(undefined, undefined);
setLeadType(undefined);
setLeadId(undefined);
setLeadOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
@@ -365,7 +295,8 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
type="button"
key={m.user_id}
onClick={() => {
updateLead("member", m.user_id);
setLeadType("member");
setLeadId(m.user_id);
setLeadOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
@@ -386,7 +317,8 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
type="button"
key={a.id}
onClick={() => {
updateLead("agent", a.id);
setLeadType("agent");
setLeadId(a.id);
setLeadOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
@@ -407,113 +339,10 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
</div>
</PopoverContent>
</Popover>
</div>
<Popover open={repoPopoverOpen} onOpenChange={setRepoPopoverOpen}>
<PopoverTrigger
render={
<PillButton>
<GithubIcon className="size-3" />
<span>
{selectedRepos.length === 0
? "Repos"
: `${selectedRepos.length} repo${selectedRepos.length === 1 ? "" : "s"}`}
</span>
</PillButton>
}
/>
<PopoverContent align="start" className="w-72 p-2 space-y-2">
<div className="text-xs font-medium text-muted-foreground">
Attach GitHub repos to this project
</div>
{workspaceRepos.length > 0 ? (
<div className="space-y-1">
{workspaceRepos.map((repo) => {
const checked = selectedRepos.includes(repo.url);
return (
<button
type="button"
key={repo.url}
onClick={() => toggleRepo(repo.url)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-accent transition-colors",
checked && "bg-accent",
)}
>
<input
type="checkbox"
checked={checked}
readOnly
className="size-3.5"
/>
<GithubIcon className="size-3.5" />
<span className="truncate flex-1 text-left">{repo.url}</span>
</button>
);
})}
</div>
) : (
<p className="text-xs text-muted-foreground">
No workspace-level repos yet. Paste a URL below to attach one
ad-hoc.
</p>
)}
<form
onSubmit={(e) => {
e.preventDefault();
addCustomRepo();
}}
className="flex items-center gap-1.5 pt-1 border-t"
>
<input
type="url"
value={customRepoUrl}
onChange={(e) => setCustomRepoUrl(e.target.value)}
placeholder="https://github.com/owner/repo"
className="flex-1 bg-transparent text-xs px-2 py-1 outline-none placeholder:text-muted-foreground"
/>
<Button
type="submit"
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
disabled={!customRepoUrl.trim()}
>
Add
</Button>
</form>
{selectedRepos.length > 0 && (
<div className="space-y-1 pt-1 border-t">
<div className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Selected
</div>
{selectedRepos.map((url) => (
<div
key={url}
className="flex items-center gap-2 text-xs"
>
<GithubIcon className="size-3 text-muted-foreground" />
<span className="truncate flex-1">{url}</span>
<button
type="button"
onClick={() => toggleRepo(url)}
className="text-muted-foreground hover:text-foreground"
>
<XIcon className="size-3" />
</button>
</div>
))}
</div>
)}
</PopoverContent>
</Popover>
</div>
<Button
size="sm"
onClick={handleSubmit}
disabled={!title.trim() || submitting}
className="shrink-0"
>
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Project"}
</Button>
</div>

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