mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-18 04:09:13 +02:00
Compare commits
1 Commits
agent/lamb
...
fix/deskto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e149075d20 |
@@ -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:
|
||||
|
||||
|
||||
12
README.md
12
README.md
@@ -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.
|
||||
|
||||
@@ -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 生命周期:从任务分配到执行监控再到技能复用。
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -65,7 +65,5 @@ function overlayPath(overlay: WindowOverlay): string {
|
||||
return "/onboarding";
|
||||
case "invite":
|
||||
return `/invite/${overlay.invitationId}`;
|
||||
case "invitations":
|
||||
return "/invitations";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { create } from "zustand";
|
||||
export type WindowOverlay =
|
||||
| { type: "new-workspace" }
|
||||
| { type: "invite"; invitationId: string }
|
||||
| { type: "invitations" }
|
||||
| { type: "onboarding" };
|
||||
|
||||
interface WindowOverlayStore {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"members-roles",
|
||||
"issues",
|
||||
"comments",
|
||||
"project-resources",
|
||||
"---Agents---",
|
||||
"agents",
|
||||
"agents-create",
|
||||
|
||||
@@ -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.
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 4–5
|
||||
// (runtime / agent / first issue) can run. The new entry-point
|
||||
// judgment in callback / login handles "where should this user go on
|
||||
// login" so OnboardingPage no longer needs to second-guess it.
|
||||
if (hasOnboarded) {
|
||||
if (hasOnboarded && workspacesFetched) {
|
||||
router.replace(resolvePostAuthDestination(workspaces, hasOnboarded));
|
||||
}
|
||||
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, router]);
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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());
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./mutations";
|
||||
export { useFeedbackDraftStore } from "./draft-store";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./queries";
|
||||
export * from "./mutations";
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -20,7 +20,6 @@ export const RESERVED_SLUGS = new Set([
|
||||
"oauth",
|
||||
"callback",
|
||||
"invite",
|
||||
"invitations",
|
||||
"verify",
|
||||
"reset",
|
||||
"password",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export type MemberRole = "owner" | "admin" | "member";
|
||||
|
||||
export interface WorkspaceRepo {
|
||||
url: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] } },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { InvitationsPage } from "./invitations-page";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user