Compare commits

...

3 Commits

Author SHA1 Message Date
Naiyuan Qing
6468b80e57 feat(agents): hide personal agents from list and @mention for non-owners
Until now an agent's "Personal" visibility only narrowed the assign-to-issue
gate — every workspace member still saw every personal agent in the list
and the @mention dropdown. Members would see, click, and fail.

This filters those surfaces with the canonical canAssignAgentToIssue rule:
regular members only see workspace-visibility agents and the personal
agents they own; workspace owners and admins continue to see everything
(admin override path is intact).

- agents-page: visibleInView layer between active/archived and Mine/All
  scope so segment counts also reflect the filter
- mention-suggestion: filter agentItems before they enter the recency-
  ranked list; expand the test mock to cover the auth + visibility paths
  and add two assertions (member hides others' personal agents; admin
  still sees them)

Backend keeps returning every agent — admin tools and direct API access
are unaffected. This is a UI-only filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:22:27 +08:00
Naiyuan Qing
db28370c3d feat(views): permission-aware UI across agent/comment/runtime/skill surfaces
Apply the new permission rules to every surface where the UI was either
lying about who can do what or letting users hit 403s by clicking buttons
the backend would reject.

Agent detail
- Hide archive/restore actions for non-owner non-admin
- Replace inline editors (avatar, name, description, runtime/model/visibility/
  concurrency picker, skill-attach) with read-only display when canEdit is
  false — value is information, the editor is the action
- Show CapabilityBanner under the header explaining who can edit

Visibility surfaces
- visibility-picker / create-agent-dialog: replace "only you can assign"
  (false) with "Only you and workspace admins can assign" via shared
  VISIBILITY_DESCRIPTION constants
- agent-columns: truthful tooltip + "You" badge on agents the current user
  owns

Comments
- Restore admin override on comment edit/delete (backend already permits
  it via comment.go:507-512; the frontend was incorrectly hiding the menu).
  canModerate is computed once in issue-detail and threaded down.

Other
- Members tab: disable "demote" options for the last owner with tooltip
- Assignee picker: tooltip on disabled personal agents the user can't assign
- Runtime delete: tooltip and dialog explain the gate; owner column gains
  a name label next to the avatar in All scope
- Skill detail: page-level CapabilityBanner alongside the existing lock chip
- Issue delete (single + batch): note that any workspace member can delete
  issues — by-design semantics, made transparent

Backend is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:21:38 +08:00
Naiyuan Qing
de0f9e4bad feat(permissions): add core permission module and shared UI primitives
Foundation for permission-aware UI: pure rules that mirror the Go backend
permission gates, lightweight per-resource hooks, and two reusable display
components used across agent/skill/runtime detail pages.

- packages/core/permissions: types, rules, hooks (Decision-shaped — carries
  reason + message so UI can render disabled state, tooltip, and banner
  copy from one source)
- packages/core/agents/visibility-label: VISIBILITY_LABEL/DESCRIPTION/TOOLTIP
  constants ("Personal" / "Workspace") to replace scattered hard-coded copy
- packages/views/agents/visibility-badge: read-only visibility chip used on
  hover cards, list rows, and inspector when not editable
- packages/ui/components/common/capability-banner: "View only — only X and
  admins can edit Y" banner shown on agent / skill detail when current user
  lacks edit permission

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:20:35 +08:00
32 changed files with 1358 additions and 82 deletions

View File

@@ -5,3 +5,4 @@ export * from "./use-agent-presence";
export * from "./use-agent-activity";
export * from "./use-workspace-presence-prefetch";
export * from "./constants";
export * from "./visibility-label";

View File

@@ -0,0 +1,31 @@
import type { AgentVisibility } from "../types";
/**
* Display labels for agent visibility. The DB stores `private` as the value
* but the UI surface name is "Personal" — better matches what the field
* actually means now that workspace admins can also assign private agents.
*/
export const VISIBILITY_LABEL: Record<AgentVisibility, string> = {
workspace: "Workspace",
private: "Personal",
};
/**
* Honest descriptions for assignability. The previous "Only you can assign"
* text was a lie — workspace owners and admins can assign private agents too
* (server `issue.go:1471-1490`).
*/
export const VISIBILITY_DESCRIPTION: Record<AgentVisibility, string> = {
workspace: "All members can assign",
private: "Only you and workspace admins can assign",
};
/** Tooltip suitable for read-only badges on hover/list rows. */
export const VISIBILITY_TOOLTIP: Record<AgentVisibility, string> = {
workspace: "Workspace — all members can assign",
private: "Personal — only you and workspace admins can assign",
};
export function visibilityLabel(v: AgentVisibility): string {
return VISIBILITY_LABEL[v];
}

View File

@@ -46,6 +46,8 @@
"./agents/queries": "./agents/queries.ts",
"./agents/derive-presence": "./agents/derive-presence.ts",
"./agents/use-agent-presence": "./agents/use-agent-presence.ts",
"./agents/visibility-label": "./agents/visibility-label.ts",
"./permissions": "./permissions/index.ts",
"./projects": "./projects/index.ts",
"./projects/queries": "./projects/queries.ts",
"./projects/mutations": "./projects/mutations.ts",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import {
type AgentActivity,
type AgentPresenceDetail,
summarizeActivityWindow,
VISIBILITY_TOOLTIP,
} from "@multica/core/agents";
import {
Tooltip,
@@ -30,6 +31,8 @@ export interface AgentRow {
// Inline owner avatar — non-null when the page wants to attribute the
// agent to a teammate (typically All scope on someone else's agent).
ownerIdToShow: string | null;
// True when the current user owns this agent (drives the "You" badge).
isOwnedByMe: boolean;
// True when the current user can archive / cancel-tasks on this agent.
canManage: boolean;
}
@@ -150,7 +153,7 @@ export function createAgentColumns({
// ---------------------------------------------------------------------------
function AgentNameCell({ row }: { row: AgentRow }) {
const { agent, ownerIdToShow } = row;
const { agent, ownerIdToShow, isOwnedByMe } = row;
const isArchived = !!agent.archived_at;
const isPrivate = agent.visibility === "private";
@@ -180,10 +183,15 @@ function AgentNameCell({ row }: { row: AgentRow }) {
}
/>
<TooltipContent>
Private only the owner can assign work
{VISIBILITY_TOOLTIP.private}
</TooltipContent>
</Tooltip>
)}
{isOwnedByMe && !ownerIdToShow && (
<span className="shrink-0 rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
You
</span>
)}
{ownerIdToShow && (
<ActorAvatar
actorType="member"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,11 +26,14 @@ export function ModelPicker({
runtimeId,
runtimeOnline,
value,
canEdit = true,
onChange,
}: {
runtimeId: string | null;
runtimeOnline: boolean;
value: string;
/** When false, render a static read-only display and skip the popover. */
canEdit?: boolean;
onChange: (next: string) => Promise<void> | void;
}) {
const [open, setOpen] = useState(false);
@@ -83,6 +86,17 @@ export function ModelPicker({
(defaultModel ? `Default — ${defaultModel.label}` : "Default");
const triggerTitle = `Model · ${triggerLabel}`;
if (!canEdit) {
return (
<span
className="min-w-0 truncate px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
title={triggerTitle}
>
{triggerLabel}
</span>
);
}
return (
<PropertyPicker
open={open}

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,13 @@ vi.mock("@multica/core/api", () => ({
},
}));
// Mock the auth store: items() reads `useAuthStore.getState()` imperatively
// to identify the current user when filtering personal agents.
const authState = { user: { id: "u1" } as { id: string } | null };
vi.mock("@multica/core/auth", () => ({
useAuthStore: { getState: () => authState },
}));
import {
createMentionSuggestion,
MentionList,
@@ -29,8 +36,14 @@ import {
} from "./mention-suggestion";
function fakeQc(data: {
members?: Array<{ user_id: string; name: string }>;
agents?: Array<{ id: string; name: string; archived_at: string | null }>;
members?: Array<{ user_id: string; name: string; role?: string }>;
agents?: Array<{
id: string;
name: string;
archived_at: string | null;
visibility?: "workspace" | "private";
owner_id?: string | null;
}>;
issues?: Array<{ id: string; identifier: string; title: string; status: string }>;
}): QueryClient {
const map = new Map<string, unknown>();
@@ -57,8 +70,16 @@ describe("createMentionSuggestion", () => {
it("returns members and agents synchronously without waiting for the server search", () => {
const qc = fakeQc({
members: [{ user_id: "u1", name: "Alice" }],
agents: [{ id: "a1", name: "Aegis", archived_at: null }],
members: [{ user_id: "u1", name: "Alice", role: "member" }],
agents: [
{
id: "a1",
name: "Aegis",
archived_at: null,
visibility: "workspace",
owner_id: null,
},
],
});
// A pending fetch — would block the result if items() awaited it.
searchIssuesMock.mockReturnValue(new Promise(() => {}));
@@ -119,6 +140,78 @@ describe("createMentionSuggestion", () => {
).toBe(true);
});
it("hides personal agents owned by someone else from a regular member", () => {
const qc = fakeQc({
members: [
{ user_id: "u1", name: "Alice", role: "member" },
{ user_id: "u2", name: "Bob", role: "member" },
],
agents: [
// Bob's personal agent — Alice (current user) should not see it.
{
id: "a-personal-bob",
name: "Atlas",
archived_at: null,
visibility: "private",
owner_id: "u2",
},
// Alice's own personal agent — should be visible.
{
id: "a-personal-alice",
name: "Athena",
archived_at: null,
visibility: "private",
owner_id: "u1",
},
// Workspace agent — visible to everyone.
{
id: "a-shared",
name: "Aether",
archived_at: null,
visibility: "workspace",
owner_id: "u2",
},
],
});
searchIssuesMock.mockReturnValue(new Promise(() => {}));
const config = createMentionSuggestion(qc);
const result = config.items!({ query: "a", editor: {} as never });
const items = result as MentionItem[];
expect(items.some((i) => i.type === "agent" && i.label === "Athena")).toBe(true);
expect(items.some((i) => i.type === "agent" && i.label === "Aether")).toBe(true);
expect(items.some((i) => i.type === "agent" && i.label === "Atlas")).toBe(false);
});
it("shows everyone's personal agents to a workspace admin", () => {
// Role lives in the member fixture, not in authState — promoting Alice
// to admin here is enough to flip the gate. Backend gate allows admins
// to assign anyone's personal agent, so the @mention list mirrors that.
const qc = fakeQc({
members: [
{ user_id: "u1", name: "Alice", role: "admin" },
{ user_id: "u2", name: "Bob", role: "member" },
],
agents: [
{
id: "a-personal-bob",
name: "Atlas",
archived_at: null,
visibility: "private",
owner_id: "u2",
},
],
});
searchIssuesMock.mockReturnValue(new Promise(() => {}));
const config = createMentionSuggestion(qc);
const result = config.items!({ query: "a", editor: {} as never });
const items = result as MentionItem[];
expect(items.some((i) => i.type === "agent" && i.label === "Atlas")).toBe(true);
});
it("includes cached issues in the synchronous response", () => {
const qc = fakeQc({
issues: [

View File

@@ -15,6 +15,8 @@ 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,
@@ -363,6 +365,15 @@ 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[] =
@@ -379,7 +390,12 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
}));
const agentItems: MentionItem[] = agents
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
.filter(
(a) =>
!a.archived_at &&
a.name.toLowerCase().includes(q) &&
canAssignAgentToIssue(a, { userId, role: myRole }).allowed,
)
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
// Members and agents share a single ranked list — recently mentioned

View File

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

View File

@@ -47,6 +47,14 @@ 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;
@@ -153,6 +161,7 @@ function CommentRow({
issueId,
entry,
currentUserId,
canModerate = false,
onEdit,
onDelete,
onToggleReaction,
@@ -160,6 +169,7 @@ 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;
@@ -175,6 +185,8 @@ 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);
@@ -252,18 +264,22 @@ function CommentRow({
<Copy className="h-3.5 w-3.5" />
Copy
</DropdownMenuItem>
{isOwn && (
{(canEditEntry || canDeleteEntry) && (
<>
<DropdownMenuSeparator />
<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>
{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>
)}
</>
)}
</DropdownMenuContent>
@@ -337,6 +353,7 @@ function CommentCard({
entry,
allReplies,
currentUserId,
canModerate = false,
onReply,
onEdit,
onDelete,
@@ -358,6 +375,12 @@ 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);
@@ -467,18 +490,22 @@ function CommentCard({
<Copy className="h-3.5 w-3.5" />
Copy
</DropdownMenuItem>
{isOwn && (
{(canEditEntry || canDeleteEntry) && (
<>
<DropdownMenuSeparator />
<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>
{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>
)}
</>
)}
</DropdownMenuContent>
@@ -554,6 +581,7 @@ function CommentCard({
issueId={issueId}
entry={reply}
currentUserId={currentUserId}
canModerate={canModerate}
onEdit={onEdit}
onDelete={onDelete}
onToggleReaction={onToggleReaction}

View File

@@ -159,6 +159,13 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
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);
@@ -908,6 +915,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
entry={entry}
allReplies={repliesByParent}
currentUserId={user?.id}
canModerate={canModerateComments}
onReply={submitReply}
onEdit={editComment}
onDelete={deleteComment}

View File

@@ -5,6 +5,7 @@ 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";
@@ -16,11 +17,22 @@ import {
PickerEmpty,
} from "./property-picker";
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;
/**
* 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 AssigneePicker({
@@ -147,12 +159,22 @@ export function AssigneePicker({
{filteredAgents.length > 0 && (
<PickerSection label="Agents">
{filteredAgents.map((a) => {
const allowed = canAssignAgent(a, user?.id, memberRole);
const decision = canAssignAgentToIssue(a, {
userId: user?.id ?? null,
role:
memberRole === "owner" ||
memberRole === "admin" ||
memberRole === "member"
? memberRole
: null,
});
const allowed = decision.allowed;
return (
<PickerItem
key={a.id}
selected={isSelected("agent", a.id)}
disabled={!allowed}
tooltip={!allowed ? decision.message : undefined}
onClick={() => {
if (!allowed) return;
onUpdate({

View File

@@ -49,6 +49,9 @@ export function DeleteIssueConfirmModal({
<AlertDialogTitle>Delete issue</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this issue and all its comments. This action cannot be undone.
<span className="mt-2 block text-xs text-muted-foreground/80">
Any workspace member can delete issues.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View File

@@ -118,11 +118,16 @@ export function createRuntimeColumns({
size: COL_WIDTHS.owner,
cell: ({ row }) =>
row.original.ownerMember ? (
<ActorAvatar
actorType="member"
actorId={row.original.ownerMember.user_id}
size={18}
/>
<span className="inline-flex min-w-0 items-center gap-1.5">
<ActorAvatar
actorType="member"
actorId={row.original.ownerMember.user_id}
size={18}
/>
<span className="truncate text-xs text-muted-foreground">
{row.original.ownerMember.name}
</span>
</span>
) : (
<span className="text-xs text-muted-foreground/50"></span>
),
@@ -510,6 +515,7 @@ function RowMenu({
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteOpen(true)}
title="Only the runtime owner and workspace admins can delete this runtime"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
@@ -529,6 +535,9 @@ function RowMenu({
<AlertDialogDescription>
Are you sure you want to delete &ldquo;{runtime.name}&rdquo;?
This action cannot be undone.
<span className="mt-2 block text-xs text-muted-foreground/80">
Only the runtime owner and workspace admins can delete a runtime.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View File

@@ -53,6 +53,7 @@ function MemberRow({
member,
canManage,
canManageOwners,
ownerCount,
isSelf,
busy,
onRoleChange,
@@ -61,6 +62,9 @@ function MemberRow({
member: MemberWithUser;
canManage: boolean;
canManageOwners: boolean;
/** Total number of owners in this workspace — needed to gate demoting the
* last owner per `workspace.go:497-507`. */
ownerCount: number;
isSelf: boolean;
busy: boolean;
onRoleChange: (role: MemberRole) => void;
@@ -70,6 +74,7 @@ function MemberRow({
const RoleIcon = rc.icon;
const canEditRole = canManage && !isSelf && (member.role !== "owner" || canManageOwners);
const canRemove = canManage && !isSelf && (member.role !== "owner" || canManageOwners);
const isLastOwner = member.role === "owner" && ownerCount <= 1;
const showMenu = canEditRole || canRemove;
return (
@@ -100,16 +105,31 @@ function MemberRow({
([role, config]) => {
if (role === "owner" && !canManageOwners) return null;
const Icon = config.icon;
// Demoting the last owner would leave the workspace
// ownerless — server rejects with 400, mirror that
// here as a disabled option with explanation.
const wouldDemoteLastOwner =
isLastOwner && role !== "owner";
return (
<DropdownMenuItem
key={role}
onClick={() => onRoleChange(role)}
onClick={() =>
wouldDemoteLastOwner ? undefined : onRoleChange(role)
}
disabled={wouldDemoteLastOwner}
title={
wouldDemoteLastOwner
? "Promote another member to owner first — a workspace must keep at least one owner."
: undefined
}
>
<Icon className="h-3.5 w-3.5" />
<div className="flex flex-col">
<span>{config.label}</span>
<span className="text-xs text-muted-foreground font-normal">
{config.description}
{wouldDemoteLastOwner
? "Cannot demote the last owner"
: config.description}
</span>
</div>
{member.role === role && (
@@ -206,6 +226,7 @@ export function MembersTab() {
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
const isOwner = currentMember?.role === "owner";
const ownerCount = members.filter((m) => m.role === "owner").length;
const handleInviteMember = async () => {
if (!workspace) return;
@@ -337,6 +358,7 @@ export function MembersTab() {
member={m}
canManage={canManageWorkspace}
canManageOwners={isOwner}
ownerCount={ownerCount}
isSelf={m.user_id === user?.id}
busy={memberActionId === m.id}
onRoleChange={(role) => handleRoleChange(m.id, role)}

View File

@@ -58,6 +58,8 @@ import {
} from "@multica/ui/components/ui/tooltip";
import { AppLink, useNavigation } from "../../navigation";
import { useCanEditSkill } from "../hooks/use-can-edit-skill";
import { useSkillPermissions } from "@multica/core/permissions";
import { CapabilityBanner } from "@multica/ui/components/common/capability-banner";
import { readOrigin, totalFileCount, type OriginInfo } from "../lib/origin";
import { FileTree } from "./file-tree";
import { FileViewer } from "./file-viewer";
@@ -259,6 +261,9 @@ export function SkillDetailPage({ skillId }: { skillId: string }) {
);
const canEdit = useCanEditSkill(skill, wsId);
// Rich Decision for the read-only banner — same answer as `canEdit`, but
// carries the `reason` enum the banner needs to render correct copy.
const skillPermissions = useSkillPermissions(skill ?? null, wsId);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
@@ -608,6 +613,16 @@ export function SkillDetailPage({ skillId }: { skillId: string }) {
</div>
</div>
{!canEdit && (
<div className="px-4 pt-3">
<CapabilityBanner
reason={skillPermissions.canEdit.reason}
resource="skill"
ownerName={creator?.name}
/>
</div>
)}
{/* Supporting query error banner (non-blocking — the page still works
but agent attribution / runtime names / permission checks are
partial). */}