From 4d74091f8d59041a448085cea2a3cc9ce65bb997 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:58:43 +0800 Subject: [PATCH] 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) --- apps/web/app/(dashboard)/agents/page.tsx | 37 +---- .../settings/_components/members-tab.tsx | 10 +- apps/web/components/common/actor-avatar.tsx | 1 + .../components/common/mention-hover-card.tsx | 5 +- .../components/batch-action-toolbar.tsx | 147 ++---------------- .../issues/components/issue-detail.tsx | 80 +++------- .../issues/components/issues-header.tsx | 13 +- .../components/pickers/assignee-picker.tsx | 14 +- .../components/pickers/property-picker.tsx | 7 +- 9 files changed, 65 insertions(+), 249 deletions(-) diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index e87934a8..b0a3361f 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -76,6 +76,7 @@ import { useWorkspaceStore } from "@/features/workspace"; import { useRuntimeStore } from "@/features/runtimes"; import { useIssueStore } from "@/features/issues"; import { useFileUpload } from "@/shared/hooks/use-file-upload"; +import { ActorAvatar } from "@/components/common/actor-avatar"; // --------------------------------------------------------------------------- @@ -99,14 +100,6 @@ const taskStatusConfig: Record w[0]) - .join("") - .toUpperCase() - .slice(0, 2); -} function generateId(): string { return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; @@ -343,13 +336,7 @@ function AgentListItem({ isSelected ? "bg-accent" : "hover:bg-accent/50" }`} > -
- {agent.avatar_url ? ( - {agent.name} - ) : ( - getInitials(agent.name) - )} -
+
@@ -1231,17 +1218,7 @@ function SettingsTab({ onClick={() => fileInputRef.current?.click()} disabled={uploading} > - {agent.avatar_url ? ( - {agent.name} - ) : ( - - {getInitials(agent.name)} - - )} +
{uploading ? ( @@ -1385,13 +1362,7 @@ function AgentDetail({
{/* Header */}
-
- {agent.avatar_url ? ( - {agent.name} - ) : ( - getInitials(agent.name) - )} -
+

{agent.name}

diff --git a/apps/web/app/(dashboard)/settings/_components/members-tab.tsx b/apps/web/app/(dashboard)/settings/_components/members-tab.tsx index fd101e08..e49d5403 100644 --- a/apps/web/app/(dashboard)/settings/_components/members-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/members-tab.tsx @@ -2,6 +2,7 @@ import { useState } from "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 { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -70,14 +71,7 @@ function MemberRow({ return (
-
- {member.name - .split(" ") - .map((w) => w[0]) - .join("") - .toUpperCase() - .slice(0, 2)} -
+
{member.name}
{member.email}
diff --git a/apps/web/components/common/actor-avatar.tsx b/apps/web/components/common/actor-avatar.tsx index a50d71cc..5a94e7a1 100644 --- a/apps/web/components/common/actor-avatar.tsx +++ b/apps/web/components/common/actor-avatar.tsx @@ -45,6 +45,7 @@ function ActorAvatar({ return (
-
- -
+

{agent.name}

{agent.description && ( diff --git a/apps/web/features/issues/components/batch-action-toolbar.tsx b/apps/web/features/issues/components/batch-action-toolbar.tsx index 4ccd67b0..0d6e3c2a 100644 --- a/apps/web/features/issues/components/batch-action-toolbar.tsx +++ b/apps/web/features/issues/components/batch-action-toolbar.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { X, Trash2, Lock, UserMinus } from "lucide-react"; +import { X, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -19,16 +19,14 @@ import { PopoverTrigger, PopoverContent, } 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 { useAuthStore } from "@/features/auth"; -import { useWorkspaceStore } from "@/features/workspace"; import { useIssueStore } from "@/features/issues/store"; import { useIssueSelectionStore } from "@/features/issues/stores/selection-store"; import { api } from "@/shared/api"; -import { ActorAvatar } from "@/components/common/actor-avatar"; import { StatusIcon } from "./status-icon"; import { PriorityIcon } from "./priority-icon"; +import { AssigneePicker } from "./pickers"; export function BatchActionToolbar() { const selectedIds = useIssueSelectionStore((s) => s.selectedIds); @@ -45,7 +43,7 @@ export function BatchActionToolbar() { const ids = Array.from(selectedIds); - const handleBatchUpdate = async (updates: UpdateIssueRequest) => { + const handleBatchUpdate = async (updates: Partial) => { setLoading(true); try { await api.batchUpdateIssues(ids, updates); @@ -162,11 +160,15 @@ export function BatchActionToolbar() { {/* Assignee */} - } + trigger="Assignee" + align="center" /> {/* 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 ( - { - onOpenChange(v); - if (!v) setFilter(""); - }} - > - - } - > - Assignee - - -
- setFilter(e.target.value)} - placeholder="Assign to..." - className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none" - /> -
-
- - - {filteredMembers.length > 0 && ( -
-
- Members -
- {filteredMembers.map((m) => ( - - ))} -
- )} - - {filteredAgents.length > 0 && ( -
-
- Agents -
- {filteredAgents.map((a) => { - const allowed = canAssignAgent(a, user?.id, memberRole); - return ( - - ); - })} -
- )} -
-
-
- ); -} diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index c6be3dfe..6f57f0b3 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -53,11 +53,11 @@ import { import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; 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 type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types"; 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 { CommentInput } from "./comment-input"; 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 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; // Issue navigation const allIssues = useIssueStore((s) => s.issues); const currentIndex = allIssues.findIndex((i) => i.id === id); const prevIssue = currentIndex > 0 ? 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 { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: layoutId, @@ -425,7 +427,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo {issue.assignee_type === "member" && issue.assignee_id === m.user_id && } ))} - {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) => ( handleUpdateField({ assignee_type: "agent", assignee_id: a.id })} @@ -597,9 +604,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo {subscribers.length > 0 ? ( {subscribers.slice(0, 4).map((sub) => ( - - {getActorInitials(sub.user_type, sub.user_id)} - + ))} {subscribers.length > 4 && ( +{subscribers.length - 4} @@ -868,56 +878,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo {/* Assignee */} - - - {issue.assignee_type && issue.assignee_id ? ( - <> - - {getActorName(issue.assignee_type, issue.assignee_id)} - - ) : ( - Unassigned - )} - - - handleUpdateField({ assignee_type: null, assignee_id: null })}> - - Unassigned - - {members.length > 0 && ( - <> - - - Members - {members.map((m) => ( - handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}> - - {m.name} - - ))} - - - )} - {agents.length > 0 && ( - <> - - - Agents - {agents.map((a) => ( - handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}> - - {a.name} - - ))} - - - )} - - + {/* Due date */} diff --git a/apps/web/features/issues/components/issues-header.tsx b/apps/web/features/issues/components/issues-header.tsx index 33b6680d..77a0e594 100644 --- a/apps/web/features/issues/components/issues-header.tsx +++ b/apps/web/features/issues/components/issues-header.tsx @@ -4,7 +4,6 @@ import { useMemo, useState } from "react"; import { ArrowDown, ArrowUp, - Bot, Check, ChevronDown, CircleDot, @@ -47,7 +46,8 @@ import { PRIORITY_CONFIG, } from "@/features/issues/config"; 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 { useIssueViewStore, SORT_OPTIONS, @@ -147,7 +147,6 @@ function ActorSubContent({ const [search, setSearch] = useState(""); const members = useWorkspaceStore((s) => s.members); const agents = useWorkspaceStore((s) => s.agents); - const { getActorInitials } = useActorName(); const query = search.toLowerCase(); const filteredMembers = members.filter((m) => @@ -208,9 +207,7 @@ function ActorSubContent({ className={FILTER_ITEM_CLASS} > -
- {getActorInitials("member", m.user_id)} -
+ {m.name} {count > 0 && ( @@ -239,9 +236,7 @@ function ActorSubContent({ className={FILTER_ITEM_CLASS} > -
- -
+ {a.name} {count > 0 && ( diff --git a/apps/web/features/issues/components/pickers/assignee-picker.tsx b/apps/web/features/issues/components/pickers/assignee-picker.tsx index fad98f2c..4bd367d5 100644 --- a/apps/web/features/issues/components/pickers/assignee-picker.tsx +++ b/apps/web/features/issues/components/pickers/assignee-picker.tsx @@ -25,13 +25,23 @@ export function AssigneePicker({ assigneeId, onUpdate, trigger: customTrigger, + triggerRender, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, + align, }: { assigneeType: IssueAssigneeType | null; assigneeId: string | null; onUpdate: (updates: Partial) => void; 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 user = useAuthStore((s) => s.user); const members = useWorkspaceStore((s) => s.members); @@ -65,6 +75,8 @@ export function AssigneePicker({ if (!v) setFilter(""); }} width="w-52" + align={align} + triggerRender={triggerRender} searchable searchPlaceholder="Assign to..." onSearchChange={setFilter} diff --git a/apps/web/features/issues/components/pickers/property-picker.tsx b/apps/web/features/issues/components/pickers/property-picker.tsx index 1329fa4d..6a53c82f 100644 --- a/apps/web/features/issues/components/pickers/property-picker.tsx +++ b/apps/web/features/issues/components/pickers/property-picker.tsx @@ -16,6 +16,7 @@ export function PropertyPicker({ open, onOpenChange, trigger, + triggerRender, width = "w-48", align = "end", searchable = false, @@ -26,6 +27,7 @@ export function PropertyPicker({ open: boolean; onOpenChange: (v: boolean) => void; trigger: React.ReactNode; + triggerRender?: React.ReactElement; width?: string; align?: "start" | "center" | "end"; searchable?: boolean; @@ -48,7 +50,10 @@ export function PropertyPicker({ return ( - + {trigger}