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:
parent
39c5cf2cbe
commit
4d74091f8d
9 changed files with 65 additions and 249 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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`}>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue