multica/apps/web/features/issues/components/pickers/assignee-picker.tsx
Jiayuan c0166e5264 fix(ui): show avatar images in assignee dropdowns
Replace inline div placeholders (initials/Bot icon) with ActorAvatar
component so actual user and agent avatar images render in all assignee
picker dropdowns.
2026-04-01 03:50:47 +08:00

151 lines
4.6 KiB
TypeScript

"use client";
import { useState } from "react";
import { Lock, UserMinus } from "lucide-react";
import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@/shared/types";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { ActorAvatar } from "@/components/common/actor-avatar";
import {
PropertyPicker,
PickerItem,
PickerSection,
PickerEmpty,
} from "./property-picker";
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,
}: {
assigneeType: IssueAssigneeType | null;
assigneeId: string | null;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
trigger?: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const user = useAuthStore((s) => s.user);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
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.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"
searchable
searchPlaceholder="Assign to..."
onSearchChange={setFilter}
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>
);
}