Files
multica/packages/core/permissions/use-resource-permissions.ts
Jiayuan Zhang 3bd69bdaa2 MUL-4432: add DM button on agent detail page (#5289)
* feat(agents): add DM button on agent detail page

The header gains a DM action next to Assign work. It shares the MUL-3963
invocation gate with assignment: allowed users land on the Chat tab with a
fresh compose bound to the agent via a new one-shot ?agent=<id> deep link
(consumed once the permission-filtered agent list resolves, then stripped);
denied users get an explanatory toast instead of a hidden affordance.

Closes MUL-4432

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): harden the ?agent= deep link against races and undetermined permissions

Review follow-up for the DM button (PR #5289):

- An explicit user action (thread select, archive, manual new chat) now
  supersedes a still-pending ?agent= intent, so an intent deferred by slow
  agent/member queries can no longer fire late and clobber that choice.
- The composingNew reset reads the live store value; under StrictMode's
  effect replay the render-captured snapshot re-closed the compose pane the
  intent had just opened (one-shot guard rightly refuses to re-fire).
- A settled miss (revoked access, archived agent, bad id) now toasts and
  strips the param instead of silently keeping a re-fireable intent; still-
  loading queries keep the intent pending. Query errors never settle.
- useAgentPermissions exposes isLoading: the detail page disables DM while
  membership resolves instead of toasting a false not_member deny at a
  legitimate member.

The chat store test mock is now reactive (useSyncExternalStore) — with a
plain mutable ref React bails out of committing post-effect re-renders and
the StrictMode regression cannot reproduce. Both new regression tests fail
against the pre-fix code.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-12 13:56:31 +08:00

72 lines
2.1 KiB
TypeScript

"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;
isLoading: boolean;
} {
const { userId, role, isLoading } = useCurrentMember(wsId);
const ctx = { userId, role };
// While the member query is in flight, `role` is null and the rules below
// would misread a legitimate member as denied (e.g. `not_member` for a
// public_to+workspace agent). Callers with always-clickable affordances
// must treat `isLoading` as "undetermined", not as a deny.
if (agent === null) {
return { canEdit: PENDING, canAssign: PENDING, isLoading };
}
return {
canEdit: canEditAgent(agent, ctx),
canAssign: canAssignAgentToIssue(agent, ctx),
isLoading,
};
}
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),
};
}