From be518529b7cea6d62fbded82a4f034f77fc1c814 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 6 Apr 2026 16:18:28 -0500 Subject: [PATCH] Move reviewer/approver pickers to inline header pills Extract execution participant pickers from sidebar PropertyPicker rows into compact pill-style Popover triggers in the issue header row, next to labels. Creates a reusable ExecutionParticipantPicker component with matching text-[10px] sizing. Removes the old sidebar rows and unused code. Co-Authored-By: Paperclip --- .../components/ExecutionParticipantPicker.tsx | 166 ++++++++++++++++++ ui/src/components/IssueProperties.tsx | 111 ------------ ui/src/pages/IssueDetail.tsx | 18 ++ 3 files changed, 184 insertions(+), 111 deletions(-) create mode 100644 ui/src/components/ExecutionParticipantPicker.tsx diff --git a/ui/src/components/ExecutionParticipantPicker.tsx b/ui/src/components/ExecutionParticipantPicker.tsx new file mode 100644 index 00000000..7e8942b5 --- /dev/null +++ b/ui/src/components/ExecutionParticipantPicker.tsx @@ -0,0 +1,166 @@ +import { useState } from "react"; +import type { Agent, Issue } from "@paperclipai/shared"; +import { formatAssigneeUserLabel } from "../lib/assignees"; +import { sortAgentsByRecency, getRecentAssigneeIds } from "../lib/recent-assignees"; +import { + buildExecutionPolicy, + stageParticipantValues, +} from "../lib/issue-execution-policy"; +import { cn } from "../lib/utils"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { User, Eye, ShieldCheck } from "lucide-react"; +import { AgentIcon } from "./AgentIconPicker"; + +type StageType = "review" | "approval"; + +interface ExecutionParticipantPickerProps { + issue: Issue; + stageType: StageType; + agents: Agent[]; + currentUserId: string | null; + onUpdate: (data: Record) => void; +} + +export function ExecutionParticipantPicker({ + issue, + stageType, + agents, + currentUserId, + onUpdate, +}: ExecutionParticipantPickerProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + const reviewerValues = stageParticipantValues(issue.executionPolicy, "review"); + const approverValues = stageParticipantValues(issue.executionPolicy, "approval"); + const values = stageType === "review" ? reviewerValues : approverValues; + + const sortedAgents = sortAgentsByRecency( + agents.filter((a) => a.status !== "terminated"), + getRecentAssigneeIds(), + ); + + const userLabel = (userId: string | null | undefined) => + formatAssigneeUserLabel(userId, currentUserId); + const creatorUserLabel = userLabel(issue.createdByUserId); + + const agentName = (id: string) => { + const agent = agents.find((a) => a.id === id); + return agent?.name ?? id.slice(0, 8); + }; + + const participantLabel = (value: string) => { + if (value.startsWith("agent:")) return agentName(value.slice("agent:".length)); + if (value.startsWith("user:")) return userLabel(value.slice("user:".length)) ?? "User"; + return value; + }; + + const updatePolicy = (nextValues: string[]) => { + onUpdate({ + executionPolicy: buildExecutionPolicy({ + existingPolicy: issue.executionPolicy ?? null, + reviewerValues: stageType === "review" ? nextValues : reviewerValues, + approverValues: stageType === "approval" ? nextValues : approverValues, + }), + }); + }; + + const toggle = (value: string) => { + const next = values.includes(value) + ? values.filter((v) => v !== value) + : [...values, value]; + updatePolicy(next); + }; + + const label = stageType === "review" ? "Reviewers" : "Approvers"; + const Icon = stageType === "review" ? Eye : ShieldCheck; + + return ( + { setOpen(o); if (!o) setSearch(""); }}> + + + + + setSearch(e.target.value)} + autoFocus + /> +
+ + {currentUserId && ( + + )} + {issue.createdByUserId && issue.createdByUserId !== currentUserId && ( + + )} + {sortedAgents + .filter((agent) => { + if (!search.trim()) return true; + return agent.name.toLowerCase().includes(search.toLowerCase()); + }) + .map((agent) => { + const encoded = `agent:${agent.id}`; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 937e4628..9f882ef8 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -12,7 +12,6 @@ import { queryKeys } from "../lib/queryKeys"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { formatAssigneeUserLabel } from "../lib/assignees"; -import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execution-policy"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { Identity } from "./Identity"; @@ -270,45 +269,9 @@ export function IssueProperties({ const assignee = issue.assigneeAgentId ? agents?.find((a) => a.id === issue.assigneeAgentId) : null; - const reviewerValues = stageParticipantValues(issue.executionPolicy, "review"); - const approverValues = stageParticipantValues(issue.executionPolicy, "approval"); const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId); const assigneeUserLabel = userLabel(issue.assigneeUserId); const creatorUserLabel = userLabel(issue.createdByUserId); - const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => { - onUpdate({ - executionPolicy: buildExecutionPolicy({ - existingPolicy: issue.executionPolicy ?? null, - reviewerValues: nextReviewers, - approverValues: nextApprovers, - }), - }); - }; - const toggleExecutionParticipant = (stageType: "review" | "approval", value: string) => { - const currentValues = stageType === "review" ? reviewerValues : approverValues; - const nextValues = currentValues.includes(value) - ? currentValues.filter((candidate) => candidate !== value) - : [...currentValues, value]; - updateExecutionPolicy( - stageType === "review" ? nextValues : reviewerValues, - stageType === "approval" ? nextValues : approverValues, - ); - }; - const executionParticipantLabel = (value: string) => { - if (value.startsWith("agent:")) { - return agentName(value.slice("agent:".length)) ?? value.slice("agent:".length, "agent:".length + 8); - } - if (value.startsWith("user:")) { - return userLabel(value.slice("user:".length)) ?? "User"; - } - return value; - }; - const reviewerTrigger = reviewerValues.length > 0 - ? {reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")} - : None; - const approverTrigger = approverValues.length > 0 - ? {approverValues.map((value) => executionParticipantLabel(value)).join(", ")} - : None; const currentExecutionLabel = (() => { if (!issue.executionState?.currentStageType) return null; const stageLabel = issue.executionState.currentStageType === "review" ? "Review" : "Approval"; @@ -509,80 +472,6 @@ export function IssueProperties({ ); - const executionParticipantsContent = ( - stageType: "review" | "approval", - values: string[], - search: string, - setSearch: (value: string) => void, - onClear: () => void, - ) => ( - <> - setSearch(e.target.value)} - autoFocus={!inline} - /> -
- - {currentUserId && ( - - )} - {issue.createdByUserId && issue.createdByUserId !== currentUserId && ( - - )} - {sortedAgents - .filter((agent) => { - if (!search.trim()) return true; - return agent.name.toLowerCase().includes(search.toLowerCase()); - }) - .map((agent) => { - const encoded = `agent:${agent.id}`; - return ( - - ); - })} -
- - ); - const projectTrigger = issue.projectId ? ( <> )} +
+ updateIssue.mutate(data)} + /> + updateIssue.mutate(data)} + /> +
+