refactor(web): unify all avatar rendering with ActorAvatar

Replace all inline avatar implementations (initials divs, Bot icons,
inline img tags) with the shared ActorAvatar component for consistency.

- Extend AssigneePicker with controlled open/onOpenChange, triggerRender,
  and align props to support batch toolbar and other contexts
- Replace BatchAssigneePicker (~130 lines) with shared AssigneePicker
- Replace issue-detail sidebar inline DropdownMenu with AssigneePicker
- Add canAssignAgent filtering to issue-detail more menu
- Replace inline avatars in: filter panel, members-tab, agents page,
  mention-hover-card, subscribers AvatarGroup
- Add data-slot="avatar" to ActorAvatar for AvatarGroup compatibility
- Add triggerRender prop to PropertyPicker for custom trigger elements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-04-01 16:58:43 +08:00
parent 39c5cf2cbe
commit 4d74091f8d
9 changed files with 65 additions and 249 deletions

View file

@ -76,6 +76,7 @@ import { useWorkspaceStore } from "@/features/workspace";
import { useRuntimeStore } from "@/features/runtimes"; import { useRuntimeStore } from "@/features/runtimes";
import { useIssueStore } from "@/features/issues"; import { useIssueStore } from "@/features/issues";
import { useFileUpload } from "@/shared/hooks/use-file-upload"; import { useFileUpload } from "@/shared/hooks/use-file-upload";
import { ActorAvatar } from "@/components/common/actor-avatar";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -99,14 +100,6 @@ const taskStatusConfig: Record<string, { label: string; icon: typeof CheckCircle
cancelled: { label: "Cancelled", icon: XCircle, color: "text-muted-foreground" }, cancelled: { label: "Cancelled", icon: XCircle, color: "text-muted-foreground" },
}; };
function getInitials(name: string): string {
return name
.split(/[\s-]+/)
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
function generateId(): string { function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
@ -343,13 +336,7 @@ function AgentListItem({
isSelected ? "bg-accent" : "hover:bg-accent/50" isSelected ? "bg-accent" : "hover:bg-accent/50"
}`} }`}
> >
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted text-xs font-semibold overflow-hidden"> <ActorAvatar actorType="agent" actorId={agent.id} size={32} className="rounded-lg" />
{agent.avatar_url ? (
<img src={agent.avatar_url} alt={agent.name} className="h-full w-full object-cover" />
) : (
getInitials(agent.name)
)}
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -1231,17 +1218,7 @@ function SettingsTab({
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={uploading} disabled={uploading}
> >
{agent.avatar_url ? ( <ActorAvatar actorType="agent" actorId={agent.id} size={64} className="rounded-none" />
<img
src={agent.avatar_url}
alt={agent.name}
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-lg font-semibold text-muted-foreground">
{getInitials(agent.name)}
</span>
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100"> <div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
{uploading ? ( {uploading ? (
<Loader2 className="h-5 w-5 animate-spin text-white" /> <Loader2 className="h-5 w-5 animate-spin text-white" />
@ -1385,13 +1362,7 @@ function AgentDetail({
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Header */} {/* Header */}
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4"> <div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-bold overflow-hidden"> <ActorAvatar actorType="agent" actorId={agent.id} size={28} className="rounded-md" />
{agent.avatar_url ? (
<img src={agent.avatar_url} alt={agent.name} className="h-full w-full object-cover" />
) : (
getInitials(agent.name)
)}
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-sm font-semibold truncate">{agent.name}</h2> <h2 className="text-sm font-semibold truncate">{agent.name}</h2>

View file

@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { Crown, Shield, User, Plus, MoreHorizontal, UserMinus, Users } from "lucide-react"; import { Crown, Shield, User, Plus, MoreHorizontal, UserMinus, Users } from "lucide-react";
import { ActorAvatar } from "@/components/common/actor-avatar";
import type { MemberWithUser, MemberRole } from "@/shared/types"; import type { MemberWithUser, MemberRole } from "@/shared/types";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -70,14 +71,7 @@ function MemberRow({
return ( return (
<div className="flex items-center gap-3 px-4 py-3"> <div className="flex items-center gap-3 px-4 py-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold"> <ActorAvatar actorType="member" actorId={member.user_id} size={32} />
{member.name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{member.name}</div> <div className="text-sm font-medium truncate">{member.name}</div>
<div className="text-xs text-muted-foreground truncate">{member.email}</div> <div className="text-xs text-muted-foreground truncate">{member.email}</div>

View file

@ -45,6 +45,7 @@ function ActorAvatar({
return ( return (
<div <div
data-slot="avatar"
className={cn( className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full font-medium overflow-hidden", "inline-flex shrink-0 items-center justify-center rounded-full font-medium overflow-hidden",
"bg-muted text-muted-foreground", "bg-muted text-muted-foreground",

View file

@ -1,7 +1,6 @@
"use client"; "use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Bot } from "lucide-react";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card"; import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
import { ActorAvatar } from "@/components/common/actor-avatar"; import { ActorAvatar } from "@/components/common/actor-avatar";
import { useWorkspaceStore } from "@/features/workspace"; import { useWorkspaceStore } from "@/features/workspace";
@ -49,9 +48,7 @@ function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72"> <HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted"> <ActorAvatar actorType="agent" actorId={id} size={32} />
<Bot className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium truncate">{agent.name}</p> <p className="text-sm font-medium truncate">{agent.name}</p>
{agent.description && ( {agent.description && (

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { X, Trash2, Lock, UserMinus } from "lucide-react"; import { X, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -19,16 +19,14 @@ import {
PopoverTrigger, PopoverTrigger,
PopoverContent, PopoverContent,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import type { Agent, UpdateIssueRequest } from "@/shared/types"; import type { UpdateIssueRequest } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config"; import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useIssueStore } from "@/features/issues/store"; import { useIssueStore } from "@/features/issues/store";
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store"; import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
import { api } from "@/shared/api"; import { api } from "@/shared/api";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { StatusIcon } from "./status-icon"; import { StatusIcon } from "./status-icon";
import { PriorityIcon } from "./priority-icon"; import { PriorityIcon } from "./priority-icon";
import { AssigneePicker } from "./pickers";
export function BatchActionToolbar() { export function BatchActionToolbar() {
const selectedIds = useIssueSelectionStore((s) => s.selectedIds); const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
@ -45,7 +43,7 @@ export function BatchActionToolbar() {
const ids = Array.from(selectedIds); const ids = Array.from(selectedIds);
const handleBatchUpdate = async (updates: UpdateIssueRequest) => { const handleBatchUpdate = async (updates: Partial<UpdateIssueRequest>) => {
setLoading(true); setLoading(true);
try { try {
await api.batchUpdateIssues(ids, updates); await api.batchUpdateIssues(ids, updates);
@ -162,11 +160,15 @@ export function BatchActionToolbar() {
</Popover> </Popover>
{/* Assignee */} {/* Assignee */}
<BatchAssigneePicker <AssigneePicker
assigneeType={null}
assigneeId={null}
onUpdate={handleBatchUpdate}
open={assigneeOpen} open={assigneeOpen}
onOpenChange={setAssigneeOpen} onOpenChange={setAssigneeOpen}
onUpdate={handleBatchUpdate} triggerRender={<Button variant="ghost" size="sm" disabled={loading} />}
loading={loading} trigger="Assignee"
align="center"
/> />
{/* Delete */} {/* Delete */}
@ -208,130 +210,3 @@ export function BatchActionToolbar() {
); );
} }
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;
}
function BatchAssigneePicker({
open,
onOpenChange,
onUpdate,
loading,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onUpdate: (updates: UpdateIssueRequest) => void;
loading: boolean;
}) {
const [filter, setFilter] = useState("");
const user = useAuthStore((s) => s.user);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
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),
);
return (
<Popover
open={open}
onOpenChange={(v) => {
onOpenChange(v);
if (!v) setFilter("");
}}
>
<PopoverTrigger
render={
<Button variant="ghost" size="sm" disabled={loading} />
}
>
Assignee
</PopoverTrigger>
<PopoverContent align="center" className="w-52 p-0">
<div className="px-2 py-1.5 border-b">
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Assign to..."
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="p-1 max-h-60 overflow-y-auto">
<button
type="button"
onClick={() => {
onUpdate({ assignee_type: null, assignee_id: null });
onOpenChange(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Unassigned</span>
</button>
{filteredMembers.length > 0 && (
<div>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Members
</div>
{filteredMembers.map((m) => (
<button
key={m.user_id}
type="button"
onClick={() => {
onUpdate({ assignee_type: "member", assignee_id: m.user_id });
onOpenChange(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
<span>{m.name}</span>
</button>
))}
</div>
)}
{filteredAgents.length > 0 && (
<div>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Agents
</div>
{filteredAgents.map((a) => {
const allowed = canAssignAgent(a, user?.id, memberRole);
return (
<button
key={a.id}
type="button"
disabled={!allowed}
onClick={() => {
if (!allowed) return;
onUpdate({ assignee_type: "agent", assignee_id: a.id });
onOpenChange(false);
}}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${allowed ? "hover:bg-accent" : "opacity-50 cursor-not-allowed"}`}
>
<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" />
)}
</button>
);
})}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}

View file

@ -53,11 +53,11 @@ import {
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"; import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar"; import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
import { ActorAvatar } from "@/components/common/actor-avatar"; import { ActorAvatar } from "@/components/common/actor-avatar";
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types"; import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config"; import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { StatusIcon, PriorityIcon, DueDatePicker } from "@/features/issues/components"; import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker } from "@/features/issues/components";
import { CommentCard } from "./comment-card"; import { CommentCard } from "./comment-card";
import { CommentInput } from "./comment-input"; import { CommentInput } from "./comment-input";
import { AgentLiveCard, TaskRunHistory } from "./agent-live-card"; import { AgentLiveCard, TaskRunHistory } from "./agent-live-card";
@ -173,13 +173,15 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const workspace = useWorkspaceStore((s) => s.workspace); const workspace = useWorkspaceStore((s) => s.workspace);
const members = useWorkspaceStore((s) => s.members); const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents); const agents = useWorkspaceStore((s) => s.agents);
const currentMember = members.find((m) => m.user_id === user?.id);
const memberRole = currentMember?.role;
// Issue navigation // Issue navigation
const allIssues = useIssueStore((s) => s.issues); const allIssues = useIssueStore((s) => s.issues);
const currentIndex = allIssues.findIndex((i) => i.id === id); const currentIndex = allIssues.findIndex((i) => i.id === id);
const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null; const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null;
const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null; const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null;
const { getActorName, getActorInitials } = useActorName(); const { getActorName } = useActorName();
const { uploadWithToast } = useFileUpload(); const { uploadWithToast } = useFileUpload();
const { defaultLayout, onLayoutChanged } = useDefaultLayout({ const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: layoutId, id: layoutId,
@ -425,7 +427,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{issue.assignee_type === "member" && issue.assignee_id === m.user_id && <span className="ml-auto text-xs text-muted-foreground"></span>} {issue.assignee_type === "member" && issue.assignee_id === m.user_id && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
{agents.map((a) => ( {agents.filter((a) => {
if (a.visibility !== "private") return true;
if (a.owner_id === user?.id) return true;
if (memberRole === "owner" || memberRole === "admin") return true;
return false;
}).map((a) => (
<DropdownMenuItem <DropdownMenuItem
key={a.id} key={a.id}
onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })} onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}
@ -597,9 +604,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{subscribers.length > 0 ? ( {subscribers.length > 0 ? (
<AvatarGroup> <AvatarGroup>
{subscribers.slice(0, 4).map((sub) => ( {subscribers.slice(0, 4).map((sub) => (
<Avatar key={`${sub.user_type}-${sub.user_id}`} size="sm"> <ActorAvatar
<AvatarFallback>{getActorInitials(sub.user_type, sub.user_id)}</AvatarFallback> key={`${sub.user_type}-${sub.user_id}`}
</Avatar> actorType={sub.user_type}
actorId={sub.user_id}
size={24}
/>
))} ))}
{subscribers.length > 4 && ( {subscribers.length > 4 && (
<AvatarGroupCount>+{subscribers.length - 4}</AvatarGroupCount> <AvatarGroupCount>+{subscribers.length - 4}</AvatarGroupCount>
@ -868,56 +878,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{/* Assignee */} {/* Assignee */}
<PropRow label="Assignee"> <PropRow label="Assignee">
<DropdownMenu> <AssigneePicker
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden"> assigneeType={issue.assignee_type}
{issue.assignee_type && issue.assignee_id ? ( assigneeId={issue.assignee_id}
<> onUpdate={handleUpdateField}
<ActorAvatar align="start"
actorType={issue.assignee_type} />
actorId={issue.assignee_id}
size={18}
/>
<span className="truncate">{getActorName(issue.assignee_type, issue.assignee_id)}</span>
</>
) : (
<span className="text-muted-foreground">Unassigned</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuItem onClick={() => handleUpdateField({ assignee_type: null, assignee_id: null })}>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
Unassigned
</DropdownMenuItem>
{members.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Members</DropdownMenuLabel>
{members.map((m) => (
<DropdownMenuItem key={m.user_id} onClick={() => handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}>
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
{m.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</>
)}
{agents.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Agents</DropdownMenuLabel>
{agents.map((a) => (
<DropdownMenuItem key={a.id} onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}>
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
{a.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</PropRow> </PropRow>
{/* Due date */} {/* Due date */}

View file

@ -4,7 +4,6 @@ import { useMemo, useState } from "react";
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
Bot,
Check, Check,
ChevronDown, ChevronDown,
CircleDot, CircleDot,
@ -47,7 +46,8 @@ import {
PRIORITY_CONFIG, PRIORITY_CONFIG,
} from "@/features/issues/config"; } from "@/features/issues/config";
import { StatusIcon, PriorityIcon } from "@/features/issues/components"; import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { useWorkspaceStore, useActorName } from "@/features/workspace"; import { useWorkspaceStore } from "@/features/workspace";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { import {
useIssueViewStore, useIssueViewStore,
SORT_OPTIONS, SORT_OPTIONS,
@ -147,7 +147,6 @@ function ActorSubContent({
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const members = useWorkspaceStore((s) => s.members); const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents); const agents = useWorkspaceStore((s) => s.agents);
const { getActorInitials } = useActorName();
const query = search.toLowerCase(); const query = search.toLowerCase();
const filteredMembers = members.filter((m) => const filteredMembers = members.filter((m) =>
@ -208,9 +207,7 @@ function ActorSubContent({
className={FILTER_ITEM_CLASS} className={FILTER_ITEM_CLASS}
> >
<HoverCheck checked={checked} /> <HoverCheck checked={checked} />
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground"> <ActorAvatar actorType="member" actorId={m.user_id} size={18} />
{getActorInitials("member", m.user_id)}
</div>
<span className="truncate">{m.name}</span> <span className="truncate">{m.name}</span>
{count > 0 && ( {count > 0 && (
<span className="ml-auto text-xs text-muted-foreground"> <span className="ml-auto text-xs text-muted-foreground">
@ -239,9 +236,7 @@ function ActorSubContent({
className={FILTER_ITEM_CLASS} className={FILTER_ITEM_CLASS}
> >
<HoverCheck checked={checked} /> <HoverCheck checked={checked} />
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info"> <ActorAvatar actorType="agent" actorId={a.id} size={18} />
<Bot className="size-2.5" />
</div>
<span className="truncate">{a.name}</span> <span className="truncate">{a.name}</span>
{count > 0 && ( {count > 0 && (
<span className="ml-auto text-xs text-muted-foreground"> <span className="ml-auto text-xs text-muted-foreground">

View file

@ -25,13 +25,23 @@ export function AssigneePicker({
assigneeId, assigneeId,
onUpdate, onUpdate,
trigger: customTrigger, trigger: customTrigger,
triggerRender,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
align,
}: { }: {
assigneeType: IssueAssigneeType | null; assigneeType: IssueAssigneeType | null;
assigneeId: string | null; assigneeId: string | null;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void; onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
trigger?: React.ReactNode; trigger?: React.ReactNode;
triggerRender?: React.ReactElement;
open?: boolean;
onOpenChange?: (v: boolean) => void;
align?: "start" | "center" | "end";
}) { }) {
const [open, setOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = controlledOnOpenChange ?? setInternalOpen;
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const members = useWorkspaceStore((s) => s.members); const members = useWorkspaceStore((s) => s.members);
@ -65,6 +75,8 @@ export function AssigneePicker({
if (!v) setFilter(""); if (!v) setFilter("");
}} }}
width="w-52" width="w-52"
align={align}
triggerRender={triggerRender}
searchable searchable
searchPlaceholder="Assign to..." searchPlaceholder="Assign to..."
onSearchChange={setFilter} onSearchChange={setFilter}

View file

@ -16,6 +16,7 @@ export function PropertyPicker({
open, open,
onOpenChange, onOpenChange,
trigger, trigger,
triggerRender,
width = "w-48", width = "w-48",
align = "end", align = "end",
searchable = false, searchable = false,
@ -26,6 +27,7 @@ export function PropertyPicker({
open: boolean; open: boolean;
onOpenChange: (v: boolean) => void; onOpenChange: (v: boolean) => void;
trigger: React.ReactNode; trigger: React.ReactNode;
triggerRender?: React.ReactElement;
width?: string; width?: string;
align?: "start" | "center" | "end"; align?: "start" | "center" | "end";
searchable?: boolean; searchable?: boolean;
@ -48,7 +50,10 @@ export function PropertyPicker({
return ( return (
<Popover open={open} onOpenChange={handleOpenChange}> <Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden"> <PopoverTrigger
className={triggerRender ? undefined : "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden"}
render={triggerRender}
>
{trigger} {trigger}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align={align} className={`${width} gap-0 p-0`}> <PopoverContent align={align} className={`${width} gap-0 p-0`}>