diff --git a/packages/core/permissions/use-resource-permissions.ts b/packages/core/permissions/use-resource-permissions.ts index df3ef3cac6..02fa1285be 100644 --- a/packages/core/permissions/use-resource-permissions.ts +++ b/packages/core/permissions/use-resource-permissions.ts @@ -34,15 +34,21 @@ export function useAgentPermissions( ): { canEdit: Decision; canAssign: Decision; + isLoading: boolean; } { - const { userId, role } = useCurrentMember(wsId); + 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 }; + return { canEdit: PENDING, canAssign: PENDING, isLoading }; } return { canEdit: canEditAgent(agent, ctx), canAssign: canAssignAgentToIssue(agent, ctx), + isLoading, }; } diff --git a/packages/views/agents/components/agent-detail-page.test.tsx b/packages/views/agents/components/agent-detail-page.test.tsx index 0e153d969b..27fe4073c3 100644 --- a/packages/views/agents/components/agent-detail-page.test.tsx +++ b/packages/views/agents/components/agent-detail-page.test.tsx @@ -29,6 +29,9 @@ vi.mock("./agent-presence-indicator", () => ({ const agentsRef = vi.hoisted(() => ({ current: [] as unknown[] })); const membersRef = vi.hoisted(() => ({ current: [] as unknown[] })); +// When set, the member query never resolves — the "membership still loading" +// window in which the DM decision is undetermined. +const membersPendingRef = vi.hoisted(() => ({ current: false })); const currentUserRef = vi.hoisted(() => ({ current: { id: "user-1" } as { id: string } | null, })); @@ -48,7 +51,10 @@ vi.mock("@multica/core/workspace/queries", () => ({ }), memberListOptions: (wsId: string) => ({ queryKey: ["members", wsId], - queryFn: () => Promise.resolve(membersRef.current), + queryFn: () => + membersPendingRef.current + ? new Promise(() => {}) + : Promise.resolve(membersRef.current), }), workspaceKeys: { agents: (wsId: string) => ["agents", wsId] }, })); @@ -152,6 +158,7 @@ beforeEach(() => { vi.clearAllMocks(); currentUserRef.current = { id: "user-1" }; membersRef.current = [{ user_id: "user-1", role: "member" }]; + membersPendingRef.current = false; agentsRef.current = [baseAgent]; }); @@ -179,6 +186,19 @@ describe("AgentDetailPage DM button", () => { expect(push).not.toHaveBeenCalled(); }); + it("disables DM while membership is resolving instead of toasting a false deny", async () => { + // Review P2: a pending member query collapses role to null, which the + // rules read as not_member — a legitimate public_to+workspace member + // would get a wrong "no access" toast. Undetermined must disable, not deny. + membersPendingRef.current = true; + const { push } = renderPage(); + const dm = await screen.findByRole("button", { name: "DM" }); + expect(dm).toBeDisabled(); + fireEvent.click(dm); + expect(mockToastError).not.toHaveBeenCalled(); + expect(push).not.toHaveBeenCalled(); + }); + it("hides the DM button on an archived agent", async () => { agentsRef.current = [ { ...baseAgent, archived_at: "2026-06-01T00:00:00Z" }, diff --git a/packages/views/agents/components/agent-detail-page.tsx b/packages/views/agents/components/agent-detail-page.tsx index a457665f81..8e7bb6ffb2 100644 --- a/packages/views/agents/components/agent-detail-page.tsx +++ b/packages/views/agents/components/agent-detail-page.tsx @@ -107,7 +107,11 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) { // 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 { canAssign, canEdit } = useAgentPermissions(agent, wsId); + const { + canAssign, + canEdit, + isLoading: permissionsLoading, + } = useAgentPermissions(agent, wsId); const [confirmArchive, setConfirmArchive] = useState(false); @@ -256,8 +260,11 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) { // Chat shares the invocation gate with assignment (MUL-3963): starting a // chat triggers agent runs. The button stays visible either way — a denied - // click explains itself instead of the affordance silently missing. + // click explains itself instead of the affordance silently missing. While + // membership is still resolving the decision is undetermined, so the button + // is disabled rather than toasting a false "no access" at a real member. const handleDm = () => { + if (permissionsLoading) return; if (!canAssign.allowed) { toast.error(t(($) => $.detail.dm_no_permission_toast)); return; @@ -274,6 +281,7 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) { backHref={paths.agents()} canAssign={canAssign.allowed} canArchive={canEdit.allowed} + dmPending={permissionsLoading} onDm={handleDm} onAssign={() => useModalStore @@ -380,6 +388,7 @@ function DetailHeader({ backHref, canAssign, canArchive, + dmPending, onDm, onAssign, onArchive, @@ -390,6 +399,7 @@ function DetailHeader({ backHref: string; canAssign: boolean; canArchive: boolean; + dmPending: boolean; onDm: () => void; onAssign: () => void; onArchive: () => void; @@ -457,6 +467,7 @@ function DetailHeader({ type="button" variant="outline" size="sm" + disabled={dmPending} onClick={onDm} >