mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
3 Commits
main
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c61a0e888 | ||
|
|
bc25de8b3a | ||
|
|
285c3fb3ca |
@@ -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(
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user