refactor(web): unify assignee dropdowns with ActorAvatar and shared AssigneePicker

- Replace inline initials/Bot divs with ActorAvatar across all assignee UIs
- Replace issue-detail sidebar DropdownMenu with shared AssigneePicker
- Delete BatchAssigneePicker (~130 lines), reuse AssigneePicker in controlled mode
- Add controlled mode (open/onOpenChange), align, and triggerRender props to AssigneePicker/PropertyPicker
- Add canAssignAgent visibility check to issue-detail more menu
- Clean up unused imports (Bot, useAuthStore, useWorkspaceStore, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-04-01 15:37:33 +08:00
parent afdefa9b65
commit f891a5bbd7
7 changed files with 54 additions and 240 deletions

View file

@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { X, Trash2, Bot, Lock, UserMinus } from "lucide-react";
import { X, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@ -19,15 +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, useActorName } from "@/features/workspace";
import { useIssueStore } from "@/features/issues/store";
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
import { api } from "@/shared/api";
import { StatusIcon } from "./status-icon";
import { PriorityIcon } from "./priority-icon";
import { AssigneePicker } from "./pickers";
export function BatchActionToolbar() {
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
@ -44,7 +43,7 @@ export function BatchActionToolbar() {
const ids = Array.from(selectedIds);
const handleBatchUpdate = async (updates: UpdateIssueRequest) => {
const handleBatchUpdate = async (updates: Partial<UpdateIssueRequest>) => {
setLoading(true);
try {
await api.batchUpdateIssues(ids, updates);
@ -161,11 +160,15 @@ export function BatchActionToolbar() {
</Popover>
{/* Assignee */}
<BatchAssigneePicker
<AssigneePicker
assigneeType={null}
assigneeId={null}
onUpdate={handleBatchUpdate}
open={assigneeOpen}
onOpenChange={setAssigneeOpen}
onUpdate={handleBatchUpdate}
loading={loading}
triggerRender={<Button variant="ghost" size="sm" disabled={loading} />}
trigger="Assignee"
align="center"
/>
{/* Delete */}
@ -207,136 +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 { getActorInitials } = 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),
);
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"
>
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
<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"}`}
>
<div className={`inline-flex size-4.5 shrink-0 items-center justify-center rounded-full ${allowed ? "bg-info/10 text-info" : "bg-muted text-muted-foreground"}`}>
<Bot className="size-2.5" />
</div>
<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

@ -1,6 +1,6 @@
export { StatusIcon } from "./status-icon";
export { PriorityIcon } from "./priority-icon";
export { StatusPicker, PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
export { StatusPicker, PriorityPicker, AssigneePicker, canAssignAgent, DueDatePicker } from "./pickers";
export { IssueDetail } from "./issue-detail";
export { IssuesPage } from "./issues-page";
export { CommentCard } from "./comment-card";

View file

@ -5,7 +5,6 @@ import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Bot,
Calendar,
Check,
ChevronLeft,
@ -58,7 +57,7 @@ import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount } from "@/compone
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, canAssignAgent } from "@/features/issues/components";
import { CommentCard } from "./comment-card";
import { CommentInput } from "./comment-input";
import { AgentLiveCard, TaskRunHistory } from "./agent-live-card";
@ -174,6 +173,7 @@ 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 currentMemberRole = members.find((m) => m.user_id === user?.id)?.role;
// Issue navigation
const allIssues = useIssueStore((s) => s.issues);
@ -421,21 +421,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
key={m.user_id}
onClick={() => handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}
>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
<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>}
</DropdownMenuItem>
))}
{agents.map((a) => (
{agents.filter((a) => canAssignAgent(a, user?.id, currentMemberRole)).map((a) => (
<DropdownMenuItem
key={a.id}
onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}
>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="size-2.5" />
</div>
<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>}
</DropdownMenuItem>
@ -873,60 +869,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{/* Assignee */}
<PropRow label="Assignee">
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
{issue.assignee_type && issue.assignee_id ? (
<>
<ActorAvatar
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 })}>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
{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 })}>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="size-2.5" />
</div>
{a.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<AssigneePicker
assigneeType={issue.assignee_type}
assigneeId={issue.assignee_id}
onUpdate={handleUpdateField}
align="start"
/>
</PropRow>
{/* Due date */}

View file

@ -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,8 +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) =>
m.name.toLowerCase().includes(query),
@ -208,9 +206,7 @@ function ActorSubContent({
className={FILTER_ITEM_CLASS}
>
<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">
{getActorInitials("member", m.user_id)}
</div>
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
<span className="truncate">{m.name}</span>
{count > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
@ -239,9 +235,7 @@ function ActorSubContent({
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={checked} />
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="size-2.5" />
</div>
<ActorAvatar actorType="agent" actorId={a.id} size={18} />
<span className="truncate">{a.name}</span>
{count > 0 && (
<span className="ml-auto text-xs text-muted-foreground">

View file

@ -1,10 +1,11 @@
"use client";
import { useState } from "react";
import { Bot, Lock, UserMinus } from "lucide-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,
@ -12,7 +13,7 @@ import {
PickerEmpty,
} from "./property-picker";
function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
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;
@ -24,18 +25,28 @@ export function AssigneePicker({
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 [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);
const agents = useWorkspaceStore((s) => s.agents);
const { getActorName, getActorInitials } = useActorName();
const { getActorName } = useActorName();
const currentMember = members.find((m) => m.user_id === user?.id);
const memberRole = currentMember?.role;
@ -64,25 +75,15 @@ export function AssigneePicker({
if (!v) setFilter("");
}}
width="w-52"
align={align}
searchable
searchPlaceholder="Assign to..."
onSearchChange={setFilter}
triggerRender={triggerRender}
trigger={
customTrigger ? customTrigger : assigneeType && assigneeId ? (
<>
<div
className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium text-[8px] size-4.5 ${
assigneeType === "agent"
? "bg-info/10 text-info"
: "bg-muted text-muted-foreground"
}`}
>
{assigneeType === "agent" ? (
<Bot className="size-2.5" />
) : (
getActorInitials(assigneeType, assigneeId)
)}
</div>
<ActorAvatar actorType={assigneeType} actorId={assigneeId} size={18} />
<span className="truncate">{triggerLabel}</span>
</>
) : (
@ -117,9 +118,7 @@ export function AssigneePicker({
setOpen(false);
}}
>
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
<span>{m.name}</span>
</PickerItem>
))}
@ -145,9 +144,7 @@ export function AssigneePicker({
setOpen(false);
}}
>
<div className={`inline-flex size-4.5 shrink-0 items-center justify-center rounded-full ${allowed ? "bg-info/10 text-info" : "bg-muted text-muted-foreground"}`}>
<Bot className="size-2.5" />
</div>
<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" />

View file

@ -1,5 +1,5 @@
export { PropertyPicker, PickerItem, PickerSection, PickerEmpty } from "./property-picker";
export { StatusPicker } from "./status-picker";
export { PriorityPicker } from "./priority-picker";
export { AssigneePicker } from "./assignee-picker";
export { AssigneePicker, canAssignAgent } from "./assignee-picker";
export { DueDatePicker } from "./due-date-picker";

View file

@ -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 (
<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}
</PopoverTrigger>
<PopoverContent align={align} className={`${width} gap-0 p-0`}>