multica/apps/web/features/issues/components/pickers/assignee-picker.tsx
Naiyuan Qing e40341ab73 feat(core): migrate workspace + runtimes to TanStack Query (Phase 3+4)
- Create core/workspace/ with queries (members, agents, skills, list) and mutations
- Create core/runtimes/ with queries
- Migrate 11 consumer files from useWorkspaceStore.members/agents/skills to useQuery
- Replace all WS refreshMap entries with qc.invalidateQueries
- Simplify workspace store: delete members/agents/skills fields + refresh methods,
  hydrateWorkspace becomes synchronous (TQ auto-fetches on component mount)
- Delete useRuntimeStore (no consumers left), runtimes-page uses local useState + TQ
- Remove workspace→runtime cross-store dependency
- Clean up dead test helper mocks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:19:52 +08:00

167 lines
5.3 KiB
TypeScript

"use client";
import { useState } from "react";
import { Lock, UserMinus } from "lucide-react";
import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@/shared/types";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@/features/auth";
import { useActorName } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
import { ActorAvatar } from "@/components/common/actor-avatar";
import {
PropertyPicker,
PickerItem,
PickerSection,
PickerEmpty,
} from "./property-picker";
export function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
if (agent.visibility !== "private") return true;
if (agent.owner_id === userId) return true;
if (memberRole === "owner" || memberRole === "admin") return true;
return false;
}
export function AssigneePicker({
assigneeType,
assigneeId,
onUpdate,
trigger: customTrigger,
triggerRender,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
align,
}: {
assigneeType: IssueAssigneeType | null;
assigneeId: string | null;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
trigger?: React.ReactNode;
triggerRender?: React.ReactElement;
open?: boolean;
onOpenChange?: (v: boolean) => void;
align?: "start" | "center" | "end";
}) {
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = controlledOnOpenChange ?? setInternalOpen;
const [filter, setFilter] = useState("");
const user = useAuthStore((s) => s.user);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { getActorName } = useActorName();
const currentMember = members.find((m) => m.user_id === user?.id);
const memberRole = currentMember?.role;
const query = filter.toLowerCase();
const filteredMembers = members.filter((m) =>
m.name.toLowerCase().includes(query),
);
const filteredAgents = agents.filter((a) =>
!a.archived_at && a.name.toLowerCase().includes(query),
);
const isSelected = (type: string, id: string) =>
assigneeType === type && assigneeId === id;
const triggerLabel =
assigneeType && assigneeId
? getActorName(assigneeType, assigneeId)
: "Unassigned";
return (
<PropertyPicker
open={open}
onOpenChange={(v: boolean) => {
setOpen(v);
if (!v) setFilter("");
}}
width="w-52"
align={align}
searchable
searchPlaceholder="Assign to..."
onSearchChange={setFilter}
triggerRender={triggerRender}
trigger={
customTrigger ? customTrigger : assigneeType && assigneeId ? (
<>
<ActorAvatar actorType={assigneeType} actorId={assigneeId} size={18} />
<span className="truncate">{triggerLabel}</span>
</>
) : (
<span className="text-muted-foreground">Unassigned</span>
)
}
>
{/* Unassigned option */}
<PickerItem
selected={!assigneeType && !assigneeId}
onClick={() => {
onUpdate({ assignee_type: null, assignee_id: null });
setOpen(false);
}}
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Unassigned</span>
</PickerItem>
{/* Members */}
{filteredMembers.length > 0 && (
<PickerSection label="Members">
{filteredMembers.map((m) => (
<PickerItem
key={m.user_id}
selected={isSelected("member", m.user_id)}
onClick={() => {
onUpdate({
assignee_type: "member",
assignee_id: m.user_id,
});
setOpen(false);
}}
>
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
<span>{m.name}</span>
</PickerItem>
))}
</PickerSection>
)}
{/* Agents */}
{filteredAgents.length > 0 && (
<PickerSection label="Agents">
{filteredAgents.map((a) => {
const allowed = canAssignAgent(a, user?.id, memberRole);
return (
<PickerItem
key={a.id}
selected={isSelected("agent", a.id)}
disabled={!allowed}
onClick={() => {
if (!allowed) return;
onUpdate({
assignee_type: "agent",
assignee_id: a.id,
});
setOpen(false);
}}
>
<ActorAvatar actorType="agent" actorId={a.id} size={18} />
<span className={allowed ? "" : "text-muted-foreground"}>{a.name}</span>
{a.visibility === "private" && (
<Lock className="ml-auto h-3 w-3 text-muted-foreground" />
)}
</PickerItem>
);
})}
</PickerSection>
)}
{filteredMembers.length === 0 &&
filteredAgents.length === 0 &&
filter && <PickerEmpty />}
</PropertyPicker>
);
}