mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 05:46:58 +02:00
* 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>
72 lines
2.1 KiB
TypeScript
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),
|
|
};
|
|
}
|