| import { useMemo, 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 } from "lucide-react"; |
| import { AgentIcon } from "./AgentIconPicker"; |
|
|
| 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; |
| 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> |
| ); |
| } |
|
|
| |
| 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 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 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 ? ( |
| <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: pickTextColorForPillBg(label.color, 0.13), |
| }} |
| > |
| {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> |
| {currentUserId && ( |
| <button |
| className={cn( |
| "flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50", |
| issue.assigneeUserId === currentUserId && "bg-accent", |
| )} |
| onClick={() => { |
| onUpdate({ assigneeAgentId: null, assigneeUserId: currentUserId }); |
| setAssigneeOpen(false); |
| }} |
| > |
| <User className="h-3 w-3 shrink-0 text-muted-foreground" /> |
| Assign to me |
| </button> |
| )} |
| {issue.createdByUserId && issue.createdByUserId !== currentUserId && ( |
| <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}` : "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, |
| projectWorkspaceId: null, |
| executionWorkspaceId: null, |
| executionWorkspacePreference: null, |
| executionWorkspaceSettings: 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={() => { |
| const defaultMode = defaultExecutionWorkspaceModeForProject(p); |
| onUpdate({ |
| projectId: p.id, |
| projectWorkspaceId: defaultProjectWorkspaceIdForProject(p), |
| executionWorkspaceId: null, |
| executionWorkspacePreference: defaultMode, |
| executionWorkspaceSettings: p.executionWorkspacePolicy?.enabled |
| ? { mode: defaultMode } |
| : null, |
| }); |
| 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.createdByAgentId || issue.createdByUserId) && ( |
| <PropertyRow label="Created by"> |
| {issue.createdByAgentId ? ( |
| <Link |
| to={`/agents/${issue.createdByAgentId}`} |
| className="hover:underline" |
| > |
| <Identity name={agentName(issue.createdByAgentId) ?? issue.createdByAgentId.slice(0, 8)} size="sm" /> |
| </Link> |
| ) : ( |
| <> |
| <User className="h-3.5 w-3.5 text-muted-foreground" /> |
| <span className="text-sm">{creatorUserLabel ?? "User"}</span> |
| </> |
| )} |
| </PropertyRow> |
| )} |
| {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> |
| ); |
| } |
|
|