Tracks most recently selected assignees in localStorage and sorts both the issue properties and new issue dialog assignee lists accordingly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
548 lines
19 KiB
TypeScript
548 lines
19 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { Link } from "@/lib/router";
|
|
import type { Issue } from "@paperclipai/shared";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { agentsApi } from "../api/agents";
|
|
import { authApi } from "../api/auth";
|
|
import { issuesApi } from "../api/issues";
|
|
import { projectsApi } from "../api/projects";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
|
import { StatusIcon } from "./StatusIcon";
|
|
import { PriorityIcon } from "./PriorityIcon";
|
|
import { Identity } from "./Identity";
|
|
import { formatDate, cn, projectUrl } from "../lib/utils";
|
|
import { timeAgo } from "../lib/timeAgo";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2 } from "lucide-react";
|
|
import { AgentIcon } from "./AgentIconPicker";
|
|
|
|
interface IssuePropertiesProps {
|
|
issue: Issue;
|
|
onUpdate: (data: Record<string, unknown>) => void;
|
|
inline?: boolean;
|
|
}
|
|
|
|
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex items-center gap-3 py-1.5">
|
|
<span className="text-xs text-muted-foreground shrink-0 w-20">{label}</span>
|
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Renders a Popover on desktop, or an inline collapsible section on mobile (inline mode). */
|
|
function PropertyPicker({
|
|
inline,
|
|
label,
|
|
open,
|
|
onOpenChange,
|
|
triggerContent,
|
|
triggerClassName,
|
|
popoverClassName,
|
|
popoverAlign = "end",
|
|
extra,
|
|
children,
|
|
}: {
|
|
inline?: boolean;
|
|
label: string;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
triggerContent: React.ReactNode;
|
|
triggerClassName?: string;
|
|
popoverClassName?: string;
|
|
popoverAlign?: "start" | "center" | "end";
|
|
extra?: React.ReactNode;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const btnCn = cn(
|
|
"inline-flex items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors",
|
|
triggerClassName,
|
|
);
|
|
|
|
if (inline) {
|
|
return (
|
|
<div>
|
|
<PropertyRow label={label}>
|
|
<button className={btnCn} onClick={() => onOpenChange(!open)}>
|
|
{triggerContent}
|
|
</button>
|
|
{extra}
|
|
</PropertyRow>
|
|
{open && (
|
|
<div className={cn("rounded-md border border-border bg-popover p-1 mb-2", popoverClassName)}>
|
|
{children}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PropertyRow label={label}>
|
|
<Popover open={open} onOpenChange={onOpenChange}>
|
|
<PopoverTrigger asChild>
|
|
<button className={btnCn}>{triggerContent}</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className={cn("p-1", popoverClassName)} align={popoverAlign} collisionPadding={16}>
|
|
{children}
|
|
</PopoverContent>
|
|
</Popover>
|
|
{extra}
|
|
</PropertyRow>
|
|
);
|
|
}
|
|
|
|
export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) {
|
|
const { selectedCompanyId } = useCompany();
|
|
const queryClient = useQueryClient();
|
|
const companyId = issue.companyId ?? selectedCompanyId;
|
|
const [assigneeOpen, setAssigneeOpen] = useState(false);
|
|
const [assigneeSearch, setAssigneeSearch] = useState("");
|
|
const [projectOpen, setProjectOpen] = useState(false);
|
|
const [projectSearch, setProjectSearch] = useState("");
|
|
const [labelsOpen, setLabelsOpen] = useState(false);
|
|
const [labelSearch, setLabelSearch] = useState("");
|
|
const [newLabelName, setNewLabelName] = useState("");
|
|
const [newLabelColor, setNewLabelColor] = useState("#6366f1");
|
|
|
|
const { data: session } = useQuery({
|
|
queryKey: queryKeys.auth.session,
|
|
queryFn: () => authApi.getSession(),
|
|
});
|
|
const currentUserId = session?.user?.id ?? session?.session?.userId;
|
|
|
|
const { data: agents } = useQuery({
|
|
queryKey: queryKeys.agents.list(companyId!),
|
|
queryFn: () => agentsApi.list(companyId!),
|
|
enabled: !!companyId,
|
|
});
|
|
|
|
const { data: projects } = useQuery({
|
|
queryKey: queryKeys.projects.list(companyId!),
|
|
queryFn: () => projectsApi.list(companyId!),
|
|
enabled: !!companyId,
|
|
});
|
|
const { orderedProjects } = useProjectOrder({
|
|
projects: projects ?? [],
|
|
companyId,
|
|
userId: currentUserId,
|
|
});
|
|
|
|
const { data: labels } = useQuery({
|
|
queryKey: queryKeys.issues.labels(companyId!),
|
|
queryFn: () => issuesApi.listLabels(companyId!),
|
|
enabled: !!companyId,
|
|
});
|
|
|
|
const createLabel = useMutation({
|
|
mutationFn: (data: { name: string; color: string }) => issuesApi.createLabel(companyId!, data),
|
|
onSuccess: async (created) => {
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) });
|
|
onUpdate({ labelIds: [...(issue.labelIds ?? []), created.id] });
|
|
setNewLabelName("");
|
|
},
|
|
});
|
|
|
|
const deleteLabel = useMutation({
|
|
mutationFn: (labelId: string) => issuesApi.deleteLabel(labelId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId!) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
|
|
},
|
|
});
|
|
|
|
const toggleLabel = (labelId: string) => {
|
|
const ids = issue.labelIds ?? [];
|
|
const next = ids.includes(labelId)
|
|
? ids.filter((id) => id !== labelId)
|
|
: [...ids, labelId];
|
|
onUpdate({ labelIds: next });
|
|
};
|
|
|
|
const agentName = (id: string | null) => {
|
|
if (!id || !agents) return null;
|
|
const agent = agents.find((a) => a.id === id);
|
|
return agent?.name ?? id.slice(0, 8);
|
|
};
|
|
|
|
const projectName = (id: string | null) => {
|
|
if (!id) return id?.slice(0, 8) ?? "None";
|
|
const project = orderedProjects.find((p) => p.id === id);
|
|
return project?.name ?? id.slice(0, 8);
|
|
};
|
|
const projectLink = (id: string | null) => {
|
|
if (!id) return null;
|
|
const project = projects?.find((p) => p.id === id) ?? null;
|
|
return project ? projectUrl(project) : `/projects/${id}`;
|
|
};
|
|
|
|
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]);
|
|
const sortedAgents = useMemo(
|
|
() => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds),
|
|
[agents, recentAssigneeIds],
|
|
);
|
|
|
|
const assignee = issue.assigneeAgentId
|
|
? agents?.find((a) => a.id === issue.assigneeAgentId)
|
|
: null;
|
|
const userLabel = (userId: string | null | undefined) =>
|
|
userId
|
|
? userId === "local-board"
|
|
? "Board"
|
|
: currentUserId && userId === currentUserId
|
|
? "Me"
|
|
: userId.slice(0, 5)
|
|
: null;
|
|
const assigneeUserLabel = userLabel(issue.assigneeUserId);
|
|
const creatorUserLabel = userLabel(issue.createdByUserId);
|
|
|
|
const labelsTrigger = (issue.labels ?? []).length > 0 ? (
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
{(issue.labels ?? []).slice(0, 3).map((label) => (
|
|
<span
|
|
key={label.id}
|
|
className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium border"
|
|
style={{
|
|
borderColor: label.color,
|
|
backgroundColor: `${label.color}22`,
|
|
color: label.color,
|
|
}}
|
|
>
|
|
{label.name}
|
|
</span>
|
|
))}
|
|
{(issue.labels ?? []).length > 3 && (
|
|
<span className="text-xs text-muted-foreground">+{(issue.labels ?? []).length - 3}</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-sm text-muted-foreground">No labels</span>
|
|
</>
|
|
);
|
|
|
|
const labelsContent = (
|
|
<>
|
|
<input
|
|
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
|
placeholder="Search labels..."
|
|
value={labelSearch}
|
|
onChange={(e) => setLabelSearch(e.target.value)}
|
|
autoFocus={!inline}
|
|
/>
|
|
<div className="max-h-44 overflow-y-auto overscroll-contain space-y-0.5">
|
|
{(labels ?? [])
|
|
.filter((label) => {
|
|
if (!labelSearch.trim()) return true;
|
|
return label.name.toLowerCase().includes(labelSearch.toLowerCase());
|
|
})
|
|
.map((label) => {
|
|
const selected = (issue.labelIds ?? []).includes(label.id);
|
|
return (
|
|
<div key={label.id} className="flex items-center gap-1">
|
|
<button
|
|
className={cn(
|
|
"flex items-center gap-2 flex-1 px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
|
|
selected && "bg-accent"
|
|
)}
|
|
onClick={() => toggleLabel(label.id)}
|
|
>
|
|
<span className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: label.color }} />
|
|
<span className="truncate">{label.name}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="p-1 text-muted-foreground hover:text-destructive rounded"
|
|
onClick={() => deleteLabel.mutate(label.id)}
|
|
title={`Delete ${label.name}`}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="mt-2 border-t border-border pt-2 space-y-1">
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
className="h-7 w-7 p-0 rounded bg-transparent"
|
|
type="color"
|
|
value={newLabelColor}
|
|
onChange={(e) => setNewLabelColor(e.target.value)}
|
|
/>
|
|
<input
|
|
className="flex-1 px-2 py-1.5 text-xs bg-transparent outline-none rounded placeholder:text-muted-foreground/50"
|
|
placeholder="New label"
|
|
value={newLabelName}
|
|
onChange={(e) => setNewLabelName(e.target.value)}
|
|
/>
|
|
</div>
|
|
<button
|
|
className="flex items-center justify-center gap-1.5 w-full px-2 py-1.5 text-xs rounded border border-border hover:bg-accent/50 disabled:opacity-50"
|
|
disabled={!newLabelName.trim() || createLabel.isPending}
|
|
onClick={() =>
|
|
createLabel.mutate({
|
|
name: newLabelName.trim(),
|
|
color: newLabelColor,
|
|
})
|
|
}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
{createLabel.isPending ? "Creating…" : "Create label"}
|
|
</button>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
const assigneeTrigger = assignee ? (
|
|
<Identity name={assignee.name} size="sm" />
|
|
) : assigneeUserLabel ? (
|
|
<>
|
|
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-sm">{assigneeUserLabel}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-sm text-muted-foreground">Unassigned</span>
|
|
</>
|
|
);
|
|
|
|
const assigneeContent = (
|
|
<>
|
|
<input
|
|
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
|
placeholder="Search assignees..."
|
|
value={assigneeSearch}
|
|
onChange={(e) => setAssigneeSearch(e.target.value)}
|
|
autoFocus={!inline}
|
|
/>
|
|
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
|
<button
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent"
|
|
)}
|
|
onClick={() => { onUpdate({ assigneeAgentId: null, assigneeUserId: null }); setAssigneeOpen(false); }}
|
|
>
|
|
No assignee
|
|
</button>
|
|
{issue.createdByUserId && (
|
|
<button
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
issue.assigneeUserId === issue.createdByUserId && "bg-accent",
|
|
)}
|
|
onClick={() => {
|
|
onUpdate({ assigneeAgentId: null, assigneeUserId: issue.createdByUserId });
|
|
setAssigneeOpen(false);
|
|
}}
|
|
>
|
|
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
{creatorUserLabel ? `Assign to ${creatorUserLabel === "Me" ? "me" : creatorUserLabel}` : "Assign to requester"}
|
|
</button>
|
|
)}
|
|
{sortedAgents
|
|
.filter((a) => {
|
|
if (!assigneeSearch.trim()) return true;
|
|
const q = assigneeSearch.toLowerCase();
|
|
return a.name.toLowerCase().includes(q);
|
|
})
|
|
.map((a) => (
|
|
<button
|
|
key={a.id}
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
a.id === issue.assigneeAgentId && "bg-accent"
|
|
)}
|
|
onClick={() => { trackRecentAssignee(a.id); onUpdate({ assigneeAgentId: a.id, assigneeUserId: null }); setAssigneeOpen(false); }}
|
|
>
|
|
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
|
{a.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
const projectTrigger = issue.projectId ? (
|
|
<>
|
|
<span
|
|
className="shrink-0 h-3 w-3 rounded-sm"
|
|
style={{ backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "#6366f1" }}
|
|
/>
|
|
<span className="text-sm truncate">{projectName(issue.projectId)}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Hexagon className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-sm text-muted-foreground">No project</span>
|
|
</>
|
|
);
|
|
|
|
const projectContent = (
|
|
<>
|
|
<input
|
|
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
|
placeholder="Search projects..."
|
|
value={projectSearch}
|
|
onChange={(e) => setProjectSearch(e.target.value)}
|
|
autoFocus={!inline}
|
|
/>
|
|
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
|
<button
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
|
!issue.projectId && "bg-accent"
|
|
)}
|
|
onClick={() => { onUpdate({ projectId: null }); setProjectOpen(false); }}
|
|
>
|
|
No project
|
|
</button>
|
|
{orderedProjects
|
|
.filter((p) => {
|
|
if (!projectSearch.trim()) return true;
|
|
const q = projectSearch.toLowerCase();
|
|
return p.name.toLowerCase().includes(q);
|
|
})
|
|
.map((p) => (
|
|
<button
|
|
key={p.id}
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
|
p.id === issue.projectId && "bg-accent"
|
|
)}
|
|
onClick={() => { onUpdate({ projectId: p.id }); setProjectOpen(false); }}
|
|
>
|
|
<span
|
|
className="shrink-0 h-3 w-3 rounded-sm"
|
|
style={{ backgroundColor: p.color ?? "#6366f1" }}
|
|
/>
|
|
{p.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="space-y-1">
|
|
<PropertyRow label="Status">
|
|
<StatusIcon
|
|
status={issue.status}
|
|
onChange={(status) => onUpdate({ status })}
|
|
showLabel
|
|
/>
|
|
</PropertyRow>
|
|
|
|
<PropertyRow label="Priority">
|
|
<PriorityIcon
|
|
priority={issue.priority}
|
|
onChange={(priority) => onUpdate({ priority })}
|
|
showLabel
|
|
/>
|
|
</PropertyRow>
|
|
|
|
<PropertyPicker
|
|
inline={inline}
|
|
label="Labels"
|
|
open={labelsOpen}
|
|
onOpenChange={(open) => { setLabelsOpen(open); if (!open) setLabelSearch(""); }}
|
|
triggerContent={labelsTrigger}
|
|
triggerClassName="min-w-0 max-w-full"
|
|
popoverClassName="w-64"
|
|
>
|
|
{labelsContent}
|
|
</PropertyPicker>
|
|
|
|
<PropertyPicker
|
|
inline={inline}
|
|
label="Assignee"
|
|
open={assigneeOpen}
|
|
onOpenChange={(open) => { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}
|
|
triggerContent={assigneeTrigger}
|
|
popoverClassName="w-52"
|
|
extra={issue.assigneeAgentId ? (
|
|
<Link
|
|
to={`/agents/${issue.assigneeAgentId}`}
|
|
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<ArrowUpRight className="h-3 w-3" />
|
|
</Link>
|
|
) : undefined}
|
|
>
|
|
{assigneeContent}
|
|
</PropertyPicker>
|
|
|
|
<PropertyPicker
|
|
inline={inline}
|
|
label="Project"
|
|
open={projectOpen}
|
|
onOpenChange={(open) => { setProjectOpen(open); if (!open) setProjectSearch(""); }}
|
|
triggerContent={projectTrigger}
|
|
triggerClassName="min-w-0 max-w-full"
|
|
popoverClassName="w-fit min-w-[11rem]"
|
|
extra={issue.projectId ? (
|
|
<Link
|
|
to={projectLink(issue.projectId)!}
|
|
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<ArrowUpRight className="h-3 w-3" />
|
|
</Link>
|
|
) : undefined}
|
|
>
|
|
{projectContent}
|
|
</PropertyPicker>
|
|
|
|
{issue.parentId && (
|
|
<PropertyRow label="Parent">
|
|
<Link
|
|
to={`/issues/${issue.ancestors?.[0]?.identifier ?? issue.parentId}`}
|
|
className="text-sm hover:underline"
|
|
>
|
|
{issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)}
|
|
</Link>
|
|
</PropertyRow>
|
|
)}
|
|
|
|
{issue.requestDepth > 0 && (
|
|
<PropertyRow label="Depth">
|
|
<span className="text-sm font-mono">{issue.requestDepth}</span>
|
|
</PropertyRow>
|
|
)}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div className="space-y-1">
|
|
{issue.startedAt && (
|
|
<PropertyRow label="Started">
|
|
<span className="text-sm">{formatDate(issue.startedAt)}</span>
|
|
</PropertyRow>
|
|
)}
|
|
{issue.completedAt && (
|
|
<PropertyRow label="Completed">
|
|
<span className="text-sm">{formatDate(issue.completedAt)}</span>
|
|
</PropertyRow>
|
|
)}
|
|
<PropertyRow label="Created">
|
|
<span className="text-sm">{formatDate(issue.createdAt)}</span>
|
|
</PropertyRow>
|
|
<PropertyRow label="Updated">
|
|
<span className="text-sm">{timeAgo(issue.updatedAt)}</span>
|
|
</PropertyRow>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|