import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { pickTextColorForPillBg } from "@/lib/color-contrast"; 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 { formatAssigneeUserLabel } from "../lib/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, GitBranch, FolderOpen, Copy, Check } from "lucide-react"; import { AgentIcon } from "./AgentIconPicker"; function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) { const [copied, setCopied] = useState(false); const timerRef = useRef>(undefined); useEffect(() => () => clearTimeout(timerRef.current), []); const handleCopy = useCallback(async () => { try { await navigator.clipboard.writeText(value); setCopied(true); clearTimeout(timerRef.current); timerRef.current = setTimeout(() => setCopied(false), 1500); } catch { /* noop */ } }, [value]); return (
{value}
); } function defaultProjectWorkspaceIdForProject(project: { workspaces?: Array<{ id: string; isPrimary: boolean }>; executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null; } | null | undefined) { if (!project) return null; return project.executionWorkspacePolicy?.defaultProjectWorkspaceId ?? project.workspaces?.find((workspace) => workspace.isPrimary)?.id ?? project.workspaces?.[0]?.id ?? null; } function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined) { const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null; if (defaultMode === "isolated_workspace" || defaultMode === "operator_branch") return defaultMode; if (defaultMode === "adapter_default") return "agent_default"; return "shared_workspace"; } interface IssuePropertiesProps { issue: Issue; childIssues?: Issue[]; onAddSubIssue?: () => void; onUpdate: (data: Record) => void; inline?: boolean; } function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return (
{label}
{children}
); } /** 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 (
{extra} {open && (
{children}
)}
); } return ( {children} {extra} ); } export function IssueProperties({ issue, childIssues = [], onAddSubIssue, 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 [blockedByOpen, setBlockedByOpen] = useState(false); const [blockedBySearch, setBlockedBySearch] = 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 activeProjects = useMemo( () => (projects ?? []).filter((p) => !p.archivedAt || p.id === issue.projectId), [projects, issue.projectId], ); const { orderedProjects } = useProjectOrder({ projects: activeProjects, companyId, userId: currentUserId, }); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(companyId!), queryFn: () => issuesApi.listLabels(companyId!), enabled: !!companyId, }); const { data: allIssues } = useQuery({ queryKey: queryKeys.issues.list(companyId!), queryFn: () => issuesApi.list(companyId!), enabled: !!companyId && blockedByOpen, }); 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 currentProject = issue.projectId ? orderedProjects.find((project) => project.id === issue.projectId) ?? null : null; 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) => formatAssigneeUserLabel(userId, currentUserId); const assigneeUserLabel = userLabel(issue.assigneeUserId); const creatorUserLabel = userLabel(issue.createdByUserId); const labelsTrigger = (issue.labels ?? []).length > 0 ? (
{(issue.labels ?? []).slice(0, 3).map((label) => ( {label.name} ))} {(issue.labels ?? []).length > 3 && ( +{(issue.labels ?? []).length - 3} )}
) : ( <> No labels ); const labelsContent = ( <> setLabelSearch(e.target.value)} autoFocus={!inline} />
{(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 (
); })}
setNewLabelColor(e.target.value)} /> setNewLabelName(e.target.value)} />
); const assigneeTrigger = assignee ? ( ) : assigneeUserLabel ? ( <> {assigneeUserLabel} ) : ( <> Unassigned ); const assigneeContent = ( <> setAssigneeSearch(e.target.value)} autoFocus={!inline} />
{currentUserId && ( )} {issue.createdByUserId && issue.createdByUserId !== currentUserId && ( )} {sortedAgents .filter((a) => { if (!assigneeSearch.trim()) return true; const q = assigneeSearch.toLowerCase(); return a.name.toLowerCase().includes(q); }) .map((a) => ( ))}
); const projectTrigger = issue.projectId ? ( <> p.id === issue.projectId)?.color ?? "#6366f1" }} /> {projectName(issue.projectId)} ) : ( <> No project ); const projectContent = ( <> setProjectSearch(e.target.value)} autoFocus={!inline} />
{orderedProjects .filter((p) => { if (!projectSearch.trim()) return true; const q = projectSearch.toLowerCase(); return p.name.toLowerCase().includes(q); }) .map((p) => ( ))}
); const blockedByIds = issue.blockedBy?.map((relation) => relation.id) ?? []; const blockedByTrigger = blockedByIds.length > 0 ? (
{(issue.blockedBy ?? []).slice(0, 2).map((relation) => ( {relation.identifier ?? relation.title} ))} {(issue.blockedBy ?? []).length > 2 && ( +{(issue.blockedBy ?? []).length - 2} )}
) : ( No blockers ); const blockingIssues = issue.blocks ?? []; const blockerOptions = (allIssues ?? []) .filter((candidate) => candidate.id !== issue.id) .filter((candidate) => { if (!blockedBySearch.trim()) return true; const query = blockedBySearch.toLowerCase(); return ( (candidate.identifier ?? "").toLowerCase().includes(query) || candidate.title.toLowerCase().includes(query) ); }) .sort((a, b) => { const aLabel = `${a.identifier ?? ""} ${a.title}`.trim(); const bLabel = `${b.identifier ?? ""} ${b.title}`.trim(); return aLabel.localeCompare(bLabel); }); const toggleBlockedBy = (blockedByIssueId: string) => { const nextBlockedByIds = blockedByIds.includes(blockedByIssueId) ? blockedByIds.filter((candidate) => candidate !== blockedByIssueId) : [...blockedByIds, blockedByIssueId]; onUpdate({ blockedByIssueIds: nextBlockedByIds }); }; const blockedByContent = ( <> setBlockedBySearch(e.target.value)} autoFocus={!inline} />
{blockerOptions.map((candidate) => { const selected = blockedByIds.includes(candidate.id); return ( ); })}
); return (
onUpdate({ status })} showLabel /> onUpdate({ priority })} showLabel /> { setLabelsOpen(open); if (!open) setLabelSearch(""); }} triggerContent={labelsTrigger} triggerClassName="min-w-0 max-w-full" popoverClassName="w-64" > {labelsContent} { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }} triggerContent={assigneeTrigger} popoverClassName="w-52" extra={issue.assigneeAgentId ? ( e.stopPropagation()} > ) : undefined} > {assigneeContent} { setProjectOpen(open); if (!open) setProjectSearch(""); }} triggerContent={projectTrigger} triggerClassName="min-w-0 max-w-full" popoverClassName="w-fit min-w-[11rem]" extra={issue.projectId ? ( e.stopPropagation()} > ) : undefined} > {projectContent} { setBlockedByOpen(open); if (!open) setBlockedBySearch(""); }} triggerContent={blockedByTrigger} triggerClassName="min-w-0 max-w-full" popoverClassName="w-72" > {blockedByContent} {blockingIssues.length > 0 ? (
{blockingIssues.map((relation) => ( {relation.identifier ?? relation.title} ))}
) : ( None )}
{childIssues.length > 0 ? ( childIssues.map((child) => ( {child.identifier ?? child.title} )) ) : ( None )} {onAddSubIssue ? ( ) : null}
{issue.parentId && ( {issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)} )} {issue.requestDepth > 0 && ( {issue.requestDepth} )}
{issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd ? ( <>
{issue.currentExecutionWorkspace?.branchName && ( )} {issue.currentExecutionWorkspace?.cwd && ( )}
) : null}
{(issue.createdByAgentId || issue.createdByUserId) && ( {issue.createdByAgentId ? ( ) : ( <> {creatorUserLabel ?? "User"} )} )} {issue.startedAt && ( {formatDate(issue.startedAt)} )} {issue.completedAt && ( {formatDate(issue.completedAt)} )} {formatDate(issue.createdAt)} {timeAgo(issue.updatedAt)}
); }