Compare commits

...

3 Commits

Author SHA1 Message Date
Jiayuan Zhang
3c61a0e888 Merge branch 'main' into agent/lambda/bb798d97 2026-05-13 15:59:35 +02:00
Jiayuan Zhang
bc25de8b3a test(issues): mock squadListOptions + add Assignee picker handoff test
`AssigneePicker` reads `squadListOptions` and `assigneeFrequencyOptions`
from `@multica/core/workspace/queries`. Tests that render IssueDetail
or IssueActionsDropdown without those mocks throw at the picker's
useQuery call and cascade into unrelated assertion failures — this is
what was leaving the `@multica/views` test job red on the MUL-2157 PR.

Add the missing mocks. Add a regression test that clicks the Assignee
menu item and asserts the shared picker (search input + Members group)
takes over, so a future regression to the parallel-implementation bug
this PR fixes fails loudly instead of silently.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 19:48:57 +08:00
Jiayuan Zhang
285c3fb3ca fix(issues): unify assignee menu with shared AssigneePicker (MUL-2157)
The Assignee submenu inside IssueActionsMenuItems was a parallel
implementation: no search, no squads, no agent permission check, no
archive filter, no frequency sort. The divergence was most visible from
the Inbox (where the issue detail's sidebar starts collapsed, so users
reach for the 3-dot menu).

Replace the submenu with a single menu item that closes the
surrounding dropdown / context menu and hands off to the shared
AssigneePicker popover — same component already used in the issue
detail sidebar, board cards, batch toolbar, and create-issue modal.

The picker is conditionally mounted to avoid every row in list / board
views subscribing to the members / agents / squads / frequency queries
on mount.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 19:39:00 +08:00
6 changed files with 147 additions and 126 deletions

View File

@@ -48,6 +48,18 @@ vi.mock("@multica/core/workspace/queries", () => ({
queryKey: ["workspaces", "ws-1", "agents"],
queryFn: () => Promise.resolve([]),
}),
squadListOptions: () => ({
queryKey: ["workspaces", "ws-1", "squads"],
queryFn: () => Promise.resolve([]),
}),
assigneeFrequencyOptions: () => ({
queryKey: ["workspaces", "ws-1", "assignee-frequency"],
queryFn: () => Promise.resolve([]),
}),
}));
vi.mock("@multica/core/workspace/hooks", () => ({
useActorName: () => ({ getActorName: (_t: string, _id: string) => "" }),
}));
vi.mock("@multica/core/pins", () => ({
@@ -158,6 +170,29 @@ describe("IssueActionsDropdown", () => {
expect(screen.queryByText("Add sub-issue...")).not.toBeInTheDocument();
});
it("clicking the Assignee item opens the shared AssigneePicker popover", async () => {
render(
wrap(
<IssueActionsDropdown
issue={mockIssue}
trigger={<button data-testid="trigger">Menu</button>}
/>,
),
);
fireEvent.click(screen.getByTestId("trigger"));
fireEvent.click(await screen.findByText("Assignee"));
// The shared picker exposes a search input and renders the workspace
// member under a "Members" group — both come from `AssigneePicker`, not
// the legacy submenu (which had neither).
expect(
await screen.findByPlaceholderText("Assign to..."),
).toBeInTheDocument();
expect(await screen.findByText("Members")).toBeInTheDocument();
expect(await screen.findByText("Test User")).toBeInTheDocument();
});
it("clicking Delete issue opens the delete-confirm modal", async () => {
render(
wrap(

View File

@@ -27,20 +27,6 @@ vi.mock("@multica/core/auth", () => ({
registerAuthStore: vi.fn(),
}));
vi.mock("@multica/core/workspace/queries", () => ({
memberListOptions: () => ({
queryKey: ["workspaces", "ws-1", "members"],
queryFn: () =>
Promise.resolve([
{ user_id: "user-1", name: "Test User", email: "t@t.com", role: "admin" },
]),
}),
agentListOptions: () => ({
queryKey: ["workspaces", "ws-1", "agents"],
queryFn: () => Promise.resolve([]),
}),
}));
// Mutable so individual tests can seed the pin list.
const pinListRef: { value: Array<{ item_type: string; item_id: string }> } = {
value: [],
@@ -267,12 +253,4 @@ describe("useIssueActions", () => {
expect(mockOpenModal).not.toHaveBeenCalled();
});
it("members and filtered agents are exposed on the result", async () => {
const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper });
await waitFor(() => {
expect(result.current.members.length).toBe(1);
});
expect(result.current.members[0]!.user_id).toBe("user-1");
expect(result.current.agents).toEqual([]);
});
});

View File

@@ -1,6 +1,6 @@
"use client";
import type { ReactElement } from "react";
import { useRef, useState, type ReactElement } from "react";
import type { Issue } from "@multica/core/types";
import {
ContextMenu,
@@ -12,6 +12,7 @@ import {
IssueActionsMenuItems,
contextPrimitives,
} from "./issue-actions-menu-items";
import { AssigneePicker } from "../components/pickers";
interface IssueActionsContextMenuProps {
issue: Issue;
@@ -24,16 +25,59 @@ export function IssueActionsContextMenu({
children,
}: IssueActionsContextMenuProps) {
const actions = useIssueActions(issue);
const [assigneeOpen, setAssigneeOpen] = useState(false);
// Right-click coordinates captured during contextmenu so the AssigneePicker
// opens where the context menu just was, instead of jumping to the row's
// top-left corner. Reset between opens; only consulted while the picker is
// mounted-open.
const clickPosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const handleContextMenu = (e: React.MouseEvent) => {
clickPosRef.current = { x: e.clientX, y: e.clientY };
};
return (
<ContextMenu>
<ContextMenuTrigger render={children} />
<ContextMenuContent>
<IssueActionsMenuItems
issue={issue}
actions={actions}
primitives={contextPrimitives}
<>
<ContextMenu>
<ContextMenuTrigger
render={children}
onContextMenu={handleContextMenu}
/>
</ContextMenuContent>
</ContextMenu>
<ContextMenuContent>
<IssueActionsMenuItems
issue={issue}
actions={actions}
primitives={contextPrimitives}
onOpenAssignee={() => setAssigneeOpen(true)}
/>
</ContextMenuContent>
</ContextMenu>
{/* Mount the picker only once the user actually opens it. Otherwise
every row in a list/board would subscribe to members/agents/squads
/frequency queries on mount, multiplying memory + render cost. */}
{assigneeOpen && (
<AssigneePicker
assigneeType={issue.assignee_type}
assigneeId={issue.assignee_id}
onUpdate={actions.updateField}
open={assigneeOpen}
onOpenChange={setAssigneeOpen}
triggerRender={
<span
aria-hidden
className="pointer-events-none fixed"
style={{
left: clickPosRef.current.x,
top: clickPosRef.current.y,
width: 0,
height: 0,
}}
/>
}
trigger={<span />}
align="start"
/>
)}
</>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import type { ReactElement } from "react";
import { useState, type ReactElement } from "react";
import type { Issue } from "@multica/core/types";
import {
DropdownMenu,
@@ -12,6 +12,7 @@ import {
IssueActionsMenuItems,
dropdownPrimitives,
} from "./issue-actions-menu-items";
import { AssigneePicker } from "../components/pickers";
interface IssueActionsDropdownProps {
issue: Issue;
@@ -29,17 +30,46 @@ export function IssueActionsDropdown({
onDeletedNavigateTo,
}: IssueActionsDropdownProps) {
const actions = useIssueActions(issue);
const [assigneeOpen, setAssigneeOpen] = useState(false);
// The outer `relative inline-flex` is the picker's anchor box: the
// absolute, pointer-events-none span inside `triggerRender` fills it, so
// the popover positions itself relative to the dropdown's 3-dot button
// without us having to thread a ref through Base UI's anchor API.
return (
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align={align} className="w-auto">
<IssueActionsMenuItems
issue={issue}
actions={actions}
primitives={dropdownPrimitives}
onDeletedNavigateTo={onDeletedNavigateTo}
<span className="relative inline-flex">
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align={align} className="w-auto">
<IssueActionsMenuItems
issue={issue}
actions={actions}
primitives={dropdownPrimitives}
onOpenAssignee={() => setAssigneeOpen(true)}
onDeletedNavigateTo={onDeletedNavigateTo}
/>
</DropdownMenuContent>
</DropdownMenu>
{/* Mount the picker only once the user actually opens it. Otherwise
every row in a list/board would subscribe to members/agents/squads
/frequency queries on mount, multiplying memory + render cost. */}
{assigneeOpen && (
<AssigneePicker
assigneeType={issue.assignee_type}
assigneeId={issue.assignee_id}
onUpdate={actions.updateField}
open={assigneeOpen}
onOpenChange={setAssigneeOpen}
triggerRender={
<span
aria-hidden
className="pointer-events-none absolute inset-0"
/>
}
trigger={<span />}
align={align}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
</span>
);
}

View File

@@ -26,7 +26,6 @@ import {
import { issueKeys } from "@multica/core/issues/queries";
import { StatusIcon } from "../components/status-icon";
import { PriorityIcon } from "../components/priority-icon";
import { ActorAvatar } from "../../common/actor-avatar";
import {
DropdownMenuItem,
DropdownMenuSub,
@@ -78,6 +77,11 @@ interface IssueActionsMenuItemsProps {
issue: Issue;
actions: UseIssueActionsResult;
primitives: MenuPrimitives;
/** Called when the user clicks the Assignee menu item. The parent should
* close the surrounding menu and open the shared `AssigneePicker` popover.
* Decoupled this way so the same item can drive both the dropdown
* (3-dot button) and the context menu (right-click) wrappers. */
onOpenAssignee: () => void;
/** If set, navigate here after the issue is deleted (used by the detail page). */
onDeletedNavigateTo?: string;
}
@@ -86,12 +90,11 @@ export function IssueActionsMenuItems({
issue,
actions,
primitives: P,
onOpenAssignee,
onDeletedNavigateTo,
}: IssueActionsMenuItemsProps) {
const { t } = useT("issues");
const {
members,
agents,
isPinned,
updateField,
togglePin,
@@ -184,55 +187,15 @@ export function IssueActionsMenuItems({
</P.SubContent>
</P.Sub>
{/* Assignee */}
<P.Sub>
<P.SubTrigger>
<UserMinus className="h-3.5 w-3.5" />
{t(($) => $.actions.assignee)}
</P.SubTrigger>
<P.SubContent>
<P.Item
onClick={() =>
updateField({ assignee_type: null, assignee_id: null })
}
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
{t(($) => $.actions.unassigned)}
{!issue.assignee_type && (
<span className="ml-auto text-xs text-muted-foreground">{"✓"}</span>
)}
</P.Item>
{members.map((m) => (
<P.Item
key={m.user_id}
onClick={() =>
updateField({ assignee_type: "member", assignee_id: m.user_id })
}
>
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
{m.name}
{issue.assignee_type === "member" &&
issue.assignee_id === m.user_id && (
<span className="ml-auto text-xs text-muted-foreground">{"✓"}</span>
)}
</P.Item>
))}
{agents.map((a) => (
<P.Item
key={a.id}
onClick={() =>
updateField({ assignee_type: "agent", assignee_id: a.id })
}
>
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
{a.name}
{issue.assignee_type === "agent" && issue.assignee_id === a.id && (
<span className="ml-auto text-xs text-muted-foreground">{"✓"}</span>
)}
</P.Item>
))}
</P.SubContent>
</P.Sub>
{/* Assignee — closes this menu and hands off to the shared
AssigneePicker (members + agents + squads, with search and
permission checks). Keeps a single source of truth for the
assignee UX across detail sidebar, board cards, and right-click /
3-dot menus. */}
<P.Item onClick={onOpenAssignee}>
<UserMinus className="h-3.5 w-3.5" />
{t(($) => $.actions.assignee)}
</P.Item>
{/* Due date */}
<P.Sub>

View File

@@ -1,36 +1,22 @@
"use client";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import type {
Issue,
MemberWithUser,
Agent,
UpdateIssueRequest,
} from "@multica/core/types";
import type { Issue, UpdateIssueRequest } from "@multica/core/types";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { useModalStore } from "@multica/core/modals";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import {
memberListOptions,
agentListOptions,
} from "@multica/core/workspace/queries";
import { pinListOptions, useCreatePin, useDeletePin } from "@multica/core/pins";
import { canAssignAgent } from "../components/pickers";
import { useNavigation } from "../../navigation";
import { useT } from "../../i18n";
const BACKLOG_HINT_LS_KEY = "multica:backlog-agent-hint-dismissed";
export interface UseIssueActionsResult {
// Derived data for rendering menu rows
members: MemberWithUser[];
agents: Agent[];
isPinned: boolean;
// Handlers
updateField: (updates: Partial<UpdateIssueRequest>) => void;
togglePin: () => void;
copyLink: () => Promise<void>;
@@ -53,24 +39,11 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
const user = useAuthStore((s) => s.user);
const userId = user?.id;
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: pinnedItems = [] } = useQuery({
...pinListOptions(wsId, userId ?? ""),
enabled: !!userId,
});
const currentMemberRole = useMemo(
() => members.find((m) => m.user_id === userId)?.role,
[members, userId],
);
const filteredAgents = useMemo(
() =>
agents.filter(
(a) => !a.archived_at && canAssignAgent(a, userId, currentMemberRole),
),
[agents, userId, currentMemberRole],
);
const isPinned =
!!issue &&
pinnedItems.some(
@@ -161,8 +134,6 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
);
return {
members,
agents: filteredAgents,
isPinned,
updateField,
togglePin,